From ce11dea9763527c59bbb044699513ec17e1d9ada Mon Sep 17 00:00:00 2001 From: shunli Date: Thu, 8 Oct 2026 15:35:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=B4=A2=E5=8A=A1=E7=BB=8F?= =?UTF-8?q?=E5=8A=9E=E4=B8=8E=E5=85=AC=E5=8F=B8=E4=BB=A3=E8=A1=A8=E5=85=B3?= =?UTF-8?q?=E8=81=94=E6=96=B9=E8=BF=98=E6=AC=BE=E7=A1=AE=E8=AE=A4=EF=BC=88?= =?UTF-8?q?09-17-company-loan-principal-repayment=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。 仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。 贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。 授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。 本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。 已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。 按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。 限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。 生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。 --- .../spec/hyb-oa/backend-fastify-prisma.md | 15 +- .../design.md | 111 +++++ .../implement.md | 174 +++++++- .../prd.md | 80 +++- client/src/api/companyLoans.ts | 27 +- client/src/api/errorReasons.ts | 16 +- client/src/api/paymentBatches.ts | 7 +- .../common/LoanBankReconciliation.tsx | 30 +- .../components/common/LoanPaymentDetails.tsx | 23 +- .../common/LoanRelatedAuthorizations.tsx | 89 ++++ .../PaymentReceiptVerificationDialog.tsx | 8 +- client/src/components/navigation/NavLinks.tsx | 2 +- client/src/pages/CompanyLoansPage.tsx | 22 +- client/src/pages/PaymentBatchesPage.tsx | 92 ++-- client/src/router/index.tsx | 4 +- scripts/qa/company-loan-browser.cjs | 3 +- scripts/qa/company-loan-http-fixture.cjs | 20 +- scripts/qa/company-loan-related-browser.cjs | 218 +++++++++ scripts/qa/company-loan-related-fixture.cjs | 421 ++++++++++++++++++ .../migration.sql | 308 +++++++++++++ server/prisma/schema.prisma | 77 ++++ server/src/constants/errorReasons.js | 16 +- server/src/constants/notificationTypes.js | 3 + server/src/routes/companyLoans.js | 73 ++- server/src/routes/paymentBatches.js | 84 ++-- server/src/services/companyLoanPayments.js | 56 ++- server/src/services/loanApprovalPolicy.js | 276 ++++++++++++ server/src/services/loanBankResults.js | 11 +- server/src/services/loanBankSubmissions.js | 13 +- server/src/services/loanBankTerminalFacts.js | 23 +- server/src/services/loanNotSubmitted.js | 3 + server/src/services/loanReleaseRecoveries.js | 12 +- .../src/services/paymentProcessingProgress.js | 15 + .../paymentReceiptManualPersistence.js | 59 ++- server/src/services/paymentResponsibility.js | 22 +- server/src/services/workspaceSettings.js | 5 +- .../unit/fixtures/error-reason-messages.json | 16 +- .../unit/services/loanApprovalPolicy.test.js | 106 +++++ .../unit/workspaceSettings.navigation.test.js | 4 + 39 files changed, 2336 insertions(+), 208 deletions(-) create mode 100644 client/src/components/common/LoanRelatedAuthorizations.tsx create mode 100644 scripts/qa/company-loan-related-browser.cjs create mode 100644 scripts/qa/company-loan-related-fixture.cjs create mode 100644 server/prisma/migrations/20261009010000_loan_related_approval/migration.sql create mode 100644 server/src/services/loanApprovalPolicy.js create mode 100644 server/tests/unit/services/loanApprovalPolicy.test.js diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index bfd3b2a0..9c7ed75b 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -1116,15 +1116,22 @@ await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id); - `09-17-company-loan-principal-repayment` adds the explicit `loan_repayment` payment source: one retained repayment/batch/item, multiple allocations to confirmed same-company/lender/CNY loans. Preserve original principal and confirmed opening evidence; Decimal reservations and settlements must not exceed opening outstanding principal. SQL checks/deferred triggers complement locked application validation. - Loan management requires an explicit finance/finance_mgr role plus loan.manage. Independent review requires active manager/CEO with review permission and excludes maker, submitter and lender OA identity; admin wildcard cannot replace these identities. External lenders require explicit verified no-OA identity. Account changes revoke identity confirmation and invalidate old unpaid snapshots. +- Related-party loans use the explicit `loanApprovalPolicy` branch, not a global reviewer exception. Finance proposes `LoanRelatedAuthorization` for one company/lender, its verified employee identity and specific confirmed loans; that designated active manager/CEO confirms it. Grants, scope and lifecycle events are append-only. Resolve the latest confirmed version without falling back to an older grant when the new scope is narrower or stopped; a superseded version cannot receive stop events. +- Freeze `LoanRepayment.approval_mode`, `authorization_id` and `approval_context` at creation. Only `related_party` waives the designated payee-conflict reason. Keep maker/submitter/exporter/uploader/manual-entry/proposer conflicts as a separate union of all history, including voided and superseded materials; do not remove the payee employee from that union. Reject designated-reviewer participation at maker entry points as well as decision/final-consumer checks. Old independent loans and shared reimbursement/travel rules do not switch modes. +- New payment creation/approval/export checks current payment authority; new reconciliation decisions and final reservation release check current reconciliation authority. `decisionFields()` stores purpose, actual actor, target, scope, authorization version/event and conflict identities. `assertDecision()` validates immutable historical provenance at receipt reconstruction, settlement, terminal/recovery consumption and bank-event booking; it does not require the old reviewer to remain active or the old grant to remain enabled. Stopping payment and stopping reconciliation are separate events. Previously approved bank facts remain usable if their evidence and all money/transaction checks still hold. +- Related payment review may return an application with `decision=reject` and a reason. This records an immutable decision without releasing reservations; it cannot later be approved, exported or paid. Finance uses safe never-sent cancellation and creates a corrected application. The UI, responsibility and notification consumers must distinguish this from an approved payment or an independent review. +- Shared loan error reasons must describe confirmation under the batch's rules rather than require independent review for every mode. Keep registry text, client mapping and fixed message fixtures together; independent-only reviewer failures and Q6 still state their independent-review requirements. Wording does not relax any actor or evidence guard. - Creation locks lender, sorted loans, then day sequence; it never waits for a previously existing batch. Existing settlement locks batch, global receipt identity, attachments, lender/reviewer, sorted loans. Source maintenance never locks an existing batch. Claims, allocation settlement, batch/item payment facts and audit commit atomically. -- Export/upload marks bank outcome unknown without reducing principal. Unknown/in-transit cannot release reservations. A never-sent cancellation or independently reviewed, current and exactly matching failed-bank evidence may release; preserve the old request. Paid and terminal allocation/history facts are immutable. Accounting handoff is a separate audited reference, not a second payment state. +- Authority lifecycle changes lock the lender and never wait for an existing batch. Existing-batch decisions acquire the lender only after the batch/receipt locks, then repeat authority checks; creation locks lender and loans and creates a new batch. Test both interleavings of stopping authority against approval, export, fact confirmation and final release; do not introduce lender-to-existing-batch lock inversion. +- Export/upload marks bank outcome unknown without reducing principal. Unknown/in-transit cannot release reservations. A never-sent cancellation or current, exactly matching failed-bank evidence confirmed under the frozen approval mode may release; preserve the old request. Paid and terminal allocation/history facts are immutable. Accounting handoff is a separate audited reference, not a second payment state. - Repayment BIFF8 uses encrypted account-name snapshot and purpose 还款/归还借款本金; receipt matching/manual review explicitly supports loan source and shares the reimbursement claim/alias namespace. Real bank/OCR acceptance is not established by synthetic recognition tests. -- Loan evidence uses governed PDF/image bytes, random object keys, bounded content/hash checks and audit-before-download. Finance reads the ledger; reviewers can download only materials in an eligible payment's allocations, not the full ledger. +- Loan evidence uses governed PDF/image bytes, random object keys, bounded content/hash checks and audit-before-download. Finance reads the ledger; reviewers can download only materials in an eligible payment's allocations or their own designated authorization scope, not the full ledger. Related reviewers retain scope-limited historical reading after authority stops while still requiring current reader eligibility. Full accounts/originals require purpose, audited access and no-store responses; do not grant global `loan.manage` to make review pages accessible. UI capabilities project the same policy and never replace transaction-level authorization. - SR recovery of a still-reserved `confirmed_failed`/`confirmed_not_paid` batch never rewrites the bank outcome or reopens export/payment. Once any recovery round exists, final cancellation must consume the latest valid approval; rejected, withdrawn or superseded rounds must not fall back to the original cancellation path. Consumption, allocation release, batch cancellation and audit share the transaction; deferred SQL checks protect the release/consumption pairing. -- Recovery snapshots bind material revisions, submission/fact/receipt identities, verification versions, independent participants and the current same-amount candidate set. Material writers (including OCR persistence) serialize on the batch; candidate formation also locks the lender. A local batch revision alone cannot detect another batch becoming a candidate. Validate at submission, approval and final cancellation, with physical document digest checks before release. +- Recovery snapshots bind material revisions, submission/fact/receipt identities, verification versions, participants under the explicit approval mode and the current same-amount candidate set. Material writers (including OCR persistence) serialize on the batch; candidate formation also locks the lender. A local batch revision alone cannot detect another batch becoming a candidate. Validate at submission, approval and final cancellation, with physical document digest checks before release. - JSONB can reorder object keys. Compare snapshots using recursive key-canonicalized hashes, retaining array order, rather than hashing raw `JSON.stringify` results before and after storage. Keep snapshot payloads out of public response mapping. New evidence for the same bank transaction still requires new review. -- Verify recovery through `RUN_ISOLATED_LOAN_DB_TEST=1 node scripts/qa/test-company-loan-db.cjs` with the locked BIFF8 Python environment: real HTTP/evidence/OCR, stale-round repair, no fallback after withdrawal, atomic rollback and deterministic interleavings with evidence, submissions, OCR and candidate formation. Existing browser checks do not prove the deferred S8 recovery UI; inline self-review is not independent review. +- Verify loan and recovery paths through `RUN_ISOLATED_LOAN_DB_TEST=1 node scripts/qa/test-company-loan-db.cjs` with the locked BIFF8 Python environment: real HTTP/evidence/OCR, stale-round repair, no fallback after withdrawal, atomic rollback and deterministic interleavings with evidence, submissions, OCR and candidate formation. Add `RUN_ISOLATED_LOAN_BROWSER=1` for actual desktop/mobile actions, including the two-actor related workflow; mock only cloud OCR output. `RUN_ISOLATED_LOAN_RELATED_ONLY=1` selects the related suite, and `RUN_ISOLATED_LOAN_RELATED_UI_ONLY=1` is browser setup only and must report HTTP as not run. Preserve the old independent regression; inline self-review is not independent review. - Manual no-submission is a separate terminal fact, never a bank termination claim: fixed documentary categories, complete reviewed export/submission inventory, two distinct independent reviews, material/candidate snapshot and physical digest checks at final release. An empty submission attestation is valid only for actual exports and no historical submission rows; withdrawn submissions do not prove non-submission. +- Q6 manual no-submission is unavailable in related-party mode at proposal, review and final release, with database guards as well as hidden controls. Neither two-person confirmation nor OCR supplies missing attribution, instruction termination or return evidence. Once related decisions exist, do not downgrade to code that cannot interpret their mode/provenance or delete those fields; stop new payments and use an approved compatible fix. - Late-payment supplements and bank returns use an append-only ledger, not exportable payment batches. Include supplemental principal in both application balance calculations and SQL capacity checks. Their keyed transaction claims must exclude ordinary receipt claims and aliases in both directions under the shared receipt lock. Freeze causes close independently; partial returns and unresolved reservation conflicts keep their own causes active. The ordinary pre-payment gate must not block factual reconciliation. - ERP-specific API/adapters/jobs/enqueues are retired by `09-17-erp-exclusive-retirement`. Shared outbox/notification/WeCom and historical records remain. New ERP enqueue and direct dispatch reject; pending selection excludes `erp.*`; historical retry returns `integration.erp_retired` without mutating records. Never restore these retired consumers solely to satisfy old success-path tests. diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md index 6afb0cf3..861b05c8 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md @@ -1,3 +1,114 @@ +## 2026-10-08 财务经办与马经理关联方确认方案(本地实现及合成验证完成,已授权本地提交) + +用户已确认马经理有权代表公司批准,并在规划完成后明确“开始实施”。马经理同时为股东和个人出借收款人;技术员工不参与复核、审批、见证,不增加权限或分派待办。公司授权这一业务事实不再待决。本节是本次权限模式变更的执行合同;本地实现及合成验证已完成,具体证据与剩余验收见 implement 顶部。下方历史设计中的独立复核,除 Q6 外按本节明确区分模式;本地改动不等于生产已具备该模式。 + +### RP-A 业务范围与实际分工 + +- 默认 `independent` 模式保留原规则;新增 `related_party` 模式只用于经登记的本公司、真实个人出借人、其关联员工以及明确列出的已确认借款。马经理的选择依据真实员工与出借人关联,代码不硬编码姓名、不虚建“非员工出借人”隐藏关系。 +- 财务办理资料核对、申请、制单、提交记录和银行证据。马经理在公司授权范围内批准或退回,按钮及记录标明“关联方确认:确认人同时为本笔出借收款人”。这是一项真实的利益关联例外,不称为独立审批,不以两人确认或 OCR 抵消该关联。 +- 付款退回沿原付款决定接口留存 `decision=reject`、理由、决定人和授权上下文;该申请不能再批准或导出,退回本身不释放占用。财务按未发送取消规则取消原申请,再新建更正申请;原退回记录不可改。数据库禁止将退回决定当成已批准付款。 +- 财务兼任人事行政不影响其经办,但不能自审。同一员工的多账号或多业务角色按同一真实身份处理。经理可以承担本批多个确认环节,不新增第三位审批人;不能承担本批经办、上传或录入等被排除工作。 +- 正常流程为财务核实本金并发起申请→马经理代表公司确认→财务导出并在网银付款→上传回单沿既有 OCR 核对→证据满足后由财务登记核销。完全满足自动核验的回单无需再增加一次人工回单确认;有人工例外或归属歧义才进入对应确认。 +- 借入凭据、历史归还和代账核对仍是本金的事实来源。出借人确认余额、账户或收到钱与代表公司批准是不同声明,分别留痕;本方案不把聊天中提及的金额或资料自动录成真实余额。 + +### RP-B 公司授权、范围与模式 + +采用借款模块内的小范围授权记录,不改变通用角色平台。财务录入已存在的公司授权事实、依据说明/引用、公司、出借人、指定确认人员工标识及覆盖的借款;马经理本人确认后生效。该登记反映用户已经确认的公司代表权,不再要求技术员工或额外第三人批准。 + +1. 授权内容按版本保存,覆盖借款用真实关联关系约束,不能包含其他公司、出借人或未确认借款。财务不能单人激活、扩充范围或把自己设成确认人;新增借款需登记新范围版本并由马经理确认。 +2. 授权确认只是业务授权留痕,不授予新的全局经理/财务权限。每次新决定还要检查当前人员、经理/CEO业务角色及相应现有权限,授权记录不能让离职人员或无权用户通过。 +3. 新建还款时服务端查找有效且覆盖全部分配借款的范围,冻结 `approval_mode`、授权标识/版本、公司、出借人及指定确认人。界面显示适用模式,不能用客户端参数或调用顺序把无资格的独立单改成关联单;范围缺失或冲突明确拒绝。 +4. 旧独立批次不自动转换,已送审、已导出或已发生银行事实的模式均不能改写。既有草稿若需改用关联模式,只能满足原安全取消条件后重新申请,不能重用旧审批、导出或银行事实;不安全取消的历史批次另行评估。 +5. 授权生命周期区分“允许新付款”与“允许处理历史对账”。停止新付款后不能建单、付款前批准或导出;仍覆盖原借款的对账授权不会因付款范围用完或付款期限届满自动失效。停止对账权限阻止新的人工对账决定及最终释放,不能删除旧事实或旧批准。 +6. 财务或马经理可登记当前版本的停止及理由;已被取代版本不能再停用。恢复或扩大权限采用财务登记、马经理确认的新版本,缩小范围或停止后不得回退使用更早版本。历史内容和生命周期事件保留,不能靠恢复状态让失效材料批准复活。新的对账决定可引用当前覆盖原批次的新授权版本,原批次的付款授权引用与模式不改。 + +### RP-C 决定与经办冲突 + +独立模式继续要求无利益关联。关联模式只豁免“指定确认人等于出借收款人”这一种冲突;原来各路径的制单、提交、导出、登记、证据上传和人工录入冲突继续生效。参与人检查必须保留冲突原因,不能在算完一组排除员工标识后直接删掉马经理,否则他兼任上传人时也会被错误放行。 + +服务端在关联批次的经办入口就拒绝指定确认人创建/提交/导出、登记银行提交或上传/录入/提案银行证据,避免只有两人的配置事后找不到确认人。决定入口再次检查全部相关历史经办记录,包括已作废或被取代的回单,不能靠作废清除冲突。公司授权确认、此前正常审批或收款人声明本身不等同于经办参与。 + +| 环节 | 财务职责 | 马经理职责 | 保留的最终条件 | +|---|---|---|---| +| 授权与未还本金 | 登记授权范围、借入及历史还款依据、与代账核对;只有已确认本金可申请 | 确认其公司授权登记;需要时另作债权/账户声明 | 范围、身份、资料及未还本金有效,授权确认不代替余额核实 | +| 付款申请与制单 | 创建、分配、提交、导出、实际网银操作 | 代表公司作关联方付款确认或退回 | 当前授权、角色、身份版本、启用状态、金额和收付款快照、容量与冻结门禁 | +| 银行提交与清单 | 逐次登记/撤回提交,确认清单完整 | 关联方确认完整清单 | 新登记、撤回、导出均使原清单确认失效;每次提交分别解释 | +| 成功或失败回单归属 | 上传/录入回单,提出证据及排除其他候选的理由 | 对需人工判断的归属作关联方确认 | 交易身份、核验/人工录入记录、完整候选及所覆盖指令一致;同金额/账户或两人声明不足 | +| 人工回单例外 | 留存原件并录入 | 核对原件作关联方确认 | 状态、字段和原件可读,失败/处理中/矛盾信息不当成成功;证据重建及核销仍重验 | +| 严格失败、银行终态、取消 | 登记逐提交事实及银行证据;条件满足后取消 | 承担当前需要的失败/终态决定,以关联方确认记录 | 严格失败口径、指令对应、全部提交与导出覆盖、3天等待期、无未解决成功/处理中事实;最终释放重验 | +| SR补证恢复 | 对原终态补证、重新送审 | 对新增事实和恢复轮次作关联方确认 | 原结论不改;材料变化旧批准失效;不重开付款,最终取消消费最新有效恢复 | +| 晚到成功 | 登记原件与事实,触发冻结,条件满足后补记 | 确认归属及银行事实 | 原付款历史快照、唯一交易、逐借款容量;先解决占用冲突,差额列待追回;补记不能导出 | +| 误报/重复登记关闭 | 提出更正依据 | 关联方确认更正 | 原记录与更正留存,证据不足不能关闭;不能改变已入账事实或清掉其他冻结原因 | +| 部分/全额追回 | 登记真实到账及材料,执行符合条件的入账 | 关联方确认到账 | 实际到账、交易唯一、部分只冲减待追回;全部有效原因关闭才解冻,不做冲抵 | +| Q6人工确认未提交 | 本模式不可发起 | 本模式不可确认 | 原规则需两名额外独立复核人;服务端、最终释放及界面共同限制 | + +材料不足的结果仍为待对账、保持占用或冻结。关联确认不降低证据门槛,也不能保证同金额分期都自动核销。若两位实际办理人中没有当前合格确认人,可继续由有权财务留存事实,不以单人确认恢复资金动作。 + +### RP-D 当前资格、历史效力与并发 + +| 动作类型 | 资格与授权时点 | 停用后的处理 | +|---|---|---| +| 新申请、付款前批准、导出及重新导出 | 当前经办/确认资格、付款授权、冻结状态及版本;实际导出前再次检查 | 停止新付款即拦截,旧“已批准”不能继续导出 | +| 新的银行事实、归属、人工回单、恢复或追回决定 | 当前确认人业务资格及对账授权,绑定本次证据;不要求原付款审批人或出借人恢复旧资格 | 新决定在对账权限停用后拒绝;有权财务仍可登记新事实,冻结照常产生 | +| 确认未付款及最终取消释放 | 当前操作人、当前对账授权、有效的对应决定、最新材料与全部释放门禁 | 对账授权停止后不能新释放;付款授权停止本身不阻断有证据的对账释放 | +| 已付款核销、已确认银行事实的补记/追回入账 | 当前记账操作人有权;原付款/事实决定当时有权且证据仍有效,按各自冻结模式和快照重验资金条件 | 停止新付款、原审批人/出借员工事后离职或主数据停用不抹掉已付事实;不新增付款指令。需要重新人工确认的失效证据仍按新决定的当前资格规则处理 | + +已作出的关联决定不能伪装成独立批准。最终使用历史决定校验其当时记录的模式、授权版本、范围和参与身份,不再通过“历史收款人必须独立”的旧函数;也不能只认一个 `approved` 状态而不验材料与交易。 + +沿用既有批次→回单交易/附件→出借人及人员资格的锁序;创建锁出借人→借款且不等待既有批次。授权停用/新版本生效仅锁出借人,不再取得既有批次锁。资金动作在持出借人锁后重验授权,因而与停用串行;不得新引入出借人→既有批次的逆序。当前资格在决定事务内校验,决定/审计/资金更新原子执行。 + +- 付款授权停用先于新批准/导出提交:后者拒绝;导出先完成:保留导出事实,后续停用不能让已下载文件在银行失效。 +- 对账授权停用先于新决定或最终释放:拒绝;有效决定先完成:保留该历史决定,最终释放仍重验当前对账授权;已确认的真实付款可按历史证据记账。 +- 材料/候选变化仍按现有机制使旧批准失效。核销/释放只依据事务当时已知事实;之后才出现的新证据进入晚到结果对账,不宣称锁能防止未来事实。 +- 禁止通过前端能力缓存、自动重试、权限恢复或删掉冲突原因绕过上述时点;幂等请求只返回原事务结果,不重复产生付款或释放。 + +### RP-E 数据与完整消费者 + +本地新增增量迁移 `20261009010000_loan_related_approval`,仅在隔离合成数据库验证。采用现有还款/事实表加明确模式及授权引用,不建设通用代理审批系统。 + +| 数据 | 实现内容与约束 | +|---|---| +| 借款关联授权、覆盖借款与生命周期事件 | 公司、出借人、确认人员工外键,范围借款外键,版本、依据引用、财务登记/经理确认、两个权限方面的状态事件;内容与已确认事件不可改删,扩大范围必须新版本 | +| LoanRepayment付款上下文 | `approval_mode = independent / related_party`、授权标识/版本、确认人及关联身份快照;创建时冻结,之后不可转换;新关联记录缺少范围或身份不能落库 | +| 逐次决定上下文 | 批次审批与其审计、归属、提交清单、终态事实、SR恢复、银行事件及借款人工回单决定均留存确认模式、授权版本、关联身份、决定人/时点及证据快照;异常的新决定可引用新的合法授权版本,但不覆盖原付款上下文 | +| 历史和数据库保护 | 原记录显式归为独立模式,不补造授权;保留经办人与确认人不同、已复核记录不可改删、交易唯一与资金容量约束;关联字段完整性/外键与模式一致性另加约束,共享报销记录不得标成借款关联模式 | + +快照只保存身份标识、版本、引用与摘要,不复制解密账号或银行原文。待审记录变化仍新建/取代,不回写已作出的决定;驳回也保留当时模式与理由。 + +源码已确认的改动消费者如下,实施须覆盖入口、读取/重建与最终使用,不能仅修改 `activeReviewer` 或页面按钮: + +| 位置 | 需要贯通的职责 | +|---|---| +| `companyLoanPayments.js` | `prepareLoanRepayment` 创建前候选门禁、`eligibleLoanReviewers`、`activeReviewer`、`validateLoanPayment`、`assertApprovedIndependently`、归属确认、严格失败、核销与取消;把冲突原因与模式分开 | +| `loanBankSubmissions.js` / `loanBankTerminalFacts.js` | 清单确认、终态参与人及决定、`confirmNotPaid`、材料状态与最终释放重验;保留导出/指令逐次覆盖和等待期 | +| `loanReleaseRecoveries.js` / `loanBankResults.js` | 恢复逐事实/整轮确认、最终消费、晚到/更正/追回决定与记账;冻结关闭仍只能由对应事务完成 | +| `paymentReceiptManualReview.js` / `paymentReceiptManualPersistence.js` | 人工提案决定、`receiptParticipants`、`assertIndependentReceiptReviewer`、`loadApprovedManualReceiptEvidence`、详情重建及所有复用人工证据的校验;借款分支识别模式,报销/差旅继续原规则 | +| `paymentReceiptVerification.js` 及交易认领路径 | 自动核验和最终消费使用正确历史审批上下文,保留唯一交易、字段/状态及候选检查;OCR成功不代替公司批准 | +| `loanNotSubmitted.js` 与取消消费者 | 关联模式在发起、决定及最终消费三处拒绝Q6;旧独立Q6双审与材料合同不变 | +| `routes/paymentBatches.js` / `routes/companyLoans.js` | 服务端候选、动作能力、授权登记、范围受限的经理材料读取与确认;不得为绕过目前财务专用读取检查而授予经理全局财务权限 | +| `CompanyLoansPage.tsx`、还款详情/对账组件、待办及通知消费者 | 依据服务端返回的模式和能力展示动作、材料与历史文案;技术员工不进入候选或通知对象,现有独立模式显示不变 | + +### RP-F 页面、错误与回单 + +- 沿用已有页面、对话框和样式。授权及范围在借款业务内登记;还款详情、确认弹窗、历史决定和待办都显示关联身份。按钮区分财务“提交/登记/核销”与经理“关联方确认/退回”,不出现“独立复核通过”的错误描述。 +- 服务端返回受范围限制的能力和不可用原因;错误码合同区分无有效授权、超范围、经办冲突、证据失效和本模式不支持Q6。未授权人员不通过原因文本获得借款、人员或银行材料详情。经理只获得作出本笔决定所需材料,完整账号/原件仍要求用途、权限和审计。 +- 收款人多角色不能获得本批经办入口;财务多角色不能获得自己的确认按钮,直接调用接口同样拒绝。电脑和手机均完成授权确认、付款及异常流程操作,不以共用组件替代移动端验证。 +- 普通回单继续 `BankSlipOCR → normalizeBankSlip → 匹配/必要人工核对`。按获准使用的实际文件确认可读字段和状态;合成测试只替换云端返回。无需提前问银行能否识别,也不能补造回单未记载的流水号或把 OCR 视为银行验真。 +- 回单流水号/凭证号与提交指令号的关系从实际回单、网银提交记录核对,不默认相同。现有依赖编号的失败/处理中路径继续严格检查;信息不能对应时保持待对账。银行终态或“无提交”的证明属于异常证据问题;Q6在此模式下关闭,官方无结果材料不再作为普通还款启用前提。 + +### RP-G 实施、验收及启用边界 + +1. RP1:数据、授权范围、两类权限生命周期与冻结模式;RP2:所有服务/路由/证据重建/最终消费;RP3:现有电脑手机界面与范围访问;RP4:合成正反例、并发、资金守卫变异、只读复核和最终候选检查。详细清单及验收映射见 implement 顶部。 +2. 改动应一起完成到可办理、可对账再启用,不能仅部署放开收款人审批的中间版本。AC20–AC26已有本轮本地合成证据,AC27数据库部分通过、独立代码复核及固定候选正式门禁待完成;原AC3保留独立模式基线,其余资金要求不降级。 +3. 本地已有A/B/C、S1–S8、SR和电脑/手机界面;本次是在已有流程上调整模式,不重做或声称这些尚未实现。`4c37fcee` 的门禁只证明旧独立模式,关联模式必须绑定自己的测试结果。完成实现后按项目规则只读复核;inline下无法派审则如实保留待独立复核。 +4. 2026-10-07的获准生产只读核实为OA借款记录0、73迁移已应用;本轮新增后本地82迁移。这只是时点事实。发布前再获准核对存量及实际迁移差集,不能永久复用空存量结论;若出现历史在途独立批次,单独评估且不自动转模式。 +5. 新决定一旦产生,旧代码不理解关联确认的含义,不允许直接降级、删除模式字段或改写历史。需要停止新付款并采用获批兼容修复/回退方案,保留已发生银行事实及其证据。 +6. 本轮已授权本地实施与验证;用户随后明确授权将完整的 39 项改动本地提交,并运行新候选正式门禁。真实授权配置、生产查询、推送、部署及真实付款不在此次执行内。运行时验证、主会话检查、独立复核和真实材料验收分别记录,不沿用旧候选回执证明新模式。 + +### 已否定方向(历史说明) + +此前讨论过让技术员工获得借款专项复核资格。用户已明确否定,不采用、不实施、不再作为待确认项;现方案不依赖该员工,也不以另建身份或增加通用角色代替他的同意。 + ## 2026-10-07 S4–S8 实施细化(沿已批准范围,尚待最终验收) - Q6 使用独立的人工声明与复核记录,不混入银行终态事实。三类附件固定分类;查询及对账覆盖首次导出至登记日(公司时区),每个相关导出人均须有文件去向说明。完整空提交清单可以独立复核;撤回登记不能充当“未提交”。混合批次仍逐条验证已提交部分的银行终态。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md index 00b5b380..360368a5 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -1,4 +1,176 @@ -## 2026-10-08 测试稳定性集中检查与新候选门禁 +## 2026-10-08 关联方模式本地提交与正式验证(已授权) + +用户明确答复“是,授权”,同意将本轮 39 项改动作为一个完整的本地提交,并对干净的新候选运行正式验证。提交范围为 33 个修改文件和 6 个新增文件,含一个新增迁移;文件摘要已与上一轮收尾清单核对一致,本次只补授权及验证安排的记录。 + +- 按中文提交说明引用 `09-17-company-loan-principal-repayment`,保留“真实回单待验、独立代码复核待完成”的限制;不推送、不部署、不查询生产、不配置真实授权或操作真实资金。 +- 提交后只运行 `bash scripts/qa/run-release-gate.sh verify-candidate`,在独立的 `artifacts/release-gate/` 目录留存该候选回执。运行期间冻结源码、测试与任务文档,不把旧候选或限定重验结果视为本次正式通过。 +- 门禁发现本任务问题时,在已批准范围内修复并做针对性验证,再形成新候选;失败证据保留。每个实际候选和最终结论以 Git 记录及其对应回执为准,不提前记录 PASS。 +- 独立代码复核仍受 inline 限制而待完成;正式本地门禁不能替代独立复核、实际回单验收和生产启用审批。整体任务保持 `in_progress`,不归档。 + +下方“未提交”“待授权”等为相应阶段时点,当前授权与执行顺序以本节为准。 + +## 2026-10-08 关联方模式收尾接续(用户要求按既定思路规划并实施) + +沿已批准的 RP1–RP4 和两人办理规则继续完成本地收尾,不重新实施已通过的流程,也不重新要求确认马经理公司代表权。技术员工不参与业务审批;普通回单直接按获准实际材料验收既有 OCR,无需先问银行。 + +- [x] 对当前 39 项改动做最终主会话检查,重点核对授权时点、历史经办冲突、材料读取、最终资金消费者及迁移;修复下述文案遗漏,未发现未处理的本轮资金规则阻断。 +- [x] 核对既有验证回执和当前差异,补齐可供只读复核的范围及真实材料验收清单;无变化的通过结果直接复用,不把旧候选正式门禁当成关联模式门禁。 +- [x] 更新本任务记录并完成任务、治理、文档引用及差异格式检查,形成可审阅的 39 项本地提交范围;本次没有提交、推送、部署或查询生产。 +- [ ] 本轮独立代码复核:当前 inline 指令禁止派 implement/check 子代理;已有两份代理报告属于前阶段,不覆盖关联方改动。主会话检查不替代此项。 +- [ ] 本地提交及固定候选正式门禁:提交按明确授权执行;门禁使用干净、已提交的新候选,不能通过临时提交或忽略工作区差异绕过。 +- [ ] 真实材料验收和发布准备:财务提供既有借入凭据、历史还款及余额核对、双方账户、可授权使用的回单与提交记录;生产查询、真实录入、推送和部署各自按明确授权执行。此前空存量结果不代替发布前查询。 + +本次不更改业务规则或扩大为 Q6、第三人审批、远端备份切换;待配合事项在本地收尾完成后集中说明。 + +### 复核交接范围 + +基线为 `4c37fcee`,本轮待提交范围仍为 33 个修改文件和 6 个新增文件。正式复核须同时检查未跟踪的新文件;仅检查 `git diff` 会漏掉新授权服务、迁移、界面及测试。以下作为只读复核入口,不能把此交接清单视为独立复核通过: + +| 检查面 | 入口与必须保持的结果 | +|---|---| +| 授权与历史决定 | `loanApprovalPolicy.js`、新迁移及 Prisma:最新已确认范围不回退、付款/对账分别停用、同一员工多账号仍冲突、历史有效决定可消费、模式不可改 | +| 最终资金动作 | `companyLoanPayments.js`、提交/终态/SR/银行结果服务和人工回单重建:所有入口及最终消费使用相同模式;缺证仍占用、重复交易仍拒绝、关联 Q6 不可用 | +| 页面与读取 | 两个路由、授权/还款/对账组件、付款页、导航及责任人:财务经办、指定公司代表确认,技术员工无审批和材料权限;完整账号及原件仍按用途审计 | +| 数据及并发 | 新迁移不改旧迁移,旧记录独立模式;当前授权、决定人与材料上下文受约束;既有批次→回单→出借人→借款锁序,无逆序 | +| 验证及文案 | 关联 HTTP/电脑手机 fixture、单测、错误码合同:包括完整闭环、资金拒绝后的本金占用不变和授权停用两种顺序;公共借款错误提示不能把关联确认说成独立复核 | + +本次主会话发现并修正 5 条公共借款错误提示:银行结果、补证需确认/已失效、回单归属、提交清单。只改提示及对应的前后端合同,未改资金判断;独立模式专用的无复核人提示和 Q6 双人要求保留。 + +本次增量验证证据位于 `artifacts/qa-flow/loan-related-closeout-20261008/`:错误码合同零发现;已有错误提示及业务错误测试 2 文件、908/908 通过;前端类型检查和构建通过;UI 规则 161 文件零发现;改动的服务端常量语法检查通过。未为文字修改重跑数据库、HTTP、浏览器或资金变异,也未重跑服务端全量。前阶段完整借款专项仍为 82 迁移、HTTP 109、浏览器 33、数据库约束 9 组通过;有效变异分三轮 8+3+2 全部捕获。最后一轮变异回执中的 5 个资金/权限关键文件摘要与当前文件一致;此比较只覆盖所列文件,不作为整仓或发布证明。主会话检查、独立复核、正式门禁及真实验收仍分别记录。 + +本次 task validate、Trellis 治理(含敏感扫描)、流程合同 baseline 和 `git diff --check` 通过。文档注册校验零错误,保留原有“22 个 docs 未登记”的一条汇总告警;不扩展清理范围。inline 缺失的 implement/check manifests 按现有规则跳过。 + +### 财务配合与真实材料验收 + +| 顺序 | 需要的既有材料 | 核对结果 | +|---|---|---| +| 1 本金 | 原借入凭据、协议或确认资料、系统外已还依据、代账核对结果及截止日期 | 按原借款逐笔确定未还本金;资料未核实不能申请,OA 无记录不能推定银行未付款 | +| 2 身份与范围 | 公司主体、财务和马经理真实员工关联、已确认的公司代表权说明、所覆盖借款、双方银行账户 | 在获准业务录入时登记范围并由马经理确认;不新增第三人审批,不在聊天或测试报告抄录完整账号 | +| 3 回单 | 获准用于验收的既有银行回单原件,以及有对应关系的银行提交记录 | 在受控环境按实际 OCR→归一化→匹配处理,逐项核对金额、币种、账户、状态、流水号及时间;不能仅看 OCR 返回成功 | +| 4 歧义及异常 | 现有同金额候选的归属依据;已有失败/处理中材料及相应指令终态材料(如有) | 按实际文件记录支持与不支持的字段及编号对应;材料不足保持待对账。缺少异常样本就保留该项真实验收未完成,不制造付款取样 | + +原件只在获准的受控位置处理;验收记录只保留样本代号、字段是否可读、匹配或拒绝结论及脱敏证据引用。普通 OCR 验收不以询问银行或取得“查询无结果”PDF为前提;公司现有人员安排下,关联 Q6 继续关闭。 + +### 候选与发布接续 + +1. 本地问题修复及针对性验证完成后,固定只读复核范围;独立复核未完成时明确保留该项。拟提交内容为完整的关联模式及验证、任务记录,提交说明保留“未部署、真实回单待验、独立代码复核状态”。提交只在获得明确授权后进行。 +2. 对获准提交后的干净候选运行 `bash scripts/qa/run-release-gate.sh verify-candidate`,另存候选回执;运行期间不修改源码、测试或任务文档,不并发重建前端。此命令只验证,不使用会进入生产链的 `all` 或 `ci-release`。 +3. 发布前获准重新只读核实生产借款、在途/终态批次及迁移差集;本地为 82 个迁移,若生产仍保持此前的 73 个,预计差集为 9 个,但不得把这个推算填作实际 `EXPECTED_PENDING_MIGRATIONS`。准备覆盖数据库和附件的部署前手动快照,以及生成关联决定后禁止直接降级的兼容处置方案。 +4. 推送、部署和真实授权/借款录入/付款各自按明确授权执行;部署后核对版本、实际迁移、电脑手机入口和权限,再办理真实已批准的还款。备份切换仍只按原独立范围安装指定脚本,不能借此发布整个应用。 + +## 2026-10-08 关联方还款本地实施完成(未提交;独立代码复核及真实材料待验) + +用户在规划完成后明确“开始实施”,授权按 RP1–RP4 自主完成本地实施及验证。本轮按财务经办、马经理代表公司确认的已定分工实施;技术员工不参与财务审批、复核或见证。当前 `inline` 由主会话直接实施与检查,未派 implement/check 代理;以前代理的复核不覆盖本轮差异。没有提交、推送、部署、查询生产、配置真实授权或操作真实资金。 + +- [x] RP1:新增一个增量迁移 `20261009010000_loan_related_approval`(本地共 82 个迁移),新增授权、覆盖借款和生命周期事件,冻结还款模式与范围,补齐逐次决定上下文及数据库约束。历史记录默认独立模式,不补造授权;未改旧迁移。 +- [x] RP2:贯通付款批准/退回、导出、清单、成功/失败归属、人工回单决定及冷重建、严格失败、终态与最终取消、SR、晚到成功、误报及追回。只豁免指定确认人的收款人关联,全部历史经办冲突保留;关联 Q6 在发起、决定和最终释放处拒绝。 +- [x] RP3:现有电脑/手机页面接入授权范围和公司代表确认,按服务端能力区分动作;经理只读授权范围内的材料,查看原件与完整账号需用途及审计。两个独立登录实际操作完整正常及异常流程,技术员工无新增候选、待办或材料权限。 +- [x] RP4 可执行部分:定向 DB/HTTP/浏览器、确定性交错、资金守卫变异、受影响共用路径、主会话只读检查及既有规约维护完成。任务与流程收尾校验另见本节末。 +- [ ] 本轮独立代码复核、获准真实材料验收及固定提交候选的正式发布门禁;不以本地测试或旧候选回执勾选 AC27/整体借款验收。 + +### 关键行为与收口修正 + +- 授权分为新付款与历史对账两方面。使用最新已确认版本,缩小范围或停用后不回退旧版本;旧版本不能再登记停用事件。新申请/批准/导出检查当前付款授权,新对账决定及最终释放检查当前对账授权;已生效审批及银行事实在证据有效时按历史上下文消费,不因事后停用或确认人离职丢失效力。 +- 公司代表可以退回付款申请,必须写理由;决定不可改,不能再批准或导出,退回本身不释放占用。财务安全取消后新建更正申请,详情、台账、责任人及提醒均标为退回。 +- 同一员工兼任角色或使用多账号仍不能自审;经办冲突收集包含作废、撤回及被取代材料。数据库同时校验决定的实际确认人与上下文相符、目标及授权范围一致,禁止把关联决定写进旧独立记录。 +- 核销、人工证据重建、银行事实补记及恢复释放均重验历史决定,而非只信任 `approved`。篡改原付款或人工决定上下文的隔离反例会被拒,本金和占用不变。 +- 手机实测发现退回菜单被底部导航遮挡,已改为向上展开并保留可点击高度。完整浏览器回归后仅另改一处账号查看弹窗文案,将借款的“报销单”改成“还款单”;该文字修正后受管构建/类型检查、UI 规则及差旅冻结保护再次通过。 + +锁序保持:既有批次 → 回单交易/附件 → 出借人及人员资格 → 排序借款;创建锁出借人 → 借款,仅新建批次。授权生命周期只锁出借人,不等待既有批次。授权停用分别与批准、导出、事实确认和最终释放做两种先后顺序,共 8 个确定性交错;材料及候选变化继续沿原门禁。串行化只覆盖动作当时已知事实,之后的新银行事实仍走晚到结果对账。 + +### 本地验证与证据 + +主要记录在 `artifacts/qa-flow/loan-related-20261008/`,均为合成资料与本轮拥有的隔离环境。 + +| 范围 | 实际结果与边界 | +|---|---| +| 完整借款专项 | `loan-full-first.log`:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组全部通过,清理通过。HTTP 包含原独立模式 95 组及关联 14 组;浏览器包含原 19 组及关联电脑/手机各 7 组。OCR 只替换云端返回,识别、归一化、匹配、人工重建及最终消费走真实实现 | +| 关联正常及异常 | 范围登记/确认、原件读取、批准与退回、导出/OCR核销、人工核对、归属变化重提、逐指令终态、SR及释放、占用冲突、部分/整笔超额、部分/全额追回、误报、Q6拒绝及技术员工拒绝访问通过;审核或资金写入异常的事务回滚也有反例 | +| 服务端 | `server-full.log`:227 文件、4519 项通过,1 项原有跳过。此后补旧授权停用检查与数据库决定人核对,最后的 `targeted-final.log` 5 文件 77/77 通过,最新数据库/HTTP验证包含于上述完整专项;没有把较早全量回执说成最后小改后再次全跑 | +| 受影响共用界面 | `loan-related-shared-20261008` 的受管首轮为 35 通过、1 失败:报销付款、付款权限和导航通过;误选了覆盖合同已标 `frozen` 的历史 `travel-payment-flow.spec.ts`,员工入口按现行规则返回 403。未改差旅代码或权限,改按合同入口 `travel-freeze-protection.spec.ts` 在 `loan-related-shared-freeze-20261008` 重验 2/2 通过。保留首轮失败,不称首轮全通过;两轮源码/仓库完整性和自有环境清理通过,均不具有正式发布资格 | +| 静态与编译 | Prisma validate、前端类型检查/构建通过;最新 UI 规则扫描 161 文件零发现,错误码合同、敏感扫描及 21 个改动 JS/CJS 的语法检查通过。文字修正后的类型检查/构建日志在 `shared-freeze-retained.log` | +| 守卫变异 | `mutations.json` 8/8、`mutations-extra.json` 3/3、`mutations-final.json` 2/2,共 13 个有效变异全部捕获;均在临时源码副本测试,工作区被测源码摘要未变,副本已清理 | + +变异覆盖当前付款授权、范围、指定身份、历史经办冲突、最终原批准消费、人工回单重建、Q6、退回决定、冻结付款上下文、最终释放当前资格、范围读取、旧授权停用和数据库首个决定的实际确认人。移除原批准/人工证据最终检查会造成错误核销,测试明确捕获;移除旧授权停用的服务检查时,数据库仍拒绝,测试捕获的是 `500` 取代约定 `409`,不是错误停用或错误资金动作。最初变异运行器用错 Vitest 配置扩展名的启动失败不计入 13 个有效结果。 + +中间浏览器失败记录保留于 `related-browser-*.log`、`related-browser-ui-*.log`:先修正隔离 origin 的端口、两个视口重复按钮定位、提交响应与页面刷新的等待、证据选择与查询刷新的等待,再修复实际手机菜单遮挡。最后完整专项通过;不将先前局部桌面通过描述为电脑/手机全过。相关截图位于完整专项回执列出的 `artifacts/qa-layout/` 子目录。 + +复现入口: + +- 完整专项:`RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs`;需沿已验证的 BIFF8 Python 环境和 Playwright 浏览器缓存配置。 +- 关联定向:增加 `RUN_ISOLATED_LOAN_RELATED_ONLY=1`;若另加 `RUN_ISOLATED_LOAN_RELATED_UI_ONLY=1`,只做浏览器准备,关联 HTTP 明确为 `NOT_RUN`,不得计通过。 +- 共用界面:受管 `bash scripts/qa/run-web-gates.sh retained-local`,通过 `QA_RETAINED_SPECS` 选择报销付款、付款权限、导航及现行差旅冻结保护合同。不能用已封存的差旅整链恢复被冻结权限,也不把 retained-local 当作 `verify-candidate`。 + +### 主会话检查及后续边界 + +主会话只读检查覆盖所有授权生命周期、决定入口与最终消费者、共享人工回单重建、范围读取/审计、不可变迁移、Q6及锁序,未发现未处理的本轮阻断项。对比原迁移确认两个被替换的历史触发器仅适配新增决定字段,旧状态转换保护保留。按 `trellis-update-spec` 更新既有后端规约,写入模式/权限时点、经办冲突、历史消费、读取与回退合同,并替换过时的“S8恢复界面未做”测试说明;运行次数保留在本任务。 + +- 任务仍为 `in_progress`。AC20–AC26 仅勾选本地合成验收;AC5、AC8、AC27及既有整体业务验收保持开放。仓库 `.trellis/config.yaml` 的 `dispatch_mode: inline` 和 workflow 禁止派 implement/check 代理,本轮没有独立代码复核结论;不需要技术员工承担财务复核。 +- 新模式真实启用前仍需财务核实借款余额与账户、登记已获确认的公司授权范围,再使用获准实际回单和提交记录验收现有 OCR 及编号对应。无需事先向银行咨询普通 OCR;证据无法对应时保持待对账。Q6 在关联模式下不可用,不能以两人声明代替银行终态证据。 +- 当前 HEAD 仍为 `4c37fcee`,本地 main 比本地 origin/main 多 12 个已存在提交,本轮差异未提交。旧候选正式门禁不证明本轮差异;后续固定提交候选再做正式门禁,推送、部署、真实授权/付款及发布前生产核实分别按授权执行。 +- 新增迁移须纳入将来部署的实际迁移差集。2026-10-07 的 OA 空存量与 73 个已应用迁移只是当时时点,不能代替发布前再核实;已有银行事实后不得降回不能解释关联模式的旧代码。 +- 收尾 task validate、Trellis 治理(含敏感扫描)、流程合同 baseline 及 `git diff --check` 通过;inline 下不存在的 implement/check manifests 按规则跳过。文档注册检查零错误,保留原有一条“22 个 docs 未登记”的汇总告警,本轮未扩大清理范围。AC1–AC27 连续、原 AC1–AC19 勾选状态保留、历史正文未删;较早门禁标题仅补充“历史计划”标记。 + +## 2026-10-08 关联方还款实施规划(历史规划回执,已获本地实施授权) + +用户已确认马经理有权代表公司批准,要求按财务与马经理两人办理的方向规划;技术员工不参与复核、审批、见证或财务授权。本轮完成既有PRD/design/implement收敛,不把“规划”扩为代码或真实业务操作授权。下面较早的阶段记录保留各自时点,当前实现状态与后续工作以本节为准。 + +### 当前基础与本轮产物 + +- 既有A/B/C、S1–S8、SR补证恢复、电脑/手机界面均已在本地实现。当前HEAD为 `4c37fcee`,本地main比本地origin/main多12个提交,未推送、未部署。本次待做的是关联方模式,不再把S4–S8或SR列为未实现。 +- 该干净候选的正式 `verify-candidate` 回执为PASS:浏览器372/372、无失败/跳过/重试;服务端4480通过、1项原有跳过;7个默认关闭的可选专项未执行。源码/仓库完整性及自有环境清理通过,专用浏览器目录已清理。 +- 证据为 `artifacts/release-gate/loan-4c37fcee-20261008/release-result.json` 与同目录 `local-followup.json`;保留此前失败回执,不以新回执覆盖历史。更早的借款专项为81迁移、HTTP95、浏览器19、数据库约束9组通过。以上只证明旧独立模式及各自候选,不能证明新模式或真实还款已验收。 +- S0人员事实已明确:财务一人、收款经理一人,技术员工不参与,独立模式无人可办理该笔还款。马经理公司代表权已确认,不再提问;关联模式关闭Q6,不再把额外两名独立复核人或无结果PDF作为普通还款启用条件。 +- 普通回单沿已有OCR按实际文件处理,无需先问银行;真实样本尚未验收,不补造字段或编号关系。2026-10-07生产只读汇总为空只是当时时点,本轮不重查。 + +本轮文档清单: + +- [x] 记录已确认的公司授权、两人分工、技术员工排除和OCR验收纠正;删除未定方向的表述,保留被否定方案的历史说明。 +- [x] 收敛PRD的R3/R4/R6、原AC3独立基线及AC9–AC19的模式语义,新增未勾选的AC20–AC27;已付、异常与Q6边界一致。 +- [x] 在design给出范围/授权、权限时点、全流程分工、数据与消费者、并发、界面及启用/回退方案;覆盖人工回单重建与最终消费,不能只改审批入口。 +- [x] 拆分以下RP1–RP4及定向验收;本轮无应用实现、迁移或业务配置。 +- [x] 任务校验、Trellis治理(含敏感扫描)、流程合同baseline及git diff --check通过。主会话另核对AC1–AC27连续且既有勾选状态保留、新AC未提前验收、design/implement历史正文保留、差异仅三份规划文档;未运行应用测试或正式门禁,未进行独立代理复核。 + +### 原实施拆分(历史规划时点;当前结果见顶部) + +| 切片 | 内容与主要范围 | 验收和完成条件 | +|---|---|---| +| RP1 授权、模式与数据约束 | 一个新增型迁移;借款范围授权及生命周期事件,真实身份关联,付款/对账授权区分,冻结还款模式,逐次决定上下文。沿用现有角色而不授予全局新权 | AC20/21/22/25/27;历史独立回填、关联字段/外键/不可变约束、范围和状态验证;不会单独使付款入口放行 | +| RP2 后端全链路 | 候选/创建/批准/导出;清单、两种归属、人工回单含重建、严格失败、终态、取消、SR、晚到/误报/追回;所有最终消费者、范围读取及授权接口;关联Q6三处拒绝 | AC21–AC25;每个消费者两人正例与经办冲突反例;拒绝后本金/占用/冻结正确;原独立、报销和差旅守卫不变 | +| RP3 现有页面接入 | 在借款内登记/确认范围,现有还款及对账页面显示模式、公司代表身份、材料/理由、分工按钮及不可用原因;待办、通知和审计文案一致 | AC22/26;电脑和手机实际走两人正常及异常流程,技术员工无候选/任务/材料,前后端能力一致 | +| RP4 验证、只读复核与收口 | 定向DB/HTTP/浏览器、资金守卫变异及确定性交错;针对真实OCR处理链的合成输入;补齐任务证据及复核发现,按最终变更范围跑必需检查 | AC20–AC27及受影响旧AC;只读review不改代码,inline无法派审时明确待独立复核;固定最终已提交候选后按已获授权运行正式门禁 | + +- [ ] RP1完成。 +- [ ] RP2完成。 +- [ ] RP3完成。 +- [ ] RP4完成;记录运行时验证、静态复核、真实业务验收各自结论,不合并写成“全部验收”。 + +所有切片作为完整可办理、可对账的模式一起启用;即使某部分能独立验证或提交,也不能部署只放开收款人审批的中间状态。公司授权登记只反映已确认的代表权,不再增加第三人批准层。新授权版本、付款批准和银行事实决定分开留痕,模式不通过前端开关或通用admin权限绕过。 + +### 针对性验收矩阵 + +| 主题 | 必须证明的行为 | +|---|---| +| 实际两人正例 | 仅财务、收款经理两名合成办理人,从范围登记/确认、建单、经理关联方确认、财务导出、OCR成功到核销;技术员工只作为拒绝访问的合成反例 | +| 只豁免收款人关联 | 经理仅作为收款人可以确认;分别加入制单、提交、导出、清单登记、各原件上传、人工录入或提案参与后全部拒绝。覆盖多角色及同一员工多账号,移除一条检查不能由删除收款人标识一并放行 | +| 授权和历史 | 未生效、超公司/出借人/借款范围、身份变更、客户端伪造模式、旧独立单转模式均拒绝;新版本不能改写旧付款授权。付款权限停止不破坏已付核销,新的对账决定及最终释放检查当前对账授权 | +| 正常与异常闭环 | 自动回单成功;人工录入→关联确认→证据重建→核销;同额成功/失败归属;清单及逐提交终态;严格失败与最终取消;补证重提;晚到成功→占用冲突/超额→部分/全额追回;误报关闭与多原因冻结 | +| 证据持续有效 | 缺字段、状态矛盾、同金额无归属、指令不符、缺任次提交终态、等待期未满、材料作废/替换/候选变化均不因关联模式放行;只登记事实不直接减少本金或释放占用 | +| Q6及其他业务 | 关联模式发起、复核、伪造双审后的取消均拒绝;旧独立Q6仍要求两名独立人及全证据;独立借款、报销/差旅及跨业务交易唯一性回归 | +| 决定与资金并发 | 授权停止分别与批准、导出、事实确认及释放测试两种顺序;沿现有出借人锁与人员资格锁,不新增逆序。材料/候选变化与最终资金动作仍做确定性交错;结果只保证当时已知集合,晚到证据进入对账 | +| 不可变与回滚 | 模式/授权引用不能换写,批准人与提案人不得同人,旧事实不可删;审计或资金更新失败全回滚;重复请求不重复核销/释放/追回;相关限制在服务和数据库分别验证 | +| 界面和隐私 | 两个独立登录在电脑、手机走实际弹窗;关联文案覆盖详情/待办/历史/通知,角色能力从服务端来;完整材料仍按用途审计,越范围及技术员工直接访问拒绝 | + +测试沿项目已有隔离数据库、真实Fastify、Vitest及浏览器入口,不新造一套业务认证替身;OCR仅替换云端返回。每个关键资金/权限守卫做有区分度的反例及定向变异,若下游保护仍阻断则如实记录,不谎称该变异已造成错误资金动作。按切片做针对性回归,完整候选形成后再做受影响范围的完整验证,避免纯文档阶段重复全量门禁。 + +### 真实启用前的事项 + +1. 完成上述代码、页面、迁移及验证;独立只读review按项目规则执行,受inline限制时保留待办而不虚报通过。 +2. 经正式业务录入核实公司、借款、未还本金、双方账户及马经理员工关联,在系统里登记此次已经确认的公司授权与范围。无需聊天提供完整账号,也不安排技术员工审批。 +3. 使用获准的实际回单及提交记录验收现有OCR和编号关系;正常识别不以事先银行咨询为前提。异常无终态证据继续占用,Q6保持不可用,不因样本不足降低证据标准。 +4. 提交、推送、部署与生产录入/付款分别按授权执行;发布前重新获准核实存量、迁移差集及手动快照。备份脚本的单独远端切换不在此范围,不能借此发布当前main。 + +## 2026-10-08 测试稳定性集中检查与新候选门禁(历史计划,最终回执见上) 用户已批准集中检查、必要测试修正、本地提交及对新干净候选运行正式门禁。本轮不推送、不部署、不查询生产、不执行远端备份操作;任务仍为进行中。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md index 2b706464..ec571ca7 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -1,6 +1,15 @@ # 公司借款台账与本金归还 -## 2026-10-07 本地阶段状态 +## 2026-10-08 两人办理关联方还款(本地实施与合成验证完成,已授权本地提交及正式验证) + +- 用户确认:实际经办为一名财务兼人事行政;马经理为股东、个人出借收款人,且有权代表公司批准还款。技术员工不参与复核、审批或见证,不增加财务权限,不作为候选、任务接收人或业务验收所需人员。 +- 按用户“按照这个思路规划”的要求,采用“财务经办 + 马经理代表公司作关联方确认”。公司授权已明确,不再列为待决问题;其授权范围和真实身份在业务启用时留存。该模式具有两名操作者的分工,不具备无利益关联的独立审批,页面、凭据和审计必须如实标明。 +- 原独立模式在创建时排除出借收款人,按实际人员配置无法办理该笔还款;该默认规则保留。新增关联模式覆盖正常付款及归属、人工回单、提交清单、失败/终态释放、补证恢复、晚到成功、追回和误报关闭,实施验证记录见 implement 顶部。 +- 新模式只豁免指定确认人的“收款人关联”冲突,不豁免制单、提交、导出、上传、录入或提案等经办冲突。财务可承担多项经办工作;同一真实身份不能通过多个账号或角色自审。Q6“人工确认未提交”在此模式下不启用,原双独立复核条件不降低。 +- 回单 OCR 已实现,普通回单按实际文件识别和匹配,无需先向银行咨询。银行终态证据与普通 OCR 验收分开;OCR 或两人的确认均不能补足缺失的交易归属,也不能从“没有回单”推断没有付款。 +- 用户已明确“开始实施”,本轮已完成 RP1–RP4 的本地实现及可执行验证;随后明确授权将 39 项本地改动提交,并运行新候选正式门禁,执行安排见 implement 顶部。AC20–AC26 有本轮合成证据;AC27 的数据库部分已验,独立代码复核及真实材料验收仍待完成。新候选门禁结论以该候选回执为准,旧候选 `4c37fcee` 的通过回执不用于证明新模式;未授权推送、部署或本轮生产查询。 + +## 2026-10-07 本地阶段状态(历史时点,现状见 Current Status) S4–S8 已本地提交 `431c36ea`;补齐剩余界面后合成回归为 81 迁移、HTTP 95、浏览器 19、数据库约束 9 组通过。应用代码沿用修复后的同模型只读静态复核,本轮只补测试与记录。S0 生产只读查询确认 OA 借款模块当前记录为 0,73 个已应用迁移校验值一致,待执行 8 个;不代表银行或线下没有付款。人员、真实建行材料和业务独立验收仍未完成,正式发布门禁尚待执行;不据此勾选整体真实还款验收或声明已部署。完整证据与限制见 implement.md 顶部。 @@ -10,14 +19,14 @@ S4–S8 已本地提交 `431c36ea`;补齐剩余界面后合成回归为 81 迁 ## Goal -让财务能按已核实凭据查清公司向个人借入的未还本金,并完成申请、独立审批、网银付款、回单留存和业务本金核销。OA管理业务台账及付款证据,代账系统负责原负债科目记账,不把借款还本变成费用报销。 +让财务能按已核实凭据查清公司向个人借入的未还本金,并完成申请、有权审批、网银付款、回单留存和业务本金核销。默认保留独立审批;本公司已授权、身份及借款范围明确的关联方还款,按 R3 采用财务经办与公司有权人的关联方确认。OA管理业务台账及付款证据,代账系统负责原负债科目记账,不把借款还本变成费用报销。 ## Background - 用户提出历史两笔借入合并一次归还的真实需求;聊天提供的历史日期、金额和“本年无借还”尚未由本任务核验原始凭据,不据此创建真实余额或付款。 -- 当前代码仅有reimbursement/travel_order付款来源(`server/prisma/schema.prisma:273`);创建依赖已有来源单,不支持自由填收款人金额的通用付款申请。 -- 建行制单用途/备注按报销生成(`server/src/services/paymentOrderXls.js:158`);通用审批配置也只有既有五类业务(`server/src/routes/workflowDefinitions.js:9`)。回单OCR及人工核对范围须显式适配,不能只改备注。 -- 本task于2026-09-17获准规划,独立于报销收尾任务,status=planning。先处理08-23恢复关键遗留及09-05到期治理;本次不是实施、真实资料录入或付款授权。 +- 立项时仅有 reimbursement/travel_order 付款来源,创建依赖已有来源单;本任务后来新增借款还本来源,仍不开放自由填写收款人和用途的通用付款。 +- 立项时建行用途/备注按报销生成,回单 OCR 及人工核对未覆盖借款。本地已有显式适配,实际材料的识别与银行接受性仍须分别验证。 +- 本 task 于 2026-09-17 获准规划,独立于报销收尾任务;后续实施授权与证据见 design/implement。目前为 in_progress,本轮已本地实现关联方模式,不扩大真实录入或付款授权。 ## Scope @@ -33,63 +42,92 @@ S4–S8 已本地提交 `431c36ea`;补齐剩余界面后合成回归为 81 迁 列表区分未还本金、已占用待付款额、可申请额及核对截止日期,资料覆盖不全时不得回答“已结清”。财务选择借款及本次分配金额,正数、两位小数且合计等于付款额;不得跨公司/出借人/币种合并。并发申请不能超额,已付、取消及有银行在途的状态必须分别处理。 -### R3 有权且独立的审批 +### R3 有权审批与明确的关联方例外 -财务创建并提交,复用付款批次的经理/CEO独立复核作为首期还款审批,不再加一套重复通用审批链。审批人须在职、有明确业务角色及权限,排除制单/提交人和出借收款人对应的OA身份;身份不清或无人可批则阻止提交,不用admin通配兜底、不靠临时虚建员工绕过。对出借人的OA身份关联必须确认并留痕,执行时重验,不按姓名猜测。 +财务创建并提交,沿付款批次完成决定,不新增重复的通用审批链。默认 `independent` 模式沿现有经理/CEO独立复核,排除制单/提交人和出借收款人对应的OA身份,无合格人时阻止提交。不得使用 admin 通配、虚建非员工出借人或按姓名猜身份绕过。 + +新增 `related_party` 模式,限定公司、真实个人出借人、其关联员工及授权覆盖的已确认借款。财务登记实际公司授权与范围,指定有权的马经理确认;不要求技术员工或第三名员工参与。还款创建时由服务端核定并冻结模式、授权版本和身份;客户端不能自行切换,历史独立批次不能自动转为关联模式。 + +马经理作出每个新决定时,须当前在职、有相应经理/CEO业务权限、具备覆盖该事项的公司授权;仅豁免其作为本笔收款人的利益关联。经办、提交、导出、证据上传、人工录入和提案冲突继续拒绝,按真实员工身份判断,同一人兼任角色也不能消除冲突。关联模式的经办入口提前禁止指定确认人参与这些经办动作;其公司授权确认或债权/收款声明不记作独立复核。 + +付款申请可由公司代表填写理由退回;退回决定与授权留痕不可改,原申请不得继续批准或导出。退回不直接释放占用,由财务按未发送取消规则取消后重新申请,不修改原申请的付款快照。 + +付款前核对当前权限、授权、版本与启用状态;付款后的原审批与出借人资格按当时快照判断。新的银行事实确认仍核对当前确认人的资格。停止新付款不抹掉历史授权、不阻止银行事实留存或符合条件的已付核销;停止后续对账决定单独记录。具体时点及最终动作检查见 design 顶部。 ### R4 付款与回单证据 审批只占用额度,不减少未还本金。财务在网银实际付款,用途为“还款”,短备注按借款年份/本金语义生成,受现有银行长度校验;详细原借款对应关系留在OA,不硬塞进银行备注。原件可先留存,但金额、币种、付款/收款账户、交易成功状态及唯一身份不满足时不得登记已付。 -复用现有回单核对的自动匹配/人工例外边界,显式支持新付款来源;真实样本适配未验证前不能宣称新来源自动识别可用。OCR不清或不可用时,原件留存、经办录入、独立人员复核不能伪称机器核实或银行验真;明确失败、受理中、账户/金额不符及重复交易不得靠普通理由放行。独立人工复核继续排除收款人和提案人。 +复用现有回单核对的自动匹配/人工例外边界,显式支持借款来源;真实样本未验证前不宣称真实识别已验收。普通回单直接沿已有 OCR 处理实际文件,不先向银行询问字段。OCR不清或不可用时,原件留存、经办录入和按 R3 模式作出的人工决定不能伪称机器核实或银行验真;明确失败、受理中、账户/金额不符及重复交易不得靠普通理由放行。独立模式排除收款人和提案人;关联模式仅豁免指定收款人关联,上传/录入/提案等经办冲突继续排除,读取及最终消费人工证据同样执行该规则。 ### R5 核销、异常与会计交接 -只有符合既有付款登记证据门禁并由财务明确登记成功时,才在同一事务内占用交易身份、核销分配本金并更新付款事实;同一请求重试幂等,跨批次/跨报销复用交易拒绝。确认失败或未发起银行付款的取消可释放额度;银行状态未知/在途时不能直接释放后重付。付款已登记后的错误处置保留原事实,另审纠错,不删除或覆盖记录。 +只有符合既有付款登记证据门禁并由财务明确登记成功时,才在同一事务内占用交易身份、核销分配本金并更新付款事实;同一请求重试幂等,跨批次/跨报销复用交易拒绝。取消须满足相应安全取消或逐提交终态证据门禁;有导出或银行提交后,不能凭口头“没有付款”释放额度。银行状态未知/在途时不能直接释放后重付。付款已登记后的错误处置保留原事实,另审纠错,不删除或覆盖记录。 付款成功与代账记账分开,记录凭证编号/记账确认及材料交接,不由OA自动生成会计分录。材料包括原借入凭证、协议/确认书、余额核对、审批及还款回单;本金不要求费用发票,不进入费用报销统计或费用预算。关键材料按适用访问权限留存,历史余额/分配不得无审计改写。 -## 2026-10-07 S4–S8 本地接续 +### R6 关联方模式的对账接续与留痕 + +R3 的模式同时作用于付款审批、提交清单、成功/失败归属、人工回单、银行终态、失败确认、SR补证、晚到成功、误报关闭和追回;需要人工决定的事项由财务登记、马经理作关联方确认,最终核销/释放/补记/解冻仍重验各自证据和资金条件。成功回单完全满足自动核验时,不增加一次无必要的人工回单确认。两人确认、时间相近、金额或账户相同均不能替代指令归属、终态或到账证据。 + +每次决定记录模式、公司授权及版本、实际决定人与关联身份、材料快照和理由;后续读取、重建、最终消费均使用同一语义。Q6保持两名独立复核人的现有合同,在关联模式下服务端拒绝发起、复核和释放,页面说明不可用;无可用终态证据仍保持占用。旧独立记录及报销/差旅权限不因此放宽。 + +## 2026-10-07 S4–S8 本地接续(历史时点) 用户要求沿已确定工作直接执行。S4–S8 的本地实现和验证继续推进,阶段证据在 implement 顶部;AC9–AC19 不因后端或合成测试通过而自动勾为整体验收通过。新银行事件均标明人工核对,不伪称真实银行/OCR 已验;生产查询、推送、部署仍未执行。 ## Acceptance Criteria +勾选仅表示相应本地合成验收完成,不能代替真实材料、独立代码复核或生产启用。原勾选项保留独立模式基线,AC20–AC26 另有本轮关联模式证据;AC27 的数据库部分已验证,独立代码复核及固定候选正式门禁仍待完成。AC9–AC19 中一般“复核/确认”按 R3 区分独立复核与关联方确认,具体消费者见 design;AC12 的双独立复核不适用此转换。 + - [x] AC1:合成历史两笔借入可登记并确认余额;未确认/重复登记/无凭据不进入还款,历史录入不被统计成当天新增收款。 - [x] AC2:同一出借人两笔借款合并一笔付款、部分偿还正确;超额、跨主体/出借人/币种、重复提交和并发占用均拒绝或幂等,金额用精确小数。 -- [x] AC3:财务不能自审,收款人即使持CEO/manager或多角色也不能批准自己的还款;无合格审批人、身份关联缺失/变动拒绝,无管理通配旁路。 +- [x] AC3(既有独立模式):财务不能自审,收款人即使持CEO/manager或多角色也不能批准自己的还款;无合格审批人、身份关联缺失/变动拒绝,无管理通配旁路。新关联模式的限定例外另按 AC20–AC27 验收,不以此勾选代替。 - [x] AC4:审批/导出/上传回单均不会单独减少本金;处理中保持占用,确定失败/安全取消释放,明确成功登记与分配核销原子完成,数据库异常整体回滚。 - [ ] AC5:正确回单可匹配新来源,错金额/账户/币种、失败/受理、同额歧义、重复交易、晚到版本及人工自审分别受阻;真实样本未覆盖的自动识别仍明确待验。 - [x] AC6:付款完成可追到原借款分配和会计交接状态;余额查询标明截止/核对状态,不把OA无记录说成银行无还款,不把本金计入费用。 - [x] AC7:报销/差旅既有付款、独立复核、制单、回单和权限回归通过;PC/手机沿既有交互,机密字段默认脱敏,普通员工不能读全台账。 - [ ] AC8:迁移与回退影响、独立只读复核、恢复风险处置和发布前条件完整;本地合成通过不替代真实银行接受性或部署,真实录入/付款另批。 -- [ ] AC9(对账,design 3.1/3.3):制单导出与银行提交尝试分别落表并关联;提交清单须经独立复核;确认失败只计未作废附件,但作废的成功或处理中回单仍按事实阻断;确认失败按批次冻结快照核对,出借员工离职不阻断;一条指令的终态不能推定其他提交已结束。 +- [ ] AC9(对账,design 3.1/3.3):制单导出与银行提交尝试分别落表并关联;提交清单须经相应模式的确认;确认失败只计未作废附件,但作废的成功或处理中回单仍按事实阻断;确认失败按批次冻结快照核对,出借员工离职不阻断原审批效力;新的事实决定仍须有当前合格确认人;一条指令的终态不能推定其他提交已结束。 - [ ] AC10(失败归属,3.1):存在同金额候选时,确认失败须有已复核、写明覆盖哪些指令的失败归属;未确认时保持占用。 -- [ ] AC11(银行终态释放,3.2;2026-10-07 补充:同一指令的终态事实可了结该指令此前的处理中回单,其他提交不能代替;最终取消释放的事务须重验全部条件,并与新提交、晚到回单串行;终态证据与付款回单分开存放,作废或被替换后不能继续作为依据):每次已登记提交均有能唯一定位指令、经独立复核的终态证据,且无未解决的成功或处理中事实,才可转 `confirmed_not_paid` 并释放;对账单、截图、时间依据与等待期均不能单独放行;释放后禁止再导出。 -- [ ] AC12(人工确认未提交,3.2a,用户 2026-10-06 确认支持):全部证据加两名不同的独立复核人,转 `confirmed_not_submitted`,记录与页面标为人工确认;人员不足或材料缺失时继续占用。 -- [ ] AC13(冻结,3.5):冻结按原因记录;拦截新申请及其他在途申请的复核与导出,不拦事实登记;误报经独立复核关闭不补记;证据不确定继续冻结;全部原因关闭才解冻。 +- [ ] AC11(银行终态释放,3.2;2026-10-07 补充:同一指令的终态事实可了结该指令此前的处理中回单,其他提交不能代替;最终取消释放的事务须重验全部条件,并与新提交、晚到回单串行;终态证据与付款回单分开存放,作废或被替换后不能继续作为依据):每次已登记提交均有能唯一定位指令、经相应模式确认的终态证据,且无未解决的成功或处理中事实,才可转 `confirmed_not_paid` 并释放;对账单、截图、时间依据与等待期均不能单独放行;释放后禁止再导出。 +- [ ] AC12(人工确认未提交,3.2a,用户 2026-10-06 确认支持):仅独立模式在全部证据加两名不同的独立复核人满足时转 `confirmed_not_submitted`,记录与页面标为人工确认;人员不足或材料缺失时继续占用。关联模式不启用 Q6。 +- [ ] AC13(冻结,3.5):冻结按原因记录;拦截新申请及其他在途申请的复核与导出,不拦事实登记;误报经相应模式确认后关闭不补记;证据不确定继续冻结;全部原因关闭才解冻。 - [ ] AC14(晚到成功,3.4):只增不改并标记原结论被推翻;按冻结快照与历史审批核对;覆盖已释放与已确认失败未释放;逐笔先处理占用冲突再按差额结清,余额列待追回;补记不生成可导出指令,整笔超额也完整记录;同一交易不能两次使用。 -- [ ] AC15(追回,3.5):凭到账证据经独立复核登记,支持部分追回;仅登记不解冻;本期不支持冲抵。 +- [ ] AC15(追回,3.5):凭到账证据经相应模式确认登记,支持部分追回;仅登记不解冻;本期不支持冲抵。 - [ ] AC16(存量):历史批次保留已知的导出次数,新增“历史导出汇总”标明已知与待核实信息;它不是一次真实导出或银行提交,不能据此放行额度;上线前须核实生产存量(S0)。 -- [ ] AC17(补证恢复,SR1–SR3;方案已批准,后端验证见 implement):已确认失败或未付款且本金仍全部占用时,可补充有效证据并重新独立复核;原结论与依据不可改,旧证据/复核可追溯;恢复不得重开付款、导出或改变本金,不适用于已释放/已结清、成功事实或 Q6 人工未提交。 -- [ ] AC18(恢复批准有效性,SR2–SR4;后端验证见 implement):材料、核验版本、提交清单、归属或同额候选变化使旧恢复批准失效;新建再撤回、换回同一流水号、撤回/驳回恢复均不能复活旧批准或回退原释放依据;补证后能重新送审,不永久卡死;当前复核人合格且与全部相关经办身份独立。 +- [ ] AC17(补证恢复,SR1–SR3;既有实现验证见 implement):已确认失败或未付款且本金仍全部占用时,可补充有效证据并重新按相应模式确认;原结论与依据不可改,旧证据/复核可追溯;恢复不得重开付款、导出或改变本金,不适用于已释放/已结清、成功事实或 Q6 人工未提交。 +- [ ] AC18(恢复批准有效性,SR2–SR4;既有实现验证见 implement):材料、核验版本、提交清单、归属或同额候选变化使旧恢复批准失效;新建再撤回、换回同一流水号、撤回/驳回恢复均不能复活旧批准或回退原释放依据;补证后能重新送审,不永久卡死;当前确认人合格且不属于任何相关经办身份,关联模式仅例外允许指定收款人身份。 - [ ] AC19(最终恢复释放,SR3–SR5;后端验证见 implement):确认与最终取消均满足 S3 全部条件及等待期;最终事务重验最新恢复批准与证据,释放、取消、消费批准、审计原子且幂等;与证据/提交/核验/候选变化确定性交错时不错误释放;成功事实转对账、未知继续占用。PC/手机完整操作与真实银行接受性分别验收。 +- [x] AC20(关联范围):仅经登记确认的公司、个人出借人、关联员工及借款范围可新建关联模式还款;模式由服务端核定并冻结,伪造模式/授权/公司/借款、身份未核实或范围不符均拒绝;旧独立批次、报销与差旅不变。 +- [x] AC21(两人分工):财务可合并经办角色;指定马经理可以代表公司确认本人的还款,但不能兼任本批经办/提交/导出/上传/录入/提案人。分别测试各冲突与多角色、多账号;不能把其从所有排除列表整体删除,技术员工始终无审批及敏感读取权限。 +- [x] AC22(真实留痕):所有人工决定及审计记录模式、授权版本、确认人的关联身份和证据快照;详情、待办和最终证据重建一致,不把关联方确认显示为独立复核;已决定记录不可改、不可删除、不可事后换模式或授权。 +- [x] AC23(完整接续):以财务和收款经理两个合成身份贯通付款、提交清单、成功/失败归属、人工回单、失败/终态释放、SR、晚到补记、误报关闭及部分/全额追回;确认入口、材料读取与最终消费均可执行。缺证、同额归属不明、冲突或重复交易仍拒绝,拒绝后本金、占用和冻结保持正确。 +- [x] AC24(Q6边界):关联模式的 Q6 发起、双审及最终释放均由服务端拒绝,页面说明不启用;直接调用或伪装旧独立批准也不能绕过。原独立模式双审反例仍通过,无回单不能释放占用。 +- [x] AC25(资格时点与并发):新申请/批准/导出校验当前付款授权;新的对账决定及最终释放校验当前对账授权;已付核销/已确认银行事实入账使用有效的历史审批与证据,不因停止新付款或原审批人事后离职而失败。新授权不能改写旧模式;授权停止、材料变化与确认/导出/释放按确定性交错验证,先后结果有明确语义。 +- [x] AC26(页面与访问):电脑和手机以现有组件走通两人流程;财务无自审按钮,经理无经办按钮,必要材料按本批范围及用途审计读取;技术员工不获候选、待办、通知或材料。前端隐藏不能替代服务端鉴权。 +- [ ] AC27(数据与回退):仅新增型迁移,历史记录明确归为独立模式且不伪造授权;新字段、外键、模式/关联身份及不可变约束在数据库验收。新增关联决定后禁止不经评估降回不理解该模式的旧代码;最终候选重新验证并按规则只读复核,不沿用旧门禁宣称通过。 + ## Out of Scope -- 不处理利息、工资/分红、员工向公司借备用金、资本金、债转股、机构借款、外币、退款或债务冲销。 +- 不处理利息、工资/分红、员工向公司借备用金、资本金、债转股、机构借款、外币、一般退款或债务冲销;仅保留本任务已定义的超额还款实际追回,不做冲抵后续本金。 - 不做银行直连、流水自动导入、AI自然语言财务问答、自动会计凭证/税务、跨系统重复审批或全量ERP。 - 不伪造报销单承载本金,不改现有报销一单一笔付款规则,不解冻其他低频模块,不引入新视觉设计体系。 - 不索取聊天中的完整账号或真实凭证,不读取/轮换生产密钥,不沿用旧恢复/部署/真实付款授权。 ## Test Plan -真实Fastify路由与隔离PostgreSQL覆盖AC1–AC6的正反例、并发和事务回滚;前端沿现有Vitest/Playwright以合成财务、审批人、收款人和普通员工验证PC/手机权限/动作/余额状态。跨业务重复交易与报销回归单列;仅云端响应可在合成测试中替换,不用预签身份替代正式认证全链。执行端承担软件验证,不让用户制造真实还款来代测。 +真实Fastify路由与隔离PostgreSQL覆盖资金、身份、并发和事务回滚;前端沿现有Vitest/Playwright验证PC/手机。独立模式保留合成财务、独立审批人、收款人的基线;关联模式使用财务与收款经理两名真实角色的合成身份,技术员工只作无权访问反例,不让实际员工参与财务复核。跨业务重复交易与报销回归单列;OCR沿真实识别/归一化/匹配链,仅替换云端返回,不用预签身份替代正式认证全链。关键资金守卫配定向变异和确定性交错,按 implement 的 RP1–RP4 执行。执行端承担软件验证,不让用户制造真实还款来代测。 ## Current Status -T1–T4本地合成实施已完成,AC1–AC4、AC6–AC7有本地证据;AC5真实样本及AC8独立/恢复/发布条件保留开放。精确回执见implement顶部。2026-09-27 已随 `70af0c6b` 上线(借款迁移已应用);生产是否已有真实还款待核实,不声称真实银行接受或真实批次已通知。2026-10-05 同模型只读复核报出阻断 B1(在途还款被出借人维护卡死)与回单批次归属缺陷,处理进展见 implement 顶部;真实还款验收在其收口前不得进行。 +本地已有 A/B/C、S1–S8、SR恢复及电脑/手机界面,本轮另完成关联方模式 RP1–RP4 的实现与可执行验证,已获准本地提交及新候选正式验证。最新借款专项为82迁移、HTTP 109、浏览器33、数据库约束9组通过;关联模式单列14组HTTP及电脑/手机各7组浏览器,13个有效守卫变异均捕获。报销付款、付款权限和导航35项通过,现行差旅冻结保护另2项通过;一次误选封存差旅整链的403失败保留,未恢复差旅权限。服务端全量4519通过、1项原有跳过,后续小改的77项定向验证通过,详细先后和证据见implement。AC20–AC26仅完成本地合成验收;AC5、AC8、AC27及其余整体业务验收保持开放。 + +较早候选 `4c37fcee` 的正式门禁 PASS 只证明旧独立模式;本轮提交后单独生成 `verify-candidate` 回执,不提前宣称通过,主会话检查也不等同独立代码复核。不得以测试数字推定真实银行接受性或生产已经应用。 + +生产曾于2026-09-27上线早期版本;上述后续本地提交尚未推送或部署。2026-10-07 22:08–22:09获准只读核实时,OA借款相关记录为0,已应用73迁移;这不是银行/线下无付款的结论,发布前仍需重新获准核对存量及迁移差集。人员条件现已明确为本节两人安排,独立模式无人可办理本笔还款;后续为本轮独立代码复核、真实材料及授权录入验收、固定候选门禁和获准发布,不再将关联模式、S8或SR列为未实现。 ## Planning Status(历史时点,已由顶部实施接续替代) diff --git a/client/src/api/companyLoans.ts b/client/src/api/companyLoans.ts index 496b9509..3f8af433 100644 --- a/client/src/api/companyLoans.ts +++ b/client/src/api/companyLoans.ts @@ -18,6 +18,14 @@ export type CompanyLoan = { evidences: LoanEvidence[]; accountingConfirmationRef: string | null; }; export type LoanRepayment = LoanReconciliation & { + approvalMode?: "independent" | "related_party"; authorizedApproverId?: string | null; + paymentDecision?: "approve" | "reject" | null; + authorizationId?: string | null; authorizationVersion?: number | null; + actionCapabilities?: null | { + canPrepare: boolean; canRecordFacts: boolean; canApprovePayment: boolean; canExport: boolean; + canReviewReconciliation: boolean; canRelease: boolean; canReadPayeeAccount: boolean; + canUseNotSubmitted: boolean; paymentAuthorized: boolean; reconciliationAuthorized: boolean; + }; id: string; repaymentNo: string; lenderId: string; lenderName: string; amount: string; reason: string; bankResultState: string; paymentBatchId: string; status: string; accountingHandoffRef: string | null; accountingHandoffAt: string | null; @@ -58,8 +66,23 @@ export function uploadLoanEvidence(id: string, kind: string, file: File) { const body = new FormData(); body.append("file", file); return apiRequestData(`/api/company-loans/${id}/evidences/${kind}`, { method: "POST", body }); } -export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string) { - return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId }) }); +export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string, authorizationId?: string) { + return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId, authorizationId }) }); +} + +export type LoanRelatedAuthorization = { + id: string; lenderId: string; legalEntityId: string; approverId: string; version: number; + basis: string; proposedBy: string; proposedAt: string; loanIds: string[]; + confirmed: boolean; current: boolean; paymentEnabled: boolean; reconcileEnabled: boolean; + lenderName: string; legalEntityName: string; approverName: string; + actions: { confirm: boolean; stopPayment: boolean; stopReconcile: boolean }; + events: Array<{ id: string; action: string; actorId: string; reason: string; createdAt: string }>; +}; +export function fetchLoanRelatedAuthorizations(page: number, signal?: AbortSignal) { + return apiRequestData<{ items: LoanRelatedAuthorization[]; total: number }>(`/api/loan-related-authorizations?page=${page}&pageSize=30`, { signal }); +} +export function fetchLoanRelatedAuthorization(id: string, signal?: AbortSignal) { + return apiRequestData(`/api/loan-related-authorizations/${id}`, { signal }); } export type LoanReconciliation = { diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index 604e254f..621ff76e 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -1,11 +1,15 @@ export const ERROR_REASON_MESSAGES: Record = { - "loan.bank_result_reconcile_required": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。", + "loan.related_authorization_required": "本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。", + "loan.related_scope_invalid": "关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。", + "loan.related_actor_conflict": "关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。", + "loan.related_q6_unavailable": "关联方模式不支持人工确认未提交;没有符合要求的银行终态证据时,额度继续占用。", + "loan.bank_result_reconcile_required": "银行对账事实、归属或证据尚不满足条件,请补齐材料并按本批次规则完成确认;占用或冻结继续保留。", "loan.lender_frozen": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。", "loan.not_submitted_invalid": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。", - "loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。", + "loan.recovery_required": "释放依据已进入补证流程,须按本批次规则完成最新一轮确认后才能取消释放。", "loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。", "loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。", - "loan.recovery_stale": "补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。", + "loan.recovery_stale": "补证材料、提交清单或候选批次已变化,请重新发起恢复并按本批次规则完成确认。", "integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。", "loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。", @@ -21,17 +25,17 @@ export const ERROR_REASON_MESSAGES: Record = { "loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。", "loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。", "loan.reviewer_required": "没有符合独立复核要求的经理或总经理;制单人、提交人和收款人不能复核。", - "loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。", + "loan.independent_review_required": "当前人员不符合本批次确认规则,请核对业务权限、确认模式及经办身份冲突。", "loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。", "loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。", "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", "loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。", - "loan.receipt_attribution_required": "该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。", + "loan.receipt_attribution_required": "该回单可能属于同一出借人的其他同金额还款,须登记归属依据并按本批次规则完成确认,确认前不能核销或释放额度。", "loan.receipt_attribution_not_required": "本还款没有同金额的其他候选批次,无需确认回单归属。", "loan.receipt_attribution_unavailable": "尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。", "loan.receipt_attribution_exists": "本还款已有待复核或已通过的回单归属确认,请勿重复提交。", "loan.submission_required": "须先登记本批次实际向银行提交的指令,才能确认提交清单。", - "loan.submission_list_unverified": "银行提交清单尚未经独立复核,或复核后已有变化,须先完成提交清单复核。", + "loan.submission_list_unverified": "银行提交清单尚未完成有效确认,或确认后已有变化,须按本批次规则重新确认清单。", "loan.submission_history_unverified": "该批次为历史批次,导出明细及与银行提交的对应关系待核实,当前不能确认失败或释放额度。", "loan.multiple_submissions_unresolved": "该批次有多次导出或多次银行提交,须逐次取得终态证据,当前不能确认失败。", "loan.voided_receipt_unresolved": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。", diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts index 47c19940..5758eb97 100644 --- a/client/src/api/paymentBatches.ts +++ b/client/src/api/paymentBatches.ts @@ -51,11 +51,12 @@ export type PaymentBatch = { thresholdAmount: string; requiredReviewCount: number; completedReviewCount: number; - nextStep: "first_review" | "ready_for_execute"; + nextStep: "first_review" | "ready_for_execute" | "returned"; makerCheckerRequired: boolean; reviewerPermission: string; }; reviewComment: string | null; + relatedPaymentDecision?: "approve" | "reject" | null; secondaryReviewComment: string | null; executionSummaryJson: Record | null; receiptAttachmentCount?: number; @@ -202,7 +203,7 @@ export async function updatePaymentBatchItem(batchId: string, itemId: string, pa ); } -export async function reviewPaymentBatch(batchId: string, payload: { comment?: string; resolveRiskFlagIds?: string[] } = {}) { +export async function reviewPaymentBatch(batchId: string, payload: { decision?: "approve" | "reject"; comment?: string; resolveRiskFlagIds?: string[] } = {}) { return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}/review`, { method: "POST", body: JSON.stringify(payload), @@ -310,7 +311,7 @@ export type ReceiptVerificationDetail = { pagesExpected: number; pages: ReceiptVerificationPage[]; proposal: null | { - id: string; status: string; proposedBy: string; reviewedBy: string | null; reason: string; + id: string; status: string; proposedBy: string; reviewedBy: string | null; reason: string; approvalMode?: "independent" | "related_party"; changedFields: { pageNumber: number; fields: string[] }[]; pages: ReceiptVerificationPage[]; }; diff --git a/client/src/components/common/LoanBankReconciliation.tsx b/client/src/components/common/LoanBankReconciliation.tsx index 28333ed8..36daaf43 100644 --- a/client/src/components/common/LoanBankReconciliation.tsx +++ b/client/src/components/common/LoanBankReconciliation.tsx @@ -16,8 +16,11 @@ const actions: Record = { downloadReceipt: "查看对应付款 export function LoanBankReconciliation({ repayment: r, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) { const { me, permissions } = useAuth(); const cache = useQueryClient(); - const finance = !!me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role)) && permissions.includes("loan.manage"); - const reviewer = !!me?.roleCodes.some((role) => ["manager", "ceo"].includes(role)) && permissions.includes("payment.batch.review"); + const related = r.approvalMode === "related_party"; + const finance = related ? r.actionCapabilities?.canRecordFacts === true : !!me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role)) && permissions.includes("loan.manage"); + const reviewer = related ? r.actionCapabilities?.canReviewReconciliation === true : !!me?.roleCodes.some((role) => ["manager", "ceo"].includes(role)) && permissions.includes("payment.batch.review"); + const actionLabel = (name: string) => related ? actions[name].replaceAll("独立复核", "公司代表确认").replaceAll("复核", "公司代表确认") : actions[name]; + const stateLabel = (status: string) => related && ["proposed", "approved"].includes(status) ? status === "proposed" ? "待公司代表确认" : "公司代表已确认" : states[status]; const [details, setDetails] = useState> | null>(null); const [action, setAction] = useState(null); const [target, setTarget] = useState(""); const [pendingIds, setPendingIds] = useState([]); @@ -58,7 +61,7 @@ export function LoanBankReconciliation({ repayment: r, attachments }: { repaymen if (action === "apply") await saveLoanAction(`${base}/loan-bank-results/${target}/apply`, {}); }, onSuccess: async () => { if (action === "details") return; setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); } }); function open(name: string, id = "") { setAction(name); setTarget(id); setValues({}); setPendingIds([]); setDetails(null); setFile(null); mutation.reset(); } - const button = (name: string, id = "") => ; + const button = (name: string, id = "") => ; const input = (key: string, label: string, type = "text") => ; const select = (key: string, label: string, choices: Array<[string, string]>, required = true) => ; const evidenceSelect = (key: string, label: string, kind: string, required = true) => select(key, label, evidences.filter((e) => !e.voided && e.evidenceKind === kind).map((e) => [e.id, e.originalName]), required); @@ -76,24 +79,25 @@ export function LoanBankReconciliation({ repayment: r, attachments }: { repaymen

