借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)

新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。

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

真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
这个提交包含在:
2026-10-07 21:58:03 +08:00
父节点 2e00d30fd0
当前提交 431c36eae3
共修改 32 个文件,包含 1902 行新增和 25 行删除
@@ -0,0 +1,278 @@
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
module.exports = async function bankResultsFixture(h) {
const { prisma, pool, call, app, req, finance, manager, independent, verifiedLender, confirmedLoan, repay,
exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid } = h;
const { PDFDocument } = req('pdf-lib');
const config = h.config;
const { decryptStringOrThrow } = req('./src/services/crypto');
async function upload(batchId, kind) {
const doc = await PDFDocument.create(); doc.setTitle(`synthetic-${randomUUID()}`); doc.addPage([200, 200]);
const boundary = `synthetic-${randomUUID()}`;
const res = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/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`), Buffer.from(await doc.save()), Buffer.from(`\r\n--${boundary}--\r\n`)]) });
assert.equal(res.statusCode, 200); return res.json().data.id;
}
async function setup(cancelled = true) {
const lender = await verifiedLender(`合成晚到${randomUUID().slice(0, 6)}`, null, '5');
const loan = await confirmedLoan(lender.id, '100.00', '合成晚到借款');
const batch = await repay(lender.id, loan.id, '80.00');
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id);
const exp = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: batch.id } });
const sub = await register(batch.id, `LATE-${randomUUID()}`, exp.id); await attest(batch.id);
const ev = await uploadEvidence(batch.id);
const fact = await proposeFact(batch.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: ev });
await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id);
if (cancelled) await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '合成原终态取消' });
const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id } });
return { lender, loan, batch, payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted) };
}
function fields(s, amount = '80.00', incoming = false, ref = `BANK-${randomUUID()}`) {
return [{ Name: '付款账号', Value: incoming ? s.lender.bankAccount : s.payer }, { Name: '收款账号', Value: incoming ? s.payer : s.lender.bankAccount },
{ Name: '金额', Value: amount }, { Name: '币种', Value: 'CNY' }, { Name: '交易状态', Value: '交易成功' }, { Name: '流水号', Value: ref }];
}
async function propose(s, kind = 'late_success', parentId, amount, ref, status = 200) {
return call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results`, { kind, ...(parentId ? { parentId } : {}),
evidenceId: await upload(s.batch.id, kind === 'return' ? 'incoming_return' : 'late_success'), requestKey: randomUUID(),
fields: fields(s, amount, kind === 'return', ref), attributionNote: '合成银行附言唯一定位原批次的付款事实', exclusionNote: '合成银行查询材料逐一排除其他相同金额候选' }, status);
}
const review = (s, e, who = independent, body = {}, status = 200) => call(who, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results/${e.id}/review`, { decision: 'approve', reason: '合成独立核对流水归属及银行证据', ...body }, status);
const apply = (s, e, status = 200) => call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results/${e.id}/apply`, {}, status);
const balances = (s) => call(finance, 'GET', `/company-loans/${s.loan.id}`);
const checks = [];
for (const cancelled of [true, false]) {
const s = await setup(cancelled); const count = await prisma.paymentBatch.count();
const e = await propose(s);
if (!cancelled) await call(finance, 'POST', `/payment-batches/${s.batch.id}/cancel`, { reason: '合成不得用旧结论释放' }, 409, 'loan.bank_result_reconcile_required');
await review(s, e, finance, {}, 403); await review(s, e);
await apply(s, e); await apply(s, e);
assert.equal((await balances(s)).outstanding, '20.00'); assert.equal((await balances(s)).reserved, '0.00');
assert.equal(await prisma.paymentBatch.count(), count);
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.batch.loanRepaymentId } })).bank_result_state, 'confirmed_not_paid');
const record = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: e.id } });
assert.equal(record.bank_amount.toFixed(2), '80.00'); assert.equal(record.settled_amount.toFixed(2), '80.00');
await assert.rejects(prisma.loanSupplementalSettlement.update({ where: { id: record.id }, data: { settled_amount: '1.00' } }));
}
checks.push('s6_late_success_cancelled_or_still_reserved_books_once_without_new_payment_batch');
{
const s = await setup(); const conflict = await repay(s.lender.id, s.loan.id, '70.00');
const e = await propose(s); await review(s, e); assert.equal((await apply(s, e)).conflict, true);
assert.equal((await balances(s)).outstanding, '100.00');
await call(manager, 'POST', `/payment-batches/${conflict.id}/review`, {}, 409, 'loan.lender_frozen');
await call(finance, 'POST', `/payment-batches/${conflict.id}/cancel`, { reason: '合成清除未付款占用' });
await apply(s, e); assert.equal((await balances(s)).outstanding, '20.00');
}
checks.push('s6_reserved_conflict_is_not_overpayment_and_blocks_until_resolved');
{
const s = await setup();
// Another real payment uses the existing HTTP/OCR settlement path.
const paid = await repay(s.lender.id, s.loan.id, '60.00');
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
await h.receipt(paid.id, '6000', `ALREADY-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
const e = await propose(s); await review(s, e); await apply(s, e);
const row = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: e.id } });
assert.equal(row.settled_amount.toFixed(2), '40.00'); assert.equal(row.receivable_amount.toFixed(2), '40.00');
assert.equal((await balances(s)).outstanding, '0.00');
await propose(s, 'return', e.id, '0.00', undefined, 409);
const ret = await propose(s, 'return', e.id, '15.00'); await review(s, ret); await apply(s, ret);
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
const rest = await propose(s, 'return', e.id, '25.00'); await review(s, rest); await apply(s, rest);
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
assert.equal((await balances(s)).outstanding, '0.00');
}
checks.push('s6_s7_overpayment_is_difference_partial_return_keeps_frozen_full_return_closes');
{
const s = await setup(); const e = await propose(s);
await review(s, e, independent, { decision: 'reject' }, 409);
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
const correctionEvidenceId = await upload(s.batch.id, 'bank_terminal');
await review(s, e, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId });
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event_id: e.id } }), 0);
assert.equal((await balances(s)).outstanding, '100.00');
}
checks.push('s5_false_alarm_requires_evidenced_independent_dismissal_uncertainty_stays_frozen');
console.log(JSON.stringify({ stage: "s6_s7", status: "PASS", checks }));
{
const s = await setup();
const paid = await repay(s.lender.id, s.loan.id, '100.00');
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
const paidRef = `FULL-${randomUUID()}`;
await h.receipt(paid.id, '10000', paidRef, { payeeAccount: s.lender.bankAccount });
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
const event = await propose(s); await review(s, event); await apply(s, event);
const row = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: event.id } });
assert.equal(row.settled_amount.toFixed(2), '0.00'); assert.equal(row.receivable_amount.toFixed(2), '80.00');
assert.equal(row.bank_amount.toFixed(2), '80.00');
// Same transaction as an ordinary executed payment must be rejected.
const reused = await propose(s, 'late_success', undefined, '80.00', paidRef);
await review(s, reused, independent, {}, 409);
// The review-only correction exit does not erase the existing receivable cause.
const correctionEvidenceId = await upload(s.batch.id, 'bank_terminal');
await review(s, reused, independent, { decision: 'reject', correctionKind: 'duplicate', correctionEvidenceId });
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
}
checks.push('s6_full_excess_retains_bank_amount_and_shared_transaction_identity_blocks_reuse');
{
const s = await setup(); const event = await propose(s); await review(s, event);
// Late factual payment must use original account snapshots even after legal
// master-data changes, and a departed original approver cannot strand it.
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
try { await apply(s, event); } finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
assert.equal((await balances(s)).outstanding, '20.00');
}
checks.push('s6_original_approver_departure_does_not_block_bank_fact_booking');
{
const s = await setup(); const event = await propose(s); await review(s, event);
await pool.query(`CREATE FUNCTION synthetic_bank_result_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF NEW.action='LOAN_LATE_SUCCESS_BOOKED' THEN RAISE EXCEPTION 'synthetic late audit failure'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_bank_result_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_bank_result_audit_failure()');
try { await apply(s, event, 500); }
finally { await pool.query('DROP TRIGGER synthetic_bank_result_audit_failure ON audit_logs'); await pool.query('DROP FUNCTION synthetic_bank_result_audit_failure()'); }
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event_id: event.id } }), 0);
assert.equal((await balances(s)).outstanding, '100.00');
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: event.id } })).status, 'approved');
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
await apply(s, event);
}
checks.push('s6_bookkeeping_audit_failure_rolls_back_amounts_status_and_freezes');
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('bank result requests did not queue on lender');
}
try {
for (const freezeFirst of [false, true]) {
const s = await setup(); const next = await repay(s.lender.id, s.loan.id, '10.00');
await call(manager, 'POST', `/payment-batches/${next.id}/review`, {});
const evidenceId = await upload(s.batch.id, 'late_success');
const holder = await lockPool.connect();
const freeze = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-results`, headers: finance.headers,
payload: { kind: 'late_success', evidenceId, requestKey: randomUUID(), fields: fields(s), attributionNote: '合成确定性交错银行归属说明', exclusionNote: '合成确定性交错排除其他付款批次' } });
const exporting = () => app.inject({ method: 'POST', url: `/api/payment-batches/${next.id}/payment-order`, headers: finance.headers, payload: {} });
let first, second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
first = freezeFirst ? freeze() : exporting(); await waiters(1);
second = freezeFirst ? exporting() : freeze(); 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, freezeFirst ? 409 : 200);
assert.equal(await prisma.paymentOrderExport.count({ where: { payment_batch_id: next.id } }), freezeFirst ? 0 : 1);
}
for (const bookFirst of [false, true]) {
const s = await setup(); const event = await propose(s); await review(s, event);
const holder = await lockPool.connect();
const book = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-results/${event.id}/apply`, headers: finance.headers, payload: {} });
const request = () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers,
payload: { lenderId: s.lender.id, reason: '合成并发超额申请', requestKey: randomUUID(), allocations: [{ loanId: s.loan.id, amount: '30.00' }] } });
let first, second;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
first = bookFirst ? book() : request(); await waiters(1);
second = bookFirst ? request() : book(); 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, bookFirst ? 200 : 409); assert.equal(second.statusCode, bookFirst ? 409 : 200);
assert.equal((await balances(s)).outstanding, '20.00'); assert.equal((await balances(s)).reserved, '0.00');
}
} finally { await lockPool.end(); }
checks.push('s5_s6_freeze_vs_export_and_supplement_vs_new_request_deterministic_both_orders');
{
const s = await setup(false); const ref = `REPLACE-${randomUUID()}`;
const old = await propose(s, 'late_success', undefined, '80.00', ref);
const oldRow = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } });
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${oldRow.evidence_id}/void`, { reason: '合成待审材料换版' });
await review(s, old, independent, {}, 409);
const fresh = await propose(s, 'late_success', undefined, '80.00', ref);
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } })).status, 'superseded');
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
await review(s, fresh);
const current = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: fresh.id } });
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${current.evidence_id}/void`, { reason: '合成已审事实不可作废' }, 409, 'loan.bank_result_reconcile_required');
await apply(s, fresh);
}
checks.push('s6_pending_evidence_replaced_without_unfreeze_reviewed_bank_fact_evidence_retained');
{
const s = await setup(); const otherLoan = await confirmedLoan(s.lender.id, '100.00', '合成多借款补记第二笔');
const combined = (await call(finance, 'POST', '/loan-repayments', { lenderId: s.lender.id, reason: '合成跨原借款逐笔补记', requestKey: randomUUID(),
allocations: [{ loanId: s.loan.id, amount: '20.00' }, { loanId: otherLoan.id, amount: '40.00' }] })).item;
await call(manager, 'POST', `/payment-batches/${combined.id}/review`, {}); await exportOrder(combined.id);
const exp = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: combined.id } });
const submission = await register(combined.id, `COMBINED-${randomUUID()}`, exp.id); await attest(combined.id);
const evidenceId = await uploadEvidence(combined.id);
const fact = await proposeFact(combined.id, { submissionId: submission.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
await reviewFact(combined.id, fact.id); await confirmNotPaid(combined.id);
await call(finance, 'POST', `/payment-batches/${combined.id}/cancel`, { reason: '合成多借款原结论取消' });
for (const [loanId, amount] of [[s.loan.id, '90.00'], [otherLoan.id, '70.00']]) {
const paid = await repay(s.lender.id, loanId, amount);
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
await h.receipt(paid.id, String(Number(amount) * 100), `MULTI-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
}
const combinedSource = { ...s, batch: combined }; const event = await propose(combinedSource, 'late_success', undefined, '60.00');
await review(combinedSource, event); await apply(combinedSource, event);
const rows = await prisma.loanSupplementalSettlement.findMany({ where: { event_id: event.id } });
assert.equal(rows.length, 2);
const a = rows.find((row) => row.loan_id === s.loan.id); const b = rows.find((row) => row.loan_id === otherLoan.id);
assert.deepEqual([a.settled_amount.toFixed(2), a.receivable_amount.toFixed(2)], ['10.00', '10.00']);
assert.deepEqual([b.settled_amount.toFixed(2), b.receivable_amount.toFixed(2)], ['30.00', '10.00']);
assert.equal((await balances(s)).outstanding, '0.00');
assert.equal((await call(finance, 'GET', `/company-loans/${otherLoan.id}`)).outstanding, '0.00');
}
checks.push('s6_multiple_original_loans_settle_each_difference_without_cross_loan_offset');
{
const s = await setup(); const competitor = await repay(s.lender.id, s.loan.id, '80.00');
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
const ref = `STALE-APPROVED-${randomUUID()}`;
const old = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, old);
const submitted = await register(competitor.id, `NEW-CANDIDATE-${randomUUID()}`, null);
await apply(s, old, 409);
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: old.id } }), 0, 'review does not consume a transaction');
await attest(competitor.id); const evidenceId = await uploadEvidence(competitor.id);
const fact = await proposeFact(competitor.id, { submissionId: submitted.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
await reviewFact(competitor.id, fact.id); await confirmNotPaid(competitor.id);
await call(finance, 'POST', `/payment-batches/${competitor.id}/cancel`, { reason: '合成其他候选已确认终止' });
const fresh = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, fresh); await apply(s, fresh);
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } })).status, 'superseded');
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: fresh.id } }), 1);
assert.equal((await balances(s)).outstanding, '20.00');
}
checks.push('s6_approved_attribution_stales_on_new_candidate_reregistration_rechecks_then_claims_once');
for (const dismiss of [false, true]) {
const s = await setup(); const conflict = await repay(s.lender.id, s.loan.id, '70.00');
const ref = `REPLACE-CONFLICT-${randomUUID()}`;
const old = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, old);
assert.equal((await apply(s, old)).conflict, true);
const fresh = await propose(s, 'late_success', undefined, '80.00', ref);
assert.equal(await prisma.loanFreezeReason.count({ where: { source_key: old.id, status: 'active' } }), 0);
assert.equal(await prisma.loanFreezeReason.count({ where: { source_key: fresh.id, status: 'active' } }), 2);
if (dismiss) {
await review(s, fresh, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: await upload(s.batch.id, 'bank_terminal') });
assert.equal((await balances(s)).outstanding, '100.00');
} else {
await review(s, fresh);
await call(finance, 'POST', `/payment-batches/${conflict.id}/cancel`, { reason: '合成解除其他申请占用' });
await apply(s, fresh); assert.equal((await balances(s)).outstanding, '20.00');
}
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
}
checks.push('s6_replacement_transfers_conflict_freeze_and_booking_or_evidenced_dismissal_closes_it');
{
const s = await setup(false); const event = await propose(s);
const correction = await upload(s.batch.id, 'bank_terminal');
await review(s, event, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: correction });
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${correction}/void`, { reason: '合成不得撤销已消费的误报更正依据' }, 409, 'loan.bank_result_reconcile_required');
await assert.rejects(prisma.loanBankTerminalEvidence.update({ where: { id: correction }, data: { voided_at: new Date(), voided_by: finance.id, void_reason: '合成数据库旁路拒绝' } }));
assert.equal((await prisma.loanBankTerminalEvidence.findUniqueOrThrow({ where: { id: correction } })).voided_at, null);
assert.equal((await balances(s)).reserved, '80.00');
}
checks.push('s5_consumed_false_alarm_correction_cannot_be_voided_in_service_or_database');
return checks;
};
+143 -1
查看文件
@@ -6,11 +6,25 @@ const root = path.resolve(__dirname, '../..');
const req = createRequire(path.join(root, 'client/package.json'));
const { chromium, expect } = req('@playwright/test');
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi }) {
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi }) {
const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-'));
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
const checks = [];
async function findOnPages(page, locator, endpoint) {
for (let attempt = 0; attempt < 12; attempt++) {
await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
if (await locator.count()) return;
const next = page.getByRole('button', { name: '下一页', exact: true });
await expect(next).toBeEnabled();
const currentLabel = await page.getByText(/^第 \d+ 页$/).first().innerText();
await next.click();
await expect(page.getByText(currentLabel, { exact: true })).toHaveCount(0);
await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
}
throw new Error('synthetic record not found after pagination');
}
try {
for (const mobile of [false, true]) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
@@ -36,6 +50,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
await expect(dialog).not.toBeVisible();
await page.getByRole('tab', { name: '出借人', exact: true }).click();
const lenderCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) });
await findOnPages(page, lenderCard, 'loan-lenders');
await lenderCard.getByRole('button', { name: '核实身份关联' }).click();
dialog = page.getByRole('dialog');
await dialog.getByLabel('出借人对应身份').selectOption('external');
@@ -58,6 +73,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
await page.getByRole('tab', { name: '借款台账', exact: true }).click();
const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference } });
const card = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${loan.loan_no}`, exact: true }) });
await findOnPages(page, card, 'company-loans');
await card.locator('summary').click();
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) {
const received = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST');
@@ -89,6 +105,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
await page.screenshot({ path: path.join(output, `${mode}-lenders-after-submit.png`), fullPage: true });
await page.getByRole('tab', { name: '出借人', exact: true }).click();
const frozenCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) });
await findOnPages(page, frozenCard, 'loan-lenders');
await expect(frozenCard).toContainText('有还款正在办理');
await expect(frozenCard.getByRole('button', { name: '核实身份关联' })).toBeDisabled();
await expect(frozenCard.getByRole('button', { name: '更新收款资料' })).toBeDisabled();
@@ -155,6 +172,131 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
assert.equal(settled.statusCode, 200, 'settles after UI re-registration and review');
checks.push('desktop_stale_attribution_reregistered_reviewed_and_settled');
}
if (reconciliationUi) {
async function confirmDialog(dialog, step) {
await dialog.getByRole('button', { name: '确认', exact: true }).click();
try { await expect(dialog).not.toBeVisible(); }
catch { throw new Error(`synthetic Q6 UI ${step}: ${await dialog.getByRole('alert').allTextContents()} (dialog remains open)`); }
}
for (const [index, batchId] of reconciliationUi.batches.entries()) {
const mobile = index === 1;
async function session(who) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) })));
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batchId}`)}`);
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click();
await page.waitForURL(/\/payment-batches\?id=/);
const panel = page.getByRole('region', { name: '银行结果对账', exact: true }); await expect(panel).toBeVisible({ timeout: 20000 });
return { context, page, panel };
}
let view = await session(finance);
for (const kind of ['query_no_result', 'account_statement', 'exporter_statement']) {
await view.panel.getByRole('button', { name: '上传银行证据', exact: true }).click();
const dialog = view.page.getByRole('dialog'); await dialog.getByLabel('证据类别').selectOption(kind);
await dialog.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await reconciliationUi.bankLetterPdf() });
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
}
await view.panel.getByRole('button', { name: '登记人工未提交', exact: true }).click();
let dialog = view.page.getByRole('dialog');
await dialog.getByLabel('查询无结果官方 PDF', { exact: true }).selectOption({ index: 1 });
await dialog.getByLabel('同期对账单', { exact: true }).selectOption({ index: 1 });
await dialog.getByLabel('导出人 1 的文件去向说明', { exact: true }).selectOption({ index: 1 });
const now = new Date(); const earlier = new Date(now.getTime() - 86400000);
// Browser local timezone is UTC in this synthetic context.
await dialog.getByLabel('查询与对账单覆盖开始').fill(earlier.toISOString().slice(0, 16));
await dialog.getByLabel('查询与对账单覆盖截至(须含登记日)').fill(now.toISOString().slice(0, 16));
await dialog.getByLabel('依据与说明').fill('合成浏览器逐份核对官方查询、对账单和文件去向');
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
await expect(view.panel).toContainText('已有 0 人同意');
await expect(view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true })).toHaveCount(0);
await view.context.close();
for (const [n, who] of [manager, reconciliationUi.independent].entries()) {
view = await session(who); await view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true }).click();
dialog = view.page.getByRole('dialog'); await dialog.getByLabel('复核结论').selectOption('approve');
await dialog.getByLabel('依据与说明').fill('合成浏览器独立复核全部证据与未提交结论');
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
await expect(view.panel).toContainText(`已有 ${n + 1} 人同意`);
await expect(view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true })).toHaveCount(0);
await view.context.close();
}
view = await session(finance); await view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true }).click();
dialog = view.page.getByRole('dialog'); await dialog.getByLabel('依据与说明').fill('合成浏览器特别复核完成后重验并取消');
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
await expect(view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true })).toHaveCount(0);
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
await view.page.screenshot({ path: path.join(output, `${mobile ? 'mobile' : 'desktop'}-manual-not-submitted.png`), fullPage: true });
await view.context.close();
checks.push(`${mobile ? 'mobile' : 'desktop'}_manual_not_submitted_evidence_upload_two_independent_reviews_and_release`);
}
}
if (reconciliationUi) {
for (const recovery of [false, true]) for (const [index, batch] of (recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).entries()) {
const mobile = index === 1;
async function session(who) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) })));
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batch.id}`)}`);
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click();
await page.waitForURL(/\/payment-batches\?id=/);
const panel = page.getByRole('region', { name: '银行结果对账', exact: true }); await expect(panel).toBeVisible({ timeout: 20000 });
return { context, page, panel };
}
let view = await session(finance);
async function action(name, fill) {
await view.panel.getByRole('button', { name, exact: true }).click();
const dialog = view.page.getByRole('dialog');
if (fill) await fill(dialog);
if (await dialog.getByLabel('依据与说明').count()) await dialog.getByLabel('依据与说明').fill('合成浏览器独立核对指定银行指令与全部依据');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
try { await expect(dialog).not.toBeVisible(); } catch { throw new Error(`synthetic reconciliation ${name}: ${await dialog.getByRole('alert').allTextContents()}`); }
}
if (recovery) {
await action('发起补证恢复');
await action('上传银行证据', async (dialog) => {
await dialog.getByLabel('证据类别').selectOption('bank_terminal');
await dialog.getByLabel('证据文件').setInputFiles({ name: 'replacement-terminal.pdf', mimeType: 'application/pdf', buffer: await reconciliationUi.bankLetterPdf() });
});
}
await action('登记银行终态事实', async (dialog) => {
await dialog.getByLabel('对应银行提交').selectOption({ index: 1 });
await dialog.getByLabel('银行终态书面证据').selectOption({ index: 1 });
await dialog.getByLabel('银行终态', { exact: true }).selectOption('failed');
if (!recovery) { const boxes = dialog.getByRole('checkbox'); await expect(boxes).toHaveCount(2); for (const box of await boxes.all()) await box.check(); }
});
await expect(view.panel).toContainText(batch.instruction); await view.context.close();
view = await session(manager);
await expect(view.panel).toContainText(batch.instruction);
if (!recovery) {
await expect(view.panel).toContainText('了结处理中回单');
const receiptButtons = view.panel.getByRole('button', { name: /^付款回单 [12]/ });
await expect(receiptButtons).toHaveCount(2);
for (const receiptButton of await receiptButtons.all()) {
await receiptButton.click(); const dialog = view.page.getByRole('dialog');
await dialog.getByLabel('依据与说明').fill('合成复核指定银行指令的历史回单事实');
const downloaded = view.page.waitForEvent('download');
await dialog.getByRole('button', { name: '确认', exact: true }).click(); await downloaded;
await expect(dialog).not.toBeVisible();
}
}
await action('独立复核终态事实', async (dialog) => dialog.getByLabel('复核结论').selectOption('approve'));
if (!recovery) await action('确认银行未付款');
await view.context.close(); view = await session(finance);
if (recovery) {
await action('送审补证恢复'); await view.context.close(); view = await session(manager);
await action('复核补证恢复', async (dialog) => dialog.getByLabel('复核结论').selectOption('approve'));
await view.context.close(); view = await session(finance);
}
await action('重验依据并取消释放');
await expect(view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true })).toHaveCount(0);
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
await view.page.screenshot({ path: path.join(output, `${mobile ? 'mobile' : 'desktop'}-${recovery ? 'recovery' : 'terminal-multiple-receipts'}.png`), fullPage: true });
await view.context.close(); checks.push(`${mobile ? 'mobile' : 'desktop'}_${recovery ? 'recovery_start_evidence_review_round_release' : 'terminal_multiple_pending_receipts_independent_review_release'}`);
}
}
const context = await browser.newContext({ serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
@@ -0,0 +1,31 @@
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
module.exports = async function freezeFixture(h) {
const { prisma, call, req, finance, manager, verifiedLender, confirmedLoan, repay, exportOrder, uploadEvidence } = h;
const freezes = req('./src/services/loanFreezes');
const lender = await verifiedLender('合成冻结出借人', null, '4');
const loan = await confirmedLoan(lender.id, '1000.00', '合成冻结借款');
const first = await repay(lender.id, loan.id, '10.00');
await call(manager, 'POST', `/payment-batches/${first.id}/review`, {}); await exportOrder(first.id);
const draft = await repay(lender.id, loan.id, '20.00');
const request = { employee: { id: finance.id }, headers: {}, id: randomUUID(), ip: "127.0.0.1" };
const batch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: first.id }, include: { items: true } });
const open = (kind, key) => prisma.$transaction((tx) => freezes.openReason(tx, batch, request, kind, key));
const close = (kind, key) => prisma.$transaction((tx) => freezes.closeReason(tx, batch, request, kind, key, { action: 'synthetic_source_resolved', recordId: randomUUID() }));
const key = randomUUID(); const other = randomUUID();
const reason = await open('late_success', key); await open('receivable', other);
assert.equal((await open('late_success', key)).id, reason.id);
await call(manager, 'POST', `/payment-batches/${draft.id}/review`, {}, 409, 'loan.lender_frozen');
await call(finance, 'POST', `/payment-batches/${first.id}/payment-order`, {}, 409, 'loan.lender_frozen');
await call(finance, 'POST', '/loan-repayments', { lenderId: lender.id, reason: '合成冻结后新付款', requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] }, 409, 'loan.lender_frozen');
await uploadEvidence(first.id); // Facts remain recordable.
await close('late_success', key);
assert.equal((await freezes.state(prisma, lender.id)).frozen, true);
await call(manager, 'POST', `/payment-batches/${draft.id}/review`, {}, 409, 'loan.lender_frozen');
await close('receivable', other);
assert.equal((await freezes.state(prisma, lender.id)).frozen, false);
await call(manager, 'POST', `/payment-batches/${draft.id}/review`, {});
await assert.rejects(prisma.loanFreezeReason.delete({ where: { id: reason.id } }));
await assert.rejects(prisma.loanFreezeReason.update({ where: { id: reason.id }, data: { status: 'active' } }));
return ['s5_all_active_causes_block_new_payment_review_export_but_allow_facts', 's5_individual_closure_never_unfreezes_other_causes_and_history_immutable'];
};
+45 -1
查看文件
@@ -934,6 +934,16 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
resolvesAttachmentIds: [pending.attachment.id] }, 409, 'loan.terminal_fact_invalid');
const pF1 = await proposeFact(pend.id, { submissionId: pS1.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pEv,
resolvesAttachmentIds: [pending.attachment.id] });
if (voidFirst) {
const url = `/api/payment-batches/${pend.id}/receipt-attachments/${pending.attachment.id}/file`;
assert.equal((await app.inject({ method: 'GET', url, headers: manager.headers })).statusCode, 409);
const query = '?purpose=loan_reconciliation&reason=' + encodeURIComponent('合成复核已作废的处理中银行事实');
const historical = await app.inject({ method: 'GET', url: url + query, headers: manager.headers });
assert.equal(historical.statusCode, 200); assert.match(historical.headers['cache-control'], /no-store/);
assert.equal((await app.inject({ method: 'GET', url: url + query, headers: ordinary.headers })).statusCode, 403);
assert.equal((await app.inject({ method: 'GET', url: url + '?purpose=loan_reconciliation', headers: manager.headers })).statusCode, 400);
assert.ok(await prisma.auditLog.count({ where: { target_id: pend.id, action: 'PAYMENT_BATCH_RECEIPT_DOWNLOADED' } }));
}
await reviewFact(pend.id, pF1.id);
const pF2 = await proposeFact(pend.id, { submissionId: pS2.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: pEv });
await reviewFact(pend.id, pF2.id);
@@ -1157,6 +1167,12 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
checks.push('s3_evidence_and_fact_history_immutable');
checks.push(...await require('./company-loan-recovery-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
checks.push(...await require('./company-loan-not-submitted-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
checks.push(...await require('./company-loan-freeze-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
checks.push(...await require('./company-loan-bank-results-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
delete config.loanReleaseWaitDays;
// History guards in the database.
@@ -1257,7 +1273,35 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
assert.equal((await receipt(uiTarget.id, '500', 'SYNTHETIC-UI-ATTRIBUTION', { payeeAccount: uiLender.bankAccount })).result.status, 'matched');
const attributionUi = { batchId: uiTarget.id, addCompetitor: uiExported,
execute: () => app.inject({ method: 'POST', url: `/api/payment-batches/${uiTarget.id}/execute`, headers: finance.headers, payload: {} }) };
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi });
// HTTP cases above verify the real three-day gate. UI fixtures use the
// supported synthetic wait override so this run does not wait real days.
config.loanReleaseWaitDays = 0;
const reconciliationUi = { batches: [], independent, bankLetterPdf };
const uiManualLender = await verifiedLender('合成特别复核界面出借人', null, '6');
const uiManualLoan = await confirmedLoan(uiManualLender.id, '100.00', '合成特别复核界面借款');
for (const amount of ['1.00', '2.00']) {
const batch = await repay(uiManualLender.id, uiManualLoan.id, amount);
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id); await attest(batch.id);
reconciliationUi.batches.push(batch.id);
}
reconciliationUi.terminalBatches = []; reconciliationUi.recoveryBatches = [];
for (const recovery of [false, true]) for (const amount of (recovery ? ['5.00', '6.00'] : ['3.00', '4.00'])) {
const batch = await repay(uiManualLender.id, uiManualLoan.id, amount);
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id);
const exported = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: batch.id } });
const ref = `UI-TERMINAL-${amount}`; const sub = await register(batch.id, ref, exported.id); await attest(batch.id);
if (recovery) {
const evidenceId = await uploadEvidence(batch.id);
const fact = await proposeFact(batch.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id);
await call(finance, 'POST', `/payment-batches/${batch.id}/loan-terminal-evidences/${evidenceId}/void`, { reason: '合成旧证据撤回,浏览器重新补证' });
} else {
for (let i = 0; i < 2; i++) { const uploaded = await receipt(batch.id, String(Number(amount) * 100), ref, { payeeAccount: uiManualLender.bankAccount, transactionStatus: 'pending' }); if (i === 0) await call(finance, 'POST', `/payment-batches/${batch.id}/receipt-attachments/${uploaded.attachment.id}/void`, { reason: '合成作废的处理中事实仍供复核' }); }
await uploadEvidence(batch.id);
}
(recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).push({ id: batch.id, instruction: ref });
}
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi });
}
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
} finally { await app.close(); await prisma.$disconnect(); }
@@ -0,0 +1,140 @@
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;
};