统一付款详情与批次短编号(08-01-reimbursement-notification-progress-clarity)
这个提交包含在:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const ADVISORY_LOCK_NAMESPACES = Object.freeze({
|
||||||
|
paymentBatchDailySequence: 1001,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ADVISORY_LOCK_NAMESPACES,
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户