借款出借人增加员工与公司组合数据库唯一约束(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新迁移 20261011010000_loan_lender_employee_unique:(legal_entity_id, employee_id) 唯一,归档计入、无员工出借人不冲突;生产只读排查重复 0 组
- 触到约束时返回原有 loan.employee_lender_conflict,不报 500 或误报请求重复;员工行锁与代码检查保留
- 测试改为断言数据库拒绝模糊旧数据,补空员工共存与归档占用;变异 12 项含去索引与数据库兜底两种运行
- PRD 补本迁移回退说明;独立只读复核无阻断,发布窗口需重跑重复计数(AC59)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-11 13:21:08 +08:00
共同撰写人 Claude Opus 5.5
父节点 5469db585c
当前提交 36b949ecf6
共修改 8 个文件,包含 91 行新增和 10 行删除
@@ -0,0 +1,5 @@
-- One lender per employee and company, archived rows included. Application
-- writes already enforce this under the employee row lock; existing data was
-- checked read-only for duplicates before this migration. NULL employee_id
-- (manual lenders) never conflicts.
CREATE UNIQUE INDEX "loan_lenders_legal_entity_id_employee_id_key" ON "loan_lenders"("legal_entity_id", "employee_id");
+3
查看文件
@@ -4020,6 +4020,9 @@ model LoanLender {
@@unique([legal_entity_id, id])
@@unique([created_by, request_key])
// One lender per employee and company, archived rows included; NULL
// employee_id (manual lenders) never conflicts.
@@unique([legal_entity_id, employee_id])
@@index([employee_id])
@@map("loan_lenders")
}
+3
查看文件
@@ -297,6 +297,9 @@ async function companyLoansRoutes(fastify) {
identityVersion: result.identity_version, employeeId: body.employeeId, externalPersonConfirmed: body.externalPersonConfirmed,
});
return result;
}).catch((error) => {
if (employeeLenders.isEmployeeLenderUniqueViolation(error)) throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
throw error;
});
return sendData(reply, request, mapLender(fastify.config, item));
});
+13 -1
查看文件
@@ -32,6 +32,17 @@ async function linkedLenders(db, employeeId, legalEntityId) {
include: LENDER_INCLUDE, orderBy: { id: "asc" }, take: 2 });
}
// The database constraint backs the locked check below; a violation reports
// the same conflict instead of a generic duplicate or a 500.
const EMPLOYEE_LENDER_UNIQUE = "loan_lenders_legal_entity_id_employee_id_key";
function isEmployeeLenderUniqueViolation(error) {
if (error?.code !== "P2002") return false;
const meta = error.meta ?? {};
const fields = [meta.target, meta.driverAdapterError?.cause?.constraint?.fields].flat().filter(Boolean).map(String);
return fields.includes("employee_id") || fields.includes(EMPLOYEE_LENDER_UNIQUE) ||
String(meta.driverAdapterError?.cause?.originalMessage ?? "").includes(EMPLOYEE_LENDER_UNIQUE);
}
async function assertUniqueEmployeeLender(tx, employeeId, legalEntityId, lenderId) {
if (!employeeId) return;
const links = await linkedLenders(tx, employeeId, legalEntityId);
@@ -167,6 +178,7 @@ async function linkEmployeeLender(prisma, config, request, input) {
});
if (result) return result;
} catch (error) {
if (isEmployeeLenderUniqueViolation(error)) throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
if (error?.code === "P2002") throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
throw error;
}
@@ -199,6 +211,6 @@ async function refreshEmployeeLender(tx, config, request, id, input) {
}
module.exports = {
ensureEmployeeBankAccess, lockEmployeeIdentities, assertUniqueEmployeeLender,
ensureEmployeeBankAccess, lockEmployeeIdentities, assertUniqueEmployeeLender, isEmployeeLenderUniqueViolation,
previewEmployeeLender, linkEmployeeLender, refreshEmployeeLender,
};