支持财务经办与公司代表关联方还款确认(09-17-company-loan-principal-repayment)
公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。 仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。 贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。 授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。 本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。 已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。 按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。 限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。 生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
这个提交包含在:
@@ -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<LoanEvidence>(`/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<LoanRelatedAuthorization & { loans: CompanyLoan[] }>(`/api/loan-related-authorizations/${id}`, { signal });
|
||||
}
|
||||
|
||||
export type LoanReconciliation = {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
export const ERROR_REASON_MESSAGES: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。",
|
||||
|
||||
@@ -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<string, unknown> | 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[];
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 <section className="card-std space-y-3 p-4" aria-label="本金还款分配">
|
||||
{related ? <div className="space-y-1 rounded border p-3 text-sm" aria-label="关联方还款模式"><p className="font-medium">公司代表关联方确认 · 授权第 {repayment.authorizationVersion} 版</p><p>收款人以公司代表身份确认;财务负责制单、付款和记录。经办人不能确认自己提供的依据。</p>
|
||||
{repayment.actionCapabilities?.paymentAuthorized === false ? <p>新付款授权已停止或范围不再覆盖,不能继续批准或导出。</p> : null}
|
||||
{repayment.actionCapabilities?.reconciliationAuthorized === false ? <p>当前对账授权不可用,新的确认和释放占用暂停;已发生的银行事实仍可登记。</p> : null}</div> : null}
|
||||
<h2 className="font-semibold">原借款分配与会计交接</h2>
|
||||
<LoanBankReconciliation repayment={repayment} attachments={attachments} />
|
||||
<p className="break-words text-sm">{repayment.reason}</p>
|
||||
{repayment.bankResultState === "unknown" ? <p className="text-sm text-amber-800">银行结果待查证;本金保持占用,不能取消释放或重复付款。</p> : null}
|
||||
{overturned ? <p className="text-sm text-amber-800">原未付款结论已被晚到成功推翻;实际付款、结清本金与待追回款见银行结果对账。</p> : null}
|
||||
{repayment.bankResultState === "confirmed_failed" && !lateBankFact ? <p className="text-sm text-amber-800">银行失败证据已由独立人员确认。请财务取消本申请释放额度,保留原记录。</p> : null}
|
||||
{repayment.bankResultState === "confirmed_failed" && !lateBankFact ? <p className="text-sm text-amber-800">{related ? "银行失败证据已由公司代表确认。" : "银行失败证据已由独立人员确认。"}请财务取消本申请释放额度,保留原记录。</p> : null}
|
||||
{attribution?.required && payable ? <div className="space-y-2 rounded-md border border-amber-300 p-3 text-sm" aria-label="回单归属确认">
|
||||
<p className="font-medium text-amber-800">回单归属待确认:同一出借人另有 {attribution.competitors.length} 笔同金额还款曾制单或上传回单({attribution.competitors.map((item) => item.batchNo).join("、")})。金额与账号一致不能证明回单属于本批次,确认前不能核销或释放额度。同一出借人同金额的每一期还款都需单独确认归属;存在同金额候选时,失败回单须另外确认归属,并满足完整的银行终态释放条件。</p>
|
||||
{attribution.current ? <div className="space-y-1">
|
||||
<p>归属确认:{attributionStatusLabels[attribution.current.status]}{attribution.current.stale ? "(回单或候选批次已变化,须重新登记并复核)" : ""}</p>
|
||||
<p>归属确认:{related ? ({ proposed: "待公司代表确认", approved: "公司代表已确认", rejected: "已驳回", superseded: "已失效(已被新登记取代)" }[attribution.current.status]) : attributionStatusLabels[attribution.current.status]}{attribution.current.stale ? "(回单或候选批次已变化,须重新登记并复核)" : ""}</p>
|
||||
<p className="break-words">依据:{attributionBasisLabels[attribution.current.basisKind]};{attribution.current.basisNote}</p>
|
||||
<p className="break-words">排除其他批次的理由:{attribution.current.exclusionNote}</p>
|
||||
<p>登记:{attribution.current.proposedByName ?? "—"}{attribution.current.reviewedByName ? ` · 复核:${attribution.current.reviewedByName}` : ""}{attribution.current.reviewNote ? `(${attribution.current.reviewNote})` : ""}</p>
|
||||
@@ -71,7 +76,7 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attribution.receiptAvailable === false ? <p className="text-amber-800">尚无已核验的银行回单,须先完成回单识别或人工核对再登记归属。</p> : null}
|
||||
{loanManager && !openAttribution && attribution.receiptAvailable !== false ? <Button variant="outline" onClick={() => open("attribute")}>登记回单归属依据</Button> : null}
|
||||
{reviewer && attribution.current?.status === "proposed" && !attribution.current.stale && attribution.current.proposedBy !== me?.id ? <Button variant="outline" onClick={() => open("attributionReview")}>独立复核回单归属</Button> : null}
|
||||
{reviewer && attribution.current?.status === "proposed" && !attribution.current.stale && attribution.current.proposedBy !== me?.id ? <Button variant="outline" onClick={() => open("attributionReview")}>{reviewLabel("独立复核回单归属")}</Button> : null}
|
||||
</div>
|
||||
</div> : null}
|
||||
{repayment.allocations.map((allocation) => <div key={allocation.loanId} className="space-y-2 rounded-md border p-3 text-sm">
|
||||
@@ -84,12 +89,12 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan
|
||||
<p className="text-sm">会计交接:{repayment.accountingHandoffRef ?? "待交接(与银行付款登记分别确认)"}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{finance && repayment.status === "executed" && !repayment.accountingHandoffAt ? <Button variant="outline" onClick={() => open("handoff")}>确认会计交接</Button> : null}
|
||||
{reviewer && ["reviewed", "failed"].includes(repayment.status) && repayment.bankResultState === "unknown" ? <Button variant="outline" onClick={() => open("failure")}>复核银行失败证据</Button> : null}
|
||||
{reviewer && ["reviewed", "failed"].includes(repayment.status) && repayment.bankResultState === "unknown" ? <Button variant="outline" onClick={() => open("failure")}>{related ? "公司代表确认银行失败证据" : "复核银行失败证据"}</Button> : null}
|
||||
</div>
|
||||
<Dialog open={action !== null} onOpenChange={(value) => { if (!value && !mutation.isPending) setAction(null); }}>
|
||||
<DialogContent><DialogHeader><DialogTitle>{action === "handoff" ? "确认会计交接" : action === "failure" ? "独立复核银行失败证据" :
|
||||
action === "attribute" ? "登记回单归属依据" : action === "attributionReview" ? "独立复核回单归属" : "下载原借款材料"}</DialogTitle>
|
||||
<DialogDescription>{action === "attribute" ? "写明回单中能指向本批次的依据,以及逐一排除其他候选批次的理由。交易时间与制单时间吻合只能作辅助说明,不能单独作为依据;证据不足请保持待对账。" : action === "attributionReview" ? "核对依据能否排除其他候选批次。登记人、批次创建人、提交人、导出制单人、回单上传人和出借人关联员工不能复核。" : action === "failure" ? "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"}</DialogDescription></DialogHeader>
|
||||
<DialogContent><DialogHeader><DialogTitle>{action === "handoff" ? "确认会计交接" : action === "failure" ? reviewLabel("独立复核银行失败证据") :
|
||||
action === "attribute" ? "登记回单归属依据" : action === "attributionReview" ? reviewLabel("独立复核回单归属") : "下载原借款材料"}</DialogTitle>
|
||||
<DialogDescription>{action === "attribute" ? "写明回单中能指向本批次的依据,以及逐一排除其他候选批次的理由。交易时间与制单时间吻合只能作辅助说明,不能单独作为依据;证据不足请保持待对账。" : action === "attributionReview" ? related ? "核对依据能否排除其他候选批次。公司代表可以同时为收款人,但不能兼任登记、创建、提交、导出、回单上传或人工录入人。" : "核对依据能否排除其他候选批次。登记人、批次创建人、提交人、导出制单人、回单上传人和出借人关联员工不能复核。" : action === "failure" ? related ? "公司代表核对明确失败的银行回单及对应指令;金额、账号和币种必须一致。经办人不得自审,未明事实继续占用。" : "仅限金额、双方账号和币种一致且明确失败的银行回单;受理中、未知、未识别的材料不能释放额度。上传人和收款人不得自审。" : "核对业务材料后填写真实引用或用途,操作将留存审计。"}</DialogDescription></DialogHeader>
|
||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(); }}>
|
||||
{mutation.error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error)}</p> : null}
|
||||
{action === "attribute" ? <>
|
||||
|
||||
@@ -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<Action | null>(null); const [selected, setSelected] = useState<LoanRelatedAuthorization | null>(null);
|
||||
const [lenderId, setLenderId] = useState(""); const [loanIds, setLoanIds] = useState<string[]>([]); 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 <section className="space-y-4" aria-label="公司代表授权范围">
|
||||
<p className="text-sm text-zinc-600">财务登记公司、出借人和具体借款范围;收款人以公司代表身份确认。这是关联方确认,经办与确认仍由两人分别承担。</p>
|
||||
{finance ? <Button onClick={() => open("propose")}>{labels.propose}</Button> : null}
|
||||
{error && !action ? <p role="alert">{resolveErrorMessage(error)}</p> : null}
|
||||
{records.isPending ? <p role="status">加载授权范围…</p> : null}
|
||||
{records.isSuccess && records.data.items.length === 0 ? <p>暂无可查看的授权范围。</p> : null}
|
||||
{records.data?.items.map((record) => <section key={record.id} className="card-std space-y-3 p-4">
|
||||
<h2 className="break-words font-semibold">{record.legalEntityName} · {record.lenderName} · 授权第 {record.version} 版</h2>
|
||||
<p className="text-sm">公司代表:{record.approverName}(同时为收款人) · {record.loanIds.length} 笔借款</p>
|
||||
<p className="text-sm">{!record.confirmed ? "待公司代表确认" : !record.current ? "历史版本,已被取代" : `当前范围 · 新付款${record.paymentEnabled ? "可用" : "已停止"} · 对账${record.reconcileEnabled ? "可用" : "已停止"}`}</p>
|
||||
<p className="break-words text-sm">依据:{record.basis}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => open("details", record)}>{labels.details}</Button>
|
||||
{record.actions.confirm ? <Button onClick={() => open("confirm", record)}>{labels.confirm}</Button> : null}
|
||||
{record.actions.stopPayment ? <Button variant="outline" onClick={() => open("stop_payment", record)}>{labels.stop_payment}</Button> : null}
|
||||
{record.actions.stopReconcile ? <Button variant="outline" onClick={() => open("stop_reconcile", record)}>{labels.stop_reconcile}</Button> : null}
|
||||
</div>
|
||||
</section>)}
|
||||
<div className="flex items-center gap-3"><Button variant="outline" disabled={page === 1} onClick={() => setPage(page - 1)}>上一页授权</Button><span>第 {page} 页</span><Button variant="outline" disabled={!records.data || page * 30 >= records.data.total} onClick={() => setPage(page + 1)}>下一页授权</Button></div>
|
||||
<Dialog open={action !== null} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setAction(null); }}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>{action ? labels[action] : "公司代表授权"}</DialogTitle>
|
||||
<DialogDescription>{action === "stop_payment" ? "停止后不能新申请、批准或导出付款;已经发生的付款仍可核销和对账。" : action === "stop_reconcile" ? "停止后不能作出新的对账确认或最终释放占用。历史批准与银行事实保留,财务仍可登记已发生的事实。" : "逐笔核对授权范围和原借款材料。该授权只允许收款人代表公司确认,不允许兼任本单经办;人工确认未提交在此模式不可用。"}</DialogDescription></DialogHeader>
|
||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(); }}>
|
||||
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
|
||||
{action === "propose" ? <>
|
||||
<label className="grid gap-2 text-sm">出借人<select required aria-label="授权出借人" className="min-h-11 min-w-0 rounded border px-3" value={lenderId} onChange={(e) => { setLenderId(e.target.value); setLoanIds([]); setLoanPage(1); }}>
|
||||
<option value="">请选择已核实关联身份的出借人</option>{lenders.data?.items.filter((l) => l.identityStatus === "verified" && l.employeeId && l.employeeId !== me?.id).map((l) => <option key={l.id} value={l.id}>{l.name} · {l.legalEntityName}</option>)}
|
||||
</select></label>
|
||||
<div className="flex flex-wrap gap-2"><Button type="button" variant="outline" disabled={lenderPage === 1} onClick={() => setLenderPage(lenderPage - 1)}>上一页出借人</Button><Button type="button" variant="outline" disabled={!lenders.data || lenderPage * 30 >= lenders.data.total} onClick={() => setLenderPage(lenderPage + 1)}>下一页出借人</Button></div>
|
||||
<fieldset className="space-y-2"><legend className="text-sm">具体借款范围(已选 {loanIds.length} 笔)</legend>
|
||||
{loans.data?.items.filter((l) => l.confirmationStatus === "confirmed").map((loan) => <label key={loan.id} className="flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" checked={loanIds.includes(loan.id)} onChange={(e) => setLoanIds((old) => e.target.checked ? [...old, loan.id] : old.filter((id) => id !== loan.id))} /><span className="break-words">{loan.loanNo} · {loan.originReference} · 未还 ¥{loan.outstanding}</span></label>)}
|
||||
</fieldset>
|
||||
{lenderId ? <div className="flex gap-2"><Button type="button" variant="outline" disabled={loanPage === 1} onClick={() => setLoanPage(loanPage - 1)}>上一页借款</Button><Button type="button" variant="outline" disabled={!loans.data || loanPage * 30 >= loans.data.total} onClick={() => setLoanPage(loanPage + 1)}>下一页借款</Button></div> : null}
|
||||
</> : action !== "download" ? <>
|
||||
{detail.isPending ? <p role="status">加载范围材料…</p> : null}
|
||||
{detail.data?.loans.map((loan) => <section key={loan.id} className="space-y-2 rounded border p-3 text-sm"><p className="break-words">{loan.loanNo} · {loan.originReference} · 未还本金 ¥{loan.outstanding}</p>
|
||||
<p>{loan.lender.legalEntityName} · {loan.lender.name} · {loan.lender.bankAccountMasked}</p>
|
||||
{loan.evidences.filter((e) => !e.archivedAt).map((e) => <Button key={e.id} type="button" variant="link" className="max-w-full whitespace-normal break-all" onClick={() => { setEvidence({ loanId: loan.id, id: e.id, name: e.originalName }); setAction("download"); setReason(""); }}>{e.originalName}</Button>)}
|
||||
</section>)}
|
||||
{detail.data?.events.map((e) => <p key={e.id} className="break-words text-sm">{labels[e.action as Action] ?? e.action}:{e.reason}</p>)}
|
||||
</> : null}
|
||||
{action !== "details" ? <>
|
||||
<label className="grid gap-2 text-sm">{action === "propose" ? "代表权及授权范围依据" : action === "download" ? "查看用途" : "确认或停止的理由"}<Input required minLength={action === "propose" ? 10 : 2} maxLength={action === "propose" ? 1000 : 500} value={reason} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
<Button type="submit" disabled={mutation.isPending || (action === "propose" ? !loanIds.length || !loans.isSuccess : !detail.isSuccess)}>{mutation.isPending ? "处理中…" : "确认"}</Button>
|
||||
</> : null}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>;
|
||||
}
|
||||
@@ -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] ? "取消本页更正" : "更正识别"}</Button>
|
||||
{editing[pageNumber] ? <div className="space-y-3">
|
||||
<p className="text-xs text-zinc-500">只填写要更正的字段,其他字段保留原识别值。更正后需要独立复核。</p>
|
||||
<p className="text-xs text-zinc-500">只填写要更正的字段,其他字段保留原识别值。更正后需要{props.relatedParty ? "公司代表确认" : "独立复核"}。</p>
|
||||
{FIELD_NAMES.map((name) => <label key={name} className="block space-y-1 text-sm">
|
||||
<span>第{pageNumber}页更正{name}</span>
|
||||
{name === "单据状态" ? <select className="min-h-11 w-full rounded-md border bg-white px-3"
|
||||
@@ -141,7 +141,7 @@ export function PaymentReceiptVerificationDialog(props: Props) {
|
||||
})}
|
||||
{detail?.proposal ? <p className="whitespace-pre-wrap break-words text-sm">核验说明:{detail.proposal.reason}</p> : null}
|
||||
{detail?.proposal?.status === "pending_review" && !reviewable ?
|
||||
<p role="status" className="text-sm text-zinc-600">等待有资格的独立审批人复核;经办人和报销参与人不能自审。</p> : null}
|
||||
<p role="status" className="text-sm text-zinc-600">{props.relatedParty ? "等待有当前授权的公司代表确认;上传人、录入人和制单人不能自审。" : "等待有资格的独立审批人复核;经办人和报销参与人不能自审。"}</p> : null}
|
||||
{editable || reviewable ? <label className="block space-y-1 text-sm">
|
||||
<span>{reviewable ? "复核说明" : "更正说明(仅关联可留空)"}</span>
|
||||
<Textarea value={reason} maxLength={500} disabled={busy} onChange={(event) => setReason(event.target.value)} />
|
||||
@@ -152,7 +152,7 @@ export function PaymentReceiptVerificationDialog(props: Props) {
|
||||
onClick={() => save.mutate()}>{save.isPending ? "保存中…" : "保存核验"}</Button> : null}
|
||||
{reviewable ? <>
|
||||
<Button variant="secondary" disabled={busy || query.isFetching || !reason.trim()} onClick={() => review.mutate("reject")}>不通过</Button>
|
||||
<Button disabled={busy || query.isFetching || !reason.trim()} onClick={() => review.mutate("approve")}>确认复核</Button>
|
||||
<Button disabled={busy || query.isFetching || !reason.trim()} onClick={() => review.mutate("approve")}>{props.relatedParty ? "公司代表确认" : "确认复核"}</Button>
|
||||
</> : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
|
||||
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
|
||||
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
|
||||
{ to: "/company-loans", label: "公司借款", icon: "account_balance", anyOf: ["loan.manage"] },
|
||||
{ to: "/company-loans", label: "公司借款", icon: "account_balance", anyOf: ["loan.manage", "payment.batch.review"] },
|
||||
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["finance.ledger.view"] },
|
||||
{ to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
|
||||
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fetchLegalEntities } from "../api/masterData";
|
||||
import { fetchBankInstitutions } from "../api/paymentAccounts";
|
||||
import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoanIdentities, fetchLoanLenders,
|
||||
fetchLoanRepayments, saveLoanAction, uploadLoanEvidence, type CompanyLoan, type LoanLender } from "../api/companyLoans";
|
||||
import { LoanRelatedAuthorizations } from "../components/common/LoanRelatedAuthorizations";
|
||||
import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -34,7 +35,7 @@ export function CompanyLoansPage() {
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
const allowed = Boolean(me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role))) && hasPermission(permissions, "loan.manage");
|
||||
const [page, setPage] = useState(1); const [lenderPage, setLenderPage] = useState(1); const [repayPage, setRepayPage] = useState(1);
|
||||
const [tab, setTab] = useState<"loans" | "lenders" | "repayments">("loans");
|
||||
const [tab, setTab] = useState<"loans" | "lenders" | "repayments" | "authorizations">("loans");
|
||||
const [mode, setMode] = useState<Mode>(null); const [selectedLender, setSelectedLender] = useState<LoanLender | null>(null);
|
||||
const [selectedLoan, setSelectedLoan] = useState<CompanyLoan | null>(null);
|
||||
const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null);
|
||||
@@ -92,7 +93,9 @@ export function CompanyLoansPage() {
|
||||
if (mounted.current && identity.current === actorId) setMode(null);
|
||||
});
|
||||
}
|
||||
if (!allowed) return <p role="alert">当前账号无借款台账权限。</p>;
|
||||
if (!allowed) return me?.roleCodes.some((r) => ["manager", "ceo"].includes(r)) && hasPermission(permissions, "payment.batch.review")
|
||||
? <ListWorkspace><WorkspaceHeader title="公司借款" subtitle="查看分配给本人的公司代表授权及范围材料。" /><LoanRelatedAuthorizations key={me.id} finance={false} /></ListWorkspace>
|
||||
: <p role="alert">当前账号无借款台账权限。</p>;
|
||||
const error = mutation.error ?? loans.error ?? lenders.error ?? repayments.error ?? companies.error ?? banks.error ?? people.error;
|
||||
const current = tab === "loans" ? loans : tab === "lenders" ? lenders : repayments;
|
||||
const modalQueryBlocked = mode === "lender" ? !companies.isSuccess || !banks.isSuccess : mode === "account" ? !banks.isSuccess : mode === "identity" ? !people.isSuccess : mode === "loan" ? !lenders.isSuccess : mode === "repay" ? !lenders.isSuccess || !candidates.isSuccess : false;
|
||||
@@ -103,12 +106,13 @@ export function CompanyLoansPage() {
|
||||
actions={<HeaderActionGroup><Button variant="outline" onClick={() => open("lender")}>登记出借人</Button>
|
||||
<Button onClick={() => open("loan")}>登记借入</Button><Button variant="outline" onClick={() => open("repay")}>申请还本</Button></HeaderActionGroup>} />
|
||||
<div className="flex flex-wrap gap-2" role="tablist" aria-label="借款台账视图">
|
||||
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"]] as const).map(([key, label]) =>
|
||||
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"], ["authorizations", "公司代表授权"]] as const).map(([key, label]) =>
|
||||
<Button key={key} role="tab" aria-selected={tab === key} variant={tab === key ? "default" : "outline"} onClick={() => setTab(key)}>{label}</Button>)}
|
||||
</div>
|
||||
{tab === "authorizations" ? <LoanRelatedAuthorizations key={me?.id} finance /> : null}
|
||||
{error && !mode ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
|
||||
{current.isPending ? <p role="status">加载中…</p> : null}
|
||||
{current.isSuccess && !current.data.items.length ? <p className="card-std p-4">暂无记录;不能据此判断银行实际借还款情况。</p> : null}
|
||||
{tab !== "authorizations" && current.isPending ? <p role="status">加载中…</p> : null}
|
||||
{tab !== "authorizations" && current.isSuccess && !current.data.items.length ? <p className="card-std p-4">暂无记录;不能据此判断银行实际借还款情况。</p> : null}
|
||||
{tab === "loans" && loans.isSuccess ? loans.data.items.map((loan) => <section key={loan.id} className="card-std min-w-0 space-y-3 p-4">
|
||||
<div className="flex flex-wrap justify-between gap-2"><h2 className="font-semibold break-all">{loan.lender.name} · {loan.loanNo}</h2>
|
||||
<span>{loan.confirmationStatus === "confirmed" ? "余额已确认" : "余额待核实"}</span></div>
|
||||
@@ -141,17 +145,17 @@ export function CompanyLoansPage() {
|
||||
</section>) : null}
|
||||
{tab === "repayments" && repayments.isSuccess ? repayments.data.items.map((repayment) => <section key={repayment.id} className="card-std space-y-2 p-4">
|
||||
<h2 className="font-semibold break-all">{repayment.lenderName} · {repayment.repaymentNo}</h2>
|
||||
<p>¥{repayment.amount} · {stateLabels[repayment.status] ?? "待核对"}</p>
|
||||
<p>¥{repayment.amount} · {repayment.approvalMode === "related_party" && repayment.status === "draft" ? repayment.paymentDecision === "reject" ? "公司代表已退回,待财务处理" : "待公司代表关联方确认" : stateLabels[repayment.status] ?? "待核对"}</p>
|
||||
{repayment.bankResultState === "unknown" ? <p className="text-sm text-amber-800">银行结果待查证,额度保持占用,请勿重复付款。</p> : null}
|
||||
<p className="text-sm">会计交接:{repayment.accountingHandoffRef ?? "待交接"}</p>
|
||||
<Link className="inline-flex min-h-11 items-center text-primary underline" to={`${paymentPath}?id=${encodeURIComponent(repayment.paymentBatchId)}`}>查看还款分配、付款与回单</Link>
|
||||
</section>) : null}
|
||||
<div className="flex items-center gap-3"><Button variant="outline" disabled={currentPage <= 1} onClick={() => setCurrentPage(currentPage - 1)}>上一页</Button>
|
||||
<span>第 {currentPage} 页</span><Button variant="outline" disabled={!current.data || currentPage * 30 >= current.data.total} onClick={() => setCurrentPage(currentPage + 1)}>下一页</Button></div>
|
||||
{tab !== "authorizations" ? <div className="flex items-center gap-3"><Button variant="outline" disabled={currentPage <= 1} onClick={() => setCurrentPage(currentPage - 1)}>上一页</Button>
|
||||
<span>第 {currentPage} 页</span><Button variant="outline" disabled={!current.data || currentPage * 30 >= current.data.total} onClick={() => setCurrentPage(currentPage + 1)}>下一页</Button></div> : null}
|
||||
<Dialog open={mode !== null} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>
|
||||
{({ lender: "登记出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
|
||||
</DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款,提交后进入独立付款复核。" :
|
||||
</DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款。系统按已确认的授权范围确定独立复核或公司代表关联方确认,提交后不能切换模式。" :
|
||||
mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
|
||||
mode === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
|
||||
@@ -75,6 +75,7 @@ import { toast } from "sonner";
|
||||
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
|
||||
|
||||
type PaymentActionKind =
|
||||
| "return"
|
||||
| "review"
|
||||
| "downloadOrder"
|
||||
| "uploadReceipt"
|
||||
@@ -169,6 +170,7 @@ export function PaymentBatchesPage() {
|
||||
} | null>(null);
|
||||
const [reasonAction, setReasonAction] = useState<
|
||||
| { kind: "cancel"; batchId: string; batchNo: string }
|
||||
| { kind: "return"; batchId: string; batchNo: string }
|
||||
| { kind: "voidReceipt"; batchId: string; batchNo: string; attachmentId: string; attachmentName: string }
|
||||
| { kind: "paymentOrder"; batchId: string; batchNo: string; reexport: boolean }
|
||||
| { kind: "payeeBank"; batchId: string; batchNo: string; itemId: string; entityCode: string }
|
||||
@@ -260,6 +262,8 @@ export function PaymentBatchesPage() {
|
||||
queryFn: () => fetchPaymentBatchDetail(selectedBatchId),
|
||||
});
|
||||
const selectedBatch = selectedBatchQuery.data?.item ?? null;
|
||||
const relatedParty = selectedBatch?.loanRepayment?.approvalMode === "related_party";
|
||||
const loanCapabilities = selectedBatch?.loanRepayment?.actionCapabilities;
|
||||
const receiptAttachmentsQuery = useQuery({
|
||||
queryKey: ["payment-batch-receipts", selectedBatch?.id],
|
||||
enabled: Boolean(selectedBatch?.id),
|
||||
@@ -271,22 +275,22 @@ export function PaymentBatchesPage() {
|
||||
});
|
||||
|
||||
const canEditSelectedBatch =
|
||||
canManageBatch &&
|
||||
canManageBatch && (!relatedParty || loanCapabilities?.canPrepare === true) &&
|
||||
!!selectedBatch &&
|
||||
["reviewed", "failed"].includes(selectedBatch.status);
|
||||
const canEditReceiptAttachments =
|
||||
canManageBatch &&
|
||||
canManageBatch && (!relatedParty || loanCapabilities?.canPrepare === true) &&
|
||||
!!selectedBatch &&
|
||||
["reviewed", "failed"].includes(selectedBatch.status);
|
||||
const canReviewSelectedBatch =
|
||||
canReviewBatch &&
|
||||
canReviewBatch && (!relatedParty || loanCapabilities?.canApprovePayment === true) &&
|
||||
!!selectedBatch &&
|
||||
selectedBatch.createdBy !== me?.id &&
|
||||
(
|
||||
selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review"
|
||||
);
|
||||
const canExecuteSelectedBatch =
|
||||
canManageBatch &&
|
||||
canManageBatch && (!relatedParty || loanCapabilities?.canPrepare === true) &&
|
||||
!!selectedBatch &&
|
||||
["reviewed", "failed"].includes(selectedBatch.status) &&
|
||||
selectedBatch.reviewRequirement.nextStep === "ready_for_execute";
|
||||
@@ -314,15 +318,16 @@ export function PaymentBatchesPage() {
|
||||
(!requiresBankReceipt || (receiptAttachmentsQuery.isSuccess && activeReceiptCount > 0 &&
|
||||
receiptReadinessQuery.isSuccess && !receiptReadinessQuery.isFetching && receiptReadinessQuery.data.item.ready));
|
||||
const canCancelSelectedBatch =
|
||||
canManageBatch &&
|
||||
canManageBatch && (!relatedParty || loanCapabilities?.canPrepare === true) &&
|
||||
!!selectedBatch &&
|
||||
(!relatedParty || loanCapabilities?.canRelease === true) &&
|
||||
!["executed", "cancelled"].includes(selectedBatch.status) && selectedBatch.loanRepayment?.bankResultState !== "unknown";
|
||||
|
||||
const paymentActionModel = useMemo(() => {
|
||||
const canDownloadOrder =
|
||||
selectedBatch?.status === "reviewed" &&
|
||||
selectedBatch.canExportPaymentOrder &&
|
||||
canExportPaymentOrder;
|
||||
canExportPaymentOrder && (!relatedParty || loanCapabilities?.canExport === true);
|
||||
const primary = canReviewSelectedBatch
|
||||
? "review"
|
||||
: needsBankReceipt
|
||||
@@ -333,6 +338,7 @@ export function PaymentBatchesPage() {
|
||||
? "execute"
|
||||
: null;
|
||||
const more: PaymentActionKind[] = [];
|
||||
if (canReviewSelectedBatch && relatedParty) more.push("return");
|
||||
if (primary === "downloadOrder" && canUploadReceipt) more.push("uploadReceipt");
|
||||
if (canDownloadOrder && primary !== "downloadOrder") {
|
||||
more.push(selectedBatch.paymentOrderExportCount > 0 ? "redownloadOrder" : "downloadOrder");
|
||||
@@ -341,7 +347,7 @@ export function PaymentBatchesPage() {
|
||||
return { primary: primary as PaymentActionKind | null, more };
|
||||
}, [
|
||||
canCancelSelectedBatch,
|
||||
canExportPaymentOrder,
|
||||
canExportPaymentOrder, relatedParty, loanCapabilities,
|
||||
canRegisterPaymentComplete,
|
||||
canReviewSelectedBatch,
|
||||
canUploadReceipt,
|
||||
@@ -436,7 +442,7 @@ export function PaymentBatchesPage() {
|
||||
onSuccess: async ({ item }) => {
|
||||
setError("");
|
||||
await refresh();
|
||||
toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核");
|
||||
toast.success(item.relatedPaymentDecision === "approve" ? "公司代表已确认付款" : item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核");
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = resolveError(err);
|
||||
@@ -445,6 +451,16 @@ export function PaymentBatchesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const returnMutation = useMutation({
|
||||
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) =>
|
||||
reviewPaymentBatch(batchId, { decision: "reject", comment: reason }),
|
||||
onSuccess: async () => {
|
||||
setError(""); setReasonAction(null); setActionReason("");
|
||||
await refresh(); toast.success("付款申请已退回,等待财务处理");
|
||||
},
|
||||
onError: (err) => { const message = resolveError(err); setError(message); toast.error(message); },
|
||||
});
|
||||
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: executePaymentBatch,
|
||||
onSuccess: async ({ item }) => {
|
||||
@@ -632,7 +648,8 @@ export function PaymentBatchesPage() {
|
||||
});
|
||||
|
||||
const paymentActionLabel = (kind: PaymentActionKind) => {
|
||||
if (kind === "review") return reviewMutation.isPending ? "复核中..." : "复核通过";
|
||||
if (kind === "return") return "退回付款申请";
|
||||
if (kind === "review") return reviewMutation.isPending ? "处理中..." : relatedParty ? "公司代表确认付款" : "复核通过";
|
||||
if (kind === "downloadOrder") return paymentOrderMutation.isPending ? "生成中..." : "下载银行制单";
|
||||
if (kind === "uploadReceipt") return uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单";
|
||||
if (kind === "execute") return executeMutation.isPending ? "登记中..." : "登记付款完成";
|
||||
@@ -641,6 +658,7 @@ export function PaymentBatchesPage() {
|
||||
};
|
||||
|
||||
const paymentActionDisabled = (kind: PaymentActionKind) => {
|
||||
if (kind === "return") return returnMutation.isPending || reviewMutation.isPending;
|
||||
if (kind === "review") return reviewMutation.isPending;
|
||||
if (["downloadOrder", "redownloadOrder"].includes(kind)) return paymentOrderMutation.isPending;
|
||||
if (kind === "uploadReceipt") return uploadReceiptMutation.isPending;
|
||||
@@ -665,8 +683,8 @@ export function PaymentBatchesPage() {
|
||||
return;
|
||||
}
|
||||
setActionReason("");
|
||||
if (kind === "cancel") {
|
||||
setReasonAction({ kind: "cancel", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo });
|
||||
if (kind === "cancel" || kind === "return") {
|
||||
setReasonAction({ kind, batchId: selectedBatch.id, batchNo: selectedBatch.batchNo });
|
||||
return;
|
||||
}
|
||||
setReasonAction({
|
||||
@@ -880,10 +898,10 @@ export function PaymentBatchesPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />
|
||||
<StatusBadge status={selectedBatch.status} label={selectedBatch.status === "draft" && selectedBatch.relatedPaymentDecision === "reject" ? "已退回" : paymentBatchStatusLabel(selectedBatch.status)} />
|
||||
</div>
|
||||
<div className="detail-fact-grid text-sm xl:grid-cols-4">
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{selectedBatch.status === "draft" && selectedBatch.relatedPaymentDecision === "reject" ? "已退回" : paymentBatchStatusLabel(selectedBatch.status)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{formatMoney(selectedBatch.totalAmount)}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div>
|
||||
@@ -892,6 +910,9 @@ export function PaymentBatchesPage() {
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">付款账户</div><div className="detail-fact-card__value">{selectedBatch.paymentAccount ? `${selectedBatch.paymentAccount.accountName} · ${selectedBatch.paymentAccount.accountNumberMasked}` : "未冻结(仅支持人工付款)"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">银行制单</div><div className="detail-fact-card__value">{selectedBatch.paymentOrderExportCount > 0 ? `已生成 ${selectedBatch.paymentOrderExportCount} 次` : "尚未生成"}</div></div>
|
||||
</div>
|
||||
{selectedBatch.relatedPaymentDecision === "reject" ? <div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900" role="status">
|
||||
公司代表已退回:{selectedBatch.reviewComment}。{selectedBatch.status === "draft" ? "退回未释放占用,财务须取消原申请后重新提交。" : "原退回决定保留。"}
|
||||
</div> : null}
|
||||
{selectedBatch.status === "reviewed" && selectedBatch.paymentOrderExportBlockers.length > 0 ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900">
|
||||
<div className="font-medium">当前批次不能生成银行制单,可继续人工付款:</div>
|
||||
@@ -955,7 +976,7 @@ export function PaymentBatchesPage() {
|
||||
<div className="mt-2 detail-fact-grid text-sm md:grid-cols-4">
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value flex items-center gap-1"><span className="min-w-0 break-all">{item.bankAccountMasked || "-"}</span>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="ghost" className="h-11 w-11 shrink-0 p-0" aria-label="查看完整账号" title="查看完整账号" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}><Eye aria-hidden="true" className="size-4" /></Button> : null}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value flex items-center gap-1"><span className="min-w-0 break-all">{item.bankAccountMasked || "-"}</span>{(canRevealPayeeBank || loanCapabilities?.canReadPayeeAccount) && (["reviewed", "failed"].includes(selectedBatch.status) || relatedParty && loanCapabilities?.canReadPayeeAccount && selectedBatch.status === "draft") && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="ghost" className="h-11 w-11 shrink-0 p-0" aria-label="查看完整账号" title="查看完整账号" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}><Eye aria-hidden="true" className="size-4" /></Button> : null}</div></div>
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
|
||||
</div>
|
||||
{["reviewed", "failed"].includes(selectedBatch.status) ? (
|
||||
@@ -985,7 +1006,7 @@ export function PaymentBatchesPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||
付款复核通过后,由财务登记付款凭证号和实际付款日期。
|
||||
{relatedParty ? "公司代表确认后" : "付款复核通过后"},由财务登记付款凭证号和实际付款日期。
|
||||
</div>
|
||||
)}
|
||||
<RiskFlagsCard items={paymentRiskFlagsForDisplay(item.openRiskFlags)} title="关联开放风险" />
|
||||
@@ -1004,7 +1025,7 @@ export function PaymentBatchesPage() {
|
||||
: !requiresBankReceipt
|
||||
? "当前付款渠道不要求银行回单,核对付款结果后可直接登记完成。"
|
||||
: selectedBatch.status === "draft"
|
||||
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
|
||||
? relatedParty ? "公司代表确认后,由财务在线下完成付款并上传银行回单。" : "付款复核通过后,由财务在线下完成付款并上传银行回单。"
|
||||
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1040,7 +1061,7 @@ export function PaymentBatchesPage() {
|
||||
<div className="space-y-2 rounded-lg border p-3 text-sm" role="status">
|
||||
<p>{receiptReadinessQuery.isFetching ? "正在核对回单…" : hasReceiptSystemFailure ?
|
||||
"回单尚未满足付款登记条件,存在系统核验故障,请联系管理员排查下方失败原因。修复并获原件重传授权后,可重新上传回单;保留原失败记录,不要直接重复识别或登记付款。" : receiptReadinessQuery.isError ?
|
||||
resolveError(receiptReadinessQuery.error) : "回单尚未满足付款登记条件,请查看核验详情,完成识别、更正或独立复核。"}</p>
|
||||
resolveError(receiptReadinessQuery.error) : relatedParty ? "回单尚未满足付款登记条件,请完成识别、更正或公司代表确认。" : "回单尚未满足付款登记条件,请查看核验详情,完成识别、更正或独立复核。"}</p>
|
||||
<Button type="button" size="sm" variant="secondary" disabled={receiptReadinessQuery.isFetching}
|
||||
onClick={() => { void receiptReadinessQuery.refetch(); void receiptAttachmentsQuery.refetch(); }}>
|
||||
重新核对回单
|
||||
@@ -1078,8 +1099,8 @@ export function PaymentBatchesPage() {
|
||||
key_failed: "核验密钥不可用,请联系管理员修复;不要重复识别",
|
||||
snapshot_failed: "付款业务或银行快照不支持,请联系管理员核实",
|
||||
ocr_failed: "识别未完成,可重试", needs_review: "待核验", matched: "回单信息匹配,非银行验真",
|
||||
pending_review: "人工核验待独立复核", association_ready: "明细已关联",
|
||||
manual_approved: "人工核验已复核,非银行验真", manual_rejected: "人工核验未通过" }[attachment.ocrState]}
|
||||
pending_review: relatedParty ? "人工核验待公司代表确认" : "人工核验待独立复核", association_ready: "明细已关联",
|
||||
manual_approved: relatedParty ? "人工核验经公司代表确认,非银行验真" : "人工核验已复核,非银行验真", manual_rejected: "人工核验未通过" }[attachment.ocrState]}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1154,7 +1175,7 @@ export function PaymentBatchesPage() {
|
||||
<BusinessDetailActionPanel
|
||||
inlineActions
|
||||
className="hidden lg:block lg:static"
|
||||
status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />}
|
||||
status={<StatusBadge status={selectedBatch.status} label={selectedBatch.status === "draft" && selectedBatch.relatedPaymentDecision === "reject" ? "已退回" : paymentBatchStatusLabel(selectedBatch.status)} />}
|
||||
primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined}
|
||||
moreActions={paymentActionModel.more.length ? (
|
||||
<DropdownMenu>
|
||||
@@ -1199,12 +1220,12 @@ export function PaymentBatchesPage() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="secondary">更多</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" side="top">
|
||||
{paymentActionModel.more.map((kind) => (
|
||||
<DropdownMenuItem
|
||||
key={kind}
|
||||
disabled={paymentActionDisabled(kind)}
|
||||
className={kind === "cancel" ? "text-red-600 focus:text-red-600" : undefined}
|
||||
className={kind === "cancel" ? "min-h-11 text-red-600 focus:text-red-600" : "min-h-11"}
|
||||
onSelect={() => activatePaymentAction(kind)}
|
||||
>
|
||||
{paymentActionLabel(kind)}
|
||||
@@ -1247,7 +1268,7 @@ export function PaymentBatchesPage() {
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.batchNo}</div>
|
||||
<StatusBadge status={item.status} label={paymentBatchStatusLabel(item.status)} />
|
||||
<StatusBadge status={item.status} label={item.status === "draft" && item.relatedPaymentDecision === "reject" ? "已退回" : paymentBatchStatusLabel(item.status)} />
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">{item.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
@@ -1285,7 +1306,7 @@ export function PaymentBatchesPage() {
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={item.status} label={paymentBatchStatusLabel(item.status)} />
|
||||
<StatusBadge status={item.status} label={item.status === "draft" && item.relatedPaymentDecision === "reject" ? "已退回" : paymentBatchStatusLabel(item.status)} />
|
||||
{item.highRisk ? <span className="badge-warning">高风险</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
@@ -1322,10 +1343,12 @@ export function PaymentBatchesPage() {
|
||||
if (!open) setConfirmPaymentAction(null);
|
||||
}}
|
||||
kind={confirmPaymentAction?.kind ?? "financePayment"}
|
||||
title={relatedParty && confirmPaymentAction?.kind === "financeReview" ? "关联方付款确认" : undefined}
|
||||
confirmLabel={relatedParty && confirmPaymentAction?.kind === "financeReview" ? "代表公司确认" : undefined}
|
||||
targetName={confirmPaymentAction?.batchNo}
|
||||
description={
|
||||
confirmPaymentAction?.kind === "financeReview"
|
||||
? confirmPaymentAction.highRisk
|
||||
? relatedParty ? "您同时是收款人,将以公司代表身份确认本次付款。请核对借款分配、账户和金额;本次按关联方确认留痕。" : confirmPaymentAction.highRisk
|
||||
? `该批次金额为 ${formatMoney(confirmPaymentAction.totalAmount)},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。`
|
||||
: "确认后将完成一次独立复核并写入付款批次审计记录。"
|
||||
: confirmPaymentAction?.highRisk
|
||||
@@ -1381,16 +1404,16 @@ export function PaymentBatchesPage() {
|
||||
>
|
||||
<DialogContent className="max-w-md p-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : reasonAction?.kind === "paymentOrder" ? (reasonAction.reexport ? "重新下载银行制单" : "下载银行制单") : reasonAction?.kind === "payeeBank" ? "查看收款完整账号" : "取消付款批次"}</DialogTitle>
|
||||
<DialogTitle>{reasonAction?.kind === "return" ? "退回付款申请" : reasonAction?.kind === "voidReceipt" ? "作废银行回单" : reasonAction?.kind === "paymentOrder" ? (reasonAction.reexport ? "重新下载银行制单" : "下载银行制单") : reasonAction?.kind === "payeeBank" ? "查看收款完整账号" : "取消付款批次"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{reasonAction?.kind === "voidReceipt"
|
||||
{reasonAction?.kind === "return" ? "您同时是本笔收款人,将以公司代表身份退回。原决定和理由会保留;占用保持不变,由财务取消原申请后重新提交。" : reasonAction?.kind === "voidReceipt"
|
||||
? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。`
|
||||
: reasonAction?.kind === "paymentOrder"
|
||||
? reasonAction.reexport
|
||||
? `批次:${reasonAction.batchNo}。重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。`
|
||||
: `批次:${reasonAction.batchNo}。文件包含完整银行账号,仅用于导入企业网银。`
|
||||
: reasonAction?.kind === "payeeBank"
|
||||
? `报销单:${reasonAction.entityCode}。本次查看会记录查看人、批次项、时间和原因。`
|
||||
? `${selectedBatch?.entityType === "loan_repayment" ? "还款单" : "报销单"}:${reasonAction.entityCode}。本次查看会记录查看人、批次项、时间和原因。`
|
||||
: reasonAction
|
||||
? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。`
|
||||
: "请填写原因。"}
|
||||
@@ -1402,10 +1425,10 @@ export function PaymentBatchesPage() {
|
||||
<Textarea
|
||||
value={actionReason}
|
||||
onChange={(event) => setActionReason(event.target.value)}
|
||||
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
|
||||
aria-label={reasonAction?.kind === "return" ? "付款退回理由" : reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
|
||||
placeholder={reasonAction?.kind === "return" ? "说明需要财务更正的内容" : reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-zinc-500">
|
||||
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
|
||||
@@ -1425,6 +1448,7 @@ export function PaymentBatchesPage() {
|
||||
disabled={
|
||||
(reasonAction?.kind !== "paymentOrder" && actionReason.trim().length < 2) ||
|
||||
cancelMutation.isPending ||
|
||||
returnMutation.isPending ||
|
||||
voidReceiptMutation.isPending ||
|
||||
paymentOrderMutation.isPending ||
|
||||
revealPayeeBankMutation.isPending
|
||||
@@ -1432,7 +1456,9 @@ export function PaymentBatchesPage() {
|
||||
onClick={() => {
|
||||
if (!reasonAction) return;
|
||||
const reason = actionReason.trim();
|
||||
if (reasonAction.kind === "cancel") {
|
||||
if (reasonAction.kind === "return") {
|
||||
returnMutation.mutate({ batchId: reasonAction.batchId, reason });
|
||||
} else if (reasonAction.kind === "cancel") {
|
||||
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
|
||||
} else if (reasonAction.kind === "paymentOrder") {
|
||||
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, batchNo: reasonAction.batchNo });
|
||||
@@ -1447,7 +1473,7 @@ export function PaymentBatchesPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reasonAction?.kind === "voidReceipt" ? "确认作废" : reasonAction?.kind === "paymentOrder" ? "确认下载" : reasonAction?.kind === "payeeBank" ? "确认查看" : "确认取消"}
|
||||
{reasonAction?.kind === "return" ? "确认退回" : reasonAction?.kind === "voidReceipt" ? "确认作废" : reasonAction?.kind === "paymentOrder" ? "确认下载" : reasonAction?.kind === "payeeBank" ? "确认查看" : "确认取消"}
|
||||
</Button> : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1458,7 +1484,7 @@ export function PaymentBatchesPage() {
|
||||
key={`${me.id}:${selectedBatch.id}:${verificationTarget.attachmentId}`}
|
||||
actorId={me.id} batchId={selectedBatch.id} attachmentId={verificationTarget.attachmentId}
|
||||
makerId={selectedBatch.createdBy ?? null}
|
||||
canManage={Boolean(canEditReceiptAttachments)} canReview={Boolean(canReviewBatch)}
|
||||
canManage={Boolean(canEditReceiptAttachments)} canReview={Boolean(canReviewBatch && (!relatedParty || loanCapabilities?.canReviewReconciliation))} relatedParty={relatedParty}
|
||||
items={selectedBatch.items.map((item) => ({ id: item.id, label: `${item.entityCode} · ${formatMoney(item.amount)}` }))}
|
||||
onClose={() => setVerificationTarget(null)}
|
||||
onChanged={() => { void receiptAttachmentsQuery.refetch(); }}
|
||||
|
||||
@@ -304,8 +304,8 @@ const routeElements = (
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="company-loans" element={<RequirePermission anyOf={["loan.manage"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
|
||||
<Route path="mobile/company-loans" element={<RequirePermission anyOf={["loan.manage"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
|
||||
<Route path="company-loans" element={<RequirePermission anyOf={["loan.manage", "payment.batch.review"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
|
||||
<Route path="mobile/company-loans" element={<RequirePermission anyOf={["loan.manage", "payment.batch.review"]}>{withRouteFallback(<CompanyLoansPage />)}</RequirePermission>} />
|
||||
|
||||
<Route
|
||||
path="payment-batches"
|
||||
|
||||
在新工单中引用
屏蔽一个用户