文件
hyb-oa/scripts/qa/company-loan-not-submitted-fixture.cjs
wangsli 431c36eae3 借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)
新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。

验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。

真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
2026-10-07 21:58:03 +08:00

141 行
11 KiB
JavaScript

const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
module.exports = async function notSubmittedFixture(h) {
const { prisma, pool, req, call, app, finance, manager, independent, config,
verifiedLender, confirmedLoan, repay, exportOrder, register, attest, receipt } = h;
const { PDFDocument } = req("pdf-lib");
const checks = [];
const lender = await verifiedLender('合成人工未提交出借人', null, '3');
const loan = await confirmedLoan(lender.id, '5000.00', '合成人工未提交借入');
let amount = 400;
const url = (s) => `/payment-batches/${s.batch.id}/loan-not-submitted`;
async function upload(s, kind) {
const doc = await PDFDocument.create(); doc.setTitle(`synthetic-${randomUUID()}`); doc.addPage([200, 200]);
const bytes = Buffer.from(await doc.save()); const boundary = `synthetic-${randomUUID()}`;
const res = await app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-terminal-evidences?kind=${kind}`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="synthetic.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), bytes, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
assert.equal(res.statusCode, 200, `synthetic upload ${res.body.slice(0, 100)}`);
return res.json().data.id;
}
async function setup() {
const cents = ++amount;
const batch = await repay(lender.id, loan.id, (cents / 100).toFixed(2));
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id); await attest(batch.id);
const exports = await prisma.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id } });
const s = { batch, cents, exports };
s.input = { exportIds: exports.map((e) => e.id), queryEvidenceId: await upload(s, 'query_no_result'),
statementEvidenceId: await upload(s, 'account_statement'),
exporterStatements: [{ exporterId: exports[0].exported_by, evidenceId: await upload(s, 'exporter_statement') }],
coverageFrom: new Date(Date.now() - 86400000).toISOString(), coverageThrough: new Date().toISOString(), note: '合成逐份核对文件去向及无提交无支出' };
return s;
}
const propose = (s, input = s.input, status = 200, reason) => call(finance, 'POST', url(s), input, status, reason);
const review = (s, p, who = manager, decision = 'approve', status = 200, reason) =>
call(who, 'POST', `${url(s)}/${p.id}/review`, { decision, reason: '合成独立核对全部证据与提交清单' }, status, reason);
const cancel = (s, status = 200, reason) => call(finance, 'POST', `/payment-batches/${s.batch.id}/cancel`, { reason: '合成人工未提交复核后取消' }, status, reason);
async function ready() { const s = await setup(); const p = await propose(s); await review(s, p); await review(s, p, independent); return { s, p }; }
const s = await setup(); const before = await call(finance, 'GET', `/company-loans/${loan.id}`);
const p = await propose(s);
await review(s, p, finance, 'approve', 403);
await review(s, p);
await review(s, p, manager, 'approve', 409, 'loan.not_submitted_invalid');
await cancel(s, 409, 'loan.bank_state_unsafe');
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.batch.loanRepaymentId } })).bank_result_state, 'unknown');
await review(s, p, independent);
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.batch.loanRepaymentId } })).bank_result_state, 'confirmed_not_submitted');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).reserved, before.reserved);
await cancel(s);
const after = await call(finance, 'GET', `/company-loans/${loan.id}`);
assert.equal(after.outstanding, before.outstanding); assert.equal(Number(after.reserved), Number((Number(before.reserved) - s.cents / 100).toFixed(2)));
await call(finance, 'POST', `/payment-batches/${s.batch.id}/payment-order`, {}, 409);
await call(finance, 'POST', `/payment-batches/${s.batch.id}/execute`, {}, 409);
checks.push('s4_three_evidence_categories_two_distinct_reviews_then_release_no_principal_change');
const invalid = await setup();
await propose(invalid, { ...invalid.input, queryEvidenceId: invalid.input.statementEvidenceId }, 409, 'loan.not_submitted_invalid');
const wrongCategory = await upload(invalid, 'bank_terminal');
await propose(invalid, { ...invalid.input, queryEvidenceId: wrongCategory }, 409, 'loan.not_submitted_invalid');
await propose(invalid, { ...invalid.input, exporterStatements: [] }, 400);
await propose(invalid, { ...invalid.input, exportIds: [randomUUID()] }, 409, 'loan.not_submitted_invalid');
await propose(invalid, { ...invalid.input, coverageThrough: new Date(Date.now() - 86400000 * 2).toISOString() }, 409, 'loan.not_submitted_invalid');
const oldWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 3;
try { await propose(invalid, undefined, 409, 'loan.release_wait_period'); } finally { config.loanReleaseWaitDays = oldWait; }
const ip = await propose(invalid); await review(invalid, ip, manager, 'reject'); await cancel(invalid, 409);
const again = await propose(invalid); await review(invalid, again);
await exportOrder(invalid.batch.id);
await review(invalid, again, independent, 'approve', 409, 'loan.not_submitted_invalid');
checks.push('s4_missing_wrong_category_coverage_wait_and_changed_export_fail_closed');
const stale = await ready();
await call(finance, 'POST', `/payment-batches/${stale.s.batch.id}/loan-terminal-evidences/${stale.s.input.queryEvidenceId}/void`, { reason: '合成旧材料作废' });
await cancel(stale.s, 409, 'loan.not_submitted_invalid');
stale.s.input.queryEvidenceId = await upload(stale.s, 'query_no_result');
const replacement = await propose(stale.s); await review(stale.s, replacement); await review(stale.s, replacement, independent); await cancel(stale.s);
checks.push('s4_voided_evidence_invalidates_both_reviews_fresh_round_restores_release');
const late = await ready();
await receipt(late.s.batch.id, String(late.s.cents), `S4-LATE-${randomUUID()}`, { payeeAccount: lender.bankAccount });
await cancel(late.s, 409, 'loan.not_submitted_invalid');
const sub = await ready(); await register(sub.s.batch.id, `S4-SUB-${randomUUID()}`, sub.s.exports[0].id);
await cancel(sub.s, 409, 'loan.not_submitted_invalid');
checks.push('s4_late_receipt_and_new_submission_block_final_release');
// Existing bank-side facts cannot become 'never submitted' by withdrawal.
const wd = await setup(); const submission = await register(wd.batch.id, `S4-WITHDRAW-${randomUUID()}`, wd.exports[0].id);
await call(finance, 'POST', `/payment-batches/${wd.batch.id}/loan-bank-submissions/${submission.id}/withdraw`, { reason: '合成撤回登记' });
await propose(wd, undefined, 409, 'loan.not_submitted_invalid');
checks.push('s4_withdrawn_submission_not_erased_by_manual_exception');
const { Pool } = req('pg'); const lockPool = new Pool({ ...pool.options, max: 3 });
async function waiters(n) {
for (let i = 0; i < 300; i++) {
const result = await lockPool.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type='Lock' AND datname=current_database()");
if (result.rows[0].n >= n) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error('manual exception requests did not queue');
}
try {
for (const releaseFirst of [false, true]) {
const { s } = await ready(); const holder = await lockPool.connect();
const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers, payload: { reason: '合成并发人工释放' } });
const mutate = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-terminal-evidences/${s.input.queryEvidenceId}/void`, headers: finance.headers, payload: { reason: '合成并发作废查询证据' } });
let first, second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [s.batch.id]);
first = releaseFirst ? release() : mutate(); await waiters(1);
second = releaseFirst ? mutate() : release(); await waiters(2);
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
} finally { await holder.query('ROLLBACK'); holder.release(); }
assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 409);
assert.equal((await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } })).reservation_state, releaseFirst ? 'released' : 'reserved');
}
} finally { await lockPool.end(); }
checks.push('s4_final_release_and_evidence_void_deterministic_both_orders');
{
const { s } = await ready();
const file = await prisma.loanBankTerminalEvidence.findUniqueOrThrow({ where: { id: s.input.queryEvidenceId } });
const { readFileBuffer, writeBuffer } = req('./src/services/storage');
const original = await readFileBuffer({ config, storagePath: file.storage_path });
await writeBuffer({ config, key: file.storage_path, buffer: Buffer.from('synthetic corrupted Q6 evidence') });
try { await cancel(s, 409, 'loan.not_submitted_invalid'); }
finally { await writeBuffer({ config, key: file.storage_path, buffer: original }); }
await cancel(s);
const changed = await ready(); await upload(changed.s, 'bank_terminal');
await cancel(changed.s, 409, 'loan.not_submitted_invalid');
}
checks.push('s4_final_release_requires_physical_digest_and_unchanged_material_revision');
// Direct SQL cannot fabricate a second review, rewrite history or reclassify.
await assert.rejects(prisma.loanNotSubmittedReview.updateMany({ where: { proposal_id: p.id }, data: { reason: 'tampered' } }));
await assert.rejects(prisma.loanNotSubmittedReview.deleteMany({ where: { proposal_id: p.id } }));
await assert.rejects(prisma.loanBankTerminalEvidence.update({ where: { id: invalid.input.queryEvidenceId }, data: { evidence_kind: 'bank_terminal' } }));
const one = await setup(); const op = await propose(one); await review(one, op);
await assert.rejects(prisma.loanNotSubmittedProposal.update({ where: { id: op.id }, data: { status: 'approved' } }));
await assert.rejects(prisma.loanRepayment.update({ where: { id: one.batch.loanRepaymentId }, data: { bank_result_state: 'confirmed_not_submitted', bank_result_ref: 'invalid-manual' } }));
checks.push('s4_database_two_person_decisions_and_evidence_kind_immutable');
console.log(JSON.stringify({ stage: "s4", status: "PASS", checks }));
return checks;
};