From 185d64dcd6ed57d5cd4bfcf2cc8c7bd56518febc Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 2 Aug 2026 15:15:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E4=BB=98=E6=AC=BE=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=E8=B5=84=E6=96=99=E4=B8=8E=E9=98=B6=E6=AE=B5=E7=BC=96?= =?UTF-8?q?=E8=BE=91=EF=BC=8808-01-reimbursement-notification-progress-cla?= =?UTF-8?q?rity=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/approval-workflow.md | 66 +++- .../design.md | 20 ++ .../implement.md | 16 + .../prd.md | 7 + client/src/api/paymentBatches.ts | 7 +- client/src/pages/ApOperationsPage.tsx | 19 +- client/src/pages/PaymentBatchesPage.tsx | 128 +++++-- .../e2e/payment-reviewer-permissions.spec.ts | 3 + .../e2e/pc-finance-admin-visual-wave4.spec.ts | 1 - .../reimbursement-payment-batch-flow.spec.ts | 43 ++- .../e2e/system-action-confirmation.spec.ts | 56 ++- server/src/routes/paymentBatches.js | 184 ++++++++-- .../check-payment-postgres-concurrency.js | 17 +- .../paymentBatches.reimbursement.test.js | 331 +++++++++++++++++- 14 files changed, 777 insertions(+), 121 deletions(-) diff --git a/.trellis/spec/hyb-oa/approval-workflow.md b/.trellis/spec/hyb-oa/approval-workflow.md index f5bb77f0..933f2845 100644 --- a/.trellis/spec/hyb-oa/approval-workflow.md +++ b/.trellis/spec/hyb-oa/approval-workflow.md @@ -343,7 +343,11 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] defaultPaymentDate?, defaultPaymentChannel?, voucherPrefix? }`. - Review: `POST /api/payment-batches/:id/review`. - 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 void API: + `POST /api/payment-batches/:id/receipt-attachments/:attachmentId/void`. - Internal status machine: `draft -> reviewed -> executed|failed|cancelled`; there is no separate submit endpoint or submitted database status. @@ -353,9 +357,11 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] ### 3. Contracts -- For the normal reimbursement UI, creation sends only `entityType` and - selected `entityIds`. The server still accepts optional fields for controlled - compatibility and exceptional item overrides. +- The normal reimbursement UI sends `entityType`, selected `entityIds` and + `defaultPaymentChannel`; the channel defaults to `bank` and becomes an + 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 ` 付款批次`; the server creates the unique batch number. Missing channel becomes `bank`. Missing actual payment date is resolved to the company-local registration date only when @@ -379,6 +385,23 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] an active receipt exists may the UI expose `登记付款完成`. Loading or failed receipt queries expose neither action rather than guessing that the 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 @@ -395,6 +418,20 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] shown independently and the user may retry. - Existing null-channel reimbursement batch -> project as bank and apply the receipt guard without updating `default_payment_channel`. +- 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 @@ -403,9 +440,14 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] eligible non-maker receives the task notification. - Base: after review, finance completes the bank transfer outside OA, uploads the receipt, then registers payment completion using the company-local date. +- 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, show `登记付款完成` before receipt status is known, or silently rewrite a 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 @@ -414,9 +456,18 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] null-channel compatibility without mutation. - PostgreSQL concurrency regression asserts double create, double execute, execute/cancel and direct-pay/batch races remain mutually exclusive. -- Client E2E asserts creation sends only `entityType+entityIds`, all consumers - use the shared labels, and receipt upload switches the sole primary action - from `上传银行回单` to `登记付款完成`. +- Route tests assert the full state matrix, channel strict rejection, zero + mutation side effects, masked new snapshots, legacy fallback and malicious + 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 are required before commit. @@ -426,6 +477,7 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] ```tsx const label = status === "draft" ? "草稿" : status; + patchItem({ paymentChannelOverride: value })} /> ``` @@ -433,5 +485,7 @@ const label = status === "draft" ? "草稿" : status; ```tsx +{batch.status === "draft" ? : null} +{["reviewed", "failed"].includes(batch.status) ? : null} {needsBankReceipt ? : canRegister ? : null} ``` diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md index b8133ae5..4ff5f92a 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md @@ -144,3 +144,23 @@ - 在同一个 Prisma 交互事务中按固定顺序执行:取得咨询锁 → 查询当日严格新格式 `^PB-YYYYMMDD-[0-9]{3}$` 的最大序号 → 生成编号/自动标题 → 创建批次及既有项、审计、通知。 - 不使用 `hashtext`,不把旧长格式纳入最大值,不在 P2002 后原事务内重试;PostgreSQL 唯一冲突会使事务进入 aborted 状态,任何兜底只能重试整个事务。本设计通过咨询锁消除正常同日并发冲突。 - 无 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` 查询当前报销归属人并只读补齐响应,不回写历史批次。 +- 复核详情继续显示单号、摘要、金额和状态,并补充付款渠道与收款摘要;页面四段结构和二次确认弹窗保持不变。 diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md index a468d642..22507235 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md @@ -123,3 +123,19 @@ - 部署后以新完整 SHA 重装 automatic-backup 与 WARCH3 合同;4 个 service 的工作目录、环境文件、构建 SHA 与规范根一致,4 个 timer 均为 enabled/active。 - 已手工触发一次完整真实自动备份周期:源端成功状态新鲜且构建标识一致,`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,未修改文件。 diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md index 3bbde133..c2740765 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md @@ -49,6 +49,9 @@ - 付款页的列表、详情总额、批次项、确认弹窗和高风险阈值统一使用共享 `formatMoney()`;不得继续裸输出金额或在页面内新增货币格式化副本。 - 新建付款批次编号使用公司时区日期和三位日序号 `PB-YYYYMMDD-NNN`;既有编号不迁移。未显式传名称时自动标题为 `YYYY-MM-DD 付款批次 NNN`,显式名称原样保留。 - 日序号必须在创建批次的同一交互事务中,先取得双参数 PostgreSQL 事务级咨询锁,再查询当日严格新格式最大序号并创建;咨询锁使用固定 int4 命名空间和 int4 `YYYYMMDD`,不得依赖唯一冲突后的原事务内重试。 +- `draft` 已表示“创建并提交、待付款复核”,批次项不得继续沿用未提交草稿的编辑权限;付款凭证号、实际付款日期和银行回单只能在 `reviewed/failed` 财务处理阶段维护,`executed/cancelled` 全部只读。 +- 付款渠道在创建面板确定并作为复核事实提交,默认银行转账;创建后不得通过批次项覆盖改变,避免复核对象在复核前后漂移。 +- 付款复核明细必须显示收款人姓名、开户银行和脱敏银行账户摘要;不得向付款批次 API 暴露完整账号或加密原值。新批次固化脱敏快照,既有批次以当前报销归属人只读降级展示。 ## Acceptance Criteria @@ -75,6 +78,10 @@ - [ ] 付款页所有人民币金额统一显示为 `¥1,234.56` 形式,列表、详情、批次项、确认弹窗和高风险阈值无裸金额残留。 - [ ] 新批次编号为公司时区 `PB-YYYYMMDD-NNN`,同日并发创建得到唯一递增序号,跨日从 `001` 开始;超过 `999` 时 fail-closed,不改变编号格式。 - [ ] 自动标题在编号生成后使用相同日序号;调用方显式提供名称时不追加序号;旧编号保持原值且导出/AP/外部传递继续按不透明字符串兼容。 +- [ ] 待付款复核期间批次项和回单均不可编辑;后端直接 PATCH/上传/作废请求同样拒绝,且不产生静默未审计变更。 +- [ ] 创建批次时可选择付款渠道且默认银行转账;创建后渠道只读,复核人看到的渠道与创建事实一致。 +- [ ] 每条付款明细显示收款人姓名、开户银行和脱敏账户摘要,不返回完整账号、密文或其他身份信息;当前既有批次无需迁移即可展示。 +- [ ] `reviewed/failed` 允许登记凭证号、实际付款日期及维护回单;`executed/cancelled` 的回单与付款资料只读。 ## Out of Scope diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts index 165e1c31..37c51c8b 100644 --- a/client/src/api/paymentBatches.ts +++ b/client/src/api/paymentBatches.ts @@ -16,10 +16,12 @@ export type PaymentBatchItem = { paymentVoucherNoOverride: string | null; paymentDateOverride: string | null; paymentChannelOverride: PaymentChannel | null; + payeeName: string | null; + bankName: string | null; + bankAccountMasked: string | null; status: string; failureCode: string | null; failureMessage: string | null; - snapshotJson: Record; createdAt: string | null; openRiskFlags: RiskFlag[]; }; @@ -138,9 +140,8 @@ export async function createPaymentBatch(payload: { export async function updatePaymentBatchItem(batchId: string, itemId: string, payload: { paymentVoucherNoOverride?: string | null; paymentDateOverride?: string | null; - paymentChannelOverride?: PaymentChannel | null; }) { - return apiRequestData<{ item: Pick }>( + return apiRequestData<{ item: Pick }>( `/api/payment-batches/${batchId}/items/${itemId}`, { method: "PATCH", diff --git a/client/src/pages/ApOperationsPage.tsx b/client/src/pages/ApOperationsPage.tsx index d790344d..29f55005 100644 --- a/client/src/pages/ApOperationsPage.tsx +++ b/client/src/pages/ApOperationsPage.tsx @@ -1402,10 +1402,21 @@ export function ApOperationsPage() {
-
快照
-
-                  {prettyJson(failedDetailItem.snapshotJson)}
-                
+
付款信息
+
+
+
收款人
+
{failedDetailItem.payeeName ?? "-"}
+
+
+
开户银行
+
{failedDetailItem.bankName ?? "-"}
+
+
+
银行账户
+
{failedDetailItem.bankAccountMasked ?? "-"}
+
+
) : null} diff --git a/client/src/pages/PaymentBatchesPage.tsx b/client/src/pages/PaymentBatchesPage.tsx index 78c539e8..98a349b2 100644 --- a/client/src/pages/PaymentBatchesPage.tsx +++ b/client/src/pages/PaymentBatchesPage.tsx @@ -7,13 +7,13 @@ import { downloadPaymentBatchReceiptAttachment, executePaymentBatch, fetchPaymentBatchCandidates, + fetchPaymentBatchDetail, fetchPaymentBatchReceiptAttachments, fetchPaymentBatches, reviewPaymentBatch, updatePaymentBatchItem, uploadPaymentBatchReceiptAttachments, voidPaymentBatchReceiptAttachment, - type PaymentBatch, type PaymentBatchReceiptAttachment, type PaymentChannel, } from "../api/paymentBatches"; @@ -102,6 +102,7 @@ export function PaymentBatchesPage() { (me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo")); const [candidateSearch, setCandidateSearch] = useState(""); const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); + const [selectedPaymentChannel, setSelectedPaymentChannel] = useState("bank"); const receiptInputRef = useRef(null); const [error, setError] = useState(""); const [showCreatePanel, setShowCreatePanel] = useState(false); @@ -131,9 +132,12 @@ export function PaymentBatchesPage() { const selectedBatchId = searchParams.get("id") ?? ""; const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]); - const selectedBatch = useMemo(() => { - return batches.find((item) => item.id === selectedBatchId) ?? null; - }, [batches, selectedBatchId]); + const selectedBatchQuery = useQuery({ + queryKey: ["payment-batches", "detail", selectedBatchId], + enabled: Boolean(selectedBatchId), + queryFn: () => fetchPaymentBatchDetail(selectedBatchId), + }); + const selectedBatch = selectedBatchQuery.data?.item ?? null; const receiptAttachmentsQuery = useQuery({ queryKey: ["payment-batch-receipts", selectedBatch?.id], enabled: Boolean(selectedBatch?.id), @@ -143,11 +147,11 @@ export function PaymentBatchesPage() { const canEditSelectedBatch = canManageBatch && !!selectedBatch && - ["draft", "reviewed", "failed"].includes(selectedBatch.status); + ["reviewed", "failed"].includes(selectedBatch.status); const canEditReceiptAttachments = canManageBatch && !!selectedBatch && - selectedBatch.status !== "cancelled"; + ["reviewed", "failed"].includes(selectedBatch.status); const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess; const canReviewSelectedBatch = canReviewBatch && @@ -194,11 +198,13 @@ export function PaymentBatchesPage() { createPaymentBatch({ entityType: "reimbursement", entityIds: selectedCandidateIds, + defaultPaymentChannel: selectedPaymentChannel, }), onSuccess: async ({ item }) => { setError(""); setSelectedCandidateIds([]); setCandidateSearch(""); + setSelectedPaymentChannel("bank"); const next = new URLSearchParams(searchParams); next.set("id", item.id); setSearchParams(next, { replace: true }); @@ -214,7 +220,9 @@ export function PaymentBatchesPage() { const reviewMutation = useMutation({ 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)); return reviewPaymentBatch(batchId, { resolveRiskFlagIds }); }, @@ -261,11 +269,10 @@ export function PaymentBatchesPage() { }); 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, { paymentVoucherNoOverride: payload.paymentVoucherNoOverride, paymentDateOverride: payload.paymentDateOverride, - paymentChannelOverride: payload.paymentChannelOverride, }), onSuccess: async () => { setError(""); @@ -430,8 +437,23 @@ export function PaymentBatchesPage() {
没有可加入批次的已审批报销。
) : null} +
+ + +
- 已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,默认按银行转账提交负责人复核。 + 已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,按{paymentChannelLabel(selectedPaymentChannel)}提交负责人复核。
+ + ) : selectedBatch ? (
@@ -586,31 +623,42 @@ export function PaymentBatchesPage() {
{formatMoney(item.amount)} · {paymentItemStatusLabel(item.status)}
-
- updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} - /> - updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} - /> - -
- {item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"} -
+
+
收款人
{item.payeeName || "-"}
+
开户银行
{item.bankName || "-"}
+
银行账户
{item.bankAccountMasked || "-"}
+
付款渠道
{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}
+ {["reviewed", "failed"].includes(selectedBatch.status) ? ( +
+ updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} + /> + updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} + /> +
+ {item.failureMessage ? `失败原因:${item.failureMessage}` : "付款异常:无"} +
+
+ ) : ["executed", "cancelled"].includes(selectedBatch.status) ? ( +
+
付款凭证号
{item.paymentVoucherNoOverride || "-"}
+
实际付款日期
{formatDisplayDate(item.paymentDateOverride ?? selectedBatch.defaultPaymentDate)}
+
付款结果
{item.failureMessage || paymentItemStatusLabel(item.status)}
+
+ ) : ( +
+ 付款复核通过后,由财务登记付款凭证号和实际付款日期。 +
+ )}
))} @@ -621,7 +669,11 @@ export function PaymentBatchesPage() {

银行回单

-

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

+

+ {selectedBatch.status === "draft" + ? "付款复核通过后,由财务在线下完成付款并上传银行回单。" + : "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"} +

{canUploadReceipt ? (
@@ -686,12 +738,12 @@ export function PaymentBatchesPage() { > 下载 - {canManageBatch ? ( + {canEditReceiptAttachments ? (