借款出借人增加员工与公司组合数据库唯一约束(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]>
这个提交包含在:
@@ -1,3 +1,19 @@
|
||||
## 2026-10-11 F:员工与公司组合唯一约束——实施(用户已批准;未提交)
|
||||
|
||||
- 迁移 `20261011010000_loan_lender_employee_unique`(迁移总数 83→84):`CREATE UNIQUE INDEX "loan_lenders_legal_entity_id_employee_id_key" ON loan_lenders(legal_entity_id, employee_id)`;Prisma `@@unique([legal_entity_id, employee_id])`。普通唯一约束,归档行计入、空 `employee_id` 互不冲突,与现行代码规则一致;不改数据、不回填。
|
||||
- 冲突提示:新增 `isEmployeeLenderUniqueViolation`(同时识别 `meta.target` 与驱动适配器的约束字段/名称)。从员工创建路径先判它再判请求键重复;旧“确认出借人身份”入口在事务外映射。触到约束时返回原有 409 `loan.employee_lender_conflict`,不报 500,也不误报为 `loan.duplicate_registration`。原员工行锁与代码检查保持不变。
|
||||
- 测试:原“直接写入模糊旧数据”的夹具改为断言数据库拒绝(P2002,且被新判定识别);新增两条无员工出借人可共存、归档关联继续占用组合(应用与数据库两层)。变异 11→12:`legacy_duplicate` 在临时删除索引的隔离库中运行,证明代码检查仍有效,运行后解除重复关联再重建索引;新增 `legacy_duplicate_db_backstop` 在索引存在时运行同一变异,整套夹具通过,证明数据库单独兜底也返回同一冲突提示。
|
||||
- 发布要求:部署前在同一发布窗口重跑下节只读计数确认 0 组,部署按 1 个待执行迁移校验。
|
||||
- 验证:员工专项 + 变异 12/12(`employee-only-mutations-r1.log`);完整回归 `full-regression-r1/r2.log` 各退出 0、84 迁移、12 阶段、去重 247 项(新增断言并入原检查项名,项数不变),自有资源清理 PASS;`loanLenderAcknowledgement` 单测 28/28;Prisma validate、前端构建通过。首次后台回归因相对日志路径在并行步骤切换目录后重定向失败、未实际运行,非测试失败。只读复核回执 `review-20261011-codex.json`:无阻断项,20 个内存探针通过;P2 迁移回退说明已补入 PRD F 节。证据在 `artifacts/qa-flow/loan-lender-employee-unique-20261011/`。
|
||||
|
||||
## 2026-10-11 F:员工与公司组合唯一约束——存量排查(只读)
|
||||
|
||||
用户授权本轮生产只读查询。经生产主机按部署项目标签定位唯一数据库容器,在 `BEGIN TRANSACTION READ ONLY … ROLLBACK` 内只返回计数,不输出 ID、姓名或账号,未写库:出借人共 1 条,关联员工 1 条,归档 0 条;同一公司与员工的重复组合 **0** 组。
|
||||
|
||||
代码现状:从员工创建、关联已有出借人、从员工资料刷新三条写入路径均先锁员工行,再用 `assertUniqueEmployeeLender` 拒绝同公司同员工的另一条出借人(`loan.employee_lender_conflict`);查询有意包含已归档出借人,即现行规则为“同一公司与员工最多一条,归档也计入”。代码中没有归档出借人的路径;员工停用/归档后原关联保留并继续占用。手工创建的无员工出借人 `employee_id` 为空,不要求唯一。
|
||||
|
||||
约束建议(待批准后另行实施):普通唯一约束 `(legal_entity_id, employee_id)`。PostgreSQL 唯一约束中空值互不冲突,无员工出借人不受影响;归档行计入,与现行代码一致,不采用只约束未归档行的部分索引(会放宽规则)。实施需新迁移,部署前须在同一发布窗口重跑本只读计数,确认仍为 0 组。
|
||||
|
||||
## 2026-10-10 E:追回证据刷新提示与员工资料误报(已实施、本地验证完成;未提交)
|
||||
|
||||
用户批准两项方案后实施,基线 `91fe31f0`;未提交、推送、部署或连接生产,无新迁移。资料拦截规则先由实施会话自查;2026-10-11 另一会话独立只读复核结论 `REVIEWED_NO_BLOCKER`,无阻断项,补跑 12 组服务层内存探针与电脑/手机 4 组组件浏览器检查,两轮 247 项回归与 11 项变异只核对日志未重跑;回执 `review-20261011.json` 中 7 个文件摘要与提交前工作区一致。复核限制:组件补验的 API、身份为合成替身;上线前打开的旧 v1 预览在上线后保存会被要求重新核对(有意拒绝旧版本);未跑完整发布门禁。
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 公司借款台账与本金归还
|
||||
|
||||
## 2026-10-11 F:员工与公司组合数据库唯一约束(已实施、本地验证与只读复核完成;未提交)
|
||||
|
||||
生产只读排查重复组合为 0 组后,用户批准把“同一公司与员工最多一个出借人(归档计入)”落到数据库。结果见 implement 顶部 F 节。
|
||||
|
||||
- [x] AC56:数据库拒绝同一公司与员工的第二个出借人,含归档行;空员工的手工出借人可多条共存。
|
||||
- [x] AC57:应用触到约束时返回原有“出借人冲突”提示,不报 500、不误报请求重复;代码检查与员工行锁保持。
|
||||
- [x] AC58:借款完整回归通过,并经独立只读复核(涉及迁移)。两轮完整回归各 84 迁移、12 阶段、去重 247 项通过;另一会话只读复核无阻断项、20 个内存探针通过,唯一 P2(缺本迁移回退说明)已在下方补齐。
|
||||
- [ ] AC59:发布窗口重跑存量重复计数为 0,并按 1 个待执行迁移部署。
|
||||
|
||||
### 迁移回退说明(`20261011010000_loan_lender_employee_unique`)
|
||||
|
||||
- **建索引失败**:迁移只有一条 `CREATE UNIQUE INDEX`,单语句原子执行;若发布窗口内出现重复组合导致失败,索引不会部分建立,Prisma 将该迁移记为失败并阻止后续迁移,应用不切换到新版本。处理方式是停止本次发布,回到只读计数重新排查;不在发布中手工删改出借人数据,不改写已应用的迁移历史。失败迁移记录的处理(如标记回滚后重试)须另行批准后按运维手册执行。
|
||||
- **应用回退**:回退到上一版应用时**保留**该索引,不删除。上一版代码在员工行锁内已拒绝同一组合,正常路径不会触到约束;只有极端情况越过旧检查时,旧版创建路径会把约束冲突报为“请求重复”(409),旧身份确认入口会报 500,两者都不会写入重复数据。
|
||||
- **撤销约束**:如确需取消该约束,另起新迁移 `DROP INDEX` 并单独批准,不回退或删除本迁移文件。
|
||||
|
||||
|
||||
## 2026-10-10 E:追回证据刷新提示与员工资料误报(已实施、本地验证及独立只读复核完成;未提交)
|
||||
|
||||
用户批准两项小问题按方案修复,复用本任务,不含数据库唯一约束(需先查存量重复、单独规划)。结果见 implement 顶部 E 节。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户