diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md index 59aae2ba..19040c85 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -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 预览在上线后保存会被要求重新核对(有意拒绝旧版本);未跑完整发布门禁。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md index 4f87be80..b12fdb2c 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -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 节。 diff --git a/scripts/qa/company-loan-employee-fixture.cjs b/scripts/qa/company-loan-employee-fixture.cjs index b8940457..cf9bdada 100644 --- a/scripts/qa/company-loan-employee-fixture.cjs +++ b/scripts/qa/company-loan-employee-fixture.cjs @@ -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'); diff --git a/scripts/qa/company-loan-employee-mutations.cjs b/scripts/qa/company-loan-employee-mutations.cjs index 82318cab..172d433b 100644 --- a/scripts/qa/company-loan-employee-mutations.cjs +++ b/scripts/qa/company-loan-employee-mutations.cjs @@ -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); diff --git a/server/prisma/migrations/20261011010000_loan_lender_employee_unique/migration.sql b/server/prisma/migrations/20261011010000_loan_lender_employee_unique/migration.sql new file mode 100644 index 00000000..0fcaafbd --- /dev/null +++ b/server/prisma/migrations/20261011010000_loan_lender_employee_unique/migration.sql @@ -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"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 6f3e17c8..f030595b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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") } diff --git a/server/src/routes/companyLoans.js b/server/src/routes/companyLoans.js index 4b889cfc..1a9b9000 100644 --- a/server/src/routes/companyLoans.js +++ b/server/src/routes/companyLoans.js @@ -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)); }); diff --git a/server/src/services/loanLenderEmployee.js b/server/src/services/loanLenderEmployee.js index 7270616a..874c4210 100644 --- a/server/src/services/loanLenderEmployee.js +++ b/server/src/services/loanLenderEmployee.js @@ -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, };