支持财务经办与公司代表关联方还款确认(09-17-company-loan-principal-repayment)

公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。
仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。
贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。
授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。

本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。
已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。
按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。

限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。
生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
这个提交包含在:
2026-10-08 15:35:33 +08:00
父节点 4c37fceeb4
当前提交 ce11dea976
共修改 39 个文件,包含 2336 行新增和 208 行删除
@@ -16,8 +16,11 @@ const actions: Record<string, string> = { 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<Awaited<ReturnType<typeof readLoanBankResultDetails>> | null>(null);
const [action, setAction] = useState<string | null>(null); const [target, setTarget] = useState("");
const [pendingIds, setPendingIds] = useState<string[]>([]);
@@ -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 = "") => <Button key={`${name}:${id}`} type="button" variant="outline" onClick={() => open(name, id)}>{actions[name]}</Button>;
const button = (name: string, id = "") => <Button key={`${name}:${id}`} type="button" variant="outline" onClick={() => open(name, id)}>{actionLabel(name)}</Button>;
const input = (key: string, label: string, type = "text") => <label className="grid gap-2 text-sm" key={key}>{label}<Input required type={type} value={value(key)} onChange={(e) => set(key, e.target.value)} /></label>;
const select = (key: string, label: string, choices: Array<[string, string]>, required = true) => <label className="grid gap-2 text-sm" key={key}>{label}<select aria-label={label} required={required} className="min-h-11 min-w-0 w-full rounded border px-3" value={value(key)} onChange={(e) => set(key, e.target.value)}><option value="">请选择{required ? "" : "(可不选)"}</option>{choices.map(([id, title]) => <option key={id} value={id}>{title}</option>)}</select></label>;
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
<p className="text-sm">等待期只是内部谨慎期,不能证明银行未付款。线下核对不能直接解除系统占用。</p>
{exports.map((e) => <p key={e.id} className="break-words text-sm">导出 {e.sequence} · {formatDisplayDate(e.exportedAt)} · 客户方流水号 {e.customerSerials.join("、")}</p>)}
{submissions.map((s) => <div key={s.id} className="flex flex-wrap items-center gap-2 text-sm"><span>指令 {s.bankInstructionRef} · {exportLabel(s.exportId)} · {formatDisplayDate(s.submittedAt)} · {s.note} · {s.status === "active" ? "已登记" : "已撤回登记(不代表银行撤销)"}</span>{finance && s.status === "active" && r.status !== "cancelled" ? button("withdrawSubmission", s.id) : null}</div>)}
{list ? <div className="space-y-2 text-sm"><p>提交清单:{states[list.status]}{list.stale ? ",已变化,须重新确认复核" : ""}</p>{reviewer && list.status === "proposed" && !list.stale && list.proposedBy !== me?.id ? button("reviewList", list.id) : null}</div> : null}
{list ? <div className="space-y-2 text-sm"><p>提交清单:{stateLabel(list.status)}{list.stale ? ",已变化,须重新确认复核" : ""}</p>{reviewer && list.status === "proposed" && !list.stale && list.proposedBy !== me?.id ? button("reviewList", list.id) : null}</div> : null}
<div className="flex flex-wrap gap-2">{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}</div>
{evidences.map((e, evidenceIndex) => <div key={e.id} className="flex flex-wrap items-center gap-2 text-sm"><span className="break-all">证据 {evidenceIndex + 1} · {categories[e.evidenceKind] ?? "银行证据"}:{e.originalName}{e.voided ? "(已作废)" : ""}</span>{button("download", e.id)}{finance && !e.voided && r.status !== "cancelled" ? button("void", e.id) : null}</div>)}
{r.terminal?.facts.map((f, i) => <div key={f.id} className="flex flex-wrap items-center gap-2 text-sm"><span>终态事实 {i + 1}:{states[f.status]} · 指令 {submissionLabel(f.submissionId)} · {f.kind === "failure_receipt" ? "失败回单" : ({ failed: "已失败", revoked: "已撤销", rejected: "已拒绝" }[f.terminalStatus] ?? f.terminalStatus)} · {f.note}</span>{f.evidenceId ? evidenceLink(f.evidenceId) : null}{f.receiptAttachmentId ? receiptLink(f.receiptAttachmentId) : null}{f.resolvesAttachmentIds.length ? <span>了结处理中回单:</span> : null}{f.resolvesAttachmentIds.map((id) => <span key={id}>{receiptLink(id)}</span>)}{reviewer && f.status === "proposed" && f.proposedBy !== me?.id ? button("reviewFact", f.id) : null}</div>)}
{r.failureAttributions?.map((a) => <div key={a.id} className="space-y-2 text-sm"><p>失败归属:{states[a.status]} · {a.basisNote}</p><p>{a.exclusionNote}</p><p>归属范围:{a.failureScope === "submission" ? "指定提交" : "本批次提交清单"};覆盖指令:{a.coveredSubmissionIds.map(submissionLabel).join("、")}</p>{a.receiptAttachmentIds.map((id) => <span key={id}>{receiptLink(id)}</span>)}{reviewer && a.status === "proposed" && a.proposedBy !== me?.id ? button("reviewFailure", a.id) : null}</div>)}
{q6 ? <div className="space-y-2 text-sm"><p>人工确认未提交:{states[q6.status]} · {q6.payload.note} · 覆盖 {formatDisplayDate(q6.payload.coverageFrom)} 至 {formatDisplayDate(q6.payload.coverageThrough)} · 已有 {q6.reviews.filter((x) => x.decision === "approve").length} 人同意{q6.stale ? ";材料已变化,须重新登记及双人复核" : ""}</p><p>覆盖导出:{q6.payload.exportIds.map(exportLabel).join("、")}</p><div className="flex flex-wrap gap-2"><span>查询:</span>{evidenceLink(q6.payload.queryEvidenceId)}<span>对账单:</span>{evidenceLink(q6.payload.statementEvidenceId)}{q6.payload.exporterStatements.map((x, i) => <span key={x.exporterId}>导出人 {i + 1}({exports.filter((e) => e.exportedBy === x.exporterId && q6.payload.exportIds.includes(e.id)).map((e) => exportLabel(e.id)).join("、")}):{evidenceLink(x.evidenceId)}</span>)}</div>{reviewer && q6.status === "proposed" && !q6.stale && q6.proposedBy !== me?.id && !q6.reviews.some((x) => x.reviewedBy === me?.id) ? button("reviewQ6", q6.id) : null}</div> : null}
{finance && unlinked.length > 0 && r.status !== "cancelled" && (!q6 || q6.stale || q6.status === "rejected") ? button("q6") : null}
{current ? <div className="space-y-2 text-sm"><p>补证恢复第 {current.sequence} 轮:{states[current.status]}{current.stale ? ";依据已变化,须重新发起" : ""}</p><div className="flex flex-wrap gap-2">{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}</div></div> : null}
{r.terminal?.facts.map((f, i) => <div key={f.id} className="flex flex-wrap items-center gap-2 text-sm"><span>终态事实 {i + 1}:{stateLabel(f.status)} · 指令 {submissionLabel(f.submissionId)} · {f.kind === "failure_receipt" ? "失败回单" : ({ failed: "已失败", revoked: "已撤销", rejected: "已拒绝" }[f.terminalStatus] ?? f.terminalStatus)} · {f.note}</span>{f.evidenceId ? evidenceLink(f.evidenceId) : null}{f.receiptAttachmentId ? receiptLink(f.receiptAttachmentId) : null}{f.resolvesAttachmentIds.length ? <span>了结处理中回单:</span> : null}{f.resolvesAttachmentIds.map((id) => <span key={id}>{receiptLink(id)}</span>)}{reviewer && f.status === "proposed" && f.proposedBy !== me?.id ? button("reviewFact", f.id) : null}</div>)}
{r.failureAttributions?.map((a) => <div key={a.id} className="space-y-2 text-sm"><p>失败归属:{stateLabel(a.status)} · {a.basisNote}</p><p>{a.exclusionNote}</p><p>归属范围:{a.failureScope === "submission" ? "指定提交" : "本批次提交清单"};覆盖指令:{a.coveredSubmissionIds.map(submissionLabel).join("、")}</p>{a.receiptAttachmentIds.map((id) => <span key={id}>{receiptLink(id)}</span>)}{reviewer && a.status === "proposed" && a.proposedBy !== me?.id ? button("reviewFailure", a.id) : null}</div>)}
{related ? <p className="text-sm text-amber-800">本模式不支持“人工确认未提交”。无银行终态依据时继续占用。</p> : null}
{q6 ? <div className="space-y-2 text-sm"><p>人工确认未提交:{stateLabel(q6.status)} · {q6.payload.note} · 覆盖 {formatDisplayDate(q6.payload.coverageFrom)} 至 {formatDisplayDate(q6.payload.coverageThrough)} · 已有 {q6.reviews.filter((x) => x.decision === "approve").length} 人同意{q6.stale ? ";材料已变化,须重新登记及双人复核" : ""}</p><p>覆盖导出:{q6.payload.exportIds.map(exportLabel).join("、")}</p><div className="flex flex-wrap gap-2"><span>查询:</span>{evidenceLink(q6.payload.queryEvidenceId)}<span>对账单:</span>{evidenceLink(q6.payload.statementEvidenceId)}{q6.payload.exporterStatements.map((x, i) => <span key={x.exporterId}>导出人 {i + 1}({exports.filter((e) => e.exportedBy === x.exporterId && q6.payload.exportIds.includes(e.id)).map((e) => exportLabel(e.id)).join("、")}):{evidenceLink(x.evidenceId)}</span>)}</div>{!related && reviewer && q6.status === "proposed" && !q6.stale && q6.proposedBy !== me?.id && !q6.reviews.some((x) => x.reviewedBy === me?.id) ? button("reviewQ6", q6.id) : null}</div> : null}
{!related && finance && unlinked.length > 0 && r.status !== "cancelled" && (!q6 || q6.stale || q6.status === "rejected") ? button("q6") : null}
{current ? <div className="space-y-2 text-sm"><p>补证恢复第 {current.sequence} 轮:{stateLabel(current.status)}{current.stale ? ";依据已变化,须重新发起" : ""}</p><div className="flex flex-wrap gap-2">{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}</div></div> : 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 ? <p className="text-sm text-amber-800">原制单文件不得再导入网银。系统不能使已下载文件失效;出现晚到成功应立即登记对账。</p> : null}
{finance && terminal ? button("event") : null}
{r.bankResults?.events.map((e, i) => <div key={e.id} className="space-y-2 rounded border p-3 text-sm"><p>{e.kind === "return" ? "追回到账" : "晚到成功"} {i + 1} · ¥{e.amount} · {states[e.status]}</p>{e.outstandingReturn ? <p>待追回 ¥{e.outstandingReturn}</p> : null}{e.settlements.map((s) => <p key={s.loanId}>银行已付 ¥{s.bankAmount};结清本金 ¥{s.settledAmount};超额 ¥{s.receivableAmount}</p>)}<div className="flex flex-wrap gap-2">{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}</div></div>)}
<Dialog open={action !== null} onOpenChange={(next) => { if (!next && !mutation.isPending) setAction(null); }}><DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>{action ? actions[action] : "银行对账"}</DialogTitle><DialogDescription>核对原始银行材料和具体指令。独立复核人不能兼任相关经办或收款人;材料不足时保持占用或冻结。人工未提交须三类材料齐全并由两名独立人员复核。</DialogDescription></DialogHeader>
{r.bankResults?.events.map((e, i) => <div key={e.id} className="space-y-2 rounded border p-3 text-sm"><p>{e.kind === "return" ? "追回到账" : "晚到成功"} {i + 1} · ¥{e.amount} · {stateLabel(e.status)}</p>{e.outstandingReturn ? <p>待追回 ¥{e.outstandingReturn}</p> : null}{e.settlements.map((s) => <p key={s.loanId}>银行已付 ¥{s.bankAmount};结清本金 ¥{s.settledAmount};超额 ¥{s.receivableAmount}</p>)}<div className="flex flex-wrap gap-2">{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}</div></div>)}
<Dialog open={action !== null} onOpenChange={(next) => { if (!next && !mutation.isPending) setAction(null); }}><DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>{action ? actionLabel(action) : "银行对账"}</DialogTitle><DialogDescription>{related ? "核对原始银行材料和具体指令。收款人以公司代表身份确认,不能兼任经办;材料不足时保持占用或冻结。人工确认未提交在本模式不可用。" : "核对原始银行材料和具体指令。独立复核人不能兼任相关经办或收款人;材料不足时保持占用或冻结。人工未提交须三类材料齐全并由两名独立人员复核。"}</DialogDescription></DialogHeader>
<form className="grid gap-4" onSubmit={(e) => { e.preventDefault(); mutation.mutate(); }}>
{details ? <div className="space-y-2 text-sm" aria-label="已授权查看的银行事实">{details.fields.map((f) => <p key={f.Name} className="break-all">{f.Name}:{f.Value}</p>)}<p>{details.attributionNote}</p><p>{details.exclusionNote}</p></div> : null}
{mutation.error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error)}</p> : null}
{action === "upload" ? <>{select("category", "证据类别", Object.entries(categories))}<label className="grid gap-2 text-sm">证据文件<Input required type="file" accept="application/pdf,image/png,image/jpeg" onChange={(e) => setFile(e.target.files?.[0] ?? null)} /></label></> : null}
{action === "upload" ? <>{select("category", "证据类别", Object.entries(categories).filter(([key]) => !related || !["query_no_result", "account_statement", "exporter_statement"].includes(key)))}<label className="grid gap-2 text-sm">证据文件<Input required type="file" accept="application/pdf,image/png,image/jpeg" onChange={(e) => setFile(e.target.files?.[0] ?? null)} /></label></> : 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"))}<fieldset className="grid gap-2"><legend className="text-sm">了结的同指令处理中回单(可多选)</legend>{receiptOptions.map(([id, label]) => <label key={id} className="flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" checked={pendingIds.includes(id)} onChange={(e) => setPendingIds((old) => e.target.checked ? [...old, id] : old.filter((x) => x !== id))} />{label}</label>)}</fieldset></> : 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}