支持财务经办与公司代表关联方还款确认(09-17-company-loan-principal-repayment)
公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。 仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。 贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。 授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。 本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。 已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。 按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。 限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。 生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
这个提交包含在:
@@ -2,6 +2,7 @@ const { createHash } = require("node:crypto");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { decryptStringOrThrow } = require("./crypto");
|
||||
const { auditLoan } = require("./companyLoans");
|
||||
const approvalPolicy = require("./loanApprovalPolicy");
|
||||
|
||||
// Loan bank-result reconciliation S1 (design.md 3.1, 3.3; implement.md S1).
|
||||
// Payment-order exports and bank submissions are separate facts: one export
|
||||
@@ -17,6 +18,7 @@ function loanPayments() {
|
||||
}
|
||||
|
||||
async function recordLoanPaymentOrderExport(tx, batch, request, generated) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
const previous = await tx.paymentOrderExport.count({ where: { payment_batch_id: batch.id } });
|
||||
return tx.paymentOrderExport.create({ data: {
|
||||
payment_batch_id: batch.id, sequence: previous + 1, exported_by: request.employee.id,
|
||||
@@ -32,6 +34,7 @@ function assertSubmissionBatch(batch) {
|
||||
}
|
||||
|
||||
async function registerBankSubmission(tx, batch, request, input) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
assertSubmissionBatch(batch);
|
||||
if (!SUBMISSION_CHANNELS.includes(input.channel)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
if (input.exportId) {
|
||||
@@ -67,6 +70,7 @@ async function registerBankSubmission(tx, batch, request, input) {
|
||||
}
|
||||
|
||||
async function withdrawBankSubmission(tx, batch, request, submissionId, reason) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
assertSubmissionBatch(batch);
|
||||
const submission = await tx.loanBankSubmission.findUnique({ where: { id: submissionId } });
|
||||
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") {
|
||||
@@ -112,6 +116,7 @@ async function activeAttestation(tx, batch) {
|
||||
// A stale open/approved attestation (lists changed) is superseded in the same
|
||||
// transaction so the batch can always move forward.
|
||||
async function proposeSubmissionAttestation(tx, batch, request, note) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
assertSubmissionBatch(batch);
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (!facts.active.length && (!facts.exports.length || facts.submissions.length)) throw buildBusinessValidationError("loan.submission_required", {}, 409);
|
||||
@@ -140,16 +145,17 @@ async function reviewSubmissionAttestation(tx, batch, request, attestationId, in
|
||||
const { readLoanRepayment, activeReviewer } = loanPayments();
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
const excluded = [attestation.proposed_by, batch.created_by, repayment.submitted_by, repayment.lender.employee_id,
|
||||
const excluded = [attestation.proposed_by, batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment),
|
||||
batch.payment_order_last_exported_by, facts.legacy?.last_exported_by,
|
||||
...facts.exports.map((item) => item.exported_by), ...facts.submissions.map((item) => item.registered_by)];
|
||||
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
|
||||
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true, batch });
|
||||
if (input.decision === "approve" && !attestationMatches(attestation, facts)) throw buildBusinessValidationError("loan.version_changed", {}, 409);
|
||||
const reviewed = await tx.loanBankSubmissionAttestation.update({ where: { id: attestation.id }, data: {
|
||||
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: attestation.id, conflictIds: excluded }),
|
||||
status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id,
|
||||
reviewed_at: new Date(), review_note: input.reason } });
|
||||
await auditLoan(tx, request, input.decision === "approve" ? "LOAN_SUBMISSION_ATTESTATION_APPROVED" : "LOAN_SUBMISSION_ATTESTATION_REJECTED",
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id });
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id, ...approvalPolicy.auditContext(reviewed) });
|
||||
return reviewed;
|
||||
}
|
||||
|
||||
@@ -163,6 +169,7 @@ async function assertSingleAttestedSubmission(tx, batch) {
|
||||
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
const approved = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if (!attestationMatches(approved, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
await approvalPolicy.assertDecision(tx, batch, approved);
|
||||
const [submission] = facts.active;
|
||||
// The single export (if any) must be the one this submission imported.
|
||||
if (facts.exports.length && submission.export_id !== facts.exports[0].id) throw buildBusinessValidationError("loan.export_unlinked", {}, 409);
|
||||
|
||||
在新工单中引用
屏蔽一个用户