收紧付款复核资料与阶段编辑(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 15:15:01 +08:00
父节点 9afebdb44d
当前提交 185d64dcd6
共修改 14 个文件,包含 777 行新增和 121 行删除
+60 -6
查看文件
@@ -343,7 +343,11 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
defaultPaymentDate?, defaultPaymentChannel?, voucherPrefix? }`. defaultPaymentDate?, defaultPaymentChannel?, voucherPrefix? }`.
- Review: `POST /api/payment-batches/:id/review`. - Review: `POST /api/payment-batches/:id/review`.
- Register completed payment: `POST /api/payment-batches/:id/execute`. - Register completed payment: `POST /api/payment-batches/:id/execute`.
- Payment metadata: `PATCH /api/payment-batches/:id/items/:itemId` accepts
only `paymentVoucherNoOverride?` and `paymentDateOverride?`.
- Receipt API: `GET|POST /api/payment-batches/:id/receipt-attachments`. - Receipt API: `GET|POST /api/payment-batches/:id/receipt-attachments`.
- Receipt void API:
`POST /api/payment-batches/:id/receipt-attachments/:attachmentId/void`.
- Internal status machine: - Internal status machine:
`draft -> reviewed -> executed|failed|cancelled`; there is no separate submit `draft -> reviewed -> executed|failed|cancelled`; there is no separate submit
endpoint or submitted database status. endpoint or submitted database status.
@@ -353,9 +357,11 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
### 3. Contracts ### 3. Contracts
- For the normal reimbursement UI, creation sends only `entityType` and - The normal reimbursement UI sends `entityType`, selected `entityIds` and
selected `entityIds`. The server still accepts optional fields for controlled `defaultPaymentChannel`; the channel defaults to `bank` and becomes an
compatibility and exceptional item overrides. immutable review fact after creation. Item PATCH must not accept a channel
override. A persisted legacy item override remains readable/executable for
compatibility but is never exposed as a new write path.
- Missing name becomes `<company-local YYYY-MM-DD> 付款批次`; the server creates - Missing name becomes `<company-local YYYY-MM-DD> 付款批次`; the server creates
the unique batch number. Missing channel becomes `bank`. Missing actual the unique batch number. Missing channel becomes `bank`. Missing actual
payment date is resolved to the company-local registration date only when payment date is resolved to the company-local registration date only when
@@ -379,6 +385,23 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
an active receipt exists may the UI expose `登记付款完成`. Loading or failed an active receipt exists may the UI expose `登记付款完成`. Loading or failed
receipt queries expose neither action rather than guessing that the receipt queries expose neither action rather than guessing that the
prerequisite is satisfied. prerequisite is satisfied.
- Stage editing is fixed: `draft` is read-only apart from cancel/review;
`reviewed|failed` permits voucher/date and receipt upload/void;
`executed|cancelled` is entirely read-only. Authorized receipt list/download
remains available in every state.
- Payment-batch responses never expose stored `snapshot_json` or a
`snapshotJson` DTO field. Detail responses rebuild an allowlisted review DTO
with only `payeeName`, `bankName` and a format-validated
`bankAccountMasked`; list responses keep those detail-only fields null and do
not decrypt owner accounts. New items persist the same low-sensitivity
`payee` object in `snapshot_json`; full bank accounts and encrypted source
values never enter new batch snapshots or any response.
- A legacy or malformed stored payee is not trusted merely because its field is
named `bankAccountMasked`. Invalid text, ciphertext-looking values and account
values without a valid mask cause a read-only fallback to the current
reimbursement owner. The fallback must not backfill history, and an existing
but unreadable encrypted account fails closed rather than returning the raw
stored value.
### 4. Validation & Error Matrix ### 4. Validation & Error Matrix
@@ -395,6 +418,20 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
shown independently and the user may retry. shown independently and the user may retry.
- Existing null-channel reimbursement batch -> project as bank and apply the - Existing null-channel reimbursement batch -> project as bank and apply the
receipt guard without updating `default_payment_channel`. receipt guard without updating `default_payment_channel`.
- Item PATCH in `draft|executed|cancelled` -> `409 VALID_001` with
`details.status=payment_metadata_not_editable`; zero item/audit writes.
- Item PATCH contains `paymentChannelOverride` -> strict schema rejection;
zero writes.
- Receipt upload/void outside `reviewed|failed` -> `409 VALID_001` with
`details.status=receipt_attachment_not_editable`; zero durable storage,
attachment or audit side effects.
- Receipt upload starts in `reviewed|failed` but the locked batch becomes
`executed|cancelled` before DB write -> the transaction rejects and every
object already written to storage is deleted by compensation; no attachment
or audit row remains.
- Missing legacy payee source -> return null display fields. Encryption-key or
ciphertext authentication failure -> fail closed; never substitute a full
account, ciphertext or unrelated employee.
### 5. Good/Base/Bad Cases ### 5. Good/Base/Bad Cases
@@ -403,9 +440,14 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
eligible non-maker receives the task notification. eligible non-maker receives the task notification.
- Base: after review, finance completes the bank transfer outside OA, uploads - Base: after review, finance completes the bank transfer outside OA, uploads
the receipt, then registers payment completion using the company-local date. the receipt, then registers payment completion using the company-local date.
- Good: reviewer sees the payee name, bank and masked account while every
payment-registration control and receipt mutation is absent in `draft`.
- Bad: label `draft` as `草稿`, ask the user to find a nonexistent submit button, - Bad: label `draft` as `草稿`, ask the user to find a nonexistent submit button,
show `登记付款完成` before receipt status is known, or silently rewrite a show `登记付款完成` before receipt status is known, or silently rewrite a
legacy null channel in production. legacy null channel in production.
- Bad: permit voucher/channel/receipt changes while a reviewer is checking the
batch, trust a persisted field name as proof of masking, or return
`snapshotJson`, `bank_account_encrypted`, ciphertext or a full account.
### 6. Tests Required ### 6. Tests Required
@@ -414,9 +456,18 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
null-channel compatibility without mutation. null-channel compatibility without mutation.
- PostgreSQL concurrency regression asserts double create, double execute, - PostgreSQL concurrency regression asserts double create, double execute,
execute/cancel and direct-pay/batch races remain mutually exclusive. execute/cancel and direct-pay/batch races remain mutually exclusive.
- Client E2E asserts creation sends only `entityType+entityIds`, all consumers - Route tests assert the full state matrix, channel strict rejection, zero
use the shared labels, and receipt upload switches the sole primary action mutation side effects, masked new snapshots, legacy fallback and malicious
from `上传银行回单` to `登记付款完成`. stored snapshots without historical writes or full-account/ciphertext
response values. They also assert list requests do not perform detail-level
payee lookup/decryption.
- Receipt tests cover both `reviewed` and `failed` positive paths, all three
read-only states, zero stored files on immediate rejection, and storage
compensation when the locked status changes after upload parsing.
- Client E2E asserts creation sends the selected `defaultPaymentChannel`, all
consumers use the shared labels, `draft` has no payment/receipt editors,
`executed|cancelled` remains read-only, and receipt upload switches the sole
primary action from `上传银行回单` to `登记付款完成`.
- Build/typecheck, Prisma validation and focused payment-batch route/export tests - Build/typecheck, Prisma validation and focused payment-batch route/export tests
are required before commit. are required before commit.
@@ -426,6 +477,7 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
```tsx ```tsx
const label = status === "draft" ? "草稿" : status; const label = status === "draft" ? "草稿" : status;
<ChannelSelect onChange={(value) => patchItem({ paymentChannelOverride: value })} />
<Button onClick={execute}>执行付款</Button> <Button onClick={execute}>执行付款</Button>
``` ```
@@ -433,5 +485,7 @@ const label = status === "draft" ? "草稿" : status;
```tsx ```tsx
<StatusBadge label={paymentBatchStatusLabel(batch.status)} /> <StatusBadge label={paymentBatchStatusLabel(batch.status)} />
{batch.status === "draft" ? <PayeeReviewFacts item={item} /> : null}
{["reviewed", "failed"].includes(batch.status) ? <PaymentRegistrationFields /> : null}
{needsBankReceipt ? <UploadReceipt /> : canRegister ? <RegisterPaid /> : null} {needsBankReceipt ? <UploadReceipt /> : canRegister ? <RegisterPaid /> : null}
``` ```
@@ -144,3 +144,23 @@
- 在同一个 Prisma 交互事务中按固定顺序执行:取得咨询锁 → 查询当日严格新格式 `^PB-YYYYMMDD-[0-9]{3}$` 的最大序号 → 生成编号/自动标题 → 创建批次及既有项、审计、通知。 - 在同一个 Prisma 交互事务中按固定顺序执行:取得咨询锁 → 查询当日严格新格式 `^PB-YYYYMMDD-[0-9]{3}$` 的最大序号 → 生成编号/自动标题 → 创建批次及既有项、审计、通知。
- 不使用 `hashtext`,不把旧长格式纳入最大值,不在 P2002 后原事务内重试;PostgreSQL 唯一冲突会使事务进入 aborted 状态,任何兜底只能重试整个事务。本设计通过咨询锁消除正常同日并发冲突。 - 不使用 `hashtext`,不把旧长格式纳入最大值,不在 P2002 后原事务内重试;PostgreSQL 唯一冲突会使事务进入 aborted 状态,任何兜底只能重试整个事务。本设计通过咨询锁消除正常同日并发冲突。
- 无 schema 迁移;回滚时可恢复旧生成器,已经生成的新短编号仍满足唯一字符串合同,无需改写。 - 无 schema 迁移;回滚时可恢复旧生成器,已经生成的新短编号仍满足唯一字符串合同,无需改写。
## 10. 付款复核对象与阶段编辑边界
### 10.1 状态门禁
- `draft` 是已提交待复核,不允许批次项 PATCH、回单上传或回单作废;财务仅可查看和取消,合格非制单人可复核。
- `reviewed/failed` 允许财务登记凭证号、实际付款日期以及上传/作废回单;付款渠道已是复核事实,不再接受批次项覆盖。
- `executed/cancelled` 的付款资料和回单全部只读;下载继续受既有权限、用途和附件安全门禁保护。
- 客户端按状态渐进展示当前阶段控件,服务端以同一矩阵拒绝绕过请求。删除 `draft` 的可编辑路径后,不新增一条“待复核修改审计”来延续旧草稿语义。
### 10.2 渠道与复核事实
- 创建面板保留“选择报销”主任务,同时增加一个批次级付款渠道下拉框,默认 `bank`,请求继续使用既有 `defaultPaymentChannel` 字段。
- 渠道和批次金额、收款信息一起构成复核输入;创建后锁定。确需改变渠道时取消未执行批次并重新创建,不在复核后静默改写。
### 10.3 收款信息 DTO
- 报销付款对象取 `ReimbursementForm.owner`,返回姓名、开户银行和经既有加密服务解密后立即脱敏的账户摘要;响应、快照、日志和测试均不得包含完整账号或密文。
- 新批次把上述低敏摘要写入既有 `snapshot_json`,不新增 schema;既有批次缺快照字段时按 `entity_id` 查询当前报销归属人并只读补齐响应,不回写历史批次。
- 复核详情继续显示单号、摘要、金额和状态,并补充付款渠道与收款摘要;页面四段结构和二次确认弹窗保持不变。
@@ -123,3 +123,19 @@
- 部署后以新完整 SHA 重装 automatic-backup 与 WARCH3 合同;4 个 service 的工作目录、环境文件、构建 SHA 与规范根一致,4 个 timer 均为 enabled/active。 - 部署后以新完整 SHA 重装 automatic-backup 与 WARCH3 合同;4 个 service 的工作目录、环境文件、构建 SHA 与规范根一致,4 个 timer 均为 enabled/active。
- 已手工触发一次完整真实自动备份周期:源端成功状态新鲜且构建标识一致,`resolve_active_runtime` 的运行构建、附件挂载和 PGDATA 挂载三项断言通过;异机归档完成,随后 watchdog 证明异机恢复点新鲜。没有输出归档路径、文件名、远端标识或任何密钥。 - 已手工触发一次完整真实自动备份周期:源端成功状态新鲜且构建标识一致,`resolve_active_runtime` 的运行构建、附件挂载和 PGDATA 挂载三项断言通过;异机归档完成,随后 watchdog 证明异机恢复点新鲜。没有输出归档路径、文件名、远端标识或任何密钥。
- 旧源码候选目录与旧环境文件继续保留,清理不属于本轮授权范围。任务仍保留运行观察项,不因本次部署归档。 - 旧源码候选目录与旧环境文件继续保留,清理不属于本轮授权范围。任务仍保留运行观察项,不因本次部署归档。
## 10. 付款复核对象与阶段编辑收口(本轮)
- [x] 服务端将批次项编辑限制为 `reviewed/failed` 的凭证号与实际付款日期,拒绝任何创建后渠道覆盖;`draft/executed/cancelled` 均不可编辑。
- [x] 服务端将回单上传/作废限制为 `reviewed/failed`,保留所有状态下的授权查看/下载;拒绝请求不写附件、存储对象或审计。
- [x] 创建快照加入收款人姓名、开户银行和脱敏账户摘要;既有批次通过只读 fallback 补齐 DTO,不迁移或回写生产数据。
- [x] 创建面板增加付款渠道选择并传既有 `defaultPaymentChannel`;默认银行转账,创建成功后重置。
- [x] 详情待复核阶段隐藏付款登记输入和回单维护动作;复核后显示凭证号/日期与回单动作;已付款/已取消全部只读,四段结构和确认弹窗不变。
- [x] 服务端测试覆盖状态矩阵、渠道锁定、零副作用和脱敏 DTO;前端测试覆盖创建 payload、待复核零编辑、复核后登记及已付款回单只读。
- [x] 运行付款 focused Vitest、相关 Playwright、客户端 typecheck/build、Prisma validate、task/governance/process/sensitive/diff 门禁,并执行付款状态机只读审查。
本地验证(2026-08-02):付款路由与并发保护单测 58 passed;付款权限/高风险动作 Playwright 15 passed;客户端 typecheck/build、Prisma validate、Node syntax、隔离 PostgreSQL 付款并发门禁、task/governance/process/docs/sensitive/diff 门禁通过。首次 Playwright 运行因本地静态预览未启动而在登录页统一 `ERR_CONNECTION_REFUSED`,启动本地预览后原样重跑全绿,不属于业务断言失败。文档审计保持 0 error,既有 22 个未登记 docs 文件 warning 与本轮无关。
只读审查首轮发现:原始 `snapshotJson` 仍可透传异常/历史快照,列表执行不必要的收款资料解密,且缺少 `failed` 正向和回单存储补偿测试。已移除付款响应的原始快照字段,详情只返回格式校验后的固定收款字段,列表不再加载或解密详情级收款资料;补充恶意快照、`failed`、拒绝零文件残留和上传期间状态变化清理回归。未修改生产数据,未执行部署或真实付款动作。
只读复查随后发现付款页仍把精简列表项当详情使用,会让真实收款依据显示为空;已改为按所选批次 ID 加载详情,并将 E2E 锁定为“列表收款字段为空、详情返回脱敏字段且页面确实发起详情请求”。最终复查无 Blocker/High/Medium/Low,未修改文件。
@@ -49,6 +49,9 @@
- 付款页的列表、详情总额、批次项、确认弹窗和高风险阈值统一使用共享 `formatMoney()`;不得继续裸输出金额或在页面内新增货币格式化副本。 - 付款页的列表、详情总额、批次项、确认弹窗和高风险阈值统一使用共享 `formatMoney()`;不得继续裸输出金额或在页面内新增货币格式化副本。
- 新建付款批次编号使用公司时区日期和三位日序号 `PB-YYYYMMDD-NNN`;既有编号不迁移。未显式传名称时自动标题为 `YYYY-MM-DD 付款批次 NNN`,显式名称原样保留。 - 新建付款批次编号使用公司时区日期和三位日序号 `PB-YYYYMMDD-NNN`;既有编号不迁移。未显式传名称时自动标题为 `YYYY-MM-DD 付款批次 NNN`,显式名称原样保留。
- 日序号必须在创建批次的同一交互事务中,先取得双参数 PostgreSQL 事务级咨询锁,再查询当日严格新格式最大序号并创建;咨询锁使用固定 int4 命名空间和 int4 `YYYYMMDD`,不得依赖唯一冲突后的原事务内重试。 - 日序号必须在创建批次的同一交互事务中,先取得双参数 PostgreSQL 事务级咨询锁,再查询当日严格新格式最大序号并创建;咨询锁使用固定 int4 命名空间和 int4 `YYYYMMDD`,不得依赖唯一冲突后的原事务内重试。
- `draft` 已表示“创建并提交、待付款复核”,批次项不得继续沿用未提交草稿的编辑权限;付款凭证号、实际付款日期和银行回单只能在 `reviewed/failed` 财务处理阶段维护,`executed/cancelled` 全部只读。
- 付款渠道在创建面板确定并作为复核事实提交,默认银行转账;创建后不得通过批次项覆盖改变,避免复核对象在复核前后漂移。
- 付款复核明细必须显示收款人姓名、开户银行和脱敏银行账户摘要;不得向付款批次 API 暴露完整账号或加密原值。新批次固化脱敏快照,既有批次以当前报销归属人只读降级展示。
## Acceptance Criteria ## Acceptance Criteria
@@ -75,6 +78,10 @@
- [ ] 付款页所有人民币金额统一显示为 `¥1,234.56` 形式,列表、详情、批次项、确认弹窗和高风险阈值无裸金额残留。 - [ ] 付款页所有人民币金额统一显示为 `¥1,234.56` 形式,列表、详情、批次项、确认弹窗和高风险阈值无裸金额残留。
- [ ] 新批次编号为公司时区 `PB-YYYYMMDD-NNN`,同日并发创建得到唯一递增序号,跨日从 `001` 开始;超过 `999` 时 fail-closed,不改变编号格式。 - [ ] 新批次编号为公司时区 `PB-YYYYMMDD-NNN`,同日并发创建得到唯一递增序号,跨日从 `001` 开始;超过 `999` 时 fail-closed,不改变编号格式。
- [ ] 自动标题在编号生成后使用相同日序号;调用方显式提供名称时不追加序号;旧编号保持原值且导出/AP/外部传递继续按不透明字符串兼容。 - [ ] 自动标题在编号生成后使用相同日序号;调用方显式提供名称时不追加序号;旧编号保持原值且导出/AP/外部传递继续按不透明字符串兼容。
- [ ] 待付款复核期间批次项和回单均不可编辑;后端直接 PATCH/上传/作废请求同样拒绝,且不产生静默未审计变更。
- [ ] 创建批次时可选择付款渠道且默认银行转账;创建后渠道只读,复核人看到的渠道与创建事实一致。
- [ ] 每条付款明细显示收款人姓名、开户银行和脱敏账户摘要,不返回完整账号、密文或其他身份信息;当前既有批次无需迁移即可展示。
- [ ] `reviewed/failed` 允许登记凭证号、实际付款日期及维护回单;`executed/cancelled` 的回单与付款资料只读。
## Out of Scope ## Out of Scope
+4 -3
查看文件
@@ -16,10 +16,12 @@ export type PaymentBatchItem = {
paymentVoucherNoOverride: string | null; paymentVoucherNoOverride: string | null;
paymentDateOverride: string | null; paymentDateOverride: string | null;
paymentChannelOverride: PaymentChannel | null; paymentChannelOverride: PaymentChannel | null;
payeeName: string | null;
bankName: string | null;
bankAccountMasked: string | null;
status: string; status: string;
failureCode: string | null; failureCode: string | null;
failureMessage: string | null; failureMessage: string | null;
snapshotJson: Record<string, unknown>;
createdAt: string | null; createdAt: string | null;
openRiskFlags: RiskFlag[]; openRiskFlags: RiskFlag[];
}; };
@@ -138,9 +140,8 @@ export async function createPaymentBatch(payload: {
export async function updatePaymentBatchItem(batchId: string, itemId: string, payload: { export async function updatePaymentBatchItem(batchId: string, itemId: string, payload: {
paymentVoucherNoOverride?: string | null; paymentVoucherNoOverride?: string | null;
paymentDateOverride?: string | null; paymentDateOverride?: string | null;
paymentChannelOverride?: PaymentChannel | null;
}) { }) {
return apiRequestData<{ item: Pick<PaymentBatchItem, "id" | "paymentVoucherNoOverride" | "paymentDateOverride" | "paymentChannelOverride"> }>( return apiRequestData<{ item: Pick<PaymentBatchItem, "id" | "paymentVoucherNoOverride" | "paymentDateOverride"> }>(
`/api/payment-batches/${batchId}/items/${itemId}`, `/api/payment-batches/${batchId}/items/${itemId}`,
{ {
method: "PATCH", method: "PATCH",
+15 -4
查看文件
@@ -1402,10 +1402,21 @@ export function ApOperationsPage() {
</div> </div>
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-700">快照</div> <div className="mb-2 text-sm font-medium text-zinc-700">付款信息</div>
<pre className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-950/95 p-4 text-xs text-zinc-100"> <div className="grid gap-3 md:grid-cols-3">
{prettyJson(failedDetailItem.snapshotJson)} <div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
</pre> <div className="text-xs text-zinc-500">收款人</div>
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.payeeName ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">开户银行</div>
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.bankName ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
<div className="text-xs text-zinc-500">银行账户</div>
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.bankAccountMasked ?? "-"}</div>
</div>
</div>
</div> </div>
</div> </div>
) : null} ) : null}
+77 -25
查看文件
@@ -7,13 +7,13 @@ import {
downloadPaymentBatchReceiptAttachment, downloadPaymentBatchReceiptAttachment,
executePaymentBatch, executePaymentBatch,
fetchPaymentBatchCandidates, fetchPaymentBatchCandidates,
fetchPaymentBatchDetail,
fetchPaymentBatchReceiptAttachments, fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches, fetchPaymentBatches,
reviewPaymentBatch, reviewPaymentBatch,
updatePaymentBatchItem, updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments, uploadPaymentBatchReceiptAttachments,
voidPaymentBatchReceiptAttachment, voidPaymentBatchReceiptAttachment,
type PaymentBatch,
type PaymentBatchReceiptAttachment, type PaymentBatchReceiptAttachment,
type PaymentChannel, type PaymentChannel,
} from "../api/paymentBatches"; } from "../api/paymentBatches";
@@ -102,6 +102,7 @@ export function PaymentBatchesPage() {
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo")); (me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const [candidateSearch, setCandidateSearch] = useState(""); const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]); const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
const receiptInputRef = useRef<HTMLInputElement | null>(null); const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showCreatePanel, setShowCreatePanel] = useState(false); const [showCreatePanel, setShowCreatePanel] = useState(false);
@@ -131,9 +132,12 @@ export function PaymentBatchesPage() {
const selectedBatchId = searchParams.get("id") ?? ""; const selectedBatchId = searchParams.get("id") ?? "";
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]); const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
const selectedBatch = useMemo<PaymentBatch | null>(() => { const selectedBatchQuery = useQuery({
return batches.find((item) => item.id === selectedBatchId) ?? null; queryKey: ["payment-batches", "detail", selectedBatchId],
}, [batches, selectedBatchId]); enabled: Boolean(selectedBatchId),
queryFn: () => fetchPaymentBatchDetail(selectedBatchId),
});
const selectedBatch = selectedBatchQuery.data?.item ?? null;
const receiptAttachmentsQuery = useQuery({ const receiptAttachmentsQuery = useQuery({
queryKey: ["payment-batch-receipts", selectedBatch?.id], queryKey: ["payment-batch-receipts", selectedBatch?.id],
enabled: Boolean(selectedBatch?.id), enabled: Boolean(selectedBatch?.id),
@@ -143,11 +147,11 @@ export function PaymentBatchesPage() {
const canEditSelectedBatch = const canEditSelectedBatch =
canManageBatch && canManageBatch &&
!!selectedBatch && !!selectedBatch &&
["draft", "reviewed", "failed"].includes(selectedBatch.status); ["reviewed", "failed"].includes(selectedBatch.status);
const canEditReceiptAttachments = const canEditReceiptAttachments =
canManageBatch && canManageBatch &&
!!selectedBatch && !!selectedBatch &&
selectedBatch.status !== "cancelled"; ["reviewed", "failed"].includes(selectedBatch.status);
const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess; const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess;
const canReviewSelectedBatch = const canReviewSelectedBatch =
canReviewBatch && canReviewBatch &&
@@ -194,11 +198,13 @@ export function PaymentBatchesPage() {
createPaymentBatch({ createPaymentBatch({
entityType: "reimbursement", entityType: "reimbursement",
entityIds: selectedCandidateIds, entityIds: selectedCandidateIds,
defaultPaymentChannel: selectedPaymentChannel,
}), }),
onSuccess: async ({ item }) => { onSuccess: async ({ item }) => {
setError(""); setError("");
setSelectedCandidateIds([]); setSelectedCandidateIds([]);
setCandidateSearch(""); setCandidateSearch("");
setSelectedPaymentChannel("bank");
const next = new URLSearchParams(searchParams); const next = new URLSearchParams(searchParams);
next.set("id", item.id); next.set("id", item.id);
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
@@ -214,7 +220,9 @@ export function PaymentBatchesPage() {
const reviewMutation = useMutation({ const reviewMutation = useMutation({
mutationFn: async (batchId: string) => { mutationFn: async (batchId: string) => {
const batch = listQuery.data?.items.find((item) => item.id === batchId); const batch = selectedBatch?.id === batchId
? selectedBatch
: listQuery.data?.items.find((item) => item.id === batchId);
const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id)); const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id));
return reviewPaymentBatch(batchId, { resolveRiskFlagIds }); return reviewPaymentBatch(batchId, { resolveRiskFlagIds });
}, },
@@ -261,11 +269,10 @@ export function PaymentBatchesPage() {
}); });
const updateItemMutation = useMutation({ const updateItemMutation = useMutation({
mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null; paymentChannelOverride?: PaymentChannel | null }) => mutationFn: async (payload: { batchId: string; itemId: string; paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null }) =>
updatePaymentBatchItem(payload.batchId, payload.itemId, { updatePaymentBatchItem(payload.batchId, payload.itemId, {
paymentVoucherNoOverride: payload.paymentVoucherNoOverride, paymentVoucherNoOverride: payload.paymentVoucherNoOverride,
paymentDateOverride: payload.paymentDateOverride, paymentDateOverride: payload.paymentDateOverride,
paymentChannelOverride: payload.paymentChannelOverride,
}), }),
onSuccess: async () => { onSuccess: async () => {
setError(""); setError("");
@@ -430,8 +437,23 @@ export function PaymentBatchesPage() {
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div> <div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div>
) : null} ) : null}
</div> </div>
<div className="max-w-sm space-y-2">
<Label htmlFor="payment-batch-default-channel">付款渠道</Label>
<select
id="payment-batch-default-channel"
data-testid="payment-batch-default-channel"
className="select-std w-full"
value={selectedPaymentChannel}
disabled={!canManageBatch}
onChange={(event) => setSelectedPaymentChannel(event.target.value as PaymentChannel)}
>
{PAYMENT_CHANNEL_OPTIONS.map((option) => (
<option key={option} value={option}>{paymentChannelLabel(option)}</option>
))}
</select>
</div>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,默认按银行转账提交负责人复核。 已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,按{paymentChannelLabel(selectedPaymentChannel)}提交负责人复核。
</div> </div>
</div> </div>
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}> <Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
@@ -441,7 +463,22 @@ export function PaymentBatchesPage() {
) : null} ) : null}
{selectedBatchId ? ( {selectedBatchId ? (
selectedBatch ? ( selectedBatchQuery.isLoading ? (
<div className="finance-command-panel text-sm text-zinc-500">正在加载付款批次...</div>
) : selectedBatchQuery.isError ? (
<div className="finance-command-panel flex flex-wrap items-center justify-between gap-3 text-sm text-red-600">
<span>{resolveError(selectedBatchQuery.error)}</span>
<Button
type="button"
size="sm"
variant="secondary"
disabled={selectedBatchQuery.isFetching}
onClick={() => void selectedBatchQuery.refetch()}
>
{selectedBatchQuery.isFetching ? "重新加载中..." : "重新加载"}
</Button>
</div>
) : selectedBatch ? (
<div className="space-y-5"> <div className="space-y-5">
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel"> <div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -586,31 +623,42 @@ export function PaymentBatchesPage() {
</div> </div>
<div className="text-sm text-zinc-600">{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</div> <div className="text-sm text-zinc-600">{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}</div>
</div> </div>
<div className="mt-3 grid gap-3 md:grid-cols-4"> <div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
</div>
{["reviewed", "failed"].includes(selectedBatch.status) ? (
<div className="mt-3 grid gap-3 md:grid-cols-3" data-testid="payment-batch-item-payment-fields">
<Input <Input
aria-label={`付款凭证号 ${item.entityCode}`}
defaultValue={item.paymentVoucherNoOverride ?? ""} defaultValue={item.paymentVoucherNoOverride ?? ""}
placeholder="凭证号" placeholder="付款凭证号"
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)} disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/> />
<DateTextInput <DateTextInput
aria-label={`实际付款日期 ${item.entityCode}`}
defaultValue={item.paymentDateOverride ?? ""} defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)} disabled={!canEditSelectedBatch || !["pending", "failed"].includes(item.status)}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} 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"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"} {item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"}
</div> </div>
</div> </div>
) : ["executed", "cancelled"].includes(selectedBatch.status) ? (
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-3" data-testid="payment-batch-item-payment-readonly">
<div className="detail-fact-card"><div className="detail-fact-card__label">付款凭证号</div><div className="detail-fact-card__value">{item.paymentVoucherNoOverride || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">实际付款日期</div><div className="detail-fact-card__value">{formatDisplayDate(item.paymentDateOverride ?? selectedBatch.defaultPaymentDate)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款结果</div><div className="detail-fact-card__value">{item.failureMessage || paymentItemStatusLabel(item.status)}</div></div>
</div>
) : (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
付款复核通过后,由财务登记付款凭证号和实际付款日期。
</div>
)}
<RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" /> <RiskFlagsCard items={item.openRiskFlags} title="关联开放风险" />
</div> </div>
))} ))}
@@ -621,7 +669,11 @@ export function PaymentBatchesPage() {
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">银行回单</h3> <h3 className="text-base font-semibold text-zinc-900">银行回单</h3>
<p className="mt-1 text-sm text-zinc-500">先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。</p> <p className="mt-1 text-sm text-zinc-500">
{selectedBatch.status === "draft"
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
</p>
</div> </div>
{canUploadReceipt ? ( {canUploadReceipt ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -686,12 +738,12 @@ export function PaymentBatchesPage() {
> >
下载 下载
</Button> </Button>
{canManageBatch ? ( {canEditReceiptAttachments ? (
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
disabled={selectedBatch.status === "cancelled" || Boolean(attachment.archivedAt) || voidReceiptMutation.isPending} disabled={Boolean(attachment.archivedAt) || voidReceiptMutation.isPending}
onClick={() => { onClick={() => {
setActionReason(""); setActionReason("");
setReasonAction({ setReasonAction({
@@ -415,6 +415,8 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0); await expect(page.locator('input[placeholder*="UUID"], input[placeholder*="uuid"], input[placeholder*="entityId"], input[placeholder*="内部 ID"], input[placeholder*="内部ID"]')).toHaveCount(0);
await candidateRow.getByRole("checkbox").check(); await candidateRow.getByRole("checkbox").check();
await page.getByTestId("payment-batch-default-channel").selectOption("cash");
await expect(page.getByText("按现金提交负责人复核", { exact: false })).toBeVisible();
await page.getByRole("button", { name: "创建并提交复核", exact: true }).click(); await page.getByRole("button", { name: "创建并提交复核", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`)); await expect(page).toHaveURL(new RegExp(`/payment-batches\\?id=${CREATED_PAYMENT_BATCH_ID}$`));
@@ -423,6 +425,7 @@ test("财务从业务候选选择报销并使用所选实体创建付款批次",
expect(createPayload).toEqual({ expect(createPayload).toEqual({
entityType: "reimbursement", entityType: "reimbursement",
entityIds: [PAYMENT_CANDIDATE_ENTITY_ID], entityIds: [PAYMENT_CANDIDATE_ENTITY_ID],
defaultPaymentChannel: "cash",
}); });
expectNoPageErrors(pageErrors); expectNoPageErrors(pageErrors);
}); });
@@ -156,7 +156,6 @@ function paymentBatch(status: "draft" | "reviewed" | "failed", suffix: string) {
status: failed ? "failed" : "pending", status: failed ? "failed" : "pending",
failureCode: failed ? "BANK_RECEIPT_REQUIRED" : null, failureCode: failed ? "BANK_RECEIPT_REQUIRED" : null,
failureMessage: failed ? "缺少银行回单附件" : null, failureMessage: failed ? "缺少银行回单附件" : null,
snapshotJson: {},
createdAt: "2026-05-27T09:05:00.000Z", createdAt: "2026-05-27T09:05:00.000Z",
openRiskFlags: failed ? [riskFlag] : [], openRiskFlags: failed ? [riskFlag] : [],
}, },
@@ -114,9 +114,16 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first(); const candidateRow = createCard.getByTestId("payment-batch-candidate-row").filter({ hasText: reimbursementDisplayCode }).first();
await expect(candidateRow).toBeVisible(); await expect(candidateRow).toBeVisible();
await candidateRow.getByRole("checkbox").check(); await candidateRow.getByRole("checkbox").check();
await createCard.getByTestId("payment-batch-default-channel").selectOption("bank");
await createCard.getByRole("button", { name: "创建并提交复核" }).click(); await createCard.getByRole("button", { name: "创建并提交复核" }).click();
const createBatchPayload = (await (await createBatchResponsePromise).json()) as { const createBatchResponse = await createBatchResponsePromise;
expect(createBatchResponse.request().postDataJSON()).toMatchObject({
entityType: "reimbursement",
entityIds: [reimbursementId],
defaultPaymentChannel: "bank",
});
const createBatchPayload = (await createBatchResponse.json()) as {
data?: { item?: { id?: string; name?: string } }; data?: { item?: { id?: string; name?: string } };
}; };
const batchId = createBatchPayload.data?.item?.id; const batchId = createBatchPayload.data?.item?.id;
@@ -130,19 +137,28 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await expect(batchDetailCard).toContainText("待付款复核"); await expect(batchDetailCard).toContainText("待付款复核");
await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理"); await expect(batchDetailCard).toContainText("已提交付款复核,等待负责人处理");
await expect(batchDetailCard).toContainText("0/1"); await expect(batchDetailCard).toContainText("0/1");
const draftItemsPanel = page.getByTestId("payment-batch-items-panel");
await expect(draftItemsPanel).toContainText(EMPLOYEE.name);
await expect(draftItemsPanel).toContainText("开户银行");
await expect(draftItemsPanel).toContainText("银行账户");
await expect(draftItemsPanel).toContainText("银行转账");
await expect(draftItemsPanel.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
const draftReceiptPanel = page.getByTestId("payment-batch-receipt-panel");
await expect(draftReceiptPanel).toContainText("付款复核通过后,由财务在线下完成付款并上传银行回单");
await expect(draftReceiptPanel.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
await expect(draftReceiptPanel.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await logout(page, FINANCE.name); await logout(page, FINANCE.name);
await login(page, MANAGER); await login(page, MANAGER);
await page.goto("/ap-operations"); await page.goto(`/payment-batches?id=${batchId}`);
await page.getByRole("tab", { name: /待付款/ }).click(); const managerBatchDetail = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first(); await expect(managerBatchDetail).toBeVisible();
await expect(pendingRow).toBeVisible(); await expect(page.getByTestId("payment-batch-items-panel")).toContainText(EMPLOYEE.name);
await pendingRow.getByRole("button", { name: "快捷复核" }).click(); await managerBatchDetail.getByRole("button", { name: "复核通过" }).click();
await expect(page.getByRole("dialog")).toContainText(batchName); await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核");
await page.getByPlaceholder("请输入复核意见").fill("R78 focused regression review"); await page.getByTestId("action-confirm-submit").click();
await page.getByRole("button", { name: "提交复核" }).click(); await expect(managerBatchDetail).toContainText("1/1");
await expect(pendingRow).toContainText("复核 1/1");
await logout(page, MANAGER.name); await logout(page, MANAGER.name);
@@ -153,6 +169,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await expect(executedBatchDetailCard).toContainText("1/1"); await expect(executedBatchDetailCard).toContainText("1/1");
const receiptCard = page.getByTestId("payment-batch-receipt-panel"); const receiptCard = page.getByTestId("payment-batch-receipt-panel");
await expect(receiptCard).toBeVisible(); await expect(receiptCard).toBeVisible();
await expect(page.getByTestId("payment-batch-item-payment-fields")).toBeVisible();
await expect(executedBatchDetailCard.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible(); await expect(executedBatchDetailCard.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible();
await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
const receiptUploadResponsePromise = page.waitForResponse((response) => { const receiptUploadResponsePromise = page.waitForResponse((response) => {
@@ -181,6 +198,12 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
await page.getByTestId("action-confirm-submit").click(); await page.getByTestId("action-confirm-submit").click();
await executeBatchResponsePromise; await executeBatchResponsePromise;
await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 }); await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
await expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
await expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible();
await expect(receiptCard.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
await expect(receiptCard.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(receiptCard.getByRole("button", { name: "作废" })).toHaveCount(0);
await expect(receiptCard.getByRole("button", { name: "下载" })).toBeVisible();
await page.goto(`/reimbursements/${reimbursementId}`); await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded(); await reimbursementDetail.assertLoaded();
@@ -124,7 +124,7 @@ function reimbursementDetail() {
}; };
} }
function paymentBatch(status: "draft" | "reviewed" = "reviewed", highRisk = false) { function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" = "reviewed", highRisk = false) {
return { return {
id: paymentBatchId, id: paymentBatchId,
batchNo: "PAY-ACTION-001", batchNo: "PAY-ACTION-001",
@@ -169,10 +169,12 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed", highRisk = fals
paymentVoucherNoOverride: null, paymentVoucherNoOverride: null,
paymentDateOverride: null, paymentDateOverride: null,
paymentChannelOverride: null, paymentChannelOverride: null,
payeeName: "合成收款员工",
bankName: "合成银行",
bankAccountMasked: "6222********5678",
status: "pending", status: "pending",
failureCode: null, failureCode: null,
failureMessage: null, failureMessage: null,
snapshotJson: {},
createdAt: "2026-05-15T08:00:00.000Z", createdAt: "2026-05-15T08:00:00.000Z",
openRiskFlags: [], openRiskFlags: [],
}, },
@@ -183,7 +185,7 @@ function paymentBatch(status: "draft" | "reviewed" = "reviewed", highRisk = fals
async function mockSystemActionApis( async function mockSystemActionApis(
page: Page, page: Page,
options: { options: {
batchStatus?: "draft" | "reviewed"; batchStatus?: "draft" | "reviewed" | "executed" | "cancelled";
hasReceipt?: boolean; hasReceipt?: boolean;
receiptQueryError?: boolean; receiptQueryError?: boolean;
highRisk?: boolean; highRisk?: boolean;
@@ -198,6 +200,7 @@ async function mockSystemActionApis(
deleteReimbursementAttachment: 0, deleteReimbursementAttachment: 0,
exportReimbursements: 0, exportReimbursements: 0,
executePaymentBatch: 0, executePaymentBatch: 0,
paymentBatchDetail: 0,
uploadPaymentReceipt: 0, uploadPaymentReceipt: 0,
voidPaymentReceipt: 0, voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[], attachmentDeleteReasons: [] as string[],
@@ -330,7 +333,24 @@ 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, options.highRisk)] }); const listItem = paymentBatch(batchStatus, options.highRisk);
await fulfillJson(route, {
items: [{
...listItem,
items: listItem.items.map((item) => ({
...item,
payeeName: null,
bankName: null,
bankAccountMasked: null,
})),
}],
});
return;
}
if (path === `/api/payment-batches/${paymentBatchId}` && request.method() === "GET") {
calls.paymentBatchDetail += 1;
await fulfillJson(route, { item: paymentBatch(batchStatus, options.highRisk) });
return; return;
} }
@@ -502,6 +522,34 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("线下付款并上传银行回单"); await expect(page.getByTestId("payment-batch-current-processing")).toContainText("线下付款并上传银行回单");
}); });
test("付款详情按批次状态渐进展示收款依据和财务动作", async ({ page }) => {
const calls = await mockSystemActionApis(page, { batchStatus: "draft", hasReceipt: true });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const draftItems = page.getByTestId("payment-batch-items-panel");
await expect.poll(() => calls.paymentBatchDetail).toBe(1);
await expect(draftItems).toContainText("合成收款员工");
await expect(draftItems).toContainText("合成银行");
await expect(draftItems).toContainText("6222********5678");
await expect(draftItems).toContainText("银行转账");
await expect(draftItems.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
await expect(page.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "作废" })).toHaveCount(0);
await page.unrouteAll({ behavior: "wait" });
await mockSystemActionApis(page, { batchStatus: "executed", hasReceipt: true });
await page.reload();
await expect(page.getByTestId("payment-batch-item-payment-fields")).toHaveCount(0);
await expect(page.getByTestId("payment-batch-item-payment-readonly")).toBeVisible();
await expect(page.getByTestId("payment-batch-receipt-file-input")).toHaveCount(0);
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "作废" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "下载" })).toBeVisible();
});
test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => { test("高敏附件删除要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
const calls = await mockSystemActionApis(page); const calls = await mockSystemActionApis(page);
await login(page, user); await login(page, user);
+132 -24
查看文件
@@ -17,6 +17,7 @@ const { sendNotificationEmailSafe } = require("../services/email");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl"); const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage"); const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const { const {
buildAttachmentContentDisposition, buildAttachmentContentDisposition,
@@ -61,7 +62,6 @@ const CREATE_SCHEMA = z.object({
const PATCH_ITEM_SCHEMA = z.object({ const PATCH_ITEM_SCHEMA = z.object({
paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(), paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(),
paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
paymentChannelOverride: PAYMENT_CHANNEL_SCHEMA.nullable().optional(),
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); }).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const REVIEW_SCHEMA = z.object({ const REVIEW_SCHEMA = z.object({
@@ -492,7 +492,22 @@ async function lockPaymentEntities(tx, entityType, entityIds) {
`); `);
} }
async function loadEntitySnapshots(tx, entityType, entityIds) { function buildPayeeSnapshot(config, owner) {
const encryptedAccount = owner?.bank_account_encrypted;
const bankAccount = encryptedAccount
? decryptStringOrThrow(config, encryptedAccount)
: null;
if (encryptedAccount && !bankAccount) {
throw new Error("Payee bank account could not be decrypted");
}
return {
name: owner?.name ?? null,
bankName: owner?.bank_name ?? null,
bankAccountMasked: maskBankAccount(bankAccount),
};
}
async function loadEntitySnapshots(tx, entityType, entityIds, config) {
if (entityType === "reimbursement") { if (entityType === "reimbursement") {
const items = await tx.reimbursementForm.findMany({ const items = await tx.reimbursementForm.findMany({
where: { id: { in: entityIds } }, where: { id: { in: entityIds } },
@@ -507,6 +522,13 @@ async function loadEntitySnapshots(tx, entityType, entityIds) {
payment_date: true, payment_date: true,
payment_channel: true, payment_channel: true,
paid_at: true, paid_at: true,
owner: {
select: {
name: true,
bank_name: true,
bank_account_encrypted: true,
},
},
}, },
}); });
return items.map((item) => ({ return items.map((item) => ({
@@ -525,6 +547,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds) {
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null, paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel ?? null, paymentChannel: item.payment_channel ?? null,
paidAt: item.paid_at?.toISOString?.() ?? null, paidAt: item.paid_at?.toISOString?.() ?? null,
payee: buildPayeeSnapshot(config, item.owner),
}, },
})); }));
} }
@@ -577,7 +600,77 @@ function ensureBatchEntityStatus(entityType, snapshot) {
} }
} }
function mapBatch(batch, openRiskFlags = []) { function normalizeSnapshotText(value) {
if (typeof value !== "string") return null;
const normalized = value.trim();
const compactDigits = normalized.replace(/[\s-]/g, "");
if (
!normalized ||
normalized.length > 100 ||
normalized.startsWith("v1:") ||
/^\d{9,32}$/.test(compactDigits)
) return null;
return normalized;
}
function normalizeMaskedBankAccount(value) {
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.length > 64) return null;
if (/^\*{1,8}$/.test(normalized)) return normalized;
return /^[0-9A-Za-z]{4}\*+[0-9A-Za-z]{4}$/.test(normalized) ? normalized : null;
}
function readSnapshotPayee(snapshotJson) {
const payee = snapshotJson?.payee;
if (!payee || typeof payee !== "object" || Array.isArray(payee)) {
return null;
}
const bankAccountMasked = normalizeMaskedBankAccount(payee.bankAccountMasked);
const name = normalizeSnapshotText(payee.name);
const bankName = normalizeSnapshotText(payee.bankName);
if (
(payee.name != null && !name) ||
(payee.bankName != null && !bankName) ||
(payee.bankAccountMasked != null && !bankAccountMasked)
) {
return null;
}
return {
name,
bankName,
bankAccountMasked,
};
}
async function loadLegacyPayees(tx, batch, config) {
if (batch.entity_type !== "reimbursement") {
return new Map();
}
const entityIds = (batch.items ?? [])
.filter((item) => !readSnapshotPayee(item.snapshot_json))
.map((item) => item.entity_id);
if (!entityIds.length) {
return new Map();
}
const reimbursements = await tx.reimbursementForm.findMany({
where: { id: { in: Array.from(new Set(entityIds)) } },
select: {
id: true,
owner: {
select: {
name: true,
bank_name: true,
bank_account_encrypted: true,
},
},
},
});
return new Map(reimbursements.map((item) => [item.id, buildPayeeSnapshot(config, item.owner)]));
}
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
const includePayee = options.includePayee === true;
const batchRisk = summarizeBatchRisk(batch); const batchRisk = summarizeBatchRisk(batch);
const riskMap = new Map(); const riskMap = new Map();
for (const item of openRiskFlags) { for (const item of openRiskFlags) {
@@ -615,7 +708,11 @@ function mapBatch(batch, openRiskFlags = []) {
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null, cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
cancelledBy: batch.cancelled_by ?? null, cancelledBy: batch.cancelled_by ?? null,
cancelledReason: batch.cancelled_reason ?? null, cancelledReason: batch.cancelled_reason ?? null,
items: (batch.items ?? []).map((item) => ({ items: (batch.items ?? []).map((item) => {
const payee = includePayee
? readSnapshotPayee(item.snapshot_json) ?? legacyPayees.get(item.entity_id) ?? {}
: {};
return {
id: item.id, id: item.id,
entityId: item.entity_id, entityId: item.entity_id,
entityCode: item.entity_code, entityCode: item.entity_code,
@@ -625,14 +722,22 @@ function mapBatch(batch, openRiskFlags = []) {
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null, paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null, paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: item.payment_channel_override ?? null, paymentChannelOverride: item.payment_channel_override ?? null,
payeeName: payee.name ?? null,
bankName: payee.bankName ?? null,
bankAccountMasked: payee.bankAccountMasked ?? null,
status: item.status, status: item.status,
failureCode: item.failure_code ?? null, failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null, failureMessage: item.failure_message ?? null,
snapshotJson: item.snapshot_json,
createdAt: item.created_at?.toISOString?.() ?? null, createdAt: item.created_at?.toISOString?.() ?? null,
openRiskFlags: riskMap.get(item.entity_id) ?? [], openRiskFlags: riskMap.get(item.entity_id) ?? [],
})),
}; };
}),
};
}
async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
const legacyPayees = await loadLegacyPayees(tx, batch, config);
return mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true });
} }
async function listOpenRiskFlagsForBatch(tx, batch) { async function listOpenRiskFlagsForBatch(tx, batch) {
@@ -1217,7 +1322,7 @@ async function paymentBatchesRoutes(fastify) {
const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => { const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
await lockPaymentEntities(tx, body.entityType, entityIds); await lockPaymentEntities(tx, body.entityType, entityIds);
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds); const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds, fastify.config);
if (snapshots.length !== entityIds.length) { if (snapshots.length !== entityIds.length) {
throw buildValidationError({ entityIds: "contains_not_found" }, 404); throw buildValidationError({ entityIds: "contains_not_found" }, 404);
} }
@@ -1312,7 +1417,8 @@ async function paymentBatchesRoutes(fastify) {
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask); await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } }); const mappedCreated = await mapBatchForResponse(fastify.prisma, created, [], fastify.config);
return reply.code(201).send({ requestId: request.id, data: { item: mappedCreated } });
}); });
fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => { fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -1322,7 +1428,9 @@ async function paymentBatchesRoutes(fastify) {
const batch = await loadBatchWithItems(fastify.prisma, batchId); const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch); await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch); const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) }); return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config),
});
}); });
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => { fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -1371,7 +1479,7 @@ async function paymentBatchesRoutes(fastify) {
const batchId = parseId(request.params?.id); const batchId = parseId(request.params?.id);
const batch = await loadBatchWithItems(fastify.prisma, batchId); const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch); await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (batch.status === "cancelled") { if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409); throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
} }
@@ -1397,7 +1505,7 @@ async function paymentBatchesRoutes(fastify) {
createdItems = await fastify.prisma.$transaction(async (tx) => { createdItems = await fastify.prisma.$transaction(async (tx) => {
const lockedBatch = await lockBatchWithItems(tx, batchId); const lockedBatch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, lockedBatch); await ensureBatchSourceDepartmentsEnabled(fastify, tx, lockedBatch);
if (lockedBatch.status === "cancelled") { if (!["reviewed", "failed"].includes(lockedBatch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409); throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
} }
@@ -1653,7 +1761,7 @@ async function paymentBatchesRoutes(fastify) {
const result = await fastify.prisma.$transaction(async (tx) => { const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId); const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (batch.status === "cancelled") { if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409); throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
} }
const attachment = await tx.paymentBatchReceiptAttachment.findFirst({ const attachment = await tx.paymentBatchReceiptAttachment.findFirst({
@@ -1705,8 +1813,8 @@ async function paymentBatchesRoutes(fastify) {
const updated = await fastify.prisma.$transaction(async (tx) => { const updated = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId); const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (!["draft", "reviewed", "failed"].includes(batch.status)) { if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_open_batch_editable" }, 409); throw buildValidationError({ status: "payment_metadata_not_editable" }, 409);
} }
const item = batch.items.find((entry) => entry.id === itemId); const item = batch.items.find((entry) => entry.id === itemId);
if (!item) { if (!item) {
@@ -1719,7 +1827,6 @@ async function paymentBatchesRoutes(fastify) {
const before = { const before = {
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null, paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null, paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: item.payment_channel_override ?? null,
}; };
const next = await tx.paymentBatchItem.update({ const next = await tx.paymentBatchItem.update({
where: { id: itemId }, where: { id: itemId },
@@ -1730,17 +1837,13 @@ async function paymentBatchesRoutes(fastify) {
...(body.paymentDateOverride !== undefined ...(body.paymentDateOverride !== undefined
? { payment_date_override: body.paymentDateOverride ? parseDateOnlyString(body.paymentDateOverride) : null } ? { payment_date_override: body.paymentDateOverride ? parseDateOnlyString(body.paymentDateOverride) : null }
: {}), : {}),
...(body.paymentChannelOverride !== undefined
? { payment_channel_override: body.paymentChannelOverride ?? null }
: {}),
}, },
}); });
const after = { const after = {
paymentVoucherNoOverride: next.payment_voucher_no_override ?? null, paymentVoucherNoOverride: next.payment_voucher_no_override ?? null,
paymentDateOverride: next.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null, paymentDateOverride: next.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: next.payment_channel_override ?? null,
}; };
if (batch.status !== "draft" && JSON.stringify(before) !== JSON.stringify(after)) { if (JSON.stringify(before) !== JSON.stringify(after)) {
await tx.auditLog.create({ await tx.auditLog.create({
data: buildAuditLogData(request, { data: buildAuditLogData(request, {
actorId: request.employee.id, actorId: request.employee.id,
@@ -1758,7 +1861,6 @@ async function paymentBatchesRoutes(fastify) {
id: updated.id, id: updated.id,
paymentVoucherNoOverride: updated.payment_voucher_no_override ?? null, paymentVoucherNoOverride: updated.payment_voucher_no_override ?? null,
paymentDateOverride: updated.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null, paymentDateOverride: updated.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: updated.payment_channel_override ?? null,
}, },
}); });
}); });
@@ -1859,7 +1961,9 @@ async function paymentBatchesRoutes(fastify) {
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask); await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return sendData(reply, request, { item: mapBatch(result, []) }); return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, result, [], fastify.config),
});
}); });
fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => { fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -2027,7 +2131,9 @@ async function paymentBatchesRoutes(fastify) {
} }
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result); const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result);
return sendData(reply, request, { item: mapBatch(result, openRiskFlags) }); return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, result, openRiskFlags, fastify.config),
});
}); });
fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => { fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -2078,7 +2184,9 @@ async function paymentBatchesRoutes(fastify) {
}); });
return loadBatchWithItems(tx, batchId); return loadBatchWithItems(tx, batchId);
}); });
return sendData(reply, request, { item: mapBatch(cancelled, []) }); return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, cancelled, [], fastify.config),
});
}); });
} }
@@ -5,6 +5,7 @@ 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 { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { encryptStringOrThrow } = require("../services/crypto");
const { currentDateString } = require("../utils/date"); 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]"]);
@@ -93,9 +94,9 @@ function validatePaymentConcurrencyEnvironment(env = process.env) {
fail("database_confirmation_mismatch"); fail("database_confirmation_mismatch");
} }
requireValue(env, "JWT_SECRET"); requireValue(env, "JWT_SECRET");
requireValue(env, "FIELD_ENCRYPTION_KEY"); const fieldEncryptionKey = requireValue(env, "FIELD_ENCRYPTION_KEY");
return { databaseName, databaseUrl }; return { databaseName, databaseUrl, fieldEncryptionKey };
} }
function responseJson(response) { function responseJson(response) {
@@ -200,7 +201,7 @@ async function runBlockedRace({
} }
} }
function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }) { function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId, fieldEncryptionKey }) {
return { return {
id, id,
employee_no: `QA-${runKey}-${kind}`, employee_no: `QA-${runKey}-${kind}`,
@@ -211,7 +212,10 @@ function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }
legal_entity_id: legalEntityId, legal_entity_id: legalEntityId,
title: "并发验收", title: "并发验收",
hire_date: new Date("2026-01-01T00:00:00.000Z"), hire_date: new Date("2026-01-01T00:00:00.000Z"),
bank_account_encrypted: `synthetic-${runKey}`, bank_account_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey },
`6222${runKey.slice(0, 8)}5678`,
),
bank_name: "合成银行", bank_name: "合成银行",
password_hash: "unused-by-token-based-concurrency-check", password_hash: "unused-by-token-based-concurrency-check",
token_version: 0, token_version: 0,
@@ -219,7 +223,7 @@ function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }
}; };
} }
async function seedSyntheticFixture(prisma) { async function seedSyntheticFixture(prisma, fieldEncryptionKey) {
const runKey = crypto.randomBytes(6).toString("hex"); const runKey = crypto.randomBytes(6).toString("hex");
const department = await prisma.department.create({ const department = await prisma.department.create({
data: { name: `合成部门-${runKey}` }, data: { name: `合成部门-${runKey}` },
@@ -244,6 +248,7 @@ async function seedSyntheticFixture(prisma) {
kind, kind,
departmentId: department.id, departmentId: department.id,
legalEntityId: legalEntity.id, legalEntityId: legalEntity.id,
fieldEncryptionKey,
}), }),
}); });
} }
@@ -772,7 +777,7 @@ async function runPaymentPostgresConcurrencyCheck(env = process.env) {
const { prisma, pool } = createPrismaClient(); const { prisma, pool } = createPrismaClient();
let app = null; let app = null;
try { try {
const fixture = await seedSyntheticFixture(prisma); const fixture = await seedSyntheticFixture(prisma, context.fieldEncryptionKey);
app = await buildApp({ startJobs: async () => undefined }); app = await buildApp({ startJobs: async () => undefined });
await app.ready(); await app.ready();
const tokens = { const tokens = {
@@ -12,6 +12,7 @@ const notifications = require("../../../src/services/notifications");
const email = require("../../../src/services/email"); const email = require("../../../src/services/email");
const notificationDispatcher = require("../../../src/services/notificationDispatcher"); const notificationDispatcher = require("../../../src/services/notificationDispatcher");
const erpJournal = require("../../../src/services/integrations/erpJournal"); const erpJournal = require("../../../src/services/integrations/erpJournal");
const { encryptStringOrThrow } = require("../../../src/services/crypto");
const evaluateFeatureFlagMock = vi.fn(); const evaluateFeatureFlagMock = vi.fn();
const createNotificationsWithRecordsMock = vi.fn(async () => []); const createNotificationsWithRecordsMock = vi.fn(async () => []);
@@ -52,6 +53,8 @@ const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999"; const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
const TRAVEL_ORDER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const TRAVEL_ORDER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const SYNTHETIC_RECEIPT_PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "ascii"); const SYNTHETIC_RECEIPT_PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "ascii");
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
const SYNTHETIC_BANK_ACCOUNT = `6222${"0".repeat(8)}5678`;
function buildMultipartRequest({ files, fields = {} }) { function buildMultipartRequest({ files, fields = {} }) {
const boundary = "----codex-payment-batch-receipt-boundary"; const boundary = "----codex-payment-batch-receipt-boundary";
@@ -85,6 +88,24 @@ function buildMultipartRequest({ files, fields = {} }) {
}; };
} }
async function listStoredReceiptFiles(rootDir) {
const files = [];
const pending = [rootDir];
while (pending.length) {
const current = pending.pop();
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(current, entry.name);
if (entry.isDirectory()) {
pending.push(entryPath);
} else if (entry.isFile()) {
files.push(entryPath);
}
}
}
return files;
}
function buildPaymentBatch(overrides = {}) { function buildPaymentBatch(overrides = {}) {
return { return {
id: BATCH_ID, id: BATCH_ID,
@@ -153,6 +174,11 @@ function buildReimbursement(overrides = {}) {
id: OWNER_ID, id: OWNER_ID,
employee_no: "EMP001", employee_no: "EMP001",
name: "测试员工", name: "测试员工",
bank_name: "测试银行",
bank_account_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
SYNTHETIC_BANK_ACCOUNT,
),
legal_entity: { legal_entity: {
code: "HYB", code: "HYB",
}, },
@@ -366,7 +392,7 @@ function buildPrisma(options = {}) {
options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1), options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1),
), ),
findMany: vi.fn(async ({ select } = {}) => { findMany: vi.fn(async ({ select } = {}) => {
if (select?.owner) { if (select?.owner?.select?.employee_no) {
return state.reservedEntityIds.includes(state.reimbursement.id) ? [] : [{ ...state.reimbursement }]; return state.reservedEntityIds.includes(state.reimbursement.id) ? [] : [{ ...state.reimbursement }];
} }
const reimbursements = [state.reimbursement, ...state.additionalReimbursements]; const reimbursements = [state.reimbursement, ...state.additionalReimbursements];
@@ -518,6 +544,7 @@ async function buildApp(options = {}) {
app.decorate("prisma", prisma); app.decorate("prisma", prisma);
app.decorate("config", { app.decorate("config", {
appTimezone: options.appTimezone ?? "Asia/Shanghai", appTimezone: options.appTimezone ?? "Asia/Shanghai",
fieldEncryptionKey: FIELD_ENCRYPTION_KEY,
storageDir: tempDir, storageDir: tempDir,
maxUploadSizeBytes: 5 * 1024 * 1024, maxUploadSizeBytes: 5 * 1024 * 1024,
paymentBatchEnabled: true, paymentBatchEnabled: true,
@@ -905,8 +932,8 @@ describe("payment batch reimbursement routes", () => {
await app.close(); await app.close();
}); });
it("allows a manager with payment review permission to read the batch list and detail", async () => { it("keeps the batch list compact and loads payee review facts only for detail", async () => {
const { app } = await buildApp({ const { app, tx } = await buildApp({
actor: { actor: {
id: REVIEWER_ID, id: REVIEWER_ID,
department_id: "dept-1", department_id: "dept-1",
@@ -920,16 +947,68 @@ describe("payment batch reimbursement routes", () => {
url: "/api/payment-batches", url: "/api/payment-batches",
headers: { authorization: "Bearer test" }, headers: { authorization: "Bearer test" },
}); });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items[0].id).toBe(BATCH_ID);
expect(listResponse.json().data.items[0].items[0]).toMatchObject({
payeeName: null,
bankName: null,
bankAccountMasked: null,
});
expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("snapshotJson");
expect(tx.reimbursementForm.findMany).toHaveBeenCalledTimes(1);
const detailResponse = await app.inject({ const detailResponse = await app.inject({
method: "GET", method: "GET",
url: `/api/payment-batches/${BATCH_ID}`, url: `/api/payment-batches/${BATCH_ID}`,
headers: { authorization: "Bearer test" }, headers: { authorization: "Bearer test" },
}); });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items[0].id).toBe(BATCH_ID);
expect(detailResponse.statusCode).toBe(200); expect(detailResponse.statusCode).toBe(200);
expect(detailResponse.json().data.item.id).toBe(BATCH_ID); expect(detailResponse.json().data.item.id).toBe(BATCH_ID);
expect(detailResponse.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
});
expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
await app.close();
});
it("never exposes an untrusted stored payment snapshot", async () => {
const ciphertextSentinel = "v1:synthetic:encrypted:payload";
const { app } = await buildApp({
batch: {
items: [{
...buildPaymentBatch().items[0],
snapshot_json: {
payee: {
name: SYNTHETIC_BANK_ACCOUNT,
bankName: ciphertextSentinel,
bankAccountMasked: SYNTHETIC_BANK_ACCOUNT,
},
bank_account_encrypted: ciphertextSentinel,
},
}],
},
});
const response = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
});
expect(response.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
expect(response.body).not.toContain(SYNTHETIC_BANK_ACCOUNT);
expect(response.body).not.toContain(ciphertextSentinel);
expect(response.body).not.toContain("bank_account_encrypted");
await app.close(); await app.close();
}); });
@@ -1068,10 +1147,76 @@ describe("payment batch reimbursement routes", () => {
status: "draft", status: "draft",
}), }),
}); });
expect(tx.paymentBatchItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
snapshot_json: expect.objectContaining({
payee: {
name: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
},
}),
}),
});
expect(response.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
});
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
expect(JSON.stringify(response.json())).not.toContain("bank_account_encrypted");
await app.close(); await app.close();
}); });
it("persists the payment channel selected at creation as the review fact", async () => {
const { app, tx } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
entityIds: [REIMBURSEMENT_ID],
defaultPaymentChannel: "alipay",
},
});
expect(response.statusCode).toBe(201);
expect(tx.paymentBatch.create).toHaveBeenCalledWith({
data: expect.objectContaining({ default_payment_channel: "alipay" }),
});
expect(response.json().data.item.defaultPaymentChannel).toBe("alipay");
await app.close();
});
it("fails closed before batch writes when the payee account cannot be decrypted", async () => {
const unreadableAccount = encryptStringOrThrow(
{ fieldEncryptionKey: Buffer.alloc(32, 8).toString("base64") },
SYNTHETIC_BANK_ACCOUNT,
);
const { app, tx } = await buildApp({
reimbursement: {
owner: {
...buildReimbursement().owner,
bank_account_encrypted: unreadableAccount,
},
},
});
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(500);
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("notifies one active chief executive before falling back to managers", async () => { it("notifies one active chief executive before falling back to managers", async () => {
const chiefExecutiveId = "99999999-1111-4111-8111-111111111111"; const chiefExecutiveId = "99999999-1111-4111-8111-111111111111";
const { app, tx } = await buildApp({ const { app, tx } = await buildApp({
@@ -1370,6 +1515,13 @@ describe("payment batch reimbursement routes", () => {
}); });
expect(detail.statusCode).toBe(200); expect(detail.statusCode).toBe(200);
expect(detail.json().data.item.defaultPaymentChannel).toBe("bank"); expect(detail.json().data.item.defaultPaymentChannel).toBe("bank");
expect(detail.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
});
expect(state.batch.items[0].snapshot_json).toEqual({});
expect(JSON.stringify(detail.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
expect(state.batch.default_payment_channel).toBeNull(); expect(state.batch.default_payment_channel).toBeNull();
const execute = await app.inject({ const execute = await app.inject({
@@ -1513,7 +1665,6 @@ describe("payment batch reimbursement routes", () => {
payload: { payload: {
paymentVoucherNoOverride: "BANK-RECEIPT-001", paymentVoucherNoOverride: "BANK-RECEIPT-001",
paymentDateOverride: "2026-04-30", paymentDateOverride: "2026-04-30",
paymentChannelOverride: "bank",
}, },
}); });
@@ -1521,11 +1672,10 @@ describe("payment batch reimbursement routes", () => {
expect(response.json().data.item).toMatchObject({ expect(response.json().data.item).toMatchObject({
paymentVoucherNoOverride: "BANK-RECEIPT-001", paymentVoucherNoOverride: "BANK-RECEIPT-001",
paymentDateOverride: "2026-04-30", paymentDateOverride: "2026-04-30",
paymentChannelOverride: "bank",
}); });
expect(state.batch.items[0]).toMatchObject({ expect(state.batch.items[0]).toMatchObject({
payment_voucher_no_override: "BANK-RECEIPT-001", payment_voucher_no_override: "BANK-RECEIPT-001",
payment_channel_override: "bank", payment_channel_override: null,
}); });
expect(state.batch.items[0].payment_date_override).toEqual(new Date("2026-04-30T00:00:00.000Z")); expect(state.batch.items[0].payment_date_override).toEqual(new Date("2026-04-30T00:00:00.000Z"));
expect(tx.auditLog.create).toHaveBeenCalledWith( expect(tx.auditLog.create).toHaveBeenCalledWith(
@@ -1544,8 +1694,83 @@ describe("payment batch reimbursement routes", () => {
await app.close(); await app.close();
}); });
it("uploads, downloads and voids bank receipt attachments without physical deletion", async () => { it("allows payment metadata updates while retrying a failed batch", async () => {
const { app, prisma, state, tempDir } = await buildApp({ receiptAttachments: [] }); const { app, state, tx } = await buildApp({
batch: {
status: "failed",
items: [{ ...buildPaymentBatch().items[0], status: "failed" }],
},
});
const response = await app.inject({
method: "PATCH",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" },
payload: { paymentVoucherNoOverride: "RETRY-001" },
});
expect(response.statusCode).toBe(200);
expect(state.batch.items[0].payment_voucher_no_override).toBe("RETRY-001");
expect(tx.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ action: "PAYMENT_BATCH_ITEM_METADATA_UPDATED" }),
}),
);
await app.close();
});
it("keeps draft payment metadata immutable and rejects channel overrides", async () => {
const draft = await buildApp({ batch: { status: "draft", reviewed_at: null, reviewed_by: null } });
const draftResponse = await draft.app.inject({
method: "PATCH",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" },
payload: { paymentVoucherNoOverride: "SHOULD-NOT-WRITE" },
});
expect(draftResponse.statusCode).toBe(409);
expect(draftResponse.json().error.details.status).toBe("payment_metadata_not_editable");
expect(draft.tx.paymentBatchItem.update).not.toHaveBeenCalled();
expect(draft.tx.auditLog.create).not.toHaveBeenCalled();
await draft.app.close();
const reviewed = await buildApp();
const channelResponse = await reviewed.app.inject({
method: "PATCH",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" },
payload: { paymentChannelOverride: "cash" },
});
expect(channelResponse.statusCode).toBe(400);
expect(reviewed.tx.paymentBatchItem.update).not.toHaveBeenCalled();
expect(reviewed.tx.auditLog.create).not.toHaveBeenCalled();
await reviewed.app.close();
});
it.each(["executed", "cancelled"])("keeps %s payment metadata immutable", async (status) => {
const { app, tx } = await buildApp({ batch: { status } });
const response = await app.inject({
method: "PATCH",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" },
payload: { paymentDateOverride: "2026-04-30" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.status).toBe("payment_metadata_not_editable");
expect(tx.paymentBatchItem.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it.each(["reviewed", "failed"])(
"uploads, downloads and voids bank receipt attachments in %s without physical deletion",
async (status) => {
const { app, prisma, state, tempDir } = await buildApp({
batch: { status },
receiptAttachments: [],
});
try { try {
const requestPayload = buildMultipartRequest({ const requestPayload = buildMultipartRequest({
@@ -1684,6 +1909,90 @@ describe("payment batch reimbursement routes", () => {
await app.close(); await app.close();
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
},
);
it.each(["draft", "executed", "cancelled"])(
"rejects receipt upload and void in %s without attachment side effects",
async (status) => {
const { app, tx, state, tempDir } = await buildApp({ batch: { status } });
try {
const requestPayload = buildMultipartRequest({
files: [{
filename: "blocked-receipt.pdf",
contentType: "application/pdf",
content: SYNTHETIC_RECEIPT_PDF,
}],
});
const uploadResponse = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
headers: { authorization: "Bearer test", ...requestPayload.headers },
payload: requestPayload.payload,
});
expect(uploadResponse.statusCode).toBe(409);
expect(uploadResponse.json().error.details.status).toBe("receipt_attachment_not_editable");
expect(tx.paymentBatchReceiptAttachment.create).not.toHaveBeenCalled();
expect(await listStoredReceiptFiles(tempDir)).toEqual([]);
const originalAttachment = { ...state.receiptAttachments[0] };
const voidResponse = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/void`,
headers: { authorization: "Bearer test" },
payload: { reason: "状态不允许时不得作废" },
});
expect(voidResponse.statusCode).toBe(409);
expect(voidResponse.json().error.details.status).toBe("receipt_attachment_not_editable");
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
expect(state.receiptAttachments[0]).toEqual(originalAttachment);
expect(tx.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
},
);
it("removes uploaded receipt objects when the locked batch becomes read-only", async () => {
const { app, tx, state, tempDir } = await buildApp({ receiptAttachments: [] });
tx.paymentBatch.findUnique
.mockImplementationOnce(async () => ({
...state.batch,
items: state.batch.items.map((item) => ({ ...item })),
_count: { receipt_attachments: 0 },
}))
.mockImplementationOnce(async () => ({
...state.batch,
status: "executed",
items: state.batch.items.map((item) => ({ ...item })),
_count: { receipt_attachments: 0 },
}));
try {
const requestPayload = buildMultipartRequest({
files: [{
filename: "race-receipt.pdf",
contentType: "application/pdf",
content: SYNTHETIC_RECEIPT_PDF,
}],
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
headers: { authorization: "Bearer test", ...requestPayload.headers },
payload: requestPayload.payload,
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.status).toBe("receipt_attachment_not_editable");
expect(tx.paymentBatchReceiptAttachment.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(await listStoredReceiptFiles(tempDir)).toEqual([]);
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
}); });
it("checks receipt upload permission before feature evaluation or multipart consumption", async () => { it("checks receipt upload permission before feature evaluation or multipart consumption", async () => {