借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效 - 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离 - 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页 - 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表) - 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 };
|
||||
@@ -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) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户