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; };