新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。 验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。 真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
141 行
11 KiB
JavaScript
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;
|
|
};
|