From 7c39f8b0f98550477a6980282bd59da42def3e65 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 2 Aug 2026 00:29:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E4=BB=98=E6=AC=BE=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=E5=88=9B=E5=BB=BA=E4=B8=8E=E5=A4=8D=E6=A0=B8=EF=BC=88?= =?UTF-8?q?08-01-reimbursement-notification-progress-clarity=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/approval-workflow.md | 137 +++++++++++- .../design.md | 41 ++++ .../implement.md | 17 ++ .../prd.md | 22 ++ client/src/api/errorMessages.ts | 8 + client/src/api/paymentBatches.ts | 2 +- client/src/lib/paymentBatchDisplay.ts | 14 ++ client/src/pages/ApOperationsPage.tsx | 12 +- client/src/pages/PaymentBatchesPage.tsx | 197 +++++++----------- client/tests/e2e/main-flow.spec.ts | 30 ++- .../r7e-browser-business-trialrun.spec.ts | 33 +-- .../e2e/payment-reviewer-permissions.spec.ts | 6 +- .../reimbursement-payment-batch-flow.spec.ts | 36 ++-- .../e2e/system-action-confirmation.spec.ts | 85 +++++++- server/src/routes/exports.js | 8 +- server/src/routes/paymentBatches.js | 54 +++-- .../paymentBatches.reimbursement.test.js | 116 ++++++++++- 17 files changed, 591 insertions(+), 227 deletions(-) create mode 100644 client/src/lib/paymentBatchDisplay.ts diff --git a/.trellis/spec/hyb-oa/approval-workflow.md b/.trellis/spec/hyb-oa/approval-workflow.md index 3870e0e9..f5bb77f0 100644 --- a/.trellis/spec/hyb-oa/approval-workflow.md +++ b/.trellis/spec/hyb-oa/approval-workflow.md @@ -234,9 +234,11 @@ return [submittedStage, ...approvalStages, batchReviewStage, paymentStage]; ### 3. Contracts -- Batch creation resolves active employees whose roles include `manager|ceo` - and whose effective role permissions include `payment.batch.review`; exclude - the maker and never hard-code a person's name or employee ID. +- Batch creation resolves exactly one active non-maker reviewer whose effective + role permissions include `payment.batch.review`: choose the first stable + `ceo` candidate, otherwise the first stable `manager` candidate. Candidate + ordering uses a deterministic business key plus `id`; never hard-code a + person's name or employee ID and never broadcast to every manager/CEO. - Review completion first targets the active maker when that employee still has `payment.batch.manage`. If unavailable, fall back to other active `finance|finance_mgr` employees with that permission; always exclude the @@ -255,8 +257,10 @@ return [submittedStage, ...approvalStages, batchReviewStage, paymentStage]; ### 4. Validation & Error Matrix -- No eligible non-maker reviewer -> create the batch with zero review - notifications; do not weaken maker-checker or notify the maker. +- No eligible non-maker reviewer -> `409 VALID_001` with + `details.review=no_eligible_reviewer` before batch, item, audit or notification + writes. Do not weaken maker-checker, notify the maker or leave an ownerless + batch for later diagnosis. - Maker inactive or missing `payment.batch.manage` after review -> notify the eligible finance fallback; no eligible fallback -> review remains committed with zero execution notifications. @@ -274,16 +278,21 @@ return [submittedStage, ...approvalStages, batchReviewStage, paymentStage]; - Good: finance creates one weekly batch, the eligible non-maker reviewer gets one notification, then successful review returns the action to the active maker for payment and receipt registration. -- Base: no eligible reviewer is configured; the draft batch remains visible for - diagnosis and no unqualified management broadcast is fabricated. +- Base: one active CEO and several managers qualify; only the CEO selected by + stable ordering receives the review notification. +- Bad: no eligible reviewer is configured but an ownerless batch is still + created, leaving finance with an action that nobody can complete. - Bad: notify every finance/manager account, let the maker review their own batch, send before transaction commit, or label payment completion as an approval. ### 6. Tests Required -- Route tests assert qualified reviewer resolution, maker exclusion, active - maker preference, inactive-maker finance fallback and reviewer exclusion. +- Route tests assert CEO-first single-recipient resolution, manager fallback, + maker exclusion, active maker preference after review, inactive-maker finance + fallback and reviewer exclusion. +- The no-reviewer route test asserts `409` and zero batch, item, audit, + notification and dispatch side effects. - Negative tests assert maker conflict, repeated review and duplicate batch creation produce no notification/dispatch. - Dispatcher tests assert both types are IM-supported and resolve to the @@ -316,3 +325,113 @@ const notifications = await createNotificationsWithRecords(tx, { // after commit await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] }); ``` + +## Scenario: Reimbursement Payment Batch Create-As-Submit Lifecycle + +### 1. Scope / Trigger + +- Trigger: changing reimbursement payment-batch creation, user-facing status or + actions, payment-date defaults, payment-channel compatibility, or receipt + prerequisites across server API and React clients. +- This lifecycle records an offline bank payment. OA never initiates a bank + transfer and `execute` means registering a completed payment. + +### 2. Signatures + +- Create: `POST /api/payment-batches` with + `{ entityType: "reimbursement", entityIds: UUID[], name?, + defaultPaymentDate?, defaultPaymentChannel?, voucherPrefix? }`. +- Review: `POST /api/payment-batches/:id/review`. +- Register completed payment: `POST /api/payment-batches/:id/execute`. +- Receipt API: `GET|POST /api/payment-batches/:id/receipt-attachments`. +- Internal status machine: + `draft -> reviewed -> executed|failed|cancelled`; there is no separate submit + endpoint or submitted database status. +- Shared client label API: + `paymentBatchStatusLabel(status)` from + `client/src/lib/paymentBatchDisplay.ts`. + +### 3. Contracts + +- For the normal reimbursement UI, creation sends only `entityType` and + selected `entityIds`. The server still accepts optional fields for controlled + compatibility and exceptional item overrides. +- Missing name becomes ` 付款批次`; the server creates + the unique batch number. Missing channel becomes `bank`. Missing actual + payment date is resolved to the company-local registration date only when + payment completion is registered, not when the batch is created. +- Creation is create-as-submit: the same transaction locks candidates, checks + reviewer eligibility, creates the batch/items/audit and station notification, + then the dispatcher runs after commit. Internal `draft` therefore means + `待付款复核`, never an editable unsubmitted draft. +- User-facing status labels are `draft=待付款复核`, + `reviewed=待财务付款`, `executed=已付款`, + `failed=付款登记失败`, `cancelled=已取消`. Payment-batch list/detail and AP + views import the shared label function instead of keeping local maps. +- The reviewer action is `复核通过`. The finance action is + `登记付款完成`; user-facing text must not say OA executes a transfer. +- For a reimbursement batch, a persisted null batch channel is interpreted as + effective `bank` in API projection, item channel resolution, receipt guard and + reimbursement payment record. This compatibility rule must not rewrite the + historical batch row. +- When an executable bank item exists, a successful receipt query with zero + active receipts exposes `上传银行回单` as the sole primary action. Only after + an active receipt exists may the UI expose `登记付款完成`. Loading or failed + receipt queries expose neither action rather than guessing that the + prerequisite is satisfied. + +### 4. Validation & Error Matrix + +- No selected reimbursement -> schema validation error; zero writes. +- Candidate already reserved or not payable -> existing deterministic `409`; + zero new batch. +- No eligible non-maker reviewer -> `409 VALID_001` with + `details.review=no_eligible_reviewer`; zero batch/item/audit/notification. +- Maker reviews own batch -> `409 VALID_001` with maker-checker conflict. +- Register bank payment without active receipt -> `409 VALID_001` with + `details.receiptAttachments=bank_receipt_required`; source reimbursement and + batch status remain unchanged. +- Receipt query loading/error -> no premature registration button; the error is + shown independently and the user may retry. +- Existing null-channel reimbursement batch -> project as bank and apply the + receipt guard without updating `default_payment_channel`. + +### 5. Good/Base/Bad Cases + +- Good: finance selects approved reimbursements and clicks + `创建并提交复核`; the generated batch immediately shows `待付款复核` and one + eligible non-maker receives the task notification. +- Base: after review, finance completes the bank transfer outside OA, uploads + the receipt, then registers payment completion using the company-local date. +- Bad: label `draft` as `草稿`, ask the user to find a nonexistent submit button, + show `登记付款完成` before receipt status is known, or silently rewrite a + legacy null channel in production. + +### 6. Tests Required + +- Route tests assert optional name, generated company-local title, bank default, + payment-date default, CEO-first reviewer, no-reviewer zero side effects and + null-channel compatibility without mutation. +- PostgreSQL concurrency regression asserts double create, double execute, + execute/cancel and direct-pay/batch races remain mutually exclusive. +- Client E2E asserts creation sends only `entityType+entityIds`, all consumers + use the shared labels, and receipt upload switches the sole primary action + from `上传银行回单` to `登记付款完成`. +- Build/typecheck, Prisma validation and focused payment-batch route/export tests + are required before commit. + +### 7. Wrong vs Correct + +#### Wrong + +```tsx +const label = status === "draft" ? "草稿" : status; + +``` + +#### Correct + +```tsx + +{needsBankReceipt ? : canRegister ? : null} +``` diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md index 253409c6..845676ba 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md @@ -76,3 +76,44 @@ - 移动财务工作台在付款能力开启时固定显示财务卡片,不再要求先存在批次或异常;卡片进入受同一付款权限保护的 `/mobile/payment-batches`,复用现有响应式付款页,避免企微小屏把 PC 路径重定向回首页。 - 入口只对真实 finance/finance_mgr 且具备付款权限的人员显示;不向普通员工或仅审批人开放创建能力。 - 本轮不自动打开创建面板、不自动选择报销、不代替真实财务动作。 + +## 8. 付款批次创建与复核闭环 + +### 8.1 已确认的状态事实 + +- `POST /payment-batches` 已在同一事务内生成 `batch_no`、创建批次与批次项、写创建审计,并创建 `payment_batch.review_requested`;事务提交后立即进入企微投递。 +- 当前没有 submit API。`draft` 不是“尚未提交”,而是“已经提交付款复核、尚未被非制单人复核”。继续向用户显示“草稿”会制造一个不存在的提交动作。 +- 不新增状态和接口:数据库继续使用 `draft -> reviewed -> executed/failed`,用户语义改为 `待付款复核 -> 待财务付款 -> 已付款/登记失败`。 + +### 8.2 KISS 创建合同 + +- 创建页只保留候选搜索、报销选择、已选数量和一个主按钮“创建并提交复核”。 +- 服务端 `name` 改为可选;未传时按 `APP_TIMEZONE` 生成 `YYYY-MM-DD 付款批次`。现有调用方显式传入名称时继续兼容。 +- 唯一批次编号继续由服务端生成,不把编号输入暴露给用户;本轮不引入数据库序列表或编号迁移。 +- 未传付款渠道时默认 `bank`,从而保持银行回单门禁;付款日期不在创建日预填,登记付款完成时未覆盖则按公司时区当天写入。凭证前缀保持可选且默认空。 +- 既有报销批次若历史渠道为空,API 展示和执行门禁按 `bank` 解释但不回写数据库;因此当前真实批次也会先要求回单,不需要生产数据修正。 +- 日期、渠道、凭证号的异常覆盖继续留在批次项,不让低频字段占据首次创建主路径。 + +### 8.3 当前责任与动作 + +- 创建成功反馈:`已提交付款复核`;详情说明:`等待负责人复核,制单人不可复核自己的批次`。 +- `draft`:复核责任人显示“负责人”;符合 maker-checker 的复核账号显示主动作“复核通过”,制单人不显示伪装成可用动作的禁用复核按钮。 +- `reviewed`:财务责任人显示“财务”;页面说明先在线下银行完成付款并上传回单,主动作改为“登记付款完成”。 +- `reviewed + bank + 0 份有效回单`:唯一主动作是“上传银行回单”,点击直接打开既有文件选择器;上传成功刷新附件计数后,主动作切换成“登记付款完成”。非银行渠道不强制回单,直接显示登记动作。 +- `executed`:显示“已付款”,不再提供付款动作;`failed` 显示“付款登记失败”并允许财务按既有规则重试。 +- PC 与移动复用同一响应式页面、状态函数和责任说明,不建立第二套移动状态机。 +- 付款批次状态中文映射放入客户端共享模块,付款列表、详情和其他客户端调用方统一导入;服务端报销处理进度继续输出业务阶段,但用跨层测试断言其“待付款复核”语义与客户端 `draft` 映射一致。 + +### 8.4 单一复核责任人 + +- 解析 active、具备 `payment.batch.review` 且不是制单人的候选人。 +- 先按稳定顺序选择 `ceo` 一人;不存在时再选择 `manager` 一人。一个员工同时有多个角色仍只产生一个收件人。 +- 创建事务在写批次之前完成候选解析;没有合格人员时返回可解释的业务错误,保证不会出现“批次已创建但无人能复核”。 +- 无合格人员错误面向财务显示“当前没有可复核人(复核人必须是非制单人且在职),请联系管理员”,并保留固定错误详情供自动化断言。 +- 页面显示职责而不预猜姓名;通知只发给最终选中的真实责任人。历史通知不补发、不删除。 + +### 8.5 兼容与回滚 + +- 不改 Prisma schema、历史批次或报销事实;既有 `draft` 批次部署后直接按“待付款复核”展示。 +- 若新创建合同需回滚,客户端可恢复旧表单,服务端仍接受显式名称和可选默认字段;状态机与历史数据不变。 +- 任何真实批次复核、回单上传、付款登记仍由业务人员操作,不纳入部署自动化。 diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md index 5c8f2e43..3f832e0d 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md @@ -78,3 +78,20 @@ - 提交 `37b4ef2` 已推送并通过既有安装固化入口部署;环境合同、远端 preflight、migration、`/ready`、external deps、runtime smoke、公网构建标识和前端资源扫描通过。 - runtime smoke 明确证明 env true 与 `payment_batch_v1=flag_force_on_global`;生产只读核验确认唯一 active 财务人员仍有付款管理权限、系统导航含 `paymentBatches`、已批准未付款计数为 1、可建批次候选计数为 1。 - 没有替财务创建付款批次,没有替负责人复核,也没有上传回单或执行付款。下一条真实验证由王芝玲创建批次触发,届时核验马建勇收到付款复核企微通知。 + +## 8. 首张真实付款批次闭环修正(已实施,待部署批准) + +- [x] 只读核对创建路由、状态转换、通知发送点、前端按钮显隐和聚焦 E2E;确认不存在 submit API,`draft` 创建时已经发送复核通知。 +- [x] 生产只读核验最新真实批次:已自动编号、状态 `draft`、未复核/未付款、含 1 个报销项;复核通知已生成且有 1 次企微成功投递。核验未读取名称、金额、人员标识、消息正文或原始 payload。 +- [x] 服务端允许省略名称,自动生成批次标题和银行渠道默认值;付款完成登记缺少日期时按公司时区当天落账。 +- [x] 既有报销批次渠道为空时按银行转账进行 API 展示和回单门禁,不执行生产数据回填。 +- [x] 复核责任人解析改为单一 `ceo` 优先、`manager` 兜底;没有合格非制单复核人时在任何业务写入前拒绝创建。 +- [x] 创建页收敛为选择报销 + “创建并提交复核”,移除名称、默认日期、渠道和凭证前缀的高频路径输入;异常覆盖仍留在详情。 +- [x] 统一状态和动作:`draft=待付款复核`、`reviewed=待财务付款`、`executed=已付款`、`failed=付款登记失败`;“复核”改为“复核通过”,“执行”改为“登记付款完成”。 +- [x] 提取付款批次共享状态映射并替换客户端局部映射;增加跨层语义回归,锁定报销进度与付款页对 `draft` 的一致表达。 +- [x] 详情按状态显示当前责任职责、下一步和 maker-checker 说明;银行渠道无有效回单时唯一主动作是“上传银行回单”,上传后切换为“登记付款完成”;不新增 Tab、流程设计器、第二轮复核或 submit API。 +- [x] 更新 server route 单测与付款主链 Playwright,覆盖自动字段、无复核人 fail-closed、单一收件人、状态/动作中文和实际付款日缺省值。 +- [x] 运行 server focused Vitest、客户端 typecheck/build、聚焦 Playwright、Prisma validate、Trellis/governance/process/sensitive/diff 门禁。 +- [x] 因修改付款责任解析和状态表达,实施后执行只读 reviewer 审查;通过后再申请提交、部署和公网/真机验证批准。 + +本地验证(2026-08-01):付款/报销详情/通知/导出/导航聚焦 Vitest 85 passed;隔离 PostgreSQL 付款并发门禁覆盖双执行、执行/取消、重复建批次和直接付款竞争并全部通过;客户端 typecheck/build 通过;付款权限 5 条、高风险动作 8 条 Playwright 共 13 passed。reviewer 首轮未发现数据安全或状态机阻断项,指出“兼任复核职责的制单人仍显示自审按钮”和“回单查询失败误报为空”两项交互问题;已修为前端同步排除制单人、失败态只显示错误与重新加载,复查未发现新 blocker。既有真实批次未被读取正文、修改、复核、上传回单或登记付款。 diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md index e41726db..621faafc 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md @@ -11,6 +11,8 @@ - 当前员工详情展示“付款批次复核”“付款与回单”,容易被理解为两轮额外审批;实际前者是非制单人付款复核,后者是财务线下付款后的回单与状态登记,不是审批。 - 当前职责基线:马建勇负责主管审批和非制单付款复核;王芝玲负责财务复核、创建付款批次、线下付款、上传回单和登记付款完成。一个付款批次可以覆盖多张报销单。 - `client/src/lib/approvalDisplay.ts` 已把 `start` 映射为“提交”,但 PC `WorkflowTaskDetailPage.tsx` 与移动 `MobileApprovalsPage.tsx` 各自复制了缺少 `start` 的局部映射,导致显示 `start · 杨俊`。 +- 首张真实付款批次暴露了创建闭环的表达缺陷:服务端在创建事务内已经自动编号、锁定报销、生成付款复核通知并开始投递,但数据库状态和页面仍显示“草稿”,页面也不存在提交接口或提交按钮,导致制单人无法判断是否已经送审、由谁处理、下一步是什么。 +- 生产只读核验确认该真实批次已经自动编号,当前未复核/未付款;付款复核通知已经生成并有企微成功投递。当前解析同时创建了 `ceo` 和 `manager` 两类站内收件记录,不符合“一个明确责任人、不要泛发”的小团队运行目标。 ## Scope @@ -22,6 +24,9 @@ - 每周集中建立一次付款批次作为当前运行约定,不在本任务建设定时排程、自动组批或强制付款日。 - 修复财务首次使用时的入口死锁:付款能力正式常开时,即使尚无付款批次,财务人员也必须能在 PC 和移动工作台看到“付款”入口并进入创建页。 - 恢复生产 `payment_batch_v1` 正式常开基线,并防止新环境初始化或 QA 窗口收尾把正式常开状态静默改回关闭。 +- 收口付款批次创建到复核的首个真实闭环:创建页只要求选择待付款报销,编号、标题和常用付款默认值由服务端生成;创建动作明确等价于提交付款复核,不再让用户寻找不存在的二次提交按钮。 +- 付款页用业务状态而非内部状态名表达下一责任:`draft` 显示“待付款复核”,`reviewed` 显示“待财务付款”,`executed` 显示“已付款”;详情说明当前责任职责和唯一下一步。 +- 付款复核通知收件人按 `ceo` 优先、`manager` 兜底选择一个确定的非制单人;没有合格复核人时创建前失败并给出可解释提示,不生成无人负责的批次。 ## Requirements @@ -34,6 +39,12 @@ - 付款开关恢复前先查询 R54 变更历史;若当前库无变更审计,必须区分“seed 首次创建默认值”和“seed 覆盖已有值”,不得把不存在的 runner/人工操作写成根因。 - QA 短窗口结束时必须恢复调用前快照;正式启用流程成功后保持常开,失败才回滚,不得无条件写死 `force_off`。 - 正式环境和新初始化的试运行环境必须同时满足环境硬开关与数据库开关常开;任何一层关闭都应由部署/运行门禁发现。 +- 付款批次内部仍可保留 `draft` 作为兼容状态,但该状态已代表“创建并提交、等待独立复核”,前端、通知和测试不得再把它描述成尚未提交的可编辑草稿。 +- 新建批次不要求财务手工编批次名称、付款日期、渠道或凭证前缀;服务端继续自动生成唯一批次编号,按公司时区生成标题,默认付款渠道为银行转账,实际付款日期在登记付款完成时缺省取登记当天。异常项仍可在批次详情中覆盖。 +- 付款动作必须使用“登记付款完成”等事实性文案,不得用“执行付款”暗示 OA 会连接银行或自动转账。 +- 付款复核仍是一轮 maker-checker;责任收件人只选一个,优先具备复核权限的 active `ceo`,没有时再按稳定顺序选择 active `manager`,始终排除制单人。 +- 付款页同一时刻只显示一个主动作:银行渠道复核通过但尚无有效回单时显示“上传银行回单”;上传后才显示“登记付款完成”,不能让用户先撞到 `bank_receipt_required` 才知道前置条件。 +- 付款批次状态标签必须使用共享展示映射;列表、详情、移动响应式视图和相关测试不得各自复制 `draft/reviewed/executed/failed/cancelled` 中文文案。 ## Acceptance Criteria @@ -47,6 +58,15 @@ - [ ] 财务人员在尚无付款批次但存在已批准待付款报销时,PC 与移动端均可看到“付款”入口,并能看到该报销作为新建批次候选。 - [ ] 正式初始化基线为付款批次常开;QA 窗口测试覆盖原值为 on/off 两种情况并恢复原值,不再硬编码关闭。 - [ ] 生产恢复后只验证入口和候选可见,不代替财务创建批次、负责人复核或执行付款。 +- [ ] 新建批次页面只需选择报销;提交后自动得到唯一编号、按公司时区生成的标题、银行转账默认渠道,并直接进入“待付款复核”。 +- [ ] 创建按钮显示“创建并提交复核”,成功反馈明确“已提交,等待负责人复核”;页面不存在需要用户寻找的第二个提交动作。 +- [ ] 制单人看到“待付款复核”和“负责人复核(制单人不可自审)”;实际复核人看到可用的“复核通过”;复核后财务看到“待财务付款”和“登记付款完成”。 +- [ ] 同一批次只通知一个确定的非制单复核责任人,`ceo` 优先、`manager` 兜底;无合格复核人时拒绝创建且不写批次、批次项、审计或通知。 +- [ ] 未填写实际付款日期时,登记付款完成以公司时区当天落账;银行转账仍要求先上传银行回单,OA 不执行真实转账。 +- [ ] 银行渠道复核通过且没有有效回单时,唯一主动作是“上传银行回单”;上传成功后唯一主动作切换为“登记付款完成”,不会先触发可预见的 409。 +- [ ] 无合格复核人的错误明确说明“复核人必须是非制单人且在职,请联系管理员”,不把配置问题伪装成财务人员操作错误。 +- [ ] 既有真实批次不改 ID、不改金额、不改报销绑定、不改复核/付款事实;部署后仅获得新的业务文案和下一步说明,不替任何人推进状态。 +- [ ] 既有报销批次渠道为空时按银行转账展示并执行回单门禁,但不回写历史记录;当前真实批次同样先上传回单再登记付款完成。 ## Out of Scope @@ -55,6 +75,8 @@ - 不建设每周付款日排程、自动组批、批次自动复核或定时通知。 - 不在未获批准时修改生产通知渠道、员工推送开关、企微绑定、环境变量或服务状态。 - 不顺带重构全部审批页面或统一所有业务状态标签。 +- 不新增“提交中”数据库状态或单独 submit API,不增加第二轮付款审批,不建设可配置的付款复核人选择器。 +- 不把批次创建与财务复核合并,不由系统自动复核、自动上传回单或自动登记付款完成。 ## Deferred Decision With Trigger diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 3fdfcbc8..117c38b1 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -173,6 +173,14 @@ export function mapApiErrorMessage( return "当前批次状态不允许继续复核。"; } + if (details?.review === "no_eligible_reviewer") { + return "当前没有可复核人(复核人必须是非制单人且在职),请联系管理员。"; + } + + if (details?.receiptAttachments === "bank_receipt_required") { + return "请先上传银行回单,再登记付款完成。"; + } + if (details?.transfer === "chain_limit_exceeded") { return "当前任务转审次数已达上限。"; } diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts index 234d5f86..165e1c31 100644 --- a/client/src/api/paymentBatches.ts +++ b/client/src/api/paymentBatches.ts @@ -123,7 +123,7 @@ export async function fetchPaymentBatchCandidates(query: { q?: string; page?: nu export async function createPaymentBatch(payload: { entityType: PaymentBatchEntityType; - name: string; + name?: string; entityIds: string[]; defaultPaymentDate?: string; defaultPaymentChannel?: PaymentChannel; diff --git a/client/src/lib/paymentBatchDisplay.ts b/client/src/lib/paymentBatchDisplay.ts new file mode 100644 index 00000000..f96b8561 --- /dev/null +++ b/client/src/lib/paymentBatchDisplay.ts @@ -0,0 +1,14 @@ +import type { PaymentBatchStatus } from "../api/paymentBatches"; + +const PAYMENT_BATCH_STATUS_LABELS: Record = { + draft: "待付款复核", + reviewed: "待财务付款", + executed: "已付款", + failed: "付款登记失败", + cancelled: "已取消", +}; + +export function paymentBatchStatusLabel(value: PaymentBatchStatus | string | null | undefined) { + if (!value) return "-"; + return PAYMENT_BATCH_STATUS_LABELS[value as PaymentBatchStatus] ?? value; +} diff --git a/client/src/pages/ApOperationsPage.tsx b/client/src/pages/ApOperationsPage.tsx index 2955f7d1..d790344d 100644 --- a/client/src/pages/ApOperationsPage.tsx +++ b/client/src/pages/ApOperationsPage.tsx @@ -65,6 +65,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; import { downloadBlob } from "../lib/download"; import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; +import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay"; type OperationsTab = "ledger" | "pending" | "failed" | "risk" | "reconciliation"; type RiskActionType = "resolve" | "dismiss"; @@ -138,15 +139,6 @@ function entityTypeLabel(value: string) { return value || "-"; } -function batchStatusLabel(value: string) { - if (value === "draft") return "草稿"; - if (value === "reviewed") return "已复核"; - if (value === "executed") return "已执行"; - if (value === "failed") return "执行失败"; - if (value === "cancelled") return "已取消"; - return value || "-"; -} - function reimbursementStatusLabel(value: string) { if (value === "draft") return "草稿"; if (value === "reviewing") return "审批中"; @@ -961,7 +953,7 @@ export function ApOperationsPage() { - {batchStatusLabel(item.status)} + {paymentBatchStatusLabel(item.status)} {item.highRisk ? ( diff --git a/client/src/pages/PaymentBatchesPage.tsx b/client/src/pages/PaymentBatchesPage.tsx index 94df8978..cd371e37 100644 --- a/client/src/pages/PaymentBatchesPage.tsx +++ b/client/src/pages/PaymentBatchesPage.tsx @@ -47,19 +47,10 @@ import { StatusBadge } from "../components/common/StatusBadge"; import { DateTextInput } from "../components/common/DateTextInput"; import { downloadBlob } from "../lib/download"; import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; +import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay"; import { toast } from "sonner"; const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"]; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -function batchStatusLabel(value: string) { - if (value === "draft") return "草稿"; - if (value === "reviewed") return "已复核"; - if (value === "executed") return "已执行"; - if (value === "failed") return "执行失败"; - if (value === "cancelled") return "已取消"; - return value || "-"; -} function entityTypeLabel(value: string) { if (value === "reimbursement") return "报销"; @@ -79,23 +70,13 @@ function paymentChannelLabel(value: PaymentChannel | string | null | undefined) function paymentItemStatusLabel(value: string) { if (value === "pending") return "待付款"; if (value === "paid") return "已支付"; - if (value === "executed") return "已执行"; + if (value === "executed") return "已付款"; if (value === "failed") return "付款失败"; if (value === "cancelled") return "已取消"; if (value === "skipped") return "已跳过"; return value || "-"; } -function reviewNextStepLabel(value: PaymentBatch["reviewRequirement"]["nextStep"]) { - if (value === "first_review") return "等待独立复核"; - return "可执行付款标记"; -} - -function reviewerDisplayName(value: string | null | undefined) { - if (!value) return ""; - return UUID_PATTERN.test(value) ? "已记录" : value; -} - function formatFileSize(value: number) { if (!Number.isFinite(value) || value <= 0) return "-"; if (value < 1024) return `${value} B`; @@ -119,14 +100,9 @@ export function PaymentBatchesPage() { const canReviewBatch = hasPermission(permissions, "payment.batch.review") && (me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo")); - const [name, setName] = useState(""); const [candidateSearch, setCandidateSearch] = useState(""); const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); - const [defaultPaymentDate, setDefaultPaymentDate] = useState(""); - const [defaultPaymentChannel, setDefaultPaymentChannel] = useState(""); - const [voucherPrefix, setVoucherPrefix] = useState(""); const receiptInputRef = useRef(null); - const [selectedReceiptFiles, setSelectedReceiptFiles] = useState([]); const [error, setError] = useState(""); const [showCreatePanel, setShowCreatePanel] = useState(false); const [confirmPaymentAction, setConfirmPaymentAction] = useState<{ @@ -172,9 +148,11 @@ export function PaymentBatchesPage() { canManageBatch && !!selectedBatch && selectedBatch.status !== "cancelled"; + const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess; const canReviewSelectedBatch = canReviewBatch && !!selectedBatch && + selectedBatch.createdBy !== me?.id && ( selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review" ); @@ -183,6 +161,16 @@ export function PaymentBatchesPage() { !!selectedBatch && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.reviewRequirement.nextStep === "ready_for_execute"; + const requiresBankReceipt = Boolean(selectedBatch?.items.some((item) => + ["pending", "failed"].includes(item.status) && + (item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel) === "bank", + )); + const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length; + const needsBankReceipt = + canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0; + const canRegisterPaymentComplete = + canExecuteSelectedBatch && + (!requiresBankReceipt || (receiptAttachmentsQuery.isSuccess && activeReceiptCount > 0)); const canCancelSelectedBatch = canManageBatch && !!selectedBatch && @@ -205,11 +193,7 @@ export function PaymentBatchesPage() { mutationFn: async () => createPaymentBatch({ entityType: "reimbursement", - name: name.trim() || "报销付款批次", entityIds: selectedCandidateIds, - defaultPaymentDate: defaultPaymentDate || undefined, - defaultPaymentChannel: defaultPaymentChannel || undefined, - voucherPrefix: voucherPrefix || undefined, }), onSuccess: async ({ item }) => { setError(""); @@ -219,7 +203,7 @@ export function PaymentBatchesPage() { next.set("id", item.id); setSearchParams(next, { replace: true }); await refresh(); - toast.success("付款批次已创建"); + toast.success("已提交付款复核,等待负责人处理"); }, onError: (err) => { const message = resolveError(err); @@ -251,7 +235,7 @@ export function PaymentBatchesPage() { onSuccess: async () => { setError(""); await refresh(); - toast.success("付款批次已执行"); + toast.success("付款已登记完成"); }, onError: (err) => { const message = resolveError(err); @@ -296,18 +280,17 @@ export function PaymentBatchesPage() { }); const uploadReceiptMutation = useMutation({ - mutationFn: async () => { + mutationFn: async (files: File[]) => { if (!selectedBatch) { throw new Error("请先选择付款批次"); } - if (!selectedReceiptFiles.length) { + if (!files.length) { throw new Error("请选择银行回单附件"); } - return uploadPaymentBatchReceiptAttachments(selectedBatch.id, selectedReceiptFiles); + return uploadPaymentBatchReceiptAttachments(selectedBatch.id, files); }, onSuccess: async () => { setError(""); - setSelectedReceiptFiles([]); if (receiptInputRef.current) { receiptInputRef.current.value = ""; } @@ -400,16 +383,6 @@ export function PaymentBatchesPage() { 当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。 ) : null} -
- - setName(event.target.value)} - placeholder="例如:2026Q2-财务付款批次-01" - /> -
没有可加入批次的已审批报销。
) : null} -
已选择 {selectedCandidateIds.length} 项;创建时服务端会再次核对审批和付款状态。
- -
-
- - setDefaultPaymentDate(event.target.value)} - /> -
-
- - -
-
- - setVoucherPrefix(event.target.value)} - placeholder="例如 PBX" - /> +
+ 已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,默认按银行转账提交负责人复核。
) : null} @@ -514,11 +455,10 @@ export function PaymentBatchesPage() {
- {canReviewBatch ? ( + {canReviewSelectedBatch ? ( ) : null} - {canManageBatch ? ( + {needsBankReceipt ? ( + ) : null} + {canRegisterPaymentComplete ? ( + ) : null} {canManageBatch ? ( @@ -565,7 +514,7 @@ export function PaymentBatchesPage() {
-
状态
{batchStatusLabel(selectedBatch.status)}
+
状态
{paymentBatchStatusLabel(selectedBatch.status)}
实体
{entityTypeLabel(selectedBatch.entityType)}
总金额
{selectedBatch.totalAmount}
默认日期
{formatDisplayDate(selectedBatch.defaultPaymentDate)}
@@ -573,10 +522,20 @@ export function PaymentBatchesPage() {
复核进度
{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}
- 当前要求:单次独立复核后可执行。 - {selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},执行前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""} - 下一步:{reviewNextStepLabel(selectedBatch.reviewRequirement.nextStep)}。 - {selectedBatch.reviewedBy ? ` 复核人:${reviewerDisplayName(selectedBatch.reviewedBy)}` : ""} + {selectedBatch.status === "draft" + ? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。" + : selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess + ? "付款复核已通过,正在核对银行回单状态。" + : selectedBatch.status === "reviewed" && needsBankReceipt + ? "付款复核已通过。请财务先在线下银行完成付款,再上传银行回单。" + : selectedBatch.status === "reviewed" + ? "付款复核已通过。请财务核对付款结果并登记付款完成。" + : selectedBatch.status === "executed" + ? "付款已登记完成。" + : selectedBatch.status === "failed" + ? "付款登记失败,请财务核对失败原因后重试。" + : "该付款批次已取消。"} + {selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""} {selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
@@ -585,9 +544,9 @@ export function PaymentBatchesPage() {

银行回单附件

-

先在网银完成对公付款,再上传银行回单并执行 OA 已付款标记。

+

先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。

- {canManageBatch ? ( + {canUploadReceipt ? (
setSelectedReceiptFiles(Array.from(event.target.files ?? []))} + disabled={uploadReceiptMutation.isPending} + onChange={(event) => { + const files = Array.from(event.target.files ?? []); + if (files.length) uploadReceiptMutation.mutate(files); + }} /> -
) : null}
- {selectedReceiptFiles.length ? ( -
- 待上传:{selectedReceiptFiles.map((file) => file.name).join("、")} -
- ) : null} {receiptAttachmentsQuery.isError ? ( -
- {resolveError(receiptAttachmentsQuery.error)} +
+ {resolveError(receiptAttachmentsQuery.error)} +
) : null}
@@ -671,9 +630,9 @@ export function PaymentBatchesPage() {
))} - {!receiptAttachmentsQuery.isLoading && !(receiptAttachmentsQuery.data?.items ?? []).length ? ( + {receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
- 暂无银行回单附件;默认银行渠道执行前需要上传至少一份回单。 + 暂无银行回单;银行转账必须先上传回单,才能登记付款完成。
) : null} @@ -752,7 +711,7 @@ export function PaymentBatchesPage() { >
{item.batchNo}
- +
{item.name}
@@ -790,7 +749,7 @@ export function PaymentBatchesPage() {
- + {item.highRisk ? 高风险 : null}
@@ -834,8 +793,8 @@ export function PaymentBatchesPage() { ? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。` : "确认后将完成一次独立复核并写入付款批次审计记录。" : confirmPaymentAction?.highRisk - ? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将更新源单付款状态。` - : "确认后将执行付款批次并影响付款状态,请确认批次、金额和回单均已核对。" + ? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将登记付款完成并更新源单状态。` + : "请确认线下付款和银行回单均已核对。确认后将登记付款完成并更新源单状态。" } onConfirm={async () => { if (!confirmPaymentAction) return; diff --git a/client/tests/e2e/main-flow.spec.ts b/client/tests/e2e/main-flow.spec.ts index 285abd1a..f919881c 100644 --- a/client/tests/e2e/main-flow.spec.ts +++ b/client/tests/e2e/main-flow.spec.ts @@ -107,10 +107,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登 await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0); - const paymentDate = new Date().toISOString().slice(0, 10); const reimbursement = await apiGetData<{ formNumber: string | null }>(page.request, `/api/reimbursements/${reimbursementId}`); const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId; - const batchName = `E2E-报销付款批次-${Date.now()}`; await page.goto("/payment-batches"); await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0); await page.getByRole("button", { name: "新建批次" }).click(); @@ -120,18 +118,15 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登 const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok(); }); - await createCard.getByTestId("payment-batch-create-name").fill(batchName); await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode); const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first(); await expect(candidateRow).toBeVisible(); await candidateRow.getByRole("checkbox").check(); - await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate); - await createCard.getByTestId("payment-batch-create-payment-channel").selectOption("bank"); - await createCard.getByTestId("payment-batch-create-voucher-prefix").fill("E2EPB"); - await createCard.getByRole("button", { name: "创建批次" }).click(); - const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string } } }; + await createCard.getByRole("button", { name: "创建并提交复核" }).click(); + const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string; name?: string } } }; const batchId = createBatchPayload.data?.item?.id; - if (!batchId) { + const batchName = createBatchPayload.data?.item?.name; + if (!batchId || !batchName) { throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); } @@ -155,24 +150,23 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登 await expect(executedBatchDetailCard).toContainText("1/1"); const receiptCard = page.getByTestId("payment-batch-receipt-panel"); await expect(receiptCard).toBeVisible(); - await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({ - name: "e2e-bank-receipt.pdf", - mimeType: "application/pdf", - buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"), - }); const receiptUploadResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` && response.ok(); }); - await receiptCard.getByRole("button", { name: "上传" }).click(); + await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({ + name: "e2e-bank-receipt.pdf", + mimeType: "application/pdf", + buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"), + }); await receiptUploadResponsePromise; await expect(receiptCard).toContainText("e2e-bank-receipt.pdf"); - await executedBatchDetailCard.getByRole("button", { name: "执行" }).click(); - await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次"); + await executedBatchDetailCard.getByRole("button", { name: "登记付款完成" }).click(); + await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成"); await page.getByTestId("action-confirm-submit").click(); - await expect(executedBatchDetailCard).toContainText("已执行", { timeout: 30_000 }); + await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 }); await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); diff --git a/client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts b/client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts index 0d7fd47a..cd00989a 100644 --- a/client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts +++ b/client/tests/e2e/manual/r7e-browser-business-trialrun.spec.ts @@ -1159,8 +1159,6 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请 }; } - const batchName = `${trialrunMarkerPrefix} 浏览器付款批次 ${marker}`; - state.paymentBatchName = batchName; await page.goto("/payment-batches"); await expect(page.getByRole("heading", { name: "付款", exact: true })).toBeVisible(); if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) { @@ -1174,21 +1172,17 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请 } await page.getByRole("button", { name: "新建批次" }).click(); const createCard = page.getByTestId("payment-batch-create-panel"); - await expect(createCard.getByRole("button", { name: "创建批次" })).toBeDisabled(); + await expect(createCard.getByRole("button", { name: "创建并提交复核" })).toBeDisabled(); const createBatchResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/payment-batches"; }); - await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName); const reimbursementCode = state.reimbursementCode ?? state.reimbursementId; await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementCode); const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementCode }).first(); await expect(candidateRow).toBeVisible(); await candidateRow.getByRole("checkbox").check(); - await createCard.locator('input[type="date"]').fill(new Date().toISOString().slice(0, 10)); - await createCard.locator("select").nth(0).selectOption("bank"); - await createCard.getByPlaceholder("例如 PBX").fill(`${trialrunSafePrefix}-${marker.slice(-6)}`); - await createCard.getByRole("button", { name: "创建批次" }).click(); + await createCard.getByRole("button", { name: "创建并提交复核" }).click(); const createBatchResponse = await createBatchResponsePromise; if (!createBatchResponse.ok()) { throw new Error(`创建付款批次失败:HTTP ${createBatchResponse.status()} ${await createBatchResponse.text()}`); @@ -1198,6 +1192,8 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请 if (!batch?.id) { throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); } + const batchName = batch.name; + state.paymentBatchName = batchName; state.paymentBatchId = batch.id; await logoutLiveUser(page, { name: finance.name }).catch(() => undefined); @@ -1215,10 +1211,23 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请 const financeAgain = await loginRole(page, "finance"); await page.goto(`/payment-batches?id=${batch.id}`); - const batchDetailCard = page.locator(".card-std").filter({ hasText: batchName }).filter({ hasText: "复核进度:" }).last(); + const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); await expect(batchDetailCard).toBeVisible(); - await batchDetailCard.getByRole("button", { name: "执行" }).click(); - await expect(batchDetailCard).toContainText("状态:已执行", { timeout: 30_000 }); + const receiptResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "POST" && + url.pathname === `/api/payment-batches/${batch.id}/receipt-attachments` && + response.ok(); + }); + await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({ + name: `${trialrunSafePrefix}-bank-receipt.pdf`, + mimeType: "application/pdf", + buffer: Buffer.from(`%PDF-1.4\n${trialrunMarkerPrefix} ${marker}\n%%EOF\n`, "utf8"), + }); + await receiptResponsePromise; + await batchDetailCard.getByRole("button", { name: "登记付款完成" }).click(); + await page.getByTestId("action-confirm-submit").click(); + await expect(batchDetailCard).toContainText("已付款", { timeout: 30_000 }); await page.goto(`/reimbursements/${state.reimbursementId}`); await reimbursementDetail.assertLoaded(); await expect(page.getByTestId("reimbursement-payment-status")).toContainText(/已付款|paid/, { timeout: 30_000 }); @@ -1232,7 +1241,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请 if (/\/403/.test(page.url())) { await expect(page.getByRole("heading", { name: "无权限访问", exact: true })).toBeVisible(); } else { - const createButton = page.getByRole("button", { name: "创建批次" }).first(); + const createButton = page.getByRole("button", { name: "创建并提交复核" }).first(); await expect(createButton).toBeDisabled(); } await logoutLiveUser(page, { name: admin.name }).catch(() => undefined); diff --git a/client/tests/e2e/payment-reviewer-permissions.spec.ts b/client/tests/e2e/payment-reviewer-permissions.spec.ts index eba95335..10ae962e 100644 --- a/client/tests/e2e/payment-reviewer-permissions.spec.ts +++ b/client/tests/e2e/payment-reviewer-permissions.spec.ts @@ -414,16 +414,14 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次", await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0); await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0); - await page.getByTestId("payment-batch-create-name").fill("合成付款批次"); await candidateRow.getByRole("checkbox").check(); - await page.getByRole("button", { name: "创建批次", exact: true }).click(); + await page.getByRole("button", { name: "创建并提交复核", exact: true }).click(); await expect(page).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`)); await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); - await expect(page.getByText("付款批次已创建", { exact: true })).toBeVisible(); + await expect(page.getByText("已提交付款复核,等待负责人处理", { exact: true })).toBeVisible(); expect(createPayload).toEqual({ entityType: "reimbursement", - name: "合成付款批次", entityIds: [PAYMENT_CANDIDATE_ENTITY_ID], }); expectNoPageErrors(pageErrors); diff --git a/client/tests/e2e/reimbursement-payment-batch-flow.spec.ts b/client/tests/e2e/reimbursement-payment-batch-flow.spec.ts index 6b8a9325..7ae04a09 100644 --- a/client/tests/e2e/reimbursement-payment-batch-flow.spec.ts +++ b/client/tests/e2e/reimbursement-payment-batch-flow.spec.ts @@ -24,7 +24,6 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复 const adminApi = await createAuthenticatedApiClient(ADMIN); const employeeApi = await createAuthenticatedApiClient(EMPLOYEE); const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR); - const paymentDate = new Date().toISOString().slice(0, 10); try { await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow"); @@ -100,7 +99,6 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复 await logout(page, secondApprovalUser.name); await login(page, FINANCE); - const batchName = `R78-报销付款批次-${Date.now()}`; await page.goto("/payment-batches"); await expect(page).toHaveURL(/\/payment-batches/); @@ -112,27 +110,25 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复 const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok(); }); - await createCard.getByTestId("payment-batch-create-name").fill(batchName); await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode); const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first(); await expect(candidateRow).toBeVisible(); await candidateRow.getByRole("checkbox").check(); - await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate); - await createCard.getByTestId("payment-batch-create-payment-channel").selectOption("bank"); - await createCard.getByTestId("payment-batch-create-voucher-prefix").fill("R78PB"); - await createCard.getByRole("button", { name: "创建批次" }).click(); + await createCard.getByRole("button", { name: "创建并提交复核" }).click(); const createBatchPayload = (await (await createBatchResponsePromise).json()) as { - data?: { item?: { id?: string } }; + data?: { item?: { id?: string; name?: string } }; }; const batchId = createBatchPayload.data?.item?.id; - if (!batchId) { + const batchName = createBatchPayload.data?.item?.name; + if (!batchId || !batchName) { throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); } await page.goto(`/payment-batches?id=${batchId}`); const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); await expect(batchDetailCard).toBeVisible(); - await expect(batchDetailCard).toContainText("草稿"); + await expect(batchDetailCard).toContainText("待付款复核"); + await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理"); await expect(batchDetailCard).toContainText("0/1"); await logout(page, FINANCE.name); @@ -157,20 +153,22 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复 await expect(executedBatchDetailCard).toContainText("1/1"); const receiptCard = page.getByTestId("payment-batch-receipt-panel"); await expect(receiptCard).toBeVisible(); - await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({ - name: "r78-bank-receipt.pdf", - mimeType: "application/pdf", - buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"), - }); + await expect(executedBatchDetailCard.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible(); + await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); const receiptUploadResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` && response.ok(); }); - await receiptCard.getByRole("button", { name: "上传" }).click(); + await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({ + name: "r78-bank-receipt.pdf", + mimeType: "application/pdf", + buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"), + }); await receiptUploadResponsePromise; await expect(receiptCard).toContainText("r78-bank-receipt.pdf"); + await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toBeVisible(); const executeBatchResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); @@ -178,11 +176,11 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复 url.pathname === `/api/payment-batches/${batchId}/execute` && response.ok(); }); - await executedBatchDetailCard.getByRole("button", { name: "执行" }).click(); - await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次"); + await executedBatchDetailCard.getByRole("button", { name: "登记付款完成" }).click(); + await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成"); await page.getByTestId("action-confirm-submit").click(); await executeBatchResponsePromise; - await expect(executedBatchDetailCard).toContainText("已执行", { timeout: 30_000 }); + await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 }); await page.goto(`/reimbursements/${reimbursementId}`); await reimbursementDetail.assertLoaded(); diff --git a/client/tests/e2e/system-action-confirmation.spec.ts b/client/tests/e2e/system-action-confirmation.spec.ts index 2431c5d0..3c2cbc12 100644 --- a/client/tests/e2e/system-action-confirmation.spec.ts +++ b/client/tests/e2e/system-action-confirmation.spec.ts @@ -182,15 +182,22 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") { async function mockSystemActionApis( page: Page, - options: { batchStatus?: "draft" | "reviewed"; meOverride?: Partial } = {}, + options: { + batchStatus?: "draft" | "reviewed"; + hasReceipt?: boolean; + receiptQueryError?: boolean; + meOverride?: Partial; + } = {}, ) { const currentMe = { ...me, ...options.meOverride }; const batchStatus = options.batchStatus ?? "reviewed"; + let hasReceipt = options.hasReceipt ?? true; const calls = { deleteReimbursement: 0, deleteReimbursementAttachment: 0, exportReimbursements: 0, executePaymentBatch: 0, + uploadPaymentReceipt: 0, voidPaymentReceipt: 0, attachmentDeleteReasons: [] as string[], receiptVoidReasons: [] as string[], @@ -327,8 +334,19 @@ async function mockSystemActionApis( } if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") { + if (options.receiptQueryError) { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ + requestId: "mock-system-action-confirm", + error: { code: "SYS_001", message: "回单状态暂时不可用" }, + }), + }); + return; + } await fulfillJson(route, { - items: [ + items: hasReceipt ? [ { id: paymentReceiptId, originalName: "合成银行回单.pdf", @@ -348,11 +366,18 @@ async function mockSystemActionApis( uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name }, createdAt: "2026-05-15T08:20:00.000Z", }, - ], + ] : [], }); return; } + if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "POST") { + calls.uploadPaymentReceipt += 1; + hasReceipt = true; + await fulfillJson(route, { items: [{ id: paymentReceiptId }] }); + return; + } + if ( path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/void` && request.method() === "POST" @@ -419,18 +444,37 @@ test.describe("R7E 全站高风险操作二次确认", () => { await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible(); - await page.getByRole("button", { name: "执行" }).click(); - await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次"); + await page.getByRole("button", { name: "登记付款完成" }).click(); + await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成"); expect(calls.executePaymentBatch).toBe(0); await page.getByTestId("action-confirm-cancel").click(); expect(calls.executePaymentBatch).toBe(0); - await page.getByRole("button", { name: "执行" }).click(); + await page.getByRole("button", { name: "登记付款完成" }).click(); await page.getByTestId("action-confirm-submit").click(); await expect.poll(() => calls.executePaymentBatch).toBe(1); }); + test("银行回单上传前后只显示一个付款主动作", async ({ page }) => { + const calls = await mockSystemActionApis(page, { hasReceipt: false }); + await login(page, user); + await page.goto(`/payment-batches?id=${paymentBatchId}`); + + await expect(page.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); + + await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({ + name: "合成银行回单.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF-1.4\nsynthetic receipt\n%%EOF\n", "utf8"), + }); + + await expect.poll(() => calls.uploadPaymentReceipt).toBe(1); + await expect(page.getByRole("button", { name: "登记付款完成" })).toBeVisible(); + await expect(page.getByTestId("payment-batch-detail-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0); + }); + test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => { const calls = await mockSystemActionApis(page); await login(page, user); @@ -493,6 +537,33 @@ test.describe("R7E 全站高风险操作二次确认", () => { await login(page, user); await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible(); - await expect(page.getByRole("button", { name: "复核", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0); + }); + + test("兼任付款复核职责的制单人也不显示自审动作", async ({ page }) => { + await mockSystemActionApis(page, { + batchStatus: "draft", + meOverride: { + roleCodes: ["manager", "finance"], + permissions: [...me.permissions, "payment.batch.review"], + }, + }); + + await login(page, user); + await page.goto(`/payment-batches?id=${paymentBatchId}`); + await expect(page.getByText("制单人不能复核自己的批次")).toBeVisible(); + await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0); + }); + + test("回单查询失败时不把未知状态误报为空且不显示付款动作", async ({ page }) => { + await mockSystemActionApis(page, { receiptQueryError: true }); + + await login(page, user); + await page.goto(`/payment-batches?id=${paymentBatchId}`); + await expect(page.getByText(/服务暂不可用/)).toBeVisible(); + await expect(page.getByRole("button", { name: "重新加载" })).toBeVisible(); + await expect(page.getByText(/暂无银行回单/)).toHaveCount(0); + await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); }); }); diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js index d5a2fc8b..6c363c95 100644 --- a/server/src/routes/exports.js +++ b/server/src/routes/exports.js @@ -422,10 +422,10 @@ function paymentBatchEntityTypeLabel(value) { } function paymentBatchStatusLabel(value) { - if (value === "draft") return "草稿"; - if (value === "reviewed") return "已复核"; - if (value === "executed") return "已执行"; - if (value === "failed") return "执行失败"; + if (value === "draft") return "待付款复核"; + if (value === "reviewed") return "待财务付款"; + if (value === "executed") return "已付款"; + if (value === "failed") return "付款登记失败"; if (value === "cancelled") return "已取消"; return value ?? ""; } diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index cbae1aa0..db0a7f5b 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -7,7 +7,7 @@ const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); -const { parseDateOnlyString } = require("../utils/date"); +const { currentDateString, parseDateOnlyString } = require("../utils/date"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { createNotificationsWithRecords } = require("../services/notifications"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); @@ -50,7 +50,7 @@ const CANDIDATES_QUERY_SCHEMA = z.object({ const CREATE_SCHEMA = z.object({ entityType: ENTITY_TYPE_SCHEMA, - name: z.string().trim().min(1).max(120), + name: z.string().trim().min(1).max(120).optional(), entityIds: z.array(UUID_SCHEMA).min(1).max(200), defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), defaultPaymentChannel: PAYMENT_CHANNEL_SCHEMA.optional(), @@ -132,7 +132,8 @@ function mapReceiptAttachment(item) { } function resolveItemPaymentChannel(batch, item) { - return item.payment_channel_override ?? batch.default_payment_channel ?? null; + return item.payment_channel_override ?? batch.default_payment_channel ?? + (batch.entity_type === "reimbursement" ? "bank" : null); } async function ensureBankReceiptForExecutableBatch(tx, batch) { @@ -270,9 +271,15 @@ async function resolvePaymentReviewRecipientIds(tx, makerId) { }, }); - return candidates - .filter((employee) => employeeHasRolePermission(employee, "payment.batch.review")) - .map((employee) => employee.id); + const eligible = candidates.filter((employee) => + employee.id !== makerId && employeeHasRolePermission(employee, "payment.batch.review"), + ); + const chiefExecutive = eligible.find((employee) => roleCodesOf(employee).includes("ceo")); + if (chiefExecutive) { + return [chiefExecutive.id]; + } + const manager = eligible.find((employee) => roleCodesOf(employee).includes("manager")); + return manager ? [manager.id] : []; } async function resolvePaymentExecutionRecipientIds(tx, { makerId, reviewerId }) { @@ -368,6 +375,10 @@ function buildBatchNo() { return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}-${suffix}`; } +function buildDefaultBatchName(timeZone) { + return `${currentDateString(timeZone ?? "Asia/Shanghai")} 付款批次`; +} + function summarizeBatchRisk(batch) { const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0); const totalAmount = toMoneyString(totalAmountValue); @@ -550,7 +561,7 @@ function mapBatch(batch, openRiskFlags = []) { name: batch.name, status: batch.status, defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null, - defaultPaymentChannel: batch.default_payment_channel ?? null, + defaultPaymentChannel: batch.default_payment_channel ?? (batch.entity_type === "reimbursement" ? "bank" : null), voucherPrefix: batch.voucher_prefix ?? null, totalAmount: batchRisk.totalAmount, highRisk: batchRisk.highRisk, @@ -606,7 +617,7 @@ async function listOpenRiskFlagsForBatch(tx, batch) { return items.map(buildPersistedRiskFlag); } -async function executeReimbursementPayment(tx, { batch, item, actorId, index, request }) { +async function executeReimbursementPayment(tx, { batch, item, actorId, index, request, timeZone }) { const current = await tx.reimbursementForm.findUnique({ where: { id: item.entity_id }, select: { @@ -641,8 +652,8 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index, re (batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null); const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? batch.default_payment_date?.toISOString?.()?.slice(0, 10) - ?? null; - const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null; + ?? currentDateString(timeZone ?? "Asia/Shanghai"); + const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? "bank"; const now = new Date(); const archivedResult = await tx.invoicePool.updateMany({ @@ -823,7 +834,7 @@ async function processPaidReimbursementFollowUps({ fastify, request, reimburseme } } -async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) { +async function executeTravelOrderPayment(tx, { batch, item, actorId, index, timeZone }) { const current = await tx.travelOrder.findUnique({ where: { id: item.entity_id }, select: { @@ -855,7 +866,7 @@ async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) { (batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null); const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? batch.default_payment_date?.toISOString?.()?.slice(0, 10) - ?? null; + ?? currentDateString(timeZone ?? "Asia/Shanghai"); const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null; const matchedTransaction = await tx.cardTransaction.findFirst({ where: { @@ -1199,14 +1210,19 @@ async function paymentBatchesRoutes(fastify) { throw buildValidationError({ entityIds: "contains_active_payment_batch" }, 409); } + const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id); + if (!recipientEmployeeIds.length) { + throw buildValidationError({ review: "no_eligible_reviewer" }, 409); + } + const batch = await tx.paymentBatch.create({ data: { batch_no: buildBatchNo(), entity_type: body.entityType, - name: body.name, + name: body.name ?? buildDefaultBatchName(fastify.config.appTimezone), status: "draft", default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null, - default_payment_channel: body.defaultPaymentChannel ?? null, + default_payment_channel: body.defaultPaymentChannel ?? "bank", voucher_prefix: normalizeNullableString(body.voucherPrefix), created_by: request.employee.id, updated_by: request.employee.id, @@ -1243,7 +1259,6 @@ async function paymentBatchesRoutes(fastify) { }), }); - const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id); const nextNotificationTask = await createPaymentResponsibilityNotification(tx, { recipientEmployeeIds, type: NOTIFICATION_TYPES.paymentBatchReviewRequested, @@ -1850,12 +1865,19 @@ async function paymentBatchesRoutes(fastify) { actorId: request.employee.id, index, request, + timeZone: fastify.config.appTimezone, }); if (!paymentResult.idempotent) { successfulReimbursementIds.push(item.entity_id); } } else { - await executeTravelOrderPayment(tx, { batch, item, actorId: request.employee.id, index }); + await executeTravelOrderPayment(tx, { + batch, + item, + actorId: request.employee.id, + index, + timeZone: fastify.config.appTimezone, + }); } await tx.paymentBatchItem.update({ diff --git a/server/tests/unit/routes/paymentBatches.reimbursement.test.js b/server/tests/unit/routes/paymentBatches.reimbursement.test.js index a51eb826..78b149f1 100644 --- a/server/tests/unit/routes/paymentBatches.reimbursement.test.js +++ b/server/tests/unit/routes/paymentBatches.reimbursement.test.js @@ -6,6 +6,7 @@ const Fastify = require("fastify"); const multipart = require("@fastify/multipart"); const { AppError } = require("../../../src/utils/AppError"); +const { currentDateString } = require("../../../src/utils/date"); const featureFlags = require("../../../src/services/featureFlags"); const notifications = require("../../../src/services/notifications"); const email = require("../../../src/services/email"); @@ -1033,8 +1034,60 @@ describe("payment batch reimbursement routes", () => { await app.close(); }); - it("creates no payment review notification when no eligible non-maker reviewer exists", async () => { - const { app } = await buildApp({ + it("generates the batch title and bank defaults when the client only selects reimbursements", async () => { + const { app, tx } = await buildApp(); + + const response = await app.inject({ + method: "POST", + url: "/api/payment-batches", + headers: { authorization: "Bearer test" }, + payload: { + entityType: "reimbursement", + entityIds: [REIMBURSEMENT_ID], + }, + }); + + expect(response.statusCode).toBe(201); + expect(tx.paymentBatch.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: `${currentDateString("Asia/Shanghai")} 付款批次`, + default_payment_date: null, + default_payment_channel: "bank", + voucher_prefix: null, + status: "draft", + }), + }); + + await app.close(); + }); + + it("notifies one active chief executive before falling back to managers", async () => { + const chiefExecutiveId = "99999999-1111-4111-8111-111111111111"; + const { app, tx } = await buildApp({ + employees: [ + { id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] }, + { id: chiefExecutiveId, employee_no: "CEO001", status: "active", roles: [{ role_code: "ceo" }] }, + ], + }); + + const response = await app.inject({ + method: "POST", + url: "/api/payment-batches", + headers: { authorization: "Bearer test" }, + payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] }, + }); + + expect(response.statusCode).toBe(201); + expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( + tx, + expect.objectContaining({ recipientEmployeeIds: [chiefExecutiveId] }), + ); + + await app.close(); + }); + + it("rejects creation without an eligible non-maker reviewer and leaves no side effects", async () => { + const { app, tx } = await buildApp({ employees: [ { id: ACTOR_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] }, ], @@ -1046,21 +1099,39 @@ describe("payment batch reimbursement routes", () => { headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", - name: "无人复核批次", entityIds: [REIMBURSEMENT_ID], }, }); - expect(response.statusCode).toBe(201); - expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ recipientEmployeeIds: [] }), - ); + expect(response.statusCode).toBe(409); + expect(response.json().error.details.review).toBe("no_eligible_reviewer"); + expect(tx.paymentBatch.create).not.toHaveBeenCalled(); + expect(tx.paymentBatchItem.create).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled(); await app.close(); }); + it("uses the company-local registration date when no payment date was entered", async () => { + const { app, state } = await buildApp({ + batch: { default_payment_date: null }, + }); + + const response = await app.inject({ + method: "POST", + url: `/api/payment-batches/${BATCH_ID}/execute`, + headers: { authorization: "Bearer test" }, + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect(state.reimbursement.payment_date?.toISOString().slice(0, 10)).toBe(currentDateString("Asia/Shanghai")); + + await app.close(); + }); + it("keeps a created batch when payment review notification delivery fails", async () => { createNotificationsWithRecordsMock.mockResolvedValueOnce([ { id: "notif-review-failed", recipient_employee_id: REVIEWER_ID }, @@ -1197,6 +1268,35 @@ describe("payment batch reimbursement routes", () => { await app.close(); }); + it("treats a legacy reimbursement batch without a channel as bank without rewriting it", async () => { + const { app, state } = await buildApp({ + batch: { default_payment_channel: null }, + receiptAttachments: [], + }); + + const detail = await app.inject({ + method: "GET", + url: `/api/payment-batches/${BATCH_ID}`, + headers: { authorization: "Bearer test" }, + }); + expect(detail.statusCode).toBe(200); + expect(detail.json().data.item.defaultPaymentChannel).toBe("bank"); + expect(state.batch.default_payment_channel).toBeNull(); + + const execute = await app.inject({ + method: "POST", + url: `/api/payment-batches/${BATCH_ID}/execute`, + headers: { authorization: "Bearer test" }, + payload: {}, + }); + expect(execute.statusCode).toBe(409); + expect(execute.json().error.details.receiptAttachments).toBe("bank_receipt_required"); + expect(state.batch.default_payment_channel).toBeNull(); + expect(state.reimbursement.status).toBe("approved"); + + await app.close(); + }); + it("rejects execution after the source department leaves the rollout with zero payment side effects", async () => { const { app, state, tx } = await buildApp({ paymentBatchAllowedDepartmentIds: ["dept-2"] });