- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效 - 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离 - 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页 - 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表) - 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册 Co-Authored-By: Claude Opus 5.5 <[email protected]>
290 行
24 KiB
JavaScript
290 行
24 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { randomUUID } = require('node:crypto');
|
|
const { setTimeout: delay } = require('node:timers/promises');
|
|
const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs');
|
|
const { NOTIFICATION_TYPES, IM_SUPPORTED_NOTIFICATION_TYPES } = require('../../server/src/constants/notificationTypes');
|
|
|
|
module.exports = async function lenderAckFixture(h) {
|
|
const { prisma, pool, app, call, finance, manager, ordinary, actor, verifiedLender, repay } = h;
|
|
const checks = [];
|
|
const root = '/loan-lender-acknowledgements';
|
|
async function upload(loan, kind = 'incoming') {
|
|
const boundary = `synthetic-${randomUUID()}`;
|
|
const buffer = await syntheticBankReceiptPdf({ reference: `ACK-${randomUUID()}`, amount: '100.00', direction: 'credit' });
|
|
const res = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${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,
|
|
Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
|
assert.equal(res.statusCode, 200, `upload ${kind}`); return { evidence: res.json().data, buffer };
|
|
}
|
|
async function loan(lender, extra = {}, withOriginal = true) {
|
|
const value = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id,
|
|
originReference: `SYN-ACK-${randomUUID()}`, originalPrincipal: '100.00', openingOutstandingPrincipal: '100.00',
|
|
receivedDate: '2025-03-01', balanceAsOf: '2026-01-01', historicalEntry: true, ...extra });
|
|
if (withOriginal) value.original = (await upload(value)).evidence;
|
|
return value;
|
|
}
|
|
async function setup(role = 'employee', count = 1) {
|
|
const signer = await actor([role]);
|
|
const lender = await verifiedLender(`合成声明出借人-${randomUUID().slice(0, 6)}`, signer.id, '4');
|
|
const loans = []; for (let i = 0; i < count; i++) loans.push(await loan(lender));
|
|
return { signer, lender, loans };
|
|
}
|
|
const body = (s, selected = s.loans) => ({ lenderId: s.lender.id, expectedIdentityVersion: s.lender.identityVersion,
|
|
loans: selected.map((l) => ({ id: l.id, version: l.version })), requestKey: randomUUID(), reason: '合成请本人核对借款事实和未还本金' });
|
|
const propose = (s) => call(finance, 'POST', root, body(s));
|
|
const decisionBody = (record, action = 'confirm') => ({ action, contentHash: record.contentHash,
|
|
reason: action === 'confirm' ? '合成本人已核对并确认' : '合成金额有误退回核对', originalChecked: action === 'confirm' });
|
|
const decide = (s, record, action = 'confirm', status = 200, reason) => call(s.signer, 'POST', `${root}/${record.id}/decision`, decisionBody(record, action), status, reason);
|
|
const confirmBalance = (l, status = 200) => call(finance, 'POST', `/company-loans/${l.id}/confirm`, {
|
|
expectedVersion: l.version, accountingConfirmationRef: '合成代账明细账科目及期间引用',
|
|
}, status, status === 409 ? 'loan.evidence_required' : undefined);
|
|
|
|
const s = await setup('manager', 2); const input = body(s);
|
|
const a = await call(finance, 'POST', root, input);
|
|
assert.equal(a.status, 'pending'); assert.equal(a.loans.length, 2); assert.equal(a.canDecide, false);
|
|
assert.equal((await call(finance, 'POST', root, input)).id, a.id);
|
|
await call(finance, 'POST', root, { ...input, reason: '合成换参数重用请求键' }, 409, 'loan.duplicate_registration');
|
|
await call(finance, 'POST', root, body(s), 409, 'loan.lender_ack_exists');
|
|
const noticeType = NOTIFICATION_TYPES.loanLenderAcknowledgementRequested;
|
|
const notices = await prisma.notification.findMany({ where: { target_type: 'loan_lender_acknowledgement', target_id: a.id } });
|
|
assert.equal(notices.length, 1); assert.equal(notices[0].recipient_employee_id, s.signer.id); assert.equal(notices[0].type, noticeType);
|
|
assert.ok(!IM_SUPPORTED_NOTIFICATION_TYPES.includes(noticeType), 'lender statement notice stays in OA');
|
|
assert.equal(await prisma.notificationDelivery.count({ where: { notification_id: notices[0].id } }), 0);
|
|
const inbox = await call(s.signer, 'GET', `/notifications?type=${noticeType}`);
|
|
assert.equal(inbox.items.filter(item => item.targetId === a.id).length, 1);
|
|
for (const who of [ordinary, finance, manager]) {
|
|
assert.equal((await call(who, 'GET', `/notifications?type=${noticeType}`)).items.filter(item => item.targetId === a.id).length, 0);
|
|
}
|
|
const noticeCopy = notices[0].title + notices[0].body;
|
|
for (const privateValue of [s.lender.bankAccount, s.lender.name, '100.00']) assert.ok(!noticeCopy.includes(privateValue));
|
|
checks.push('proposal_notice_only_for_actual_lender_no_duplicate_on_retry_or_external_delivery');
|
|
assert.ok(!JSON.stringify(a).includes(s.lender.bankAccount));
|
|
for (const item of a.loans) { assert.equal(item.originalPrincipal, '100.00'); assert.match(item.originals[0].sha256, /^[a-f0-9]{64}$/); }
|
|
checks.push('same_lender_multi_loan_snapshot_request_binding_and_duplicate_refusal');
|
|
|
|
await call(null, 'GET', root, undefined, 401);
|
|
await call(ordinary, 'POST', root, input, 403);
|
|
assert.equal((await call(ordinary, 'GET', root)).items.length, 0);
|
|
for (const who of [ordinary, manager]) {
|
|
await call(who, 'GET', `${root}/${a.id}`, undefined, 404);
|
|
await call(who, 'POST', `${root}/${a.id}/decision`, decisionBody(a), 404);
|
|
}
|
|
await call(finance, 'POST', `${root}/${a.id}/decision`, decisionBody(a), 403);
|
|
await call(s.signer, 'GET', '/company-loans', undefined, 403);
|
|
await call(s.signer, 'POST', `${root}/${a.id}/decision`, { ...decisionBody(a), originalChecked: false }, 400);
|
|
await call(s.signer, 'POST', `${root}/${a.id}/decision`, { ...decisionBody(a), contentHash: '0'.repeat(64) }, 409, 'loan.lender_ack_changed');
|
|
const download = (who, l, evidence, extra) => app.inject({ method: 'POST', url: `/api/company-loans/${l.id}/evidences/${evidence.id}/download`,
|
|
headers: who.headers, payload: { reason: '合成核对本人原借入凭据', lenderAcknowledgementId: a.id, ...extra } });
|
|
assert.equal((await download(s.signer, s.loans[0], s.loans[0].original)).statusCode, 200);
|
|
assert.equal((await download(ordinary, s.loans[0], s.loans[0].original)).statusCode, 404);
|
|
const accounting = await upload(s.loans[0], 'accounting_confirmation');
|
|
assert.equal((await download(s.signer, s.loans[0], accounting.evidence)).statusCode, 404);
|
|
assert.equal((await download(s.signer, s.loans[0], s.loans[0].original, { paymentBatchId: randomUUID() })).statusCode, 400);
|
|
checks.push('only_actual_lender_can_read_originals_and_decide_no_finance_or_representative_substitution');
|
|
|
|
await confirmBalance(s.loans[0], 409); // Accounting alone cannot replace agreement/ack.
|
|
await decide(s, a); await decide(s, a); // Same decision retry is idempotent.
|
|
await decide(s, a, 'return', 409, 'loan.lender_ack_decided');
|
|
await confirmBalance(s.loans[1], 409); // Valid ack cannot replace accounting.
|
|
await upload(s.loans[1], 'accounting_confirmation');
|
|
for (const l of s.loans) {
|
|
const value = await confirmBalance(l); assert.equal(value.confirmationStatus, 'confirmed');
|
|
assert.equal(value.lenderAcknowledgement.id, a.id);
|
|
}
|
|
assert.equal((await call(s.signer, 'GET', `${root}/${a.id}`)).status, 'confirmed');
|
|
const authority = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: s.lender.id,
|
|
loanIds: s.loans.map((l) => l.id), basis: '合成已有公司代表权及指定借款范围依据', requestKey: randomUUID() });
|
|
await call(s.signer, 'POST', `/loan-related-authorizations/${authority.id}/actions`, { action: 'confirm', reason: '合成以公司代表身份单独确认授权' });
|
|
const batch = await repay(s.lender.id, s.loans[0].id, '10.00');
|
|
assert.equal(batch.loanRepayment.approvalMode, 'related_party');
|
|
await call(s.signer, 'POST', `/payment-batches/${batch.id}/review`, {});
|
|
await h.exportOrder(batch.id);
|
|
checks.push('online_ack_replaces_only_agreement_and_reaches_separate_authority_repayment_review_and_export');
|
|
|
|
const ordinaryCase = await setup(); const ordinaryAck = await propose(ordinaryCase);
|
|
assert.equal((await call(ordinaryCase.signer, 'GET', `${root}/${ordinaryAck.id}`)).canDecide, true);
|
|
await decide(ordinaryCase, ordinaryAck, 'return');
|
|
assert.equal((await call(finance, 'GET', `${root}/${ordinaryAck.id}`)).status, 'returned');
|
|
const resent = await propose(ordinaryCase); await decide(ordinaryCase, resent);
|
|
assert.notEqual(resent.id, ordinaryAck.id);
|
|
checks.push('ordinary_employee_lender_can_return_and_confirm_new_request_without_manager_permission');
|
|
|
|
const partial = await loan(ordinaryCase.lender, { openingOutstandingPrincipal: '80.00' });
|
|
const partialScope = { ...ordinaryCase, loans: [partial] }; const partialAck = await propose(partialScope); await decide(partialScope, partialAck);
|
|
await upload(partial, 'accounting_confirmation'); await confirmBalance(partial, 409);
|
|
await upload(partial, 'adjustment'); await confirmBalance(partial);
|
|
const paper = await loan(ordinaryCase.lender); await upload(paper, 'agreement'); await upload(paper, 'accounting_confirmation'); await confirmBalance(paper);
|
|
checks.push('outside_repayment_evidence_still_required_and_paper_agreement_remains_valid');
|
|
|
|
const missing = await loan(ordinaryCase.lender, {}, false);
|
|
await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing] }), 409, 'loan.lender_ack_scope_invalid');
|
|
await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing, s.loans[0]] }), 409, 'loan.lender_ack_scope_invalid');
|
|
await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing, missing] }), 409, 'loan.lender_ack_scope_invalid');
|
|
const ownLender = await verifiedLender('合成财务本人出借人', finance.id, '2');
|
|
const ownLoan = await loan(ownLender);
|
|
await call(finance, 'POST', root, body({ lender: ownLender, loans: [ownLoan] }), 409, 'loan.lender_ack_scope_invalid');
|
|
checks.push('scope_requires_distinct_maker_verified_lender_same_company_unconfirmed_loans_and_originals');
|
|
|
|
await assert.rejects(prisma.loanLenderAcknowledgement.update({ where: { id: a.id }, data: { reason: '合成篡改声明' } }));
|
|
await assert.rejects(prisma.loanLenderAcknowledgementLoan.update({ where: { acknowledgement_id_loan_id: { acknowledgement_id: a.id, loan_id: s.loans[0].id } }, data: { snapshot_json: {} } }));
|
|
await assert.rejects(prisma.loanLenderAcknowledgementDecision.update({ where: { acknowledgement_id: a.id }, data: { reason: '合成篡改决定' } }));
|
|
await assert.rejects(prisma.loanLenderAcknowledgementDecision.delete({ where: { acknowledgement_id: a.id } }));
|
|
await assert.rejects(prisma.loanLenderAcknowledgementLoan.deleteMany({ where: { acknowledgement_id: a.id } }));
|
|
checks.push('database_rejects_snapshot_decision_and_scope_history_rewrites_or_deletion');
|
|
|
|
for (const [column, changed] of [['original_principal', '110.00'], ['opening_outstanding_principal', '90.00'], ['received_date', '2025-03-02'], ['balance_as_of', '2026-01-02']]) {
|
|
const l = await loan(ordinaryCase.lender); const itemScope = { ...ordinaryCase, loans: [l] }; const record = await propose(itemScope); await decide(itemScope, record);
|
|
const before = (await pool.query(`SELECT ${column}::text AS value FROM company_loans WHERE id=$1`, [l.id])).rows[0].value;
|
|
await pool.query(`UPDATE company_loans SET ${column}=$1 WHERE id=$2`, [changed, l.id]);
|
|
await pool.query(`UPDATE company_loans SET ${column}=$1 WHERE id=$2`, [before, l.id]);
|
|
assert.equal((await call(ordinaryCase.signer, 'GET', `${root}/${record.id}`)).status, 'invalid');
|
|
await upload(l, 'accounting_confirmation'); await confirmBalance(l, 409);
|
|
await assert.rejects(prisma.loanLenderAcknowledgement.update({ where: { id: record.id }, data: { invalidated_at: null, invalid_reason: null } }));
|
|
}
|
|
checks.push('each_amount_and_date_change_permanently_invalidates_even_after_restoring_old_values');
|
|
|
|
const changedOriginal = await setup('employee', 2); const originalAck = await propose(changedOriginal); await decide(changedOriginal, originalAck);
|
|
const addition = await upload(changedOriginal.loans[0]);
|
|
await prisma.loanEvidence.update({ where: { id: addition.evidence.id }, data: { archived_at: new Date() } });
|
|
const oldRead = await call(changedOriginal.signer, 'GET', `${root}/${originalAck.id}`);
|
|
assert.equal(oldRead.status, 'invalid'); assert.equal(oldRead.canReadOriginals, false);
|
|
for (const l of changedOriginal.loans) { await upload(l, 'accounting_confirmation'); await confirmBalance(l, 409); }
|
|
const identityCase = await setup(); const identityAck = await propose(identityCase); await decide(identityCase, identityAck);
|
|
await prisma.loanLender.update({ where: { id: identityCase.lender.id }, data: { identity_version: { increment: 1 } } });
|
|
await prisma.loanLender.update({ where: { id: identityCase.lender.id }, data: { identity_version: identityCase.lender.identityVersion } });
|
|
assert.equal((await call(identityCase.signer, 'GET', `${root}/${identityAck.id}`)).status, 'invalid');
|
|
checks.push('original_or_identity_changes_invalidate_whole_group_and_cannot_reactivate');
|
|
|
|
const rollbackCase = await setup(); const rollbackBody = body(rollbackCase);
|
|
async function auditFailure(action, run) {
|
|
await pool.query(`CREATE FUNCTION synthetic_fail_lender_ack_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='${action}' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$`);
|
|
await pool.query('CREATE TRIGGER synthetic_fail_lender_ack_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_lender_ack_audit()');
|
|
try { await run(); } finally { await pool.query('DROP TRIGGER synthetic_fail_lender_ack_audit ON audit_logs'); await pool.query('DROP FUNCTION synthetic_fail_lender_ack_audit()'); }
|
|
}
|
|
await auditFailure('LOAN_LENDER_ACK_PROPOSED', () => call(finance, 'POST', root, rollbackBody, 500));
|
|
assert.equal(await prisma.loanLenderAcknowledgement.count({ where: { request_key: rollbackBody.requestKey } }), 0);
|
|
assert.equal(await prisma.notification.count({ where: { recipient_employee_id: rollbackCase.signer.id, type: noticeType } }), 0);
|
|
const rollbackAck = await call(finance, 'POST', root, rollbackBody);
|
|
await auditFailure('LOAN_LENDER_ACK_CONFIRMED', () => decide(rollbackCase, rollbackAck, 'confirm', 500));
|
|
assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: rollbackAck.id } }), 0);
|
|
checks.push('proposal_and_decision_audit_failures_roll_back_completely');
|
|
|
|
const notificationFailure = await setup(); const notificationBody = body(notificationFailure);
|
|
const auditsBefore = await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_ACK_PROPOSED' } });
|
|
await pool.query(`CREATE FUNCTION synthetic_fail_ack_notice() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.type='${noticeType}' THEN RAISE EXCEPTION 'synthetic notification failure'; END IF; RETURN NEW; END $$`);
|
|
await pool.query('CREATE TRIGGER synthetic_fail_ack_notice BEFORE INSERT ON notifications FOR EACH ROW EXECUTE FUNCTION synthetic_fail_ack_notice()');
|
|
try { await call(finance, 'POST', root, notificationBody, 500); }
|
|
finally { await pool.query('DROP TRIGGER synthetic_fail_ack_notice ON notifications'); await pool.query('DROP FUNCTION synthetic_fail_ack_notice()'); }
|
|
assert.equal(await prisma.loanLenderAcknowledgement.count({ where: { request_key: notificationBody.requestKey } }), 0);
|
|
assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_ACK_PROPOSED' } }), auditsBefore);
|
|
assert.equal(await prisma.notification.count({ where: { recipient_employee_id: notificationFailure.signer.id, type: noticeType } }), 0);
|
|
const afterNotificationFailure = await call(finance, 'POST', root, notificationBody);
|
|
assert.equal(await prisma.notification.count({ where: { target_id: afterNotificationFailure.id, type: noticeType } }), 1);
|
|
checks.push('notification_write_failure_rolls_back_statement_and_success_audit_then_same_request_can_retry');
|
|
|
|
const racing = await setup(); const raceInput = body(racing);
|
|
const pair = await Promise.all([call(finance, 'POST', root, raceInput), call(finance, 'POST', root, raceInput)]);
|
|
assert.equal(pair[0].id, pair[1].id);
|
|
assert.equal(await prisma.notification.count({ where: { target_id: pair[0].id, type: noticeType } }), 1);
|
|
const responses = await Promise.all(['confirm', 'return'].map((action) => app.inject({ method: 'POST',
|
|
url: `/api${root}/${pair[0].id}/decision`, headers: racing.signer.headers, payload: decisionBody(pair[0], action) })));
|
|
assert.deepEqual(responses.map((r) => r.statusCode).sort(), [200, 409]);
|
|
assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: pair[0].id } }), 1);
|
|
checks.push('concurrent_duplicate_proposal_and_conflicting_decisions_have_one_result');
|
|
|
|
const sourceRace = await setup(); const sourceAck = await propose(sourceRace);
|
|
const locker = await pool.connect();
|
|
try {
|
|
await locker.query('BEGIN'); await locker.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [sourceRace.lender.id]);
|
|
const pending = decide(sourceRace, sourceAck, 'confirm', 409, 'loan.lender_ack_changed');
|
|
let blocked = false;
|
|
for (let i = 0; i < 80; i++) { const state = await pool.query("SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%loan_lenders%' LIMIT 1");
|
|
if (state.rowCount) { blocked = true; break; } await delay(20); }
|
|
assert.ok(blocked, 'confirmation must wait for the source lock');
|
|
await locker.query('UPDATE company_loans SET opening_outstanding_principal=90 WHERE id=$1', [sourceRace.loans[0].id]);
|
|
await locker.query('COMMIT'); await pending;
|
|
assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: sourceAck.id } }), 0);
|
|
} finally { await locker.query('ROLLBACK'); locker.release(); }
|
|
checks.push('observed_source_lock_interleaving_rejects_stale_confirmation');
|
|
|
|
for (const change of ['identity', 'sibling_original']) {
|
|
const balanceRace = await setup('employee', 2); const balanceAck = await propose(balanceRace);
|
|
await decide(balanceRace, balanceAck); await upload(balanceRace.loans[0], 'accounting_confirmation');
|
|
const sourceWriter = await pool.connect(); let pending;
|
|
try {
|
|
await sourceWriter.query('BEGIN');
|
|
await sourceWriter.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [balanceRace.lender.id]);
|
|
pending = app.inject({ method: 'POST', url: `/api/company-loans/${balanceRace.loans[0].id}/confirm`,
|
|
headers: finance.headers, payload: { expectedVersion: balanceRace.loans[0].version, accountingConfirmationRef: '合成并发代账引用' } });
|
|
// Observe an actual waiter rather than relying on a scheduled delay.
|
|
let blocked = false;
|
|
for (let i = 0; i < 80; i++) {
|
|
const state = await pool.query("SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%loan_lenders%' LIMIT 1");
|
|
if (state.rowCount) { blocked = true; break; } await delay(20);
|
|
}
|
|
assert.ok(blocked, `balance confirmation must wait for ${change} writer`);
|
|
if (change === 'identity') await sourceWriter.query('UPDATE loan_lenders SET identity_version=identity_version+1 WHERE id=$1', [balanceRace.lender.id]);
|
|
else await sourceWriter.query('UPDATE loan_evidences SET original_name=$1 WHERE id=$2', ['synthetic-changed.pdf', balanceRace.loans[1].original.id]);
|
|
await sourceWriter.query('COMMIT');
|
|
const response = await pending; assert.equal(response.statusCode, 409);
|
|
assert.equal(response.json().error.details.reasonCode, 'loan.evidence_required');
|
|
assert.equal((await prisma.companyLoan.findUniqueOrThrow({ where: { id: balanceRace.loans[0].id } })).confirmation_status, 'unconfirmed');
|
|
} finally { await sourceWriter.query('ROLLBACK'); sourceWriter.release(); if (pending) await pending; }
|
|
}
|
|
checks.push('balance_consumption_serializes_identity_and_group_original_changes_and_rechecks_after_wait');
|
|
|
|
const paperCurrent = await call(finance, 'GET', `/company-loans?lenderId=${ordinaryCase.lender.id}`);
|
|
const paperLoan = paperCurrent.items.find(l => l.id === paper.id);
|
|
assert.equal(paperLoan.lenderAcknowledgementRenewable, false);
|
|
await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [paperLoan] }), 409, 'loan.lender_ack_scope_invalid');
|
|
for (const change of ['original', 'employee_refresh']) {
|
|
const renewal = await setup('manager', 2); const previous = await propose(renewal); await decide(renewal, previous);
|
|
for (let i = 0; i < renewal.loans.length; i++) {
|
|
await upload(renewal.loans[i], 'accounting_confirmation'); renewal.loans[i] = await confirmBalance(renewal.loans[i]);
|
|
}
|
|
async function grant() {
|
|
const authorization = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: renewal.lender.id,
|
|
loanIds: renewal.loans.map(l => l.id), basis: '合成声明恢复后仍需独立办理公司代表授权', requestKey: randomUUID() });
|
|
await call(renewal.signer, 'POST', `/loan-related-authorizations/${authorization.id}/actions`, { action: 'confirm', reason: '合成公司代表身份确认' });
|
|
}
|
|
await grant();
|
|
const before = await prisma.companyLoan.findUniqueOrThrow({ where: { id: renewal.loans[0].id } });
|
|
if (change === 'original') await upload(renewal.loans[1]);
|
|
else {
|
|
await prisma.employee.update({ where: { id: renewal.signer.id }, data: { name: renewal.lender.name,
|
|
bank_institution_id: h.bank.id, bank_name: '合成建行员工收款资料' } });
|
|
const source = await call(finance, 'GET', `/loan-lenders/employee-source/${renewal.signer.id}?legalEntityId=${renewal.lender.legalEntityId}`);
|
|
renewal.lender = await call(finance, 'POST', `/loan-lenders/${renewal.lender.id}/employee-refresh`, {
|
|
expectedSourceVersion: source.sourceVersion, expectedIdentityVersion: renewal.lender.identityVersion,
|
|
expectedAccountVersion: renewal.lender.accountVersion, detailsConfirmed: true, reason: '合成刷新员工收款资料' });
|
|
}
|
|
assert.equal((await call(renewal.signer, 'GET', `${root}/${previous.id}`)).status, 'invalid');
|
|
const payBody = { requestKey: randomUUID(), lenderId: renewal.lender.id, reason: '合成验证续办还款',
|
|
allocations: [{ loanId: renewal.loans[0].id, amount: '10.00' }] };
|
|
await call(finance, 'POST', '/loan-repayments', payBody, 409, 'loan.evidence_required');
|
|
const projected = await call(finance, 'GET', `/company-loans?lenderId=${renewal.lender.id}`);
|
|
assert.ok(projected.items.every(l => l.lenderAcknowledgementRenewable));
|
|
// Renew one member of the invalid group; returning it does not trap renewal.
|
|
const selected = { ...renewal, loans: [renewal.loans[0]] };
|
|
const back = await propose(selected); await decide(selected, back, 'return');
|
|
const freshAck = await propose(selected); await decide(selected, freshAck);
|
|
assert.equal((await call(renewal.signer, 'GET', `${root}/${previous.id}`)).status, 'invalid');
|
|
assert.deepEqual(await prisma.companyLoan.findUniqueOrThrow({ where: { id: renewal.loans[0].id } }), before,
|
|
'renewal never edits the loan or its original balance confirmation');
|
|
if (change === 'employee_refresh') {
|
|
const refusedPayment = await app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: payBody });
|
|
assert.equal(refusedPayment.statusCode, 409, 'lender statement does not renew company authority');
|
|
await grant();
|
|
}
|
|
await call(finance, 'POST', '/loan-repayments', payBody);
|
|
}
|
|
checks.push('confirmed_balance_can_renew_invalid_statement_after_original_or_employee_refresh_without_rewriting_loan');
|
|
checks.push('renewal_supports_return_and_partial_group_but_does_not_replace_company_authority_or_open_paper_only_scope');
|
|
|
|
const browserScenarios = [];
|
|
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) browserScenarios.push({ mode, ...await setup('employee', 4) });
|
|
console.log(JSON.stringify({ stage: 'loan_lender_ack_http', status: 'PASS', checks }));
|
|
return { browserScenarios, upload, loan, body, decide, propose, setup, checks };
|
|
};
|