支持财务经办与公司代表关联方还款确认(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 行删除
@@ -1,11 +1,15 @@
{
"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,15 +25,15 @@
"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.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": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。",
@@ -0,0 +1,106 @@
const policy = require("../../../src/services/loanApprovalPolicy");
function scenario() {
const batch = { id: "batch", entity_type: "loan_repayment", loan_repayment_id: "repayment", created_by: "finance", reviewed_by: "ma" };
const repayment = { id: "repayment", approval_mode: "related_party", lender_id: "lender", legal_entity_id: "company", submitted_by: "finance",
approval_context: { approverId: "ma" }, lender: { employee_id: "ma" }, allocations: [{ loan_id: "loan" }] };
const grant = { id: "grant", version: 1, lender_id: "lender", legal_entity_id: "company", approver_id: "ma", loans: [{ loan_id: "loan" }],
events: [{ id: "confirmed", action: "confirm", payment_enabled: true, reconcile_enabled: true }] };
const tx = { loanRepayment: { findUniqueOrThrow: vi.fn(async () => repayment) },
loanRelatedAuthorization: { findFirst: vi.fn(async () => grant), findUnique: vi.fn(async () => grant) },
employee: { findUnique: vi.fn(async () => ({ status: "active", archived_at: null, roles: [{ role_code: "manager" }] })) } };
for (const name of ["paymentOrderExport", "loanBankSubmission", "loanBankSubmissionAttestation", "paymentBatchReceiptAttachment",
"paymentReceiptManualProposal", "loanReceiptAttribution", "loanBankTerminalEvidence", "loanBankTerminalFact", "loanReleaseRecovery", "loanBankResultEvent"]) {
tx[name] = { findMany: vi.fn(async () => []) };
}
const saved = { id: "decision", reviewed_by: "ma", loan_approval_mode: "related_party", loan_authorization_id: "grant", loan_approval_context: {
version: 1, targetId: "decision", repaymentId: "repayment", batchId: "batch", purpose: "reconcile", approverId: "ma",
lenderId: "lender", legalEntityId: "company", loanIds: ["loan"], payeeConflictWaived: true,
authorizationVersion: 1, authorizationEventId: "confirmed", conflictIds: ["finance"],
} };
return { tx, batch, repayment, grant, saved };
}
describe("related loan authority and historical decisions", () => {
it("permits a scoped payee to decide without deleting other conflict reasons", async () => {
const { tx, batch, repayment } = scenario();
expect(policy.payeeConflicts(repayment)).toEqual([]);
await expect(policy.assertReviewActor(tx, batch, "ma")).resolves.toBeUndefined();
batch.created_by = "ma";
await expect(policy.assertReviewActor(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } });
});
it.each([
["paymentOrderExport", "exported_by"], ["loanBankSubmission", "registered_by"], ["loanBankSubmissionAttestation", "proposed_by"],
["paymentBatchReceiptAttachment", "uploaded_by"], ["paymentReceiptManualProposal", "proposed_by"], ["loanReceiptAttribution", "proposed_by"],
["loanBankTerminalEvidence", "uploaded_by"], ["loanBankTerminalFact", "proposed_by"], ["loanReleaseRecovery", "started_by"],
["loanReleaseRecovery", "submitted_by"], ["loanBankResultEvent", "proposed_by"],
])("refuses the payee who also produced %s.%s, including voided/superseded history", async (model, field) => {
const { tx, batch, saved } = scenario();
tx[model].findMany.mockResolvedValue([{ [field]: "ma", status: "superseded", archived_at: new Date() }]);
await expect(policy.assertReviewActor(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } });
await expect(policy.assertDecision(tx, batch, saved)).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } });
const where = tx[model].findMany.mock.calls[0][0].where;
expect(where).not.toHaveProperty("status"); expect(where).not.toHaveProperty("archived_at");
});
it("keeps the payee excluded on an old independent repayment", async () => {
const { tx, batch, repayment, saved } = scenario(); repayment.approval_mode = "independent";
expect(policy.payeeConflicts(repayment)).toEqual(["ma"]);
await expect(policy.assertDecision(tx, batch, saved)).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } });
});
it("does not let finance or another manager use the designated representative's grant", async () => {
const { tx, batch } = scenario();
for (const actor of ["finance", "other-manager", "technical-employee"]) {
await expect(policy.assertReviewActor(tx, batch, actor)).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } });
}
await expect(policy.assertMaker(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } });
});
it.each(["loan", "company", "lender", "approver"])("rejects a current grant outside the %s scope without falling back", async (kind) => {
const { tx, repayment, grant } = scenario();
if (kind === "loan") grant.loans = [{ loan_id: "other" }];
else grant[{ company: "legal_entity_id", lender: "lender_id", approver: "approver_id" }[kind]] = "other";
await expect(policy.currentForRepayment(tx, repayment)).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } });
expect(tx.loanRelatedAuthorization.findFirst).toHaveBeenCalledTimes(1);
});
it("separates stopping payment from reconciliation and preserves already-made decisions", async () => {
const { tx, batch, repayment, grant, saved } = scenario();
grant.events.push({ id: "stop-payment", action: "stop_payment", payment_enabled: false, reconcile_enabled: true });
await expect(policy.currentForRepayment(tx, repayment, "payment")).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } });
await expect(policy.currentForRepayment(tx, repayment)).resolves.toBe(grant);
grant.events.push({ id: "stop-reconcile", action: "stop_reconcile", payment_enabled: false, reconcile_enabled: false });
await expect(policy.currentForRepayment(tx, repayment)).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } });
tx.employee.findUnique.mockResolvedValue({ status: "archived", roles: [] });
await expect(policy.assertDecision(tx, batch, saved)).resolves.toBeUndefined();
});
it("requires current employee eligibility for a new decision", async () => {
const { tx, batch } = scenario();
tx.employee.findUnique.mockResolvedValue({ status: "archived", roles: [{ role_code: "manager" }] });
await expect(policy.assertReviewActor(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } });
});
it.each(["missing", "purpose", "batchId", "targetId", "repaymentId", "lenderId", "legalEntityId", "loanIds", "conflictIds", "authorizationVersion", "authorizationEventId", "approverId", "payeeConflictWaived"])("rejects an approved record with invalid %s provenance at final use", async (field) => {
const { tx, batch, saved } = scenario();
if (field === "missing") saved.loan_approval_context = null;
else saved.loan_approval_context[field] = field === "loanIds" ? ["other"] : "invalid";
await expect(policy.assertDecision(tx, batch, saved)).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } });
});
it("never accepts a related-party Q6 grant", async () => {
const { tx, batch } = scenario();
await expect(policy.assertNotRelatedQ6(tx, batch)).rejects.toMatchObject({ details: { reasonCode: "loan.related_q6_unavailable" } });
});
it("never treats a returned application as an approved payment", async () => {
const { tx, batch, saved } = scenario();
saved.loan_approval_context.purpose = "payment";
saved.loan_approval_context.decision = "reject";
await expect(policy.assertDecision(tx, batch, saved, { purpose: "payment" })).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } });
saved.loan_approval_context.decision = "approve";
await expect(policy.assertDecision(tx, batch, saved, { purpose: "payment" })).resolves.toBeUndefined();
});
});
@@ -167,10 +167,14 @@ describe("workspace small-team navigation defaults", () => {
"合同",
"项目",
"资产",
"公司借款",
"团队",
"请假记录",
]);
expect(managerNavigation.sections.main.find((item) => item.key === "companyLoans")?.href).toBe("/company-loans");
expect(manager.permissionCodes).not.toContain("loan.manage");
const adminOnly = {
id: "admin-1",
department_id: "dept-1",