From 5d274af8d4330b7ddb17168bf169052673f46382 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 2 Aug 2026 09:02:40 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BB=98=E6=AC=BE=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E4=B8=8E=E6=89=B9=E6=AC=A1=E7=9F=AD=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=EF=BC=8808-01-reimbursement-notification-progress-clarity?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/backend-fastify-prisma.md | 83 ++++++++++ .../design.md | 27 +++ .../implement.md | 17 ++ .../prd.md | 8 + client/src/pages/PaymentBatchesPage.tsx | 156 +++++++++++------- .../e2e/payment-reviewer-permissions.spec.ts | 2 +- .../e2e/system-action-confirmation.spec.ts | 35 +++- server/src/constants/advisoryLocks.js | 7 + server/src/routes/paymentBatches.js | 56 +++++-- .../check-payment-postgres-concurrency.js | 55 ++++++ .../paymentBatches.reimbursement.test.js | 103 +++++++++++- 11 files changed, 467 insertions(+), 82 deletions(-) create mode 100644 server/src/constants/advisoryLocks.js diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 7b104e91..7e4f1588 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -514,3 +514,86 @@ const finalSummary = summarizeFinalItemStatuses(finalItemStatuses); batch.execution_summary_json = finalSummary; batch.status = finalSummary.failedCount > 0 ? "failed" : "executed"; ``` + +## Scenario: PostgreSQL Advisory-Lock Namespaces + +### 1. Scope / Trigger + +- Trigger: allocating an application-level sequence or serializing a small + cross-row critical section that cannot be protected by locking one existing + row. +- Use this only for PostgreSQL-backed paths whose correctness is also verified + against a disposable real database; do not introduce advisory locks for + ordinary single-row updates already covered by `FOR UPDATE`. + +### 2. Signatures + +- Shared registry: `server/src/constants/advisoryLocks.js`. +- Lock shape: `pg_advisory_xact_lock(namespace_int4, object_key_int4)`. +- Registered namespace: `paymentBatchDailySequence = 1001`; its object key is + the company-local `YYYYMMDD` integer. +- Prisma call for a void-returning lock statement: `tx.$executeRaw`. + +### 3. Contracts + +- Register every new first-argument namespace in the shared constant file; + never reuse an existing namespace for an unrelated critical section. +- Use the two-`int4` form with an explicit, inspectable object key. Do not use + `hashtext` or an undocumented magic pair at the call site. +- Acquire the transaction-level lock before reading the protected maximum or + current state. Lock acquisition, protected read and write must stay in the + same Prisma interactive transaction so commit or rollback releases the lock. +- Keep one stable lock order when a transaction needs more than one lock. +- A PostgreSQL uniqueness error aborts the transaction. Do not catch `P2002` + and retry inside the same interactive transaction; any fallback must retry + the complete transaction from the beginning. + +### 4. Validation & Error Matrix + +- `$queryRaw` reads `SELECT pg_advisory_xact_lock(...)` -> Prisma driver may + fail with `P2010` because PostgreSQL returns a `void` column. +- `$executeRaw` executes the same `SELECT` -> lock is acquired without result + deserialization; continue to the protected query. +- Namespace or object key outside signed `int4` -> fail before business writes; + correct the registry/key design rather than silently casting or hashing. +- Protected daily sequence exhausted -> stable `409 VALID_001` and rollback; + do not widen or randomize the public identifier format at the boundary. +- Concurrent callers with the same pair -> one waits, then reads the committed + maximum and allocates the next value. + +### 5. Good/Base/Bad Cases + +- Good: two simultaneous payment-batch creations lock `(1001, YYYYMMDD)`, then + commit distinct consecutive identifiers. +- Base: different company-local dates use different object keys and do not + contend. +- Bad: query the maximum before taking the lock, use a process mutex, or retry a + unique violation inside the already-aborted transaction. + +### 6. Tests Required + +- Unit test the exact call order and error boundary with mocked + `$executeRaw`/`$queryRaw`; assert no create, item or audit write on exhaustion. +- Run `bash scripts/qa/run-web-gates.sh payment-concurrency-local` against a + disposable PostgreSQL database. The daily-sequence race must create two + different sources concurrently and assert two consecutive unique numbers. +- Run `node --check` on the route, namespace registry and concurrency script, + plus Prisma validate and a read-only review for affected payment paths. + +### 7. Wrong vs Correct + +#### Wrong + +```js +await tx.$queryRaw`SELECT pg_advisory_xact_lock(${namespace}, ${dateKey})`; +const latest = await readLatestSequence(tx); +``` + +#### Correct + +```js +await tx.$executeRaw` + SELECT pg_advisory_xact_lock(${namespace}::int, ${dateKey}::int) +`; +const latest = await readLatestSequence(tx); +``` 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 845676ba..b8133ae5 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md @@ -117,3 +117,30 @@ - 不改 Prisma schema、历史批次或报销事实;既有 `draft` 批次部署后直接按“待付款复核”展示。 - 若新创建合同需回滚,客户端可恢复旧表单,服务端仍接受显式名称和可选默认字段;状态机与历史数据不变。 - 任何真实批次复核、回单上传、付款登记仍由业务人员操作,不纳入部署自动化。 + +## 9. 付款详情与日序号收口 + +### 9.1 页面信息层级 + +- 保留 `/payment-batches?id=` 的现有显式选择详情,不为低频付款批次增加独立路由。 +- 详情连续顺序固定为:批次概要、当前处理/下一步、报销明细、银行回单。状态和职责先于长明细,避免财务再次寻找“现在该做什么”。 +- 顶部操作组是不变的主动作坐标:`draft` 的合格非制单人可“复核通过”;`reviewed + bank + 无有效回单` 为“上传银行回单”;回单具备后为“登记付款完成”。回单章节只承载证据与次要说明,不复制同等级主按钮。 +- 当前责任只显示职责,不预猜未来姓名。状态信息缺失时降级为可理解的“待处理”,不让下游对象缺失打空详情。 + +### 9.2 金额单一展示合同 + +- `client/src/lib/displayFormat.ts` 的 `formatMoney()` 是付款页唯一人民币格式化入口。 +- 列表、移动卡片、详情总额、批次项、复核/付款确认文案和高风险阈值均传原始数值给共享函数;币种字段仍可作为业务事实保留,但 CNY 不再以裸值加代码拼接。 + +### 9.3 编号和自动标题 + +- 新格式为 `PB-YYYYMMDD-NNN`,日期使用 `fastify.config.appTimezone`,序号为当日三位正整数。既有长编号不迁移,导出、AP 与外部交接继续把编号视为不透明字符串。 +- 自动标题只能在取得编号后生成:`YYYY-MM-DD 付款批次 NNN`。请求显式传 `name` 时保持原值,不追加序号。 +- 当日序号大于 999 时返回稳定校验错误并回滚,不改成四位或随机后缀,避免格式在边界时静默漂移。 + +### 9.4 咨询锁与事务边界 + +- 使用 PostgreSQL 双 int4 事务级咨询锁:第一参数为项目登记的固定命名空间“付款批次日序号”,第二参数为公司时区 `YYYYMMDD`。 +- 在同一个 Prisma 交互事务中按固定顺序执行:取得咨询锁 → 查询当日严格新格式 `^PB-YYYYMMDD-[0-9]{3}$` 的最大序号 → 生成编号/自动标题 → 创建批次及既有项、审计、通知。 +- 不使用 `hashtext`,不把旧长格式纳入最大值,不在 P2002 后原事务内重试;PostgreSQL 唯一冲突会使事务进入 aborted 状态,任何兜底只能重试整个事务。本设计通过咨询锁消除正常同日并发冲突。 +- 无 schema 迁移;回滚时可恢复旧生成器,已经生成的新短编号仍满足唯一字符串合同,无需改写。 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 34f2d8e8..848cea2c 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md @@ -97,3 +97,20 @@ 本地验证(2026-08-01):付款/报销详情/通知/导出/导航聚焦 Vitest 85 passed;隔离 PostgreSQL 付款并发门禁覆盖双执行、执行/取消、重复建批次和直接付款竞争并全部通过;客户端 typecheck/build 通过;付款权限 5 条、高风险动作 8 条 Playwright 共 13 passed。reviewer 首轮未发现数据安全或状态机阻断项,指出“兼任复核职责的制单人仍显示自审按钮”和“回单查询失败误报为空”两项交互问题;已修为前端同步排除制单人、失败态只显示错误与重新加载,复查未发现新 blocker。既有真实批次未被读取正文、修改、复核、上传回单或登记付款。 生产发布(2026-08-02,脱敏):提交 `7c39f8b` 已推送并通过既有安装固化入口部署;同步前远端源码、普通环境文件、PGDATA 和附件目录合同通过,未同步或覆盖 `.env`。63 项 migration 为 up to date,init/server 依赖层命中缓存;`/ready` 构建短标识、external deps smoke、runtime smoke、付款能力 env/flag 常开、公网前端资源扫描和付款懒加载块四项关键文案均通过。当前 shell 未配置 live-smoke 专用凭据,因此没有宣称登录后业务链自动验收;部署未替任何人复核批次、上传回单或登记付款。 + +## 9. 付款详情与批次短编号(已实施,未部署) + +- [x] 将付款详情重排为“批次概要 → 当前处理/下一步 → 报销明细 → 银行回单”,保留现有内联详情和顶部唯一主动作,不新增独立路由。 +- [x] 付款页列表、移动卡片、详情总额、批次项、确认弹窗和高风险阈值全部改用共享 `formatMoney()`。 +- [x] 使用公司时区生成 `PB-YYYYMMDD-NNN`,在同一 Prisma 交互事务中先取得双 int4 `pg_advisory_xact_lock`,再查询严格新格式最大日序号并创建批次。 +- [x] 自动标题改为 `YYYY-MM-DD 付款批次 NNN`;显式名称保持原值;既有编号不迁移;日序号超过 999 时 fail-closed。 +- [x] 更新 route 单测,覆盖时区、自动标题、显式名称、同日递增、跨日归零、同日旧长编号隔离和边界错误;更新真实 PostgreSQL 并发门禁,证明同日并发编号唯一递增。 +- [x] 更新付款主链 Playwright,断言详情信息顺序、顶部主动作和全页金额格式;运行前端 typecheck/build。 +- [x] 运行 Prisma validate、node syntax、task/governance/process/sensitive/diff 门禁;因涉及付款创建并发和原始 SQL,执行只读 reviewer,不允许 reviewer 改代码。 +- [x] 把双参数咨询锁命名空间、锁顺序、错误矩阵和测试要求沉淀到 backend spec;通过后中文提交,不在未获新批准时部署。 + +本地验证(2026-08-02):付款 route 单测 39 passed;隔离 PostgreSQL 付款并发门禁覆盖双执行、执行/取消、重复建批次、同日两来源日序号、直接付款竞争和缺失制单人场景并全部通过;付款权限与高风险动作 Playwright 14 passed;客户端 typecheck/build、Prisma validate、相关脚本语法、Trellis task/governance/process/sensitive/diff 门禁通过。文档注册审计保持 0 error,既有 22 个未登记 docs 文件 warning 与本轮无关。 + +实现中发现 Prisma 7.8 driver adapter 无法反序列化 `SELECT pg_advisory_xact_lock(...)` 的 PostgreSQL `void` 列并返回 `P2010`;锁调用改用 `$executeRaw` 后真实 PostgreSQL 并发门禁通过。该陷阱、双 int4 命名空间登记、固定锁顺序和禁止在已 aborted 事务内重试 `P2002` 已写入 backend spec。 + +只读审查(2026-08-02):未发现权限、maker-checker、付款状态机或历史编号兼容阻断项。新编号分配在既有报销锁定、状态/占用和复核人校验之后、所有批次/批次项/审计/通知写入之前;咨询锁、严格新格式查询和创建保持在同一交互事务。旧长编号不参与日序号,超过 999 时零业务写入;付款页未新增路由或可操作权限,回单缺失时顶部仍只有一个主动作。审查未修改生产数据,也未执行部署。 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 621faafc..3bbde133 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md @@ -45,6 +45,10 @@ - 付款复核仍是一轮 maker-checker;责任收件人只选一个,优先具备复核权限的 active `ceo`,没有时再按稳定顺序选择 active `manager`,始终排除制单人。 - 付款页同一时刻只显示一个主动作:银行渠道复核通过但尚无有效回单时显示“上传银行回单”;上传后才显示“登记付款完成”,不能让用户先撞到 `bank_receipt_required` 才知道前置条件。 - 付款批次状态标签必须使用共享展示映射;列表、详情、移动响应式视图和相关测试不得各自复制 `draft/reviewed/executed/failed/cancelled` 中文文案。 +- 付款详情保持现有列表内显式选择后的详情形态,不新增独立详情路由;信息顺序固定为“批次概要 → 当前处理/下一步 → 报销明细 → 银行回单”。顶部操作组始终承载当前唯一主动作,回单章节后移不得把“上传银行回单”推到明细列表之后。 +- 付款页的列表、详情总额、批次项、确认弹窗和高风险阈值统一使用共享 `formatMoney()`;不得继续裸输出金额或在页面内新增货币格式化副本。 +- 新建付款批次编号使用公司时区日期和三位日序号 `PB-YYYYMMDD-NNN`;既有编号不迁移。未显式传名称时自动标题为 `YYYY-MM-DD 付款批次 NNN`,显式名称原样保留。 +- 日序号必须在创建批次的同一交互事务中,先取得双参数 PostgreSQL 事务级咨询锁,再查询当日严格新格式最大序号并创建;咨询锁使用固定 int4 命名空间和 int4 `YYYYMMDD`,不得依赖唯一冲突后的原事务内重试。 ## Acceptance Criteria @@ -67,6 +71,10 @@ - [ ] 无合格复核人的错误明确说明“复核人必须是非制单人且在职,请联系管理员”,不把配置问题伪装成财务人员操作错误。 - [ ] 既有真实批次不改 ID、不改金额、不改报销绑定、不改复核/付款事实;部署后仅获得新的业务文案和下一步说明,不替任何人推进状态。 - [ ] 既有报销批次渠道为空时按银行转账展示并执行回单门禁,但不回写历史记录;当前真实批次同样先上传回单再登记付款完成。 +- [ ] 付款详情按“批次概要、当前处理/下一步、报销明细、银行回单”连续展示;各状态唯一主动作位于顶部,银行回单章节内不出现第二个同等级主按钮。 +- [ ] 付款页所有人民币金额统一显示为 `¥1,234.56` 形式,列表、详情、批次项、确认弹窗和高风险阈值无裸金额残留。 +- [ ] 新批次编号为公司时区 `PB-YYYYMMDD-NNN`,同日并发创建得到唯一递增序号,跨日从 `001` 开始;超过 `999` 时 fail-closed,不改变编号格式。 +- [ ] 自动标题在编号生成后使用相同日序号;调用方显式提供名称时不追加序号;旧编号保持原值且导出/AP/外部传递继续按不透明字符串兼容。 ## Out of Scope diff --git a/client/src/pages/PaymentBatchesPage.tsx b/client/src/pages/PaymentBatchesPage.tsx index cd371e37..78c539e8 100644 --- a/client/src/pages/PaymentBatchesPage.tsx +++ b/client/src/pages/PaymentBatchesPage.tsx @@ -46,7 +46,7 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { StatusBadge } from "../components/common/StatusBadge"; import { DateTextInput } from "../components/common/DateTextInput"; import { downloadBlob } from "../lib/download"; -import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; +import { formatDisplayDate, formatDisplayDateTime, formatMoney } from "../lib/displayFormat"; import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay"; import { toast } from "sonner"; @@ -416,7 +416,7 @@ export function PaymentBatchesPage() { {candidate.businessNo} · {candidate.employee.name} - {candidate.amount} {candidate.currencyCode} + {formatMoney(candidate.amount)} {formatDisplayDate(candidate.businessDate)} @@ -516,11 +516,45 @@ export function PaymentBatchesPage() {
状态
{paymentBatchStatusLabel(selectedBatch.status)}
实体
{entityTypeLabel(selectedBatch.entityType)}
-
总金额
{selectedBatch.totalAmount}
+
总金额
{formatMoney(selectedBatch.totalAmount)}
默认日期
{formatDisplayDate(selectedBatch.defaultPaymentDate)}
默认渠道
{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}
复核进度
{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}
+ + +
+

当前处理

+
+
+
当前责任
+
+ {selectedBatch.status === "draft" + ? "负责人(非制单人)" + : ["reviewed", "failed"].includes(selectedBatch.status) + ? "财务" + : selectedBatch.status === "executed" + ? "已完成" + : "已结束"} +
+
+
+
下一步
+
+ {selectedBatch.status === "draft" + ? "复核通过" + : selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess + ? "核对银行回单状态" + : selectedBatch.status === "reviewed" && needsBankReceipt + ? "线下付款并上传银行回单" + : selectedBatch.status === "reviewed" + ? "登记付款完成" + : selectedBatch.status === "failed" + ? "核对失败原因并重试" + : "-"} +
+
+
{selectedBatch.status === "draft" ? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。" @@ -535,15 +569,58 @@ export function PaymentBatchesPage() { : selectedBatch.status === "failed" ? "付款登记失败,请财务核对失败原因后重试。" : "该付款批次已取消。"} - {selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""} + {selectedBatch.highRisk ? ` 批次金额达到 ${formatMoney(selectedBatch.reviewRequirement.thresholdAmount)},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""} {selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
+
+

报销明细

+
+ {selectedBatch.items.map((item) => ( +
+
+
+
{item.entityCode}
+
{item.entityTitle}
+
+
{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}
+
+
+ updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} + /> + updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} + /> + +
+ {item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"} +
+
+ +
+ ))} +
+
+
-

