借款出借人改为从现有员工选择并复用资料(09-17-company-loan-principal-repayment)
- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号 - 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE - 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效 - 电脑/手机共用对话框,写请求在卸载或身份切换时取消 - 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -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(); }
|
||||
|
||||
在新工单中引用
屏蔽一个用户