收口付款批次创建与复核(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 00:29:47 +08:00
父节点 78b80f88d7
当前提交 7c39f8b0f9
共修改 17 个文件,包含 591 行新增和 227 行删除
+128 -9
查看文件
@@ -234,9 +234,11 @@ return [submittedStage, ...approvalStages, batchReviewStage, paymentStage];
### 3. Contracts ### 3. Contracts
- Batch creation resolves active employees whose roles include `manager|ceo` - Batch creation resolves exactly one active non-maker reviewer whose effective
and whose effective role permissions include `payment.batch.review`; exclude role permissions include `payment.batch.review`: choose the first stable
the maker and never hard-code a person's name or employee ID. `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 - Review completion first targets the active maker when that employee still has
`payment.batch.manage`. If unavailable, fall back to other active `payment.batch.manage`. If unavailable, fall back to other active
`finance|finance_mgr` employees with that permission; always exclude the `finance|finance_mgr` employees with that permission; always exclude the
@@ -255,8 +257,10 @@ return [submittedStage, ...approvalStages, batchReviewStage, paymentStage];
### 4. Validation & Error Matrix ### 4. Validation & Error Matrix
- No eligible non-maker reviewer -> create the batch with zero review - No eligible non-maker reviewer -> `409 VALID_001` with
notifications; do not weaken maker-checker or notify the maker. `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 - Maker inactive or missing `payment.batch.manage` after review -> notify the
eligible finance fallback; no eligible fallback -> review remains committed eligible finance fallback; no eligible fallback -> review remains committed
with zero execution notifications. 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 - Good: finance creates one weekly batch, the eligible non-maker reviewer gets
one notification, then successful review returns the action to the active one notification, then successful review returns the action to the active
maker for payment and receipt registration. maker for payment and receipt registration.
- Base: no eligible reviewer is configured; the draft batch remains visible for - Base: one active CEO and several managers qualify; only the CEO selected by
diagnosis and no unqualified management broadcast is fabricated. 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 - Bad: notify every finance/manager account, let the maker review their own
batch, send before transaction commit, or label payment completion as an batch, send before transaction commit, or label payment completion as an
approval. approval.
### 6. Tests Required ### 6. Tests Required
- Route tests assert qualified reviewer resolution, maker exclusion, active - Route tests assert CEO-first single-recipient resolution, manager fallback,
maker preference, inactive-maker finance fallback and reviewer exclusion. 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 - Negative tests assert maker conflict, repeated review and duplicate batch
creation produce no notification/dispatch. creation produce no notification/dispatch.
- Dispatcher tests assert both types are IM-supported and resolve to the - Dispatcher tests assert both types are IM-supported and resolve to the
@@ -316,3 +325,113 @@ const notifications = await createNotificationsWithRecords(tx, {
// after commit // after commit
await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] }); 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 `<company-local YYYY-MM-DD> 付款批次`; 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;
<Button onClick={execute}>执行付款</Button>
```
#### Correct
```tsx
<StatusBadge label={paymentBatchStatusLabel(batch.status)} />
{needsBankReceipt ? <UploadReceipt /> : canRegister ? <RegisterPaid /> : null}
```
@@ -76,3 +76,44 @@
- 移动财务工作台在付款能力开启时固定显示财务卡片,不再要求先存在批次或异常;卡片进入受同一付款权限保护的 `/mobile/payment-batches`,复用现有响应式付款页,避免企微小屏把 PC 路径重定向回首页。 - 移动财务工作台在付款能力开启时固定显示财务卡片,不再要求先存在批次或异常;卡片进入受同一付款权限保护的 `/mobile/payment-batches`,复用现有响应式付款页,避免企微小屏把 PC 路径重定向回首页。
- 入口只对真实 finance/finance_mgr 且具备付款权限的人员显示;不向普通员工或仅审批人开放创建能力。 - 入口只对真实 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` 批次部署后直接按“待付款复核”展示。
- 若新创建合同需回滚,客户端可恢复旧表单,服务端仍接受显式名称和可选默认字段;状态机与历史数据不变。
- 任何真实批次复核、回单上传、付款登记仍由业务人员操作,不纳入部署自动化。
@@ -78,3 +78,20 @@
- 提交 `37b4ef2` 已推送并通过既有安装固化入口部署;环境合同、远端 preflight、migration、`/ready`、external deps、runtime smoke、公网构建标识和前端资源扫描通过。 - 提交 `37b4ef2` 已推送并通过既有安装固化入口部署;环境合同、远端 preflight、migration、`/ready`、external deps、runtime smoke、公网构建标识和前端资源扫描通过。
- runtime smoke 明确证明 env true 与 `payment_batch_v1=flag_force_on_global`;生产只读核验确认唯一 active 财务人员仍有付款管理权限、系统导航含 `paymentBatches`、已批准未付款计数为 1、可建批次候选计数为 1。 - 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。既有真实批次未被读取正文、修改、复核、上传回单或登记付款。
@@ -11,6 +11,8 @@
- 当前员工详情展示“付款批次复核”“付款与回单”,容易被理解为两轮额外审批;实际前者是非制单人付款复核,后者是财务线下付款后的回单与状态登记,不是审批。 - 当前员工详情展示“付款批次复核”“付款与回单”,容易被理解为两轮额外审批;实际前者是非制单人付款复核,后者是财务线下付款后的回单与状态登记,不是审批。
- 当前职责基线:马建勇负责主管审批和非制单付款复核;王芝玲负责财务复核、创建付款批次、线下付款、上传回单和登记付款完成。一个付款批次可以覆盖多张报销单。 - 当前职责基线:马建勇负责主管审批和非制单付款复核;王芝玲负责财务复核、创建付款批次、线下付款、上传回单和登记付款完成。一个付款批次可以覆盖多张报销单。
- `client/src/lib/approvalDisplay.ts` 已把 `start` 映射为“提交”,但 PC `WorkflowTaskDetailPage.tsx` 与移动 `MobileApprovalsPage.tsx` 各自复制了缺少 `start` 的局部映射,导致显示 `start · 杨俊`。 - `client/src/lib/approvalDisplay.ts` 已把 `start` 映射为“提交”,但 PC `WorkflowTaskDetailPage.tsx` 与移动 `MobileApprovalsPage.tsx` 各自复制了缺少 `start` 的局部映射,导致显示 `start · 杨俊`。
- 首张真实付款批次暴露了创建闭环的表达缺陷:服务端在创建事务内已经自动编号、锁定报销、生成付款复核通知并开始投递,但数据库状态和页面仍显示“草稿”,页面也不存在提交接口或提交按钮,导致制单人无法判断是否已经送审、由谁处理、下一步是什么。
- 生产只读核验确认该真实批次已经自动编号,当前未复核/未付款;付款复核通知已经生成并有企微成功投递。当前解析同时创建了 `ceo` 和 `manager` 两类站内收件记录,不符合“一个明确责任人、不要泛发”的小团队运行目标。
## Scope ## Scope
@@ -22,6 +24,9 @@
- 每周集中建立一次付款批次作为当前运行约定,不在本任务建设定时排程、自动组批或强制付款日。 - 每周集中建立一次付款批次作为当前运行约定,不在本任务建设定时排程、自动组批或强制付款日。
- 修复财务首次使用时的入口死锁:付款能力正式常开时,即使尚无付款批次,财务人员也必须能在 PC 和移动工作台看到“付款”入口并进入创建页。 - 修复财务首次使用时的入口死锁:付款能力正式常开时,即使尚无付款批次,财务人员也必须能在 PC 和移动工作台看到“付款”入口并进入创建页。
- 恢复生产 `payment_batch_v1` 正式常开基线,并防止新环境初始化或 QA 窗口收尾把正式常开状态静默改回关闭。 - 恢复生产 `payment_batch_v1` 正式常开基线,并防止新环境初始化或 QA 窗口收尾把正式常开状态静默改回关闭。
- 收口付款批次创建到复核的首个真实闭环:创建页只要求选择待付款报销,编号、标题和常用付款默认值由服务端生成;创建动作明确等价于提交付款复核,不再让用户寻找不存在的二次提交按钮。
- 付款页用业务状态而非内部状态名表达下一责任:`draft` 显示“待付款复核”,`reviewed` 显示“待财务付款”,`executed` 显示“已付款”;详情说明当前责任职责和唯一下一步。
- 付款复核通知收件人按 `ceo` 优先、`manager` 兜底选择一个确定的非制单人;没有合格复核人时创建前失败并给出可解释提示,不生成无人负责的批次。
## Requirements ## Requirements
@@ -34,6 +39,12 @@
- 付款开关恢复前先查询 R54 变更历史;若当前库无变更审计,必须区分“seed 首次创建默认值”和“seed 覆盖已有值”,不得把不存在的 runner/人工操作写成根因。 - 付款开关恢复前先查询 R54 变更历史;若当前库无变更审计,必须区分“seed 首次创建默认值”和“seed 覆盖已有值”,不得把不存在的 runner/人工操作写成根因。
- QA 短窗口结束时必须恢复调用前快照;正式启用流程成功后保持常开,失败才回滚,不得无条件写死 `force_off`。 - QA 短窗口结束时必须恢复调用前快照;正式启用流程成功后保持常开,失败才回滚,不得无条件写死 `force_off`。
- 正式环境和新初始化的试运行环境必须同时满足环境硬开关与数据库开关常开;任何一层关闭都应由部署/运行门禁发现。 - 正式环境和新初始化的试运行环境必须同时满足环境硬开关与数据库开关常开;任何一层关闭都应由部署/运行门禁发现。
- 付款批次内部仍可保留 `draft` 作为兼容状态,但该状态已代表“创建并提交、等待独立复核”,前端、通知和测试不得再把它描述成尚未提交的可编辑草稿。
- 新建批次不要求财务手工编批次名称、付款日期、渠道或凭证前缀;服务端继续自动生成唯一批次编号,按公司时区生成标题,默认付款渠道为银行转账,实际付款日期在登记付款完成时缺省取登记当天。异常项仍可在批次详情中覆盖。
- 付款动作必须使用“登记付款完成”等事实性文案,不得用“执行付款”暗示 OA 会连接银行或自动转账。
- 付款复核仍是一轮 maker-checker;责任收件人只选一个,优先具备复核权限的 active `ceo`,没有时再按稳定顺序选择 active `manager`,始终排除制单人。
- 付款页同一时刻只显示一个主动作:银行渠道复核通过但尚无有效回单时显示“上传银行回单”;上传后才显示“登记付款完成”,不能让用户先撞到 `bank_receipt_required` 才知道前置条件。
- 付款批次状态标签必须使用共享展示映射;列表、详情、移动响应式视图和相关测试不得各自复制 `draft/reviewed/executed/failed/cancelled` 中文文案。
## Acceptance Criteria ## Acceptance Criteria
@@ -47,6 +58,15 @@
- [ ] 财务人员在尚无付款批次但存在已批准待付款报销时,PC 与移动端均可看到“付款”入口,并能看到该报销作为新建批次候选。 - [ ] 财务人员在尚无付款批次但存在已批准待付款报销时,PC 与移动端均可看到“付款”入口,并能看到该报销作为新建批次候选。
- [ ] 正式初始化基线为付款批次常开;QA 窗口测试覆盖原值为 on/off 两种情况并恢复原值,不再硬编码关闭。 - [ ] 正式初始化基线为付款批次常开;QA 窗口测试覆盖原值为 on/off 两种情况并恢复原值,不再硬编码关闭。
- [ ] 生产恢复后只验证入口和候选可见,不代替财务创建批次、负责人复核或执行付款。 - [ ] 生产恢复后只验证入口和候选可见,不代替财务创建批次、负责人复核或执行付款。
- [ ] 新建批次页面只需选择报销;提交后自动得到唯一编号、按公司时区生成的标题、银行转账默认渠道,并直接进入“待付款复核”。
- [ ] 创建按钮显示“创建并提交复核”,成功反馈明确“已提交,等待负责人复核”;页面不存在需要用户寻找的第二个提交动作。
- [ ] 制单人看到“待付款复核”和“负责人复核(制单人不可自审)”;实际复核人看到可用的“复核通过”;复核后财务看到“待财务付款”和“登记付款完成”。
- [ ] 同一批次只通知一个确定的非制单复核责任人,`ceo` 优先、`manager` 兜底;无合格复核人时拒绝创建且不写批次、批次项、审计或通知。
- [ ] 未填写实际付款日期时,登记付款完成以公司时区当天落账;银行转账仍要求先上传银行回单,OA 不执行真实转账。
- [ ] 银行渠道复核通过且没有有效回单时,唯一主动作是“上传银行回单”;上传成功后唯一主动作切换为“登记付款完成”,不会先触发可预见的 409。
- [ ] 无合格复核人的错误明确说明“复核人必须是非制单人且在职,请联系管理员”,不把配置问题伪装成财务人员操作错误。
- [ ] 既有真实批次不改 ID、不改金额、不改报销绑定、不改复核/付款事实;部署后仅获得新的业务文案和下一步说明,不替任何人推进状态。
- [ ] 既有报销批次渠道为空时按银行转账展示并执行回单门禁,但不回写历史记录;当前真实批次同样先上传回单再登记付款完成。
## Out of Scope ## Out of Scope
@@ -55,6 +75,8 @@
- 不建设每周付款日排程、自动组批、批次自动复核或定时通知。 - 不建设每周付款日排程、自动组批、批次自动复核或定时通知。
- 不在未获批准时修改生产通知渠道、员工推送开关、企微绑定、环境变量或服务状态。 - 不在未获批准时修改生产通知渠道、员工推送开关、企微绑定、环境变量或服务状态。
- 不顺带重构全部审批页面或统一所有业务状态标签。 - 不顺带重构全部审批页面或统一所有业务状态标签。
- 不新增“提交中”数据库状态或单独 submit API,不增加第二轮付款审批,不建设可配置的付款复核人选择器。
- 不把批次创建与财务复核合并,不由系统自动复核、自动上传回单或自动登记付款完成。
## Deferred Decision With Trigger ## Deferred Decision With Trigger
+8
查看文件
@@ -173,6 +173,14 @@ export function mapApiErrorMessage(
return "当前批次状态不允许继续复核。"; return "当前批次状态不允许继续复核。";
} }
if (details?.review === "no_eligible_reviewer") {
return "当前没有可复核人(复核人必须是非制单人且在职),请联系管理员。";
}
if (details?.receiptAttachments === "bank_receipt_required") {
return "请先上传银行回单,再登记付款完成。";
}
if (details?.transfer === "chain_limit_exceeded") { if (details?.transfer === "chain_limit_exceeded") {
return "当前任务转审次数已达上限。"; return "当前任务转审次数已达上限。";
} }
+1 -1
查看文件
@@ -123,7 +123,7 @@ export async function fetchPaymentBatchCandidates(query: { q?: string; page?: nu
export async function createPaymentBatch(payload: { export async function createPaymentBatch(payload: {
entityType: PaymentBatchEntityType; entityType: PaymentBatchEntityType;
name: string; name?: string;
entityIds: string[]; entityIds: string[];
defaultPaymentDate?: string; defaultPaymentDate?: string;
defaultPaymentChannel?: PaymentChannel; defaultPaymentChannel?: PaymentChannel;
+14
查看文件
@@ -0,0 +1,14 @@
import type { PaymentBatchStatus } from "../api/paymentBatches";
const PAYMENT_BATCH_STATUS_LABELS: Record<PaymentBatchStatus, string> = {
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;
}
+2 -10
查看文件
@@ -65,6 +65,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { downloadBlob } from "../lib/download"; import { downloadBlob } from "../lib/download";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay";
type OperationsTab = "ledger" | "pending" | "failed" | "risk" | "reconciliation"; type OperationsTab = "ledger" | "pending" | "failed" | "risk" | "reconciliation";
type RiskActionType = "resolve" | "dismiss"; type RiskActionType = "resolve" | "dismiss";
@@ -138,15 +139,6 @@ function entityTypeLabel(value: string) {
return value || "-"; 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) { function reimbursementStatusLabel(value: string) {
if (value === "draft") return "草稿"; if (value === "draft") return "草稿";
if (value === "reviewing") return "审批中"; if (value === "reviewing") return "审批中";
@@ -961,7 +953,7 @@ export function ApOperationsPage() {
<span <span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.status)}`} className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.status)}`}
> >
{batchStatusLabel(item.status)} {paymentBatchStatusLabel(item.status)}
</span> </span>
{item.highRisk ? ( {item.highRisk ? (
<span className="inline-flex rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700"> <span className="inline-flex rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
+78 -119
查看文件
@@ -47,19 +47,10 @@ import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput"; import { DateTextInput } from "../components/common/DateTextInput";
import { downloadBlob } from "../lib/download"; import { downloadBlob } from "../lib/download";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay";
import { toast } from "sonner"; import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"]; 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) { function entityTypeLabel(value: string) {
if (value === "reimbursement") return "报销"; if (value === "reimbursement") return "报销";
@@ -79,23 +70,13 @@ function paymentChannelLabel(value: PaymentChannel | string | null | undefined)
function paymentItemStatusLabel(value: string) { function paymentItemStatusLabel(value: string) {
if (value === "pending") return "待付款"; if (value === "pending") return "待付款";
if (value === "paid") return "已支付"; if (value === "paid") return "已支付";
if (value === "executed") return "已执行"; if (value === "executed") return "已付款";
if (value === "failed") return "付款失败"; if (value === "failed") return "付款失败";
if (value === "cancelled") return "已取消"; if (value === "cancelled") return "已取消";
if (value === "skipped") return "已跳过"; if (value === "skipped") return "已跳过";
return value || "-"; 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) { function formatFileSize(value: number) {
if (!Number.isFinite(value) || value <= 0) return "-"; if (!Number.isFinite(value) || value <= 0) return "-";
if (value < 1024) return `${value} B`; if (value < 1024) return `${value} B`;
@@ -119,14 +100,9 @@ export function PaymentBatchesPage() {
const canReviewBatch = const canReviewBatch =
hasPermission(permissions, "payment.batch.review") && hasPermission(permissions, "payment.batch.review") &&
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo")); (me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const [name, setName] = useState("");
const [candidateSearch, setCandidateSearch] = useState(""); const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]); const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [defaultPaymentDate, setDefaultPaymentDate] = useState("");
const [defaultPaymentChannel, setDefaultPaymentChannel] = useState<PaymentChannel | "">("");
const [voucherPrefix, setVoucherPrefix] = useState("");
const receiptInputRef = useRef<HTMLInputElement | null>(null); const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showCreatePanel, setShowCreatePanel] = useState(false); const [showCreatePanel, setShowCreatePanel] = useState(false);
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{ const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
@@ -172,9 +148,11 @@ export function PaymentBatchesPage() {
canManageBatch && canManageBatch &&
!!selectedBatch && !!selectedBatch &&
selectedBatch.status !== "cancelled"; selectedBatch.status !== "cancelled";
const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess;
const canReviewSelectedBatch = const canReviewSelectedBatch =
canReviewBatch && canReviewBatch &&
!!selectedBatch && !!selectedBatch &&
selectedBatch.createdBy !== me?.id &&
( (
selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review" selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review"
); );
@@ -183,6 +161,16 @@ export function PaymentBatchesPage() {
!!selectedBatch && !!selectedBatch &&
["reviewed", "failed"].includes(selectedBatch.status) && ["reviewed", "failed"].includes(selectedBatch.status) &&
selectedBatch.reviewRequirement.nextStep === "ready_for_execute"; 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 = const canCancelSelectedBatch =
canManageBatch && canManageBatch &&
!!selectedBatch && !!selectedBatch &&
@@ -205,11 +193,7 @@ export function PaymentBatchesPage() {
mutationFn: async () => mutationFn: async () =>
createPaymentBatch({ createPaymentBatch({
entityType: "reimbursement", entityType: "reimbursement",
name: name.trim() || "报销付款批次",
entityIds: selectedCandidateIds, entityIds: selectedCandidateIds,
defaultPaymentDate: defaultPaymentDate || undefined,
defaultPaymentChannel: defaultPaymentChannel || undefined,
voucherPrefix: voucherPrefix || undefined,
}), }),
onSuccess: async ({ item }) => { onSuccess: async ({ item }) => {
setError(""); setError("");
@@ -219,7 +203,7 @@ export function PaymentBatchesPage() {
next.set("id", item.id); next.set("id", item.id);
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
await refresh(); await refresh();
toast.success("付款批次已创建"); toast.success("已提交付款复核,等待负责人处理");
}, },
onError: (err) => { onError: (err) => {
const message = resolveError(err); const message = resolveError(err);
@@ -251,7 +235,7 @@ export function PaymentBatchesPage() {
onSuccess: async () => { onSuccess: async () => {
setError(""); setError("");
await refresh(); await refresh();
toast.success("付款批次已执行"); toast.success("付款已登记完成");
}, },
onError: (err) => { onError: (err) => {
const message = resolveError(err); const message = resolveError(err);
@@ -296,18 +280,17 @@ export function PaymentBatchesPage() {
}); });
const uploadReceiptMutation = useMutation({ const uploadReceiptMutation = useMutation({
mutationFn: async () => { mutationFn: async (files: File[]) => {
if (!selectedBatch) { if (!selectedBatch) {
throw new Error("请先选择付款批次"); throw new Error("请先选择付款批次");
} }
if (!selectedReceiptFiles.length) { if (!files.length) {
throw new Error("请选择银行回单附件"); throw new Error("请选择银行回单附件");
} }
return uploadPaymentBatchReceiptAttachments(selectedBatch.id, selectedReceiptFiles); return uploadPaymentBatchReceiptAttachments(selectedBatch.id, files);
}, },
onSuccess: async () => { onSuccess: async () => {
setError(""); setError("");
setSelectedReceiptFiles([]);
if (receiptInputRef.current) { if (receiptInputRef.current) {
receiptInputRef.current.value = ""; receiptInputRef.current.value = "";
} }
@@ -400,16 +383,6 @@ export function PaymentBatchesPage() {
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。 当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
</div> </div>
) : null} ) : null}
<div className="space-y-2">
<Label>批次名称</Label>
<Input
data-testid="payment-batch-create-name"
value={name}
disabled={!canManageBatch}
onChange={(event) => setName(event.target.value)}
placeholder="例如:2026Q2-财务付款批次-01"
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>选择待付款报销</Label> <Label>选择待付款报销</Label>
<Input <Input
@@ -457,44 +430,12 @@ export function PaymentBatchesPage() {
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div> <div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div>
) : null} ) : null}
</div> </div>
<div className="text-xs text-zinc-500">已选择 {selectedCandidateIds.length} 项;创建时服务端会再次核对审批和付款状态。</div> <div className="text-xs text-zinc-500">
</div> 已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,默认按银行转账提交负责人复核。
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-2">
<Label>默认付款日期</Label>
<DateTextInput
data-testid="payment-batch-create-payment-date"
value={defaultPaymentDate}
disabled={!canManageBatch}
onChange={(event) => setDefaultPaymentDate(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label>默认渠道</Label>
<select
className="select-std"
data-testid="payment-batch-create-payment-channel"
value={defaultPaymentChannel}
disabled={!canManageBatch}
onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "")}
>
<option value="">不设置</option>
{PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{paymentChannelLabel(item)}</option>)}
</select>
</div>
<div className="space-y-2">
<Label>凭证前缀</Label>
<Input
data-testid="payment-batch-create-voucher-prefix"
value={voucherPrefix}
disabled={!canManageBatch}
onChange={(event) => setVoucherPrefix(event.target.value)}
placeholder="例如 PBX"
/>
</div> </div>
</div> </div>
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}> <Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建批次"} {createMutation.isPending ? "提交中..." : "创建并提交复核"}
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -514,11 +455,10 @@ export function PaymentBatchesPage() {
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{canReviewBatch ? ( {canReviewSelectedBatch ? (
<Button <Button
type="button" type="button"
variant="secondary" disabled={reviewMutation.isPending}
disabled={reviewMutation.isPending || !canReviewSelectedBatch}
onClick={() => setConfirmPaymentAction({ onClick={() => setConfirmPaymentAction({
kind: "financeReview", kind: "financeReview",
batchId: selectedBatch.id, batchId: selectedBatch.id,
@@ -527,13 +467,22 @@ export function PaymentBatchesPage() {
totalAmount: selectedBatch.totalAmount, totalAmount: selectedBatch.totalAmount,
})} })}
> >
复核 复核通过
</Button> </Button>
) : null} ) : null}
{canManageBatch ? ( {needsBankReceipt ? (
<Button <Button
type="button" type="button"
disabled={executeMutation.isPending || !canExecuteSelectedBatch} disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
) : null}
{canRegisterPaymentComplete ? (
<Button
type="button"
disabled={executeMutation.isPending}
onClick={() => setConfirmPaymentAction({ onClick={() => setConfirmPaymentAction({
kind: "financePayment", kind: "financePayment",
batchId: selectedBatch.id, batchId: selectedBatch.id,
@@ -542,7 +491,7 @@ export function PaymentBatchesPage() {
totalAmount: selectedBatch.totalAmount, totalAmount: selectedBatch.totalAmount,
})} })}
> >
执行 登记付款完成
</Button> </Button>
) : null} ) : null}
{canManageBatch ? ( {canManageBatch ? (
@@ -565,7 +514,7 @@ export function PaymentBatchesPage() {
</div> </div>
</div> </div>
<div className="detail-fact-grid text-sm xl:grid-cols-6"> <div className="detail-fact-grid text-sm xl:grid-cols-6">
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{batchStatusLabel(selectedBatch.status)}</div></div> <div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div> <div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{selectedBatch.totalAmount}</div></div> <div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{selectedBatch.totalAmount}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div> <div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div>
@@ -573,10 +522,20 @@ export function PaymentBatchesPage() {
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div> <div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
当前要求:单次独立复核后可执行。 {selectedBatch.status === "draft"
{selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},执行前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""} ? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。"
下一步:{reviewNextStepLabel(selectedBatch.reviewRequirement.nextStep)}。 : selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
{selectedBatch.reviewedBy ? ` 复核人:${reviewerDisplayName(selectedBatch.reviewedBy)}` : ""} ? "付款复核已通过,正在核对银行回单状态。"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "付款复核已通过。请财务先在线下银行完成付款,再上传银行回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
? "付款已登记完成。"
: selectedBatch.status === "failed"
? "付款登记失败,请财务核对失败原因后重试。"
: "该付款批次已取消。"}
{selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""} {selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
</div> </div>
</div> </div>
@@ -585,9 +544,9 @@ export function PaymentBatchesPage() {
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">银行回单附件</h3> <h3 className="text-base font-semibold text-zinc-900">银行回单附件</h3>
<p className="mt-1 text-sm text-zinc-500">先在网银完成对公付款,再上传银行回单并执行 OA 已付款标记。</p> <p className="mt-1 text-sm text-zinc-500">先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。</p>
</div> </div>
{canManageBatch ? ( {canUploadReceipt ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<input <input
ref={receiptInputRef} ref={receiptInputRef}
@@ -596,35 +555,35 @@ export function PaymentBatchesPage() {
className="hidden" className="hidden"
accept=".pdf,image/jpeg,image/png,image/webp" accept=".pdf,image/jpeg,image/png,image/webp"
multiple multiple
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending} disabled={uploadReceiptMutation.isPending}
onChange={(event) => setSelectedReceiptFiles(Array.from(event.target.files ?? []))} onChange={(event) => {
const files = Array.from(event.target.files ?? []);
if (files.length) uploadReceiptMutation.mutate(files);
}}
/> />
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending} disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()} onClick={() => receiptInputRef.current?.click()}
> >
选择回单 {uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
<Button
type="button"
disabled={!canEditReceiptAttachments || uploadReceiptMutation.isPending || selectedReceiptFiles.length === 0}
onClick={() => uploadReceiptMutation.mutate()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传"}
</Button> </Button>
</div> </div>
) : null} ) : null}
</div> </div>
{selectedReceiptFiles.length ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
待上传:{selectedReceiptFiles.map((file) => file.name).join("、")}
</div>
) : null}
{receiptAttachmentsQuery.isError ? ( {receiptAttachmentsQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"> <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(receiptAttachmentsQuery.error)} <span>{resolveError(receiptAttachmentsQuery.error)}</span>
<Button
type="button"
size="sm"
variant="secondary"
disabled={receiptAttachmentsQuery.isFetching}
onClick={() => void receiptAttachmentsQuery.refetch()}
>
{receiptAttachmentsQuery.isFetching ? "重新加载中..." : "重新加载"}
</Button>
</div> </div>
) : null} ) : null}
<div className="space-y-2"> <div className="space-y-2">
@@ -671,9 +630,9 @@ export function PaymentBatchesPage() {
</div> </div>
</div> </div>
))} ))}
{!receiptAttachmentsQuery.isLoading && !(receiptAttachmentsQuery.data?.items ?? []).length ? ( {receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500"> <div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
暂无银行回单附件;默认银行渠道执行前需要上传至少一份回单。 暂无银行回单;银行转账必须先上传回单,才能登记付款完成。
</div> </div>
) : null} ) : null}
</div> </div>
@@ -752,7 +711,7 @@ export function PaymentBatchesPage() {
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.batchNo}</div> <div className="font-medium text-zinc-900">{item.batchNo}</div>
<StatusBadge status={item.status} label={batchStatusLabel(item.status)} /> <StatusBadge status={item.status} label={paymentBatchStatusLabel(item.status)} />
</div> </div>
<div className="mt-1 text-sm text-zinc-600">{item.name}</div> <div className="mt-1 text-sm text-zinc-600">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs text-zinc-500">
@@ -790,7 +749,7 @@ export function PaymentBatchesPage() {
</td> </td>
<td className="table-td"> <td className="table-td">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<StatusBadge status={item.status} label={batchStatusLabel(item.status)} /> <StatusBadge status={item.status} label={paymentBatchStatusLabel(item.status)} />
{item.highRisk ? <span className="badge-warning">高风险</span> : null} {item.highRisk ? <span className="badge-warning">高风险</span> : null}
</div> </div>
</td> </td>
@@ -834,8 +793,8 @@ export function PaymentBatchesPage() {
? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。` ? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。`
: "确认后将完成一次独立复核并写入付款批次审计记录。" : "确认后将完成一次独立复核并写入付款批次审计记录。"
: confirmPaymentAction?.highRisk : confirmPaymentAction?.highRisk
? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将更新源单付款状态。` ? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将登记付款完成并更新源单状态。`
: "确认后将执行付款批次并影响付款状态,请确认批次、金额和回单均已核对。" : "请确认线下付款和银行回单均已核对。确认后将登记付款完成并更新源单状态。"
} }
onConfirm={async () => { onConfirm={async () => {
if (!confirmPaymentAction) return; if (!confirmPaymentAction) return;
+12 -18
查看文件
@@ -107,10 +107,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0); 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 reimbursement = await apiGetData<{ formNumber: string | null }>(page.request, `/api/reimbursements/${reimbursementId}`);
const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId; const reimbursementDisplayCode = reimbursement.formNumber ?? reimbursementId;
const batchName = `E2E-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0); await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "新建批次" }).click(); await page.getByRole("button", { name: "新建批次" }).click();
@@ -120,18 +118,15 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok(); 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); await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first(); const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible(); await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check(); await candidateRow.getByRole("checkbox").check();
await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate); await createCard.getByRole("button", { name: "创建并提交复核" }).click();
await createCard.getByTestId("payment-batch-create-payment-channel").selectOption("bank"); const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string; name?: string } } };
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 } } };
const batchId = createBatchPayload.data?.item?.id; const batchId = createBatchPayload.data?.item?.id;
if (!batchId) { const batchName = createBatchPayload.data?.item?.name;
if (!batchId || !batchName) {
throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
} }
@@ -155,24 +150,23 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
await expect(executedBatchDetailCard).toContainText("1/1"); await expect(executedBatchDetailCard).toContainText("1/1");
const receiptCard = page.getByTestId("payment-batch-receipt-panel"); const receiptCard = page.getByTestId("payment-batch-receipt-panel");
await expect(receiptCard).toBeVisible(); 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 receiptUploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && return response.request().method() === "POST" &&
url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` && url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` &&
response.ok(); 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 receiptUploadResponsePromise;
await expect(receiptCard).toContainText("e2e-bank-receipt.pdf"); await expect(receiptCard).toContainText("e2e-bank-receipt.pdf");
await executedBatchDetailCard.getByRole("button", { name: "执行" }).click(); await executedBatchDetailCard.getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次"); await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
await page.getByTestId("action-confirm-submit").click(); 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 page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded(); await reimbursementDetail.assertLoaded();
@@ -1159,8 +1159,6 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
}; };
} }
const batchName = `${trialrunMarkerPrefix} 浏览器付款批次 ${marker}`;
state.paymentBatchName = batchName;
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page.getByRole("heading", { name: "付款", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "付款", exact: true })).toBeVisible();
if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) { if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) {
@@ -1174,21 +1172,17 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
} }
await page.getByRole("button", { name: "新建批次" }).click(); await page.getByRole("button", { name: "新建批次" }).click();
const createCard = page.getByTestId("payment-batch-create-panel"); 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 createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches"; return response.request().method() === "POST" && url.pathname === "/api/payment-batches";
}); });
await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName);
const reimbursementCode = state.reimbursementCode ?? state.reimbursementId; const reimbursementCode = state.reimbursementCode ?? state.reimbursementId;
await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementCode); await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementCode }).first(); const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementCode }).first();
await expect(candidateRow).toBeVisible(); await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check(); await candidateRow.getByRole("checkbox").check();
await createCard.locator('input[type="date"]').fill(new Date().toISOString().slice(0, 10)); await createCard.getByRole("button", { name: "创建并提交复核" }).click();
await createCard.locator("select").nth(0).selectOption("bank");
await createCard.getByPlaceholder("例如 PBX").fill(`${trialrunSafePrefix}-${marker.slice(-6)}`);
await createCard.getByRole("button", { name: "创建批次" }).click();
const createBatchResponse = await createBatchResponsePromise; const createBatchResponse = await createBatchResponsePromise;
if (!createBatchResponse.ok()) { if (!createBatchResponse.ok()) {
throw new Error(`创建付款批次失败:HTTP ${createBatchResponse.status()} ${await createBatchResponse.text()}`); throw new Error(`创建付款批次失败:HTTP ${createBatchResponse.status()} ${await createBatchResponse.text()}`);
@@ -1198,6 +1192,8 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
if (!batch?.id) { if (!batch?.id) {
throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
} }
const batchName = batch.name;
state.paymentBatchName = batchName;
state.paymentBatchId = batch.id; state.paymentBatchId = batch.id;
await logoutLiveUser(page, { name: finance.name }).catch(() => undefined); await logoutLiveUser(page, { name: finance.name }).catch(() => undefined);
@@ -1215,10 +1211,23 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
const financeAgain = await loginRole(page, "finance"); const financeAgain = await loginRole(page, "finance");
await page.goto(`/payment-batches?id=${batch.id}`); 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 expect(batchDetailCard).toBeVisible();
await batchDetailCard.getByRole("button", { name: "执行" }).click(); const receiptResponsePromise = page.waitForResponse((response) => {
await expect(batchDetailCard).toContainText("状态:已执行", { timeout: 30_000 }); 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 page.goto(`/reimbursements/${state.reimbursementId}`);
await reimbursementDetail.assertLoaded(); await reimbursementDetail.assertLoaded();
await expect(page.getByTestId("reimbursement-payment-status")).toContainText(/已付款|paid/, { timeout: 30_000 }); await expect(page.getByTestId("reimbursement-payment-status")).toContainText(/已付款|paid/, { timeout: 30_000 });
@@ -1232,7 +1241,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
if (/\/403/.test(page.url())) { if (/\/403/.test(page.url())) {
await expect(page.getByRole("heading", { name: "无权限访问", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "无权限访问", exact: true })).toBeVisible();
} else { } else {
const createButton = page.getByRole("button", { name: "创建批次" }).first(); const createButton = page.getByRole("button", { name: "创建并提交复核" }).first();
await expect(createButton).toBeDisabled(); await expect(createButton).toBeDisabled();
} }
await logoutLiveUser(page, { name: admin.name }).catch(() => undefined); await logoutLiveUser(page, { name: admin.name }).catch(() => undefined);
@@ -414,16 +414,14 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
await expect(page.getByText(PAYMENT_CANDIDATE_ENTITY_ID, { exact: true })).toHaveCount(0); 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 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 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).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`));
await expect(page.getByRole("heading", { level: 1, name: "付款", exact: true })).toBeVisible(); 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({ expect(createPayload).toEqual({
entityType: "reimbursement", entityType: "reimbursement",
name: "合成付款批次",
entityIds: [PAYMENT_CANDIDATE_ENTITY_ID], entityIds: [PAYMENT_CANDIDATE_ENTITY_ID],
}); });
expectNoPageErrors(pageErrors); expectNoPageErrors(pageErrors);
@@ -24,7 +24,6 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const adminApi = await createAuthenticatedApiClient(ADMIN); const adminApi = await createAuthenticatedApiClient(ADMIN);
const employeeApi = await createAuthenticatedApiClient(EMPLOYEE); const employeeApi = await createAuthenticatedApiClient(EMPLOYEE);
const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR); const financeMgrApi = await createAuthenticatedApiClient(FINANCE_MGR);
const paymentDate = new Date().toISOString().slice(0, 10);
try { try {
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow"); await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow");
@@ -100,7 +99,6 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await logout(page, secondApprovalUser.name); await logout(page, secondApprovalUser.name);
await login(page, FINANCE); await login(page, FINANCE);
const batchName = `R78-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches/); await expect(page).toHaveURL(/\/payment-batches/);
@@ -112,27 +110,25 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok(); 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); await createCard.getByTestId("payment-batch-candidate-search").fill(reimbursementDisplayCode);
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first(); const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible(); await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check(); await candidateRow.getByRole("checkbox").check();
await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate); await createCard.getByRole("button", { name: "创建并提交复核" }).click();
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();
const createBatchPayload = (await (await createBatchResponsePromise).json()) as { const createBatchPayload = (await (await createBatchResponsePromise).json()) as {
data?: { item?: { id?: string } }; data?: { item?: { id?: string; name?: string } };
}; };
const batchId = createBatchPayload.data?.item?.id; const batchId = createBatchPayload.data?.item?.id;
if (!batchId) { const batchName = createBatchPayload.data?.item?.name;
if (!batchId || !batchName) {
throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
} }
await page.goto(`/payment-batches?id=${batchId}`); await page.goto(`/payment-batches?id=${batchId}`);
const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(batchDetailCard).toBeVisible(); await expect(batchDetailCard).toBeVisible();
await expect(batchDetailCard).toContainText("草稿"); await expect(batchDetailCard).toContainText("待付款复核");
await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理");
await expect(batchDetailCard).toContainText("0/1"); await expect(batchDetailCard).toContainText("0/1");
await logout(page, FINANCE.name); await logout(page, FINANCE.name);
@@ -157,20 +153,22 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await expect(executedBatchDetailCard).toContainText("1/1"); await expect(executedBatchDetailCard).toContainText("1/1");
const receiptCard = page.getByTestId("payment-batch-receipt-panel"); const receiptCard = page.getByTestId("payment-batch-receipt-panel");
await expect(receiptCard).toBeVisible(); await expect(receiptCard).toBeVisible();
await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({ await expect(executedBatchDetailCard.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible();
name: "r78-bank-receipt.pdf", await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
mimeType: "application/pdf",
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
});
const receiptUploadResponsePromise = page.waitForResponse((response) => { const receiptUploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && return response.request().method() === "POST" &&
url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` && url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` &&
response.ok(); 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 receiptUploadResponsePromise;
await expect(receiptCard).toContainText("r78-bank-receipt.pdf"); await expect(receiptCard).toContainText("r78-bank-receipt.pdf");
await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toBeVisible();
const executeBatchResponsePromise = page.waitForResponse((response) => { const executeBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
@@ -178,11 +176,11 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
url.pathname === `/api/payment-batches/${batchId}/execute` && url.pathname === `/api/payment-batches/${batchId}/execute` &&
response.ok(); response.ok();
}); });
await executedBatchDetailCard.getByRole("button", { name: "执行" }).click(); await executedBatchDetailCard.getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次"); await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
await page.getByTestId("action-confirm-submit").click(); await page.getByTestId("action-confirm-submit").click();
await executeBatchResponsePromise; await executeBatchResponsePromise;
await expect(executedBatchDetailCard).toContainText("已执行", { timeout: 30_000 }); await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
await page.goto(`/reimbursements/${reimbursementId}`); await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded(); await reimbursementDetail.assertLoaded();
@@ -182,15 +182,22 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
async function mockSystemActionApis( async function mockSystemActionApis(
page: Page, page: Page,
options: { batchStatus?: "draft" | "reviewed"; meOverride?: Partial<typeof me> } = {}, options: {
batchStatus?: "draft" | "reviewed";
hasReceipt?: boolean;
receiptQueryError?: boolean;
meOverride?: Partial<typeof me>;
} = {},
) { ) {
const currentMe = { ...me, ...options.meOverride }; const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed"; const batchStatus = options.batchStatus ?? "reviewed";
let hasReceipt = options.hasReceipt ?? true;
const calls = { const calls = {
deleteReimbursement: 0, deleteReimbursement: 0,
deleteReimbursementAttachment: 0, deleteReimbursementAttachment: 0,
exportReimbursements: 0, exportReimbursements: 0,
executePaymentBatch: 0, executePaymentBatch: 0,
uploadPaymentReceipt: 0,
voidPaymentReceipt: 0, voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[], attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[], receiptVoidReasons: [] as string[],
@@ -327,8 +334,19 @@ async function mockSystemActionApis(
} }
if (path === `/api/payment-batches/${paymentBatchId}/receipt-attachments` && request.method() === "GET") { 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, { await fulfillJson(route, {
items: [ items: hasReceipt ? [
{ {
id: paymentReceiptId, id: paymentReceiptId,
originalName: "合成银行回单.pdf", originalName: "合成银行回单.pdf",
@@ -348,11 +366,18 @@ async function mockSystemActionApis(
uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name }, uploadedBy: { id: me.id, employeeNo: me.employeeNo, name: me.name },
createdAt: "2026-05-15T08:20:00.000Z", createdAt: "2026-05-15T08:20:00.000Z",
}, },
], ] : [],
}); });
return; 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 ( if (
path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/void` && path === `/api/payment-batches/${paymentBatchId}/receipt-attachments/${paymentReceiptId}/void` &&
request.method() === "POST" request.method() === "POST"
@@ -419,18 +444,37 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/payment-batches?id=${paymentBatchId}`); await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible(); await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible();
await page.getByRole("button", { name: "执行" }).click(); await page.getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次"); await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
expect(calls.executePaymentBatch).toBe(0); expect(calls.executePaymentBatch).toBe(0);
await page.getByTestId("action-confirm-cancel").click(); await page.getByTestId("action-confirm-cancel").click();
expect(calls.executePaymentBatch).toBe(0); 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 page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.executePaymentBatch).toBe(1); 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 }) => { test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page); const calls = await mockSystemActionApis(page);
await login(page, user); await login(page, user);
@@ -493,6 +537,33 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await login(page, user); await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`); await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("PAY-ACTION-001").first()).toBeVisible(); 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);
}); });
}); });
+4 -4
查看文件
@@ -422,10 +422,10 @@ function paymentBatchEntityTypeLabel(value) {
} }
function paymentBatchStatusLabel(value) { function paymentBatchStatusLabel(value) {
if (value === "draft") return "草稿"; if (value === "draft") return "待付款复核";
if (value === "reviewed") return "已复核"; if (value === "reviewed") return "待财务付款";
if (value === "executed") return "已执行"; if (value === "executed") return "已付款";
if (value === "failed") return "执行失败"; if (value === "failed") return "付款登记失败";
if (value === "cancelled") return "已取消"; if (value === "cancelled") return "已取消";
return value ?? ""; return value ?? "";
} }
+38 -16
查看文件
@@ -7,7 +7,7 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError"); const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply"); const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit"); 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 { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { createNotificationsWithRecords } = require("../services/notifications"); const { createNotificationsWithRecords } = require("../services/notifications");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
@@ -50,7 +50,7 @@ const CANDIDATES_QUERY_SCHEMA = z.object({
const CREATE_SCHEMA = z.object({ const CREATE_SCHEMA = z.object({
entityType: ENTITY_TYPE_SCHEMA, 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), entityIds: z.array(UUID_SCHEMA).min(1).max(200),
defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
defaultPaymentChannel: PAYMENT_CHANNEL_SCHEMA.optional(), defaultPaymentChannel: PAYMENT_CHANNEL_SCHEMA.optional(),
@@ -132,7 +132,8 @@ function mapReceiptAttachment(item) {
} }
function resolveItemPaymentChannel(batch, 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) { async function ensureBankReceiptForExecutableBatch(tx, batch) {
@@ -270,9 +271,15 @@ async function resolvePaymentReviewRecipientIds(tx, makerId) {
}, },
}); });
return candidates const eligible = candidates.filter((employee) =>
.filter((employee) => employeeHasRolePermission(employee, "payment.batch.review")) employee.id !== makerId && employeeHasRolePermission(employee, "payment.batch.review"),
.map((employee) => employee.id); );
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 }) { async function resolvePaymentExecutionRecipientIds(tx, { makerId, reviewerId }) {
@@ -368,6 +375,10 @@ function buildBatchNo() {
return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}-${suffix}`; return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}-${suffix}`;
} }
function buildDefaultBatchName(timeZone) {
return `${currentDateString(timeZone ?? "Asia/Shanghai")} 付款批次`;
}
function summarizeBatchRisk(batch) { function summarizeBatchRisk(batch) {
const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0); const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0);
const totalAmount = toMoneyString(totalAmountValue); const totalAmount = toMoneyString(totalAmountValue);
@@ -550,7 +561,7 @@ function mapBatch(batch, openRiskFlags = []) {
name: batch.name, name: batch.name,
status: batch.status, status: batch.status,
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null, 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, voucherPrefix: batch.voucher_prefix ?? null,
totalAmount: batchRisk.totalAmount, totalAmount: batchRisk.totalAmount,
highRisk: batchRisk.highRisk, highRisk: batchRisk.highRisk,
@@ -606,7 +617,7 @@ async function listOpenRiskFlagsForBatch(tx, batch) {
return items.map(buildPersistedRiskFlag); 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({ const current = await tx.reimbursementForm.findUnique({
where: { id: item.entity_id }, where: { id: item.entity_id },
select: { 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); (batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10) const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
?? batch.default_payment_date?.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 paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? "bank";
const now = new Date(); const now = new Date();
const archivedResult = await tx.invoicePool.updateMany({ 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({ const current = await tx.travelOrder.findUnique({
where: { id: item.entity_id }, where: { id: item.entity_id },
select: { 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); (batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10) const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
?? batch.default_payment_date?.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 paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null;
const matchedTransaction = await tx.cardTransaction.findFirst({ const matchedTransaction = await tx.cardTransaction.findFirst({
where: { where: {
@@ -1199,14 +1210,19 @@ async function paymentBatchesRoutes(fastify) {
throw buildValidationError({ entityIds: "contains_active_payment_batch" }, 409); 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({ const batch = await tx.paymentBatch.create({
data: { data: {
batch_no: buildBatchNo(), batch_no: buildBatchNo(),
entity_type: body.entityType, entity_type: body.entityType,
name: body.name, name: body.name ?? buildDefaultBatchName(fastify.config.appTimezone),
status: "draft", status: "draft",
default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null, 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), voucher_prefix: normalizeNullableString(body.voucherPrefix),
created_by: request.employee.id, created_by: request.employee.id,
updated_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, { const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds, recipientEmployeeIds,
type: NOTIFICATION_TYPES.paymentBatchReviewRequested, type: NOTIFICATION_TYPES.paymentBatchReviewRequested,
@@ -1850,12 +1865,19 @@ async function paymentBatchesRoutes(fastify) {
actorId: request.employee.id, actorId: request.employee.id,
index, index,
request, request,
timeZone: fastify.config.appTimezone,
}); });
if (!paymentResult.idempotent) { if (!paymentResult.idempotent) {
successfulReimbursementIds.push(item.entity_id); successfulReimbursementIds.push(item.entity_id);
} }
} else { } 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({ await tx.paymentBatchItem.update({
@@ -6,6 +6,7 @@ const Fastify = require("fastify");
const multipart = require("@fastify/multipart"); const multipart = require("@fastify/multipart");
const { AppError } = require("../../../src/utils/AppError"); const { AppError } = require("../../../src/utils/AppError");
const { currentDateString } = require("../../../src/utils/date");
const featureFlags = require("../../../src/services/featureFlags"); const featureFlags = require("../../../src/services/featureFlags");
const notifications = require("../../../src/services/notifications"); const notifications = require("../../../src/services/notifications");
const email = require("../../../src/services/email"); const email = require("../../../src/services/email");
@@ -1033,8 +1034,60 @@ describe("payment batch reimbursement routes", () => {
await app.close(); await app.close();
}); });
it("creates no payment review notification when no eligible non-maker reviewer exists", async () => { it("generates the batch title and bank defaults when the client only selects reimbursements", async () => {
const { app } = await buildApp({ 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: [ employees: [
{ id: ACTOR_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] }, { 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" }, headers: { authorization: "Bearer test" },
payload: { payload: {
entityType: "reimbursement", entityType: "reimbursement",
name: "无人复核批次",
entityIds: [REIMBURSEMENT_ID], entityIds: [REIMBURSEMENT_ID],
}, },
}); });
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(409);
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( expect(response.json().error.details.review).toBe("no_eligible_reviewer");
expect.any(Object), expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect.objectContaining({ recipientEmployeeIds: [] }), expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
); expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled(); expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
await app.close(); 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 () => { it("keeps a created batch when payment review notification delivery fails", async () => {
createNotificationsWithRecordsMock.mockResolvedValueOnce([ createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-review-failed", recipient_employee_id: REVIEWER_ID }, { id: "notif-review-failed", recipient_employee_id: REVIEWER_ID },
@@ -1197,6 +1268,35 @@ describe("payment batch reimbursement routes", () => {
await app.close(); 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 () => { 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"] }); const { app, state, tx } = await buildApp({ paymentBatchAllowedDepartmentIds: ["dept-2"] });