文件
hyb-oa/server/tests/unit/services/loanApprovalPolicy.test.js
T
wangsli ce11dea976 支持财务经办与公司代表关联方还款确认(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

107 行
7.8 KiB
JavaScript

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();
});
});