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