比较提交

...
2 次代码提交
作者 SHA1 备注 提交日期
wangsli fe025e65cf fix: 优化报销制单下载与付款进度(09-08-reimbursement-payment-closeout)
ci-minimal / check-minimal (push) Canceled after 0s
ci-e2e / e2e (push) Canceled after 0s
2026-09-10 16:39:01 +08:00
wangsli 73fcd5a65a fix: 退回报销支持留痕删除(09-04-reimbursement-state-gap-finance-view) 2026-09-10 16:38:37 +08:00
共修改 39 个文件,包含 1060 行新增和 136 行删除
@@ -5,6 +5,37 @@
Read `docs/报销发票附件规则.md` before changing reimbursement, invoice, OCR,
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
- 隔离串联的 `RUN_JOB_QUEUE=1` 必须运行真实 startPgBossJobs 与上传入口 enqueue,
+8 -1
查看文件
@@ -84,8 +84,15 @@ explicit deferred decision, not a completed scope restriction.
`payment_order_export_*` fields.
- Reveal APIs use `POST` with JSON `{ "reason": "2-500 chars" }`; ordinary read
APIs return only bank name and masked account.
- Payment-order API: `POST /api/payment-batches/:id/payment-order` with a reason;
- Payment-order API: `POST /api/payment-batches/:id/payment-order` with `{}`;
success is an in-memory BIFF8 `.xls` download with `Cache-Control: no-store`.
- Payment-order download confirms sensitive-file handling, but does not require
a typed reason. Re-download warns about duplicate bank import/payment.
Audit records system purpose `reimbursement_bank_transfer` with
`purposeSource: system`; optional legacy user reasons remain genuine input,
never fabricated. Account reveal, cancellation and receipt policies stay unchanged.
- Filename is `报销制单_<batchNo>.xls`, sent with RFC 5987 UTF-8 encoding and
ASCII fallback. No names, amounts or account numbers enter the filename.
- Runtime: Node passes validated row JSON through stdin to the fixed
`fill-payment-order-template.py`; the dedicated Python runtime returns BIFF8
bytes on stdout. `PAYMENT_ORDER_PYTHON_BIN` may select the fixed interpreter,
@@ -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,5 +1,56 @@
# 报销状态空隙与财务处理视角:实施计划
## 2026-09-10 退回删除接续
### 提交准备(未提交)
用户要求继续操作;本轮按官方 Phase 3.4 先准备两笔提交,不将泛化的继续操作视为提交/推送/生产授权。当前 inline 无独立 reviewer,主会话只读复查及原自动验证不冒充独立复核。
1. `fix: 退回报销支持留痕删除(09-04-reimbursement-state-gap-finance-view)`:使用原独立删除包的 27 文件,包含迁移、参与人/状态守卫、历史材料保留、查询过滤、PC/手机入口、测试及对应规约。
2. `fix: 优化报销制单下载与付款进度(09-08-reimbursement-payment-closeout)`:在第一笔树上应用付款体验及集成验收记录;共享 system-action-confirmation.spec.ts、test-invoice-first-http.cjs 按差异追加,不整文件覆盖。包括中文文件名解析及其回归,不重复携带删除运行时代码。
用临时 Git index 构造两笔文件树并比对最终候选,正式 index、HEAD 和分支均不修改;具体文件清单及树摘要见 artifacts/qa-flow/20260910-combined/commit-plan.json。根 checkout 的治理/task 未提交内容全部排除,不自动快进本地 main、不推送、不部署。
发布需重新核对基线、磁盘、备份、独立回滚制品与迁移状态;existing 模式不得执行初始化 seed。本次含删除标记迁移,首次出现留痕删除后旧应用会忽略标记,不能用只回旧镜像作为完整回退方案。最终候选及该回滚限制须随发布方案单独交审,旧权限修复例外不沿用。
合并验收补记:已在独立集成副本按补丁与 09-08 付款体验合并,未覆盖原独立删除副本,原复核包摘要不变。合并后后端 296 项及下载文件名 7 项通过;真实 PostgreSQL/认证/HTTP/桌面/手机联合流程通过并退出 0(含新迁移、退回删除、凭证留存、发票重用、重新提交审批、付款组批/独立复核/中文制单)。合并证据在 artifacts/qa-flow/20260910-combined/real-db-browser.log;OCR 为 mock provider,不是真腾讯新验证,不执行银行付款。独立复核、提交、部署仍未完成。
- 用户要求继续剩余任务,先退回删除、后付款体验;沿既有 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. 启动门
- 用户批准本版 `prd.md`、`design.md`、`implement.md` 后,才运行 `task.py start`。
@@ -2,6 +2,19 @@
## 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 共享“状态空隙必须有明确责任人和可见入口”的产品原则,但必须保持代码边界、测试证据和回滚点独立,不能因一项回滚而被迫撤销另一项。
@@ -2,7 +2,7 @@
"id": "reimbursement-state-gap-finance-view",
"name": "reimbursement-state-gap-finance-view",
"title": "报销状态空隙与财务处理视角",
"description": "修复已通过报销在组批前缺少财务处理入口,以及全范围读取暴露从未提交草稿的问题。",
"description": "修复已通过报销在组批前缺少财务处理入口、草稿读取边界;追加退回报销留痕删除和发票重用。",
"status": "in_progress",
"dev_type": null,
"scope": "server,client,tests,spec",
@@ -21,6 +21,6 @@
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "业务提交:R2=8e0af82,R1=5fe9f78。",
"notes": "历史业务提交:R2=8e0af82,R1=5fe9f78。2026-09-10 退回留痕删除复用本任务,独立副本 20260910-rejected-delete;本地实现与验证完成,待独立复核及另批提交/部署,不继承旧阶段已发布结论。",
"meta": {}
}
@@ -65,3 +65,59 @@ AP task 保留原实施写入面和已通过用户复核。提交/推送后冻
本轮界面修复仅改变已选付款详情的动作优先级,继续使用既有按钮/更多/移动端固定操作区,不重画页面、不调整角色、审批、付款状态机、数据快照或后台接口。保留现金/历史批次人工付款、已有回单直接登记及回单查询失败时不冒充空状态的行为。
报销主体和收款资料应在组批前补齐,因为批次冻结的是当时的账户快照;生成文件不能静默取之后变更的个人账号替换已复核快照。已有批次若需取消/重建,重新交审,不直接更新旧快照。
# 2026-09-09 后续主线综合方案(规划,非部署授权)
## 范围与归属
付款体验沿用本 task;退回单删除/历史可见性归 09-04-reimbursement-state-gap-finance-view;选票后自动生成明细与关联归 08-29-reimbursement-expense-policy-invoice-guard。用户已确认该默认操作取向,本轮按要求更新规划;撤回上一版正常路径的“待关联区→手工建明细→再关联”。验证随对应功能共同交付,不为每个按钮、测试或部署后检查新建 task,不启动 09-07 认证任务的历史批量迁移。各 task PRD 的同日增量是本次需求入口,本文组织跨任务依赖,不覆盖旧阶段事实。
当前生产已发布 b87cc55;用户确认付款复核及文件下载可用,尚无实际支付完成证据。原主工作树含其他未提交治理/状态差异,实施从最新已发布/已推送基线的干净独立副本开始;按官方 task current/start 对齐实际执行目录,已部署副本及冻结包不改。两个报销子项有共享文件,可在同一已批准的报销修复副本协调,但必须有各自验收和可审增量;付款变更单独副本,不能将路径隔离冒充文件不重叠。
## 第一批:报销录入与退回重建
先核实删除事务、workflow 历史、材料关系、占用/去重、查询投影及绑定接口,再完成最小设计补充。界面“删除退回单”,存储层保留历史,优先复用能表达该语义的已有机制;不得假定只有删除按钮变化或无需 schema。若发现必须迁移或实质扩大历史访问/数据修复范围,明确变更清单与回滚后再交审,不偷偷切状态掩盖删除。
退回原单修改重提保持可用;不再使用的单据确认删除后停止流转,解除合法发票的有效占用。软删除/取消历史如何读取、原附件如何保留、财务查询/待办如何排除均列入服务端合同。禁止把“可读历史”误扩成“可继续操作旧单”。撤回变 draft 但有历史的单据要单列,保护原审批留痕。
默认点击“添加发票”打开“我的发票”面板,选择可用旧票或上传新 PDF;新票自动腾讯 OCR,识别成功默认勾选且尊重用户取消。点击“添加到报销单”才触发一票一条可编辑明细并自动关联,多选批量生成;不是 OCR 完成就悄悄创建明细。票面价税合计、开票日期、项目说明作可编辑初值,类型仅可靠时推荐,不确定只补必要字段。用户无需再关联,正常明细仅显示发票数量/预览;手动关联仅保留为异常修复。已有明细入口则直接添加票到指定明细,不生成新明细或覆盖原值。允许提前存票,池和报销内使用同一数据来源。明细生成/票据占用原子完成,失败逐票解释、重试幂等;票据门禁仍严格。费用发生日期不能被开票日期无提示替代,不虚构缺失金额/类型;详细数据兼容与事务方案在发票 task 收敛。
## 第二批:付款下载与详情体验
后端输出中文文件名并使用兼容的 Content-Disposition 编码;前端解析中文 filename*,本地回退名一致,浏览器实测下载名而非只断言 toast。保持 BIFF8 模板、列顺序、格式、金额与批次内容不变,文件名不引入敏感资料。
制单接口单独调整原因合同,不放宽共享 REASON_SCHEMA。系统记录用途来源、操作者、批次、生成序次、成功/失败等必要审计;用户不填理由,不以固定文字假装用户说明。首次确认敏感文件用途,再次确认重复付款风险,权限/状态门禁与防双击保留。取消批次、查看完整账号、回单下载/作废的现有理由策略不在本轮改变。
只将 ProcessingProgress 移到付款页操作卡片下方,复用既有组件/样式,桌面与窄屏验证阅读和键盘顺序。本地未提供 frontend-design skill,不引入新视觉系统;不顺手改报销页壳或进行大文件重构。
## 验证先行,执行端负责
1. 实施前用合成数据复现空单添加发票和退回不能删除的原状,明确测试预期是新业务要求还是既有回归;先有可复现用例,避免按现状写出假绿。
2. 每个改动有单测/路由测试,状态、权限、并发、历史可见性有反例。单测可 mock,但证明业务闭环的端到端测试必须使用真实应用后端、隔离 PostgreSQL、附件存储与合法登录;不 mock 核心业务 API 后声称真实流程通过。
3. 使用专用合成申请人、审批人、财务,按角色分别登录操作。独立复核约束必须保留,不能全程拿 admin 通配抵扣角色验收。合成票据不得含真实银行资料;真实腾讯 OCR 自动识别是交付要求,关闭/误配属于异常,不再作为正常成功分支。需要获准样票和调用条件,缺条件时由执行端准备补齐方案,不能用 mock 或数据库 provider 标记冒充真实识别成功,不擅自改生产密钥或发真实票据。
4. 浏览器必跑空单→我的发票弹窗上传→自动 OCR→选新/旧票→自动生成明细并关联→刷新→核对/补必要项→提交→审批→独立付款复核→下载,以及提前存票、多选票、取消选择、已有明细多票、部分失败/重试/并发、退回修改重提、删除退回→释放发票→新单重用→提交。不允许测试夹具预造明细或手工补一次关联来掩盖默认路径失败。网银付款不自动执行,回单/付款登记仅在完全隔离测试环境使用明确标记的合成资料。
5. 桌面/窄屏核验右侧进度布局,实际下载文件名、BIFF8 结构及重复下载提示;截图/日志脱敏,不能输出真实票据、银行账号或凭证。
6. 涉及状态/权限/数据合同,需只读独立复核;inline 不能派 implement/check 代理,准备明确交审包,不能把主会话复测冒充独立复核。
## 发布与部署后验证合同
每批独立提交、集成验证、冻结候选;提交/推送/部署分别按明确授权执行。两批可以分别交付,不让付款的小优化必须等报销模型改造,但不能积压多批未提交差异。既有完整候选门禁仍有缺口,局部业务链全绿不等于完整候选全绿;发布资格按完整门禁或新的明确例外处理,旧 b87cc55 例外不复用。
上线前先确认可执行的部署后检查方式,不能部署完才发现没有测试账号。执行端负责公网版本/资源、权限、接口及浏览器验证,不把员工的日常业务操作当 QA。
生产写入验证必须先形成并获批测试数据清单:专用账户与角色、测试业务主数据/主体及付款资料、标记、允许接口、预期记录数、通知去向和收尾方式。能保证隔离才执行;专用账号本身不保证隔离,要防止真实审批派给员工、真实通知、预算/报表污染及测试制单流入网银。不改真实账号角色、不拿当前真人单据做删除/重提测试。
若生产无法安全隔离写入,先提供与候选同制品的隔离环境完整验证,再做公网只读/无副作用检查;明确生产写入链未验证,不声称等价,也不让员工补测。缺条件时执行端准备补齐方案,需新增权限或生产写入授权才向用户提出具体请求,不反复索取同范围许可。
每批回执分列:代码/本地单测/真实后端 E2E/独立复核/部署/公网验证/剩余缺口/下一动作。未执行、被环境阻断或失败均不能算通过。测试数据只按获批收尾规则处理,不能为清理测试痕迹物理删除审计或正式单据。
## 暂不扩展
不修改六个 view_all、不启用 R1/合同阶段 B、不逐条修 58 条、不实施通用软删除框架、不重构全部报销页面。统一待办投影是后续主线,本轮只保证本次删除/提交产生的待办正确更新;不以此另造待办引擎。错误豁免 10-05 到期事项沿原任务继续跟踪,不能因本轮反馈被遗忘或统一延期。生产磁盘已清理至足够余量的近期观测只是发布输入,下一次发布仍需检查时效,不追加清理。
# 2026-09-10 付款体验实施补记
- 制单接口使用专用可选原因合同;空对象有效,旧客户端的合法真实原因兼容,非法原因/未知字段仍拒绝。共享必填原因合同不变。
- 成功、重复和生成失败审计标明系统用途及来源;不以固定文本冒充用户原因。权限失败/请求校验失败沿既有位置拒绝,不虚报进入生成审计。
- 文件名使用现有安全 Content-Disposition 工具,UTF-8 中文名和 ASCII fallback;前端缺少响应文件名时使用批次号兜底,模板和二进制生成不变。
- 保留敏感文件确认、重下载警告、取消零请求与 pending 防重复点击。下载次数更新后主动作仍切换上传回单,不表示付款完成。
- 右侧操作卡与进度卡相邻;移动端保留底栏快捷动作且正文操作/进度可达。操作卡不独立 sticky,避免滚动覆盖紧随其后的进度。
- 验证:路由与 BIFF8 单测、mock 桌面/移动端真实下载、隔离 PostgreSQL/真实认证 HTTP 创建复核下载,再与退回删除做同候选验证;禁止真实网银付款和伪造回单。
@@ -1,5 +1,29 @@
# 执行顺序(本地技术审查/修复已批准,真实业务动作另批)
## 2026-09-10 当前执行
### 合并候选验证与发布边界
- 最终页面回归:合并构建产物 preview,Playwright system-action-confirmation.spec.ts 按“财务先制单/制单失败/付款/银行回单/报销”选择,19/19,退出 0;ui-final.log 为最终证据,ui.log 为中断记录。已检查桌面与手机截图:操作卡下接进度,窄屏无横向溢出,底栏动作可达。当前本地实现与联合验证完成,等待独立复核及提交发布授权。
- 本轮独立付款副本基线 e5685f1;与退回删除按补丁合入 artifacts/isolated-worktrees/20260910-reimbursement-payment-integration,没有提交或覆盖主 checkout 的既有改动。
- 合并 focused 后端:23 文件、296/296;另下载文件名解析 7/7。命令以删除 task 的 focused 列表加 paymentOrderXls.test.js 为准;文件名测试为 tests/unit/utils/client-download-filename.test.js。记录见合并副本 artifacts/qa-flow/20260910-combined/unit.log、filename.log。
- 真实隔离执行:RUN_ISOLATED_DB_TEST=1 RUN_RETAINED_DELETE_TEST=1 RUN_BROWSER_TEST=1 RUN_PAYMENT_EXPERIENCE_TEST=1 node scripts/qa/test-invoice-first-http.cjs;BIFF8 使用既有受控测试解释器。real-db-browser.log 为 PASS_REAL_DB_HTTP,退出 0,数据库、合成文件及浏览器完成清理。
- 联合验证涵盖上传选票填明细、退回重提与三步审批、退回留痕删除与票据复用、并发/事务回滚、财务组批、CEO 复核、非授权下载拒绝、桌面/手机真实下载中文 BIFF8、不要求理由、再次下载警告。不执行真实银行付款,OCR 为真实 worker + mock provider,不能称为本轮腾讯 OCR 新验证。
- 前端 typecheck/build、Prisma validate、UI strict/self-test、错误解释 strict、process baseline、task validate、Trellis governance、diff 检查通过。文档 registry errors=0 / warnings=1,为既有 22 个未登记文件,不扩修。
- 最初真实付款 fixture 缺环境功能开关导致 409,修正仅限隔离配置;浏览器实际复现中文名称被旧解析器丢弃,已修产品代码并补七项解析测试。合并页面首轮受 SIGTERM 中断(143),不能视为通过,单独重跑结果另记。
- 保留生产边界:未提交/推送/部署,未触碰真实单据/账户;六个 view_all 与审批角色不变,不能宣称经理只能看相关报销。退回留痕删除含新增迁移,旧镜像可能重新显示留痕删除记录,不能复用“无迁移、只改权限”旧例外。
- 主会话执行本地自审与自动验证;独立只读 reviewer 未执行。下一步先复核两份独立补丁,再按明确授权分别提交、冻结统一候选、重新发布预检和部署后自动验收,不交给员工发现基础缺陷。
联调发现并已修:共享下载客户端只解析 filename,忽略 filename*,导致服务端中文名仍被 ASCII fallback 替代。现优先解析 UTF-8 扩展名;损坏编码、不支持编码、空值回退原 filename。新增 7 项客户端解析回归,已通过。付款路由/BIFF8 93 项通过;最终浏览器和合并验证见本节记录,本地通过不代表已发布。
用户批准本轮报销/付款改动全部完成、合并验证后再部署。付款在独立副本实施,基线 e5685f1,不混入退回删除的迁移。既有历史阶段不代表当前发布状态。
1. 中文制单文件名、无手填理由的敏感下载确认、操作卡下方处理进度。
2. 制单独立请求合同允许省略原因;旧客户端真实填写的原因仍保留,系统用途及来源独立记录,不伪造用户原因。
3. 路由、BIFF8、桌面/移动浏览器定向回归,再与退回删除合并做隔离数据库全链验证。
4. 独立复核、提交和发布尚未完成;生产动作另行授权,不以员工正式业务代测。
## 2026-09-08 获批技术执行
1. 检查 CLI/项目/npm 稳定版本及官方 update dry-run;保留现有定制,稳定通道无新版则不切 beta、不强刷文件。
@@ -144,3 +168,18 @@ T0 是新增代码工作的前置;T1 数据库路径获成功证据后推进 T
## 2026-09-09 提交与推送授权
用户对“AP 与付款修复分两笔提交并推送,不部署、不执行真实恢复、不混入后续准备文档”明确回复“是”。本次只提交本 task 对应已隔离交审文件及当前授权记录;原恢复 task、错误豁免后续阶段和另外两个未跟踪 task 不进入这两笔。实际提交 SHA 与远端结果以 Git 回执为准,不提前宣称 push 成功;生产仍未部署,业务验收状态不随提交改变。
## 2026-09-09 后续工作执行顺序(按已确认选票流程更新规划)
本节为最新规划,旧阶段结果保留;已确认付款独立复核和制单下载,不确认实际付款。当前只更新已有 task 需求与设计,没有创建 task、改代码、运行产品测试或部署。
- [x] 收口用户取向:报销制单中文名;首次/再次不强制手填理由但保留确认与审计;处理进度右移;退回单可以删除后新建;验证由执行端承担。
- [x] 将删除阶段归入 09-04-reimbursement-state-gap-finance-view,将“选票自动生成明细并关联”归入 08-29-reimbursement-expense-policy-invoice-guard,付款优化留本 task;对应 PRD/design/implement 同步。明确替换旧手工关联方案,不新建 task。
- [ ] 综合方案确认后,先为第一批补齐存储/绑定接口设计与隔离端到端夹具,复现两个原始阻断;评估是否涉及迁移,不默认免迁移。
- [ ] 第一批在干净副本实施:退回单删除留痕与发票占用释放、选票自动生成明细并关联及已有明细直接加票;验证所有旧状态的拒绝边界,核实真实腾讯 OCR,异常不交给员工发现。
- [ ] 并行准备部署后专用合成测试条件;缺失项由执行端列出具体配置和授权请求,不调用真人单据或凭据。
- [ ] 第一批完成真实后端角色链、桌面/窄屏验证及只读交审,取得明确提交推送授权后收口;不把另一批产品变更叠入同一脏树。
- [ ] 第二批按已确认付款方案实施中文名、独立下载原因合同和布局;完成文件内容/审计/权限/布局回归及本批交审。若第一批遇真正外部阻塞,第二批可在独立副本提前完成,不扩大范围。
- [ ] 每批冻结候选并提出发布方案,附可执行的部署后自动检查;部署另批,上线后由执行端运行验证、报告失败与回滚建议,必要回滚按具体授权执行。
- [ ] 功能交付后回到统一待办投影;同期维护原错误豁免截止日队列,不批量归档或延期。
首个实施动作明确为“用空报销合成数据复现发票顺序问题,并核查退回删除事务/材料关系”,不是继续生产清理或让 majianyong 再尝试一次。主工作树在旧基线且有治理差异,执行副本须从最新 origin/main 核验建立,按任务登记分批文件清单与测试命令。
@@ -2,6 +2,21 @@
## Goal
### 2026-09-10 本轮批准范围
本轮报销/付款体验全部完成并合并验证后再部署,不等待项目所有存量技术债。付款三项为中文文件名、下载免手填理由、操作卡下方处理进度;退回留痕删除由 09-04 task 独立交付,共同验收。已发布的发票优先录入保留回归,不重复实施。本段覆盖下方历史 R8 的“制单下载必须原因”;不覆盖回单下载、完整账号查看或取消原因策略。真实付款仍由业务人员执行。
### 2026-09-09 真人反馈后的后续验收增量
当前业务事实:用户反馈独立付款复核后,已成功下载银行制单且文件内容没有问题;未据此确认网银付款或回单登记完成。以下付款体验取向已获用户采纳,报销状态与录入改动的综合实施/测试方案见 design.md 最新阶段,待整体方案确认后执行,不继承上次部署授权。
后续确认:用户选择“添加发票→我的发票弹窗→选择旧票/上传并自动 OCR→添加到报销单→自动生成明细并关联”的默认路径。无需再手工建立明细后关联;已有明细入口直接加票,正常页面只展示发票摘要而非关联状态。真实腾讯 OCR 默认可用是业务要求,不是尚待选择是否启用的产品选项;是否配置正确/识别成功仍需执行端验证。此轮授权是更新规划,不表示已实施、已部署或取得新的生产配置/票据发送授权。详细合同归 08-29 发票 task,退回删除与付款三项保持已确认方向。
- 下载文件名统一为“报销制单_批次号.xls”,例如报销制单_PB-20260909-001.xls;保留唯一批次号,不放姓名/金额/账户信息,不改变 BIFF8 内容、工作表、模板和列格式。
- 首次和再次下载均不强制手填用途;敏感文件确认保留,再次下载明确提醒重复导入/重复付款风险。保留权限、已复核状态、独立复核约束及完整下载审计,系统用途不得伪装为用户填写原因;查看完整账号、作废回单、取消批次等理由要求不变。
- 桌面付款详情右侧为操作卡片、处理进度卡片,左侧为概要和单据明细;小屏操作与进度相邻且可达,避免横向溢出。不改流程含义,不将文件下载次数等同银行付款结果。
- 交付需包含相关真实后端端到端验证和部署后验证,不将员工/经理的正式业务操作作为发现基础功能缺陷的默认验收方法。银行实付、真实单据业务判断仍由业务人员负责,代理不得代付或伪造回单。
以业务结果为目标:已审批未付款的 0014 完成付款准备、独立付款复核、按用户模板生成建行制单、银行付款及回单登记;三张退回报销由本人修改重提,实际审批后再进入付款,不让两条业务线互相等待。
2026-09-08 首轮为规划;用户随后明确批准检查并按指南更新 Trellis、多角度审查修订计划,以及完成影响这些业务的技术债务。当前执行范围为本地技术审查、针对性合成验证和已定位阻断的最小修复;不改变既有人工制单/网银流程。未批准生产数据回填、真实账号解密、真实提交/审批/组批/导出/付款、部署或外部通知。此前财务直接经理单字段修正已完成,不重复操作。
@@ -21,6 +21,6 @@
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "用户追加批准Trellis更新检查、多角度计划复核及影响付款业务的本地技术债修复。稳定版已最新,不切beta、不覆盖项目定制。沿用人工下载xls/网银流程,修复首次制单动作优先级并补合成验证;生产数据/真实银行卡解密/实际业务操作/提交推送部署仍另批。",
"notes": "2026-09-10:代码已实现付款中文文件名、下载免手填理由、右侧处理进度及共享UTF-8文件名解析;与09-04退回留痕删除按独立补丁完成本地联合验证(296+7单测、19页面回归、真实隔离DB/HTTP/双端),不混提交。发布未执行;业务仅确认用户已成功下载旧版制单,银行实付/回单未代办。当前等待独立只读复核及明确提交发布授权;不让员工代测。详见implement.md本轮证据。",
"meta": {}
}
+9
查看文件
@@ -165,6 +165,15 @@ function parseFilenameFromContentDisposition(headerValue: string | null): string
}
const parts = headerValue.split(";").map((part) => part.trim());
const encodedPart = parts.find((part) => part.toLowerCase().startsWith("filename*="));
const encodedValue = encodedPart?.slice("filename*=".length).trim().replace(/^"|"$/g, "");
const utf8Value = encodedValue?.match(/^UTF-8'[^']*'(.*)$/i);
if (utf8Value) {
try {
const decoded = decodeURIComponent(utf8Value[1]);
if (decoded) return decoded;
} catch {}
}
const filenamePart = parts.find((part) => part.toLowerCase().startsWith("filename="));
if (!filenamePart) {
return null;
+2 -2
查看文件
@@ -215,11 +215,11 @@ export async function cancelPaymentBatch(batchId: string, reason: string) {
});
}
export async function downloadPaymentOrder(batchId: string, reason: string): Promise<ApiBlobResponse> {
export async function downloadPaymentOrder(batchId: string): Promise<ApiBlobResponse> {
return apiRequestBlob(`/api/payment-batches/${batchId}/payment-order`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reason }),
body: JSON.stringify({}),
});
}
+1
查看文件
@@ -128,6 +128,7 @@ export type ReimbursementApprovalHistoryItem = {
};
export type ReimbursementDetail = {
deletedAt?: string | null;
id: string;
formNumber: string | null;
submitterId: string;
@@ -30,6 +30,7 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
rejected: { label: "已驳回", className: "badge-error" },
ocr_failed: { label: "识别失败", className: "badge-error" },
cancelled: { label: "已取消", className: "badge-neutral" },
deleted: { label: "已删除", className: "badge-neutral" },
archived: { label: "已归档", className: "badge-neutral" },
skipped: { label: "已跳过", className: "badge-neutral" },
revoked: { label: "已撤销", className: "badge-neutral" },
@@ -208,7 +208,7 @@ export function MobileReimbursementDetailPage() {
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
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 reimbursementPrecheckQuery = useQuery({
@@ -389,7 +389,7 @@ export function MobileReimbursementDetailPage() {
mutationFn: () => deleteReimbursement(reimbursementId),
onSuccess: async () => {
await invalidateDetail();
toast.success("草稿已删除");
toast.success("报销单已删除");
navigate("/mobile/reimbursements");
},
onError: (error) => {
@@ -938,6 +938,7 @@ export function MobileReimbursementDetailPage() {
};
const workspaceHeaderDetails = (
<>
{detail.deletedAt ? <p role="status">已删除,仅供查看历史记录</p> : 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}
{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}
<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;
@@ -986,7 +987,7 @@ export function MobileReimbursementDetailPage() {
return (
<>
{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}>
<DialogContent
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
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title="删除草稿?"
description="删除后无法恢复,已添加发票会回到可添加状态。"
title="删除报销单?"
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
variant="destructive"
confirmLabel="删除草稿"
confirmLabel="删除报销单"
onConfirm={() => deleteReimbursementMutation.mutate()}
/>
@@ -136,7 +136,7 @@ function ReimbursementCard({
type="button"
variant="destructive"
>
删除草稿
删除报销单
</Button>
</div>
</details>
@@ -240,7 +240,7 @@ export function MobileReimbursementsPage() {
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
]);
setDeleteTarget(null);
toast.success("草稿已删除");
toast.success("报销单已删除");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
@@ -358,7 +358,7 @@ export function MobileReimbursementsPage() {
) : null}
{visibleItems.map((item) => (
<ReimbursementCard
canDeleteDraft={canDeleteExpense && scope === "own" && item.status === "draft"}
canDeleteDraft={canDeleteExpense && scope === "own" && ["draft", "rejected"].includes(item.status)}
currentMobileSource={currentMobileSource}
isDeleting={deleteMutation.isPending}
item={item}
@@ -376,10 +376,10 @@ export function MobileReimbursementsPage() {
setDeleteTarget(null);
}
}}
title="删除草稿?"
description="删除后无法恢复,已添加发票会回到可用状态。"
title="删除报销单?"
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
variant="destructive"
confirmLabel="删除草稿"
confirmLabel="删除报销单"
onConfirm={async () => {
if (!deleteTarget) {
return;
+20 -20
查看文件
@@ -561,10 +561,10 @@ export function PaymentBatchesPage() {
});
const paymentOrderMutation = useMutation({
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) =>
downloadPaymentOrder(batchId, reason),
onSuccess: async (response) => {
downloadBlob(response.blob, response.filename ?? "payment-order.xls");
mutationFn: ({ batchId }: { batchId: string; batchNo: string }) =>
downloadPaymentOrder(batchId),
onSuccess: async (response, variables) => {
downloadBlob(response.blob, response.filename ?? `报销制单_${variables.batchNo.replace(/[^A-Za-z0-9-]/g, "_")}.xls`);
setReasonAction(null);
setActionReason("");
await refresh();
@@ -864,15 +864,6 @@ export function PaymentBatchesPage() {
) : null}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-processing-panel">
<h3 className="text-base font-semibold text-zinc-900">处理进度</h3>
<ProcessingProgress
progress={selectedBatch.processingProgress}
ariaLabel="付款批次处理进度"
testId="payment-batch-processing-progress"
/>
</div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
<h3 className="text-base font-semibold text-zinc-900">
{selectedBatch.entityType === "reimbursement" ? "本批次报销单" : "本批次差旅订单"}
@@ -1080,8 +1071,9 @@ export function PaymentBatchesPage() {
</div>
</BusinessDetailMain>
<BusinessDetailAside className="hidden lg:block">
<BusinessDetailAside>
<BusinessDetailActionPanel
className="lg:static"
status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />}
primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined}
moreActions={paymentActionModel.more.length ? (
@@ -1102,6 +1094,14 @@ export function PaymentBatchesPage() {
)}
testId="payment-batch-action-panel"
/>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-processing-panel">
<h3 className="text-base font-semibold text-zinc-900">处理进度</h3>
<ProcessingProgress
progress={selectedBatch.processingProgress}
ariaLabel="付款批次处理进度"
testId="payment-batch-processing-progress"
/>
</div>
</BusinessDetailAside>
</BusinessDetailWorkspace>
@@ -1316,20 +1316,20 @@ export function PaymentBatchesPage() {
</DialogHeader>
{reasonAction?.kind === "payeeBank" && revealedPayeeAccount ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedPayeeAccount}</div>
) : <div className="mt-4 space-y-2">
) : reasonAction?.kind !== "paymentOrder" ? <div className="mt-4 space-y-2">
<Textarea
value={actionReason}
onChange={(event) => setActionReason(event.target.value)}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "paymentOrder" ? "制单下载原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
rows={4}
maxLength={500}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "paymentOrder" ? "说明本次制单用途" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
/>
<div className="flex items-center justify-between text-xs text-zinc-500">
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
<span>{actionReason.trim().length}/500</span>
</div>
</div>}
</div> : null}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => {
setReasonAction(null);
@@ -1341,7 +1341,7 @@ export function PaymentBatchesPage() {
type="button"
variant={reasonAction?.kind === "paymentOrder" ? "default" : "destructive"}
disabled={
actionReason.trim().length < 2 ||
(reasonAction?.kind !== "paymentOrder" && actionReason.trim().length < 2) ||
cancelMutation.isPending ||
voidReceiptMutation.isPending ||
paymentOrderMutation.isPending ||
@@ -1353,7 +1353,7 @@ export function PaymentBatchesPage() {
if (reasonAction.kind === "cancel") {
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "paymentOrder") {
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, reason });
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, batchNo: reasonAction.batchNo });
} else if (reasonAction.kind === "payeeBank") {
revealPayeeBankMutation.mutate({ batchId: reasonAction.batchId, itemId: reasonAction.itemId, reason });
} else {
+9 -7
查看文件
@@ -351,11 +351,11 @@ export function ReimbursementDetailPage() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
toast.success("草稿已删除");
toast.success("报销单已删除");
navigate("/reimbursements?status=draft");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "删除草稿失败");
const message = buildActionErrorMessage(error, "删除报销单失败");
setActionError(message);
toast.error(message);
},
@@ -782,7 +782,8 @@ export function ReimbursementDetailPage() {
canViewOwnInvoice;
const canUnbindInvoices = canMaintainReimbursement;
const canDeleteReimbursement =
detail.status === "draft" &&
["draft", "rejected"].includes(detail.status) &&
!detail.deletedAt &&
canDeleteExpense &&
isReimbursementParticipant;
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
@@ -939,6 +940,7 @@ export function ReimbursementDetailPage() {
stage.state === "current" || stage.state === "exception",
);
const nextStepText = (() => {
if (detail.deletedAt) return "已删除,仅供查看历史记录";
if (["draft", "rejected"].includes(detail.status)) {
if (!isReimbursementParticipant) {
return detail.status === "rejected" ? "等待报销人修改后重提" : "等待报销人完善";
@@ -1107,7 +1109,7 @@ export function ReimbursementDetailPage() {
</>
);
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 })} />
</BusinessDetailActionPanel>
);
@@ -1115,7 +1117,7 @@ export function ReimbursementDetailPage() {
return (
<>
{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}>
<DialogContent
className="max-h-[85vh] max-w-3xl overflow-y-auto"
@@ -1369,9 +1371,9 @@ export function ReimbursementDetailPage() {
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title={`确认删除 ${displayCode}?`}
description="删除后无法恢复,已添加发票会回到可添加状态。"
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
variant="destructive"
confirmLabel="删除草稿"
confirmLabel="删除报销单"
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
/>
<AttachmentDeleteConfirmDialog
+1 -1
查看文件
@@ -156,7 +156,7 @@ export function ReimbursementPrintPage() {
<div className="grid gap-3 text-sm">
<div className="flex justify-between gap-4">
<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 className="flex justify-between gap-4">
<span className="text-zinc-500">提交人</span>
+6 -6
查看文件
@@ -240,10 +240,10 @@ export function ReimbursementsPage() {
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
toast.success("草稿已删除");
toast.success("报销单已删除");
},
onError: (error) => {
toast.error(resolveErrorMessage(error, "删除草稿失败"));
toast.error(resolveErrorMessage(error, "删除报销单失败"));
},
});
@@ -502,7 +502,7 @@ export function ReimbursementsPage() {
items.map((item) => {
const listAction = resolveListAction(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);
return (
@@ -615,7 +615,7 @@ export function ReimbursementsPage() {
items.map((item) => {
const listAction = resolveListAction(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);
return (
@@ -684,7 +684,7 @@ export function ReimbursementsPage() {
className="text-red-600"
onSelect={() => handleDeleteDraft(item)}
>
删除草稿
删除报销单
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -732,7 +732,7 @@ export function ReimbursementsPage() {
}}
kind="delete"
targetName={deleteTarget ? resolveReimbursementDisplayCode(deleteTarget) : undefined}
description="删除后无法恢复,已提交或审批中的报销单不会受到影响。"
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
onConfirm={async () => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget.id);
@@ -559,7 +559,7 @@ async function mockSystemActionApis(
}
if (!options.paymentOrderFile) throw new Error("合成制单文件未配置");
paymentOrderExportCount += 1;
await route.fulfill({ status: 200, contentType: "application/vnd.ms-excel", headers: { "content-disposition": "attachment; filename=payment-order-PAY-ACTION-001.xls" }, body: options.paymentOrderFile });
await route.fulfill({ status: 200, contentType: "application/vnd.ms-excel", headers: { "content-disposition": `attachment; filename=payment-order-PAY-ACTION-001.xls; filename*=UTF-8''${encodeURIComponent("报销制单_PAY-ACTION-001.xls")}` }, body: options.paymentOrderFile });
return;
}
@@ -658,15 +658,15 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click();
await page.getByRole("menuitem", { name: "删除草稿" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
await page.getByRole("menuitem", { name: "删除报销单" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("已发生的审批记录仍保留备查");
expect(calls.deleteReimbursement).toBe(0);
await page.getByTestId("action-confirm-cancel").click();
expect(calls.deleteReimbursement).toBe(0);
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 expect.poll(() => calls.deleteReimbursement).toBe(1);
@@ -698,7 +698,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-more-toggle").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);
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => deleteCalls.deleteReimbursement).toBe(1);
@@ -838,18 +838,29 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel");
await expect(actions.getByTestId("payment-batch-action-downloadOrder")).toBeVisible();
const sideActions = page.getByTestId("payment-batch-action-panel");
const progress = page.getByTestId("payment-batch-processing-panel");
await expect(progress).toBeVisible();
expect(await progress.evaluate((element) => Boolean(element.previousElementSibling?.querySelector('[data-testid="payment-batch-action-panel"]')))).toBe(true);
const actionBounds = await sideActions.boundingBox();
const progressBounds = await progress.boundingBox();
expect(actionBounds).not.toBeNull();
expect(progressBounds).not.toBeNull();
expect(progressBounds!.y).toBeGreaterThanOrEqual(actionBounds!.y + actionBounds!.height);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath("before-payment-order.png"), fullPage: true });
await actions.getByTestId("payment-batch-action-downloadOrder").click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "确认下载" })).toBeDisabled();
await expect(dialog.getByRole("button", { name: "确认下载" })).toBeEnabled();
await expect(dialog.getByRole("textbox")).toHaveCount(0);
await dialog.getByRole("button", { name: "返回", exact: true }).click();
expect(calls.paymentOrderReasons).toEqual([]);
await actions.getByTestId("payment-batch-action-downloadOrder").click();
await dialog.getByRole("textbox", { name: "制单下载原因" }).fill("合成网银制单验证");
await expect(dialog).toContainText("文件包含完整银行账号");
const firstDownload = page.waitForEvent("download");
await dialog.getByRole("button", { name: "确认下载" }).click();
const downloaded = await firstDownload;
expect(downloaded.suggestedFilename()).toBe("payment-order-PAY-ACTION-001.xls");
expect(downloaded.suggestedFilename()).toBe("报销制单_PAY-ACTION-001.xls");
const downloadedPath = await downloaded.path();
if (!downloadedPath) throw new Error("未收到合成制单文件");
expect(readFileSync(downloadedPath).equals(paymentOrderFile)).toBe(true);
@@ -862,12 +873,12 @@ test.describe("R7E 全站高风险操作二次确认", () => {
if (mobile) await page.getByRole("menuitem", { name: "重新下载制单" }).click();
else await actions.getByTestId("payment-batch-action-redownloadOrder").click();
await expect(dialog).toContainText("请勿重复提交");
await dialog.getByRole("textbox", { name: "制单下载原因" }).fill("合成文件重新下载验证");
await expect(dialog.getByRole("textbox")).toHaveCount(0);
const secondDownload = page.waitForEvent("download");
await dialog.getByRole("button", { name: "确认下载" }).click();
await secondDownload;
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("已生成 2 次");
expect(calls.paymentOrderReasons).toEqual(["合成网银制单验证", "合成文件重新下载验证"]);
expect(calls.paymentOrderReasons).toEqual([undefined, undefined]);
expect(calls.executePaymentBatch).toBe(0);
expect(calls.uploadPaymentReceipt).toBe(0);
});
@@ -880,7 +891,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await page.getByTestId("payment-batch-action-panel").getByTestId("payment-batch-action-downloadOrder").click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox", { name: "制单下载原因" }).fill("合成制单失败验证");
await expect(dialog.getByRole("textbox")).toHaveCount(0);
await dialog.getByRole("button", { name: "确认下载" }).click();
await expect.poll(() => calls.paymentOrderReasons.length).toBe(1);
await expect(dialog.getByRole("button", { name: "确认下载" })).toBeEnabled();
@@ -906,9 +917,9 @@ test.describe("R7E 全站高风险操作二次确认", () => {
].join(",")).evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(sectionOrder).toEqual([
"payment-batch-detail-panel",
"payment-batch-processing-panel",
"payment-batch-items-panel",
"payment-batch-receipt-panel",
"payment-batch-processing-panel",
]);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("¥88.00");
@@ -921,7 +932,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
};
});
expect(responsiveContract.gridColumns.split(" ").filter(Boolean)).toHaveLength(2);
expect(responsiveContract.actionPosition).toBe("sticky");
expect(responsiveContract.actionPosition).toBe("static");
});
test("付款详情展示费用明细并支持回单在线查看,下载仍要求用途", async ({ page }) => {
+8
查看文件
@@ -17,6 +17,14 @@ R5 公网验收后确认:正式发票/OCR 上传入口只接受 PDF 发票文
- 报销审批必须在 OA 内完成,企业微信只发送提醒和 deeplink,不作为报销审批流或财务附件主库。
- 财务台账 V1 只做付款准备、付款批次、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要;真实付款继续在线下银行或既有财务流程完成,OA 不做银行直连、银行流水自动导入、ERP/总账、凭证生成、自动对账或税务申报。
## 退回后的修改与删除
- 退回单优先直接修改后重新提交,不要求重新录入已有内容。
- 报销人或合法提交人在有删除权限时,可删除草稿及退回单;界面只做一次后果确认,不要求填写理由。审批中、已批准、付款处理中、已付款不能通过此入口删除。
- 从未提交的草稿沿用删除;有流程历史的草稿及退回单删除后从日常列表移除,但保留单据、明细、审批历史、附件及删除审计。旧链接只对原有权人员展示只读历史,不增加读取权限。
- 删除时解除本单有效发票占用,符合条件的票据可用于新单;旧单保留当时关联票据的 PDF 凭证引用及摘要,不跟随新单修改。此前已经释放且缺少关联证据的历史票据不猜测补回。
- 删除不代表撤销付款,也不删除真实发票原件。该能力须随对应候选发布才在生产生效。
## 发票状态
发票主要状态:
+16 -3
查看文件
@@ -85,7 +85,7 @@ async function main() {
await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" });
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
const envelope = (data) => ({ requestId: "synthetic-http", data });
app.get("/api/me", { preHandler: app.authenticate }, async () => envelope({ id: employee.id, employeeNo: employee.employee_no, name: "synthetic", email: employee.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: ["employee"], notificationPushEnabled: false }));
app.get("/api/me", { preHandler: app.authenticate }, async (request) => envelope({ id: request.employee.id, employeeNo: request.employee.employee_no, name: "synthetic", email: request.employee.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: request.employee.roleCodes, notificationPushEnabled: false }));
app.get("/api/system/navigation", async () => envelope({ workspace: { id: "synthetic", profile: "small_team_internal", orgMode: "single_company", moduleVisibility: {} }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [] }));
app.get("/api/notifications", async () => envelope({ items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 }));
await app.register(serverRequire("./src/routes/expenseTypes"), { prefix: "/api" });
@@ -93,6 +93,9 @@ async function main() {
await app.register(serverRequire("./src/routes/invoicePool"), { prefix: "/api" });
await app.register(serverRequire("./src/routes/reimbursements"), { prefix: "/api" });
await app.register(serverRequire("./src/routes/workflow"), { prefix: "/api" });
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
await app.register(serverRequire("./src/routes/paymentBatches"), { prefix: "/api" });
}
if (process.env.RUN_JOB_QUEUE === "1") {
Object.assign(app.config, { jobDriver: "pgboss", pgBossSchema: "synthetic_jobs", ocrMaxAttempts: 3, ocrPollCron: "0 0 1 1 *", delegationRevokeCron: "0 0 1 1 *", invoiceArchiveCron: "0 0 1 1 *", integrationOutboxCron: "0 0 1 1 *" });
await serverRequire("./src/jobs/pgBossJobs").startPgBossJobs(app);
@@ -283,6 +286,9 @@ async function main() {
assert.equal(await prisma.workflowTask.count({ where: { instance_id: newWorkflowId, status: "pending" } }), 0);
assert.equal((await prisma.workflowInstance.findUnique({ where: { id: newWorkflowId } })).current_status, "approved");
passed.push("owner cannot self-approve; return/edit/resubmit and manager/ceo/finance approval complete without payment");
if (process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1") {
await require("./test-payment-experience.cjs")({ app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword });
}
const competingForms = await Promise.all([makeForm(), makeForm()]);
const sharedInvoice = await makeInvoice();
@@ -353,10 +359,17 @@ async function main() {
const simultaneous = await Promise.all([retryRequest, unbindRequest]);
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");
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 } } });
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");
console.log(JSON.stringify({ status: "PASS_REAL_DB_HTTP", passed, authentication: "REAL_PASSWORD_LOGIN_JWT_SYNTHETIC_ACCOUNTS", liveOcr: "NOT_TESTED", ocrWorker: process.env.RUN_JOB_QUEUE === "1" ? "REAL_PGBOSS_MOCK_PROVIDER" : process.env.RUN_BROWSER_TEST === "1" ? "REAL_WORKER_MOCK_PROVIDER_MANUAL_DISPATCH" : "NOT_TESTED", approvalFlow: "RETURN_RESUBMIT_THREE_STEP_APPROVAL", payment: "NOT_TESTED" }, null, 2));
console.log(JSON.stringify({ status: "PASS_REAL_DB_HTTP", passed, authentication: "REAL_PASSWORD_LOGIN_JWT_SYNTHETIC_ACCOUNTS", liveOcr: "NOT_TESTED", ocrWorker: process.env.RUN_JOB_QUEUE === "1" ? "REAL_PGBOSS_MOCK_PROVIDER" : process.env.RUN_BROWSER_TEST === "1" ? "REAL_WORKER_MOCK_PROVIDER_MANUAL_DISPATCH" : "NOT_TESTED", approvalFlow: "RETURN_RESUBMIT_THREE_STEP_APPROVAL", payment: process.env.RUN_PAYMENT_EXPERIENCE_TEST === "1" ? "CREATE_REVIEW_DOWNLOAD_STILL_UNPAID" : "NOT_TESTED" }, null, 2));
} finally {
if (browser) await browser.close().catch(() => { process.exitCode = 1; console.error("Browser cleanup failed"); });
if (preview) await new Promise((resolve, reject) => preview.httpServer.close((error) => error ? reject(error) : resolve())).catch(() => { process.exitCode = 1; console.error("Preview cleanup failed"); });
@@ -374,4 +387,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; });
+97
查看文件
@@ -0,0 +1,97 @@
const assert = require("node:assert/strict");
const { randomBytes } = require("node:crypto");
const { readFileSync } = require("node:fs");
const { createRequire } = require("node:module");
const path = require("node:path");
module.exports = async function verifyPaymentExperience({
app, prisma, employee, approvers, form, post, origin, tokens, serverRequire, passed, browser, preview, loginPassword,
}) {
app.config.fieldEncryptionKey = randomBytes(32).toString("base64");
app.config.paymentBatchEnabled = true;
const { encryptStringOrThrow } = serverRequire("./src/services/crypto");
const company = await prisma.legalEntity.create({ data: { code: "SYN-PAY", name: "合成测试主体" } });
const bank = await prisma.bankInstitution.upsert({
where: { code: "CCB" }, create: { code: "CCB", name: "中国建设银行" }, update: {},
});
await prisma.employee.update({ where: { id: employee.id }, data: {
legal_entity_id: company.id, bank_institution_id: bank.id, bank_name: bank.name,
bank_account_encrypted: encryptStringOrThrow(app.config, `6222${"0".repeat(8)}5678`),
} });
await prisma.paymentAccount.create({ data: {
legal_entity_id: company.id, bank_institution_id: bank.id, account_name: "合成测试主体",
account_number_encrypted: encryptStringOrThrow(app.config, `6217${"0".repeat(8)}1234`),
} });
await prisma.featureFlag.create({ data: { flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } });
const created = await post("/payment-batches", {
entityType: "reimbursement", entityIds: [form.id], defaultPaymentChannel: "bank",
}, approvers.finance.id);
assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`);
const batch = created.body.data.item;
const download = (actor) => fetch(`${origin}/api/payment-batches/${batch.id}/payment-order`, {
method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${tokens.get(actor.id)}` },
body: "{}", signal: AbortSignal.timeout(15000),
});
assert.equal((await download(approvers.finance)).status, 409);
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.finance.id)).status, 403);
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.ceo.id)).status, 200);
assert.equal((await download(approvers.ceo)).status, 403);
assert.equal((await download(employee)).status, 403);
for (const count of [1, 2]) {
const response = await download(approvers.finance);
assert.equal(response.status, 200);
assert.ok(decodeURIComponent(response.headers.get("content-disposition")).includes(`报销制单_${batch.batchNo}.xls`));
assert.equal(response.headers.get("cache-control"), "no-store");
const workbook = Buffer.from(await response.arrayBuffer());
assert.equal(workbook.subarray(0, 8).toString("hex"), "d0cf11e0a1b11ae1");
const current = await prisma.paymentBatch.findUnique({ where: { id: batch.id } });
assert.equal(current.payment_order_export_count, count);
assert.equal(current.status, "reviewed");
const source = await prisma.reimbursementForm.findUnique({ where: { id: form.id } });
assert.equal(source.status, "approved");
assert.equal(source.paid_at, null);
}
const audits = await prisma.auditLog.findMany({ where: { target_id: batch.id, action: { in: ["PAYMENT_ORDER_EXPORTED", "PAYMENT_ORDER_REEXPORTED"] } } });
assert.equal(audits.length, 2);
for (const audit of audits) {
assert.equal(audit.extra.purpose, "reimbursement_bank_transfer");
assert.equal(audit.extra.purposeSource, "system");
assert.equal(Object.hasOwn(audit.extra, "reason"), false);
}
if (browser && preview) {
const clientRequire = createRequire(path.resolve(__dirname, "../../client/package.json"));
const { expect } = clientRequire("@playwright/test");
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
for (const mobile of [false, true]) {
const context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
try {
const fingerprint = `synthetic-payment-${mobile}`;
await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
const login = await context.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: approvers.finance.email, password: loginPassword } });
assert.equal(login.status(), 200);
const page = await context.newPage();
await page.goto(`${uiOrigin}/payment-batches?id=${batch.id}`);
const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel");
await expect(actions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await actions.getByText("更多", { exact: true }).click();
if (mobile) await page.getByRole("menuitem", { name: "重新下载制单" }).click();
else await actions.getByTestId("payment-batch-action-redownloadOrder").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toContainText("请勿重复提交");
await expect(dialog.getByRole("textbox")).toHaveCount(0);
const received = page.waitForEvent("download");
await dialog.getByRole("button", { name: "确认下载" }).click();
const downloaded = await received;
assert.equal(downloaded.suggestedFilename(), `报销制单_${batch.batchNo}.xls`);
assert.equal(readFileSync(await downloaded.path()).subarray(0, 8).toString("hex"), "d0cf11e0a1b11ae1");
await expect(dialog).toBeHidden();
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
} finally {
await context.close();
}
}
passed.push("payment: real authenticated desktop/mobile browser downloads Chinese BIFF8 with no reason input; repeat warning and receipt action retained");
}
passed.push("payment: real finance create, CEO review, unauthorized export refused, Chinese BIFF8 first/re-download without reason, audited and still unpaid");
};
@@ -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");
+5
查看文件
@@ -575,6 +575,7 @@ model Department {
}
model Employee {
deleted_reimbursements ReimbursementForm[] @relation("ReimbursementDeletedBy")
id String @id @default(uuid()) @db.Uuid
employee_no String @unique @default(dbgenerated("generate_employee_number()"))
name String
@@ -1441,6 +1442,9 @@ model InventoryCheckItem {
}
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
form_number String? @unique
submitter_id String @db.Uuid
@@ -1491,6 +1495,7 @@ model ReimbursementForm {
@@index([status])
@@index([reimbursement_date])
@@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")
}
+11 -4
查看文件
@@ -87,6 +87,8 @@ const REASON_SCHEMA = z.object({
reason: z.string().trim().min(2).max(500),
}).strict();
const PAYMENT_ORDER_SCHEMA = REASON_SCHEMA.partial();
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
mode: z.enum(["download", "preview"]).default("download"),
reason: z.string().max(500).optional(),
@@ -1642,7 +1644,12 @@ async function paymentBatchesRoutes(fastify) {
await ensureFeatureEnabled(fastify);
ensurePaymentOrderExportPermission(fastify, request);
const batchId = parseId(request.params?.id);
const { reason } = parseBody(REASON_SCHEMA, request.body);
const { reason } = parseBody(PAYMENT_ORDER_SCHEMA, request.body ?? {});
const downloadContext = {
purpose: "reimbursement_bank_transfer",
purposeSource: "system",
...(reason === undefined ? {} : { reason }),
};
let result;
try {
@@ -1675,7 +1682,7 @@ async function paymentBatchesRoutes(fastify) {
batchNo: batch.batch_no,
rowCount: generated.rowCount,
totalAmount: generated.totalAmount,
reason,
...downloadContext,
exportCount,
},
}),
@@ -1698,7 +1705,7 @@ async function paymentBatchesRoutes(fastify) {
action: "PAYMENT_ORDER_EXPORT_BLOCKED",
targetType: "payment_batch",
targetId: batchId,
extra: { reason, resultCode },
extra: { ...downloadContext, resultCode },
}),
}).catch((auditError) => {
fastify.log.error({ err: auditError, requestId: request.id }, "payment order block audit failed");
@@ -1716,7 +1723,7 @@ async function paymentBatchesRoutes(fastify) {
const safeBatchNo = String(result.batchNo).replace(/[^A-Za-z0-9-]/g, "_");
reply.header("content-type", "application/vnd.ms-excel");
reply.header("content-disposition", `attachment; filename="payment-order-${safeBatchNo}.xls"`);
reply.header("content-disposition", buildAttachmentContentDisposition(`报销制单_${safeBatchNo}.xls`));
reply.header("cache-control", "no-store");
reply.header("x-content-type-options", "nosniff");
return reply.send(result.buffer);
+1 -1
查看文件
@@ -502,7 +502,7 @@ async function portalRoutes(fastify) {
teamStatusBoard,
] = await Promise.all([
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.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
roleCapabilities.canUseFinance
+1 -1
查看文件
@@ -842,7 +842,7 @@ async function projectsRoutes(fastify) {
const projectId = parseId(request.params?.id);
await ensureReadableProject(fastify, request, projectId);
const items = await fastify.prisma.reimbursementForm.findMany({
where: { project_id: projectId },
where: { project_id: projectId, deleted_at: null },
orderBy: { updated_at: "desc" },
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 {
stages: [
stageByKey.get("submitted"),
...(approvalStages.length > 0
? approvalStages
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
stageByKey.get("payment_batch_created"),
stageByKey.get("payment_batch_review"),
stageByKey.get("payment_and_receipt"),
],
};
const stages = [
stageByKey.get("submitted"),
...(approvalStages.length > 0
? approvalStages
: [stageByKey.get("manager_approval"), stageByKey.get("finance_review")]),
stageByKey.get("payment_batch_created"),
stageByKey.get("payment_batch_review"),
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) {
@@ -812,7 +831,7 @@ function buildReimbursementStatsConditions({
formAlias = "rf",
ownerAlias = "owner_employee",
}) {
const conditions = [Prisma.sql`1 = 1`];
const conditions = [Prisma.sql`${sqlIdentifier(formAlias, "deleted_at")} IS NULL`];
if (dateFrom) {
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "reimbursement_date")} >= ${dateFrom}`);
@@ -2266,6 +2285,7 @@ async function reimbursementRoutes(fastify) {
owner_id: true,
department_id: true,
created_at: true,
deleted_at: true,
reimbursement_date: true,
total_amount: true,
status: true,
@@ -2604,6 +2624,7 @@ async function reimbursementRoutes(fastify) {
: null,
createdAt: reimbursement.created_at?.toISOString?.() ?? null,
submittedAt: latestWorkflowInstance?.created_at?.toISOString?.() ?? null,
deletedAt: reimbursement.deleted_at?.toISOString?.() ?? null,
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
@@ -2797,7 +2818,7 @@ async function reimbursementRoutes(fastify) {
const result = await fastify.prisma.$transaction(async (tx) => {
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"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
@@ -2814,10 +2835,61 @@ async function reimbursementRoutes(fastify) {
const reimbursement = reimbursementRows[0];
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);
}
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({
where: {
linked_reimbursement_id: reimbursementId,
@@ -2831,8 +2903,35 @@ async function reimbursementRoutes(fastify) {
},
});
await tx.reimbursementForm.delete({
where: { id: reimbursementId },
if (retainHistory) {
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 {
@@ -2956,42 +3055,54 @@ async function reimbursementRoutes(fastify) {
contentType: file.mimeType,
});
const created = await fastify.prisma.reimbursementAttachment.create({
data: {
reimbursement_id: reimbursementId,
storage_path: storagePath,
original_name: file.originalName,
mime_type: file.mimeType,
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(governanceFields, governanceDefaults),
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: 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,
const created = await fastify.prisma.$transaction(async (tx) => {
const currentRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid FOR UPDATE
`;
const current = currentRows[0];
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
ensureReimbursementParticipant(request, current);
if (!["draft", "rejected"].includes(current.status)) {
throw buildBusinessValidationError("reimbursement.status_invalid_attachment_edit_status", { status: "invalid_attachment_edit_status" }, 409);
}
return tx.reimbursementAttachment.create({
data: {
reimbursement_id: reimbursementId,
storage_path: storagePath,
original_name: file.originalName,
mime_type: file.mimeType,
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
...governanceCreateData(governanceFields, governanceDefaults),
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
reviewed_by: 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);
}
@@ -4565,9 +4676,15 @@ async function reimbursementRoutes(fastify) {
if (!["draft", "rejected"].includes(current.status)) {
throw buildBusinessValidationError("reimbursement.reimbursement_invalid_status_for_project_link", { reimbursement: "invalid_status_for_project_link" }, 409);
}
const updated = await fastify.prisma.reimbursementForm.update({
where: { id: reimbursementId },
const changed = await fastify.prisma.reimbursementForm.updateMany({
where: { id: reimbursementId, deleted_at: null, status: { in: ["draft", "rejected"] } },
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 } } },
});
return sendData(reply, request, {
+1 -1
查看文件
@@ -38,7 +38,7 @@ function dateRange(dateFrom, dateTo) {
}
function buildFinanceLedgerWhere(filters = {}) {
const clauses = [];
const clauses = [{ deleted_at: null }];
const query = filters.q?.trim();
if (filters.status) {
@@ -47,11 +47,11 @@ function buildReadableReimbursementWhere({ employee, scope = "all", filters = {}
: buildViewAllReimbursementScopeWhere(employee);
if (scope === "own") {
return { AND: [scopeWhere, filters] };
return { AND: [scopeWhere, filters, { deleted_at: null }] };
}
return {
AND: [scopeWhere, buildReimbursementBusinessFactWhere(employee.id), filters],
AND: [scopeWhere, buildReimbursementBusinessFactWhere(employee.id), filters, { deleted_at: null }],
};
}
@@ -2559,7 +2559,7 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("exports a reviewed CCB batch as BIFF8 and records the first export", async () => {
it.each([{}, { reason: "导入企业网银" }])("exports BIFF8 without requiring a reason and preserves legacy input: %j", async (payload) => {
const payerEncrypted = encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
`6217${"0".repeat(8)}1234`,
@@ -2603,14 +2603,28 @@ describe("payment batch reimbursement routes", () => {
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: { reason: "导入企业网银" },
payload,
});
expect(response.statusCode).toBe(200);
expect(response.rawPayload.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
expect(response.headers["cache-control"]).toBe("no-store");
expect(decodeURIComponent(response.headers["content-disposition"])).toContain("报销制单_");
expect(state.batch.payment_order_export_count).toBe(1);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED", extra: expect.objectContaining({ reason: "导入企业网银", exportCount: 1 }) }),
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED", extra: expect.objectContaining({ purpose: "reimbursement_bank_transfer", purposeSource: "system", exportCount: 1, ...payload }) }),
});
const firstAudit = tx.auditLog.create.mock.calls.find(([args]) => args.data.action === "PAYMENT_ORDER_EXPORTED")[0].data.extra;
if (!payload.reason) expect(firstAudit).not.toHaveProperty("reason");
const repeated = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" },
payload: {},
});
expect(repeated.statusCode).toBe(200);
expect(state.batch.payment_order_export_count).toBe(2);
expect(tx.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: "PAYMENT_ORDER_REEXPORTED", extra: expect.objectContaining({ purposeSource: "system", exportCount: 2 }) }),
});
await app.close();
});
@@ -2713,6 +2727,31 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it.each([{ reason: "" }, { reason: "x" }, { purposeSource: "user" }])("rejects invalid optional download input: %j", async (payload) => {
const { app, tx } = await buildApp({
actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.manage", "payment.order.export"], roleCodes: ["finance"] },
});
const response = await app.inject({
method: "POST", url: `/api/payment-batches/${BATCH_ID}/payment-order`,
headers: { authorization: "Bearer test" }, payload,
});
expect(response.statusCode).toBe(400);
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
await app.close();
});
it.each(["cancel", `items/${ITEM_ID}/payee-bank/reveal`])("still requires reasons for %s", async (action) => {
const { app } = await buildApp({
actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["*"], roleCodes: ["finance"] },
});
const response = await app.inject({
method: "POST", url: `/api/payment-batches/${BATCH_ID}/${action}`,
headers: { authorization: "Bearer test" }, payload: {},
});
expect(response.statusCode).toBe(400);
await app.close();
});
it.each(["draft", "failed", "cancelled", "executed"])(
"rejects direct payment-order export while the batch is %s",
async (status) => {
@@ -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 = {
$transaction: vi.fn(async (callback) => callback(tx)),
reimbursementForm: {
updateMany: vi.fn(async () => ({ count: 1 })),
findUnique: vi.fn(async ({ where }) => {
if (where.id !== REIMBURSEMENT_ID) {
return null;
@@ -1083,7 +1084,7 @@ describe("POST /api/reimbursements/:id/link-project authorization", () => {
});
expect(response.statusCode, response.body).toBe(403);
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
expect(prisma.reimbursementForm.updateMany).not.toHaveBeenCalled();
await app.close();
});
@@ -1101,7 +1102,7 @@ describe("POST /api/reimbursements/:id/link-project authorization", () => {
});
expect(response.statusCode, response.body).toBe(409);
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
expect(prisma.reimbursementForm.updateMany).not.toHaveBeenCalled();
await app.close();
});
@@ -1119,12 +1120,33 @@ describe("POST /api/reimbursements/:id/link-project authorization", () => {
});
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();
});
});
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 expenseTypeId = "88888888-8888-4888-8888-888888888888";
@@ -9,6 +9,7 @@ describe("finance ledger service", () => {
expect(where).toEqual({
AND: [
{ deleted_at: null },
{
OR: expect.arrayContaining([
{ project: { is: { project_no: { contains: "交付项目", mode: "insensitive" } } } },
@@ -44,6 +44,7 @@ describe("reimbursement visibility", () => {
],
},
{ status: "draft" },
{ deleted_at: null },
],
});
});
@@ -57,6 +58,7 @@ describe("reimbursement visibility", () => {
AND: [
{ OR: [{ owner_id: ACTOR_ID }, { submitter_id: ACTOR_ID }] },
{ status: "draft" },
{ deleted_at: null },
],
});
});
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { apiRequestBlob } from "../../../../client/src/api/http";
afterEach(() => vi.unstubAllGlobals());
describe("browser download Content-Disposition filenames", () => {
it.each([
["attachment; filename=legacy.xls; filename*=UTF-8''%E6%8A%A5%E9%94%80%E5%88%B6%E5%8D%95_PB-001.xls", "报销制单_PB-001.xls"],
["attachment; FILENAME*=utf-8'zh'%E6%8A%A5%E9%94%80.xls; filename=legacy.xls", "报销.xls"],
['attachment; filename="receipt.pdf"', "receipt.pdf"],
["attachment; filename*=UTF-8''%broken; filename=legacy.xls", "legacy.xls"],
["attachment; filename*=ISO-8859-1''abc; filename=legacy.xls", "legacy.xls"],
["attachment; filename*=UTF-8''; filename=legacy.xls", "legacy.xls"],
["attachment", null],
])("resolves %s", async (header, filename) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("synthetic", {
status: 200, headers: { "content-disposition": header },
})));
const response = await apiRequestBlob("/synthetic-download", { auth: false });
expect(response.filename).toBe(filename);
expect(await response.blob.text()).toBe("synthetic");
});
});