借款出借人改为从现有员工选择并复用资料(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号
- 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE
- 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效
- 电脑/手机共用对话框,写请求在卸载或身份切换时取消
- 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-09 21:03:07 +08:00
共同撰写人 Claude Opus 5.5
父节点 c1034bbe38
当前提交 45737ccb9a
共修改 20 个文件,包含 1157 行新增和 28 行删除
+46 -13
查看文件
@@ -19,9 +19,18 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
paymentBatchEnabled: true,
};
app.decorate('config', config); app.decorate('prisma', prisma);
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({
code: error.code ?? 'SYS_001', details: error.details ?? {},
}));
const { AppError } = req('./src/utils/AppError');
app.setErrorHandler((error, request, reply) => {
const known = error instanceof AppError;
const tooLarge = error.code === 'FST_REQ_FILE_TOO_LARGE';
// Match the production envelope, so browser refusals exercise real reason
// projection instead of silently falling back to a generic server failure.
return reply.code(tooLarge ? 400 : known ? error.statusCode : 500).send({ requestId: request.id, error: {
code: tooLarge ? 'VALID_001' : known ? error.code : 'SYS_001',
message: tooLarge ? '参数校验失败' : known ? error.message : '服务器内部错误',
details: tooLarge ? { file: 'file_too_large' } : known ? error.details ?? {} : {},
} });
});
await app.register(req('@fastify/cookie'));
await app.register(req('@fastify/multipart'));
await app.register(req('./src/plugins/auth'));
@@ -64,8 +73,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
const ordinary = await actor(['employee']);
async function call(who, method, url, payload, status = 200, reason) {
const response = await app.inject({ method, url: `/api${url}`, headers: who?.headers ?? {}, payload });
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().details?.reasonCode ?? ''}`);
if (reason) assert.equal(response.json().details?.reasonCode, reason);
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().error?.details?.reasonCode ?? ''}`);
if (reason) assert.equal(response.json().error?.details?.reasonCode, reason);
return response.json().data;
}
for (const who of [manager, system, admin, ordinary]) {
@@ -216,7 +225,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
return result;
}
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(exportResponse.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().details)}`);
assert.equal(exportResponse.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().error?.details)}`);
assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe');
@@ -367,7 +376,16 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
// Fresh lenders below: the first lender keeps unknown-state reservations from
// the negative receipt loop above, which by design block its maintenance.
async function verifiedLender(name, employeeId, digit, account = digit.repeat(16)) {
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name,
// Each independent scenario needs its own company when the same synthetic
// employee already has a lender there. Do not seed a forbidden duplicate.
let legalEntityId = company.id;
if (employeeId && await prisma.loanLender.count({ where: { employee_id: employeeId, legal_entity_id: company.id } })) {
const isolatedCompany = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: `合成场景公司-${randomUUID().slice(0, 8)}` } });
legalEntityId = isolatedCompany.id;
await prisma.paymentAccount.create({ data: { legal_entity_id: legalEntityId, bank_institution_id: bank.id,
account_name: '合成付款公司', account_number_encrypted: encryptStringOrThrow(config, payerAccount) } });
}
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId, name,
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: account, accountName: name });
const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, {
expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' });
@@ -384,8 +402,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
requestKey: randomUUID(), lenderId, reason: '合成还本', allocations: [{ loanId, amount }] })).item;
async function exportOrder(batchId, status = 200, reason) {
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(response.statusCode, status, `export ${response.statusCode} ${response.headers['content-type']?.includes('json') ? response.json().details?.reasonCode ?? '' : '<binary omitted>'}`);
if (reason) assert.equal(response.json().details?.reasonCode, reason);
assert.equal(response.statusCode, status, `export ${response.statusCode} ${response.headers['content-type']?.includes('json') ? response.json().error?.details?.reasonCode ?? '' : '<binary omitted>'}`);
if (reason) assert.equal(response.json().error?.details?.reasonCode, reason);
}
const lenderState = async (id) => (await call(finance, 'GET', '/loan-lenders')).items.find((item) => item.id === id);
const independent = await actor(['ceo']);
@@ -396,6 +414,15 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-related-browser.cjs')({ app, finance, manager, ordinary, password, prisma, config, relatedParty });
return;
}
const employeeHarness = { prisma, pool, call, app, req, finance, manager, ordinary,
config, password, actor, company, bank, confirmedLoan, repay, exportOrder, receipt };
const employeeSources = await require('./company-loan-employee-fixture.cjs')(employeeHarness);
if (process.env.RUN_ISOLATED_LOAN_EMPLOYEE_MUTATIONS === '1') await require('./company-loan-employee-mutations.cjs')(
employeeHarness, require('./company-loan-employee-fixture.cjs'));
if (process.env.RUN_ISOLATED_LOAN_EMPLOYEE_ONLY === '1') {
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-employee-browser.cjs')({ app, finance, password, prisma, config, employeeSources });
return;
}
// Lender linked to a manager: that manager cannot approve, an independent CEO can.
@@ -433,6 +460,12 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
checks.push('reserved_allocation_blocks_lender_maintenance_before_and_after_export');
// Account change after release revokes identity and blocks new requests until re-verified.
await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.employee_account_managed');
// The hand-entered account path now belongs only to a verified external
// person. Employee refresh is covered separately with real source data.
await call(finance, 'POST', `/loan-lenders/${linked.id}/identity-confirmation`, {
expectedVersion: (await lenderState(linked.id)).identityVersion, employeeId: null,
externalPersonConfirmed: true, reason: '合成旧手工账户路径隔离为非员工测试' });
const changedAccount = await accountChange((await lenderState(linked.id)).accountVersion);
assert.equal(changedAccount.identityStatus, 'unverified');
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: linked.id, reason: '合成未核实',
@@ -471,7 +504,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
if (first === 'create') {
assert.equal(created.statusCode, 200);
assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().details?.reasonCode, 'loan.lender_in_flight');
assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().error?.details?.reasonCode, 'loan.lender_in_flight');
} else {
assert.equal(maintained.statusCode, 200, 'maintenance first succeeds');
assert.equal(created.statusCode, 200, 'creation after maintenance uses the new versions');
@@ -699,7 +732,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
assert.equal(uploaded.statusCode, 200, `${order} competitor upload`);
if (order === 'settle_first') assert.equal(executed.statusCode, 200, 'settlement checked before the competitor existed');
else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().details?.reasonCode, 'loan.receipt_attribution_required'); }
else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().error?.details?.reasonCode, 'loan.receipt_attribution_required'); }
// Keep the shared OCR queue clean for later scenarios.
await call(finance, 'POST', `/payment-batches/${competitor.id}/receipt-attachments/${uploaded.json().data.items[0].id}/void`, { reason: '合成并发验证结束' });
}
@@ -1181,7 +1214,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
const dupEvidence = await app.inject({ method: 'POST', url: `/api/payment-batches/${wdB.id}/loan-terminal-evidences`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${dupBoundary}` },
payload: Buffer.concat([Buffer.from(`--${dupBoundary}\r\nContent-Disposition: form-data; name="file"; filename="bank-letter.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), sharedLetter, Buffer.from(`\r\n--${dupBoundary}--\r\n`)]) });
assert.equal(dupEvidence.statusCode, 409); assert.equal(dupEvidence.json().details?.reasonCode, 'loan.terminal_evidence_invalid');
assert.equal(dupEvidence.statusCode, 409); assert.equal(dupEvidence.json().error?.details?.reasonCode, 'loan.terminal_evidence_invalid');
checks.push('s3_withdrawn_ref_and_bank_letter_cannot_move_to_another_batch');
// Evidence separation and history.
@@ -1356,7 +1389,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
reconciliationUi.late.push({ id: original.id, lenderId: lateLender.id, reference: `UI-LATE-${mode}`,
payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted), payee: lateLender.bankAccount });
}
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty });
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty, employeeSources });
}
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
} finally { await app.close(); await prisma.$disconnect(); }