fix: 退回报销支持留痕删除(09-04-reimbursement-state-gap-finance-view)
这个提交包含在:
@@ -5,6 +5,37 @@
|
|||||||
Read `docs/报销发票附件规则.md` before changing reimbursement, invoice, OCR,
|
Read `docs/报销发票附件规则.md` before changing reimbursement, invoice, OCR,
|
||||||
duplicate detection or attachment behavior.
|
duplicate detection or attachment behavior.
|
||||||
|
|
||||||
|
## Retained Reimbursement Deletion
|
||||||
|
|
||||||
|
- DELETE /reimbursements/:id requires expense.delete_own and owner/submitter
|
||||||
|
participation, including idempotent retries. Global reading or wildcard alone
|
||||||
|
never substitutes for participation.
|
||||||
|
- Only draft/rejected may be deleted, with no active workflow/task, non-cancelled
|
||||||
|
payment reservation, paid_at or non-bound associated invoice. Lock the form
|
||||||
|
before invoices, then release only invoices still bound to that form.
|
||||||
|
- A never-submitted draft may be physically deleted. Rejected or any draft with
|
||||||
|
workflow history must retain the form/items/workflow/attachments and set
|
||||||
|
cancelled + deleted_at/deleted_by. Never infer history absence from draft.
|
||||||
|
- Before release, preserve current invoice PDF object references/hash as invoice
|
||||||
|
attachments of the old form; use its existing material authorization, not the
|
||||||
|
invoice's future binding. Storage objects are immutable; any future physical
|
||||||
|
cleanup must account for retained reimbursement attachment references.
|
||||||
|
- Audit, evidence references, release and deletion are one transaction. Failed
|
||||||
|
audit must roll back every database effect. Historical invoices already
|
||||||
|
released before this change are not reconstructed or silently rebound.
|
||||||
|
- Normal reads exclude deleted_at records through shared visibility/ledger
|
||||||
|
filters; raw statistics, portal counts and project summaries must also exclude
|
||||||
|
them. Authorized historical detail/material reads remain, with deletedAt and
|
||||||
|
terminated next-action projection. No role-grant changes.
|
||||||
|
- Verify with RUN_ISOLATED_DB_TEST=1 RUN_RETAINED_DELETE_TEST=1 RUN_BROWSER_TEST=1
|
||||||
|
node scripts/qa/test-invoice-first-http.cjs after building the current client.
|
||||||
|
Uses disposable PostgreSQL, synthetic accounts/files and real auth/HTTP/browser;
|
||||||
|
OCR in this suite is mock-provider, not production Tencent evidence.
|
||||||
|
- Migration adds nullable fields and a deleted-state check. After retained
|
||||||
|
deletion exists, old code can redisplay it; image rollback alone is not
|
||||||
|
behavior-preserving. Prefer forward repair or an explicitly reviewed compatible
|
||||||
|
rollback; do not remove history or reverse current invoice bindings blindly.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- 隔离串联的 `RUN_JOB_QUEUE=1` 必须运行真实 startPgBossJobs 与上传入口 enqueue,
|
- 隔离串联的 `RUN_JOB_QUEUE=1` 必须运行真实 startPgBossJobs 与上传入口 enqueue,
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# 报销状态空隙与财务处理视角:设计
|
# 报销状态空隙与财务处理视角:设计
|
||||||
|
|
||||||
|
## 2026-09-10 追加阶段:退回删除最小持久化方案(已批准本地实施)
|
||||||
|
|
||||||
|
用户在官方资料调研后确认实施:修改重提为主,删除为次;无需填写删除理由。易快报公开接口明确草稿/已驳回可删除,但未证明其物理存储或删除后发票释放方式;本项目留痕设计是自己的安全合同,不冒充行业统一实现。
|
||||||
|
|
||||||
|
票据释放前将当时仍关联的 PDF 文件引用、摘要保留为本单附件,通过报销原有材料权限读取;使用不可变存储对象,不读取票据重新绑定后的内容。删除审计记录票据与明细引用。历史撤回早已释放、没有关联证据的票据不猜测回填;本轮保留删除时实际仍存在的证据及原有流程记录,不宣称补全过去缺失的提交快照。
|
||||||
|
|
||||||
|
本阶段仅处理用户确认的退回删除与重新建单;下文 R1/R2 为旧交付合同,不重开其范围。执行副本基于已发布 e5685f1,独立于发票与付款副本。
|
||||||
|
|
||||||
|
### 核查结果
|
||||||
|
|
||||||
|
- 当前 DELETE /reimbursements/:id 只允许 draft,调用 reimbursementForm.delete;WorkflowInstance.reimbursement_form 为 onDelete:Cascade,其 tasks/history 继续级联。因此“退回/撤回到 draft 且有流程历史”同样不能继续物理删除,更不能只把 rejected 加入白名单。
|
||||||
|
- 模型只有 draft/reviewing/approved/paid/rejected/cancelled,无 deleted_at/deleted_by。cancelled 只表达状态,不能区分主动删除及其他取消,也不能单独保证所有列表/统计的删除语义。不借改写 description 或挪用付款字段保存删除标记。
|
||||||
|
|
||||||
|
### 推荐合同
|
||||||
|
|
||||||
|
1. 新增 nullable deleted_at 与 deleted_by(删除者引用)及相应索引;既有记录保持 null,不回填业务数据,不新增状态枚举。删除时记录原状态、删除者/时间和释放的票据引用至既有审计,状态落为 cancelled,前端依据删除标记显示“已删除”。这是明确的删除语义,不将 cancelled 本身冒充删除判据。
|
||||||
|
2. 仅 owner/submitter 且具备 expense.delete_own 可删 draft/rejected;只读全范围、管理员通配均不替代参与关系。仍在活动审批、有非取消付款占用或已付款的异常数据拒绝。重复删除先授权再幂等返回。
|
||||||
|
3. 未进入过 workflow 的普通 draft 保持现有删除路径;曾提交的 draft 以及 rejected 使用留痕删除。已有单据、明细、附件、workflow tasks/history 保留;正常列表/搜索/统计/导出排除已删除单据,旧深链仅向原有权人员呈现只读历史与已删除提示,不新增查看他人记录的权限。
|
||||||
|
4. 删除事务先锁单再锁票,记录关联引用后仅释放当前仍绑定本单的有效票;不改归属、不覆盖别单关联、不删除文件。旧单取消态及显式删除校验共同阻断提交/修改/绑定/审批/付款,发票能否用于新单仍执行原归属/去重/状态门禁。
|
||||||
|
5. 发票释放后历史材料读取从本次审计保存的引用与原单授权出发,不能因为票已换绑就授权旧单访问新单;只保存必要引用,不把完整票面原始 payload 写进审计。具体查询与材料快照边界在实现前以测试锁定,不把仅保留文件声称为历史材料关联已保留。
|
||||||
|
|
||||||
|
### 验证与回滚
|
||||||
|
|
||||||
|
- 本地先复现 rejected 拒绝和有历史 draft 的级联风险;随后覆盖双方参与人、非参与人、重复删除、活动流程/付款拒绝、旧深链只读、各列表旁路、删除与提交/绑定并发、释放票据重新建单。
|
||||||
|
- 隔离 PostgreSQL 与真实认证/浏览器验证“退回→删除→历史仍在→票据重用→新单提交”;公网不自动删除真实退回单,不将上次发布授权沿用到本迁移。
|
||||||
|
- 迁移仅增 nullable 字段;旧应用不理解删除标记,回滚后可能重新显示已删除历史。不得声称只换旧镜像行为无变化;首次产生删除记录后优先前向修复,必要回退需兼容删除过滤,不移除审计或猜测恢复已释放票据。
|
||||||
|
- 用户已批准本地迁移/跨层实施;生产迁移、提交推送及部署仍另批。本阶段不修改原 R1/R2 权限授予,不扩经理六个 view_all。
|
||||||
|
|
||||||
## 1. 设计边界
|
## 1. 设计边界
|
||||||
|
|
||||||
本任务包含两个独立变更单元:
|
本任务包含两个独立变更单元:
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
# 报销状态空隙与财务处理视角:实施计划
|
# 报销状态空隙与财务处理视角:实施计划
|
||||||
|
|
||||||
|
## 2026-09-10 退回删除接续
|
||||||
|
|
||||||
|
- 用户要求继续剩余任务,先退回删除、后付款体验;沿既有 task,不新增管理任务。独立副本 `artifacts/isolated-worktrees/20260910-rejected-delete` 基线 e5685f1,官方 current 已对齐,无新分支,未混入其他工作树未提交内容。
|
||||||
|
- 已核对最新 PRD 追加范围及付款 task 综合方案。现有 DELETE 会级联删除 workflow 历史;draft 本身不是可物理删除的充分条件。当前模型没有专用删除标记,不能只放开 rejected。
|
||||||
|
- design 顶部补充 deleted_at/deleted_by 最小迁移、审计与材料引用、参与人授权、查询排除、锁顺序及旧应用回滚风险;行业调研后用户确认实施,本轮已编写迁移并仅在一次性合成数据库执行,未运行生产写入。
|
||||||
|
- [x] 当前实现与历史级联风险核查。
|
||||||
|
- [x] 用户在行业调研后确认本地实施,生产操作与提交仍另批。
|
||||||
|
- [x] 实施服务端删除/材料历史/查询边界和双端入口,补充针对性单测及真实隔离场景;没有声称新增测试先在旧代码上完整跑红。
|
||||||
|
- [x] 完成真实隔离流程、并发/权限反例与前端回归,保留独立副本作为只读复核入口。
|
||||||
|
- [ ] 独立只读复核(当前 inline,仅主会话自审,不冒充独立 reviewer)。
|
||||||
|
- [ ] 通过后再按授权提交发布;付款体验在独立副本与原 task 承接,不一并提交。
|
||||||
|
|
||||||
|
### 实现与证据
|
||||||
|
|
||||||
|
- 运行时:仅 draft/rejected + 删除权限 + owner/submitter 可删;拒绝活动流程/待办、非取消付款占用、paid_at、异常关联票据。重复留痕删除先授权,再幂等返回。
|
||||||
|
- 无流程历史的 draft 原样物理删除;历史 draft/rejected 保留 form/items/workflow/attachments,写 cancelled + deleted_at/deleted_by,并保留票据 PDF 引用/摘要;删除审计与释放同事务。
|
||||||
|
- 查询:共享报销可读条件、财务台账、原始 SQL 统计、门户计数及项目汇总排除删除标记。详情、材料按既有权限只读,进度不再要求继续处理。
|
||||||
|
- 并发补口:附件上传落库时重取单锁校验状态;项目关联使用条件 updateMany,防止读到可编辑状态后越过删除继续写入。
|
||||||
|
- 双端详情/列表允许退回删除,确认不填理由;手机删除位于“更多”,删除成功提示使用“报销单”而非“草稿”。历史详情及打印有删除标识。
|
||||||
|
- Prisma validate、相关 node --check、前端 typecheck/build 已通过。
|
||||||
|
- 后端最终 focused:22 文件、279/279,通过记录在 artifacts/qa-flow/20260910-rejected-delete/unit-final.log。运行目录为本副本 server;命令为 PAYMENT_ORDER_PYTHON_BIN=<既有 BIFF8 虚拟环境解释器> npx vitest run tests/unit/routes/reimbursements*.test.js tests/unit/routes/reimbursement.submit-side-effects.test.js tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/services/reimbursementVisibility.test.js tests/unit/services/financeLedger.test.js tests/unit/routes/exports*.test.js tests/unit/routes/search.test.js tests/unit/routes/portal.test.js tests/unit/routes/projects.test.js。
|
||||||
|
- 页面回归:本地构建产物 preview,system-action-confirmation.spec.ts --grep '报销',6/6;ui-preview.log。与真实隔离 HTTP/browser 分开计,不把 mock 页面回归当真实服务验收。
|
||||||
|
- 隔离:RUN_ISOLATED_DB_TEST=1 RUN_RETAINED_DELETE_TEST=1 RUN_BROWSER_TEST=1 node scripts/qa/test-invoice-first-http.cjs;真实 PostgreSQL、密码登录/JWT、HTTP、桌面/手机;合成票据,OCR 为手动调度真实 worker + mock provider。涵盖退回/驳回/撤回→删除→原历史/凭证可读→票据重用→新单提交;删除与 submit/bind/delete 锁屏障;审计异常全事务回滚。
|
||||||
|
- 增补原始统计/台账排除的最终复跑记录:final-isolated.log,PASS_REAL_DB_HTTP,退出 0;临时容器、合成文件和浏览器已清理。不以之前只输出 PASS 但退出 143 的管道运行替代完整退出结果。
|
||||||
|
- UI strict/self-test、错误解释 strict、process contract baseline、Trellis governance、git diff --check 通过;文档 registry 为 errors=0、warnings=1(既有 22 项未注册文件),不扩修。
|
||||||
|
|
||||||
|
### 调试与证据边界
|
||||||
|
|
||||||
|
- 首轮 BIFF8 回归 1 项失败为系统 python 缺 xlwt,使用既有专用解释器后 76 项付款相关测试通过;未修改付款产品逻辑。
|
||||||
|
- 隔离 fixture 最初复用了同一文件摘要,被真实重复发票门禁正确拒绝;改为每个场景独立合成 PDF,不通过假摘要/放宽去重绕过测试。
|
||||||
|
- 一轮新手机“更多”定位与旧构建产物不一致而超时;重建后真实双端通过。原日志留存,不把测试器问题写成业务通过。
|
||||||
|
- Vite dev 冷启动首项停在登录控件加载,终止该 mock 运行后改用构建产物 preview,6 项重跑通过;没有要求真实用户代测,也没有修改生产登录。
|
||||||
|
- 本轮保留删除时实际存在的流程、明细和凭证,不回填历史已释放票据、不补造过去提交版本。不可承诺完整重建原本未留存的历史快照。
|
||||||
|
- 当前状态:代码已实现、本地验证完成;发布未执行;真实业务删除未执行;等待独立复核及提交/部署授权。数据库 nullable 增量不等于旧应用可无损回滚:旧应用可能重新显示删除记录,需前向修复或经过审查的兼容回退。
|
||||||
|
- 主会话已只读检查任务 diff 的状态/参与人守卫、释放锁顺序、材料授权、查询旁路、迁移和回滚边界;独立 reviewer 尚未执行。复核包 artifacts/qa-flow/20260910-rejected-delete/changes.diff 包含本副本全部任务改动及未跟踪的迁移/测试文件,不含其他副本或生产数据。
|
||||||
|
|
||||||
|
下文是原 R1/R2 阶段清单,不能继承其无 migration 前提或勾选为本追加阶段的验收结果。
|
||||||
|
|
||||||
## 0. 启动门
|
## 0. 启动门
|
||||||
|
|
||||||
- 用户批准本版 `prd.md`、`design.md`、`implement.md` 后,才运行 `task.py start`。
|
- 用户批准本版 `prd.md`、`design.md`、`implement.md` 后,才运行 `task.py start`。
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
### 2026-09-09 后续阶段:退回报销的删除与重建
|
||||||
|
|
||||||
|
本阶段归属复用,不重开原 R1/R2 的全部范围;综合顺序与验证见 09-08-reimbursement-payment-closeout 的最新 design/implement。用户已明确业务取向:退回后既能修改原单重提,也能删除旧单再创建报销提交;界面使用“删除”,不要求员工理解底层作废/留痕术语。2026-09-10 行业调研后用户确认本地实施;本追加阶段允许 design 中的 nullable 删除标记迁移,旧 R1/R2 的“无 schema 变更”约束不适用于本阶段。提交、推送及部署仍另批。
|
||||||
|
|
||||||
|
- 删除退回单必须保留必要的提交、审批、退回及删除审计记录,不能只把 rejected 加入现有物理删除状态白名单。核对“退回为 draft 且已有 workflow 历史”的路径,不能只按显示状态判断是否可硬删除。
|
||||||
|
- 删除后旧单不能重提、审批、进入付款或继续修改,不再占据正常待处理列表;有权的历史/审计访问仍遵循原范围,普通列表/搜索/统计/深链行为必须一致。
|
||||||
|
- 按原申请人/合法代填参与关系设计权限,不能把管理员或 view_all 的全量读取当成删除授权;明确本人和非参与人的正反例。
|
||||||
|
- 删除与提交/审批/发票绑定并发时,只允许一个合法终态;检查活动审批、付款占用及最终状态,审批中/已通过/已支付不因本次改动变为可删。
|
||||||
|
- 解除旧单对发票的有效占用,原发票/附件证据不随历史单物理抹掉;只有仍符合当前归属、状态及去重条件的发票才可用于新单,不能覆盖其他单据的绑定或自动转移别人发票。
|
||||||
|
- 重复删除、旧深链、撤回再删除、发票重新使用均有回归。不批量删除既有真实退回单,是否删除由申请人决定,不替员工执行。
|
||||||
|
|
||||||
|
本阶段验收:桌面/手机退回单可取消或确认删除;修改重提保留;历史草稿不物理删除;本人及合法提交人可删,view_all/通配不替代参与关系;活动审批/付款/已付拒绝;列表、搜索、统计、导出和门户计数不展示删除记录;已删除深链只读且无“待处理”指引;发票重用不改变旧单凭证;删除与提交/绑定/重复删除并发及审计失败回滚可复现。
|
||||||
|
|
||||||
消除报销从审批通过到付款批次创建之间、以及报销回到草稿状态之后的两个运营空隙:财务应能在付款页直接发现并处理尚未组批的已通过报销,同时“全范围”不得暴露他人从未提交的私人草稿,但必须保留对已经进入过流程的退回或撤回草稿的追踪能力。
|
消除报销从审批通过到付款批次创建之间、以及报销回到草稿状态之后的两个运营空隙:财务应能在付款页直接发现并处理尚未组批的已通过报销,同时“全范围”不得暴露他人从未提交的私人草稿,但必须保留对已经进入过流程的退回或撤回草稿的追踪能力。
|
||||||
|
|
||||||
本任务来自真实业务单据 `0014` 的首次使用反馈。两个 Requirement 共享“状态空隙必须有明确责任人和可见入口”的产品原则,但必须保持代码边界、测试证据和回滚点独立,不能因一项回滚而被迫撤销另一项。
|
本任务来自真实业务单据 `0014` 的首次使用反馈。两个 Requirement 共享“状态空隙必须有明确责任人和可见入口”的产品原则,但必须保持代码边界、测试证据和回滚点独立,不能因一项回滚而被迫撤销另一项。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "reimbursement-state-gap-finance-view",
|
"id": "reimbursement-state-gap-finance-view",
|
||||||
"name": "reimbursement-state-gap-finance-view",
|
"name": "reimbursement-state-gap-finance-view",
|
||||||
"title": "报销状态空隙与财务处理视角",
|
"title": "报销状态空隙与财务处理视角",
|
||||||
"description": "修复已通过报销在组批前缺少财务处理入口,以及全范围读取暴露从未提交草稿的问题。",
|
"description": "修复已通过报销在组批前缺少财务处理入口、草稿读取边界;追加退回报销留痕删除和发票重用。",
|
||||||
"status": "in_progress",
|
"status": "in_progress",
|
||||||
"dev_type": null,
|
"dev_type": null,
|
||||||
"scope": "server,client,tests,spec",
|
"scope": "server,client,tests,spec",
|
||||||
@@ -21,6 +21,6 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"relatedFiles": [],
|
"relatedFiles": [],
|
||||||
"notes": "业务提交:R2=8e0af82,R1=5fe9f78。",
|
"notes": "历史业务提交:R2=8e0af82,R1=5fe9f78。2026-09-10 退回留痕删除复用本任务,独立副本 20260910-rejected-delete;本地实现与验证完成,待独立复核及另批提交/部署,不继承旧阶段已发布结论。",
|
||||||
"meta": {}
|
"meta": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export type ReimbursementApprovalHistoryItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ReimbursementDetail = {
|
export type ReimbursementDetail = {
|
||||||
|
deletedAt?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
formNumber: string | null;
|
formNumber: string | null;
|
||||||
submitterId: string;
|
submitterId: string;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
|||||||
rejected: { label: "已驳回", className: "badge-error" },
|
rejected: { label: "已驳回", className: "badge-error" },
|
||||||
ocr_failed: { label: "识别失败", className: "badge-error" },
|
ocr_failed: { label: "识别失败", className: "badge-error" },
|
||||||
cancelled: { label: "已取消", className: "badge-neutral" },
|
cancelled: { label: "已取消", className: "badge-neutral" },
|
||||||
|
deleted: { label: "已删除", className: "badge-neutral" },
|
||||||
archived: { label: "已归档", className: "badge-neutral" },
|
archived: { label: "已归档", className: "badge-neutral" },
|
||||||
skipped: { label: "已跳过", className: "badge-neutral" },
|
skipped: { label: "已跳过", className: "badge-neutral" },
|
||||||
revoked: { label: "已撤销", className: "badge-neutral" },
|
revoked: { label: "已撤销", className: "badge-neutral" },
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
|
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
|
||||||
const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
|
const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
|
||||||
const canUnbindInvoices = canMaintainReimbursement;
|
const canUnbindInvoices = canMaintainReimbursement;
|
||||||
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && isParticipant);
|
const canDeleteDraft = Boolean(detail && ["draft", "rejected"].includes(detail.status) && !detail.deletedAt && canDeleteExpense && isParticipant);
|
||||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||||
|
|
||||||
const reimbursementPrecheckQuery = useQuery({
|
const reimbursementPrecheckQuery = useQuery({
|
||||||
@@ -389,7 +389,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
mutationFn: () => deleteReimbursement(reimbursementId),
|
mutationFn: () => deleteReimbursement(reimbursementId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await invalidateDetail();
|
await invalidateDetail();
|
||||||
toast.success("草稿已删除");
|
toast.success("报销单已删除");
|
||||||
navigate("/mobile/reimbursements");
|
navigate("/mobile/reimbursements");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -938,6 +938,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
};
|
};
|
||||||
const workspaceHeaderDetails = (
|
const workspaceHeaderDetails = (
|
||||||
<>
|
<>
|
||||||
|
{detail.deletedAt ? <p role="status">已删除,仅供查看历史记录</p> : null}
|
||||||
{canMaintainReimbursement ? <ReimbursementBatchClassification key={reimbursementId} reimbursementId={reimbursementId} items={detail.items} expenseTypes={expenseTypes} /> : null}
|
{canMaintainReimbursement ? <ReimbursementBatchClassification key={reimbursementId} reimbursementId={reimbursementId} items={detail.items} expenseTypes={expenseTypes} /> : null}
|
||||||
{!isParticipant ? <p className="text-xs text-zinc-500">只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}</p> : null}
|
{!isParticipant ? <p className="text-xs text-zinc-500">只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}</p> : null}
|
||||||
{canMaintainReimbursement ? <ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable mobile showDescription={!isTravelReimbursement(detail)} /> : null}
|
{canMaintainReimbursement ? <ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable mobile showDescription={!isTravelReimbursement(detail)} /> : null}
|
||||||
@@ -978,7 +979,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
<>
|
<>
|
||||||
{(detail.budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <section className="mobile-card space-y-3"><h2 className="text-base font-semibold text-zinc-900">需要关注</h2>{detail.budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {detail.budgetHint.budgetCode}:提交后预计使用 {detail.budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</section> : null}
|
{(detail.budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <section className="mobile-card space-y-3"><h2 className="text-base font-semibold text-zinc-900">需要关注</h2>{detail.budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {detail.budgetHint.budgetCode}:提交后预计使用 {detail.budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</section> : null}
|
||||||
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" targetLinkLabel="查看所在付款批次" mobile /></section>
|
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" targetLinkLabel="查看所在付款批次" mobile /></section>
|
||||||
{canDeleteDraft ? <section className="mobile-card"><Button className="w-full" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button></section> : null}
|
{canDeleteDraft ? <section className="mobile-card"><details><summary className="cursor-pointer py-3 text-sm" data-testid="mobile-reimbursement-more">更多</summary><Button className="w-full" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除报销单</Button></details></section> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const mobileActions = showSubmitAction ? <MobileStickyActionBar testId="mobile-reimbursement-sticky-actions"><Button className="min-h-12 w-full" data-testid={TEST_IDS.reimbursement.submit} disabled={submitMutation.isPending} onClick={() => void handleSubmit()} type="button">{submitMutation.isPending ? "提交中..." : "提交审批"}</Button></MobileStickyActionBar> : null;
|
const mobileActions = showSubmitAction ? <MobileStickyActionBar testId="mobile-reimbursement-sticky-actions"><Button className="min-h-12 w-full" data-testid={TEST_IDS.reimbursement.submit} disabled={submitMutation.isPending} onClick={() => void handleSubmit()} type="button">{submitMutation.isPending ? "提交中..." : "提交审批"}</Button></MobileStickyActionBar> : null;
|
||||||
@@ -986,7 +987,7 @@ export function MobileReimbursementDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{downloadReasonDialog}
|
{downloadReasonDialog}
|
||||||
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
|
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{detail.deletedAt ? "已删除" : STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
|
||||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
|
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="left-0 top-0 w-screen max-w-none translate-x-0 translate-y-0 overflow-y-auto rounded-none p-4"
|
className="left-0 top-0 w-screen max-w-none translate-x-0 translate-y-0 overflow-y-auto rounded-none p-4"
|
||||||
@@ -1301,10 +1302,10 @@ export function MobileReimbursementDetailPage() {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmDeleteReimbursement}
|
open={confirmDeleteReimbursement}
|
||||||
onOpenChange={setConfirmDeleteReimbursement}
|
onOpenChange={setConfirmDeleteReimbursement}
|
||||||
title="删除草稿?"
|
title="删除报销单?"
|
||||||
description="删除后无法恢复,已添加发票会回到可添加状态。"
|
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmLabel="删除草稿"
|
confirmLabel="删除报销单"
|
||||||
onConfirm={() => deleteReimbursementMutation.mutate()}
|
onConfirm={() => deleteReimbursementMutation.mutate()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ function ReimbursementCard({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
删除草稿
|
删除报销单
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -240,7 +240,7 @@ export function MobileReimbursementsPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||||
]);
|
]);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
toast.success("草稿已删除");
|
toast.success("报销单已删除");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(resolveErrorMessage(error));
|
toast.error(resolveErrorMessage(error));
|
||||||
@@ -358,7 +358,7 @@ export function MobileReimbursementsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{visibleItems.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<ReimbursementCard
|
<ReimbursementCard
|
||||||
canDeleteDraft={canDeleteExpense && scope === "own" && item.status === "draft"}
|
canDeleteDraft={canDeleteExpense && scope === "own" && ["draft", "rejected"].includes(item.status)}
|
||||||
currentMobileSource={currentMobileSource}
|
currentMobileSource={currentMobileSource}
|
||||||
isDeleting={deleteMutation.isPending}
|
isDeleting={deleteMutation.isPending}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -376,10 +376,10 @@ export function MobileReimbursementsPage() {
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="删除草稿?"
|
title="删除报销单?"
|
||||||
description="删除后无法恢复,已添加发票会回到可用状态。"
|
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmLabel="删除草稿"
|
confirmLabel="删除报销单"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (!deleteTarget) {
|
if (!deleteTarget) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -351,11 +351,11 @@ export function ReimbursementDetailPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
toast.success("草稿已删除");
|
toast.success("报销单已删除");
|
||||||
navigate("/reimbursements?status=draft");
|
navigate("/reimbursements?status=draft");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = buildActionErrorMessage(error, "删除草稿失败");
|
const message = buildActionErrorMessage(error, "删除报销单失败");
|
||||||
setActionError(message);
|
setActionError(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
},
|
},
|
||||||
@@ -782,7 +782,8 @@ export function ReimbursementDetailPage() {
|
|||||||
canViewOwnInvoice;
|
canViewOwnInvoice;
|
||||||
const canUnbindInvoices = canMaintainReimbursement;
|
const canUnbindInvoices = canMaintainReimbursement;
|
||||||
const canDeleteReimbursement =
|
const canDeleteReimbursement =
|
||||||
detail.status === "draft" &&
|
["draft", "rejected"].includes(detail.status) &&
|
||||||
|
!detail.deletedAt &&
|
||||||
canDeleteExpense &&
|
canDeleteExpense &&
|
||||||
isReimbursementParticipant;
|
isReimbursementParticipant;
|
||||||
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
||||||
@@ -939,6 +940,7 @@ export function ReimbursementDetailPage() {
|
|||||||
stage.state === "current" || stage.state === "exception",
|
stage.state === "current" || stage.state === "exception",
|
||||||
);
|
);
|
||||||
const nextStepText = (() => {
|
const nextStepText = (() => {
|
||||||
|
if (detail.deletedAt) return "已删除,仅供查看历史记录";
|
||||||
if (["draft", "rejected"].includes(detail.status)) {
|
if (["draft", "rejected"].includes(detail.status)) {
|
||||||
if (!isReimbursementParticipant) {
|
if (!isReimbursementParticipant) {
|
||||||
return detail.status === "rejected" ? "等待报销人修改后重提" : "等待报销人完善";
|
return detail.status === "rejected" ? "等待报销人修改后重提" : "等待报销人完善";
|
||||||
@@ -1107,7 +1109,7 @@ export function ReimbursementDetailPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const workspaceActionPanel = (
|
const workspaceActionPanel = (
|
||||||
<BusinessDetailActionPanel status={<StatusBadge status={detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
|
<BusinessDetailActionPanel status={<StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除报销单</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
|
||||||
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} submitDisabled={blockStandardChecks.length > 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
|
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} submitDisabled={blockStandardChecks.length > 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
|
||||||
</BusinessDetailActionPanel>
|
</BusinessDetailActionPanel>
|
||||||
);
|
);
|
||||||
@@ -1115,7 +1117,7 @@ export function ReimbursementDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{downloadReasonDialog}
|
{downloadReasonDialog}
|
||||||
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel }} />
|
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel }} />
|
||||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
|
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-h-[85vh] max-w-3xl overflow-y-auto"
|
className="max-h-[85vh] max-w-3xl overflow-y-auto"
|
||||||
@@ -1369,9 +1371,9 @@ export function ReimbursementDetailPage() {
|
|||||||
open={confirmDeleteReimbursement}
|
open={confirmDeleteReimbursement}
|
||||||
onOpenChange={setConfirmDeleteReimbursement}
|
onOpenChange={setConfirmDeleteReimbursement}
|
||||||
title={`确认删除 ${displayCode}?`}
|
title={`确认删除 ${displayCode}?`}
|
||||||
description="删除后无法恢复,已添加发票会回到可添加状态。"
|
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmLabel="删除草稿"
|
confirmLabel="删除报销单"
|
||||||
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
|
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
|
||||||
/>
|
/>
|
||||||
<AttachmentDeleteConfirmDialog
|
<AttachmentDeleteConfirmDialog
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function ReimbursementPrintPage() {
|
|||||||
<div className="grid gap-3 text-sm">
|
<div className="grid gap-3 text-sm">
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
<span className="text-zinc-500">当前状态</span>
|
<span className="text-zinc-500">当前状态</span>
|
||||||
<span className="font-medium text-zinc-900">{detail.status}</span>
|
<span className="font-medium text-zinc-900">{detail.deletedAt ? "已删除(历史记录)" : detail.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
<span className="text-zinc-500">提交人</span>
|
<span className="text-zinc-500">提交人</span>
|
||||||
|
|||||||
@@ -240,10 +240,10 @@ export function ReimbursementsPage() {
|
|||||||
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
toast.success("草稿已删除");
|
toast.success("报销单已删除");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(resolveErrorMessage(error, "删除草稿失败"));
|
toast.error(resolveErrorMessage(error, "删除报销单失败"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -502,7 +502,7 @@ export function ReimbursementsPage() {
|
|||||||
items.map((item) => {
|
items.map((item) => {
|
||||||
const listAction = resolveListAction(item);
|
const listAction = resolveListAction(item);
|
||||||
const displayCode = resolveReimbursementDisplayCode(item);
|
const displayCode = resolveReimbursementDisplayCode(item);
|
||||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
const canDeleteDraft = ["draft", "rejected"].includes(item.status) && canDeleteExpense && Boolean(me?.id && [item.owner?.id, item.submitter?.id].includes(me.id));
|
||||||
const workflowHint = resolveWorkflowHint(item);
|
const workflowHint = resolveWorkflowHint(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -615,7 +615,7 @@ export function ReimbursementsPage() {
|
|||||||
items.map((item) => {
|
items.map((item) => {
|
||||||
const listAction = resolveListAction(item);
|
const listAction = resolveListAction(item);
|
||||||
const displayCode = resolveReimbursementDisplayCode(item);
|
const displayCode = resolveReimbursementDisplayCode(item);
|
||||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
const canDeleteDraft = ["draft", "rejected"].includes(item.status) && canDeleteExpense && Boolean(me?.id && [item.owner?.id, item.submitter?.id].includes(me.id));
|
||||||
const workflowHint = resolveWorkflowHint(item);
|
const workflowHint = resolveWorkflowHint(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -684,7 +684,7 @@ export function ReimbursementsPage() {
|
|||||||
className="text-red-600"
|
className="text-red-600"
|
||||||
onSelect={() => handleDeleteDraft(item)}
|
onSelect={() => handleDeleteDraft(item)}
|
||||||
>
|
>
|
||||||
删除草稿
|
删除报销单
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -732,7 +732,7 @@ export function ReimbursementsPage() {
|
|||||||
}}
|
}}
|
||||||
kind="delete"
|
kind="delete"
|
||||||
targetName={deleteTarget ? resolveReimbursementDisplayCode(deleteTarget) : undefined}
|
targetName={deleteTarget ? resolveReimbursementDisplayCode(deleteTarget) : undefined}
|
||||||
description="删除后无法恢复,已提交或审批中的报销单不会受到影响。"
|
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (deleteTarget) {
|
if (deleteTarget) {
|
||||||
deleteMutation.mutate(deleteTarget.id);
|
deleteMutation.mutate(deleteTarget.id);
|
||||||
|
|||||||
@@ -658,15 +658,15 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
|||||||
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
|
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
|
||||||
await page.getByRole("menuitem", { name: "删除草稿" }).click();
|
await page.getByRole("menuitem", { name: "删除报销单" }).click();
|
||||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
|
await expect(page.getByTestId("action-confirm-dialog")).toContainText("已发生的审批记录仍保留备查");
|
||||||
expect(calls.deleteReimbursement).toBe(0);
|
expect(calls.deleteReimbursement).toBe(0);
|
||||||
|
|
||||||
await page.getByTestId("action-confirm-cancel").click();
|
await page.getByTestId("action-confirm-cancel").click();
|
||||||
expect(calls.deleteReimbursement).toBe(0);
|
expect(calls.deleteReimbursement).toBe(0);
|
||||||
|
|
||||||
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
|
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
|
||||||
await page.getByRole("menuitem", { name: "删除草稿" }).click();
|
await page.getByRole("menuitem", { name: "删除报销单" }).click();
|
||||||
await page.getByTestId("action-confirm-submit").click();
|
await page.getByTestId("action-confirm-submit").click();
|
||||||
await expect.poll(() => calls.deleteReimbursement).toBe(1);
|
await expect.poll(() => calls.deleteReimbursement).toBe(1);
|
||||||
|
|
||||||
@@ -698,7 +698,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
|||||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
await page.getByTestId("reimbursement-more-toggle").click();
|
await page.getByTestId("reimbursement-more-toggle").click();
|
||||||
await page.getByTestId("reimbursement-delete-draft").click();
|
await page.getByTestId("reimbursement-delete-draft").click();
|
||||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
|
await expect(page.getByTestId("action-confirm-dialog")).toContainText("已发生的审批记录仍保留备查");
|
||||||
expect(deleteCalls.deleteReimbursement).toBe(0);
|
expect(deleteCalls.deleteReimbursement).toBe(0);
|
||||||
await page.getByTestId("action-confirm-submit").click();
|
await page.getByTestId("action-confirm-submit").click();
|
||||||
await expect.poll(() => deleteCalls.deleteReimbursement).toBe(1);
|
await expect.poll(() => deleteCalls.deleteReimbursement).toBe(1);
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
|
|||||||
- 报销审批必须在 OA 内完成,企业微信只发送提醒和 deeplink,不作为报销审批流或财务附件主库。
|
- 报销审批必须在 OA 内完成,企业微信只发送提醒和 deeplink,不作为报销审批流或财务附件主库。
|
||||||
- 财务台账 V1 只做付款准备、付款批次、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要;真实付款继续在线下银行或既有财务流程完成,OA 不做银行直连、银行流水自动导入、ERP/总账、凭证生成、自动对账或税务申报。
|
- 财务台账 V1 只做付款准备、付款批次、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要;真实付款继续在线下银行或既有财务流程完成,OA 不做银行直连、银行流水自动导入、ERP/总账、凭证生成、自动对账或税务申报。
|
||||||
|
|
||||||
|
## 退回后的修改与删除
|
||||||
|
|
||||||
|
- 退回单优先直接修改后重新提交,不要求重新录入已有内容。
|
||||||
|
- 报销人或合法提交人在有删除权限时,可删除草稿及退回单;界面只做一次后果确认,不要求填写理由。审批中、已批准、付款处理中、已付款不能通过此入口删除。
|
||||||
|
- 从未提交的草稿沿用删除;有流程历史的草稿及退回单删除后从日常列表移除,但保留单据、明细、审批历史、附件及删除审计。旧链接只对原有权人员展示只读历史,不增加读取权限。
|
||||||
|
- 删除时解除本单有效发票占用,符合条件的票据可用于新单;旧单保留当时关联票据的 PDF 凭证引用及摘要,不跟随新单修改。此前已经释放且缺少关联证据的历史票据不猜测补回。
|
||||||
|
- 删除不代表撤销付款,也不删除真实发票原件。该能力须随对应候选发布才在生产生效。
|
||||||
|
|
||||||
## 发票状态
|
## 发票状态
|
||||||
|
|
||||||
发票主要状态:
|
发票主要状态:
|
||||||
|
|||||||
@@ -353,6 +353,13 @@ async function main() {
|
|||||||
const simultaneous = await Promise.all([retryRequest, unbindRequest]);
|
const simultaneous = await Promise.all([retryRequest, unbindRequest]);
|
||||||
assert.deepEqual(simultaneous.map((result) => result.status), [200, 200], "retry and unbind must not deadlock");
|
assert.deepEqual(simultaneous.map((result) => result.status), [200, 200], "retry and unbind must not deadlock");
|
||||||
passed.push("create retry and legacy unbind share a consistent lock order");
|
passed.push("create retry and legacy unbind share a consistent lock order");
|
||||||
|
if (process.env.RUN_RETAINED_DELETE_TEST === "1") {
|
||||||
|
await require("./test-reimbursement-retained-delete.cjs")({
|
||||||
|
prisma, pool, app, origin, tokens, employee, manager, expenseType,
|
||||||
|
makeForm, makeInvoice, bind, post, uploadedRecord, storageDir, passed,
|
||||||
|
root, browser, preview, loginPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
await prisma.employee.update({ where: { id: employee.id }, data: { token_version: { increment: 1 } } });
|
await prisma.employee.update({ where: { id: employee.id }, data: { token_version: { increment: 1 } } });
|
||||||
assert.equal((await fetch(`${origin}/api/me`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } })).status, 401);
|
assert.equal((await fetch(`${origin}/api/me`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } })).status, 401);
|
||||||
passed.push("revoked token version rejected by real auth plugin");
|
passed.push("revoked token version rejected by real auth plugin");
|
||||||
@@ -374,4 +381,4 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => { console.error("Isolated HTTP verification failed:", error.code ?? error.name, error.code === "ERR_ASSERTION" ? error.message : "", error.stack?.match(/test-invoice-first-http\.cjs:\d+:\d+/)?.[0] ?? ""); process.exitCode = 1; });
|
main().catch((error) => { console.error("Isolated HTTP verification failed:", error.code ?? error.name, error.code === "ERR_ASSERTION" ? error.message : "", error.stack?.match(/test-(?:invoice-first-http|reimbursement-retained-delete)\.cjs:\d+:\d+/)?.[0] ?? ""); process.exitCode = 1; });
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { readFileSync, writeFileSync } = require("node:fs");
|
||||||
|
const { createHash, randomUUID } = require("node:crypto");
|
||||||
|
const { createRequire } = require("node:module");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { setTimeout: delay } = require("node:timers/promises");
|
||||||
|
|
||||||
|
module.exports = async function verifyRetainedDeletion(context) {
|
||||||
|
const {
|
||||||
|
prisma, pool, origin, tokens, employee, manager, expenseType,
|
||||||
|
makeForm, makeInvoice, bind, post, uploadedRecord, storageDir, passed,
|
||||||
|
root, browser, preview, loginPassword,
|
||||||
|
} = context;
|
||||||
|
const remove = (form, actor) => post(`/reimbursements/${form.id}`, {}, actor, "DELETE");
|
||||||
|
const get = async (url, actor = employee.id) => {
|
||||||
|
const response = await fetch(`${origin}/api${url}`, {
|
||||||
|
headers: { authorization: `Bearer ${tokens.get(actor)}` }, signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
return { status: response.status, data: (await response.json()).data };
|
||||||
|
};
|
||||||
|
const populate = async (form, invoice) => {
|
||||||
|
assert.equal((await bind(invoice, form)).status, 200);
|
||||||
|
const item = await prisma.reimbursementItem.findFirst({ where: { reimbursement_id: form.id } });
|
||||||
|
assert.equal((await post(`/reimbursements/${form.id}/items/classify`, { itemIds: [item.id], expenseTypeId: expenseType.id })).status, 200);
|
||||||
|
assert.equal((await post(`/reimbursements/${form.id}/items/${item.id}`, { description: "synthetic retained history" }, null, "PATCH")).status, 200);
|
||||||
|
};
|
||||||
|
const prepare = async (action) => {
|
||||||
|
const form = await makeForm();
|
||||||
|
const fileName = `synthetic-delete-${randomUUID()}.pdf`;
|
||||||
|
const bytes = Buffer.concat([readFileSync(path.join(storageDir, uploadedRecord.storage_path)), Buffer.from(`\n% ${fileName}\n`)]);
|
||||||
|
writeFileSync(path.join(storageDir, fileName), bytes);
|
||||||
|
const invoice = await makeInvoice({
|
||||||
|
storage_path: fileName,
|
||||||
|
file_sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
file_size: bytes.length,
|
||||||
|
});
|
||||||
|
await populate(form, invoice);
|
||||||
|
const submitted = await post(`/reimbursements/${form.id}/submit`, {});
|
||||||
|
assert.equal(submitted.status, 200);
|
||||||
|
const instanceId = submitted.body.data.workflowInstanceId;
|
||||||
|
const task = await prisma.workflowTask.findFirst({ where: { instance_id: instanceId, status: "pending" } });
|
||||||
|
if (action === "withdraw") {
|
||||||
|
assert.equal((await post(`/reimbursements/${form.id}/withdraw`, { comment: "synthetic withdraw" })).status, 200);
|
||||||
|
} else {
|
||||||
|
assert.equal((await post(`/workflow/tasks/${task.id}/${action}`, { comment: "synthetic correction" }, manager.id)).status, 200);
|
||||||
|
}
|
||||||
|
return { form, invoice, instanceId };
|
||||||
|
};
|
||||||
|
const verify = async ({ form, invoice, instanceId }, action) => {
|
||||||
|
const record = await prisma.reimbursementForm.findUnique({ where: { id: form.id } });
|
||||||
|
assert.equal(record.status, "cancelled");
|
||||||
|
assert.ok(record.deleted_at);
|
||||||
|
assert.equal(record.deleted_by, employee.id);
|
||||||
|
assert.ok(await prisma.workflowInstance.findUnique({ where: { id: instanceId } }));
|
||||||
|
assert.ok(await prisma.workflowHistory.count({ where: { instance_id: instanceId } }));
|
||||||
|
assert.ok(await prisma.reimbursementItem.count({ where: { reimbursement_id: form.id } }));
|
||||||
|
const detail = await get(`/reimbursements/${form.id}`);
|
||||||
|
assert.equal(detail.status, 200);
|
||||||
|
assert.ok(detail.data.deletedAt);
|
||||||
|
assert.equal(detail.data.invoices.length, 0);
|
||||||
|
if (action !== "withdraw") {
|
||||||
|
const evidence = await prisma.reimbursementAttachment.findFirst({ where: { reimbursement_id: form.id, attachment_type: "invoice" } });
|
||||||
|
assert.ok(evidence);
|
||||||
|
const response = await fetch(`${origin}/api/reimbursements/${form.id}/attachments/${evidence.id}/file`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
assert.equal(createHash("sha256").update(bytes).digest("hex"), evidence.file_sha256);
|
||||||
|
assert.deepEqual(bytes, readFileSync(path.join(storageDir, invoice.storage_path)));
|
||||||
|
}
|
||||||
|
for (const scope of ["own", "all"]) {
|
||||||
|
const listing = await get(`/reimbursements?scope=${scope}&status=cancelled`, scope === "all" ? manager.id : employee.id);
|
||||||
|
assert.equal(listing.status, 200);
|
||||||
|
assert.ok(!listing.data.items.some((item) => item.id === form.id));
|
||||||
|
}
|
||||||
|
const summary = await get("/reimbursements/stats/summary", manager.id);
|
||||||
|
assert.equal(summary.status, 200);
|
||||||
|
assert.equal(summary.data.byStatus.cancelled.count, 0);
|
||||||
|
const serverRequire = createRequire(path.join(root, "server/package.json"));
|
||||||
|
const ledgerWhere = serverRequire("./src/services/financeLedger").buildFinanceLedgerWhere({ status: "cancelled" });
|
||||||
|
assert.equal(await prisma.reimbursementForm.count({ where: ledgerWhere }), 0);
|
||||||
|
assert.equal((await remove(form)).status, 200);
|
||||||
|
assert.equal(await prisma.auditLog.count({ where: { action: "REIMBURSEMENT_DELETED", target_id: form.id } }), 1);
|
||||||
|
assert.equal((await post(`/reimbursements/${form.id}/submit`, {})).status, 409);
|
||||||
|
assert.equal((await post(`/reimbursements/${form.id}`, { description: "forbidden" }, null, "PATCH")).status, 409);
|
||||||
|
assert.equal((await bind(await makeInvoice(), form)).status, 409);
|
||||||
|
const replacement = await makeForm();
|
||||||
|
await populate(replacement, invoice);
|
||||||
|
assert.equal((await remove(form)).status, 200);
|
||||||
|
assert.equal((await prisma.invoicePool.findUnique({ where: { id: invoice.id } })).linked_reimbursement_id, replacement.id);
|
||||||
|
assert.equal((await post(`/reimbursements/${replacement.id}/submit`, {})).status, 200);
|
||||||
|
const historicalDetail = await get(`/reimbursements/${form.id}`);
|
||||||
|
assert.ok(historicalDetail.data.deletedAt);
|
||||||
|
assert.equal(historicalDetail.data.invoices.length, 0);
|
||||||
|
assert.equal(historicalDetail.data.attachments.length, detail.data.attachments.length);
|
||||||
|
assert.ok(historicalDetail.data.processingProgress.stages.every((stage) => !["pending", "current"].includes(stage.state)));
|
||||||
|
};
|
||||||
|
for (const action of ["reject", "return", "withdraw"]) {
|
||||||
|
const fixture = await prepare(action);
|
||||||
|
assert.equal((await remove(fixture.form, manager.id)).status, 403);
|
||||||
|
assert.equal((await remove(fixture.form)).status, 200);
|
||||||
|
await verify(fixture, action);
|
||||||
|
}
|
||||||
|
passed.push("reject/return/withdraw -> retained deletion -> old history readable -> invoice reused and new form submitted; old record immutable; lists/raw stats/ledger exclude deleted records");
|
||||||
|
|
||||||
|
const rollback = await prepare("reject");
|
||||||
|
await prisma.$executeRawUnsafe("CREATE FUNCTION synthetic_reject_delete_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'REIMBURSEMENT_DELETED' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$");
|
||||||
|
await prisma.$executeRawUnsafe("CREATE TRIGGER synthetic_reject_delete_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_reject_delete_audit()");
|
||||||
|
assert.equal((await remove(rollback.form)).status, 500);
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: rollback.form.id } })).status, "rejected");
|
||||||
|
assert.equal((await prisma.invoicePool.findUnique({ where: { id: rollback.invoice.id } })).linked_reimbursement_id, rollback.form.id);
|
||||||
|
assert.equal(await prisma.reimbursementAttachment.count({ where: { reimbursement_id: rollback.form.id } }), 0);
|
||||||
|
await prisma.$executeRawUnsafe("DROP TRIGGER synthetic_reject_delete_audit ON audit_logs");
|
||||||
|
passed.push("audit failure atomically rolls back deletion, invoice release and evidence attachment");
|
||||||
|
|
||||||
|
for (const competitor of ["submit", "bind", "delete"]) {
|
||||||
|
const fixture = await prepare("reject");
|
||||||
|
const blocker = await pool.connect();
|
||||||
|
const waitForLocks = async (count) => {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
const result = await pool.query("SELECT count(*)::int AS count FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock' AND query LIKE '%reimbursement_forms%'");
|
||||||
|
if (result.rows[0].count >= count) return;
|
||||||
|
await delay(20);
|
||||||
|
}
|
||||||
|
assert.fail("delete concurrency barrier timed out");
|
||||||
|
};
|
||||||
|
let deletion;
|
||||||
|
let competing;
|
||||||
|
try {
|
||||||
|
await blocker.query("BEGIN");
|
||||||
|
await blocker.query("SELECT id FROM reimbursement_forms WHERE id = $1 FOR UPDATE", [fixture.form.id]);
|
||||||
|
deletion = remove(fixture.form);
|
||||||
|
await waitForLocks(1);
|
||||||
|
competing = competitor === "delete" ? remove(fixture.form)
|
||||||
|
: competitor === "submit" ? post(`/reimbursements/${fixture.form.id}/submit`, {})
|
||||||
|
: bind(await makeInvoice(), fixture.form);
|
||||||
|
await waitForLocks(2);
|
||||||
|
} finally {
|
||||||
|
await blocker.query("ROLLBACK");
|
||||||
|
blocker.release();
|
||||||
|
}
|
||||||
|
const results = await Promise.all([deletion, competing]);
|
||||||
|
assert.deepEqual(results.map((result) => result.status), [200, competitor === "delete" ? 200 : 409]);
|
||||||
|
assert.equal(await prisma.auditLog.count({ where: { action: "REIMBURSEMENT_DELETED", target_id: fixture.form.id } }), 1);
|
||||||
|
}
|
||||||
|
passed.push("real PostgreSQL barriers: deletion vs submit/bind/repeated delete has one legal terminal state");
|
||||||
|
|
||||||
|
if (browser && preview) {
|
||||||
|
const { expect } = createRequire(path.join(root, "client/package.json"))("@playwright/test");
|
||||||
|
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
|
||||||
|
for (const mobile of [false, true]) {
|
||||||
|
const fixture = await prepare("reject");
|
||||||
|
const browserContext = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
|
||||||
|
try {
|
||||||
|
const fingerprint = `synthetic-delete-${mobile}`;
|
||||||
|
await browserContext.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
|
||||||
|
const login = await browserContext.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: employee.email, password: loginPassword } });
|
||||||
|
assert.equal(login.status(), 200);
|
||||||
|
const page = await browserContext.newPage();
|
||||||
|
const target = `${uiOrigin}/${mobile ? "mobile/" : ""}reimbursements/${fixture.form.id}`;
|
||||||
|
await page.goto(target);
|
||||||
|
if (!mobile) await page.getByTestId("reimbursement-more-toggle").click();
|
||||||
|
else await page.getByTestId("mobile-reimbursement-more").click();
|
||||||
|
await page.getByRole("button", { name: "删除报销单", exact: true }).click();
|
||||||
|
await expect(page.getByTestId("action-confirm-dialog")).toContainText("审批记录仍保留");
|
||||||
|
await page.getByTestId("action-confirm-cancel").click();
|
||||||
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at, null);
|
||||||
|
await page.getByRole("button", { name: "删除报销单", exact: true }).click();
|
||||||
|
await page.getByTestId("action-confirm-submit").click();
|
||||||
|
await expect.poll(async () => Boolean((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at)).toBe(true);
|
||||||
|
await page.goto(target);
|
||||||
|
await expect(page.getByText("已删除", { exact: true }).first()).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-submit")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("reimbursement-invoice-add")).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "删除报销单", exact: true })).toHaveCount(0);
|
||||||
|
await verify(fixture, "reject");
|
||||||
|
} finally { await browserContext.close(); }
|
||||||
|
}
|
||||||
|
passed.push("desktop/mobile real browser: rejected deletion cancel/confirm, persisted historical deep link, no edit/submit/delete actions");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE "reimbursement_forms"
|
||||||
|
ADD COLUMN "deleted_at" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "deleted_by" UUID,
|
||||||
|
ADD CONSTRAINT "reimbursement_forms_deleted_by_fkey"
|
||||||
|
FOREIGN KEY ("deleted_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
ADD CONSTRAINT "reimbursement_forms_deleted_state_check"
|
||||||
|
CHECK (("deleted_at" IS NULL AND "deleted_by" IS NULL)
|
||||||
|
OR ("deleted_at" IS NOT NULL AND "deleted_by" IS NOT NULL AND "status" = 'cancelled'));
|
||||||
|
|
||||||
|
CREATE INDEX "idx_reimbursement_forms_deleted_at" ON "reimbursement_forms"("deleted_at");
|
||||||
@@ -575,6 +575,7 @@ model Department {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Employee {
|
model Employee {
|
||||||
|
deleted_reimbursements ReimbursementForm[] @relation("ReimbursementDeletedBy")
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
employee_no String @unique @default(dbgenerated("generate_employee_number()"))
|
employee_no String @unique @default(dbgenerated("generate_employee_number()"))
|
||||||
name String
|
name String
|
||||||
@@ -1441,6 +1442,9 @@ model InventoryCheckItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ReimbursementForm {
|
model ReimbursementForm {
|
||||||
|
deleted_at DateTime?
|
||||||
|
deleted_by String? @db.Uuid
|
||||||
|
deleted_by_employee Employee? @relation("ReimbursementDeletedBy", fields: [deleted_by], references: [id], onDelete: Restrict)
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
form_number String? @unique
|
form_number String? @unique
|
||||||
submitter_id String @db.Uuid
|
submitter_id String @db.Uuid
|
||||||
@@ -1491,6 +1495,7 @@ model ReimbursementForm {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([reimbursement_date])
|
@@index([reimbursement_date])
|
||||||
@@index([owner_id, status, updated_at], map: "idx_reimbursement_owner_status_updated_at")
|
@@index([owner_id, status, updated_at], map: "idx_reimbursement_owner_status_updated_at")
|
||||||
|
@@index([deleted_at], map: "idx_reimbursement_forms_deleted_at")
|
||||||
@@map("reimbursement_forms")
|
@@map("reimbursement_forms")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ async function portalRoutes(fastify) {
|
|||||||
teamStatusBoard,
|
teamStatusBoard,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }),
|
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }),
|
||||||
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
fastify.prisma.reimbursementForm.count({ where: { deleted_at: null, OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
||||||
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
|
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
|
||||||
fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
|
fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
|
||||||
roleCapabilities.canUseFinance
|
roleCapabilities.canUseFinance
|
||||||
|
|||||||
@@ -842,7 +842,7 @@ async function projectsRoutes(fastify) {
|
|||||||
const projectId = parseId(request.params?.id);
|
const projectId = parseId(request.params?.id);
|
||||||
await ensureReadableProject(fastify, request, projectId);
|
await ensureReadableProject(fastify, request, projectId);
|
||||||
const items = await fastify.prisma.reimbursementForm.findMany({
|
const items = await fastify.prisma.reimbursementForm.findMany({
|
||||||
where: { project_id: projectId },
|
where: { project_id: projectId, deleted_at: null },
|
||||||
orderBy: { updated_at: "desc" },
|
orderBy: { updated_at: "desc" },
|
||||||
select: { id: true, form_number: true, status: true, total_amount: true, reimbursement_date: true, updated_at: true },
|
select: { id: true, form_number: true, status: true, total_amount: true, reimbursement_date: true, updated_at: true },
|
||||||
});
|
});
|
||||||
|
|||||||
+169
-52
@@ -633,17 +633,36 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const stages = [
|
||||||
stages: [
|
stageByKey.get("submitted"),
|
||||||
stageByKey.get("submitted"),
|
...(approvalStages.length > 0
|
||||||
...(approvalStages.length > 0
|
? approvalStages
|
||||||
? approvalStages
|
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
|
||||||
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
|
stageByKey.get("payment_batch_created"),
|
||||||
stageByKey.get("payment_batch_created"),
|
stageByKey.get("payment_batch_review"),
|
||||||
stageByKey.get("payment_batch_review"),
|
stageByKey.get("payment_and_receipt"),
|
||||||
stageByKey.get("payment_and_receipt"),
|
];
|
||||||
],
|
if (reimbursement.deleted_at) {
|
||||||
};
|
for (const stage of stages) {
|
||||||
|
if (["pending", "current"].includes(stage.state)) {
|
||||||
|
Object.assign(stage, { state: "terminated", detail: "报销单已删除,不再继续处理", targetHref: null });
|
||||||
|
} else if (stage.state === "exception") {
|
||||||
|
stage.detail = "历史审批结果,报销单已删除";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stages.push({
|
||||||
|
key: "deleted",
|
||||||
|
label: "删除报销单",
|
||||||
|
state: "terminated",
|
||||||
|
actorName: null,
|
||||||
|
occurredAt: isoDateTimeOrNull(reimbursement.deleted_at),
|
||||||
|
result: "已删除",
|
||||||
|
comment: null,
|
||||||
|
detail: "仅保留历史记录",
|
||||||
|
targetHref: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { stages };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureReimbursementParticipant(request, reimbursement) {
|
function ensureReimbursementParticipant(request, reimbursement) {
|
||||||
@@ -812,7 +831,7 @@ function buildReimbursementStatsConditions({
|
|||||||
formAlias = "rf",
|
formAlias = "rf",
|
||||||
ownerAlias = "owner_employee",
|
ownerAlias = "owner_employee",
|
||||||
}) {
|
}) {
|
||||||
const conditions = [Prisma.sql`1 = 1`];
|
const conditions = [Prisma.sql`${sqlIdentifier(formAlias, "deleted_at")} IS NULL`];
|
||||||
|
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} >= ${dateFrom}`);
|
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} >= ${dateFrom}`);
|
||||||
@@ -2266,6 +2285,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
owner_id: true,
|
owner_id: true,
|
||||||
department_id: true,
|
department_id: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
|
deleted_at: true,
|
||||||
reimbursement_date: true,
|
reimbursement_date: true,
|
||||||
total_amount: true,
|
total_amount: true,
|
||||||
status: true,
|
status: true,
|
||||||
@@ -2604,6 +2624,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
: null,
|
: null,
|
||||||
createdAt: reimbursement.created_at?.toISOString?.() ?? null,
|
createdAt: reimbursement.created_at?.toISOString?.() ?? null,
|
||||||
submittedAt: latestWorkflowInstance?.created_at?.toISOString?.() ?? null,
|
submittedAt: latestWorkflowInstance?.created_at?.toISOString?.() ?? null,
|
||||||
|
deletedAt: reimbursement.deleted_at?.toISOString?.() ?? null,
|
||||||
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
|
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
|
||||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||||
status: reimbursement.status,
|
status: reimbursement.status,
|
||||||
@@ -2797,7 +2818,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
const reimbursementRows = await tx.$queryRaw`
|
||||||
SELECT id, owner_id, submitter_id, status
|
SELECT id, owner_id, submitter_id, status, deleted_at, paid_at
|
||||||
FROM "reimbursement_forms"
|
FROM "reimbursement_forms"
|
||||||
WHERE id = ${reimbursementId}::uuid
|
WHERE id = ${reimbursementId}::uuid
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -2814,10 +2835,61 @@ async function reimbursementRoutes(fastify) {
|
|||||||
const reimbursement = reimbursementRows[0];
|
const reimbursement = reimbursementRows[0];
|
||||||
ensureReimbursementParticipant(request, reimbursement);
|
ensureReimbursementParticipant(request, reimbursement);
|
||||||
|
|
||||||
if (reimbursement.status !== "draft") {
|
if (reimbursement.deleted_at) {
|
||||||
|
return { reimbursementId, unboundInvoiceCount: 0 };
|
||||||
|
}
|
||||||
|
if (!["draft", "rejected"].includes(reimbursement.status) || reimbursement.paid_at) {
|
||||||
throw buildBusinessValidationError("reimbursement.status_invalid_delete_status", { status: "invalid_delete_status" }, 409);
|
throw buildBusinessValidationError("reimbursement.status_invalid_delete_status", { status: "invalid_delete_status" }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeWorkflow = await tx.workflowInstance.findFirst({
|
||||||
|
where: {
|
||||||
|
entity_type: "reimbursement",
|
||||||
|
entity_id: reimbursementId,
|
||||||
|
OR: [{ current_status: "pending" }, { workflow_tasks: { some: { status: "pending" } } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const paymentReservation = await tx.paymentBatchItem.findFirst({
|
||||||
|
where: {
|
||||||
|
entity_id: reimbursementId,
|
||||||
|
payment_batch: { entity_type: "reimbursement", status: { not: "cancelled" } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (activeWorkflow || paymentReservation) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.status_invalid_delete_status", { status: "invalid_delete_status" }, 409);
|
||||||
|
}
|
||||||
|
const history = await tx.workflowInstance.findFirst({
|
||||||
|
where: { entity_type: "reimbursement", entity_id: reimbursementId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const retainHistory = reimbursement.status === "rejected" || Boolean(history);
|
||||||
|
const invoices = await tx.$queryRaw`
|
||||||
|
SELECT id, linked_item_id, storage_path, original_name, mime_type, file_size, file_sha256,
|
||||||
|
uploaded_by_employee_id, status
|
||||||
|
FROM "invoice_pool"
|
||||||
|
WHERE linked_reimbursement_id = ${reimbursementId}::uuid
|
||||||
|
ORDER BY id
|
||||||
|
FOR UPDATE
|
||||||
|
`;
|
||||||
|
if (invoices.some((invoice) => invoice.status !== "bound")) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.status_invalid_delete_status", { status: "invalid_delete_status" }, 409);
|
||||||
|
}
|
||||||
|
if (retainHistory && invoices.length) {
|
||||||
|
await tx.reimbursementAttachment.createMany({
|
||||||
|
data: invoices.map((invoice) => ({
|
||||||
|
reimbursement_id: reimbursementId,
|
||||||
|
storage_path: invoice.storage_path,
|
||||||
|
original_name: invoice.original_name,
|
||||||
|
mime_type: invoice.mime_type,
|
||||||
|
file_size: invoice.file_size,
|
||||||
|
file_sha256: invoice.file_sha256,
|
||||||
|
uploaded_by: invoice.uploaded_by_employee_id,
|
||||||
|
attachment_type: "invoice",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
const unbound = await tx.invoicePool.updateMany({
|
const unbound = await tx.invoicePool.updateMany({
|
||||||
where: {
|
where: {
|
||||||
linked_reimbursement_id: reimbursementId,
|
linked_reimbursement_id: reimbursementId,
|
||||||
@@ -2831,8 +2903,35 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.reimbursementForm.delete({
|
if (retainHistory) {
|
||||||
where: { id: reimbursementId },
|
await tx.reimbursementForm.update({
|
||||||
|
where: { id: reimbursementId },
|
||||||
|
data: {
|
||||||
|
status: "cancelled",
|
||||||
|
deleted_at: new Date(),
|
||||||
|
deleted_by: request.employee.id,
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await tx.reimbursementForm.delete({ where: { id: reimbursementId } });
|
||||||
|
}
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "REIMBURSEMENT_DELETED",
|
||||||
|
targetType: "reimbursement",
|
||||||
|
targetId: reimbursementId,
|
||||||
|
extra: {
|
||||||
|
previousStatus: reimbursement.status,
|
||||||
|
retainedHistory: retainHistory,
|
||||||
|
releasedInvoices: invoices.map((invoice) => ({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
itemId: invoice.linked_item_id,
|
||||||
|
fileSha256: invoice.file_sha256,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2956,42 +3055,54 @@ async function reimbursementRoutes(fastify) {
|
|||||||
contentType: file.mimeType,
|
contentType: file.mimeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = await fastify.prisma.reimbursementAttachment.create({
|
const created = await fastify.prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const currentRows = await tx.$queryRaw`
|
||||||
reimbursement_id: reimbursementId,
|
SELECT id, owner_id, submitter_id, status FROM "reimbursement_forms"
|
||||||
storage_path: storagePath,
|
WHERE id = ${reimbursementId}::uuid FOR UPDATE
|
||||||
original_name: file.originalName,
|
`;
|
||||||
mime_type: file.mimeType,
|
const current = currentRows[0];
|
||||||
file_size: file.fileSize,
|
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
file_sha256: fileSha256,
|
ensureReimbursementParticipant(request, current);
|
||||||
uploaded_by: request.employee.id,
|
if (!["draft", "rejected"].includes(current.status)) {
|
||||||
...governanceCreateData(governanceFields, governanceDefaults),
|
throw buildBusinessValidationError("reimbursement.status_invalid_attachment_edit_status", { status: "invalid_attachment_edit_status" }, 409);
|
||||||
},
|
}
|
||||||
select: {
|
return tx.reimbursementAttachment.create({
|
||||||
id: true,
|
data: {
|
||||||
original_name: true,
|
reimbursement_id: reimbursementId,
|
||||||
mime_type: true,
|
storage_path: storagePath,
|
||||||
file_size: true,
|
original_name: file.originalName,
|
||||||
file_sha256: true,
|
mime_type: file.mimeType,
|
||||||
governance_status: true,
|
file_size: file.fileSize,
|
||||||
sensitivity_level: true,
|
file_sha256: fileSha256,
|
||||||
attachment_type: true,
|
uploaded_by: request.employee.id,
|
||||||
reviewed_by: true,
|
...governanceCreateData(governanceFields, governanceDefaults),
|
||||||
reviewed_at: true,
|
},
|
||||||
review_note: true,
|
select: {
|
||||||
archived_by: true,
|
id: true,
|
||||||
archived_at: true,
|
original_name: true,
|
||||||
archive_note: true,
|
mime_type: true,
|
||||||
download_policy: true,
|
file_size: true,
|
||||||
created_at: true,
|
file_sha256: true,
|
||||||
uploader: {
|
governance_status: true,
|
||||||
select: {
|
sensitivity_level: true,
|
||||||
id: true,
|
attachment_type: true,
|
||||||
employee_no: true,
|
reviewed_by: true,
|
||||||
name: true,
|
reviewed_at: true,
|
||||||
|
review_note: true,
|
||||||
|
archived_by: true,
|
||||||
|
archived_at: true,
|
||||||
|
archive_note: true,
|
||||||
|
download_policy: true,
|
||||||
|
created_at: true,
|
||||||
|
uploader: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
employee_no: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
});
|
||||||
createdItems.push(created);
|
createdItems.push(created);
|
||||||
}
|
}
|
||||||
@@ -4565,9 +4676,15 @@ async function reimbursementRoutes(fastify) {
|
|||||||
if (!["draft", "rejected"].includes(current.status)) {
|
if (!["draft", "rejected"].includes(current.status)) {
|
||||||
throw buildBusinessValidationError("reimbursement.reimbursement_invalid_status_for_project_link", { reimbursement: "invalid_status_for_project_link" }, 409);
|
throw buildBusinessValidationError("reimbursement.reimbursement_invalid_status_for_project_link", { reimbursement: "invalid_status_for_project_link" }, 409);
|
||||||
}
|
}
|
||||||
const updated = await fastify.prisma.reimbursementForm.update({
|
const changed = await fastify.prisma.reimbursementForm.updateMany({
|
||||||
where: { id: reimbursementId },
|
where: { id: reimbursementId, deleted_at: null, status: { in: ["draft", "rejected"] } },
|
||||||
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
|
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
|
||||||
|
});
|
||||||
|
if (!changed.count) {
|
||||||
|
throw buildBusinessValidationError("reimbursement.reimbursement_invalid_status_for_project_link", { reimbursement: "invalid_status_for_project_link" }, 409);
|
||||||
|
}
|
||||||
|
const updated = await fastify.prisma.reimbursementForm.findUnique({
|
||||||
|
where: { id: reimbursementId },
|
||||||
select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } },
|
select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } },
|
||||||
});
|
});
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function dateRange(dateFrom, dateTo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildFinanceLedgerWhere(filters = {}) {
|
function buildFinanceLedgerWhere(filters = {}) {
|
||||||
const clauses = [];
|
const clauses = [{ deleted_at: null }];
|
||||||
const query = filters.q?.trim();
|
const query = filters.q?.trim();
|
||||||
|
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ function buildReadableReimbursementWhere({ employee, scope = "all", filters = {}
|
|||||||
: buildViewAllReimbursementScopeWhere(employee);
|
: buildViewAllReimbursementScopeWhere(employee);
|
||||||
|
|
||||||
if (scope === "own") {
|
if (scope === "own") {
|
||||||
return { AND: [scopeWhere, filters] };
|
return { AND: [scopeWhere, filters, { deleted_at: null }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
AND: [scopeWhere, buildReimbursementBusinessFactWhere(employee.id), filters],
|
AND: [scopeWhere, buildReimbursementBusinessFactWhere(employee.id), filters, { deleted_at: null }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
const routes = require("../../../src/routes/reimbursements");
|
||||||
|
|
||||||
|
const FORM_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const ACTOR_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const OTHER_ID = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const INVOICE_ID = "44444444-4444-4444-8444-444444444444";
|
||||||
|
|
||||||
|
async function setup(options = {}) {
|
||||||
|
let record = {
|
||||||
|
id: FORM_ID, owner_id: ACTOR_ID, submitter_id: OTHER_ID,
|
||||||
|
status: "rejected", deleted_at: null, paid_at: null, ...options.record,
|
||||||
|
};
|
||||||
|
const invoices = options.invoices ?? [{
|
||||||
|
id: INVOICE_ID, linked_item_id: "item-1", storage_path: "synthetic.pdf",
|
||||||
|
original_name: "synthetic.pdf", mime_type: "application/pdf", file_size: 20,
|
||||||
|
file_sha256: "synthetic-hash", uploaded_by_employee_id: ACTOR_ID, status: "bound",
|
||||||
|
}];
|
||||||
|
const tx = {
|
||||||
|
$queryRaw: vi.fn(async (strings) => strings.join("").includes('"invoice_pool"') ? invoices : [record]),
|
||||||
|
workflowInstance: {
|
||||||
|
findFirst: vi.fn(async ({ where }) => where.OR
|
||||||
|
? (options.active ? { id: "active" } : null)
|
||||||
|
: (options.history === false ? null : { id: "history" })),
|
||||||
|
},
|
||||||
|
paymentBatchItem: { findFirst: vi.fn(async () => options.payment ? { id: "batch-item" } : null) },
|
||||||
|
reimbursementAttachment: { createMany: vi.fn(async () => ({ count: invoices.length })) },
|
||||||
|
reimbursementForm: {
|
||||||
|
update: vi.fn(async ({ data }) => { record = { ...record, ...data }; return record; }),
|
||||||
|
delete: vi.fn(async () => record),
|
||||||
|
},
|
||||||
|
invoicePool: { updateMany: vi.fn(async () => ({ count: invoices.length })) },
|
||||||
|
auditLog: { create: vi.fn(async () => ({ id: "audit" })) },
|
||||||
|
};
|
||||||
|
const actor = { id: options.actorId ?? ACTOR_ID, permissionCodes: options.permissions ?? ["expense.delete_own"], roleCodes: [] };
|
||||||
|
const app = Fastify();
|
||||||
|
app.decorate("prisma", { $transaction: async (callback) => callback(tx) });
|
||||||
|
app.decorate("config", {});
|
||||||
|
app.decorate("authenticate", async (request) => { request.employee = actor; });
|
||||||
|
app.decorate("hasPermission", (employee, code) => employee.permissionCodes.includes(code) || employee.permissionCodes.includes("*"));
|
||||||
|
app.decorate("requirePermission", (code) => async (request) => {
|
||||||
|
request.employee = actor;
|
||||||
|
if (!app.hasPermission(actor, code)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
|
});
|
||||||
|
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
|
||||||
|
await app.register(routes, { prefix: "/api" });
|
||||||
|
return { app, tx, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reimbursement retained deletion", () => {
|
||||||
|
const apps = [];
|
||||||
|
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
|
||||||
|
async function arrange(options) {
|
||||||
|
const result = await setup(options);
|
||||||
|
apps.push(result.app);
|
||||||
|
return { ...result, remove: () => result.app.inject({ method: "DELETE", url: `/api/reimbursements/${FORM_ID}` }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(["rejected", "draft"])("retains history and immutable invoice references for historical %s", async (status) => {
|
||||||
|
const { remove, tx } = await arrange({ record: { status } });
|
||||||
|
expect((await remove()).statusCode).toBe(200);
|
||||||
|
expect(tx.reimbursementForm.delete).not.toHaveBeenCalled();
|
||||||
|
expect(tx.reimbursementForm.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: FORM_ID },
|
||||||
|
data: { status: "cancelled", deleted_at: expect.any(Date), deleted_by: ACTOR_ID, updated_by: ACTOR_ID },
|
||||||
|
});
|
||||||
|
expect(tx.reimbursementAttachment.createMany.mock.calls[0][0].data[0]).toMatchObject({
|
||||||
|
reimbursement_id: FORM_ID, storage_path: "synthetic.pdf", file_sha256: "synthetic-hash", attachment_type: "invoice",
|
||||||
|
});
|
||||||
|
expect(tx.invoicePool.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { linked_reimbursement_id: FORM_ID, status: "bound" },
|
||||||
|
data: { status: "available", linked_reimbursement_id: null, linked_item_id: null, updated_by: ACTOR_ID },
|
||||||
|
});
|
||||||
|
expect(tx.auditLog.create.mock.calls[0][0].data).toMatchObject({
|
||||||
|
action: "REIMBURSEMENT_DELETED",
|
||||||
|
extra: { previousStatus: status, retainedHistory: true, releasedInvoices: [{ invoiceId: INVOICE_ID, itemId: "item-1", fileSha256: "synthetic-hash" }] },
|
||||||
|
});
|
||||||
|
expect(tx.$queryRaw.mock.calls.map(([strings]) => strings.join(""))[0]).toContain('"reimbursement_forms"');
|
||||||
|
expect(tx.$queryRaw.mock.calls.map(([strings]) => strings.join(""))[1]).toContain('"invoice_pool"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("physically deletes only never-submitted drafts", async () => {
|
||||||
|
const { remove, tx } = await arrange({ record: { status: "draft" }, history: false });
|
||||||
|
expect((await remove()).statusCode).toBe(200);
|
||||||
|
expect(tx.reimbursementForm.delete).toHaveBeenCalledWith({ where: { id: FORM_ID } });
|
||||||
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||||
|
expect(tx.reimbursementAttachment.createMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not assume rejected records without workflow are safe to erase", async () => {
|
||||||
|
const { remove, tx } = await arrange({ history: false });
|
||||||
|
expect((await remove()).statusCode).toBe(200);
|
||||||
|
expect(tx.reimbursementForm.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permits the submitter and retries without releasing invoices twice", async () => {
|
||||||
|
const { remove, tx } = await arrange({ actorId: OTHER_ID });
|
||||||
|
expect((await remove()).statusCode).toBe(200);
|
||||||
|
expect((await remove()).statusCode).toBe(200);
|
||||||
|
expect(tx.invoicePool.updateMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ actorId: INVOICE_ID, permissions: ["*"] },
|
||||||
|
{ actorId: INVOICE_ID, permissions: ["expense.delete_own", "expense.view_all"] },
|
||||||
|
{ permissions: ["expense.view_all"] },
|
||||||
|
{ actorId: INVOICE_ID, permissions: ["*"], record: { deleted_at: new Date(), status: "cancelled" } },
|
||||||
|
])("refuses unauthorized deletion even with full reading or wildcard", async (options) => {
|
||||||
|
const { remove, tx } = await arrange(options);
|
||||||
|
expect((await remove()).statusCode).toBe(403);
|
||||||
|
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ record: { status: "reviewing" } },
|
||||||
|
{ record: { status: "approved" } },
|
||||||
|
{ record: { status: "paid" } },
|
||||||
|
{ record: { status: "cancelled" } },
|
||||||
|
{ record: { paid_at: new Date() } },
|
||||||
|
{ active: true },
|
||||||
|
{ payment: true },
|
||||||
|
{ invoices: [{ id: INVOICE_ID, status: "archived" }] },
|
||||||
|
])("refuses unsafe state without any write", async (options) => {
|
||||||
|
const { remove, tx } = await arrange(options);
|
||||||
|
const response = await remove();
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json().error.details.reasonCode).toBe("reimbursement.status_invalid_delete_status");
|
||||||
|
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||||
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -234,6 +234,7 @@ async function buildApp({
|
|||||||
const prisma = {
|
const prisma = {
|
||||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
reimbursementForm: {
|
reimbursementForm: {
|
||||||
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||||
findUnique: vi.fn(async ({ where }) => {
|
findUnique: vi.fn(async ({ where }) => {
|
||||||
if (where.id !== REIMBURSEMENT_ID) {
|
if (where.id !== REIMBURSEMENT_ID) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1083,7 +1084,7 @@ describe("POST /api/reimbursements/:id/link-project authorization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode, response.body).toBe(403);
|
expect(response.statusCode, response.body).toBe(403);
|
||||||
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
|
expect(prisma.reimbursementForm.updateMany).not.toHaveBeenCalled();
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1101,7 +1102,7 @@ describe("POST /api/reimbursements/:id/link-project authorization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode, response.body).toBe(409);
|
expect(response.statusCode, response.body).toBe(409);
|
||||||
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
|
expect(prisma.reimbursementForm.updateMany).not.toHaveBeenCalled();
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1119,12 +1120,33 @@ describe("POST /api/reimbursements/:id/link-project authorization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode, response.body).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(prisma.reimbursementForm.update).toHaveBeenCalledTimes(1);
|
expect(prisma.reimbursementForm.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: REIMBURSEMENT_ID, deleted_at: null, status: { in: ["draft", "rejected"] } },
|
||||||
|
data: { project_id: projectId, updated_by: SUBMITTER_ID },
|
||||||
|
});
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("reimbursement maintenance authorization", () => {
|
describe("reimbursement maintenance authorization", () => {
|
||||||
|
it("marks deleted historical detail and terminates next-action projections", async () => {
|
||||||
|
const { app } = await buildApp({
|
||||||
|
employeeId: SUBMITTER_ID,
|
||||||
|
permissionCodes: ["expense.view_own"],
|
||||||
|
reimbursement: buildReimbursementRecord({ status: "cancelled", deleted_at: new Date("2026-09-10T01:00:00Z") }),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await app.inject({ method: "GET", url: `/api/reimbursements/${REIMBURSEMENT_ID}`, headers: { authorization: "Bearer test" } });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const detail = response.json().data;
|
||||||
|
expect(detail.deletedAt).toBe("2026-09-10T01:00:00.000Z");
|
||||||
|
expect(detail.processingProgress.stages.some((stage) => ["pending", "current"].includes(stage.state))).toBe(false);
|
||||||
|
expect(detail.processingProgress.stages.at(-1)).toMatchObject({ key: "deleted", result: "已删除", state: "terminated" });
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const itemId = "77777777-7777-4777-8777-777777777777";
|
const itemId = "77777777-7777-4777-8777-777777777777";
|
||||||
const expenseTypeId = "88888888-8888-4888-8888-888888888888";
|
const expenseTypeId = "88888888-8888-4888-8888-888888888888";
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ describe("finance ledger service", () => {
|
|||||||
|
|
||||||
expect(where).toEqual({
|
expect(where).toEqual({
|
||||||
AND: [
|
AND: [
|
||||||
|
{ deleted_at: null },
|
||||||
{
|
{
|
||||||
OR: expect.arrayContaining([
|
OR: expect.arrayContaining([
|
||||||
{ project: { is: { project_no: { contains: "交付项目", mode: "insensitive" } } } },
|
{ project: { is: { project_no: { contains: "交付项目", mode: "insensitive" } } } },
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ describe("reimbursement visibility", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ status: "draft" },
|
{ status: "draft" },
|
||||||
|
{ deleted_at: null },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -57,6 +58,7 @@ describe("reimbursement visibility", () => {
|
|||||||
AND: [
|
AND: [
|
||||||
{ OR: [{ owner_id: ACTOR_ID }, { submitter_id: ACTOR_ID }] },
|
{ OR: [{ owner_id: ACTOR_ID }, { submitter_id: ACTOR_ID }] },
|
||||||
{ status: "draft" },
|
{ status: "draft" },
|
||||||
|
{ deleted_at: null },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户