借款出借人增加员工与公司组合数据库唯一约束(09-17-company-loan-principal-repayment)
- 新迁移 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]>
这个提交包含在:
@@ -136,13 +136,19 @@ module.exports = async function employeeFixture(h) {
|
||||
await call(finance, 'POST', `/loan-lenders/${other.id}/identity-confirmation`, { expectedVersion: other.identityVersion,
|
||||
employeeId: staff.id, externalPersonConfirmed: false, reason: '合成旧入口重复关联拒绝' }, 409, 'loan.employee_lender_conflict');
|
||||
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: other.id } })).employee_id, null);
|
||||
// Model an old ambiguous dataset directly; this is not an application path.
|
||||
await prisma.loanLender.update({ where: { id: other.id }, data: { employee_id: staff.id } });
|
||||
assert.equal((await source(staff)).linkState, 'conflict');
|
||||
await link(input(staff, await source(staff)), 409, 'loan.employee_lender_conflict');
|
||||
await refresh(lender.id, refreshInput({ ...preview, existingLender: lender }), 409, 'loan.employee_lender_conflict');
|
||||
await prisma.loanLender.update({ where: { id: other.id }, data: { employee_id: null } });
|
||||
// An ambiguous dataset can no longer be written at all: the database rejects
|
||||
// a second lender for the same employee and company, even outside the app.
|
||||
await assert.rejects(prisma.loanLender.update({ where: { id: other.id }, data: { employee_id: staff.id } }),
|
||||
(error) => error.code === 'P2002' && req('./src/services/loanLenderEmployee').isEmployeeLenderUniqueViolation(error));
|
||||
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: other.id } })).employee_id, null);
|
||||
assert.equal((await source(staff)).linkState, 'reusable');
|
||||
// NULL employee_id never conflicts: several manual lenders coexist in one company.
|
||||
const manualTwin = await manualFor(staff);
|
||||
assert.equal(await prisma.loanLender.count({ where: { legal_entity_id: company.id, employee_id: null, id: { in: [other.id, manualTwin.id] } } }), 2);
|
||||
// An archived link still occupies the pair, in the app and in the database.
|
||||
await prisma.loanLender.update({ where: { id: freshLender.id }, data: { archived_at: new Date() } });
|
||||
await assert.rejects(prisma.loanLender.update({ where: { id: manualTwin.id }, data: { employee_id: stale.id } }),
|
||||
(error) => error.code === 'P2002');
|
||||
assert.equal((await source(stale)).linkState, 'conflict');
|
||||
await link(input(stale, await source(stale)), 409, 'loan.employee_lender_conflict');
|
||||
checks.push('legacy_identity_entry_and_ambiguous_or_archived_links_cannot_duplicate');
|
||||
|
||||
@@ -16,7 +16,10 @@ module.exports = async function employeeMutations(h, runFixture) {
|
||||
// Restores the pre-fix fingerprint (updated_at selected and hashed).
|
||||
['source_version_updated_at', [['status: true, archived_at: true,\n', 'status: true, archived_at: true, updated_at: true,\n'],
|
||||
['"loan-employee-source-v2", legalEntityId, employee.id,', '"loan-employee-source-v2", legalEntityId, employee.id, employee.updated_at,']]],
|
||||
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)'],
|
||||
// The code check is proven with the database constraint removed; with it in
|
||||
// place the same mutant must be fully absorbed, reporting the same conflict.
|
||||
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)', { withoutConstraint: true }],
|
||||
['legacy_duplicate_db_backstop', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)', { expectAbsorbed: true }],
|
||||
['employee_lock', 'if (unique.length) await tx.$queryRaw', 'if (false) await tx.$queryRaw'],
|
||||
['employee_lock_fk_compatibility', 'ORDER BY id FOR NO KEY UPDATE', 'ORDER BY id FOR UPDATE'],
|
||||
['in_flight', 'await assertLenderNotInFlight(tx, id);', ''],
|
||||
@@ -25,7 +28,8 @@ module.exports = async function employeeMutations(h, runFixture) {
|
||||
['required_audit', 'await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_CREATED", created.id, {\n ...requestBinding, employeeSourceRequestKey: input.requestKey,\n });', ''],
|
||||
];
|
||||
const results = [];
|
||||
for (const [name, from, to] of mutations) {
|
||||
const INDEX = 'loan_lenders_legal_entity_id_employee_id_key';
|
||||
for (const [name, from, to, options = {}] of mutations) {
|
||||
const edits = Array.isArray(from) ? from : [[from, to]];
|
||||
let mutated = source;
|
||||
for (const [target, replacement] of edits) {
|
||||
@@ -37,8 +41,24 @@ module.exports = async function employeeMutations(h, runFixture) {
|
||||
mutant._compile(mutated, filename);
|
||||
let failure;
|
||||
Object.assign(live, mutant.exports);
|
||||
if (options.withoutConstraint) await h.prisma.$executeRawUnsafe(`DROP INDEX "${INDEX}"`);
|
||||
try { await runFixture(h); } catch (error) { failure = error; }
|
||||
finally { Object.assign(live, original); }
|
||||
finally {
|
||||
Object.assign(live, original);
|
||||
if (options.withoutConstraint) {
|
||||
// Detach the duplicates this mutant let through, then restore the index.
|
||||
await h.prisma.$executeRawUnsafe(`UPDATE loan_lenders SET employee_id = NULL WHERE id IN (SELECT id FROM (
|
||||
SELECT id, row_number() OVER (PARTITION BY legal_entity_id, employee_id ORDER BY created_at, id) AS n
|
||||
FROM loan_lenders WHERE employee_id IS NOT NULL) ranked WHERE n > 1)`);
|
||||
await h.prisma.$executeRawUnsafe(`CREATE UNIQUE INDEX "${INDEX}" ON loan_lenders (legal_entity_id, employee_id)`);
|
||||
}
|
||||
}
|
||||
if (options.expectAbsorbed) {
|
||||
assert.ok(!failure, `mutation ${name} was not absorbed by the database constraint: ${failure?.message?.split('\n')[0]}`);
|
||||
results.push({ name, absorbedByDatabase: true, witness: 'database_constraint_backstop' });
|
||||
console.log(JSON.stringify({ stage: 'loan_employee_mutation', ...results.at(-1) }));
|
||||
continue;
|
||||
}
|
||||
assert.ok(failure, `mutation ${name} survived`);
|
||||
// A generic environment, SQL or parsing failure is not a killed guard.
|
||||
const wrongAcceptance = failure.code === 'ERR_ASSERTION' && failure.actual === 200 && [403, 409, 500].includes(failure.expected);
|
||||
|
||||
在新工单中引用
屏蔽一个用户