等待期只是内部谨慎期,不能证明银行未付款。线下核对不能直接解除系统占用。

{exports.map((e) =>

导出 {e.sequence} · {formatDisplayDate(e.exportedAt)} · 客户方流水号 {e.customerSerials.join("、")}

)} {submissions.map((s) =>
指令 {s.bankInstructionRef} · {exportLabel(s.exportId)} · {formatDisplayDate(s.submittedAt)} · {s.note} · {s.status === "active" ? "已登记" : "已撤回登记(不代表银行撤销)"}{finance && s.status === "active" && r.status !== "cancelled" ? button("withdrawSubmission", s.id) : null}
)} - {list ?

提交清单:{states[list.status]}{list.stale ? ",已变化,须重新确认复核" : ""}

{reviewer && list.status === "proposed" && !list.stale && list.proposedBy !== me?.id ? button("reviewList", list.id) : null}
: null} + {list ?

提交清单:{stateLabel(list.status)}{list.stale ? ",已变化,须重新确认复核" : ""}

{reviewer && list.status === "proposed" && !list.stale && list.proposedBy !== me?.id ? button("reviewList", list.id) : null}
: null}
{finance && ["reviewed", "failed", "cancelled"].includes(r.status) ? button("upload") : null}{finance && r.status !== "cancelled" ? <>{button("submission")}{button("attest")}{button("terminal")}{button("failureAttribution")} : null}{reviewer && r.bankResultState === "unknown" ? button("confirm") : null}
{evidences.map((e, evidenceIndex) =>
证据 {evidenceIndex + 1} · {categories[e.evidenceKind] ?? "银行证据"}:{e.originalName}{e.voided ? "(已作废)" : ""}{button("download", e.id)}{finance && !e.voided && r.status !== "cancelled" ? button("void", e.id) : null}
)} - {r.terminal?.facts.map((f, i) =>
终态事实 {i + 1}:{states[f.status]} · 指令 {submissionLabel(f.submissionId)} · {f.kind === "failure_receipt" ? "失败回单" : ({ failed: "已失败", revoked: "已撤销", rejected: "已拒绝" }[f.terminalStatus] ?? f.terminalStatus)} · {f.note}{f.evidenceId ? evidenceLink(f.evidenceId) : null}{f.receiptAttachmentId ? receiptLink(f.receiptAttachmentId) : null}{f.resolvesAttachmentIds.length ? 了结处理中回单: : null}{f.resolvesAttachmentIds.map((id) => {receiptLink(id)})}{reviewer && f.status === "proposed" && f.proposedBy !== me?.id ? button("reviewFact", f.id) : null}
)} - {r.failureAttributions?.map((a) =>

失败归属:{states[a.status]} · {a.basisNote}

{a.exclusionNote}

归属范围:{a.failureScope === "submission" ? "指定提交" : "本批次提交清单"};覆盖指令:{a.coveredSubmissionIds.map(submissionLabel).join("、")}

{a.receiptAttachmentIds.map((id) => {receiptLink(id)})}{reviewer && a.status === "proposed" && a.proposedBy !== me?.id ? button("reviewFailure", a.id) : null}
)} - {q6 ?

人工确认未提交:{states[q6.status]} · {q6.payload.note} · 覆盖 {formatDisplayDate(q6.payload.coverageFrom)} 至 {formatDisplayDate(q6.payload.coverageThrough)} · 已有 {q6.reviews.filter((x) => x.decision === "approve").length} 人同意{q6.stale ? ";材料已变化,须重新登记及双人复核" : ""}

覆盖导出:{q6.payload.exportIds.map(exportLabel).join("、")}

查询:{evidenceLink(q6.payload.queryEvidenceId)}对账单:{evidenceLink(q6.payload.statementEvidenceId)}{q6.payload.exporterStatements.map((x, i) => 导出人 {i + 1}({exports.filter((e) => e.exportedBy === x.exporterId && q6.payload.exportIds.includes(e.id)).map((e) => exportLabel(e.id)).join("、")}):{evidenceLink(x.evidenceId)})}
{reviewer && q6.status === "proposed" && !q6.stale && q6.proposedBy !== me?.id && !q6.reviews.some((x) => x.reviewedBy === me?.id) ? button("reviewQ6", q6.id) : null}
: null} - {finance && unlinked.length > 0 && r.status !== "cancelled" && (!q6 || q6.stale || q6.status === "rejected") ? button("q6") : null} - {current ?

补证恢复第 {current.sequence} 轮:{states[current.status]}{current.stale ? ";依据已变化,须重新发起" : ""}

{finance && current.status === "collecting" ? button("submitRecovery", current.id) : null}{finance && ["collecting", "proposed"].includes(current.status) ? button("withdrawRecovery", current.id) : null}{reviewer && current.status === "proposed" && !current.stale && r.releaseRecovery?.history[0]?.startedBy !== me?.id ? button("reviewRecovery", current.id) : null}
: null} + {r.terminal?.facts.map((f, i) =>
终态事实 {i + 1}:{stateLabel(f.status)} · 指令 {submissionLabel(f.submissionId)} · {f.kind === "failure_receipt" ? "失败回单" : ({ failed: "已失败", revoked: "已撤销", rejected: "已拒绝" }[f.terminalStatus] ?? f.terminalStatus)} · {f.note}{f.evidenceId ? evidenceLink(f.evidenceId) : null}{f.receiptAttachmentId ? receiptLink(f.receiptAttachmentId) : null}{f.resolvesAttachmentIds.length ? 了结处理中回单: : null}{f.resolvesAttachmentIds.map((id) => {receiptLink(id)})}{reviewer && f.status === "proposed" && f.proposedBy !== me?.id ? button("reviewFact", f.id) : null}
)} + {r.failureAttributions?.map((a) =>

失败归属:{stateLabel(a.status)} · {a.basisNote}

{a.exclusionNote}

归属范围:{a.failureScope === "submission" ? "指定提交" : "本批次提交清单"};覆盖指令:{a.coveredSubmissionIds.map(submissionLabel).join("、")}

{a.receiptAttachmentIds.map((id) => {receiptLink(id)})}{reviewer && a.status === "proposed" && a.proposedBy !== me?.id ? button("reviewFailure", a.id) : null}
)} + {related ?

本模式不支持“人工确认未提交”。无银行终态依据时继续占用。

: null} + {q6 ?

人工确认未提交:{stateLabel(q6.status)} · {q6.payload.note} · 覆盖 {formatDisplayDate(q6.payload.coverageFrom)} 至 {formatDisplayDate(q6.payload.coverageThrough)} · 已有 {q6.reviews.filter((x) => x.decision === "approve").length} 人同意{q6.stale ? ";材料已变化,须重新登记及双人复核" : ""}

覆盖导出:{q6.payload.exportIds.map(exportLabel).join("、")}

查询:{evidenceLink(q6.payload.queryEvidenceId)}对账单:{evidenceLink(q6.payload.statementEvidenceId)}{q6.payload.exporterStatements.map((x, i) => 导出人 {i + 1}({exports.filter((e) => e.exportedBy === x.exporterId && q6.payload.exportIds.includes(e.id)).map((e) => exportLabel(e.id)).join("、")}):{evidenceLink(x.evidenceId)})}
{!related && reviewer && q6.status === "proposed" && !q6.stale && q6.proposedBy !== me?.id && !q6.reviews.some((x) => x.reviewedBy === me?.id) ? button("reviewQ6", q6.id) : null}
: null} + {!related && finance && unlinked.length > 0 && r.status !== "cancelled" && (!q6 || q6.stale || q6.status === "rejected") ? button("q6") : null} + {current ?

补证恢复第 {current.sequence} 轮:{stateLabel(current.status)}{current.stale ? ";依据已变化,须重新发起" : ""}

{finance && current.status === "collecting" ? button("submitRecovery", current.id) : null}{finance && ["collecting", "proposed"].includes(current.status) ? button("withdrawRecovery", current.id) : null}{reviewer && current.status === "proposed" && !current.stale && r.releaseRecovery?.history[0]?.startedBy !== me?.id ? button("reviewRecovery", current.id) : null}
: null} {finance && ["confirmed_failed", "confirmed_not_paid"].includes(r.bankResultState) && r.status !== "cancelled" && (!current || current.stale || ["withdrawn", "rejected"].includes(current.status)) ? button("recovery") : null} - {finance && terminal && r.status !== "cancelled" && !r.bankResults?.events.some((e) => e.kind === "late_success" && ["proposed", "approved"].includes(e.status)) ? button("cancel") : null} + {finance && (!related || r.actionCapabilities?.canRelease) && terminal && r.status !== "cancelled" && !r.bankResults?.events.some((e) => e.kind === "late_success" && ["proposed", "approved"].includes(e.status)) ? button("cancel") : null} {terminal ?

原制单文件不得再导入网银。系统不能使已下载文件失效;出现晚到成功应立即登记对账。

: null} {finance && terminal ? button("event") : null} - {r.bankResults?.events.map((e, i) =>

{e.kind === "return" ? "追回到账" : "晚到成功"} {i + 1} · ¥{e.amount} · {states[e.status]}

{e.outstandingReturn ?

待追回 ¥{e.outstandingReturn}

: null}{e.settlements.map((s) =>

银行已付 ¥{s.bankAmount};结清本金 ¥{s.settledAmount};超额 ¥{s.receivableAmount}

)}
{button("download", e.evidenceId)}{button("details", e.id)}{reviewer && e.status === "proposed" && e.proposedBy !== me?.id ? button("reviewEvent", e.id) : null}{finance && e.status === "approved" ? button("apply", e.id) : null}{finance && e.status === "booked" && Number(e.outstandingReturn) > 0 ? button("refund", e.id) : null}
)} - { if (!next && !mutation.isPending) setAction(null); }}>{action ? actions[action] : "银行对账"}核对原始银行材料和具体指令。独立复核人不能兼任相关经办或收款人;材料不足时保持占用或冻结。人工未提交须三类材料齐全并由两名独立人员复核。 + {r.bankResults?.events.map((e, i) =>

{e.kind === "return" ? "追回到账" : "晚到成功"} {i + 1} · ¥{e.amount} · {stateLabel(e.status)}

{e.outstandingReturn ?

待追回 ¥{e.outstandingReturn}

: null}{e.settlements.map((s) =>

银行已付 ¥{s.bankAmount};结清本金 ¥{s.settledAmount};超额 ¥{s.receivableAmount}

)}
{button("download", e.evidenceId)}{button("details", e.id)}{reviewer && e.status === "proposed" && e.proposedBy !== me?.id ? button("reviewEvent", e.id) : null}{finance && e.status === "approved" ? button("apply", e.id) : null}{finance && e.status === "booked" && Number(e.outstandingReturn) > 0 ? button("refund", e.id) : null}
)} + { if (!next && !mutation.isPending) setAction(null); }}>{action ? actionLabel(action) : "银行对账"}{related ? "核对原始银行材料和具体指令。收款人以公司代表身份确认,不能兼任经办;材料不足时保持占用或冻结。人工确认未提交在本模式不可用。" : "核对原始银行材料和具体指令。独立复核人不能兼任相关经办或收款人;材料不足时保持占用或冻结。人工未提交须三类材料齐全并由两名独立人员复核。"}
{ e.preventDefault(); mutation.mutate(); }}> {details ?
{details.fields.map((f) =>

{f.Name}:{f.Value}

)}

{details.attributionNote}

{details.exclusionNote}

: null} {mutation.error ?

{resolveErrorMessage(mutation.error)}

: null} - {action === "upload" ? <>{select("category", "证据类别", Object.entries(categories))} : null} + {action === "upload" ? <>{select("category", "证据类别", Object.entries(categories).filter(([key]) => !related || !["query_no_result", "account_statement", "exporter_statement"].includes(key)))} : null} {action === "submission" ? <>{select("export", "使用的导出文件(手工录入可不选)", exports.map((e) => [e.id, `第 ${e.sequence} 次导出`]), false)}{input("reference", "银行指令号")}{input("date", "实际提交时间", "datetime-local")} : null} {action === "terminal" || action === "failureAttribution" ? <>{select("submission", "对应银行提交", submissions.filter((s) => s.status === "active").map((s) => [s.id, s.bankInstructionRef]), action === "terminal")}{select("receipt", "失败回单(终态书面证据可不选)", receiptOptions, action === "failureAttribution")}{action === "terminal" ? <>{evidenceSelect("evidence", "银行终态书面证据", "bank_terminal", !value("receipt"))}{select("terminalStatus", "银行终态", [["failed", "已失败,不会再执行"], ["revoked", "已撤销,不会再执行"], ["rejected", "已拒绝,不会再执行"]], !value("receipt"))}
了结的同指令处理中回单(可多选){receiptOptions.map(([id, label]) => )}
: input("exclusion", "排除其他候选批次的依据")} : null} {action === "q6" ? <>{evidenceSelect("query", "查询无结果官方 PDF", "query_no_result")}{evidenceSelect("statement", "同期对账单", "account_statement")}{[...new Set(unlinked.map((e) => e.exportedBy))].map((id, i) => evidenceSelect(`exporter:${id}`, `导出人 ${i + 1} 的文件去向说明`, "exporter_statement"))}{input("from", "查询与对账单覆盖开始", "datetime-local")}{input("through", "查询与对账单覆盖截至(须含登记日)", "datetime-local")} : null} diff --git a/client/src/components/common/LoanPaymentDetails.tsx b/client/src/components/common/LoanPaymentDetails.tsx index 6aad66c2..3b008e31 100644 --- a/client/src/components/common/LoanPaymentDetails.tsx +++ b/client/src/components/common/LoanPaymentDetails.tsx @@ -29,9 +29,11 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan const [decision, setDecision] = useState<"approve" | "reject">("approve"); const [text, setText] = useState(""); const [attachmentId, setAttachmentId] = useState(""); const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null); - const finance = me?.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)); + const related = repayment.approvalMode === "related_party"; + const finance = related ? repayment.actionCapabilities?.canRecordFacts === true : me?.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)); const loanManager = Boolean(finance && permissions.includes("loan.manage")); - const reviewer = me?.roleCodes.some((r) => ["manager", "ceo"].includes(r)); + const reviewer = related ? repayment.actionCapabilities?.canReviewReconciliation === true : me?.roleCodes.some((r) => ["manager", "ceo"].includes(r)); + const reviewLabel = (label: string) => related ? label.replaceAll("独立复核", "公司代表确认") : label; const mutation = useMutation({ mutationFn: async () => { if (action === "handoff") await saveLoanAction(`/loan-repayments/${repayment.id}/accounting-handoff`, { reference: text }); if (action === "failure") await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/bank-failure-review`, { attachmentId }); @@ -54,16 +56,19 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan const lateBankFact = repayment.bankResults?.events.some((e) => e.kind === "late_success" && ["proposed", "approved", "booked"].includes(e.status)); const overturned = repayment.bankResults?.events.some((e) => e.kind === "late_success" && e.status === "booked"); return
+ {related ?

公司代表关联方确认 · 授权第 {repayment.authorizationVersion} 版

收款人以公司代表身份确认;财务负责制单、付款和记录。经办人不能确认自己提供的依据。

+ {repayment.actionCapabilities?.paymentAuthorized === false ?

新付款授权已停止或范围不再覆盖,不能继续批准或导出。

: null} + {repayment.actionCapabilities?.reconciliationAuthorized === false ?

当前对账授权不可用,新的确认和释放占用暂停;已发生的银行事实仍可登记。

: null}
: null}

原借款分配与会计交接

{repayment.reason}

{repayment.bankResultState === "unknown" ?

银行结果待查证;本金保持占用,不能取消释放或重复付款。

: null} {overturned ?

原未付款结论已被晚到成功推翻;实际付款、结清本金与待追回款见银行结果对账。

: null} - {repayment.bankResultState === "confirmed_failed" && !lateBankFact ?

银行失败证据已由独立人员确认。请财务取消本申请释放额度,保留原记录。

: null} + {repayment.bankResultState === "confirmed_failed" && !lateBankFact ?

{related ? "银行失败证据已由公司代表确认。" : "银行失败证据已由独立人员确认。"}请财务取消本申请释放额度,保留原记录。

: null} {attribution?.required && payable ?

回单归属待确认:同一出借人另有 {attribution.competitors.length} 笔同金额还款曾制单或上传回单({attribution.competitors.map((item) => item.batchNo).join("、")})。金额与账号一致不能证明回单属于本批次,确认前不能核销或释放额度。同一出借人同金额的每一期还款都需单独确认归属;存在同金额候选时,失败回单须另外确认归属,并满足完整的银行终态释放条件。

{attribution.current ?
-

归属确认:{attributionStatusLabels[attribution.current.status]}{attribution.current.stale ? "(回单或候选批次已变化,须重新登记并复核)" : ""}

+

归属确认:{related ? ({ proposed: "待公司代表确认", approved: "公司代表已确认", rejected: "已驳回", superseded: "已失效(已被新登记取代)" }[attribution.current.status]) : attributionStatusLabels[attribution.current.status]}{attribution.current.stale ? "(回单或候选批次已变化,须重新登记并复核)" : ""}

依据:{attributionBasisLabels[attribution.current.basisKind]};{attribution.current.basisNote}

排除其他批次的理由:{attribution.current.exclusionNote}

登记:{attribution.current.proposedByName ?? "—"}{attribution.current.reviewedByName ? ` · 复核:${attribution.current.reviewedByName}` : ""}{attribution.current.reviewNote ? `(${attribution.current.reviewNote})` : ""}

@@ -71,7 +76,7 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan
{attribution.receiptAvailable === false ?

尚无已核验的银行回单,须先完成回单识别或人工核对再登记归属。

: null} {loanManager && !openAttribution && attribution.receiptAvailable !== false ? : null} - {reviewer && attribution.current?.status === "proposed" && !attribution.current.stale && attribution.current.proposedBy !== me?.id ? : null} + {reviewer && attribution.current?.status === "proposed" && !attribution.current.stale && attribution.current.proposedBy !== me?.id ? : null}
: null} {repayment.allocations.map((allocation) =>
@@ -84,12 +89,12 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan

会计交接:{repayment.accountingHandoffRef ?? "待交接(与银行付款登记分别确认)"}

{finance && repayment.status === "executed" && !repayment.accountingHandoffAt ? : null} - {reviewer && ["reviewed", "failed"].includes(repayment.status) && repayment.bankResultState === "unknown" ? : null} + {reviewer && ["reviewed", "failed"].includes(repayment.status) && repayment.bankResultState === "unknown" ? : null}
{ if (!value && !mutation.isPending) setAction(null); }}> - {action === "handoff" ? "确认会计交接" : action === "failure" ? "独立复核银行失败证据" : - action === "attribute" ? "登记回单归属依据" : action === "attributionReview" ? "独立复核回单归属" : "下载原借款材料"} - {action === "attribute" ? "写明回单中能指向本批次的依据,以及逐一排除其他候选批次的理由。交易时间与制单时间吻合只能作辅助说明,不能单独作为依据;证据不足请保持待对账。" : action === "attributionReview" ? "核对依据能否排除其他候选批次。登记人、批次创建人、提交人、导出制单人、回单上传人和出借人关联员工不能复核。" : action === "failure" ? "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"} + {action === "handoff" ? "确认会计交接" : action === "failure" ? reviewLabel("独立复核银行失败证据") : + action === "attribute" ? "登记回单归属依据" : action === "attributionReview" ? reviewLabel("独立复核回单归属") : "下载原借款材料"} + {action === "attribute" ? "写明回单中能指向本批次的依据,以及逐一排除其他候选批次的理由。交易时间与制单时间吻合只能作辅助说明,不能单独作为依据;证据不足请保持待对账。" : action === "attributionReview" ? related ? "核对依据能否排除其他候选批次。公司代表可以同时为收款人,但不能兼任登记、创建、提交、导出、回单上传或人工录入人。" : "核对依据能否排除其他候选批次。登记人、批次创建人、提交人、导出制单人、回单上传人和出借人关联员工不能复核。" : action === "failure" ? related ? "公司代表核对明确失败的银行回单及对应指令;金额、账号和币种必须一致。经办人不得自审,未明事实继续占用。" : "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"} { event.preventDefault(); mutation.mutate(); }}> {mutation.error ?

{resolveErrorMessage(mutation.error)}

: null} {action === "attribute" ? <> diff --git a/client/src/components/common/LoanRelatedAuthorizations.tsx b/client/src/components/common/LoanRelatedAuthorizations.tsx new file mode 100644 index 00000000..aae0f131 --- /dev/null +++ b/client/src/components/common/LoanRelatedAuthorizations.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "../../auth/AuthContext"; +import { downloadLoanEvidence, fetchCompanyLoans, fetchLoanLenders, fetchLoanRelatedAuthorization, + fetchLoanRelatedAuthorizations, saveLoanAction, type LoanRelatedAuthorization } from "../../api/companyLoans"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; +import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; +import { downloadBlob } from "../../lib/download"; + +const labels = { propose: "登记公司代表授权范围", confirm: "代表公司确认授权范围", stop_payment: "停止新付款授权", stop_reconcile: "停止对账授权", details: "查看授权范围与材料", download: "查看授权借款材料" }; +type Action = keyof typeof labels; + +export function LoanRelatedAuthorizations({ finance }: { finance: boolean }) { + const { me } = useAuth(); const cache = useQueryClient(); + const [page, setPage] = useState(1); const [lenderPage, setLenderPage] = useState(1); const [loanPage, setLoanPage] = useState(1); + const [action, setAction] = useState(null); const [selected, setSelected] = useState(null); + const [lenderId, setLenderId] = useState(""); const [loanIds, setLoanIds] = useState([]); const [reason, setReason] = useState(""); + const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null); + const [requestKey, setRequestKey] = useState(() => crypto.randomUUID()); + const records = useQuery({ queryKey: ["loan-related-authorizations", me?.id, page], queryFn: ({ signal }) => fetchLoanRelatedAuthorizations(page, signal) }); + const lenders = useQuery({ queryKey: ["loan-lenders", me?.id, "authorization", lenderPage], queryFn: ({ signal }) => fetchLoanLenders(lenderPage, signal), enabled: finance && action === "propose" }); + const loans = useQuery({ queryKey: ["company-loans", me?.id, "authorization", lenderId, loanPage], queryFn: ({ signal }) => fetchCompanyLoans(loanPage, lenderId, signal), enabled: finance && action === "propose" && Boolean(lenderId) }); + const detail = useQuery({ queryKey: ["loan-related-authorizations", me?.id, selected?.id], queryFn: ({ signal }) => fetchLoanRelatedAuthorization(selected!.id, signal), enabled: Boolean(selected && action && action !== "propose"), staleTime: 0 }); + const mutation = useMutation({ mutationFn: async () => { + if (action === "propose") await saveLoanAction("/loan-related-authorizations", { lenderId, loanIds, basis: reason, requestKey }); + else if (action === "download" && selected && evidence) { + const result = await downloadLoanEvidence(evidence.loanId, evidence.id, reason, undefined, selected.id); + downloadBlob(result.blob, result.filename ?? evidence.name); + } else if (selected && action !== "details") await saveLoanAction(`/loan-related-authorizations/${selected.id}/actions`, { action, reason }); + }, onSuccess: async () => { + setAction(null); + await Promise.all(["loan-related-authorizations", "payment-batches", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); + } }); + function open(next: Action, record: LoanRelatedAuthorization | null = null) { + mutation.reset(); setAction(next); setSelected(record); setReason(""); setLoanIds([]); setLenderId(""); setLoanPage(1); setRequestKey(crypto.randomUUID()); + } + const error = mutation.error ?? records.error ?? detail.error ?? lenders.error ?? loans.error; + return
+

财务登记公司、出借人和具体借款范围;收款人以公司代表身份确认。这是关联方确认,经办与确认仍由两人分别承担。

+ {finance ? : null} + {error && !action ?

{resolveErrorMessage(error)}

: null} + {records.isPending ?

加载授权范围…

: null} + {records.isSuccess && records.data.items.length === 0 ?

暂无可查看的授权范围。

: null} + {records.data?.items.map((record) =>
+

{record.legalEntityName} · {record.lenderName} · 授权第 {record.version} 版

+

公司代表:{record.approverName}(同时为收款人) · {record.loanIds.length} 笔借款

+

{!record.confirmed ? "待公司代表确认" : !record.current ? "历史版本,已被取代" : `当前范围 · 新付款${record.paymentEnabled ? "可用" : "已停止"} · 对账${record.reconcileEnabled ? "可用" : "已停止"}`}

+

依据:{record.basis}

+
+ + {record.actions.confirm ? : null} + {record.actions.stopPayment ? : null} + {record.actions.stopReconcile ? : null} +
+
)} +
第 {page} 页
+ { if (!opened && !mutation.isPending) setAction(null); }}> + {action ? labels[action] : "公司代表授权"} + {action === "stop_payment" ? "停止后不能新申请、批准或导出付款;已经发生的付款仍可核销和对账。" : action === "stop_reconcile" ? "停止后不能作出新的对账确认或最终释放占用。历史批准与银行事实保留,财务仍可登记已发生的事实。" : "逐笔核对授权范围和原借款材料。该授权只允许收款人代表公司确认,不允许兼任本单经办;人工确认未提交在此模式不可用。"} + { event.preventDefault(); mutation.mutate(); }}> + {error ?

{resolveErrorMessage(error)}

: null} + {action === "propose" ? <> + +
+
具体借款范围(已选 {loanIds.length} 笔) + {loans.data?.items.filter((l) => l.confirmationStatus === "confirmed").map((loan) => )} +
+ {lenderId ?
: null} + : action !== "download" ? <> + {detail.isPending ?

加载范围材料…

: null} + {detail.data?.loans.map((loan) =>

{loan.loanNo} · {loan.originReference} · 未还本金 ¥{loan.outstanding}

+

{loan.lender.legalEntityName} · {loan.lender.name} · {loan.lender.bankAccountMasked}

+ {loan.evidences.filter((e) => !e.archivedAt).map((e) => )} +
)} + {detail.data?.events.map((e) =>

{labels[e.action as Action] ?? e.action}:{e.reason}

)} + : null} + {action !== "details" ? <> + + + : null} + +
+
+
; +} diff --git a/client/src/components/common/PaymentReceiptVerificationDialog.tsx b/client/src/components/common/PaymentReceiptVerificationDialog.tsx index ff28f18d..18875ce8 100644 --- a/client/src/components/common/PaymentReceiptVerificationDialog.tsx +++ b/client/src/components/common/PaymentReceiptVerificationDialog.tsx @@ -28,7 +28,7 @@ function PageFacts({ page }: { page?: ReceiptVerificationPage }) { type Props = { actorId: string; batchId: string; attachmentId: string; makerId: string | null; - canManage: boolean; canReview: boolean; + canManage: boolean; canReview: boolean; relatedParty?: boolean; items: { id: string; label: string }[]; onClose: () => void; onChanged: () => void; }; @@ -122,7 +122,7 @@ export function PaymentReceiptVerificationDialog(props: Props) { if (editing[pageNumber]) setChanges((current) => ({ ...current, [pageNumber]: {} })); }}>{editing[pageNumber] ? "取消本页更正" : "更正识别"} {editing[pageNumber] ?
-

只填写要更正的字段,其他字段保留原识别值。更正后需要独立复核。

+

只填写要更正的字段,其他字段保留原识别值。更正后需要{props.relatedParty ? "公司代表确认" : "独立复核"}。

{FIELD_NAMES.map((name) =>