借款出借人改为从现有员工选择并复用资料(09-17-company-loan-principal-repayment)
- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号 - 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE - 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效 - 电脑/手机共用对话框,写请求在卸载或身份切换时取消 - 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -1,4 +1,9 @@
|
||||
const ERROR_REASONS = [
|
||||
{"code":"loan.employee_source_incomplete","domain":"loan","audience":"user","defaultMessage":"员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。","recovery":"follow_guidance"},
|
||||
{"code":"loan.employee_source_changed","domain":"loan","audience":"user","defaultMessage":"员工资料已变化,请重新加载并核对后再保存。","recovery":"follow_guidance"},
|
||||
{"code":"loan.employee_lender_conflict","domain":"loan","audience":"user","defaultMessage":"该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。","recovery":"follow_guidance"},
|
||||
{"code":"loan.employee_lender_refresh_required","domain":"loan","audience":"user","defaultMessage":"已有出借人资料与当前员工资料不同,请核对后使用“使用当前员工资料”更新。","recovery":"follow_guidance"},
|
||||
{"code":"loan.employee_account_managed","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"},
|
||||
|
||||
@@ -14,12 +14,14 @@ const {
|
||||
const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = require("../services/storage");
|
||||
const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy");
|
||||
const approvalPolicy = require("../services/loanApprovalPolicy");
|
||||
const employeeLenders = require("../services/loanLenderEmployee");
|
||||
|
||||
const UUID = z.string().uuid();
|
||||
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString);
|
||||
const MONEY = z.string().regex(/^\d{1,12}(\.\d{1,2})?$/);
|
||||
const TEXT = z.string().trim().min(2).max(500);
|
||||
const VERSION = z.number().int().min(1).max(2147483646);
|
||||
const EMPLOYEE_SOURCE = z.object({ expectedSourceVersion: z.string().regex(/^[a-f0-9]{64}$/), detailsConfirmed: z.literal(true) });
|
||||
const ACCOUNT = z.object({
|
||||
bankInstitutionId: UUID, bankName: z.string().trim().min(1).max(200),
|
||||
bankAccount: z.string().regex(/^\d{10,30}$/), accountName: z.string().trim().min(1).max(120),
|
||||
@@ -131,8 +133,30 @@ async function companyLoansRoutes(fastify) {
|
||||
const query = parse(z.object({ q: z.string().trim().max(100).default("") }).strict(), request.query ?? {});
|
||||
const items = await fastify.prisma.employee.findMany({ where: { status: "active", archived_at: null,
|
||||
...(query.q ? { OR: [{ name: { contains: query.q, mode: "insensitive" } }, { employee_no: { contains: query.q } }] } : {}),
|
||||
}, select: { id: true, name: true, employee_no: true }, orderBy: [{ employee_no: "asc" }, { id: "asc" }], take: 100 });
|
||||
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, name: item.name, employeeNo: item.employee_no })) });
|
||||
}, select: { id: true, name: true, employee_no: true, legal_entity_id: true }, orderBy: [{ employee_no: "asc" }, { id: "asc" }], take: 100 });
|
||||
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, name: item.name, employeeNo: item.employee_no, legalEntityId: item.legal_entity_id })) });
|
||||
});
|
||||
fastify.get("/loan-lenders/employee-source/:employeeId", auth, async (request, reply) => {
|
||||
employeeLenders.ensureEmployeeBankAccess(fastify, request);
|
||||
const employeeId = parse(UUID, request.params.employeeId);
|
||||
const query = parse(z.object({ legalEntityId: UUID }).strict(), request.query ?? {});
|
||||
const preview = await employeeLenders.previewEmployeeLender(fastify.prisma, fastify.config, employeeId, query.legalEntityId);
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return sendData(reply, request, preview);
|
||||
});
|
||||
fastify.post("/loan-lenders/from-employee", auth, async (request, reply) => {
|
||||
employeeLenders.ensureEmployeeBankAccess(fastify, request);
|
||||
const body = parse(EMPLOYEE_SOURCE.extend({ requestKey: UUID, employeeId: UUID, legalEntityId: UUID }).strict(), request.body);
|
||||
const result = await employeeLenders.linkEmployeeLender(fastify.prisma, fastify.config, request, body);
|
||||
const inFlight = await inFlightLenderIds(fastify.prisma, [result.lender.id]);
|
||||
return sendData(reply, request, { lender: mapLender(fastify.config, result.lender, { inFlight: inFlight.has(result.lender.id) }), reused: result.reused });
|
||||
});
|
||||
fastify.post("/loan-lenders/:id/employee-refresh", auth, async (request, reply) => {
|
||||
employeeLenders.ensureEmployeeBankAccess(fastify, request);
|
||||
const id = parse(UUID, request.params.id);
|
||||
const body = parse(EMPLOYEE_SOURCE.extend({ expectedIdentityVersion: VERSION, expectedAccountVersion: VERSION, reason: TEXT }).strict(), request.body);
|
||||
const result = await fastify.prisma.$transaction((tx) => employeeLenders.refreshEmployeeLender(tx, fastify.config, request, id, body));
|
||||
return sendData(reply, request, mapLender(fastify.config, result));
|
||||
});
|
||||
fastify.get("/loan-lenders", auth, async (request, reply) => {
|
||||
ensureLoanManager(fastify, request);
|
||||
@@ -177,9 +201,11 @@ async function companyLoansRoutes(fastify) {
|
||||
const lender = await lockLender(tx, id);
|
||||
assertCurrentVersion(lender.identity_version, body.expectedVersion);
|
||||
await assertLenderNotInFlight(tx, id);
|
||||
await employeeLenders.lockEmployeeIdentities(tx, [lender.employee_id, body.employeeId]);
|
||||
if (body.employeeId && !await tx.employee.findFirst({ where: { id: body.employeeId, status: "active", archived_at: null } })) {
|
||||
throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
|
||||
}
|
||||
await employeeLenders.assertUniqueEmployeeLender(tx, body.employeeId, lender.legal_entity_id, id);
|
||||
const result = await tx.loanLender.update({ where: { id }, data: {
|
||||
employee_id: body.employeeId, identity_status: "verified", identity_version: { increment: 1 },
|
||||
identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(),
|
||||
@@ -201,6 +227,7 @@ async function companyLoansRoutes(fastify) {
|
||||
const lender = await lockLender(tx, id);
|
||||
assertCurrentVersion(lender.account_version, body.expectedVersion);
|
||||
await assertLenderNotInFlight(tx, id);
|
||||
if (lender.employee_id) throw buildBusinessValidationError("loan.employee_account_managed", {}, 409);
|
||||
await validateLenderReferences(tx, { ...body, legalEntityId: lender.legal_entity_id });
|
||||
const result = await tx.loanLender.update({ where: { id }, data: {
|
||||
bank_institution_id: body.bankInstitutionId, bank_name: body.bankName, ...encryptLenderAccount(fastify.config, body),
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
const { createHash } = require("node:crypto");
|
||||
const { Prisma } = require("@prisma/client");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { decryptStringOrThrow, maskBankAccount } = require("./crypto");
|
||||
const {
|
||||
LENDER_INCLUDE, ensureLoanManager, lockLender, assertLenderNotInFlight,
|
||||
assertCurrentVersion, encryptLenderAccount, readLenderAccount, mapLender, auditLoan, inFlightLenderIds,
|
||||
} = require("./companyLoans");
|
||||
|
||||
function ensureEmployeeBankAccess(fastify, request) {
|
||||
ensureLoanManager(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "employee.bank.read") &&
|
||||
!fastify.hasPermission(request.employee, "employee.bank.manage")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Existing-lender actions always acquire the lender before these personnel locks.
|
||||
// Creation has no existing lender to lock and must never acquire one afterwards.
|
||||
// Non-key updates still serialize source changes and competing associations,
|
||||
// while allowing audit/actor foreign keys to acquire their implicit KEY SHARE.
|
||||
async function lockEmployeeIdentities(tx, ids) {
|
||||
const unique = [...new Set(ids.filter(Boolean))].sort();
|
||||
if (unique.length) await tx.$queryRaw`SELECT id FROM employees
|
||||
WHERE id IN (${Prisma.join(unique.map((id) => Prisma.sql`${id}::uuid`))}) ORDER BY id FOR NO KEY UPDATE`;
|
||||
}
|
||||
|
||||
async function linkedLenders(db, employeeId, legalEntityId) {
|
||||
// Archived or ambiguous historical links also block a second registration.
|
||||
return db.loanLender.findMany({ where: { employee_id: employeeId, legal_entity_id: legalEntityId },
|
||||
include: LENDER_INCLUDE, orderBy: { id: "asc" }, take: 2 });
|
||||
}
|
||||
|
||||
async function assertUniqueEmployeeLender(tx, employeeId, legalEntityId, lenderId) {
|
||||
if (!employeeId) return;
|
||||
const links = await linkedLenders(tx, employeeId, legalEntityId);
|
||||
if (links.some((lender) => lender.id !== lenderId)) {
|
||||
throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function readEmployeeSource(db, config, employeeId, legalEntityId) {
|
||||
const employee = await db.employee.findUnique({ where: { id: employeeId }, select: {
|
||||
id: true, name: true, employee_no: true, legal_entity_id: true, status: true, archived_at: true, updated_at: true,
|
||||
bank_name: true, bank_institution_id: true, bank_account_encrypted: true,
|
||||
legal_entity: { select: { name: true } },
|
||||
bank_institution: { select: { id: true, code: true, name: true, enabled: true, archived_at: true } },
|
||||
} });
|
||||
const company = await db.legalEntity.findFirst({ where: { id: legalEntityId, enabled: true, archived_at: null, currency_code: "CNY" } });
|
||||
if (!employee || employee.status !== "active" || employee.archived_at || !company) {
|
||||
throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
|
||||
}
|
||||
const name = employee.name?.trim() ?? "";
|
||||
const bankName = employee.bank_name?.trim() ?? "";
|
||||
const bank = employee.bank_institution;
|
||||
const problems = [];
|
||||
if (!name || name.length > 120) problems.push("name_invalid");
|
||||
if (!bank || !bankName || bankName.length > 200) problems.push("bank_missing");
|
||||
if (bank && (bank.code !== "CCB" || !bank.enabled || bank.archived_at)) problems.push("bank_unsupported");
|
||||
let bankAccount = null;
|
||||
if (!employee.bank_account_encrypted) problems.push("account_missing");
|
||||
else {
|
||||
try {
|
||||
bankAccount = decryptStringOrThrow(config, employee.bank_account_encrypted);
|
||||
if (!bankAccount) problems.push("account_unavailable");
|
||||
else if (!/^\d{10,30}$/.test(bankAccount)) problems.push("account_invalid");
|
||||
} catch { problems.push("account_unavailable"); }
|
||||
}
|
||||
// Bind to all source fields, not just the millisecond timestamp. Ciphertext is
|
||||
// hashed without exposing it or making a plaintext account hash enumerable.
|
||||
const sourceVersion = createHash("sha256").update(JSON.stringify([
|
||||
"loan-employee-source-v1", legalEntityId, employee.id, employee.updated_at,
|
||||
employee.name, employee.legal_entity_id, employee.status, employee.archived_at,
|
||||
employee.bank_name, employee.bank_institution_id, employee.bank_account_encrypted, bank,
|
||||
])).digest("hex");
|
||||
return { employee, company, name, bankName, bankAccount, bankInstitutionId: bank?.id, problems, sourceVersion };
|
||||
}
|
||||
|
||||
function assertUsableSource(source, expectedSourceVersion) {
|
||||
if (source.sourceVersion !== expectedSourceVersion) {
|
||||
throw buildBusinessValidationError("loan.employee_source_changed", {}, 409);
|
||||
}
|
||||
if (source.problems.length) throw buildBusinessValidationError("loan.employee_source_incomplete", {}, 409);
|
||||
}
|
||||
|
||||
function matchesSource(config, lender, source) {
|
||||
if (lender.identity_status !== "verified" || lender.name !== source.name ||
|
||||
lender.bank_institution_id !== source.bankInstitutionId || lender.bank_name !== source.bankName) return false;
|
||||
try {
|
||||
const account = readLenderAccount(config, lender);
|
||||
return account.bankAccount === source.bankAccount && account.accountName === source.name;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function previewEmployeeLender(db, config, employeeId, legalEntityId) {
|
||||
const source = await readEmployeeSource(db, config, employeeId, legalEntityId);
|
||||
const links = await linkedLenders(db, employeeId, legalEntityId);
|
||||
const conflict = links.length > 1 || Boolean(links[0]?.archived_at);
|
||||
const existing = !conflict ? links[0] : null;
|
||||
const inFlight = existing ? await inFlightLenderIds(db, [existing.id]) : new Set();
|
||||
return {
|
||||
employee: { id: source.employee.id, name: source.name, employeeNo: source.employee.employee_no,
|
||||
legalEntityId: source.employee.legal_entity_id, legalEntityName: source.employee.legal_entity?.name ?? null },
|
||||
legalEntityId, legalEntityName: source.company.name, sourceVersion: source.sourceVersion,
|
||||
bankName: source.bankName, bankAccountMasked: source.problems.some((p) => p.startsWith("account_"))
|
||||
? null : maskBankAccount(source.bankAccount), problems: source.problems,
|
||||
linkState: conflict ? "conflict" : !existing ? "new" : matchesSource(config, existing, source) ? "reusable" : "refresh_required",
|
||||
existingLender: existing ? mapLender(config, existing, { inFlight: inFlight.has(existing.id) }) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function linkEmployeeLender(prisma, config, request, input) {
|
||||
// A link may appear while creation waits on the employee lock. Finish that
|
||||
// transaction, then reacquire in lender -> employee order; never invert it.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const before = await linkedLenders(prisma, input.employeeId, input.legalEntityId);
|
||||
if (before.length > 1 || before[0]?.archived_at) throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const lender = before[0] ? await lockLender(tx, before[0].id) : null;
|
||||
// Also serialize this actor's request keys across different employees.
|
||||
await lockEmployeeIdentities(tx, [input.employeeId, request.employee.id]);
|
||||
const source = await readEmployeeSource(tx, config, input.employeeId, input.legalEntityId);
|
||||
assertUsableSource(source, input.expectedSourceVersion);
|
||||
const links = await linkedLenders(tx, input.employeeId, input.legalEntityId);
|
||||
if (links.length > 1 || links[0]?.archived_at || lender && links[0]?.id !== lender.id) {
|
||||
throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
|
||||
}
|
||||
if (!lender && links.length) return null;
|
||||
const requestBinding = { employeeId: input.employeeId, legalEntityId: input.legalEntityId,
|
||||
sourceVersion: input.expectedSourceVersion };
|
||||
const previousRequest = await tx.auditLog.findFirst({ where: { actor_id: request.employee.id,
|
||||
action: { in: ["LOAN_LENDER_EMPLOYEE_CREATED", "LOAN_LENDER_EMPLOYEE_REUSED"] },
|
||||
extra: { path: ["employeeSourceRequestKey"], equals: input.requestKey },
|
||||
}, select: { target_id: true, extra: true } });
|
||||
if (previousRequest && (previousRequest.target_id !== lender?.id ||
|
||||
Object.entries(requestBinding).some(([key, value]) => previousRequest.extra[key] !== value))) {
|
||||
throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
|
||||
}
|
||||
const keyed = await tx.loanLender.findUnique({ where: {
|
||||
created_by_request_key: { created_by: request.employee.id, request_key: input.requestKey },
|
||||
} });
|
||||
if (keyed && keyed.id !== lender?.id) throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
|
||||
if (lender) {
|
||||
if (!matchesSource(config, lender, source)) throw buildBusinessValidationError("loan.employee_lender_refresh_required", {}, 409);
|
||||
if (!previousRequest) await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_REUSED", lender.id, {
|
||||
...requestBinding, employeeSourceRequestKey: input.requestKey,
|
||||
});
|
||||
return { lender, reused: true };
|
||||
}
|
||||
const created = await tx.loanLender.create({ data: {
|
||||
legal_entity_id: input.legalEntityId, employee_id: input.employeeId, name: source.name,
|
||||
bank_institution_id: source.bankInstitutionId, bank_name: source.bankName,
|
||||
...encryptLenderAccount(config, { bankAccount: source.bankAccount, accountName: source.name }),
|
||||
identity_status: "verified", identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(),
|
||||
identity_confirmation: "财务已核对所选员工、借款所属公司及员工收款资料",
|
||||
created_by: request.employee.id, updated_by: request.employee.id, request_key: input.requestKey,
|
||||
}, include: LENDER_INCLUDE });
|
||||
await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_CREATED", created.id, {
|
||||
...requestBinding, employeeSourceRequestKey: input.requestKey,
|
||||
});
|
||||
return { lender: created, reused: false };
|
||||
});
|
||||
if (result) return result;
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
|
||||
}
|
||||
|
||||
async function refreshEmployeeLender(tx, config, request, id, input) {
|
||||
const lender = await lockLender(tx, id);
|
||||
assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion);
|
||||
assertCurrentVersion(lender.account_version, input.expectedAccountVersion);
|
||||
await assertLenderNotInFlight(tx, id);
|
||||
if (!lender.employee_id || lender.archived_at) throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
|
||||
await lockEmployeeIdentities(tx, [lender.employee_id]);
|
||||
await assertUniqueEmployeeLender(tx, lender.employee_id, lender.legal_entity_id, lender.id);
|
||||
const source = await readEmployeeSource(tx, config, lender.employee_id, lender.legal_entity_id);
|
||||
assertUsableSource(source, input.expectedSourceVersion);
|
||||
const result = await tx.loanLender.update({ where: { id }, data: {
|
||||
name: source.name, bank_institution_id: source.bankInstitutionId, bank_name: source.bankName,
|
||||
...encryptLenderAccount(config, { bankAccount: source.bankAccount, accountName: source.name }),
|
||||
identity_version: { increment: 1 }, account_version: { increment: 1 }, identity_status: "verified",
|
||||
identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(), identity_confirmation: input.reason,
|
||||
updated_by: request.employee.id,
|
||||
}, include: LENDER_INCLUDE });
|
||||
await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_REFRESHED", id, {
|
||||
employeeId: lender.employee_id, sourceVersion: source.sourceVersion,
|
||||
identityVersion: result.identity_version, accountVersion: result.account_version, reason: input.reason,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureEmployeeBankAccess, lockEmployeeIdentities, assertUniqueEmployeeLender,
|
||||
previewEmployeeLender, linkEmployeeLender, refreshEmployeeLender,
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loan.employee_source_incomplete": "员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。",
|
||||
"loan.employee_source_changed": "员工资料已变化,请重新加载并核对后再保存。",
|
||||
"loan.employee_lender_conflict": "该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。",
|
||||
"loan.employee_lender_refresh_required": "已有出借人资料与当前员工资料不同,请核对后使用“使用当前员工资料”更新。",
|
||||
"loan.employee_account_managed": "员工出借人的收款资料来自员工档案,请先维护员工资料,再使用“使用当前员工资料”更新。",
|
||||
"loan.related_authorization_required": "本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。",
|
||||
"loan.related_scope_invalid": "关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。",
|
||||
"loan.related_actor_conflict": "关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。",
|
||||
|
||||
在新工单中引用
屏蔽一个用户