借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效
- 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离
- 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页
- 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表)
- 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-10 15:22:12 +08:00
共同撰写人 Claude Opus 5.5
父节点 2063ff1e93
当前提交 4a5cd8f2b3
共修改 34 个文件,包含 1610 行新增和 41 行删除
+5 -1
查看文件
@@ -29,7 +29,11 @@ const ERROR_REASONS = [
{"code":"loan.account_unavailable","domain":"loan","audience":"user","defaultMessage":"借款收款资料暂不可用,请联系财务核对账户加密配置。","recovery":"follow_guidance"},
{"code":"loan.duplicate_registration","domain":"loan","audience":"user","defaultMessage":"该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。","recovery":"follow_guidance"},
{"code":"loan.confirmed_immutable","domain":"loan","audience":"user","defaultMessage":"借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。","recovery":"follow_guidance"},
{"code":"loan.evidence_required","domain":"loan","audience":"user","defaultMessage":"请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。","recovery":"follow_guidance"},
{"code":"loan.lender_ack_scope_invalid","domain":"loan","audience":"user","defaultMessage":"请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。","recovery":"follow_guidance"},
{"code":"loan.lender_ack_exists","domain":"loan","audience":"user","defaultMessage":"所选借款已有待确认或有效的出借人声明,请查看现有记录。","recovery":"follow_guidance"},
{"code":"loan.lender_ack_changed","domain":"loan","audience":"user","defaultMessage":"借款内容、原件或出借人资料已变化,请由财务重新发起确认。","recovery":"follow_guidance"},
{"code":"loan.lender_ack_decided","domain":"loan","audience":"user","defaultMessage":"这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。","recovery":"follow_guidance"},
{"code":"loan.evidence_required","domain":"loan","audience":"user","defaultMessage":"请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。","recovery":"follow_guidance"},
{"code":"loan.evidence_invalid","domain":"loan","audience":"user","defaultMessage":"借款材料不符合要求,请使用有效的 PDF 或图片文件。","recovery":"follow_guidance"},
{"code":"loan.balance_unconfirmed","domain":"loan","audience":"user","defaultMessage":"借款余额尚未核实,不能申请归还本金。","recovery":"follow_guidance"},
{"code":"loan.allocation_invalid","domain":"loan","audience":"user","defaultMessage":"分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。","recovery":"follow_guidance"},
@@ -20,6 +20,7 @@ const NOTIFICATION_TYPES = Object.freeze({
paymentBatchReviewRequested: 'payment_batch.review_requested',
paymentBatchExecutionRequested: 'payment_batch.execution_requested',
paymentBatchReturned: 'payment_batch.returned',
loanLenderAcknowledgementRequested: 'loan.lender_acknowledgement_requested',
leaveApproved: 'leave.approved',
leaveRejected: 'leave.rejected',
leaveReturned: 'leave.returned',
+1
查看文件
@@ -41,6 +41,7 @@ const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"];
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"];
const EMPLOYEE_BASE_PERMISSIONS = [
"loan.acknowledge_own",
...EXPENSE_SELF_SERVICE_PERMISSIONS,
...LEAVE_SELF_SERVICE_PERMISSIONS,
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
+60 -4
查看文件
@@ -16,6 +16,7 @@ const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, b
const approvalPolicy = require("../services/loanApprovalPolicy");
const employeeLenders = require("../services/loanLenderEmployee");
const incomingReceipts = require("../services/loanIncomingReceipt");
const lenderAcknowledgements = require("../services/loanLenderAcknowledgement");
const UUID = z.string().uuid();
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString);
@@ -45,6 +46,11 @@ const CONFIRM = z.object({ expectedVersion: VERSION, accountingConfirmationRef:
const EVIDENCE_KIND = z.enum(["agreement", "incoming", "accounting_confirmation", "adjustment"]);
const LIST = z.object({ lenderId: UUID.optional(), page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(30) }).strict();
const ACK_PROPOSE = z.object({ lenderId: UUID, expectedIdentityVersion: VERSION, requestKey: UUID, reason: TEXT,
loans: z.array(z.object({ id: UUID, version: VERSION }).strict()).min(1).max(50) }).strict();
const ACK_DECIDE = z.object({ action: z.enum(["confirm", "return"]), reason: TEXT,
contentHash: z.string().regex(/^[a-f0-9]{64}$/), originalChecked: z.boolean() }).strict()
.refine((value) => value.action !== "confirm" || value.originalChecked);
const MAX_BYTES = 10 * 1024 * 1024;
function parse(schema, value) {
@@ -110,6 +116,41 @@ 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");
function ensureAcknowledgementReader(request) {
if (!isFinance(request) && !fastify.hasPermission(request.employee, "loan.acknowledge_own")) ensureLoanManager(fastify, request);
}
fastify.get("/loan-lender-acknowledgements", auth, async (request, reply) => {
ensureAcknowledgementReader(request);
const query = parse(LIST.omit({ lenderId: true }), request.query ?? {});
const where = isFinance(request) ? {} : { employee_id: request.employee.id };
const [items, total] = await Promise.all([
fastify.prisma.loanLenderAcknowledgement.findMany({ where, include: lenderAcknowledgements.INCLUDE,
orderBy: [{ proposed_at: "desc" }, { id: "asc" }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
fastify.prisma.loanLenderAcknowledgement.count({ where }),
]);
reply.header("Cache-Control", "private, no-store");
return sendData(reply, request, { items: await Promise.all(items.map((record) =>
lenderAcknowledgements.present(fastify.prisma, record, request.employee.id))), total });
});
fastify.get("/loan-lender-acknowledgements/:id", auth, async (request, reply) => {
ensureAcknowledgementReader(request);
const record = await lenderAcknowledgements.read(fastify.prisma, fastify, request, parse(UUID, request.params.id));
reply.header("Cache-Control", "private, no-store");
return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id));
});
fastify.post("/loan-lender-acknowledgements", auth, async (request, reply) => {
ensureLoanManager(fastify, request);
const record = await lenderAcknowledgements.propose(fastify.prisma, fastify, request, parse(ACK_PROPOSE, request.body));
reply.header("Cache-Control", "private, no-store");
return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id));
});
fastify.post("/loan-lender-acknowledgements/:id/decision", auth, async (request, reply) => {
ensureAcknowledgementReader(request);
const record = await lenderAcknowledgements.decide(fastify.prisma, fastify, request,
parse(UUID, request.params.id), parse(ACK_DECIDE, request.body));
reply.header("Cache-Control", "private, no-store");
return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id));
});
async function readAuthorization(request, id) {
const record = await fastify.prisma.loanRelatedAuthorization.findUnique({ where: { id }, include: approvalPolicy.INCLUDE });
if (!record) throw loanNotFound();
@@ -366,7 +407,13 @@ async function companyLoansRoutes(fastify) {
ensureLoanManager(fastify, request);
const id = parse(UUID, request.params.id); const body = parse(CONFIRM, request.body);
const item = await fastify.prisma.$transaction(async (tx) => {
const target = await tx.companyLoan.findUnique({ where: { id }, select: { lender_id: true } });
if (!target) throw loanNotFound();
// Serialize consuming an acknowledgement with identity/original changes,
// including changes to another loan in the same frozen statement.
await lockLender(tx, target.lender_id);
const loan = await lockLoan(tx, id);
if (loan.lender_id !== target.lender_id) throw loanNotFound();
assertCurrentVersion(loan.version, body.expectedVersion);
if (loan.confirmation_status === "confirmed") throw buildBusinessValidationError("loan.confirmed_immutable", {}, 409);
assertEvidenceComplete(loan);
@@ -431,11 +478,16 @@ async function companyLoansRoutes(fastify) {
fastify.post("/company-loans/:id/evidences/:evidenceId/download", auth, async (request, reply) => {
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, authorizationId } = parse(z.object({ reason: TEXT, paymentBatchId: UUID.optional(), authorizationId: UUID.optional() }).strict()
.refine((v) => !(v.paymentBatchId && v.authorizationId)), request.body);
if (!finance) {
const { reason, paymentBatchId, authorizationId, lenderAcknowledgementId } = parse(z.object({ reason: TEXT,
paymentBatchId: UUID.optional(), authorizationId: UUID.optional(), lenderAcknowledgementId: UUID.optional() }).strict()
.refine((v) => [v.paymentBatchId, v.authorizationId, v.lenderAcknowledgementId].filter(Boolean).length <= 1), request.body);
let acknowledgedOriginal;
if (lenderAcknowledgementId) {
ensureAcknowledgementReader(request);
acknowledgedOriginal = await lenderAcknowledgements.assertOriginalAccess(fastify.prisma, fastify, request, lenderAcknowledgementId, id, evidenceId);
} else if (!finance) {
if (!request.employee.roleCodes.some((r) => ["manager", "ceo"].includes(r)) || !fastify.hasPermission(request.employee, "payment.batch.review")) ensureLoanManager(fastify, request);
if (authorizationId) {
const record = await readAuthorization(request, authorizationId);
if (!record.loans.some((loan) => loan.loan_id === id)) throw loanNotFound();
@@ -452,6 +504,7 @@ async function companyLoansRoutes(fastify) {
}
const item = await fastify.prisma.loanEvidence.findFirst({ where: { id: evidenceId, loan_id: id, archived_at: null, governance_status: "approved" } });
if (!item) throw loanNotFound();
if (acknowledgedOriginal && acknowledgedOriginal.sha256 !== item.file_sha256) throw loanNotFound();
let validated;
try {
if (item.file_size > MAX_BYTES) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409);
@@ -459,12 +512,15 @@ async function companyLoansRoutes(fastify) {
validated = await validateEvidenceFile({ buffer: await boundedBuffer(opened.stream), fileName: item.original_name,
declaredMimeType: item.mime_type, normalizeImages: false });
if (validated.fileSha256 !== item.file_sha256) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409);
// A source/identity change during storage IO must not grant stale access.
if (lenderAcknowledgementId) await lenderAcknowledgements.assertOriginalAccess(fastify.prisma, fastify, request, lenderAcknowledgementId, id, evidenceId);
} catch (error) {
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOAD_BLOCKED", id, { evidenceId });
if (error instanceof StorageNotFoundError) throw loanNotFound();
throw error;
}
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOADED", id, { evidenceId, reason,
...(lenderAcknowledgementId ? { lenderAcknowledgementId } : {}),
...(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")
+9 -2
查看文件
@@ -3,9 +3,11 @@ const { AppError } = require("../utils/AppError");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { buildAuditLogData } = require("../utils/audit");
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("./crypto");
const { validAcknowledgement, acknowledgementRenewable } = require("./loanLenderAcknowledgementSnapshot");
const LENDER_INCLUDE = { bank_institution: true, legal_entity: true };
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true };
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true,
lender_acknowledgements: { include: { acknowledgement: { include: { decision: true } } } } };
function ensureLoanManager(fastify, request) {
if (!request.employee?.roleCodes?.some((role) => ["finance", "finance_mgr"].includes(role)) ||
@@ -80,6 +82,7 @@ function mapEvidence(evidence) {
}
function mapLoan(config, loan) {
const acknowledgement = validAcknowledgement(loan);
return {
id: loan.id, loanNo: loan.loan_no, originReference: loan.origin_reference,
lender: mapLender(config, loan.lender), currency: loan.currency_code,
@@ -89,6 +92,9 @@ function mapLoan(config, loan) {
balanceAsOf: loan.balance_as_of.toISOString().slice(0, 10), historicalEntry: loan.historical_entry,
confirmationStatus: loan.confirmation_status, confirmedAt: loan.confirmed_at, version: loan.version,
accountingConfirmationRef: loan.accounting_confirmation_ref,
lenderAcknowledgement: acknowledgement ? { id: acknowledgement.id, confirmedAt: acknowledgement.decision.created_at,
lenderName: acknowledgement.lender_name } : null,
lenderAcknowledgementRenewable: acknowledgementRenewable(loan),
...loanBalances(loan), evidences: (loan.evidences ?? []).map(mapEvidence),
};
}
@@ -126,7 +132,8 @@ async function inFlightLenderIds(db, lenderIds) {
function assertEvidenceComplete(loan) {
const kinds = new Set((loan.evidences ?? []).filter((e) => !e.archived_at && e.governance_status === "approved").map((e) => e.kind));
const required = ["agreement", "incoming", "accounting_confirmation"];
const required = ["incoming", "accounting_confirmation"];
if (!validAcknowledgement(loan)) required.push("agreement");
if (new Prisma.Decimal(loan.opening_outstanding_principal).lt(loan.original_principal)) required.push("adjustment");
if (required.some((kind) => !kinds.has(kind))) throw buildBusinessValidationError("loan.evidence_required", {}, 409);
}
@@ -0,0 +1,147 @@
const { randomUUID, createHash } = require("node:crypto");
const { Prisma } = require("@prisma/client");
const { AppError } = require("../utils/AppError");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { lockLender, auditLoan, loanNotFound, assertCurrentVersion } = require("./companyLoans");
const { canonical, same, snapshot, matchesLender, acknowledgementRenewable } = require("./loanLenderAcknowledgementSnapshot");
const { createNotifications } = require("./notifications");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const INCLUDE = { loans: { orderBy: { loan_id: "asc" } }, decision: true };
const SOURCE = { lender: true, evidences: true };
const digest = (value) => createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
const errors = {
"loan.lender_ack_changed": () => buildBusinessValidationError("loan.lender_ack_changed", {}, 409),
"loan.lender_ack_scope_invalid": () => buildBusinessValidationError("loan.lender_ack_scope_invalid", {}, 409),
"loan.lender_ack_exists": () => buildBusinessValidationError("loan.lender_ack_exists", {}, 409),
"loan.lender_ack_decided": () => buildBusinessValidationError("loan.lender_ack_decided", {}, 409),
"loan.duplicate_registration": () => buildBusinessValidationError("loan.duplicate_registration", {}, 409),
};
const fail = (code = "loan.lender_ack_changed") => { throw errors[code](); };
const forbidden = () => { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); };
const isFinance = (fastify, request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) &&
fastify.hasPermission(request.employee, "loan.manage");
async function lockLoans(tx, ids) {
await tx.$queryRaw`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids.map((id) => Prisma.sql`${id}::uuid`))}) ORDER BY id FOR UPDATE`;
return tx.companyLoan.findMany({ where: { id: { in: ids } }, include: { ...SOURCE,
lender_acknowledgements: { select: { acknowledgement: { select: { invalidated_at: true } } } } }, orderBy: { id: "asc" } });
}
async function activeEmployee(tx, id, lock = false) {
if (lock) await tx.$queryRaw`SELECT id FROM employees WHERE id=${id}::uuid FOR NO KEY UPDATE`;
return tx.employee.findFirst({ where: { id, status: "active", archived_at: null }, select: { id: true, name: true } });
}
function sourceMatches(record, loans) {
return !record.invalidated_at && record.loans.length === record.loan_ids.length && loans.length === record.loan_ids.length &&
record.loans.every((item) => {
const loan = loans.find((l) => l.id === item.loan_id);
return loan && matchesLender(record, loan.lender) && same(item.snapshot_json, snapshot(loan));
});
}
async function read(db, fastify, request, id) {
const record = await db.loanLenderAcknowledgement.findFirst({
where: { id, ...(isFinance(fastify, request) ? {} : { employee_id: request.employee.id }) }, include: INCLUDE,
});
if (!record) throw loanNotFound();
return record;
}
async function present(db, record, actorId) {
const [loans, maker, signer] = await Promise.all([
db.companyLoan.findMany({ where: { id: { in: record.loan_ids } }, include: SOURCE }),
db.employee.findUnique({ where: { id: record.proposed_by }, select: { name: true } }),
activeEmployee(db, record.employee_id),
]);
const current = sourceMatches(record, loans);
return {
id: record.id, lenderId: record.lender_id, legalEntityId: record.legal_entity_id,
lenderName: record.lender_name, companyName: record.company_name,
proposedByName: maker?.name ?? "原经办人", proposedAt: record.proposed_at, reason: record.reason,
contentHash: record.content_hash, current, invalidatedAt: record.invalidated_at,
status: !current ? "invalid" : record.decision?.action === "confirm" ? "confirmed" : record.decision ? "returned" : "pending",
decision: record.decision ? { action: record.decision.action, reason: record.decision.reason,
createdAt: record.decision.created_at, lenderName: record.lender_name } : null,
canDecide: current && !record.decision && Boolean(signer) && actorId === record.employee_id && actorId !== record.proposed_by,
canReadOriginals: current,
loans: record.loans.map((item) => item.snapshot_json),
};
}
async function propose(prisma, fastify, request, input) {
const ids = input.loans.map((l) => l.id).sort();
if (ids.length !== new Set(ids).size) fail("loan.lender_ack_scope_invalid");
const requestHash = digest({ ...input, loans: [...input.loans].sort((a, b) => a.id.localeCompare(b.id)) });
const lookup = (db) => db.loanLenderAcknowledgement.findUnique({ where: {
proposed_by_request_key: { proposed_by: request.employee.id, request_key: input.requestKey },
}, include: INCLUDE });
const matchRetry = (record) => { if (record.request_hash !== requestHash) fail("loan.duplicate_registration"); return record; };
try {
return await prisma.$transaction(async (tx) => {
const lender = await lockLender(tx, input.lenderId);
const old = await lookup(tx); if (old) return matchRetry(old);
if (lender.archived_at || lender.identity_status !== "verified" || !lender.employee_id || lender.employee_id === request.employee.id) fail("loan.lender_ack_scope_invalid");
assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion);
const loans = await lockLoans(tx, ids);
if (loans.length !== ids.length || loans.some((loan) => loan.lender_id !== lender.id ||
loan.legal_entity_id !== lender.legal_entity_id ||
(loan.confirmation_status !== "unconfirmed" && !acknowledgementRenewable(loan)) || !snapshot(loan).originals.length)) fail("loan.lender_ack_scope_invalid");
for (const loan of loans) assertCurrentVersion(loan.version, input.loans.find((l) => l.id === loan.id).version);
const existing = await tx.loanLenderAcknowledgement.findFirst({ where: {
lender_id: lender.id, invalidated_at: null, loan_ids: { hasSome: ids },
OR: [{ decision: null }, { decision: { action: "confirm" } }],
} });
if (existing) fail("loan.lender_ack_exists");
if (!await activeEmployee(tx, lender.employee_id, true)) fail("loan.lender_ack_scope_invalid");
const snapshots = loans.map(snapshot);
const record = await tx.loanLenderAcknowledgement.create({ data: {
id: randomUUID(), lender_id: lender.id, legal_entity_id: lender.legal_entity_id, employee_id: lender.employee_id,
identity_version: lender.identity_version, account_version: lender.account_version,
lender_name: lender.name, company_name: lender.legal_entity.name, loan_ids: ids,
content_hash: digest(snapshots), request_hash: requestHash, reason: input.reason,
proposed_by: request.employee.id, request_key: input.requestKey,
loans: { create: snapshots.map((value) => ({ loan_id: value.loanId, snapshot_json: value })) },
}, include: INCLUDE });
await auditLoan(tx, request, "LOAN_LENDER_ACK_PROPOSED", record.id, { loanIds: ids, contentHash: record.content_hash });
await createNotifications(tx, {
recipientEmployeeIds: [record.employee_id],
type: NOTIFICATION_TYPES.loanLenderAcknowledgementRequested,
title: "借款声明待本人确认",
body: "财务已发起借款声明,请核对借款明细及原件后确认,或填写原因退回。",
targetType: "loan_lender_acknowledgement", targetId: record.id,
});
return record;
});
} catch (error) {
if (error?.code === "P2002") { const old = await lookup(prisma); if (old) return matchRetry(old); }
throw error;
}
}
async function decide(prisma, fastify, request, id, input) {
// Restrict before locking or disclosing the existence of someone else's request.
const initial = await read(prisma, fastify, request, id);
if (initial.employee_id !== request.employee.id || initial.proposed_by === request.employee.id) forbidden();
return prisma.$transaction(async (tx) => {
await lockLender(tx, initial.lender_id);
const loans = await lockLoans(tx, initial.loan_ids);
await tx.$queryRaw`SELECT id FROM loan_lender_acknowledgements WHERE id=${id}::uuid FOR UPDATE`;
const record = await read(tx, fastify, request, id);
if (input.contentHash !== record.content_hash || !sourceMatches(record, loans)) fail();
if (!await activeEmployee(tx, request.employee.id, true)) forbidden();
if (record.decision) {
if (record.decision.actor_id === request.employee.id && record.decision.action === input.action &&
record.decision.reason === input.reason && record.decision.original_checked === input.originalChecked) return record;
fail("loan.lender_ack_decided");
}
await tx.loanLenderAcknowledgementDecision.create({ data: { acknowledgement_id: id,
action: input.action, actor_id: request.employee.id, reason: input.reason, original_checked: input.originalChecked } });
await auditLoan(tx, request, input.action === "confirm" ? "LOAN_LENDER_ACK_CONFIRMED" : "LOAN_LENDER_ACK_RETURNED", id,
{ loanIds: record.loan_ids, contentHash: record.content_hash });
return tx.loanLenderAcknowledgement.findUniqueOrThrow({ where: { id }, include: INCLUDE });
});
}
async function assertOriginalAccess(db, fastify, request, acknowledgementId, loanId, evidenceId) {
const record = await read(db, fastify, request, acknowledgementId);
const loans = await db.companyLoan.findMany({ where: { id: { in: record.loan_ids } }, include: SOURCE });
if (!sourceMatches(record, loans)) fail();
const original = record.loans.find((l) => l.loan_id === loanId)?.snapshot_json?.originals?.find((e) => e.id === evidenceId);
if (!original) throw loanNotFound();
return original;
}
module.exports = { INCLUDE, isFinance, read, present, propose, decide, assertOriginalAccess, sourceMatches };
@@ -0,0 +1,42 @@
const { Prisma } = require("@prisma/client");
// Compare structured data independently of PostgreSQL JSONB key order.
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
return value;
}
const same = (left, right) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
function snapshot(loan) {
return {
loanId: loan.id, loanNo: loan.loan_no, lenderId: loan.lender_id, legalEntityId: loan.legal_entity_id,
originReference: loan.origin_reference, currency: loan.currency_code,
originalPrincipal: new Prisma.Decimal(loan.original_principal).toFixed(2),
openingOutstandingPrincipal: new Prisma.Decimal(loan.opening_outstanding_principal).toFixed(2),
receivedDate: loan.received_date.toISOString().slice(0, 10), balanceAsOf: loan.balance_as_of.toISOString().slice(0, 10),
historicalEntry: loan.historical_entry,
originals: (loan.evidences ?? []).filter((e) => e.kind === "incoming" && !e.archived_at && e.governance_status === "approved")
.sort((a, b) => a.id.localeCompare(b.id)).map((e) => ({ id: e.id, sha256: e.file_sha256,
originalName: e.original_name, fileSize: e.file_size, mimeType: e.mime_type })),
};
}
function matchesLender(record, lender) {
return Boolean(lender && !lender.archived_at && lender.identity_status === "verified" &&
record.lender_id === lender.id && record.legal_entity_id === lender.legal_entity_id &&
record.employee_id === lender.employee_id && record.identity_version === lender.identity_version &&
record.account_version === lender.account_version && record.lender_name === lender.name);
}
function validAcknowledgement(loan) {
return (loan.lender_acknowledgements ?? []).find((item) => {
const record = item.acknowledgement;
return record && !record.invalidated_at && record.decision?.action === "confirm" &&
record.decision.original_checked && record.decision.actor_id === record.employee_id &&
record.employee_id !== record.proposed_by && record.loan_ids.includes(loan.id) &&
matchesLender(record, loan.lender) && same(item.snapshot_json, snapshot(loan));
})?.acknowledgement ?? null;
}
function acknowledgementRenewable(loan) {
return loan.confirmation_status === "confirmed" && Boolean(loan.lender_acknowledgements?.some((item) => item.acknowledgement?.invalidated_at));
}
module.exports = { canonical, same, snapshot, matchesLender, validAcknowledgement, acknowledgementRenewable };
+3 -2
查看文件
@@ -348,9 +348,10 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
const financeNavigation = hasFinanceRole(hasPermission, employee);
const paymentNavigation = financeNavigation || hasPaymentReviewerRole(hasPermission, employee);
const moduleFinanceNavigation = PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation;
if (
FINANCE_NAVIGATION_MODULES.has(moduleKey) &&
!(PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation)
!moduleFinanceNavigation
) {
continue;
}
@@ -373,7 +374,7 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
const section = resolveNavigationSection({
moduleKey,
placement,
financeNavigation: PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation,
financeNavigation: moduleFinanceNavigation,
});
if (!section) {