借款还款同金额回单须经独立复核确认归属后方可核销(09-17-company-loan-principal-repayment)
- 同一出借人同金额的其他批次曾导出或上传回单(含确认失败后已取消的)时, 最终核销入口要求已复核的归属确认;OCR 匹配与人工回单复核均不能绕过, 证据不足保持待对账,本金与占用不变;存在候选时失败回单不能释放占用 - 新表 loan_receipt_attributions(迁移 20261006010000):绑定交易指纹、 回单核验与人工录入证据、当时候选集合;依据类型不含时间吻合;复核人须 当前有资格且独立于登记人、批次创建人、提交人、导出人、回单上传人、 人工录入人与出借员工;失效记录在重新登记时标为 superseded,历史只增不改 - 借款回单上传取出借人锁,与核销候选检查串行;串行只保证按当时已知候选 判断,核销之后才出现的新证据留待晚到结果对账流程处理 - 付款就绪检查反映归属要求;页面登记/复核/失效重登,写明同额分期每期需确认 - 测试:两个错误核销复现改为回归断言;证据绑定、确定性并发两种顺序、 PC 浏览器完整登记复核重登流程;变异测试均变红 注意:借款功能尚不具备真实还款验收条件(失败回单与对账流程、离职后确认 失败、真实回单样本、手机端弹窗、独立复核待完成);未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -448,11 +448,11 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
checks.push('departed_reviewer_or_lender_employee_does_not_strand_paid_repayment_via_ocr_or_manual');
|
||||
// Approval-time independence is still enforced after payment: a recorded
|
||||
// approver equal to the batch creator/submitter or lender employee is refused.
|
||||
for (const [label, approverId] of [['creator', finance.id], ['lender_employee', lenderEmployee.id]]) {
|
||||
const forged = await repay(staffLender.id, staffLoan.id, '2.00');
|
||||
for (const [label, approverId, amount] of [['creator', finance.id, '2.00'], ['lender_employee', lenderEmployee.id, '2.50']]) {
|
||||
const forged = await repay(staffLender.id, staffLoan.id, amount);
|
||||
await call(manager, 'POST', `/payment-batches/${forged.id}/review`, {});
|
||||
await exportOrder(forged.id);
|
||||
const forgedReceipt = await receipt(forged.id, '200', `SYNTHETIC-FORGED-APPROVER-${label}`, { payeeAccount: staffLender.bankAccount });
|
||||
const forgedReceipt = await receipt(forged.id, String(Math.round(Number(amount) * 100)), `SYNTHETIC-FORGED-APPROVER-${label}`, { payeeAccount: staffLender.bankAccount });
|
||||
assert.equal(forgedReceipt.result.status, 'matched');
|
||||
await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: approverId } });
|
||||
await call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {}, 403, 'loan.independent_review_required');
|
||||
@@ -470,20 +470,168 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
await call(finance, 'POST', `/payment-batches/${prePayment.id}/cancel`, { reason: '合成付款前资格失效' });
|
||||
checks.push('pre_payment_export_keeps_current_approver_eligibility');
|
||||
|
||||
// C (reproduction only, outcome recorded, not yet a guarded behaviour):
|
||||
// can a genuine receipt of one repayment settle a different same-amount batch?
|
||||
// C: a matched receipt does not prove which same-lender, same-amount batch
|
||||
// the bank paid. Settlement and failure release need an independently
|
||||
// reviewed attribution; OCR matches and manual receipt reviews cannot bypass it.
|
||||
const probeLender = await verifiedLender('合成出借人丁', null, '6');
|
||||
const probeLoan = await confirmedLoan(probeLender.id, '500.00', '合成丁借入');
|
||||
const reproductions = [];
|
||||
async function probeExecute(batchId) {
|
||||
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/execute`, headers: finance.headers, payload: {} });
|
||||
return { status: response.statusCode, reasonCode: response.json().details?.reasonCode ?? null };
|
||||
}
|
||||
const probeBalance = async () => { const b = await call(finance, 'GET', `/company-loans/${probeLoan.id}`); return `${b.outstanding}/${b.reserved}`; };
|
||||
const attribute = (who, batchId, body, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-receipt-attribution`, {
|
||||
basisKind: 'customer_serial', basisNote: '合成回单客户方流水号与本批次制单流水号一致', exclusionNote: '合成其他候选批次流水号均不一致', ...body }, status, reason);
|
||||
const reviewAttribution = (who, batchId, id, decision = 'approve', status = 200, reason) => call(who, 'POST',
|
||||
`/payment-batches/${batchId}/loan-receipt-attribution/${id}/review`, { decision, reason: '合成独立复核归属' }, status, reason);
|
||||
const detail = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}`)).item ?? (await call(finance, 'GET', `/payment-batches/${batchId}`));
|
||||
|
||||
// C1: two same-amount repayments in flight; P's genuine receipt uploaded to Q.
|
||||
const c1p = await repay(probeLender.id, probeLoan.id, '7.00');
|
||||
const c1q = await repay(probeLender.id, probeLoan.id, '7.00');
|
||||
for (const item of [c1p, c1q]) { await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); }
|
||||
const beforeC1 = await probeBalance();
|
||||
const c1 = await receipt(c1q.id, '700', 'SYNTHETIC-C1-PAYMENT-OF-P', { payeeAccount: probeLender.bankAccount });
|
||||
reproductions.push({ scenario: 'two_in_flight_same_amount_receipt_of_p_uploaded_to_q', match: c1.result.status, execute: await probeExecute(c1q.id) });
|
||||
assert.equal(c1.result.status, 'matched');
|
||||
await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
assert.equal(await probeBalance(), beforeC1, 'C1 wrong receipt must not settle or release');
|
||||
const qState = (await detail(c1q.id)).loanRepayment.receiptAttribution;
|
||||
assert.equal(qState.required, true); assert.deepEqual(qState.competitors.map((item) => item.id), [c1p.id]);
|
||||
// Correct receipt for P still needs attribution because Q is a competitor.
|
||||
await receipt(c1p.id, '700', 'SYNTHETIC-C1-P-OWN', { payeeAccount: probeLender.bankAccount });
|
||||
await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
await attribute(finance, c1p.id, { basisKind: 'time_alignment' }, 400);
|
||||
const proposed = await attribute(finance, c1p.id, {});
|
||||
await attribute(finance, c1p.id, {}, 409, 'loan.receipt_attribution_exists');
|
||||
await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
await reviewAttribution(finance, c1p.id, proposed.id, 'approve', 403);
|
||||
const dual = await actor(['finance', 'manager']);
|
||||
await reviewAttribution(dual, c1p.id, proposed.id, 'approve');
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {})).item.status, 'executed');
|
||||
await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
checks.push('same_amount_wrong_batch_receipt_blocked_and_reviewed_attribution_unlocks_settlement');
|
||||
|
||||
// A proposer holding manager rights cannot review their own attribution.
|
||||
const selfLender = await verifiedLender('合成出借人戊', null, '7');
|
||||
const selfLoan = await confirmedLoan(selfLender.id, '100.00', '合成戊借入');
|
||||
const s1 = await repay(selfLender.id, selfLoan.id, '3.00'); const s2 = await repay(selfLender.id, selfLoan.id, '3.00');
|
||||
for (const item of [s1, s2]) { await call(independent, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); }
|
||||
await receipt(s1.id, '300', 'SYNTHETIC-SELF-REVIEW', { payeeAccount: selfLender.bankAccount });
|
||||
const selfProposal = await attribute(dual, s1.id, {});
|
||||
await reviewAttribution(dual, s1.id, selfProposal.id, 'approve', 403);
|
||||
await reviewAttribution(manager, s1.id, selfProposal.id, 'reject');
|
||||
await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
// An approval stops covering once a new competitor reaches the bank.
|
||||
const reproposal = await attribute(finance, s1.id, {});
|
||||
await reviewAttribution(manager, s1.id, reproposal.id, 'approve');
|
||||
const s3 = await repay(selfLender.id, selfLoan.id, '3.00');
|
||||
await call(independent, 'POST', `/payment-batches/${s3.id}/review`, {}); await exportOrder(s3.id);
|
||||
await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
const staleState = (await detail(s1.id)).loanRepayment.receiptAttribution.current;
|
||||
assert.equal(staleState.coversCurrentCompetitors, false); assert.equal(staleState.stale, true);
|
||||
checks.push('attribution_self_review_rejection_and_new_competitor_invalidate_approval');
|
||||
// The stale approval must not deadlock the batch: re-registering supersedes it.
|
||||
const readiness = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}/receipt-readiness`)).item;
|
||||
assert.deepEqual(await readiness(s1.id), { ready: false, receiptAttributionRequired: true });
|
||||
const renewed = await attribute(finance, s1.id, {});
|
||||
assert.equal((await prisma.loanReceiptAttribution.findUniqueOrThrow({ where: { id: reproposal.id } })).status, 'superseded');
|
||||
await reviewAttribution(manager, s1.id, renewed.id, 'approve');
|
||||
assert.deepEqual(await readiness(s1.id), { ready: true });
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {})).item.status, 'executed');
|
||||
checks.push('stale_approval_superseded_by_reregistration_then_settles');
|
||||
// Swapping the receipt after approval: the old approval cannot unlock the
|
||||
// new transaction, and a new registration for the new receipt works.
|
||||
const swapReviewer = await actor(['manager']);
|
||||
const swapExporter = await actor(['finance', 'manager']);
|
||||
const swap = await repay(selfLender.id, selfLoan.id, '3.00');
|
||||
await call(independent, 'POST', `/payment-batches/${swap.id}/review`, {});
|
||||
const swapExport = await app.inject({ method: 'POST', url: `/api/payment-batches/${swap.id}/payment-order`, headers: swapExporter.headers, payload: {} });
|
||||
assert.equal(swapExport.statusCode, 200, 'exporter with manager role exports');
|
||||
const firstReceipt = await receipt(swap.id, '300', 'SYNTHETIC-SWAP-FIRST', { payeeAccount: selfLender.bankAccount });
|
||||
const swapApproved = await attribute(finance, swap.id, {});
|
||||
await reviewAttribution(swapExporter, swap.id, swapApproved.id, 'approve', 403);
|
||||
await reviewAttribution(swapReviewer, swap.id, swapApproved.id, 'approve');
|
||||
await call(finance, 'POST', `/payment-batches/${swap.id}/receipt-attachments/${firstReceipt.attachment.id}/void`, { reason: '合成回单上传错误作废' });
|
||||
await receipt(swap.id, '300', 'SYNTHETIC-SWAP-SECOND', { payeeAccount: selfLender.bankAccount });
|
||||
await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
assert.equal((await detail(swap.id)).loanRepayment.receiptAttribution.current.stale, true);
|
||||
const swapRenewed = await attribute(finance, swap.id, {});
|
||||
await reviewAttribution(swapReviewer, swap.id, swapRenewed.id, 'approve');
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {})).item.status, 'executed');
|
||||
checks.push('receipt_swap_invalidates_approval_exporter_cannot_review_and_reregistration_settles');
|
||||
// A proposal whose receipt was voided can still be rejected (no receipt needed).
|
||||
const orphanReceipt = await receipt(s2.id, '300', 'SYNTHETIC-ORPHAN', { payeeAccount: selfLender.bankAccount });
|
||||
const orphan = await attribute(finance, s2.id, {});
|
||||
await call(finance, 'POST', `/payment-batches/${s2.id}/receipt-attachments/${orphanReceipt.attachment.id}/void`, { reason: '合成回单作废' });
|
||||
assert.equal((await detail(s2.id)).loanRepayment.receiptAttribution.receiptAvailable, false);
|
||||
await attribute(finance, s2.id, {}, 409, 'loan.receipt_attribution_unavailable');
|
||||
await reviewAttribution(swapReviewer, s2.id, orphan.id, 'reject');
|
||||
checks.push('proposal_without_receipt_can_be_rejected');
|
||||
// Same transaction, different evidence (re-scanned receipt with the same bank
|
||||
// reference): the approval is bound to the reviewed evidence and stops unlocking.
|
||||
const rescan = await repay(selfLender.id, selfLoan.id, '3.00');
|
||||
await call(independent, 'POST', `/payment-batches/${rescan.id}/review`, {}); await exportOrder(rescan.id);
|
||||
const rescanFirst = await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount });
|
||||
const rescanApproved = await attribute(finance, rescan.id, {});
|
||||
await reviewAttribution(swapReviewer, rescan.id, rescanApproved.id, 'approve');
|
||||
await call(finance, 'POST', `/payment-batches/${rescan.id}/receipt-attachments/${rescanFirst.attachment.id}/void`, { reason: '合成重新扫描' });
|
||||
await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount });
|
||||
await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
assert.equal((await detail(rescan.id)).loanRepayment.receiptAttribution.current.stale, true);
|
||||
const rescanRenewed = await attribute(finance, rescan.id, {});
|
||||
const supersedeAudit = await prisma.auditLog.findFirst({ where: { action: 'LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED' }, orderBy: { created_at: 'desc' } });
|
||||
assert.ok(JSON.stringify(supersedeAudit).includes('receipt_evidence_changed'));
|
||||
await reviewAttribution(swapReviewer, rescan.id, rescanRenewed.id, 'approve');
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {})).item.status, 'executed');
|
||||
checks.push('same_transaction_new_evidence_invalidates_approval_until_rereviewed');
|
||||
|
||||
// Settlement's competitor check and a competitor's first receipt upload are
|
||||
// serialized by the lender lock. Deterministic interleaving in both orders.
|
||||
const raceLender = await verifiedLender('合成出借人庚', null, '2');
|
||||
const raceLoan = await confirmedLoan(raceLender.id, '100.00', '合成庚借入');
|
||||
const uploadOnly = (batchId) => {
|
||||
const boundary = `synthetic-${randomUUID()}`;
|
||||
return app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
|
||||
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
||||
};
|
||||
const executeRequest = (batchId) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/execute`, headers: finance.headers, payload: {} });
|
||||
const racePool = new LockPool({ ...pool.options, max: 2 });
|
||||
const raceWaiters = async () => Number((await racePool.query(
|
||||
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n);
|
||||
async function raceWaiting(count) {
|
||||
for (let i = 0; i < 300; i++) { if (await raceWaiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); }
|
||||
throw new Error(`expected ${count} requests queued behind the lender lock`);
|
||||
}
|
||||
try {
|
||||
for (const [order, amount, cents] of [['settle_first', '9.00', '900'], ['upload_first', '8.00', '800']]) {
|
||||
const settling = await repay(raceLender.id, raceLoan.id, amount);
|
||||
await call(manager, 'POST', `/payment-batches/${settling.id}/review`, {}); await exportOrder(settling.id);
|
||||
assert.equal((await receipt(settling.id, cents, `SYNTHETIC-RACE-${order}`, { payeeAccount: raceLender.bankAccount })).result.status, 'matched');
|
||||
const competitor = await repay(raceLender.id, raceLoan.id, amount);
|
||||
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
|
||||
const holder = await racePool.connect();
|
||||
let executed; let uploaded;
|
||||
try {
|
||||
await holder.query('BEGIN');
|
||||
await holder.query('SELECT id FROM loan_lenders WHERE id = $1 FOR UPDATE', [raceLender.id]);
|
||||
if (order === 'settle_first') {
|
||||
executed = executeRequest(settling.id); await raceWaiting(1);
|
||||
uploaded = uploadOnly(competitor.id); await raceWaiting(2);
|
||||
} else {
|
||||
uploaded = uploadOnly(competitor.id); await raceWaiting(1);
|
||||
executed = executeRequest(settling.id); await raceWaiting(2);
|
||||
}
|
||||
await holder.query('COMMIT');
|
||||
[executed, uploaded] = [await executed, await uploaded];
|
||||
} 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'); }
|
||||
// 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: '合成并发验证结束' });
|
||||
}
|
||||
} finally { await racePool.end(); }
|
||||
checks.push('competitor_upload_and_settlement_serialize_on_lender_lock_both_orders');
|
||||
|
||||
// C2: old batch confirmed failed and cancelled; its late success receipt
|
||||
// lands on a new same-amount batch, via OCR and via manual receipt review.
|
||||
const c2old = await repay(probeLender.id, probeLoan.id, '6.00');
|
||||
await call(manager, 'POST', `/payment-batches/${c2old.id}/review`, {});
|
||||
const c2fail = await receipt(c2old.id, '600', 'SYNTHETIC-C2-OLD-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
|
||||
@@ -491,12 +639,65 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
await call(finance, 'POST', `/payment-batches/${c2old.id}/cancel`, { reason: '合成旧批次确认失败' });
|
||||
const c2new = await repay(probeLender.id, probeLoan.id, '6.00');
|
||||
await call(manager, 'POST', `/payment-batches/${c2new.id}/review`, {}); await exportOrder(c2new.id);
|
||||
const beforeC2 = await probeBalance();
|
||||
const c2 = await receipt(c2new.id, '600', 'SYNTHETIC-C2-OLD-LATE-SUCCESS', { payeeAccount: probeLender.bankAccount });
|
||||
reproductions.push({ scenario: 'old_failed_cancelled_then_late_success_receipt_uploaded_to_new_same_amount', match: c2.result.status, execute: await probeExecute(c2new.id) });
|
||||
console.log(JSON.stringify({ stage: 'loan_receipt_attribution_reproduction', reproductions }));
|
||||
checks.push('receipt_batch_attribution_reproduction_recorded');
|
||||
assert.equal(c2.result.status, 'matched');
|
||||
await call(finance, 'POST', `/payment-batches/${c2new.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
assert.equal(await probeBalance(), beforeC2, 'C2 late receipt must not settle the new batch');
|
||||
const c2manualBatch = await repay(probeLender.id, probeLoan.id, '6.00');
|
||||
await call(manager, 'POST', `/payment-batches/${c2manualBatch.id}/review`, {}); await exportOrder(c2manualBatch.id);
|
||||
const beforeManual = await probeBalance();
|
||||
const c2unclear = await receipt(c2manualBatch.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
|
||||
const c2attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: c2unclear.attachment.id } });
|
||||
const c2path = `/payment-batches/${c2manualBatch.id}/receipt-attachments/${c2unclear.attachment.id}/manual-verification`;
|
||||
const c2proposal = await call(dual, 'POST', c2path, { expectedVersion: c2attachment.receipt_ocr_version, reason: '合成人工核对',
|
||||
pages: [{ pageNumber: 1, itemId: c2manualBatch.items[0].id, fields: Object.entries({ 小写金额: '6.00', 币别: 'CNY', 付款人账号: payerAccount,
|
||||
收款人账号: probeLender.bankAccount, 凭证号: 'SYNTHETIC-C2-MANUAL', 单据状态: '交易成功' }).map(([Name, Value]) => ({ Name, Value })) }] });
|
||||
const c2saved = c2proposal.item ?? c2proposal;
|
||||
await call(manager, 'POST', `${c2path}/${c2saved.id}/review`, { expectedVersion: c2saved.version, decision: 'approve', reason: '合成人工复核' });
|
||||
await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
||||
assert.equal(await probeBalance(), beforeManual, 'manual receipt review must not bypass attribution');
|
||||
// The person who keyed the manual receipt cannot also approve its attribution.
|
||||
const c2attribution = await attribute(finance, c2manualBatch.id, {});
|
||||
await reviewAttribution(dual, c2manualBatch.id, c2attribution.id, 'approve', 403);
|
||||
await reviewAttribution(independent, c2manualBatch.id, c2attribution.id, 'approve');
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {})).item.status, 'executed');
|
||||
checks.push('late_receipt_of_cancelled_batch_blocked_via_ocr_and_manual_review');
|
||||
|
||||
// A failed receipt cannot release a reservation while competitors exist.
|
||||
const c2failNew = await repay(probeLender.id, probeLoan.id, '6.00');
|
||||
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/review`, {});
|
||||
const c2failReceipt = await receipt(c2failNew.id, '600', 'SYNTHETIC-C2-AMBIGUOUS-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
|
||||
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failReceipt.attachment.id }, 409, 'loan.receipt_attribution_required');
|
||||
await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '歧义失败不得释放' }, 409, 'loan.bank_state_unsafe');
|
||||
checks.push('ambiguous_failed_receipt_cannot_release_reservation');
|
||||
|
||||
// No competitor: attribution is neither required nor accepted.
|
||||
const soloLender = await verifiedLender('合成出借人己', null, '1');
|
||||
const soloLoan = await confirmedLoan(soloLender.id, '50.00', '合成己借入');
|
||||
const solo = await repay(soloLender.id, soloLoan.id, '4.00');
|
||||
await call(manager, 'POST', `/payment-batches/${solo.id}/review`, {}); await exportOrder(solo.id);
|
||||
await receipt(solo.id, '400', 'SYNTHETIC-SOLO', { payeeAccount: soloLender.bankAccount });
|
||||
await attribute(finance, solo.id, {}, 409, 'loan.receipt_attribution_not_required');
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${solo.id}/execute`, {})).item.status, 'executed');
|
||||
checks.push('unique_amount_settles_without_attribution');
|
||||
// Database guards: reviewed history immutable, reviewer differs from proposer.
|
||||
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { review_note: '改写' } }));
|
||||
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { status: 'rejected' } }));
|
||||
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: reproposal.id }, data: { status: 'approved' } }));
|
||||
await assert.rejects(prisma.loanReceiptAttribution.delete({ where: { id: proposed.id } }));
|
||||
checks.push('attribution_history_immutable_in_database');
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
|
||||
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma });
|
||||
// Attribution UI scenario: a matched receipt on one of two same-amount exported batches.
|
||||
const uiLender = await verifiedLender('合成浏览器归属出借人', null, '0');
|
||||
const uiLoan = await confirmedLoan(uiLender.id, '100.00', '合成浏览器归属借入');
|
||||
const uiExported = async () => { const item = await repay(uiLender.id, uiLoan.id, '5.00');
|
||||
await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); return item; };
|
||||
const uiTarget = await uiExported(); await uiExported();
|
||||
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 });
|
||||
}
|
||||
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
|
||||
} finally { await app.close(); await prisma.$disconnect(); }
|
||||
|
||||
在新工单中引用
屏蔽一个用户