银行回单附件

+

银行回单

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

{canUploadReceipt ? ( @@ -561,14 +638,16 @@ export function PaymentBatchesPage() { if (files.length) uploadReceiptMutation.mutate(files); }} /> - + {!needsBankReceipt ? ( + + ) : null}
) : null}
@@ -637,49 +716,6 @@ export function PaymentBatchesPage() { ) : null} - -
-

批次项

-
- {selectedBatch.items.map((item) => ( -
-
-
-
{item.entityCode}
-
{item.entityTitle}
-
-
{item.amount} {item.currencyCode} · {paymentItemStatusLabel(item.status)}
-
-
- updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} - /> - updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} - /> - -
- {item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"} -
-
- -
- ))} -
-
) : (
未找到付款批次
@@ -715,7 +751,7 @@ export function PaymentBatchesPage() {
{item.name}
- 金额 {item.totalAmount} + 金额 {formatMoney(item.totalAmount)} {item.highRisk ? " · 高风险" : ""} {` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`}
@@ -743,7 +779,7 @@ export function PaymentBatchesPage() {
{item.name}
{entityTypeLabel(item.entityType)} - {item.totalAmount} + {formatMoney(item.totalAmount)} {item.reviewRequirement.completedReviewCount}/{item.reviewRequirement.requiredReviewCount} @@ -790,10 +826,10 @@ export function PaymentBatchesPage() { description={ confirmPaymentAction?.kind === "financeReview" ? confirmPaymentAction.highRisk - ? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。` + ? `该批次金额为 ${formatMoney(confirmPaymentAction.totalAmount)},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。` : "确认后将完成一次独立复核并写入付款批次审计记录。" : confirmPaymentAction?.highRisk - ? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将登记付款完成并更新源单状态。` + ? `这是高额付款批次,金额 ${formatMoney(confirmPaymentAction.totalAmount)}。请再次核对付款对象、金额、渠道和回单;确认后将登记付款完成并更新源单状态。` : "请确认线下付款和银行回单均已核对。确认后将登记付款完成并更新源单状态。" } onConfirm={async () => { diff --git a/client/tests/e2e/payment-reviewer-permissions.spec.ts b/client/tests/e2e/payment-reviewer-permissions.spec.ts index 10ae962e..d1c750e6 100644 --- a/client/tests/e2e/payment-reviewer-permissions.spec.ts +++ b/client/tests/e2e/payment-reviewer-permissions.spec.ts @@ -410,7 +410,7 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次", const candidateRow = page.getByTestId("payment-batch-candidate-row"); await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo); await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.employee.name); - await expect(candidateRow).toContainText(`${PAYMENT_CANDIDATE.amount} CNY`); + await expect(candidateRow).toContainText("¥1,280.50"); 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); diff --git a/client/tests/e2e/system-action-confirmation.spec.ts b/client/tests/e2e/system-action-confirmation.spec.ts index 3c2cbc12..ad7882d8 100644 --- a/client/tests/e2e/system-action-confirmation.spec.ts +++ b/client/tests/e2e/system-action-confirmation.spec.ts @@ -124,7 +124,7 @@ function reimbursementDetail() { }; } -function paymentBatch(status: "draft" | "reviewed" = "reviewed") { +function paymentBatch(status: "draft" | "reviewed" = "reviewed", highRisk = false) { return { id: paymentBatchId, batchNo: "PAY-ACTION-001", @@ -135,7 +135,7 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") { defaultPaymentChannel: "bank", voucherPrefix: "R7E", totalAmount: "88.00", - highRisk: false, + highRisk, reviewRequirement: { thresholdAmount: "1000.00", requiredReviewCount: 1, @@ -186,6 +186,7 @@ async function mockSystemActionApis( batchStatus?: "draft" | "reviewed"; hasReceipt?: boolean; receiptQueryError?: boolean; + highRisk?: boolean; meOverride?: Partial; } = {}, ) { @@ -329,7 +330,7 @@ async function mockSystemActionApis( } if (path === "/api/payment-batches" && request.method() === "GET") { - await fulfillJson(route, { items: [paymentBatch(batchStatus)] }); + await fulfillJson(route, { items: [paymentBatch(batchStatus, options.highRisk)] }); return; } @@ -391,7 +392,7 @@ async function mockSystemActionApis( if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") { calls.executePaymentBatch += 1; - await fulfillJson(route, { item: paymentBatch("reviewed") }); + await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk) }); return; } @@ -462,6 +463,8 @@ test.describe("R7E 全站高风险操作二次确认", () => { await page.goto(`/payment-batches?id=${paymentBatchId}`); await expect(page.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(1); + await expect(page.getByTestId("payment-batch-receipt-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({ @@ -475,6 +478,30 @@ test.describe("R7E 全站高风险操作二次确认", () => { await expect(page.getByTestId("payment-batch-detail-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0); }); + test("付款详情按处理顺序展示并统一人民币金额格式", async ({ page }) => { + await mockSystemActionApis(page, { hasReceipt: false, highRisk: true }); + await login(page, user); + await page.goto(`/payment-batches?id=${paymentBatchId}`); + await expect(page.getByTestId("payment-batch-detail-panel")).toBeVisible(); + + const sectionOrder = await page.locator([ + '[data-testid="payment-batch-detail-panel"]', + '[data-testid="payment-batch-current-processing"]', + '[data-testid="payment-batch-items-panel"]', + '[data-testid="payment-batch-receipt-panel"]', + ].join(",")).evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); + expect(sectionOrder).toEqual([ + "payment-batch-detail-panel", + "payment-batch-current-processing", + "payment-batch-items-panel", + "payment-batch-receipt-panel", + ]); + await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("¥88.00"); + await expect(page.getByTestId("payment-batch-current-processing")).toContainText("¥1,000.00"); + await expect(page.getByTestId("payment-batch-items-panel")).toContainText("¥88.00"); + await expect(page.getByTestId("payment-batch-current-processing")).toContainText("线下付款并上传银行回单"); + }); + test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => { const calls = await mockSystemActionApis(page); await login(page, user); diff --git a/server/src/constants/advisoryLocks.js b/server/src/constants/advisoryLocks.js new file mode 100644 index 00000000..6e8543a5 --- /dev/null +++ b/server/src/constants/advisoryLocks.js @@ -0,0 +1,7 @@ +const ADVISORY_LOCK_NAMESPACES = Object.freeze({ + paymentBatchDailySequence: 1001, +}); + +module.exports = { + ADVISORY_LOCK_NAMESPACES, +}; diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index db0a7f5b..3a859a8a 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -11,6 +11,7 @@ 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"); +const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions"); const { sendNotificationEmailSafe } = require("../services/email"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); @@ -73,6 +74,7 @@ const REASON_SCHEMA = z.object({ }).strict(); const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000; +const PAYMENT_BATCH_DAILY_SEQUENCE_MAX = 999; function buildValidationError(details, statusCode = 400) { return new AppError({ @@ -367,16 +369,48 @@ async function dispatchPaymentResponsibilityNotification(fastify, request, task) } } -function buildBatchNo() { - const now = new Date(); - const date = now.toISOString().slice(0, 10).replace(/-/g, ""); - const time = now.toISOString().slice(11, 19).replace(/:/g, ""); - const suffix = crypto.randomBytes(3).toString("hex").toUpperCase(); - return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}-${suffix}`; +function parsePaymentBatchDailySequence(batchNo, compactDate) { + const match = new RegExp(`^PB-${compactDate}-([0-9]{3})$`).exec(batchNo ?? ""); + return match ? Number.parseInt(match[1], 10) : 0; } -function buildDefaultBatchName(timeZone) { - return `${currentDateString(timeZone ?? "Asia/Shanghai")} 付款批次`; +async function allocatePaymentBatchNumber(tx, timeZone) { + const businessDate = currentDateString(timeZone ?? "Asia/Shanghai"); + const compactDate = businessDate.replaceAll("-", ""); + const dateLockKey = Number.parseInt(compactDate, 10); + const batchNoPrefix = `PB-${compactDate}-`; + const batchNoPattern = `^PB-${compactDate}-[0-9]{3}$`; + + await tx.$executeRaw` + SELECT pg_advisory_xact_lock( + ${ADVISORY_LOCK_NAMESPACES.paymentBatchDailySequence}::int, + ${dateLockKey}::int + ) + `; + + const rows = await tx.$queryRaw` + SELECT "batch_no" + FROM "payment_batches" + WHERE "batch_no" LIKE ${`${batchNoPrefix}%`} + AND "batch_no" ~ ${batchNoPattern} + ORDER BY "batch_no" DESC + LIMIT 1 + `; + const nextSequence = parsePaymentBatchDailySequence(rows?.[0]?.batch_no, compactDate) + 1; + if (nextSequence > PAYMENT_BATCH_DAILY_SEQUENCE_MAX) { + throw buildValidationError({ batchNo: "daily_sequence_exhausted" }, 409); + } + + const sequence = String(nextSequence).padStart(3, "0"); + return { + batchNo: `${batchNoPrefix}${sequence}`, + businessDate, + sequence, + }; +} + +function buildDefaultBatchName(businessDate, sequence) { + return `${businessDate} 付款批次 ${sequence}`; } function summarizeBatchRisk(batch) { @@ -1215,11 +1249,13 @@ async function paymentBatchesRoutes(fastify) { throw buildValidationError({ review: "no_eligible_reviewer" }, 409); } + const allocatedNumber = await allocatePaymentBatchNumber(tx, fastify.config.appTimezone); + const batch = await tx.paymentBatch.create({ data: { - batch_no: buildBatchNo(), + batch_no: allocatedNumber.batchNo, entity_type: body.entityType, - name: body.name ?? buildDefaultBatchName(fastify.config.appTimezone), + name: body.name ?? buildDefaultBatchName(allocatedNumber.businessDate, allocatedNumber.sequence), status: "draft", default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null, default_payment_channel: body.defaultPaymentChannel ?? "bank", diff --git a/server/src/scripts/check-payment-postgres-concurrency.js b/server/src/scripts/check-payment-postgres-concurrency.js index 448868ba..a339fd8a 100644 --- a/server/src/scripts/check-payment-postgres-concurrency.js +++ b/server/src/scripts/check-payment-postgres-concurrency.js @@ -4,6 +4,8 @@ const { Client } = require("pg"); const { buildApp } = require("../app"); const { createPrismaClient } = require("../prismaClient"); +const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); +const { currentDateString } = require("../utils/date"); const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); @@ -567,6 +569,56 @@ async function runDoubleCreateRace({ app, prisma, context, fixture, tokens }) { assertCondition(notificationCount === 0, "double_create_notification_created"); } +async function runDailySequenceRace({ app, prisma, context, fixture, tokens }) { + const [firstReimbursement, secondReimbursement] = await Promise.all([ + createApprovedReimbursement(prisma, fixture, 6), + createApprovedReimbursement(prisma, fixture, 7), + ]); + const businessDate = currentDateString(process.env.APP_TIMEZONE ?? "Asia/Shanghai"); + const compactDate = businessDate.replaceAll("-", ""); + const prefix = `PB-${compactDate}-`; + const existing = await prisma.paymentBatch.findMany({ + where: { batch_no: { startsWith: prefix } }, + select: { batch_no: true }, + }); + const sequencePattern = new RegExp(`^PB-${compactDate}-([0-9]{3})$`); + const previousMax = existing.reduce((maximum, batch) => { + const match = sequencePattern.exec(batch.batch_no); + return Math.max(maximum, match ? Number.parseInt(match[1], 10) : 0); + }, 0); + + const responses = await runBlockedRace({ + databaseUrl: context.databaseUrl, + lockSql: "SELECT pg_advisory_xact_lock($1::int, $2::int)", + lockParams: [ + ADVISORY_LOCK_NAMESPACES.paymentBatchDailySequence, + Number.parseInt(compactDate, 10), + ], + requestFactories: [firstReimbursement, secondReimbursement].map((reimbursement) => () => + injectJson(app, { + method: "POST", + url: "/api/payment-batches", + token: tokens.finance, + payload: { + entityType: "reimbursement", + entityIds: [reimbursement.id], + defaultPaymentChannel: "cash", + }, + })), + timeoutCode: "daily_sequence_race_timeout", + }); + assertStatusPair(responses, [201, 201], "daily_sequence_create_status_invalid"); + const batchNos = responses.map((response) => response.body?.data?.item?.batchNo).sort(); + assertCondition(batchNos.every((batchNo) => sequencePattern.test(batchNo)), "daily_sequence_format_invalid"); + const sequences = batchNos.map((batchNo) => Number.parseInt(sequencePattern.exec(batchNo)[1], 10)); + assertCondition( + JSON.stringify(sequences) === JSON.stringify([previousMax + 1, previousMax + 2]), + "daily_sequence_not_unique_incrementing", + ); + const createdCount = await prisma.paymentBatch.count({ where: { batch_no: { in: batchNos } } }); + assertCondition(createdCount === 2, "daily_sequence_batch_count_invalid"); +} + async function runMissingMakerReviewCheck({ app, prisma, fixture, tokens }) { const reimbursement = await createApprovedReimbursement(prisma, fixture, 5); const created = await injectJson(app, { @@ -737,6 +789,9 @@ async function runPaymentPostgresConcurrencyCheck(env = process.env) { console.log("[PAYMENT-CONCURRENCY] double-create: start"); await runDoubleCreateRace({ app, prisma, context, fixture, tokens }); console.log("[PAYMENT-CONCURRENCY] double-create: pass"); + console.log("[PAYMENT-CONCURRENCY] daily-sequence: start"); + await runDailySequenceRace({ app, prisma, context, fixture, tokens }); + console.log("[PAYMENT-CONCURRENCY] daily-sequence: pass"); console.log("[PAYMENT-CONCURRENCY] direct-pay-batch: start"); await runDirectPayBatchRace({ app, prisma, context, fixture, tokens }); console.log("[PAYMENT-CONCURRENCY] direct-pay-batch: pass"); diff --git a/server/tests/unit/routes/paymentBatches.reimbursement.test.js b/server/tests/unit/routes/paymentBatches.reimbursement.test.js index 78b149f1..6261b514 100644 --- a/server/tests/unit/routes/paymentBatches.reimbursement.test.js +++ b/server/tests/unit/routes/paymentBatches.reimbursement.test.js @@ -429,7 +429,17 @@ function buildPrisma(options = {}) { employee: { findMany: vi.fn(async () => state.employees.map((employee) => ({ ...employee }))), }, - $queryRaw: vi.fn(async () => [{ id: BATCH_ID }]), + $executeRaw: vi.fn(async () => 0), + $queryRaw: vi.fn(async (query, ...values) => { + const text = Array.isArray(query) ? query.join(" ") : String(query?.sql ?? query ?? ""); + if (text.includes('ORDER BY "batch_no" DESC')) { + const pattern = values[1]; + return typeof pattern === "string" && new RegExp(pattern).test(state.batch.batch_no) + ? [{ batch_no: state.batch.batch_no }] + : []; + } + return [{ id: BATCH_ID }]; + }), }; const prisma = { @@ -507,7 +517,7 @@ async function buildApp(options = {}) { app.decorate("prisma", prisma); app.decorate("config", { - appTimezone: "Asia/Shanghai", + appTimezone: options.appTimezone ?? "Asia/Shanghai", storageDir: tempDir, maxUploadSizeBytes: 5 * 1024 * 1024, paymentBatchEnabled: true, @@ -1050,7 +1060,8 @@ describe("payment batch reimbursement routes", () => { expect(response.statusCode).toBe(201); expect(tx.paymentBatch.create).toHaveBeenCalledWith({ data: expect.objectContaining({ - name: `${currentDateString("Asia/Shanghai")} 付款批次`, + batch_no: `PB-${currentDateString("Asia/Shanghai").replaceAll("-", "")}-001`, + name: `${currentDateString("Asia/Shanghai")} 付款批次 001`, default_payment_date: null, default_payment_channel: "bank", voucher_prefix: null, @@ -1157,7 +1168,7 @@ describe("payment batch reimbursement routes", () => { await app.close(); }); - it("adds a non-identifying random suffix to concurrent-style batch numbers", async () => { + it("allocates an increasing company-local daily sequence and preserves an explicit name", async () => { const { app, tx } = await buildApp(); const payload = { entityType: "reimbursement", @@ -1182,9 +1193,87 @@ describe("payment batch reimbursement routes", () => { expect(second.statusCode).toBe(201); const batchNos = tx.paymentBatch.create.mock.calls.map(([argument]) => argument.data.batch_no); expect(batchNos).toHaveLength(2); - expect(batchNos[0]).toMatch(/^PB-\d{8}-\d{6}-\d{3}-[0-9A-F]{6}$/); - expect(batchNos[1]).toMatch(/^PB-\d{8}-\d{6}-\d{3}-[0-9A-F]{6}$/); - expect(batchNos[0]).not.toBe(batchNos[1]); + const compactDate = currentDateString("Asia/Shanghai").replaceAll("-", ""); + expect(batchNos).toEqual([ + `PB-${compactDate}-001`, + `PB-${compactDate}-002`, + ]); + expect(tx.paymentBatch.create.mock.calls[0][0].data.name).toBe("付款批次编号验证"); + expect(tx.paymentBatch.create.mock.calls[1][0].data.name).toBe("付款批次编号验证"); + const advisoryLockCalls = tx.$executeRaw.mock.calls.filter(([query]) => + Array.isArray(query) && query.join(" ").includes("pg_advisory_xact_lock"), + ); + expect(advisoryLockCalls).toHaveLength(2); + + await app.close(); + }); + + it("uses the configured company timezone and starts its local day at 001", async () => { + const appTimezone = "Pacific/Honolulu"; + const businessDate = currentDateString(appTimezone); + const { app, tx } = await buildApp({ appTimezone }); + + 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({ + batch_no: `PB-${businessDate.replaceAll("-", "")}-001`, + name: `${businessDate} 付款批次 001`, + }), + }); + + await app.close(); + }); + + it("ignores a legacy long batch number from the same company-local day", async () => { + const businessDate = currentDateString("Asia/Shanghai"); + const compactDate = businessDate.replaceAll("-", ""); + const { app, tx } = await buildApp({ + batch: { batch_no: `PB-${compactDate}-063245-812-A1B2C3` }, + }); + + 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({ + batch_no: `PB-${compactDate}-001`, + name: `${businessDate} 付款批次 001`, + }), + }); + + await app.close(); + }); + + it("fails closed when the company-local daily sequence is exhausted", async () => { + const businessDate = currentDateString("Asia/Shanghai"); + const { app, tx } = await buildApp({ + batch: { batch_no: `PB-${businessDate.replaceAll("-", "")}-999` }, + }); + + 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(409); + expect(response.json().error.details.batchNo).toBe("daily_sequence_exhausted"); + expect(tx.paymentBatch.create).not.toHaveBeenCalled(); + expect(tx.paymentBatchItem.create).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); });