统一付款详情与批次短编号(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 09:02:40 +08:00
父节点 171fc09524
当前提交 5d274af8d4
共修改 11 个文件,包含 467 行新增和 82 行删除
@@ -514,3 +514,86 @@ const finalSummary = summarizeFinalItemStatuses(finalItemStatuses);
batch.execution_summary_json = finalSummary; batch.execution_summary_json = finalSummary;
batch.status = finalSummary.failedCount > 0 ? "failed" : "executed"; 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);
```
@@ -117,3 +117,30 @@
- 不改 Prisma schema、历史批次或报销事实;既有 `draft` 批次部署后直接按“待付款复核”展示。 - 不改 Prisma schema、历史批次或报销事实;既有 `draft` 批次部署后直接按“待付款复核”展示。
- 若新创建合同需回滚,客户端可恢复旧表单,服务端仍接受显式名称和可选默认字段;状态机与历史数据不变。 - 若新创建合同需回滚,客户端可恢复旧表单,服务端仍接受显式名称和可选默认字段;状态机与历史数据不变。
- 任何真实批次复核、回单上传、付款登记仍由业务人员操作,不纳入部署自动化。 - 任何真实批次复核、回单上传、付款登记仍由业务人员操作,不纳入部署自动化。
## 9. 付款详情与日序号收口
### 9.1 页面信息层级
- 保留 `/payment-batches?id=<batch-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 迁移;回滚时可恢复旧生成器,已经生成的新短编号仍满足唯一字符串合同,无需改写。
@@ -97,3 +97,20 @@
本地验证(2026-08-01):付款/报销详情/通知/导出/导航聚焦 Vitest 85 passed;隔离 PostgreSQL 付款并发门禁覆盖双执行、执行/取消、重复建批次和直接付款竞争并全部通过;客户端 typecheck/build 通过;付款权限 5 条、高风险动作 8 条 Playwright 共 13 passed。reviewer 首轮未发现数据安全或状态机阻断项,指出“兼任复核职责的制单人仍显示自审按钮”和“回单查询失败误报为空”两项交互问题;已修为前端同步排除制单人、失败态只显示错误与重新加载,复查未发现新 blocker。既有真实批次未被读取正文、修改、复核、上传回单或登记付款。 本地验证(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 专用凭据,因此没有宣称登录后业务链自动验收;部署未替任何人复核批次、上传回单或登记付款。 生产发布(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 时零业务写入;付款页未新增路由或可操作权限,回单缺失时顶部仍只有一个主动作。审查未修改生产数据,也未执行部署。
@@ -45,6 +45,10 @@
- 付款复核仍是一轮 maker-checker;责任收件人只选一个,优先具备复核权限的 active `ceo`,没有时再按稳定顺序选择 active `manager`,始终排除制单人。 - 付款复核仍是一轮 maker-checker;责任收件人只选一个,优先具备复核权限的 active `ceo`,没有时再按稳定顺序选择 active `manager`,始终排除制单人。
- 付款页同一时刻只显示一个主动作:银行渠道复核通过但尚无有效回单时显示“上传银行回单”;上传后才显示“登记付款完成”,不能让用户先撞到 `bank_receipt_required` 才知道前置条件。 - 付款页同一时刻只显示一个主动作:银行渠道复核通过但尚无有效回单时显示“上传银行回单”;上传后才显示“登记付款完成”,不能让用户先撞到 `bank_receipt_required` 才知道前置条件。
- 付款批次状态标签必须使用共享展示映射;列表、详情、移动响应式视图和相关测试不得各自复制 `draft/reviewed/executed/failed/cancelled` 中文文案。 - 付款批次状态标签必须使用共享展示映射;列表、详情、移动响应式视图和相关测试不得各自复制 `draft/reviewed/executed/failed/cancelled` 中文文案。
- 付款详情保持现有列表内显式选择后的详情形态,不新增独立详情路由;信息顺序固定为“批次概要 → 当前处理/下一步 → 报销明细 → 银行回单”。顶部操作组始终承载当前唯一主动作,回单章节后移不得把“上传银行回单”推到明细列表之后。
- 付款页的列表、详情总额、批次项、确认弹窗和高风险阈值统一使用共享 `formatMoney()`;不得继续裸输出金额或在页面内新增货币格式化副本。
- 新建付款批次编号使用公司时区日期和三位日序号 `PB-YYYYMMDD-NNN`;既有编号不迁移。未显式传名称时自动标题为 `YYYY-MM-DD 付款批次 NNN`,显式名称原样保留。
- 日序号必须在创建批次的同一交互事务中,先取得双参数 PostgreSQL 事务级咨询锁,再查询当日严格新格式最大序号并创建;咨询锁使用固定 int4 命名空间和 int4 `YYYYMMDD`,不得依赖唯一冲突后的原事务内重试。
## Acceptance Criteria ## Acceptance Criteria
@@ -67,6 +71,10 @@
- [ ] 无合格复核人的错误明确说明“复核人必须是非制单人且在职,请联系管理员”,不把配置问题伪装成财务人员操作错误。 - [ ] 无合格复核人的错误明确说明“复核人必须是非制单人且在职,请联系管理员”,不把配置问题伪装成财务人员操作错误。
- [ ] 既有真实批次不改 ID、不改金额、不改报销绑定、不改复核/付款事实;部署后仅获得新的业务文案和下一步说明,不替任何人推进状态。 - [ ] 既有真实批次不改 ID、不改金额、不改报销绑定、不改复核/付款事实;部署后仅获得新的业务文案和下一步说明,不替任何人推进状态。
- [ ] 既有报销批次渠道为空时按银行转账展示并执行回单门禁,但不回写历史记录;当前真实批次同样先上传回单再登记付款完成。 - [ ] 既有报销批次渠道为空时按银行转账展示并执行回单门禁,但不回写历史记录;当前真实批次同样先上传回单再登记付款完成。
- [ ] 付款详情按“批次概要、当前处理/下一步、报销明细、银行回单”连续展示;各状态唯一主动作位于顶部,银行回单章节内不出现第二个同等级主按钮。
- [ ] 付款页所有人民币金额统一显示为 `¥1,234.56` 形式,列表、详情、批次项、确认弹窗和高风险阈值无裸金额残留。
- [ ] 新批次编号为公司时区 `PB-YYYYMMDD-NNN`,同日并发创建得到唯一递增序号,跨日从 `001` 开始;超过 `999` 时 fail-closed,不改变编号格式。
- [ ] 自动标题在编号生成后使用相同日序号;调用方显式提供名称时不追加序号;旧编号保持原值且导出/AP/外部传递继续按不透明字符串兼容。
## Out of Scope ## Out of Scope
+96 -60
查看文件
@@ -46,7 +46,7 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge"; 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, formatMoney } from "../lib/displayFormat";
import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay"; import { paymentBatchStatusLabel } from "../lib/paymentBatchDisplay";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -416,7 +416,7 @@ export function PaymentBatchesPage() {
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center justify-between gap-2 text-sm font-medium text-zinc-900"> <span className="flex flex-wrap items-center justify-between gap-2 text-sm font-medium text-zinc-900">
<span>{candidate.businessNo} · {candidate.employee.name}</span> <span>{candidate.businessNo} · {candidate.employee.name}</span>
<span>{candidate.amount} {candidate.currencyCode}</span> <span>{formatMoney(candidate.amount)}</span>
</span> </span>
<span className="mt-1 block text-xs text-zinc-500"> <span className="mt-1 block text-xs text-zinc-500">
{formatDisplayDate(candidate.businessDate)} {formatDisplayDate(candidate.businessDate)}
@@ -516,11 +516,45 @@ export function PaymentBatchesPage() {
<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">{paymentBatchStatusLabel(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">{formatMoney(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>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div></div> <div className="detail-fact-card"><div className="detail-fact-card__label">默认渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</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 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>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-current-processing">
<h3 className="text-base font-semibold text-zinc-900">当前处理</h3>
<div className="detail-fact-grid text-sm md:grid-cols-2">
<div className="detail-fact-card">
<div className="detail-fact-card__label">当前责任</div>
<div className="detail-fact-card__value">
{selectedBatch.status === "draft"
? "负责人(非制单人)"
: ["reviewed", "failed"].includes(selectedBatch.status)
? "财务"
: selectedBatch.status === "executed"
? "已完成"
: "已结束"}
</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">下一步</div>
<div className="detail-fact-card__value">
{selectedBatch.status === "draft"
? "复核通过"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "核对银行回单状态"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "线下付款并上传银行回单"
: selectedBatch.status === "reviewed"
? "登记付款完成"
: selectedBatch.status === "failed"
? "核对失败原因并重试"
: "-"}
</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.status === "draft"
? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。" ? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。"
@@ -535,15 +569,58 @@ export function PaymentBatchesPage() {
: selectedBatch.status === "failed" : selectedBatch.status === "failed"
? "付款登记失败,请财务核对失败原因后重试。" ? "付款登记失败,请财务核对失败原因后重试。"
: "该付款批次已取消。"} : "该付款批次已取消。"}
{selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""} {selectedBatch.highRisk ? ` 批次金额达到 ${formatMoney(selectedBatch.reviewRequirement.thresholdAmount)},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""} {selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
</div> </div>
</div> </div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
<div className="space-y-4">
{selectedBatch.items.map((item) => (
<div key={item.id} className="detail-evidence-card">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{item.entityCode}</div>
<div className="text-sm text-zinc-600">{item.entityTitle}</div>
</div>
<div className="text-sm text-zinc-600">{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-4">
<Input
defaultValue={item.paymentVoucherNoOverride ?? ""}
placeholder="凭证号"
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/>
<DateTextInput
defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })}
/>
<select
className="select-std"
defaultValue={item.paymentChannelOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}
>
<option value="">默认渠道</option>
{PAYMENT_CHANNEL_OPTIONS.map((option) => <option key={option} value={option}>{paymentChannelLabel(option)}</option>)}
</select>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"}
</div>
</div>
<RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" />
</div>
))}
</div>
</div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-receipt-panel"> <div className="finance-command-panel space-y-4" data-testid="payment-batch-receipt-panel">
<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">先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。</p> <p className="mt-1 text-sm text-zinc-500">先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。</p>
</div> </div>
{canUploadReceipt ? ( {canUploadReceipt ? (
@@ -561,14 +638,16 @@ export function PaymentBatchesPage() {
if (files.length) uploadReceiptMutation.mutate(files); if (files.length) uploadReceiptMutation.mutate(files);
}} }}
/> />
<Button {!needsBankReceipt ? (
type="button" <Button
variant="secondary" type="button"
disabled={uploadReceiptMutation.isPending} variant="secondary"
onClick={() => receiptInputRef.current?.click()} disabled={uploadReceiptMutation.isPending}
> onClick={() => receiptInputRef.current?.click()}
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"} >
</Button> {uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -637,49 +716,6 @@ export function PaymentBatchesPage() {
) : null} ) : null}
</div> </div>
</div> </div>
<div className="finance-command-panel space-y-4">
<h3 className="text-base font-semibold text-zinc-900">批次项</h3>
<div className="space-y-4">
{selectedBatch.items.map((item) => (
<div key={item.id} className="detail-evidence-card">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-medium text-zinc-900">{item.entityCode}</div>
<div className="text-sm text-zinc-600">{item.entityTitle}</div>
</div>
<div className="text-sm text-zinc-600">{item.amount} {item.currencyCode} · {paymentItemStatusLabel(item.status)}</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-4">
<Input
defaultValue={item.paymentVoucherNoOverride ?? ""}
placeholder="凭证号"
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/>
<DateTextInput
defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })}
/>
<select
className="select-std"
defaultValue={item.paymentChannelOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}
>
<option value="">默认渠道</option>
{PAYMENT_CHANNEL_OPTIONS.map((option) => <option key={option} value={option}>{paymentChannelLabel(option)}</option>)}
</select>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"}
</div>
</div>
<RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" />
</div>
))}
</div>
</div>
</div> </div>
) : ( ) : (
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div> <div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
@@ -715,7 +751,7 @@ export function PaymentBatchesPage() {
</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">
金额 {item.totalAmount} 金额 {formatMoney(item.totalAmount)}
{item.highRisk ? " · 高风险" : ""} {item.highRisk ? " · 高风险" : ""}
{` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`} {` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`}
</div> </div>
@@ -743,7 +779,7 @@ export function PaymentBatchesPage() {
<div className="text-xs text-zinc-500">{item.name}</div> <div className="text-xs text-zinc-500">{item.name}</div>
</td> </td>
<td className="table-td">{entityTypeLabel(item.entityType)}</td> <td className="table-td">{entityTypeLabel(item.entityType)}</td>
<td className="table-td-strong">{item.totalAmount}</td> <td className="table-td-strong">{formatMoney(item.totalAmount)}</td>
<td className="table-td"> <td className="table-td">
{item.reviewRequirement.completedReviewCount}/{item.reviewRequirement.requiredReviewCount} {item.reviewRequirement.completedReviewCount}/{item.reviewRequirement.requiredReviewCount}
</td> </td>
@@ -790,10 +826,10 @@ export function PaymentBatchesPage() {
description={ description={
confirmPaymentAction?.kind === "financeReview" confirmPaymentAction?.kind === "financeReview"
? confirmPaymentAction.highRisk ? confirmPaymentAction.highRisk
? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。` ? `该批次金额为 ${formatMoney(confirmPaymentAction.totalAmount)},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。`
: "确认后将完成一次独立复核并写入付款批次审计记录。" : "确认后将完成一次独立复核并写入付款批次审计记录。"
: confirmPaymentAction?.highRisk : confirmPaymentAction?.highRisk
? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将登记付款完成并更新源单状态。` ? `这是高额付款批次,金额 ${formatMoney(confirmPaymentAction.totalAmount)}。请再次核对付款对象、金额、渠道和回单;确认后将登记付款完成并更新源单状态。`
: "请确认线下付款和银行回单均已核对。确认后将登记付款完成并更新源单状态。" : "请确认线下付款和银行回单均已核对。确认后将登记付款完成并更新源单状态。"
} }
onConfirm={async () => { onConfirm={async () => {
@@ -410,7 +410,7 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
const candidateRow = page.getByTestId("payment-batch-candidate-row"); const candidateRow = page.getByTestId("payment-batch-candidate-row");
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo); await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.businessNo);
await expect(candidateRow).toContainText(PAYMENT_CANDIDATE.employee.name); 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.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);
@@ -124,7 +124,7 @@ function reimbursementDetail() {
}; };
} }
function paymentBatch(status: "draft" | "reviewed" = "reviewed") { function paymentBatch(status: "draft" | "reviewed" = "reviewed", highRisk = false) {
return { return {
id: paymentBatchId, id: paymentBatchId,
batchNo: "PAY-ACTION-001", batchNo: "PAY-ACTION-001",
@@ -135,7 +135,7 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed") {
defaultPaymentChannel: "bank", defaultPaymentChannel: "bank",
voucherPrefix: "R7E", voucherPrefix: "R7E",
totalAmount: "88.00", totalAmount: "88.00",
highRisk: false, highRisk,
reviewRequirement: { reviewRequirement: {
thresholdAmount: "1000.00", thresholdAmount: "1000.00",
requiredReviewCount: 1, requiredReviewCount: 1,
@@ -186,6 +186,7 @@ async function mockSystemActionApis(
batchStatus?: "draft" | "reviewed"; batchStatus?: "draft" | "reviewed";
hasReceipt?: boolean; hasReceipt?: boolean;
receiptQueryError?: boolean; receiptQueryError?: boolean;
highRisk?: boolean;
meOverride?: Partial<typeof me>; meOverride?: Partial<typeof me>;
} = {}, } = {},
) { ) {
@@ -329,7 +330,7 @@ async function mockSystemActionApis(
} }
if (path === "/api/payment-batches" && request.method() === "GET") { if (path === "/api/payment-batches" && request.method() === "GET") {
await fulfillJson(route, { items: [paymentBatch(batchStatus)] }); await fulfillJson(route, { items: [paymentBatch(batchStatus, options.highRisk)] });
return; return;
} }
@@ -391,7 +392,7 @@ async function mockSystemActionApis(
if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") { if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") {
calls.executePaymentBatch += 1; calls.executePaymentBatch += 1;
await fulfillJson(route, { item: paymentBatch("reviewed") }); await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk) });
return; return;
} }
@@ -462,6 +463,8 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.goto(`/payment-batches?id=${paymentBatchId}`); await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible(); 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 expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({ 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); 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 }) => { test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page); const calls = await mockSystemActionApis(page);
await login(page, user); await login(page, user);
+7
查看文件
@@ -0,0 +1,7 @@
const ADVISORY_LOCK_NAMESPACES = Object.freeze({
paymentBatchDailySequence: 1001,
});
module.exports = {
ADVISORY_LOCK_NAMESPACES,
};
+46 -10
查看文件
@@ -11,6 +11,7 @@ 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");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions"); const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
const { sendNotificationEmailSafe } = require("../services/email"); const { sendNotificationEmailSafe } = require("../services/email");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
@@ -73,6 +74,7 @@ const REASON_SCHEMA = z.object({
}).strict(); }).strict();
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000; const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
const PAYMENT_BATCH_DAILY_SEQUENCE_MAX = 999;
function buildValidationError(details, statusCode = 400) { function buildValidationError(details, statusCode = 400) {
return new AppError({ return new AppError({
@@ -367,16 +369,48 @@ async function dispatchPaymentResponsibilityNotification(fastify, request, task)
} }
} }
function buildBatchNo() { function parsePaymentBatchDailySequence(batchNo, compactDate) {
const now = new Date(); const match = new RegExp(`^PB-${compactDate}-([0-9]{3})$`).exec(batchNo ?? "");
const date = now.toISOString().slice(0, 10).replace(/-/g, ""); return match ? Number.parseInt(match[1], 10) : 0;
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 buildDefaultBatchName(timeZone) { async function allocatePaymentBatchNumber(tx, timeZone) {
return `${currentDateString(timeZone ?? "Asia/Shanghai")} 付款批次`; 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) { function summarizeBatchRisk(batch) {
@@ -1215,11 +1249,13 @@ async function paymentBatchesRoutes(fastify) {
throw buildValidationError({ review: "no_eligible_reviewer" }, 409); throw buildValidationError({ review: "no_eligible_reviewer" }, 409);
} }
const allocatedNumber = await allocatePaymentBatchNumber(tx, fastify.config.appTimezone);
const batch = await tx.paymentBatch.create({ const batch = await tx.paymentBatch.create({
data: { data: {
batch_no: buildBatchNo(), batch_no: allocatedNumber.batchNo,
entity_type: body.entityType, entity_type: body.entityType,
name: body.name ?? buildDefaultBatchName(fastify.config.appTimezone), name: body.name ?? buildDefaultBatchName(allocatedNumber.businessDate, allocatedNumber.sequence),
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 ?? "bank", default_payment_channel: body.defaultPaymentChannel ?? "bank",
@@ -4,6 +4,8 @@ const { Client } = require("pg");
const { buildApp } = require("../app"); const { buildApp } = require("../app");
const { createPrismaClient } = require("../prismaClient"); 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]"]); 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"); 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 }) { async function runMissingMakerReviewCheck({ app, prisma, fixture, tokens }) {
const reimbursement = await createApprovedReimbursement(prisma, fixture, 5); const reimbursement = await createApprovedReimbursement(prisma, fixture, 5);
const created = await injectJson(app, { const created = await injectJson(app, {
@@ -737,6 +789,9 @@ async function runPaymentPostgresConcurrencyCheck(env = process.env) {
console.log("[PAYMENT-CONCURRENCY] double-create: start"); console.log("[PAYMENT-CONCURRENCY] double-create: start");
await runDoubleCreateRace({ app, prisma, context, fixture, tokens }); await runDoubleCreateRace({ app, prisma, context, fixture, tokens });
console.log("[PAYMENT-CONCURRENCY] double-create: pass"); 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"); console.log("[PAYMENT-CONCURRENCY] direct-pay-batch: start");
await runDirectPayBatchRace({ app, prisma, context, fixture, tokens }); await runDirectPayBatchRace({ app, prisma, context, fixture, tokens });
console.log("[PAYMENT-CONCURRENCY] direct-pay-batch: pass"); console.log("[PAYMENT-CONCURRENCY] direct-pay-batch: pass");
@@ -429,7 +429,17 @@ function buildPrisma(options = {}) {
employee: { employee: {
findMany: vi.fn(async () => state.employees.map((employee) => ({ ...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 = { const prisma = {
@@ -507,7 +517,7 @@ async function buildApp(options = {}) {
app.decorate("prisma", prisma); app.decorate("prisma", prisma);
app.decorate("config", { app.decorate("config", {
appTimezone: "Asia/Shanghai", appTimezone: options.appTimezone ?? "Asia/Shanghai",
storageDir: tempDir, storageDir: tempDir,
maxUploadSizeBytes: 5 * 1024 * 1024, maxUploadSizeBytes: 5 * 1024 * 1024,
paymentBatchEnabled: true, paymentBatchEnabled: true,
@@ -1050,7 +1060,8 @@ describe("payment batch reimbursement routes", () => {
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
expect(tx.paymentBatch.create).toHaveBeenCalledWith({ expect(tx.paymentBatch.create).toHaveBeenCalledWith({
data: expect.objectContaining({ 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_date: null,
default_payment_channel: "bank", default_payment_channel: "bank",
voucher_prefix: null, voucher_prefix: null,
@@ -1157,7 +1168,7 @@ describe("payment batch reimbursement routes", () => {
await app.close(); 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 { app, tx } = await buildApp();
const payload = { const payload = {
entityType: "reimbursement", entityType: "reimbursement",
@@ -1182,9 +1193,87 @@ describe("payment batch reimbursement routes", () => {
expect(second.statusCode).toBe(201); expect(second.statusCode).toBe(201);
const batchNos = tx.paymentBatch.create.mock.calls.map(([argument]) => argument.data.batch_no); const batchNos = tx.paymentBatch.create.mock.calls.map(([argument]) => argument.data.batch_no);
expect(batchNos).toHaveLength(2); expect(batchNos).toHaveLength(2);
expect(batchNos[0]).toMatch(/^PB-\d{8}-\d{6}-\d{3}-[0-9A-F]{6}$/); const compactDate = currentDateString("Asia/Shanghai").replaceAll("-", "");
expect(batchNos[1]).toMatch(/^PB-\d{8}-\d{6}-\d{3}-[0-9A-F]{6}$/); expect(batchNos).toEqual([
expect(batchNos[0]).not.toBe(batchNos[1]); `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(); await app.close();
}); });