借款对账 S1、S2:导出与银行提交分开登记、确认失败收紧及失败回单识别口径(09-17-company-loan-principal-repayment)
S1(迁移 20261007010000) - 借款导出逐次记录;存量批次保留已知导出次数为历史汇总,不视为真实导出,存在即拦截 - 银行提交登记(只能撤回一次)与提交清单确认;独立复核,复核后任何登记、撤回、导出即失效 - 确认失败只计未作废附件;作废附件无法证明为明确失败或确属他笔付款即阻断 - S3 前临时拦截:多次导出、多次提交、清单未复核或历史汇总均不能确认失败 - 确认失败改按付款后规则;确认人不得兼任导出、提交登记、清单确认 S2(迁移 20261007020000) - 存在同金额候选时须有已复核、覆盖提交清单的失败归属;结算与失败归属分槽 失败回单识别口径(用户同意并入) - 新增原始状态措辞字段(多字段原文不一致即为空);失败事实严格判定:单页完整、 仅“交易失败/转账失败/付款失败/支付失败”、唯一问题为交易失败、字段齐全; 已撤销、已冲正、已退汇不接受;顶层识别状态与成功匹配不变 - 测试改走真实识别与归一化链,仅替换云端 OCR 响应 验证:借款 HTTP 41 组、浏览器 7 组、76 迁移;server 4463;共享回单测试 237; 三轮同模型只读复核的阻断项均已修复。 注意:界面未完成,不能单独部署;真实回单待验。未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -163,18 +163,42 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '提前交接' }, 409, 'loan.handoff_not_ready');
|
||||
checks.push('two_loans_one_payment_partial_allocation_idempotency_and_review');
|
||||
const { runReceiptOcrOnce } = req('./src/jobs/paymentReceiptOcrJob');
|
||||
async function receipt(batchId, amount, reference, overrides = {}) {
|
||||
// Receipts go through the real local chain: PDF decode, recognizePaymentReceipt,
|
||||
// normalizeBankSlip, verification storage, matching and claims. Only the cloud
|
||||
// BankSlipOCR response is synthetic, built from the intended slip fields.
|
||||
const { recognizePaymentReceipt } = req('./src/services/paymentReceiptOcr');
|
||||
const { PDFDocument } = req('pdf-lib');
|
||||
const receiptPdf = async (pages = 1) => { const doc = await PDFDocument.create(); for (let i = 0; i < pages; i++) doc.addPage([200, 200]); return Buffer.from(await doc.save()); };
|
||||
const onePagePdf = await receiptPdf(1);
|
||||
const STATUS_WORDING = { success: '交易成功', failed: '交易失败', pending: '处理中' };
|
||||
function bankSlipFields(slip) {
|
||||
const fields = [];
|
||||
const add = (Name, Value) => { if (Value !== null && Value !== undefined) fields.push({ Name, Value: String(Value) }); };
|
||||
if (slip.amount !== null && slip.amount !== undefined) {
|
||||
const cents = BigInt(slip.amount);
|
||||
add('小写金额', `${cents / 100n}.${String(cents % 100n).padStart(2, '0')}`);
|
||||
}
|
||||
add('币别', slip.currency === 'CNY' ? '人民币' : slip.currency);
|
||||
add('付款人账号', slip.payerAccount); add('收款人账号', slip.payeeAccount); add('凭证号', slip.reference);
|
||||
for (const wording of slip.statusWordings ?? (slip.transactionStatus ? [STATUS_WORDING[slip.transactionStatus]] : [])) {
|
||||
add(fields.some((field) => field.Name === '单据状态') ? '交易状态' : '单据状态', wording);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
async function receipt(batchId, amount, reference, overrides = {}, { pdfPages = 1, normalizedPage = false } = {}) {
|
||||
const boundary = `synthetic-${randomUUID()}`;
|
||||
const file = pdfPages === 1 ? onePagePdf : await receiptPdf(pdfPages);
|
||||
const response = await 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`)]) });
|
||||
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`), file, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
||||
assert.equal(response.statusCode, 200, 'receipt upload');
|
||||
const recognition = { status: 'recognized', pagesExpected: 1, pagesProcessed: 1,
|
||||
pages: [{ pageNumber: 1, amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount,
|
||||
transactionStatus: 'success', reference, issues: [], ...overrides }] };
|
||||
// Only the cloud recognizer is replaced. Queue claiming, local storage,
|
||||
// normalization, verification, transaction claims and HTTP writes are real.
|
||||
const result = await runReceiptOcrOnce({ prisma, config, recognize: async () => recognition });
|
||||
const slip = { amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount, transactionStatus: 'success', reference, ...overrides };
|
||||
// normalizedPage: one explicitly synthetic defence-in-depth case that the
|
||||
// real normalizer cannot produce; every other receipt uses the real chain.
|
||||
const recognize = normalizedPage
|
||||
? async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, issues: [], ...slip }] })
|
||||
: (args) => recognizePaymentReceipt({ ...args, createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: bankSlipFields(slip) }) }) });
|
||||
const result = await runReceiptOcrOnce({ prisma, config, recognize });
|
||||
return { attachment: response.json().data.items[0], result };
|
||||
}
|
||||
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
|
||||
@@ -221,11 +245,24 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
await call(finance, 'POST', `/payment-batches/${winner.id}/cancel`, { reason: '合成未发送取消' });
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80');
|
||||
checks.push('duplicate_transaction_refusal_real_http_concurrency_and_safe_cancel');
|
||||
// S1: failure needs a registered bank submission whose completeness was
|
||||
// independently attested (registrar/proposer finance, reviewer `by`).
|
||||
async function attestSubmission(batchId, { by = manager, registrar = finance } = {}) {
|
||||
const exported = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: batchId }, orderBy: { sequence: 'desc' } });
|
||||
const submission = await call(registrar, 'POST', `/payment-batches/${batchId}/loan-bank-submissions`, {
|
||||
...(exported ? { exportId: exported.id, channel: 'online_banking_import' } : { channel: 'manual_entry' }),
|
||||
submittedAt: new Date().toISOString(), bankInstructionRef: `SYN-INSTR-${batchId.slice(0, 8)}`, note: '合成网银提交登记' });
|
||||
const attestation = await call(finance, 'POST', `/payment-batches/${batchId}/loan-submission-attestation`, { note: '合成提交清单完整' });
|
||||
await call(by, 'POST', `/payment-batches/${batchId}/loan-submission-attestation/${attestation.id}/review`, { decision: 'approve', reason: '合成清单复核' });
|
||||
return { submission, attestation };
|
||||
}
|
||||
const { item: failedBank } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '10.00' }] });
|
||||
await call(manager, 'POST', `/payment-batches/${failedBank.id}/review`, {});
|
||||
const failureReceipt = await receipt(failedBank.id, '1000', 'SYNTHETIC-FAILED', { transactionStatus: 'failed', issues: ['transaction_failed'] });
|
||||
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
|
||||
await call(finance, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 403);
|
||||
await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 409, 'loan.submission_list_unverified');
|
||||
await attestSubmission(failedBank.id);
|
||||
await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id });
|
||||
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
|
||||
await call(finance, 'POST', `/payment-batches/${failedBank.id}/cancel`, { reason: '合成银行明确失败' });
|
||||
@@ -299,12 +336,12 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 403);
|
||||
// Fresh lenders below: the first lender keeps unknown-state reservations from
|
||||
// the negative receipt loop above, which by design block its maintenance.
|
||||
async function verifiedLender(name, employeeId, digit) {
|
||||
async function verifiedLender(name, employeeId, digit, account = digit.repeat(16)) {
|
||||
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name,
|
||||
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: digit.repeat(16), accountName: name });
|
||||
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: account, accountName: name });
|
||||
const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, {
|
||||
expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' });
|
||||
return { ...confirmedLender, bankAccount: digit.repeat(16) };
|
||||
return { ...confirmedLender, bankAccount: account };
|
||||
}
|
||||
async function confirmedLoan(lenderId, principal, ref) {
|
||||
const created = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId, originReference: ref,
|
||||
@@ -630,11 +667,177 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
} finally { await racePool.end(); }
|
||||
checks.push('competitor_upload_and_settlement_serialize_on_lender_lock_both_orders');
|
||||
|
||||
// S1: exports and bank submissions are separate facts; failure confirmation
|
||||
// is gated on one attested submission and on voided receipts being resolved.
|
||||
const s1Lender = await verifiedLender('合成出借人辛', null, '1', '1357913579135791');
|
||||
const s1Loan = await confirmedLoan(s1Lender.id, '1000.00', '合成辛借入');
|
||||
const s1Batch = async (amount) => { const item = await repay(s1Lender.id, s1Loan.id, amount); await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); return item; };
|
||||
const failReceipt = (batchId, cents, ref) => receipt(batchId, cents, ref, { payeeAccount: s1Lender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
|
||||
const confirmFailure = (batchId, attachmentId, status = 200, reason) => call(manager, 'POST', `/payment-batches/${batchId}/bank-failure-review`, { attachmentId }, status, reason);
|
||||
// Two exports: recorded separately; one failed receipt cannot close both.
|
||||
const twice = await s1Batch('11.00');
|
||||
await exportOrder(twice.id); await exportOrder(twice.id);
|
||||
const exportsState = (await detail(twice.id)).loanRepayment.bankSubmissions.exports;
|
||||
assert.deepEqual(exportsState.map((item) => item.sequence), [1, 2]);
|
||||
assert.ok(exportsState.every((item) => Array.isArray(item.customerSerials) && item.customerSerials.length === 1));
|
||||
const twiceFail = await failReceipt(twice.id, '1100', 'SYNTHETIC-S1-TWICE');
|
||||
await attestSubmission(twice.id);
|
||||
await confirmFailure(twice.id, twiceFail.attachment.id, 409, 'loan.multiple_submissions_unresolved');
|
||||
checks.push('two_exports_recorded_and_single_failure_cannot_close_them');
|
||||
// A voided receipt that once showed success or pending keeps blocking.
|
||||
for (const [label, status, cents] of [['success', 'success', '1200'], ['pending', 'pending', '1300']]) {
|
||||
const voidedCase = await s1Batch(String(Number(cents) / 100) + '.00');
|
||||
const earlier = await receipt(voidedCase.id, cents, `SYNTHETIC-S1-VOIDED-${label}`, { payeeAccount: s1Lender.bankAccount,
|
||||
transactionStatus: status, issues: status === 'pending' ? ['transaction_pending'] : [] });
|
||||
await call(finance, 'POST', `/payment-batches/${voidedCase.id}/receipt-attachments/${earlier.attachment.id}/void`, { reason: '合成作废' });
|
||||
const laterFail = await failReceipt(voidedCase.id, cents, `SYNTHETIC-S1-AFTER-${label}`);
|
||||
await attestSubmission(voidedCase.id);
|
||||
await confirmFailure(voidedCase.id, laterFail.attachment.id, 409, 'loan.voided_receipt_unresolved');
|
||||
}
|
||||
checks.push('voided_success_or_pending_receipt_keeps_blocking_failure');
|
||||
// Voided material fails closed: without readable proof that it is a failure
|
||||
// or a clearly different transaction, it keeps blocking.
|
||||
const voidedBlocks = [
|
||||
['before_ocr', null],
|
||||
['unreadable', { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }],
|
||||
['ambiguous_status', { transactionStatus: null, statusWordings: ['交易失败', '处理中'] }],
|
||||
// Explicitly synthetic (real normalization cannot produce it): defence in depth.
|
||||
['failed_with_pending_issue', { transactionStatus: 'failed', transactionStatusRaw: '交易失败', issues: ['transaction_failed', 'transaction_pending'] }],
|
||||
// Same payer and payee, different amount: possibly a misread of this payment.
|
||||
['same_accounts_other_amount', { amount: '1' }],
|
||||
// A voided "revoked" slip is not a clear failure: it keeps blocking.
|
||||
['voided_revoked', { transactionStatus: 'failed', statusWordings: ['已撤销'] }],
|
||||
['voided_failed_plus_revoked', { transactionStatus: 'failed', statusWordings: ['交易失败', '已撤销'] }],
|
||||
];
|
||||
const voidedAmounts = { before_ocr: '17', unreadable: '18', ambiguous_status: '19', failed_with_pending_issue: '21', same_accounts_other_amount: '22', voided_revoked: '23', voided_failed_plus_revoked: '24' };
|
||||
for (const [label, overrides] of voidedBlocks) {
|
||||
const blocked = await s1Batch(`${voidedAmounts[label]}.00`);
|
||||
const cents = `${voidedAmounts[label]}00`;
|
||||
let voidedId;
|
||||
if (overrides === null) {
|
||||
const boundary = `synthetic-${randomUUID()}`;
|
||||
const raw = await app.inject({ method: 'POST', url: `/api/payment-batches/${blocked.id}/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`)]) });
|
||||
voidedId = raw.json().data.items[0].id;
|
||||
} else {
|
||||
voidedId = (await receipt(blocked.id, cents, `SYNTHETIC-S1-${label}`, { payeeAccount: s1Lender.bankAccount, ...overrides },
|
||||
{ normalizedPage: label === 'failed_with_pending_issue' })).attachment.id;
|
||||
}
|
||||
await call(finance, 'POST', `/payment-batches/${blocked.id}/receipt-attachments/${voidedId}/void`, { reason: '合成作废' });
|
||||
const laterFail = await failReceipt(blocked.id, cents, `SYNTHETIC-S1-AFTER-${label}`);
|
||||
await attestSubmission(blocked.id);
|
||||
await confirmFailure(blocked.id, laterFail.attachment.id, 409, 'loan.voided_receipt_unresolved');
|
||||
}
|
||||
checks.push('voided_without_readable_proof_fails_closed');
|
||||
// A voided, readable success receipt of a different payment no longer blocks;
|
||||
// attestation independence; any later add or withdraw invalidates approval.
|
||||
const misupload = await s1Batch('14.00');
|
||||
const otherPayment = await receipt(misupload.id, '9900', 'SYNTHETIC-S1-OTHER-PAYMENT', { payeeAccount: '9'.repeat(16) });
|
||||
await call(finance, 'POST', `/payment-batches/${misupload.id}/receipt-attachments/${otherPayment.attachment.id}/void`, { reason: '合成误传他笔回单' });
|
||||
const misuploadFail = await failReceipt(misupload.id, '1400', 'SYNTHETIC-S1-MISUPLOAD');
|
||||
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成无提交清单' }, 409, 'loan.submission_required');
|
||||
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'online_banking_import',
|
||||
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-NO-EXPORT', note: '合成网银导入无导出' }, 409, 'loan.submission_export_invalid');
|
||||
const registrarDual = await actor(['finance', 'manager']);
|
||||
const firstSubmission = await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'manual_entry',
|
||||
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-FIRST', note: '合成手工提交登记' });
|
||||
await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'manual_entry',
|
||||
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-MANAGER', note: '合成经理不能登记' }, 403);
|
||||
// Registered but not yet attested: still refused.
|
||||
await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified');
|
||||
const misAttest = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成提交清单完整' });
|
||||
await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified');
|
||||
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成重复提交清单' }, 409, 'loan.submission_attestation_exists');
|
||||
await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${misAttest.id}/review`, { decision: 'approve', reason: '合成登记人自审' }, 403);
|
||||
await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${misAttest.id}/review`, { decision: 'approve', reason: '合成清单复核' });
|
||||
const extra = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'other',
|
||||
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-EXTRA', note: '合成补登第二次提交' });
|
||||
await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.multiple_submissions_unresolved');
|
||||
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成误登撤回' });
|
||||
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成重复撤回' }, 409);
|
||||
// The unreviewed add-then-withdraw does not revive the earlier approval.
|
||||
assert.equal((await detail(misupload.id)).loanRepayment.bankSubmissions.attestation.stale, true);
|
||||
await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified');
|
||||
const reAttest = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成撤回后重新确认清单' });
|
||||
assert.equal((await prisma.loanBankSubmissionAttestation.findUniqueOrThrow({ where: { id: misAttest.id } })).status, 'superseded');
|
||||
await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${reAttest.id}/review`, { decision: 'approve', reason: '合成重新复核' });
|
||||
// The submission registrar (even with manager rights) cannot confirm the failure.
|
||||
await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/bank-failure-review`, { attachmentId: misuploadFail.attachment.id }, 403);
|
||||
await confirmFailure(misupload.id, misuploadFail.attachment.id);
|
||||
await call(finance, 'POST', `/payment-batches/${misupload.id}/cancel`, { reason: '合成确认失败后释放' });
|
||||
assert.ok(firstSubmission.id);
|
||||
checks.push('voided_other_payment_ignored_attestation_independent_and_any_list_change_invalidates');
|
||||
// A pre-existing batch carries only a known export count: never treated as
|
||||
// a real export or submission; failure confirmation stays blocked.
|
||||
const legacyBatch = await s1Batch('15.00');
|
||||
await prisma.$executeRaw`INSERT INTO loan_legacy_export_summaries (payment_batch_id, known_export_count) VALUES (${legacyBatch.id}::uuid, 2)`;
|
||||
const legacyFail = await failReceipt(legacyBatch.id, '1500', 'SYNTHETIC-S1-LEGACY');
|
||||
const legacyState = (await detail(legacyBatch.id)).loanRepayment.bankSubmissions;
|
||||
assert.equal(legacyState.legacy.knownExportCount, 2); assert.equal(legacyState.exports.length, 0);
|
||||
await confirmFailure(legacyBatch.id, legacyFail.attachment.id, 409, 'loan.submission_history_unverified');
|
||||
checks.push('legacy_export_summary_keeps_known_count_and_blocks_failure');
|
||||
// Failure confirmation is a post-payment fact: a departed lender employee
|
||||
// does not block it; the confirming reviewer is still judged currently.
|
||||
const departingLender = await actor(['employee']);
|
||||
const staffFailLender = await verifiedLender('合成出借人壬', departingLender.id, '2', '2468024680246802');
|
||||
const staffFailLoan = await confirmedLoan(staffFailLender.id, '100.00', '合成壬借入');
|
||||
const staffFail = await repay(staffFailLender.id, staffFailLoan.id, '16.00');
|
||||
await call(manager, 'POST', `/payment-batches/${staffFail.id}/review`, {});
|
||||
const staffFailReceipt = await receipt(staffFail.id, '1600', 'SYNTHETIC-S1-DEPARTED', { payeeAccount: staffFailLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
|
||||
await attestSubmission(staffFail.id);
|
||||
await setStatus(departingLender, 'archived');
|
||||
await call(manager, 'POST', `/payment-batches/${staffFail.id}/bank-failure-review`, { attachmentId: staffFailReceipt.attachment.id });
|
||||
await setStatus(departingLender, 'active');
|
||||
checks.push('failure_confirmation_after_lender_employee_departure');
|
||||
// Failure wording: only the four "failed" wordings prove no money left;
|
||||
// revoked/reversed/returned, incomplete or mismatched slips are refused.
|
||||
const wordingCases = [
|
||||
['转账失败', {}, 200, null],
|
||||
['付款失败', {}, 200, null],
|
||||
['支付失败', {}, 200, null],
|
||||
// Two status fields that both normalize to "failed": refused in either order.
|
||||
['交易失败', { statusWordings: ['交易失败', '已撤销'] }, 409, 'loan.bank_state_unsafe'],
|
||||
['已撤销', { statusWordings: ['已撤销', '交易失败'] }, 409, 'loan.bank_state_unsafe'],
|
||||
['已撤销', {}, 409, 'loan.bank_state_unsafe'],
|
||||
['已冲正', {}, 409, 'loan.bank_state_unsafe'],
|
||||
['已退汇', {}, 409, 'loan.bank_state_unsafe'],
|
||||
['交易失败', { reference: null }, 409, 'loan.bank_state_unsafe'],
|
||||
['交易失败', { pdfPages: 2 }, 409, 'loan.bank_state_unsafe'],
|
||||
['交易失败', { amount: '1' }, 409, 'loan.bank_state_unsafe'],
|
||||
// Explicitly synthetic: an extra issue besides the failure itself.
|
||||
['交易失败', { normalizedPage: true, transactionStatusRaw: '交易失败', issues: ['transaction_failed', 'payerAccount_ambiguous'] }, 409, 'loan.bank_state_unsafe'],
|
||||
];
|
||||
let wordingAmount = 30;
|
||||
for (const [wording, variant, status, reason] of wordingCases) {
|
||||
wordingAmount += 1;
|
||||
const item = await s1Batch(`${wordingAmount}.00`);
|
||||
const { pdfPages, normalizedPage, ...slip } = variant;
|
||||
const failed = await receipt(item.id, `${wordingAmount}00`, `SYNTHETIC-WORDING-${wordingAmount}`,
|
||||
{ payeeAccount: s1Lender.bankAccount, transactionStatus: 'failed', statusWordings: [wording], ...slip }, { pdfPages: pdfPages ?? 1, normalizedPage });
|
||||
assert.notEqual(failed.result.status, 'matched');
|
||||
await attestSubmission(item.id);
|
||||
await confirmFailure(item.id, failed.attachment.id, status, reason);
|
||||
}
|
||||
checks.push('only_four_failure_wordings_with_complete_single_page_slip_confirm_failure');
|
||||
// History guards in the database.
|
||||
const anyExport = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: twice.id } });
|
||||
await assert.rejects(prisma.paymentOrderExport.update({ where: { id: anyExport.id }, data: { sequence: 9 } }));
|
||||
await assert.rejects(prisma.paymentOrderExport.delete({ where: { id: anyExport.id } }));
|
||||
await assert.rejects(prisma.loanBankSubmission.update({ where: { id: firstSubmission.id }, data: { note: '合成改写提交' } }));
|
||||
await assert.rejects(prisma.loanLegacyExportSummary.update({ where: { payment_batch_id: legacyBatch.id }, data: { known_export_count: 1 } }));
|
||||
await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: misAttest.id }, data: { status: 'rejected' } }));
|
||||
await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: reAttest.id }, data: { all_submission_ids: [] } }));
|
||||
await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: reAttest.id }, data: { export_ids: ['00000000-0000-0000-0000-000000000000'] } }));
|
||||
await assert.rejects(prisma.loanBankSubmission.update({ where: { id: extra.id }, data: { status: 'active', withdrawn_at: null, withdrawn_reason: null } }));
|
||||
checks.push('reconciliation_history_append_only_in_database');
|
||||
|
||||
// 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'] });
|
||||
await attestSubmission(c2old.id);
|
||||
await call(manager, 'POST', `/payment-batches/${c2old.id}/bank-failure-review`, { attachmentId: c2fail.attachment.id });
|
||||
await call(finance, 'POST', `/payment-batches/${c2old.id}/cancel`, { reason: '合成旧批次确认失败' });
|
||||
const c2new = await repay(probeLender.id, probeLoan.id, '6.00');
|
||||
@@ -668,9 +871,27 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
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');
|
||||
const c2failAttachment = c2failReceipt.attachment.id;
|
||||
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }, 409, 'loan.submission_list_unverified');
|
||||
const failureAttribution = { kind: 'failure', attachmentId: c2failAttachment, basisKind: 'bank_instruction_ref',
|
||||
basisNote: '合成失败回单载明本批次网银指令号', exclusionNote: '合成其他同额批次的指令号与本回单不同' };
|
||||
await attribute(finance, c2failNew.id, failureAttribution, 409, 'loan.submission_list_unverified');
|
||||
await attestSubmission(c2failNew.id);
|
||||
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }, 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');
|
||||
// S2: a reviewed failure attribution covering the attested submission unlocks it.
|
||||
const failureProposal = await attribute(finance, c2failNew.id, failureAttribution);
|
||||
await attribute(finance, c2failNew.id, failureAttribution, 409, 'loan.receipt_attribution_exists');
|
||||
await reviewAttribution(finance, c2failNew.id, failureProposal.id, 'approve', 403);
|
||||
await reviewAttribution(independent, c2failNew.id, failureProposal.id, 'approve');
|
||||
const settlementSlot = (await detail(c2failNew.id)).loanRepayment.receiptAttribution.current;
|
||||
assert.equal(settlementSlot, null, 'failure attribution does not occupy the settlement slot');
|
||||
const beforeFailureRelease = (await call(finance, 'GET', `/company-loans/${probeLoan.id}`)).available;
|
||||
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment });
|
||||
await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '合成失败归属确认后释放' });
|
||||
assert.equal(Number((await call(finance, 'GET', `/company-loans/${probeLoan.id}`)).available), Number(beforeFailureRelease) + 6);
|
||||
checks.push('failure_attribution_reviewed_unlocks_ambiguous_failure_release');
|
||||
|
||||
// No competitor: attribution is neither required nor accepted.
|
||||
const soloLender = await verifiedLender('合成出借人己', null, '1');
|
||||
|
||||
在新工单中引用
屏蔽一个用户