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 923d0193..4c63f77d 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -1,3 +1,27 @@ +## 2026-10-06 C 回单批次归属保守拦截(用户批准实施;本地合成,未提交、未部署) + +规则:同一出借人、同金额的其他借款批次只要曾导出制单或上传回单(含确认失败后已取消的),本批次的匹配回单就不能证明归属。 +- 最终拦截在 `settleLoanRepayment`(`assertReceiptAttributed`):执行事务内 `claimVerifiedReceipts` 已为本项生成交易指纹认领,OCR 匹配与已通过的人工回单复核都经过此处;只有绑定同一交易指纹、且覆盖当前全部候选批次的已复核归属确认才能放行,否则 409 `loan.receipt_attribution_required`,本金与占用不变。 +- 确认银行失败:存在候选批次即 409,不释放占用(失败回单暂无归属确认路径,留待对账流程设计)。 +- 归属确认与人工回单复核分离(新表 `loan_receipt_attributions`,迁移 `20261006010000_loan_receipt_attribution`):财务登记依据类型(客户方流水号、附言含批次号、银行书面查询结果、其他候选均已对应其他交易或确认未付;不含“时间吻合”)+ 依据说明 + 排除其他候选的理由;绑定 `claimVerifiedReceipts(readOnly)` 计划认领的交易指纹和当时候选集合。独立复核人须当前有资格,且不是登记人、制单人、提交人或出借人关联员工;批准时回单证据或候选集合变化即拒绝。出现新候选后原批准失效。DB:每批次至多一条待复核/已通过(部分唯一索引)、复核人≠登记人、已复核记录与提案字段不可改、禁止删除。 +- 页面:还款详情显示候选批次与归属状态,财务登记、经理/总经理独立复核;说明“时间吻合只能作辅助说明”。 +- 代价:此后同一出借人同金额的正常还款(如固定金额分期)都需走归属确认。精确关联(附言写入批次号、OCR 提取客户方流水号/附言)待获准的真实建行回单样本确认字段后再做。 + +验证: +- `RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs`:74 迁移,HTTP 27 组、浏览器 5 组、DB 约束全部 PASS。原两个复现用例改为回归断言:错误回单执行 409 且本金/占用不变;C2 经 OCR 与经人工回单复核均被拦。新增:正确回单在有候选时同样需归属确认、确认后核销成功;重复登记 409;“时间吻合”类型 400;登记人兼经理不能自审、驳回后仍拦;新候选出现使批准失效;歧义失败回单不能释放;无候选时不需也不接受归属确认;已复核记录 DB 层不可改/删。 +- 5 个变异(去掉核销拦截、候选排除已取消批次、去掉失败拦截、覆盖校验恒真、允许登记人复核)均变红,源码已恢复。 +- server 4456 通过;prisma validate、client typecheck/build、UI strict、错误码合同通过。 +- 首轮同模型只读复核:BLOCK——已批准的归属一旦因新候选或换回单失效,既不能重新登记(exists)、也不能改(触发器)、又因已导出不能取消,占用永久卡死并阻塞出借人维护(复核人副本实测成立)。已修:新增终态 `superseded`(及 `superseded_at`),重新登记时同一事务内把失效的待复核/已通过记录标为已取代;触发器只放行 proposed→approved/rejected/superseded 与 approved→superseded,复核字段不可改。同轮按非阻断意见补:复核人另排除导出制单人与所认领回单的上传人;`receipt-readiness` 在需归属确认时返回 `ready:false, receiptAttributionRequired:true`;状态返回 `stale`(回单或候选已变化);页面在失效时允许重新登记、财务按钮要求 `loan.manage`,并写明同额分期每期都需确认、存在同额候选时失败回单不能释放额度须线下对账。新增回归:失效批准被重新登记取代后核销成功;换回单后旧批准不能放行、导出人兼经理不能复核、重新登记后核销成功;已通过不能改为驳回、已取代不能复活。3 个变异(不取代、复核不排除导出人、就绪不检查)均变红。 +- 第二轮同模型只读复核:PASS_WITH_NOTES,死局已解除,无阻断。按其意见加固:复核人另排除同一交易所有回单的上传人及人工回单提案人;驳回不再依赖回单(无回单时待复核记录可关闭);无已核验回单时登记返回 `loan.receipt_attribution_unavailable`,状态返回 `receiptAvailable`,页面不显示无效的登记按钮;批次详情中的归属展示在回单重建异常(如密钥不一致)时降级而非 500;触发器禁止未复核记录写入复核字段;取代审计记录原因(交易变化/候选变化)。新增回归:人工回单提案人不能复核归属、独立人员批准后核销;回单作废后待复核记录可驳回、重新登记返回 unavailable。 +- 后续项(第二轮非阻断 5):存在同额候选且银行确实失败时系统内无释放路径,需设计线下对账结论回写(如复用归属复核确认失败),与 unknown 解除、作废附件计数、晚到结果一并处理。 +- 复核其余非阻断(未处理,记录):已执行的历史同额批次永远是候选,等额分期从第二期起每期需确认,且一旦银行真实失败将无法确认失败、释放占用(与“失败归属/对账流程”一并后续设计);部分唯一索引、CHECK、触发器不在 Prisma schema 中,今后 `migrate dev`/diff 可能误报漂移(推测,仓库已有 r46 先例);`ON DELETE RESTRICT` 使带归属记录的批次/员工无法物理删除(当前无此路径)。 +- 第三轮补齐(用户转述的复核意见指出前述回执遗漏的三处): + - 证据绑定:归属记录新增 `verification_ids`、`manual_proposal_ids`(迁移内同表,提案后不可改),批准与核销均要求与当前计划认领的证据集合一致;同一交易重新扫描或重新人工录入即失效,取代审计原因记为 `receipt_evidence_changed`。核销处在执行事务内以只读方式重建计划认领比对。 + - 并发窗口:借款批次回单上传在批次锁后取出借人锁(批次→出借人,与既有链一致),与核销的候选检查串行。确定性测试:独立连接持出借人锁,两种排队顺序分别验证“核销在先→200、随后上传成功”“上传在先→核销 409”。去掉上传锁的变异使排队断言确定性变红。 + - 浏览器:PC 端两个独立登录会话完成财务登记→经理独立复核→新增同额候选后显示“须重新登记并复核”→重新登记→再次复核→核销 200;登记人看不到复核按钮、复核人看不到登记按钮。测试环境整页刷新会丢会话,改用每步新会话。 + - 验证:HTTP 32 组、浏览器 7 组、74 迁移全部 PASS;去掉证据绑定、去掉上传出借人锁两个变异均变红;server 4456、prisma validate、client typecheck/build、UI strict、错误码合同、敏感扫描、diff 检查通过。 +- 未覆盖:手机端未操作归属弹窗(与 PC 共用组件,仅 PC 浏览器覆盖);真实回单字段;独立复核仍为同模型。 + ## 2026-10-05 复核阻断 B1 修复与回单归属复现(用户批准 A/B/D/恢复手册 + C 复现调研;本地合成,未提交、未部署) 背景:同模型只读复核(页面与剩余资金流程)结论 BLOCK:在途还款期间做出借人身份/账户维护会使 identity_version 变化,之后登记付款、取消、确认失败全部被拒,已付款项永久无法登记;导出后原审批人或出借员工离职同样卡死。另报回单批次归属未校验。 diff --git a/client/src/api/companyLoans.ts b/client/src/api/companyLoans.ts index 0f0f3573..15cf8dcd 100644 --- a/client/src/api/companyLoans.ts +++ b/client/src/api/companyLoans.ts @@ -22,6 +22,18 @@ export type LoanRepayment = { bankResultState: string; paymentBatchId: string; status: string; accountingHandoffRef: string | null; accountingHandoffAt: string | null; allocations: Array<{ loanId: string; loanNo: string; amount: string; state: string; receivedDate: string; originReference: string; balanceAsOf: string; evidences: LoanEvidence[] }>; + receiptAttribution?: LoanReceiptAttributionState; +}; +export type LoanAttributionBasisKind = "customer_serial" | "bank_memo" | "bank_written_confirmation" | "competitors_resolved"; +export type LoanReceiptAttributionState = { + required: boolean; + competitors: Array<{ id: string; batchNo: string; status: string }>; + receiptAvailable: boolean | null; + current: null | { + id: string; status: "proposed" | "approved" | "rejected" | "superseded"; basisKind: LoanAttributionBasisKind; basisNote: string; exclusionNote: string; + proposedBy: string; proposedByName: string | null; proposedAt: string; reviewedByName: string | null; reviewedAt: string | null; + reviewNote: string | null; coversCurrentCompetitors: boolean; stale: boolean; + }; }; export function fetchCompanyLoans(page: number, lenderId?: string, signal?: AbortSignal) { return apiRequestData<{ items: CompanyLoan[]; total: number }>(`/api/company-loans?page=${page}&pageSize=30${lenderId ? `&lenderId=${encodeURIComponent(lenderId)}` : ""}`, { signal }); diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index f9269c7b..ce325e90 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -18,6 +18,10 @@ export const ERROR_REASON_MESSAGES: Record = { "loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。", "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", "loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。", + "loan.receipt_attribution_required": "该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。", + "loan.receipt_attribution_not_required": "本还款没有同金额的其他候选批次,无需确认回单归属。", + "loan.receipt_attribution_unavailable": "尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。", + "loan.receipt_attribution_exists": "本还款已有待复核或已通过的回单归属确认,请勿重复提交。", "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。", "config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。", "document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。", diff --git a/client/src/components/common/LoanPaymentDetails.tsx b/client/src/components/common/LoanPaymentDetails.tsx index 16f32bea..cfd28fd7 100644 --- a/client/src/components/common/LoanPaymentDetails.tsx +++ b/client/src/components/common/LoanPaymentDetails.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "../../auth/AuthContext"; -import { downloadLoanEvidence, saveLoanAction, type LoanRepayment } from "../../api/companyLoans"; +import { downloadLoanEvidence, saveLoanAction, type LoanAttributionBasisKind, type LoanRepayment } from "../../api/companyLoans"; import type { PaymentBatchReceiptAttachment } from "../../api/paymentBatches"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -10,18 +10,33 @@ import { downloadBlob } from "../../lib/download"; import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; import { formatDisplayDate } from "../../lib/displayFormat"; +const attributionBasisLabels: Record = { + customer_serial: "回单所载客户方流水号与本批次制单流水号一致", + bank_memo: "回单附言或用途载明本批次号", + bank_written_confirmation: "银行书面查询结果指明该交易对应本批次指令", + competitors_resolved: "其他候选批次均已对应其他交易或经银行确认未付款", +}; +const attributionStatusLabels = { proposed: "待独立复核", approved: "已通过独立复核", rejected: "已驳回", superseded: "已失效(已被新登记取代)" } as const; + export function LoanPaymentDetails({ repayment, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) { - const { me } = useAuth(); const cache = useQueryClient(); + const { me, permissions } = useAuth(); const cache = useQueryClient(); const active = useRef(true); useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); - const [action, setAction] = useState<"handoff" | "failure" | "download" | null>(null); + const [action, setAction] = useState<"handoff" | "failure" | "download" | "attribute" | "attributionReview" | null>(null); + const [basisKind, setBasisKind] = useState(""); const [exclusionNote, setExclusionNote] = useState(""); + 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 loanManager = Boolean(finance && permissions.includes("loan.manage")); const reviewer = me?.roleCodes.some((r) => ["manager", "ceo"].includes(r)); 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 }); + if (action === "attribute") await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/loan-receipt-attribution`, { basisKind, basisNote: text, exclusionNote }); + if (action === "attributionReview" && attribution?.current) { + await saveLoanAction(`/payment-batches/${repayment.paymentBatchId}/loan-receipt-attribution/${attribution.current.id}/review`, { decision, reason: text }); + } if (action === "download" && evidence) { const result = await downloadLoanEvidence(evidence.loanId, evidence.id, text, repayment.paymentBatchId); if (active.current) downloadBlob(result.blob, result.filename ?? evidence.name); @@ -30,12 +45,29 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan if (!active.current) return; setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); } }); - function open(next: typeof action) { setText(""); setAttachmentId(""); mutation.reset(); setAction(next); } + function open(next: typeof action) { setText(""); setAttachmentId(""); setBasisKind(""); setExclusionNote(""); setDecision("approve"); mutation.reset(); setAction(next); } + const attribution = repayment.receiptAttribution; + const openAttribution = attribution?.current && ["proposed", "approved"].includes(attribution.current.status) && !attribution.current.stale ? attribution.current : null; + const payable = ["reviewed", "failed"].includes(repayment.status); return

原借款分配与会计交接

{repayment.reason}

{repayment.bankResultState === "unknown" ?

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

: null} {repayment.bankResultState === "confirmed_failed" ?

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

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

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

+ {attribution.current ?
+

归属确认:{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})` : ""}

+
: null} +
+ {attribution.receiptAvailable === false ?

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

: null} + {loanManager && !openAttribution && attribution.receiptAvailable !== false ? : null} + {reviewer && attribution.current?.status === "proposed" && !attribution.current.stale && attribution.current.proposedBy !== me?.id ? : null} +
+
: null} {repayment.allocations.map((allocation) =>

{allocation.loanNo} · 本次归还 ¥{allocation.amount}

借入 {formatDisplayDate(allocation.receivedDate)} · 核对截至 {formatDisplayDate(allocation.balanceAsOf)}

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

{resolveErrorMessage(mutation.error)}

: null} - {action === "failure" ? +