支持财务经办与公司代表关联方还款确认(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 行删除
+10 -6
查看文件
@@ -1,11 +1,15 @@
const ERROR_REASONS = [
{"code": "loan.bank_result_reconcile_required", "domain": "loan", "audience": "user", "defaultMessage": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。", "recovery": "follow_guidance"},
{"code":"loan.related_authorization_required","domain":"loan","audience":"user","defaultMessage":"本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。","recovery":"follow_guidance"},
{"code":"loan.related_scope_invalid","domain":"loan","audience":"user","defaultMessage":"关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。","recovery":"follow_guidance"},
{"code":"loan.related_actor_conflict","domain":"loan","audience":"user","defaultMessage":"关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。","recovery":"follow_guidance"},
{"code":"loan.related_q6_unavailable","domain":"loan","audience":"user","defaultMessage":"关联方模式不支持人工确认未提交;没有符合要求的银行终态证据时,额度继续占用。","recovery":"follow_guidance"},
{"code": "loan.bank_result_reconcile_required", "domain": "loan", "audience": "user", "defaultMessage": "银行对账事实、归属或证据尚不满足条件,请补齐材料并按本批次规则完成确认;占用或冻结继续保留。", "recovery": "follow_guidance"},
{"code": "loan.lender_frozen", "domain": "loan", "audience": "user", "defaultMessage": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。", "recovery": "follow_guidance"},
{"code": "loan.not_submitted_invalid", "domain": "loan", "audience": "user", "defaultMessage": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。", "recovery": "follow_guidance"},
{"code":"loan.recovery_required","domain":"loan","audience":"user","defaultMessage":"释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。","recovery":"follow_guidance"},
{"code":"loan.recovery_required","domain":"loan","audience":"user","defaultMessage":"释放依据已进入补证流程,须按本批次规则完成最新一轮确认后才能取消释放。","recovery":"follow_guidance"},
{"code":"loan.recovery_unavailable","domain":"loan","audience":"user","defaultMessage":"当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。","recovery":"follow_guidance"},
{"code":"loan.recovery_exists","domain":"loan","audience":"user","defaultMessage":"已有补证中或有效的恢复复核,请继续处理当前轮次。","recovery":"follow_guidance"},
{"code":"loan.recovery_stale","domain":"loan","audience":"user","defaultMessage":"补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。","recovery":"follow_guidance"},
{"code":"loan.recovery_stale","domain":"loan","audience":"user","defaultMessage":"补证材料、提交清单或候选批次已变化,请重新发起恢复并按本批次规则完成确认。","recovery":"follow_guidance"},
{"code":"integration.erp_retired","domain":"integration","audience":"user","defaultMessage":"ERP 集成已停用,历史记录仅供查询,不能重新投递。","recovery":"follow_guidance"},
{"code":"loan.invalid_input","domain":"loan","audience":"user","defaultMessage":"借款信息不符合要求,请检查必填项、金额和日期。","recovery":"follow_guidance"},
@@ -21,17 +25,17 @@ const ERROR_REASONS = [
{"code":"loan.balance_unconfirmed","domain":"loan","audience":"user","defaultMessage":"借款余额尚未核实,不能申请归还本金。","recovery":"follow_guidance"},
{"code":"loan.allocation_invalid","domain":"loan","audience":"user","defaultMessage":"分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。","recovery":"follow_guidance"},
{"code":"loan.reviewer_required","domain":"loan","audience":"user","defaultMessage":"没有符合独立复核要求的经理或总经理;制单人、提交人和收款人不能复核。","recovery":"follow_guidance"},
{"code":"loan.independent_review_required","domain":"loan","audience":"user","defaultMessage":"本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。","recovery":"follow_guidance"},
{"code":"loan.independent_review_required","domain":"loan","audience":"user","defaultMessage":"当前人员不符合本批次确认规则,请核对业务权限、确认模式及经办身份冲突。","recovery":"follow_guidance"},
{"code":"loan.bank_state_unsafe","domain":"loan","audience":"user","defaultMessage":"银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。","recovery":"follow_guidance"},
{"code":"loan.handoff_not_ready","domain":"loan","audience":"user","defaultMessage":"本金付款尚未成功登记,或已完成会计交接,请核对付款状态。","recovery":"follow_guidance"},
{"code":"loan.payment_not_ready","domain":"loan","audience":"user","defaultMessage":"还款申请或付款状态已变化,请刷新核对后继续。","recovery":"follow_guidance"},
{"code":"loan.lender_in_flight","domain":"loan","audience":"user","defaultMessage":"该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_required","domain":"loan","audience":"user","defaultMessage":"该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_required","domain":"loan","audience":"user","defaultMessage":"该回单可能属于同一出借人的其他同金额还款,须登记归属依据并按本批次规则完成确认,确认前不能核销或释放额度。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_not_required","domain":"loan","audience":"user","defaultMessage":"本还款没有同金额的其他候选批次,无需确认回单归属。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_unavailable","domain":"loan","audience":"user","defaultMessage":"尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_exists","domain":"loan","audience":"user","defaultMessage":"本还款已有待复核或已通过的回单归属确认,请勿重复提交。","recovery":"follow_guidance"},
{"code":"loan.submission_required","domain":"loan","audience":"user","defaultMessage":"须先登记本批次实际向银行提交的指令,才能确认提交清单。","recovery":"follow_guidance"},
{"code":"loan.submission_list_unverified","domain":"loan","audience":"user","defaultMessage":"银行提交清单尚未经独立复核,或复核后已有变化,须先完成提交清单复核。","recovery":"follow_guidance"},
{"code":"loan.submission_list_unverified","domain":"loan","audience":"user","defaultMessage":"银行提交清单尚未完成有效确认,或确认后已有变化,须按本批次规则重新确认清单。","recovery":"follow_guidance"},
{"code":"loan.submission_history_unverified","domain":"loan","audience":"user","defaultMessage":"该批次为历史批次,导出明细及与银行提交的对应关系待核实,当前不能确认失败或释放额度。","recovery":"follow_guidance"},
{"code":"loan.multiple_submissions_unresolved","domain":"loan","audience":"user","defaultMessage":"该批次有多次导出或多次银行提交,须逐次取得终态证据,当前不能确认失败。","recovery":"follow_guidance"},
{"code":"loan.voided_receipt_unresolved","domain":"loan","audience":"user","defaultMessage":"已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。","recovery":"follow_guidance"},
@@ -19,6 +19,7 @@ const NOTIFICATION_TYPES = Object.freeze({
reimbursementPaid: 'reimbursement.paid',
paymentBatchReviewRequested: 'payment_batch.review_requested',
paymentBatchExecutionRequested: 'payment_batch.execution_requested',
paymentBatchReturned: 'payment_batch.returned',
leaveApproved: 'leave.approved',
leaveRejected: 'leave.rejected',
leaveReturned: 'leave.returned',
@@ -46,6 +47,7 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
NOTIFICATION_TYPES.reimbursementPaid,
NOTIFICATION_TYPES.paymentBatchReviewRequested,
NOTIFICATION_TYPES.paymentBatchExecutionRequested,
NOTIFICATION_TYPES.paymentBatchReturned,
NOTIFICATION_TYPES.leaveApproved,
NOTIFICATION_TYPES.leaveRejected,
NOTIFICATION_TYPES.leaveReturned,
@@ -73,6 +75,7 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
[NOTIFICATION_TYPES.reimbursementPaid]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.paymentBatchReviewRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.paymentBatchExecutionRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.paymentBatchReturned]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.leaveApproved]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.leaveReturned]: NOTIFICATION_DOMAIN_SCOPES.leave,
+70 -3
查看文件
@@ -13,6 +13,7 @@ const {
} = require("../services/companyLoans");
const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = require("../services/storage");
const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy");
const approvalPolicy = require("../services/loanApprovalPolicy");
const UUID = z.string().uuid();
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString);
@@ -67,6 +68,64 @@ async function validateEvidenceFile(input) {
async function companyLoansRoutes(fastify) {
const auth = { preHandler: fastify.authenticate };
const isFinance = (request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) &&
fastify.hasPermission(request.employee, "loan.manage");
async function readAuthorization(request, id) {
const record = await fastify.prisma.loanRelatedAuthorization.findUnique({ where: { id }, include: approvalPolicy.INCLUDE });
if (!record) throw loanNotFound();
if (!isFinance(request) && (record.approver_id !== request.employee.id ||
!await approvalPolicy.reviewerRole(fastify.prisma, request.employee.id))) {
throw buildBusinessValidationError("loan.related_actor_conflict", {}, 403);
}
return record;
}
async function mapAuthorization(request, record) {
const [lender, approver, current] = await Promise.all([
fastify.prisma.loanLender.findUnique({ where: { id: record.lender_id }, include: LENDER_INCLUDE }),
fastify.prisma.employee.findUnique({ where: { id: record.approver_id }, select: { name: true } }),
approvalPolicy.currentAuthorization(fastify.prisma, record.lender_id),
]);
const mapped = approvalPolicy.mapAuthorization(record);
return { ...mapped, current: current?.id === record.id, lenderName: lender.name,
legalEntityName: lender.legal_entity.name, approverName: approver.name,
actions: { confirm: !mapped.confirmed && (!current || current.version < record.version) &&
request.employee.id === record.approver_id && request.employee.id !== record.proposed_by &&
lender.employee_id === record.approver_id && lender.identity_version === record.identity_version,
stopPayment: current?.id === record.id && mapped.paymentEnabled,
stopReconcile: current?.id === record.id && mapped.reconcileEnabled } };
}
fastify.get("/loan-related-authorizations", auth, async (request, reply) => {
const finance = isFinance(request);
if (!finance && !await approvalPolicy.reviewerRole(fastify.prisma, request.employee.id)) ensureLoanManager(fastify, request);
const query = parse(LIST, request.query ?? {});
const where = { ...(finance ? {} : { approver_id: request.employee.id }), ...(query.lenderId ? { lender_id: query.lenderId } : {}) };
const [items, total] = await Promise.all([
fastify.prisma.loanRelatedAuthorization.findMany({ where, include: approvalPolicy.INCLUDE,
orderBy: [{ proposed_at: "desc" }, { id: "desc" }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
fastify.prisma.loanRelatedAuthorization.count({ where }),
]);
const mapped = await Promise.all(items.map((record) => mapAuthorization(request, record)));
return sendData(reply, request, { items: mapped, total });
});
fastify.get("/loan-related-authorizations/:id", auth, async (request, reply) => {
const record = await readAuthorization(request, parse(UUID, request.params.id));
const loans = await fastify.prisma.companyLoan.findMany({ where: { id: { in: record.loans.map((l) => l.loan_id) } }, include: LOAN_INCLUDE });
return sendData(reply, request, { ...await mapAuthorization(request, record), loans: loans.map((l) => mapLoan(fastify.config, l)) });
});
fastify.post("/loan-related-authorizations", auth, async (request, reply) => {
ensureLoanManager(fastify, request);
const body = parse(z.object({ lenderId: UUID, loanIds: z.array(UUID).min(1).max(100),
requestKey: UUID, basis: z.string().trim().min(10).max(1000) }).strict(), request.body);
const result = await fastify.prisma.$transaction((tx) => approvalPolicy.proposeAuthorization(tx, request, body));
return sendData(reply, request, approvalPolicy.mapAuthorization(result));
});
fastify.post("/loan-related-authorizations/:id/actions", auth, async (request, reply) => {
const id = parse(UUID, request.params.id);
await readAuthorization(request, id);
const body = parse(z.object({ action: z.enum(["confirm", "stop_payment", "stop_reconcile"]), reason: TEXT }).strict(), request.body);
const result = await fastify.prisma.$transaction((tx) => approvalPolicy.authorizationAction(tx, request, id, body));
return sendData(reply, request, approvalPolicy.mapAuthorization(result));
});
fastify.get("/loan-lenders/identity-options", auth, async (request, reply) => {
ensureLoanManager(fastify, request);
const query = parse(z.object({ q: z.string().trim().max(100).default("") }).strict(), request.query ?? {});
@@ -270,15 +329,22 @@ async function companyLoansRoutes(fastify) {
const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && fastify.hasPermission(request.employee, "loan.manage");
if (!finance && (!request.employee.roleCodes.some((r) => ["manager", "ceo"].includes(r)) || !fastify.hasPermission(request.employee, "payment.batch.review"))) ensureLoanManager(fastify, request);
const id = parse(UUID, request.params.id); const evidenceId = parse(UUID, request.params.evidenceId);
const { reason, paymentBatchId } = parse(z.object({ reason: TEXT, paymentBatchId: UUID.optional() }).strict(), request.body);
const { reason, paymentBatchId, authorizationId } = parse(z.object({ reason: TEXT, paymentBatchId: UUID.optional(), authorizationId: UUID.optional() }).strict()
.refine((v) => !(v.paymentBatchId && v.authorizationId)), request.body);
if (!finance) {
if (authorizationId) {
const record = await readAuthorization(request, authorizationId);
if (!record.loans.some((loan) => loan.loan_id === id)) throw loanNotFound();
} else {
if (!paymentBatchId) ensureLoanManager(fastify, request);
const { readLoanRepayment, activeReviewer } = require("../services/companyLoanPayments");
const batch = await fastify.prisma.paymentBatch.findUnique({ where: { id: paymentBatchId }, include: { items: true } });
if (!batch || batch.entity_type !== "loan_repayment") throw loanNotFound();
const repayment = await readLoanRepayment(fastify.prisma, batch);
await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, repayment.lender.employee_id]);
await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment)],
{ batch, purpose: "read" });
if (!repayment.allocations.some((a) => a.loan_id === id)) throw loanNotFound();
}
}
const item = await fastify.prisma.loanEvidence.findFirst({ where: { id: evidenceId, loan_id: id, archived_at: null, governance_status: "approved" } });
if (!item) throw loanNotFound();
@@ -294,7 +360,8 @@ async function companyLoansRoutes(fastify) {
if (error instanceof StorageNotFoundError) throw loanNotFound();
throw error;
}
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOADED", id, { evidenceId, reason });
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOADED", id, { evidenceId, reason,
...(authorizationId ? { authorizationId } : {}), ...(paymentBatchId ? { paymentBatchId } : {}) });
return reply.header("Cache-Control", "private, no-store").header("X-Content-Type-Options", "nosniff")
.header("Content-Security-Policy", "default-src 'none'; sandbox")
.header("Content-Disposition", buildAttachmentContentDisposition(validated.originalName))
+57 -27
查看文件
@@ -1,5 +1,6 @@
const { resolveNumberingContext, issueBusinessNumber } = require("../services/businessNumbering");
const { ensureLoanManager, auditLoan, createIdempotently, lockLender } = require("../services/companyLoans");
const loanApprovalPolicy = require("../services/loanApprovalPolicy");
const { loadPendingPaymentResponsibility } = require("../services/paymentResponsibility");
const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
activeReviewer, settleLoanRepayment, cancelLoanRepayment, mapRepayment, confirmLoanBankFailure,
@@ -91,9 +92,11 @@ const PATCH_ITEM_SCHEMA = z.object({
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const REVIEW_SCHEMA = z.object({
decision: z.enum(["approve", "reject"]).default("approve"),
comment: z.string().trim().max(500).optional(),
resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(),
}).strict();
}).strict().refine((body) => body.decision !== "reject" || (body.comment?.length ?? 0) >= 2,
{ path: ["comment"], message: "return_reason_required" });
const REASON_SCHEMA = z.object({
reason: z.string().trim().min(2).max(500),
@@ -508,9 +511,10 @@ function summarizeBatchRisk(batch) {
const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0);
const totalAmount = toMoneyString(totalAmountValue);
const highRisk = totalAmountValue >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT;
const completedReviewCount = batch.reviewed_at ? 1 : 0;
const returned = batch.loan_approval_mode === "related_party" && batch.loan_approval_context?.decision === "reject";
const completedReviewCount = batch.reviewed_at && !returned ? 1 : 0;
const requiredReviewCount = 1;
const nextStep = completedReviewCount >= requiredReviewCount
const nextStep = returned ? "returned" : completedReviewCount >= requiredReviewCount
? "ready_for_execute"
: "first_review";
@@ -933,6 +937,7 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
highRisk: batchRisk.highRisk,
reviewRequirement: batchRisk.reviewRequirement,
reviewComment: batch.review_comment ?? null,
relatedPaymentDecision: batch.loan_approval_mode === "related_party" ? batch.loan_approval_context?.decision ?? null : null,
secondaryReviewComment: batch.secondary_review_comment ?? null,
executionSummaryJson: batch.execution_summary_json ?? null,
receiptAttachmentCount: batch._count?.receipt_attachments ?? 0,
@@ -1008,6 +1013,9 @@ function buildPaymentBatchProcessingProgress(batch, actorNames, responsibility =
requiresBankReceipt: requiresBankReceipt(batch),
receiptCount: batch._count?.receipt_attachments ?? 0,
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
relatedParty: batch.approval_mode === "related_party",
relatedPaymentReturned: batch.loan_approval_mode === "related_party" && batch.loan_approval_context?.decision === "reject",
reviewComment: batch.review_comment,
});
const actorIds = {
creator: batch.created_by,
@@ -1038,6 +1046,7 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
loadLegacyPayees(tx, batch, config),
includeExpenseItems ? loadReimbursementExpenseItems(tx, batch) : Promise.resolve(new Map()),
]);
const repayment = batch.entity_type === "loan_repayment" ? await readLoanRepayment(tx, batch) : null;
const actorIds = Array.from(new Set([
batch.created_by,
batch.reviewed_by,
@@ -1053,8 +1062,7 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
const actorNames = new Map(actors.map((actor) => [actor.id, actor.name]));
return {
...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => {
const repayment = await readLoanRepayment(tx, batch);
return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config),
return { ...mapRepayment(repayment, batch), actionCapabilities: await loanApprovalPolicy.capabilities(tx, batch, repayment, options.employee), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config),
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch),
notSubmitted: await require("../services/loanNotSubmitted").state(tx, batch),
failureAttributions: await Promise.all((await tx.loanReceiptAttribution.findMany({ where: { payment_batch_id: batch.id, kind: "failure" }, orderBy: { proposed_at: "desc" } })).map(async (a) => ({
@@ -1070,7 +1078,7 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
includeExpenseItems,
expenseItemsByEntityId,
}),
processingProgress: buildPaymentBatchProcessingProgress(batch, actorNames, await loadPendingPaymentResponsibility(tx, batch)),
processingProgress: buildPaymentBatchProcessingProgress({ ...batch, approval_mode: repayment?.approval_mode }, actorNames, await loadPendingPaymentResponsibility(tx, batch)),
};
}
@@ -1488,20 +1496,29 @@ async function paymentBatchesRoutes(fastify) {
if (!request.params?.id) return;
const route = request.routeOptions.url;
const reviewAction = request.method === "POST" && (route.endsWith("/review") || route.endsWith("/bank-failure-review"));
const revealAction = request.method === "POST" && route.endsWith("/payee-bank/reveal");
if (request.method === "GET") ensureBatchReadPermission(fastify, request);
else if (reviewAction) ensureBatchReviewPermission(fastify, request);
else if (route.endsWith("/payment-order")) ensurePaymentOrderExportPermission(fastify, request);
else if (route.endsWith("/payee-bank/reveal")) ensurePayeeBankRevealPermission(fastify, request);
else if (revealAction) ensureBatchReadPermission(fastify, request);
else ensureBatchManagePermission(fastify, request);
const id = parseId(request.params.id);
const batch = await fastify.prisma.paymentBatch.findUnique({ where: { id }, include: { items: true } });
if (batch?.entity_type !== "loan_repayment") return;
if (batch?.entity_type !== "loan_repayment") {
if (revealAction) ensurePayeeBankRevealPermission(fastify, request);
return;
}
const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) &&
fastify.hasPermission(request.employee, "loan.manage");
if (request.method !== "GET" && !reviewAction) ensureLoanManager(fastify, request);
if (request.method !== "GET" && !reviewAction && !revealAction) {
ensureLoanManager(fastify, request);
await loanApprovalPolicy.assertMaker(fastify.prisma, batch, request.employee.id);
}
if (!finance || reviewAction) {
const repayment = await readLoanRepayment(fastify.prisma, batch);
await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, repayment.lender.employee_id]);
await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, ...loanApprovalPolicy.payeeConflicts(repayment)],
{ batch, purpose: reviewAction ? (route.endsWith("/payment-batches/:id/review") ? "payment" : "reconcile") : "read" });
if (revealAction && !loanApprovalPolicy.isRelated(repayment)) ensurePayeeBankRevealPermission(fastify, request);
}
};
const REPAYMENT_SCHEMA = z.object({
@@ -1768,14 +1785,15 @@ async function paymentBatchesRoutes(fastify) {
await auditLoan(tx, request, "LOAN_REPAYMENT_SUBMITTED", facts.repayment.id, { paymentBatchId: batch.id, allocationCount: body.allocations.length });
notificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds: facts.reviewerIds, type: NOTIFICATION_TYPES.paymentBatchReviewRequested,
title: "待还款复核", body: "本金还款申请已提交,请核对原借入分配和付款资料。", batchId: batch.id, actorId: request.employee.id,
title: loanApprovalPolicy.isRelated(facts.repayment) ? "待公司代表关联方确认" : "待还款复核",
body: loanApprovalPolicy.isRelated(facts.repayment) ? "关联方本金还款已提交,请以公司代表身份确认原借款分配及付款资料;此决定不是独立复核。" : "本金还款申请已提交,请核对原借入分配和付款资料。", batchId: batch.id, actorId: request.employee.id,
});
return tx.loanRepayment.findUniqueOrThrow({ where: { id: facts.repayment.id }, include });
}, (tx) => tx.loanRepayment.findUnique({ where: { submitted_by_request_key: {
submitted_by: request.employee.id, request_key: body.requestKey,
} }, include }), (saved) => repaymentMatchesInput(saved, body));
if (notificationTask) await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return sendData(reply, request, { item: await mapBatchForResponse(fastify.prisma, repayment.batches[0], [], fastify.config) });
return sendData(reply, request, { item: await mapBatchForResponse(fastify.prisma, repayment.batches[0], [], fastify.config, { employee: request.employee }) });
});
fastify.get("/loan-repayments", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -1799,6 +1817,7 @@ async function paymentBatchesRoutes(fastify) {
const result = await fastify.prisma.$transaction(async (tx) => {
const locked = await lockBatchWithItems(tx, batch.id);
const repayment = await readLoanRepayment(tx, locked);
await loanApprovalPolicy.assertMaker(tx, locked, request.employee.id);
if (locked.status !== "executed" || repayment.bank_result_state !== "confirmed_success") throw buildBusinessValidationError("loan.handoff_not_ready", {}, 409);
if (repayment.accounting_handoff_at) {
if (repayment.accounting_handoff_ref === reference) return mapRepayment(repayment, locked);
@@ -1912,7 +1931,7 @@ async function paymentBatchesRoutes(fastify) {
const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && fastify.hasPermission(request.employee, "loan.manage");
if (!finance) {
const repayment = await readLoanRepayment(fastify.prisma, batch);
try { await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, repayment.lender.employee_id]); }
try { await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, ...loanApprovalPolicy.payeeConflicts(repayment)], { batch, purpose: "read" }); }
catch (error) { if (error instanceof AppError && error.statusCode === 403) continue; throw error; }
}
}
@@ -2015,7 +2034,7 @@ async function paymentBatchesRoutes(fastify) {
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
const mappedCreated = await mapBatchForResponse(fastify.prisma, created, [], fastify.config);
const mappedCreated = await mapBatchForResponse(fastify.prisma, created, [], fastify.config, { employee: request.employee });
return reply.code(201).send({ requestId: request.id, data: { item: mappedCreated } });
});
@@ -2028,7 +2047,7 @@ async function paymentBatchesRoutes(fastify) {
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config, {
includeExpenseItems: true,
includeExpenseItems: true, employee: request.employee,
}),
});
});
@@ -2140,13 +2159,17 @@ async function paymentBatchesRoutes(fastify) {
{ preHandler: authenticateBatch },
async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensurePayeeBankRevealPermission(fastify, request);
const batchId = parseId(request.params?.id);
const itemId = parseId(request.params?.itemId, "itemId");
const { reason } = parseBody(REASON_SCHEMA, request.body);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
const repayment = await loanApprovalPolicy.repaymentFor(fastify.prisma, batch);
const relatedReviewer = loanApprovalPolicy.isRelated(repayment) && repayment.approval_context?.approverId === request.employee.id;
if (relatedReviewer) await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by],
{ batch, purpose: "read" });
else ensurePayeeBankRevealPermission(fastify, request);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (!["reviewed", "failed"].includes(batch.status)) {
if (!["reviewed", "failed", ...(relatedReviewer ? ["draft"] : [])].includes(batch.status)) {
throw buildBusinessValidationError("payment_batch.account_reveal_unavailable", { status: "payee_bank_reveal_not_available" }, 409);
}
const item = batch.items.find((candidate) => candidate.id === itemId);
@@ -2797,7 +2820,11 @@ async function paymentBatchesRoutes(fastify) {
if (batch.created_by === request.employee.id) {
throw buildBusinessValidationError("payment_batch.maker_checker_conflict", { review: "maker_checker_conflict" }, 409);
}
const openRiskFlags = await tx.entityRiskFlag.findMany({
const returned = body.decision === "reject";
if (returned && !loanApprovalPolicy.isRelated(await loanApprovalPolicy.repaymentFor(tx, batch))) {
throw buildBusinessValidationError("payment_batch.batch_not_reviewable", { review: "return_requires_related_loan" }, 409);
}
const openRiskFlags = returned ? [] : await tx.entityRiskFlag.findMany({
where: {
entity_type: batch.entity_type,
entity_id: { in: batch.items.map((item) => item.entity_id) },
@@ -2825,7 +2852,8 @@ async function paymentBatchesRoutes(fastify) {
const reviewed = await tx.paymentBatch.update({
where: { id: batchId },
data: {
status: "reviewed",
status: returned ? "draft" : "reviewed",
...await loanApprovalPolicy.decisionFields(tx, batch, request.employee.id, { purpose: "payment", decision: body.decision }),
review_comment: normalizeNullableString(body.comment),
reviewed_at: reviewedAt,
reviewed_by: request.employee.id,
@@ -2840,13 +2868,15 @@ async function paymentBatchesRoutes(fastify) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_REVIEWED",
action: returned ? "LOAN_PAYMENT_RETURNED" : "PAYMENT_BATCH_REVIEWED",
targetType: "payment_batch",
targetId: batchId,
extra: {
highRisk: batchRisk.highRisk,
resolvedRiskFlagCount: openRiskFlags.length,
reviewStage: "single_review",
reviewStage: reviewed.loan_approval_mode === "related_party" ? "related_party_confirmation" : "single_review",
...(returned ? { decision: "reject", reason: body.comment } : {}),
...loanApprovalPolicy.auditContext(reviewed),
},
}),
});
@@ -2857,9 +2887,9 @@ async function paymentBatchesRoutes(fastify) {
});
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.paymentBatchExecutionRequested,
title: "待完成付款",
body: "付款复核已通过,请完成付款并登记回单。",
type: returned ? NOTIFICATION_TYPES.paymentBatchReturned : NOTIFICATION_TYPES.paymentBatchExecutionRequested,
title: returned ? "关联方付款申请已退回" : "待完成付款",
body: returned ? "公司代表已退回本次付款申请,请查看理由,取消原申请后重新提交。退回未释放占用。" : reviewed.loan_approval_mode === "related_party" ? "关联方付款已由公司代表确认,请完成付款并登记回单。" : "付款复核已通过,请完成付款并登记回单。",
batchId,
actorId: request.employee.id,
});
@@ -2873,7 +2903,7 @@ async function paymentBatchesRoutes(fastify) {
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, result, [], fastify.config),
item: await mapBatchForResponse(fastify.prisma, result, [], fastify.config, { employee: request.employee }),
});
});
@@ -3063,7 +3093,7 @@ async function paymentBatchesRoutes(fastify) {
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result);
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, result, openRiskFlags, fastify.config),
item: await mapBatchForResponse(fastify.prisma, result, openRiskFlags, fastify.config, { employee: request.employee }),
});
});
@@ -3122,7 +3152,7 @@ async function paymentBatchesRoutes(fastify) {
return loadBatchWithItems(tx, batchId);
});
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, cancelled, [], fastify.config),
item: await mapBatchForResponse(fastify.prisma, cancelled, [], fastify.config, { employee: request.employee }),
});
});
}
+44 -12
查看文件
@@ -3,6 +3,7 @@ const { randomUUID } = require("node:crypto");
const { Prisma } = require("@prisma/client");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
const approvalPolicy = require("./loanApprovalPolicy");
const { decryptStringOrThrow, maskBankAccount } = require("./crypto");
const {
LENDER_INCLUDE, LOAN_INCLUDE, loanNotFound, lockLender, loanBalances,
@@ -24,7 +25,8 @@ function assertLenderReady(lender) {
!lender.bank_institution?.enabled || lender.bank_institution.archived_at || lender.bank_institution.code !== "CCB") { throw buildBusinessValidationError("loan.source_unavailable", {}, 409); };
}
async function activeReviewer(tx, id, excludedIds, { lock = false } = {}) {
async function activeReviewer(tx, id, excludedIds, { lock = false, batch = null, purpose = "reconcile" } = {}) {
if (batch) await approvalPolicy.assertReviewActor(tx, batch, id, { purpose, lock });
if (!id || excludedIds.includes(id)) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); };
if (lock) {
await tx.$queryRaw`SELECT id FROM employees WHERE id=${id}::uuid FOR SHARE`;
@@ -67,6 +69,11 @@ function assertApprovedIndependently(batch, excludedIds) {
}
}
async function assertOriginalApproval(tx, batch, repayment) {
assertApprovedIndependently(batch, [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment)].filter(Boolean));
await approvalPolicy.assertDecision(tx, batch, batch, { purpose: "payment" });
}
// postPayment=true is for receipt OCR, manual receipt review and settlement:
// identity/account versions and the frozen payee snapshot must still match
// (maintenance is blocked while reserved, see assertLenderNotInFlight), but
@@ -79,6 +86,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
repayment = await readLoanRepayment(tx, batch);
}
const lender = repayment.lender;
if (!postPayment) await approvalPolicy.currentForRepayment(tx, repayment, "payment");
if (!postPayment) await require("./loanFreezes").assertNotFrozen(tx, lender.id);
if (postPayment) assertLenderIdentityVerified(lender);
else assertLenderReady(lender);
@@ -88,10 +96,13 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
batch.default_payment_channel !== "bank" || batch.items[0].payment_channel_override && batch.items[0].payment_channel_override !== "bank") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
if (batch.items[0].snapshot_json?.payee?.bankAccountEncrypted !== lender.bank_account_encrypted ||
batch.items[0].snapshot_json?.payee?.accountNameEncrypted !== lender.account_name_encrypted) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
const excluded = [batch.created_by, repayment.submitted_by, lender.employee_id].filter(Boolean);
if (reviewerId) await activeReviewer(tx, reviewerId, excluded, { lock });
if (postPayment) assertApprovedIndependently(batch, excluded);
else if (recheckApproval) await activeReviewer(tx, batch.reviewed_by, excluded, { lock });
const excluded = [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment)].filter(Boolean);
if (reviewerId) await activeReviewer(tx, reviewerId, excluded, { lock, batch, purpose: postPayment ? "reconcile" : "payment" });
if (postPayment) await assertOriginalApproval(tx, batch, repayment);
else if (recheckApproval) {
await activeReviewer(tx, batch.reviewed_by, excluded, { lock, batch, purpose: "payment" });
await approvalPolicy.assertDecision(tx, batch, batch, { purpose: "payment" });
}
if (lock) {
const ids = repayment.allocations.map((a) => a.loan_id).sort();
if (!ids.length) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); };
@@ -131,7 +142,9 @@ async function prepareLoanRepayment(tx, config, request, input) {
total = total.plus(allocation.amount);
}
if (total.gt("999999999999.99")) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); };
const reviewerIds = await eligibleLoanReviewers(tx, [request.employee.id, lender.employee_id]);
const approval = await approvalPolicy.creationContext(tx, lender, ids, request.employee.id);
const reviewerIds = approval ? [approval.approval_context.approverId]
: await eligibleLoanReviewers(tx, [request.employee.id, lender.employee_id]);
const accounts = await tx.paymentAccount.findMany({ where: {
legal_entity_id: lender.legal_entity_id, enabled: true, archived_at: null,
bank_institution: { code: "CCB", enabled: true, archived_at: null },
@@ -155,6 +168,7 @@ async function prepareLoanRepayment(tx, config, request, input) {
? (await issueBusinessNumber(tx, { family: "loan_repayment", ...numbering, timeZone: config.appTimezone })).documentNumber
: `HK-${id}`;
const repayment = await tx.loanRepayment.create({ data: {
...approval,
id, repayment_no: repaymentNo, legal_entity_id: lender.legal_entity_id, lender_id: lender.id,
amount: total, reason: input.reason, submitted_by: request.employee.id,
lender_identity_version: lender.identity_version, lender_account_version: lender.account_version,
@@ -223,9 +237,11 @@ async function assertReceiptAttributed(tx, config, batch, repayment) {
if (!approved || !attributionMatches(approved, planned, competitors)) {
throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
}
await approvalPolicy.assertDecision(tx, batch, approved);
}
async function settleLoanRepayment(tx, batch, request, config) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
if (["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
@@ -241,6 +257,8 @@ async function settleLoanRepayment(tx, batch, request, config) {
async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryInput = {}) {
// Stale identity/account must not prevent cancelling a never-sent request.
const repayment = await readLoanRepayment(tx, batch);
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
if (repayment.bank_result_state !== "not_sent") await approvalPolicy.currentForRepayment(tx, repayment, "reconcile", { lock: true });
if (!["not_sent", "confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state) ||
repayment.bank_result_state === "not_sent" && (batch.payment_order_export_count > 0 ||
await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) > 0)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
@@ -267,6 +285,9 @@ async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryI
const { fact } = await assertFailureFacts(tx, config, batch, repayment, verification.attachment_id);
if (fact.verification.id !== verification.id) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
}
if (!recovered && ["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) {
await approvalPolicy.assertDecision(tx, batch, repayment);
}
const ids = repayment.allocations.map((a) => a.loan_id).sort();
await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`);
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id }, data: {
@@ -379,6 +400,7 @@ async function assertFailureFacts(tx, config, batch, repayment, attachmentId) {
const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "batch", status: "approved",
covered_submission_ids: { equals: submissionIds } } });
if (!approved || !attributionMatches(approved, fact, competitors, submissionIds)) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); };
await approvalPolicy.assertDecision(tx, batch, approved);
}
return { fact, submissionIds };
}
@@ -395,10 +417,12 @@ async function confirmLoanBankFailure(tx, config, batch, request, attachmentId)
// Independent of everyone who produced the bank-side evidence for this batch.
if ((await reconciliationParticipants(tx, batch)).concat(fact.attachment.uploaded_by).includes(request.employee.id)) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); };
await recordFailureEvidenceUse(tx, batch, fact, "failure_confirmation");
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
const decided = await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: repayment.id,
conflictIds: [...await reconciliationParticipants(tx, batch), fact.attachment.uploaded_by] }),
bank_result_state: "confirmed_failed", bank_result_ref: `verification:${fact.verification.id}`,
} });
await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: fact.verification.id, submissionIds });
await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: fact.verification.id, submissionIds, ...approvalPolicy.auditContext(decided) });
}
async function plannedLoanClaim(tx, config, batch) {
@@ -446,6 +470,7 @@ async function attributionEvidence(tx, config, batch, kind, attachmentId, submis
const submission = facts.active.find((item) => item.id === submissionId);
const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
if (!submission || !attestationMatches(approvedList, facts)) { throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409); };
await approvalPolicy.assertDecision(tx, batch, approvedList);
const recognition = JSON.parse(decryptStringOrThrow(config, claim.verification.normalized_encrypted));
if (normalizeInstructionRef(recognition.pages[0].reference) !== submission.bank_instruction_ref) { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); };
return { claim, coveredSubmissionIds: [submissionId], failureScope: "submission" };
@@ -454,6 +479,7 @@ async function attributionEvidence(tx, config, batch, kind, attachmentId, submis
}
async function proposeReceiptAttribution(tx, config, batch, request, input) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
const kind = input.kind ?? "settlement";
if (!ATTRIBUTION_BASIS_KINDS.includes(input.basisKind) || !["settlement", "failure"].includes(kind)) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); };
const { competitors } = await assertAttributableBatch(tx, batch);
@@ -489,7 +515,7 @@ async function reviewReceiptAttribution(tx, config, batch, request, attributionI
const attribution = await tx.loanReceiptAttribution.findUnique({ where: { id: attributionId } });
if (!attribution || attribution.payment_batch_id !== batch.id || attribution.status !== "proposed") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
const { repayment } = await assertAttributableBatch(tx, batch, { requireCompetitors: false });
const excluded = [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, attribution.proposed_by, batch.payment_order_last_exported_by,
const excluded = [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment), attribution.proposed_by, batch.payment_order_last_exported_by,
...(attribution.kind === "failure" ? await reconciliationParticipants(tx, batch) : [])];
if (input.decision === "approve") {
const { competitors } = await assertAttributableBatch(tx, batch);
@@ -505,13 +531,14 @@ async function reviewReceiptAttribution(tx, config, batch, request, attributionI
excluded.push(...verifications.map((v) => v.attachment?.uploaded_by), ...manual.map((m) => m.proposed_by));
}
// Rejection needs no receipt, so an open proposal can always be closed.
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true, batch });
const reviewed = await tx.loanReceiptAttribution.update({ where: { id: attribution.id }, data: {
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: attribution.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_RECEIPT_ATTRIBUTION_APPROVED" : "LOAN_RECEIPT_ATTRIBUTION_REJECTED",
batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: attribution.id });
batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: attribution.id, ...approvalPolicy.auditContext(reviewed) });
return reviewed;
}
@@ -536,6 +563,7 @@ async function loanAttributionSatisfied(tx, config, batch) {
if (!competitors.length) return true;
const claim = await plannedClaimOrNull(tx, config, batch);
const approved = claim && await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "settlement", status: "approved" } });
if (approved) await approvalPolicy.assertDecision(tx, batch, approved);
return Boolean(approved && attributionMatches(approved, claim, competitors));
}
@@ -559,6 +587,9 @@ async function loanReceiptAttributionState(tx, batch, repayment, config) {
function mapRepayment(repayment, batch) {
return {
approvalMode: repayment.approval_mode ?? "independent", authorizedApproverId: repayment.approval_context?.approverId ?? null,
paymentDecision: batch.loan_approval_mode === "related_party" ? batch.loan_approval_context?.decision ?? null : null,
authorizationId: repayment.authorization_id ?? null, authorizationVersion: repayment.approval_context?.authorizationVersion ?? null,
id: repayment.id, repaymentNo: repayment.repayment_no, lenderId: repayment.lender_id,
lenderName: repayment.lender.name, amount: repayment.amount.toFixed(2), reason: repayment.reason,
bankResultState: repayment.bank_result_state, paymentBatchId: batch.id, status: batch.status,
@@ -573,4 +604,5 @@ function mapRepayment(repayment, batch) {
module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure,
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied, isClearBankFailure, CLEAR_FAILURE_STATUSES,
failedReceiptFact, reconciliationParticipants, competingLoanBatches, attributionMatches, assertFailureFingerprintFree, recordFailureEvidenceUse };
failedReceiptFact, reconciliationParticipants, competingLoanBatches, attributionMatches, assertFailureFingerprintFree, recordFailureEvidenceUse,
assertOriginalApproval };
+276
查看文件
@@ -0,0 +1,276 @@
const { Prisma } = require("@prisma/client");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { getErrorReason } = require("../constants/errorReasons");
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
const { lockLender, auditLoan, loanNotFound } = require("./companyLoans");
const RELATED = "related_party";
const INCLUDE = { loans: { orderBy: { loan_id: "asc" } }, events: { orderBy: { sequence: "asc" } } };
const fail = (reason = "loan.related_authorization_required", status = 409) => {
throw buildBusinessValidationError(getErrorReason(reason).code, {}, status);
};
const sorted = (ids) => [...new Set(ids.filter(Boolean))].sort();
const sameIds = (a, b) => JSON.stringify(sorted(a ?? [])) === JSON.stringify(sorted(b ?? []));
const isRelated = (repayment) => repayment?.approval_mode === RELATED;
// Remove only the payee *reason*, never the same employee from maker/uploader
// conflicts. Callers append these to the separately collected actual actors.
const payeeConflicts = (repayment) => isRelated(repayment) ? [] : [repayment.lender?.employee_id].filter(Boolean);
async function repaymentFor(tx, batch) {
if (batch?.entity_type !== "loan_repayment") return null;
return tx.loanRepayment.findUniqueOrThrow({ where: { id: batch.loan_repayment_id }, include: { lender: true, allocations: true } });
}
async function reviewerRole(tx, id, { lock = false } = {}) {
if (lock) {
await tx.$queryRaw`SELECT id FROM employees WHERE id=${id}::uuid FOR SHARE`;
await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id=${id}::uuid FOR SHARE`;
}
const person = id && await tx.employee.findUnique({ where: { id }, include: { roles: true } });
const roles = person?.roles.map((r) => r.role_code) ?? [];
return person?.status === "active" && !person.archived_at && roles.some((r) => ["manager", "ceo"].includes(r)) &&
hasPermission(resolvePermissionsForRoles(roles), "payment.batch.review") ? person : null;
}
const latestEvent = (authorization) => authorization?.events?.at(-1) ?? null;
const confirmed = (authorization) => authorization?.events?.[0]?.action === "confirm";
// A stopped or narrowed latest version never falls back to an older grant.
async function currentAuthorization(tx, lenderId) {
return tx.loanRelatedAuthorization.findFirst({ where: { lender_id: lenderId, events: { some: { action: "confirm" } } },
include: INCLUDE, orderBy: { version: "desc" } });
}
function covers(authorization, { lenderId, legalEntityId, approverId, loanIds }) {
return confirmed(authorization) && authorization.lender_id === lenderId && authorization.legal_entity_id === legalEntityId &&
authorization.approver_id === approverId && loanIds.length > 0 &&
loanIds.every((id) => authorization.loans.some((loan) => loan.loan_id === id));
}
async function currentForRepayment(tx, repayment, purpose = "reconcile", { lock = false } = {}) {
if (!isRelated(repayment)) return null;
if (lock) await lockLender(tx, repayment.lender_id);
const authorization = await currentAuthorization(tx, repayment.lender_id);
if (!covers(authorization, { lenderId: repayment.lender_id, legalEntityId: repayment.legal_entity_id,
approverId: repayment.approval_context?.approverId, loanIds: repayment.allocations.map((a) => a.loan_id) })) fail();
const event = latestEvent(authorization);
if (purpose !== "read" && !(purpose === "payment" ? event.payment_enabled : event.reconcile_enabled)) fail();
if (!await reviewerRole(tx, authorization.approver_id, { lock })) fail();
return authorization;
}
async function assertReviewActor(tx, batch, actorId, { purpose = "reconcile", lock = false } = {}) {
const repayment = await repaymentFor(tx, batch);
if (!isRelated(repayment)) return;
// Read access uses the immutable assigned identity, including after stopping
// authorization; actions must additionally use a current covering grant.
if (actorId !== repayment.approval_context?.approverId) fail("loan.related_actor_conflict", 403);
if (purpose !== "read") {
await currentForRepayment(tx, repayment, purpose, { lock });
if ((await decisionConflicts(tx, batch, repayment)).includes(actorId)) fail("loan.related_actor_conflict", 403);
}
}
async function decisionConflicts(tx, batch, repayment) {
const where = { payment_batch_id: batch.id };
const [exports, submissions, lists, receipts, manual, attributions, evidence, terminal, recoveries, events] = await Promise.all([
tx.paymentOrderExport.findMany({ where, select: { exported_by: true } }),
tx.loanBankSubmission.findMany({ where, select: { registered_by: true } }),
tx.loanBankSubmissionAttestation.findMany({ where, select: { proposed_by: true } }),
tx.paymentBatchReceiptAttachment.findMany({ where, select: { uploaded_by: true } }),
tx.paymentReceiptManualProposal.findMany({ where: { attachment: where }, select: { proposed_by: true } }),
tx.loanReceiptAttribution.findMany({ where, select: { proposed_by: true } }),
tx.loanBankTerminalEvidence.findMany({ where, select: { uploaded_by: true } }),
tx.loanBankTerminalFact.findMany({ where, select: { proposed_by: true } }),
tx.loanReleaseRecovery.findMany({ where, select: { started_by: true, submitted_by: true } }),
tx.loanBankResultEvent.findMany({ where, select: { proposed_by: true } }),
]);
return sorted([batch.created_by, batch.payment_order_last_exported_by, repayment.submitted_by,
...exports.map((x) => x.exported_by), ...submissions.map((x) => x.registered_by), ...lists.map((x) => x.proposed_by),
...receipts.map((x) => x.uploaded_by), ...manual.map((x) => x.proposed_by), ...attributions.map((x) => x.proposed_by),
...evidence.map((x) => x.uploaded_by), ...terminal.map((x) => x.proposed_by),
...recoveries.flatMap((x) => [x.started_by, x.submitted_by]), ...events.map((x) => x.proposed_by)]);
}
async function assertMaker(tx, batch, actorId) {
const repayment = await repaymentFor(tx, batch);
if (isRelated(repayment) && actorId === repayment.approval_context?.approverId) fail("loan.related_actor_conflict", 403);
}
async function assertNotRelatedQ6(tx, batch) {
if (isRelated(await repaymentFor(tx, batch))) fail("loan.related_q6_unavailable");
}
async function creationContext(tx, lender, loanIds, makerId) {
const authorization = await currentAuthorization(tx, lender.id);
if (!authorization) return null;
if (!covers(authorization, { lenderId: lender.id, legalEntityId: lender.legal_entity_id,
approverId: lender.employee_id, loanIds }) || authorization.identity_version !== lender.identity_version) {
fail("loan.related_scope_invalid");
}
if (!latestEvent(authorization).payment_enabled || !await reviewerRole(tx, authorization.approver_id, { lock: true })) fail();
if (makerId === authorization.approver_id) fail("loan.related_actor_conflict", 403);
return { approval_mode: RELATED, authorization_id: authorization.id, approval_context: {
version: 1, approverId: authorization.approver_id, authorizationVersion: authorization.version,
authorizationEventId: latestEvent(authorization).id, legalEntityId: lender.legal_entity_id,
lenderId: lender.id, loanIds: sorted(loanIds), payeeConflictWaived: true,
} };
}
async function decisionFields(tx, batch, actorId, { targetId = batch.id, purpose = "reconcile", conflictIds = [], decision = "approve" } = {}) {
const repayment = await repaymentFor(tx, batch);
if (!isRelated(repayment)) return {};
await assertReviewActor(tx, batch, actorId, { purpose, lock: true });
conflictIds = [...conflictIds, ...await decisionConflicts(tx, batch, repayment)];
if (sorted([batch.created_by, repayment.submitted_by, ...conflictIds]).includes(actorId)) fail("loan.related_actor_conflict", 403);
const authorization = await currentForRepayment(tx, repayment, purpose);
return { loan_approval_mode: RELATED, loan_authorization_id: authorization.id, loan_approval_context: {
version: 1, targetId, repaymentId: repayment.id, batchId: batch.id, purpose,
...(purpose === "payment" ? { decision } : {}),
approverId: actorId, lenderId: repayment.lender_id, legalEntityId: repayment.legal_entity_id,
loanIds: sorted(repayment.allocations.map((a) => a.loan_id)), payeeConflictWaived: true,
authorizationVersion: authorization.version, authorizationEventId: latestEvent(authorization).id,
conflictIds: sorted([batch.created_by, repayment.submitted_by, ...conflictIds]),
} };
}
// Historical decision eligibility. No current role/enablement check: this is
// also used after actual payment and by read-only evidence reconstruction.
async function assertDecision(tx, batch, record, { purpose = "reconcile", conflictIds = [] } = {}) {
const repayment = await repaymentFor(tx, batch);
if (!isRelated(repayment)) {
if (record?.loan_approval_mode === RELATED) fail("loan.related_scope_invalid");
return;
}
const context = record?.loan_approval_context;
if (!context || !Array.isArray(context.loanIds) || !Array.isArray(context.conflictIds) ||
!context.loanIds.every((id) => typeof id === "string") || !context.conflictIds.every((id) => typeof id === "string")) {
fail("loan.related_scope_invalid");
}
conflictIds = [...conflictIds, ...await decisionConflicts(tx, batch, repayment)];
if (record?.loan_approval_mode !== RELATED || !record.loan_authorization_id || !context ||
context.version !== 1 || context.targetId !== record.id || context.repaymentId !== repayment.id || context.batchId !== batch.id ||
context.lenderId !== repayment.lender_id || context.legalEntityId !== repayment.legal_entity_id ||
context.purpose !== purpose || context.approverId !== repayment.approval_context?.approverId ||
(purpose === "payment" && context.decision !== "approve") ||
(record.reviewed_by && record.reviewed_by !== context.approverId) || context.payeeConflictWaived !== true ||
!sameIds(context.loanIds, repayment.allocations.map((a) => a.loan_id)) ||
sorted([batch.created_by, repayment.submitted_by, ...conflictIds, ...(context.conflictIds ?? [])]).includes(context.approverId)) {
fail("loan.related_scope_invalid");
}
const authorization = await tx.loanRelatedAuthorization.findUnique({ where: { id: record.loan_authorization_id }, include: INCLUDE });
const event = authorization?.events.find((item) => item.id === context.authorizationEventId);
if (!covers(authorization, { lenderId: repayment.lender_id, legalEntityId: repayment.legal_entity_id,
approverId: context.approverId, loanIds: context.loanIds }) || authorization.version !== context.authorizationVersion ||
!event || !(purpose === "payment" ? event.payment_enabled : event.reconcile_enabled)) fail("loan.related_scope_invalid");
}
async function proposeAuthorization(tx, request, input) {
const lender = await lockLender(tx, input.lenderId);
if (lender.archived_at || lender.identity_status !== "verified" || !lender.employee_id ||
lender.employee_id === request.employee.id || !await reviewerRole(tx, lender.employee_id, { lock: true })) fail("loan.related_scope_invalid");
const loanIds = sorted(input.loanIds);
if (!loanIds.length || loanIds.length !== input.loanIds.length) fail("loan.related_scope_invalid");
await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(loanIds)}) ORDER BY id FOR SHARE`);
const loans = await tx.companyLoan.findMany({ where: { id: { in: loanIds }, lender_id: lender.id,
legal_entity_id: lender.legal_entity_id, confirmation_status: "confirmed" } });
if (loans.length !== loanIds.length) fail("loan.related_scope_invalid");
const existing = await tx.loanRelatedAuthorization.findUnique({ where: {
proposed_by_request_key: { proposed_by: request.employee.id, request_key: input.requestKey } }, include: INCLUDE });
if (existing) {
if (existing.lender_id !== lender.id || existing.basis !== input.basis || !sameIds(existing.loans.map((l) => l.loan_id), loanIds)) {
fail("loan.related_scope_invalid");
}
return existing;
}
const previous = await tx.loanRelatedAuthorization.findFirst({ where: { lender_id: lender.id }, orderBy: { version: "desc" } });
const record = await tx.loanRelatedAuthorization.create({ data: {
legal_entity_id: lender.legal_entity_id, lender_id: lender.id, approver_id: lender.employee_id,
identity_version: lender.identity_version, version: (previous?.version ?? 0) + 1, basis: input.basis,
proposed_by: request.employee.id, request_key: input.requestKey,
loans: { create: loanIds.map((loan_id) => ({ loan_id })) },
}, include: INCLUDE });
await auditLoan(tx, request, "LOAN_RELATED_AUTHORIZATION_PROPOSED", record.id, { lenderId: lender.id, version: record.version, mode: RELATED });
return record;
}
async function authorizationAction(tx, request, id, input) {
let record = await tx.loanRelatedAuthorization.findUnique({ where: { id }, include: INCLUDE });
if (!record) throw loanNotFound();
const lender = await lockLender(tx, record.lender_id);
record = await tx.loanRelatedAuthorization.findUniqueOrThrow({ where: { id }, include: INCLUDE });
const actorId = request.employee.id; const previous = latestEvent(record);
if (input.action === "confirm") {
if (previous || actorId !== record.approver_id || actorId === record.proposed_by || lender.employee_id !== actorId ||
lender.identity_version !== record.identity_version || lender.identity_status !== "verified" ||
!await reviewerRole(tx, actorId, { lock: true })) fail("loan.related_actor_conflict", 403);
// An older pending proposal must not become the new effective scope.
const current = await currentAuthorization(tx, record.lender_id);
if (current && current.version >= record.version) fail("loan.related_scope_invalid");
} else {
if ((await currentAuthorization(tx, record.lender_id))?.id !== record.id) fail("loan.related_scope_invalid");
const roles = request.employee.roleCodes ?? [];
const finance = roles.some((r) => ["finance", "finance_mgr"].includes(r)) &&
hasPermission(resolvePermissionsForRoles(roles), "loan.manage");
if (!previous || (!finance && (actorId !== record.approver_id || !await reviewerRole(tx, actorId, { lock: true })))) {
fail("loan.related_actor_conflict", 403);
}
}
const paymentEnabled = input.action === "confirm" || (previous.payment_enabled && input.action !== "stop_payment");
const reconcileEnabled = input.action === "confirm" || (previous.reconcile_enabled && input.action !== "stop_reconcile");
if (!["confirm", "stop_payment", "stop_reconcile"].includes(input.action)) fail("loan.related_scope_invalid");
if (previous && previous.payment_enabled === paymentEnabled && previous.reconcile_enabled === reconcileEnabled) fail();
await tx.loanRelatedAuthorizationEvent.create({ data: {
authorization_id: id, sequence: (previous?.sequence ?? 0) + 1, action: input.action,
payment_enabled: paymentEnabled, reconcile_enabled: reconcileEnabled, actor_id: actorId, reason: input.reason,
} });
await auditLoan(tx, request, "LOAN_RELATED_AUTHORIZATION_DECIDED", id, { mode: RELATED, action: input.action, version: record.version });
return tx.loanRelatedAuthorization.findUniqueOrThrow({ where: { id }, include: INCLUDE });
}
function mapAuthorization(record) {
const event = latestEvent(record);
return { id: record.id, lenderId: record.lender_id, legalEntityId: record.legal_entity_id,
approverId: record.approver_id, version: record.version, basis: record.basis, proposedBy: record.proposed_by,
proposedAt: record.proposed_at, loanIds: record.loans.map((l) => l.loan_id),
confirmed: confirmed(record), paymentEnabled: event?.payment_enabled ?? false, reconcileEnabled: event?.reconcile_enabled ?? false,
events: record.events.map((e) => ({ id: e.id, sequence: e.sequence, action: e.action, actorId: e.actor_id,
reason: e.reason, createdAt: e.created_at })) };
}
function auditContext(record) {
return record?.loan_approval_mode === RELATED ? { approvalMode: RELATED, authorizationId: record.loan_authorization_id,
authorizationVersion: record.loan_approval_context?.authorizationVersion, relatedApproverId: record.loan_approval_context?.approverId } : {};
}
// UI capabilities are projections of the same authority/actor rules; final
// actions always repeat their checks inside the lender-locked transaction.
async function capabilities(tx, batch, repayment, employee) {
if (!isRelated(repayment)) return null;
const roles = employee?.roleCodes ?? [];
const actorId = employee?.id;
const designated = actorId === repayment.approval_context?.approverId;
const finance = !designated && roles.some((r) => ["finance", "finance_mgr"].includes(r)) &&
hasPermission(resolvePermissionsForRoles(roles), "loan.manage");
const allowed = async (purpose) => {
try { await currentForRepayment(tx, repayment, purpose); return true; }
catch (error) { if (error.details?.reasonCode?.startsWith("loan.")) return false; throw error; }
};
const [payment, reconcile, reviewer, frozen] = await Promise.all([
allowed("payment"), allowed("reconcile"), reviewerRole(tx, actorId),
tx.loanFreezeReason.count({ where: { lender_id: repayment.lender_id, status: "active" } }),
]);
const review = designated && Boolean(reviewer) && !(await decisionConflicts(tx, batch, repayment)).includes(actorId);
return { canPrepare: finance, canRecordFacts: finance,
canApprovePayment: review && payment && !frozen && batch.status === "draft" && !batch.reviewed_at,
canExport: finance && payment && !frozen,
canReviewReconciliation: review && reconcile,
canRelease: finance && (repayment.bank_result_state === "not_sent" || reconcile),
canReadPayeeAccount: designated && Boolean(reviewer), canUseNotSubmitted: false,
paymentAuthorized: payment, reconciliationAuthorized: reconcile,
};
}
module.exports = { RELATED, INCLUDE, isRelated, payeeConflicts, repaymentFor, reviewerRole, currentAuthorization, currentForRepayment,
assertReviewActor, assertMaker, assertNotRelatedQ6, creationContext, decisionFields, assertDecision,
proposeAuthorization, authorizationAction, mapAuthorization, auditContext, capabilities, decisionConflicts };
+9 -2
查看文件
@@ -6,6 +6,7 @@ const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching")
const { batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
const { auditLoan, loanBalances, LOAN_INCLUDE } = require("./companyLoans");
const { digest } = require("./loanReleaseRecoveries");
const approvalPolicy = require("./loanApprovalPolicy");
const payments = () => require("./companyLoanPayments");
const freezes = () => require("./loanFreezes");
const fail = () => { throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409); };
@@ -18,6 +19,7 @@ async function source(tx, batch) {
// employment or subsequently maintained lender/bank master data.
if (!batch.reviewed_by || !batch.reviewed_at || [batch.created_by, repayment.submitted_by].includes(batch.reviewed_by) ||
repayment.legal_entity_id !== batch.legal_entity_id || !repayment.amount.eq(batch.items[0].amount)) fail();
await approvalPolicy.assertDecision(tx, batch, batch, { purpose: "payment" });
return repayment;
}
async function evidence(tx, config, batch, id, kind) {
@@ -69,6 +71,7 @@ async function outstandingReturn(tx, id) {
.minus(returned.reduce((sum, r) => sum.plus(r.amount), new Prisma.Decimal(0)));
}
async function propose(tx, config, batch, request, input) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
const existing = await tx.loanBankResultEvent.findUnique({ where: { payment_batch_id_request_key: { payment_batch_id: batch.id, request_key: input.requestKey } } });
if (existing) {
const saved = JSON.parse(decryptStringOrThrow(config, existing.payload_encrypted));
@@ -124,7 +127,8 @@ async function review(tx, config, batch, request, id, input) {
const event = await eventForBatch(tx, batch, id);
if (event.status !== "proposed") fail();
await source(tx, batch);
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch), { lock: true });
const excluded = await participants(tx, batch);
await payments().activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
if (input.decision === "approve") {
const file = await evidence(tx, config, batch, event.evidence_id, event.kind === "return" ? "incoming_return" : "late_success");
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
@@ -138,6 +142,7 @@ async function review(tx, config, batch, request, id, input) {
}
const status = input.decision === "approve" ? "approved" : event.kind === "late_success" ? "dismissed" : "rejected";
const updated = await tx.loanBankResultEvent.update({ where: { id }, data: { status,
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: id, conflictIds: excluded }),
reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason, correction_evidence_id: input.correctionEvidenceId ?? null } });
if (status === "dismissed") {
const basis = { action: input.correctionKind, recordId: id, evidenceId: input.correctionEvidenceId };
@@ -145,13 +150,14 @@ async function review(tx, config, batch, request, id, input) {
if (conflict?.status === "active") await freezes().closeReason(tx, batch, request, "reservation_conflict", id, basis);
await freezes().closeReason(tx, batch, request, "late_success", id, basis);
}
await auditLoan(tx, request, "LOAN_BANK_RESULT_REVIEWED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: id, status });
await auditLoan(tx, request, "LOAN_BANK_RESULT_REVIEWED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: id, status, ...approvalPolicy.auditContext(updated) });
return updated;
}
// Finance applies an already independent bank fact. No exportable object is
// created. Original bank outcome stays terminal and the event supersedes it.
async function apply(tx, config, batch, request, id) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
const event = await eventForBatch(tx, batch, id);
if (event.status === "booked") return event;
if (event.status !== "approved") fail();
@@ -160,6 +166,7 @@ async function apply(tx, config, batch, request, id) {
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
await assertTransactionFree(tx, config, event);
if ((await participants(tx, batch)).includes(event.reviewed_by)) fail();
await approvalPolicy.assertDecision(tx, batch, event, { conflictIds: await participants(tx, batch) });
const ids = repayment.allocations.map((a) => a.loan_id).sort();
await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`);
if (event.kind === "return") {
+10 -3
查看文件
@@ -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);
+17 -6
查看文件
@@ -1,6 +1,7 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { decryptStringOrThrow } = require("./crypto");
const { auditLoan } = require("./companyLoans");
const approvalPolicy = require("./loanApprovalPolicy");
// Loan bank-result reconciliation S3 (design.md 3.2; implement.md S3).
// A repayment is released as "not paid" only when every reviewed bank submission
@@ -56,6 +57,7 @@ async function failureSlip(tx, config, batch, attachmentId) {
// reason when it no longer holds (submission withdrawn, evidence voided, the
// failed slip re-recognized or voided, a resolved pending slip changed).
async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
if (fact.status === "approved") await approvalPolicy.assertDecision(tx, batch, fact);
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
if (fact.kind === "termination_evidence") {
const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: fact.evidence_id } });
@@ -76,6 +78,7 @@ async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "submission",
status: "approved", covered_submission_ids: { equals: [submission.id] } } });
if (!approved || !attributionMatches(approved, slip, competitors, [submission.id])) throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
await approvalPolicy.assertDecision(tx, batch, approved);
}
}
for (const attachmentId of fact.resolves_attachment_ids ?? []) {
@@ -92,6 +95,7 @@ function assertTerminalBatch(batch, repayment) {
}
async function proposeTerminalFact(tx, config, batch, request, input) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
const { readLoanRepayment } = loanPayments();
const repayment = await readLoanRepayment(tx, batch);
assertTerminalBatch(batch, repayment);
@@ -131,7 +135,7 @@ async function terminalParticipants(tx, batch, repayment) {
tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } }),
]);
const receiptUploaders = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } });
return [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, ...(await reconciliationParticipants(tx, batch)),
return [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment), ...(await reconciliationParticipants(tx, batch)),
...facts.map((item) => item.proposed_by), ...evidences.map((item) => item.uploaded_by), ...receiptUploaders.map((item) => item.uploaded_by)].filter(Boolean);
}
@@ -141,7 +145,8 @@ async function reviewTerminalFact(tx, config, batch, request, factId, input) {
if (!fact || fact.payment_batch_id !== batch.id || fact.status !== "proposed") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
const repayment = await readLoanRepayment(tx, batch);
assertTerminalBatch(batch, repayment);
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, repayment), { lock: true });
const excluded = await terminalParticipants(tx, batch, repayment);
await activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
if (input.decision === "approve") {
const submission = await tx.loanBankSubmission.findUnique({ where: { id: fact.submission_id } });
await assertTerminalFactHolds(tx, config, batch, fact, submission);
@@ -150,9 +155,10 @@ async function reviewTerminalFact(tx, config, batch, request, factId, input) {
}
}
const reviewed = await tx.loanBankTerminalFact.update({ where: { id: fact.id }, data: {
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: fact.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_TERMINAL_FACT_APPROVED" : "LOAN_TERMINAL_FACT_REJECTED",
batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id });
batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id, ...approvalPolicy.auditContext(reviewed) });
return reviewed;
}
@@ -184,6 +190,7 @@ async function assertNotPaidReleasable(tx, config, batch, { manualExportIds = []
if (facts.legacy) throw buildBusinessValidationError("loan.submission_history_unverified", {}, 409);
const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
if ((!facts.active.length && !manualExportIds.length) || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
await approvalPolicy.assertDecision(tx, batch, approvedList);
const terminal = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } });
const covered = new Set();
const usedFacts = [];
@@ -227,11 +234,13 @@ async function confirmNotPaid(tx, config, batch, request) {
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
assertTerminalBatch(batch, repayment);
if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch)), { lock: true });
const excluded = await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch));
await activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
const usedFacts = await assertNotPaidReleasable(tx, config, batch);
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
const decided = await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: repayment.id, conflictIds: excluded }),
bank_result_state: "confirmed_not_paid", bank_result_ref: `terminal-facts:${usedFacts.sort().join(",")}` } });
await auditLoan(tx, request, "LOAN_NOT_PAID_CONFIRMED", repayment.id, { paymentBatchId: batch.id, factIds: usedFacts });
await auditLoan(tx, request, "LOAN_NOT_PAID_CONFIRMED", repayment.id, { paymentBatchId: batch.id, factIds: usedFacts, ...approvalPolicy.auditContext(decided) });
}
async function terminalState(tx, batch) {
@@ -248,6 +257,7 @@ async function terminalState(tx, batch) {
}
async function uploadTerminalEvidence(tx, batch, request, file) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
const { readLoanRepayment } = loanPayments();
if (batch.status === "cancelled") await require("./loanBankResults").source(tx, batch);
else assertTerminalBatch(batch, await readLoanRepayment(tx, batch));
@@ -264,6 +274,7 @@ async function uploadTerminalEvidence(tx, batch, request, file) {
// Voiding evidence invalidates every fact that relied on it.
async function voidTerminalEvidence(tx, config, batch, request, evidenceId, reason) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
// After release the evidence is part of the closed record; later facts belong
// to the late-result reconciliation flow, not to rewriting this one.
assertTerminalBatch(batch, await loanPayments().readLoanRepayment(tx, batch));
+3
查看文件
@@ -71,6 +71,7 @@ async function assertEvidence(tx, config, batch, input) {
await terminal().assertNotPaidReleasable(tx, config, batch, { manualExportIds: input.exportIds });
}
async function propose(tx, config, batch, request, input) {
await require("./loanApprovalPolicy").assertNotRelatedQ6(tx, batch);
await available(tx, batch);
const previous = await latest(tx, batch);
if (previous && ["proposed", "approved"].includes(previous.status)) {
@@ -89,6 +90,7 @@ async function propose(tx, config, batch, request, input) {
return proposal;
}
async function review(tx, config, batch, request, id, input) {
await require("./loanApprovalPolicy").assertNotRelatedQ6(tx, batch);
const proposal = await latest(tx, batch);
if (!proposal || proposal.id !== id || proposal.status !== "proposed") fail();
const repayment = await available(tx, batch);
@@ -113,6 +115,7 @@ async function review(tx, config, batch, request, id, input) {
return { id, status };
}
async function assertReleasable(tx, config, batch) {
await require("./loanApprovalPolicy").assertNotRelatedQ6(tx, batch);
const proposal = await latest(tx, batch);
if (!proposal || proposal.status !== "approved" || proposal.reviews.length !== 2 ||
proposal.reviews.some((r) => r.decision !== "approve") ||
+9 -3
查看文件
@@ -75,10 +75,11 @@ async function unchanged(tx, batch, round) {
async function event(tx, request, batch, round, action, note) {
await tx.loanReleaseRecoveryEvent.create({ data: { recovery_id: round.id, action, actor_id: request.employee.id, note } });
await auditLoan(tx, request, `LOAN_RELEASE_RECOVERY_${action.toUpperCase()}`, batch.loan_repayment_id,
{ paymentBatchId: batch.id, recoveryId: round.id });
{ paymentBatchId: batch.id, recoveryId: round.id, ...require("./loanApprovalPolicy").auditContext(round) });
}
async function startRecovery(tx, config, batch, request, input) {
await require("./loanApprovalPolicy").assertMaker(tx, batch, request.employee.id);
const existing = await tx.loanReleaseRecovery.findUnique({ where: { payment_batch_id_request_key: { payment_batch_id: batch.id, request_key: input.requestKey } } });
if (existing) {
if (existing.started_by !== request.employee.id || existing.reason !== input.reason) fail("loan.duplicate_registration");
@@ -137,13 +138,14 @@ async function assertReleaseEvidence(tx, config, batch) {
}
async function submitRecovery(tx, config, batch, request, id) {
await require("./loanApprovalPolicy").assertMaker(tx, batch, request.employee.id);
const round = await currentRound(tx, batch, id);
if (round.status !== "collecting") fail("loan.recovery_unavailable");
if (round.started_by !== request.employee.id) throw buildBusinessValidationError("loan.independent_review_required", {}, 403);
await assertReleaseEvidence(tx, config, batch);
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: "proposed", submitted_by: request.employee.id,
submitted_at: new Date(), snapshot_json: await snapshot(tx, batch) } });
await event(tx, request, batch, updated, "submitted", "补证完整,提交独立复核");
await event(tx, request, batch, updated, "submitted", "补证完整,按本批次确认模式送审");
return updated;
}
@@ -151,12 +153,14 @@ async function reviewRecovery(tx, config, batch, request, id, input) {
const round = await currentRound(tx, batch, id);
if (round.status !== "proposed") fail("loan.recovery_unavailable");
const repayment = await recoverable(tx, batch);
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch, repayment), { lock: true });
const excluded = await participants(tx, batch, repayment);
await payments().activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
if (input.decision === "approve") {
await assertReleaseEvidence(tx, config, batch);
if (!await unchanged(tx, batch, round)) fail("loan.recovery_stale");
}
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: input.decision === "approve" ? "approved" : "rejected",
...await require("./loanApprovalPolicy").decisionFields(tx, batch, request.employee.id, { targetId: id, conflictIds: excluded }),
reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason,
...(input.decision === "reject" ? { ended_at: new Date() } : {}) } });
await event(tx, request, batch, updated, updated.status, input.reason);
@@ -164,6 +168,7 @@ async function reviewRecovery(tx, config, batch, request, id, input) {
}
async function withdrawRecovery(tx, batch, request, id, reason) {
await require("./loanApprovalPolicy").assertMaker(tx, batch, request.employee.id);
const round = await currentRound(tx, batch, id);
await recoverable(tx, batch);
if (!["collecting", "proposed"].includes(round.status)) fail("loan.recovery_unavailable");
@@ -182,6 +187,7 @@ async function consumeRecovery(tx, config, batch, request, input) {
return false;
}
if (round.id !== input.recoveryId || round.status !== "approved" || !input.requestKey) fail("loan.recovery_required");
await require("./loanApprovalPolicy").assertDecision(tx, batch, round, { conflictIds: await participants(tx, batch, await recoverable(tx, batch)) });
await assertReleaseEvidence(tx, config, batch);
if (!await unchanged(tx, batch, round)) fail("loan.recovery_stale");
const updated = await tx.loanReleaseRecovery.update({ where: { id: round.id }, data: { status: "consumed", ended_at: new Date(),
@@ -38,6 +38,9 @@ function buildPaymentProcessingSemantics({
requiresBankReceipt: bankReceiptRequired = false,
receiptCount = 0,
paymentOrderExportCount = 0,
relatedParty = false,
relatedPaymentReturned = false,
reviewComment = null,
}) {
const created = buildStage(PAYMENT_PROCESSING_STAGE_DEFINITIONS.created, {
state: "completed",
@@ -124,6 +127,18 @@ function buildPaymentProcessingSemantics({
}
}
if (relatedParty) {
review.label = "公司代表关联方确认";
if (review.state === "current") { review.result = "待关联方确认"; review.detail = "收款人以公司代表身份确认;财务负责经办"; }
else if (review.state === "completed") review.detail = "已按范围授权完成关联方确认";
if (payment.detail) payment.detail = payment.detail.replaceAll("付款复核", "关联方确认");
if (relatedPaymentReturned) {
Object.assign(review, { state: "exception", result: "已退回", detail: "公司代表退回付款申请",
comment: reviewComment, actorSource: "reviewer", occurredAt: reviewedAt });
if (status === "draft") Object.assign(payment, { state: "current", result: "待财务处理",
detail: "财务取消原申请后重新提交;退回未释放占用" });
}
}
return { created, review, payment };
}
@@ -6,11 +6,27 @@ const { buildAuditLogData } = require("../utils/audit");
const { normalizeBankSlip } = require("./paymentReceiptMatching");
const { maskBankAccount } = require("./crypto");
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
const loanApprovalPolicy = require("./loanApprovalPolicy");
function requireFact(condition, code) {
if (!condition) throw new ReceiptVerificationError(code);
}
async function relatedReceiptReview(tx, batch, actorId, proposedBy, { saved = null, lock = false } = {}) {
const repayment = await loanApprovalPolicy.repaymentFor(tx, batch);
if (!loanApprovalPolicy.isRelated(repayment)) return false;
await require("./companyLoanPayments").validateLoanPayment(tx, batch, { postPayment: true });
const [attachments, manual] = await Promise.all([
tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } }),
tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } }, select: { proposed_by: true } }),
]);
const conflicts = [batch.created_by, repayment.submitted_by, proposedBy, ...attachments.map((a) => a.uploaded_by),
...manual.map((p) => p.proposed_by)].filter(Boolean);
if (saved) await loanApprovalPolicy.assertDecision(tx, batch, saved, { conflictIds: conflicts });
else await require("./companyLoanPayments").activeReviewer(tx, actorId, conflicts, { batch, lock });
return true;
}
async function receiptParticipants(tx, batch) {
if (batch.entity_type === "loan_repayment") return require("./companyLoanPayments").loanReceiptParticipants(tx, batch);
requireFact(batch.entity_type === "reimbursement", "receipt_business_unsupported");
@@ -74,20 +90,22 @@ async function loadApprovedManualReceiptEvidence(tx, config, batch, attachmentId
});
if (proposal.requiresIndependentReview) {
requireFact(saved.status === "approved" && saved.reviewed_by && saved.reviewed_at, "receipt_review_permission_required");
if (!readOnly) {
await tx.$queryRaw`SELECT id FROM employees WHERE id = ${saved.reviewed_by}::uuid FOR SHARE`;
await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id = ${saved.reviewed_by}::uuid FOR SHARE`;
if (!await relatedReceiptReview(tx, batch, saved.reviewed_by, saved.proposed_by, { saved })) {
if (!readOnly) {
await tx.$queryRaw`SELECT id FROM employees WHERE id = ${saved.reviewed_by}::uuid FOR SHARE`;
await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id = ${saved.reviewed_by}::uuid FOR SHARE`;
}
const employee = await tx.employee.findUnique({ where: { id: saved.reviewed_by }, include: { roles: true } });
requireFact(employee?.status === "active" && !employee.archived_at, "receipt_review_permission_required");
const roleCodes = employee.roles.map((role) => role.role_code);
const participants = await receiptParticipants(tx, batch);
requireFact(participants.length === new Set(batch.items.map((item) => item.entity_id)).size,
"receipt_review_identity_unresolved");
assertIndependentReceiptReviewer({ reviewer: { id: employee.id, roleCodes },
hasReviewPermission: hasPermission(resolvePermissionsForRoles(roleCodes), "payment.batch.review"),
makerId: batch.created_by, proposedBy: saved.proposed_by,
participants });
}
const employee = await tx.employee.findUnique({ where: { id: saved.reviewed_by }, include: { roles: true } });
requireFact(employee?.status === "active" && !employee.archived_at, "receipt_review_permission_required");
const roleCodes = employee.roles.map((role) => role.role_code);
const participants = await receiptParticipants(tx, batch);
requireFact(participants.length === new Set(batch.items.map((item) => item.entity_id)).size,
"receipt_review_identity_unresolved");
assertIndependentReceiptReviewer({ reviewer: { id: employee.id, roleCodes },
hasReviewPermission: hasPermission(resolvePermissionsForRoles(roleCodes), "payment.batch.review"),
makerId: batch.created_by, proposedBy: saved.proposed_by,
participants });
} else {
requireFact(saved.status === "association_ready", "receipt_match_changed");
}
@@ -167,19 +185,20 @@ async function readManualReceiptDetail(tx, config, { batchId, attachmentId, revi
(!proposal || proposal.source_sha256 === attachment.file_sha256), "receipt_source_changed");
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
const payload = proposal ? JSON.parse(decryptStringOrThrow(config, proposal.payload_encrypted)) : null;
const batch = proposal ? await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } }) : null;
let canReview = false;
if (proposal?.status === "pending_review" && reviewer) {
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
const participants = await receiptParticipants(tx, batch);
if (["reviewed", "failed"].includes(batch.status) &&
participants.length === new Set(batch.items.map((item) => item.entity_id)).size) {
try {
assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId: batch.created_by,
requireFact(hasReviewPermission, "receipt_review_permission_required");
if (!await relatedReceiptReview(tx, batch, reviewer.id, proposal.proposed_by)) assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId: batch.created_by,
proposedBy: proposal.proposed_by,
participants });
canReview = true;
} catch (error) {
if (!(error instanceof ReceiptVerificationError)) throw error;
if (!(error instanceof ReceiptVerificationError) && !String(error?.details?.reasonCode ?? "").startsWith("loan.")) throw error;
}
}
}
@@ -188,6 +207,7 @@ async function readManualReceiptDetail(tx, config, { batchId, attachmentId, revi
version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
pagesExpected: recognition.pagesExpected, pages: recognition.pages.map(pageSummary),
proposal: proposal ? { id: proposal.id, status: proposal.status, proposedBy: proposal.proposed_by,
approvalMode: batch?.entity_type === "loan_repayment" ? (await loanApprovalPolicy.repaymentFor(tx, batch)).approval_mode : "independent",
reviewedBy: proposal.reviewed_by, reason: payload.reason, changedFields: payload.changedFields,
pages: payload.pages.map((page) => ({
...pageSummary({ pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) }), itemId: page.itemId,
@@ -203,12 +223,13 @@ async function audit(tx, request, batchId, action, proposal) {
await tx.auditLog.create({ data: buildAuditLogData(request, {
actorId: request.employee.id, action, targetType: "payment_batch", targetId: batchId,
extra: { proposalId: proposal.id, attachmentId: proposal.attachment_id,
version: proposal.source_version, status: proposal.status },
version: proposal.source_version, status: proposal.status, ...loanApprovalPolicy.auditContext(proposal) },
}) });
}
async function saveManualReceiptProposal(tx, config, request, input) {
const { batch, attachment } = await lockContext(tx, input);
await loanApprovalPolicy.assertMaker(tx, batch, request.employee.id);
requireFact(attachment.receipt_ocr_version < 2147483647, "receipt_job_superseded");
requireFact(["needs_review", "ocr_failed"].includes(attachment.receipt_ocr_state),
"receipt_manual_not_available");
@@ -256,7 +277,8 @@ async function reviewManualReceiptProposal(tx, config, request, input, hasReview
const participants = await receiptParticipants(tx, batch);
requireFact(participants.length === new Set(batch.items.map((item) => item.entity_id)).size,
"receipt_review_identity_unresolved");
assertIndependentReceiptReviewer({ reviewer: request.employee, hasReviewPermission,
requireFact(hasReviewPermission, "receipt_review_permission_required");
if (!await relatedReceiptReview(tx, batch, request.employee.id, saved.proposed_by, { lock: true })) assertIndependentReceiptReviewer({ reviewer: request.employee, hasReviewPermission,
makerId: batch.created_by, proposedBy: saved.proposed_by,
participants });
requireFact(["approve", "reject"].includes(input.decision), "receipt_manual_decision_invalid");
@@ -277,6 +299,7 @@ async function reviewManualReceiptProposal(tx, config, request, input, hasReview
}
const status = input.decision === "approve" ? "approved" : "rejected";
const reviewed = await tx.paymentReceiptManualProposal.update({ where: { id: saved.id }, data: {
...await loanApprovalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: saved.id, conflictIds: [saved.proposed_by, attachment.uploaded_by] }),
status, reviewed_by: request.employee.id, reviewed_at: new Date(),
review_note_encrypted: encryptStringOrThrow(config, input.reason.trim()),
} });
+15 -7
查看文件
@@ -21,15 +21,23 @@ function personalNotificationText(notifications) {
}
async function loadPendingPaymentResponsibility(tx, batch) {
const reviewing = batch.status === "draft";
if (!reviewing && !["reviewed", "failed"].includes(batch.status)) return null;
const returned = batch.status === "draft" && batch.loan_approval_mode === "related_party" && batch.loan_approval_context?.decision === "reject";
const reviewing = batch.status === "draft" && !returned;
if (!reviewing && !returned && !["reviewed", "failed"].includes(batch.status)) return null;
const excludedIds = new Set([reviewing ? batch.created_by : batch.reviewed_by].filter(Boolean));
let relatedApproverId = null;
let paymentAuthorized = true;
if (reviewing && batch.entity_type === "loan_repayment") {
const repayment = await tx.loanRepayment.findUnique({ where: { id: batch.loan_repayment_id },
select: { submitted_by: true, lender: { select: { employee_id: true } } } });
include: { allocations: true, lender: { select: { employee_id: true } } } });
if (!repayment) return null;
excludedIds.add(repayment.submitted_by);
if (repayment.lender.employee_id) excludedIds.add(repayment.lender.employee_id);
const policy = require("./loanApprovalPolicy");
if (policy.isRelated(repayment)) {
relatedApproverId = repayment.approval_context.approverId;
try { await policy.currentForRepayment(tx, repayment, "payment"); }
catch (error) { if (!error.details?.reasonCode?.startsWith("loan.")) throw error; paymentAuthorized = false; }
} else if (repayment.lender.employee_id) excludedIds.add(repayment.lender.employee_id);
}
const roles = reviewing ? ["manager", "ceo"] : ["finance", "finance_mgr"];
const permission = reviewing ? "payment.batch.review" : "payment.batch.manage";
@@ -38,12 +46,12 @@ async function loadPendingPaymentResponsibility(tx, batch) {
orderBy: [{ employee_no: "asc" }, { id: "asc" }],
select: { id: true, name: true, status: true, archived_at: true, roles: { select: { role_code: true } } },
});
const eligible = candidates.filter((person) => !excludedIds.has(person.id) && person.status === "active" &&
const eligible = candidates.filter((person) => paymentAuthorized && (!relatedApproverId || person.id === relatedApproverId) && !excludedIds.has(person.id) && person.status === "active" &&
!person.archived_at && person.roles.some((role) => roles.includes(role.role_code)) &&
hasPermission(resolvePermissionsForRoles(person.roles.map((r) => r.role_code)), permission));
const notifications = await tx.notification.findMany({ where: {
target_type: "payment_batch", target_id: batch.id,
type: reviewing ? NOTIFICATION_TYPES.paymentBatchReviewRequested : NOTIFICATION_TYPES.paymentBatchExecutionRequested,
type: reviewing ? NOTIFICATION_TYPES.paymentBatchReviewRequested : returned ? NOTIFICATION_TYPES.paymentBatchReturned : NOTIFICATION_TYPES.paymentBatchExecutionRequested,
}, select: {
recipient_employee_id: true, recipient: { select: { name: true } },
deliveries: { select: { status: true, result_code: true }, orderBy: [{ attempted_at: "desc" }, { id: "desc" }] },
@@ -52,7 +60,7 @@ async function loadPendingPaymentResponsibility(tx, batch) {
const notified = [...new Set(notifications.map((n) => n.recipient?.name).filter(Boolean))];
return {
stageKey: reviewing ? "payment_batch_review" : "payment_and_receipt",
actorName: names.length ? `可${reviewing ? "复核" : "办理"}:${names.join("、")}` : null,
actorName: names.length ? `可${reviewing ? relatedApproverId ? "代表公司确认" : "复核" : "办理"}:${names.join("、")}` : null,
detail: [names.length ? null : "当前没有符合条件的处理人,请核对在职状态和业务角色",
notified.length ? `站内提醒对象:${notified.join("、")}` : "暂无站内提醒记录", personalNotificationText(notifications)].filter(Boolean).join(";"),
};
+2 -3
查看文件
@@ -18,7 +18,7 @@ const PAYMENT_REVIEWER_ROLE_CODES = new Set(["manager", "ceo"]);
const TEAM_NAVIGATION_ROLE_CODES = new Set(["manager", "ceo", "finance_mgr", "hr", "admin"]);
const FINANCE_PERMISSION_CODES = Object.freeze(["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]);
const FINANCE_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "companyLoans", "invoicePool"]);
const PAYMENT_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations"]);
const PAYMENT_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "companyLoans"]);
const DAY_TO_DAY_BUSINESS_MODULES = new Set(["assets", "contractsRegistry", "purchaseRequests", "projects"]);
const MANAGEMENT_CHILD_MODULES = new Set([
"workspace",
@@ -102,7 +102,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] },
paymentBatches: { key: "paymentBatches", label: "付款", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
companyLoans: { key: "companyLoans", label: "公司借款", icon: "account_balance", href: "/company-loans", anyOf: ["loan.manage"] },
companyLoans: { key: "companyLoans", label: "公司借款", icon: "account_balance", href: "/company-loans", anyOf: ["loan.manage", "payment.batch.review"] },
apOperations: { key: "apOperations", label: "应付账款", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["finance.ledger.view"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
invoicePool: { key: "invoicePool", label: "票据", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.manage"] },
inventoryChecks: { key: "inventoryChecks", label: "资产盘点", icon: "inventory", href: "/inventory-checks", anyOf: ["asset.inventory"] },
@@ -346,7 +346,6 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
continue;
}
if (moduleKey === "companyLoans" && !employee?.roleCodes?.some((r) => FINANCE_ROLE_CODES.has(r))) continue;
const financeNavigation = hasFinanceRole(hasPermission, employee);
const paymentNavigation = financeNavigation || hasPaymentReviewerRole(hasPermission, employee);
if (