修复授权分页并补齐追回到账诊断验证(09-17-company-loan-principal-repayment)
这个提交包含在:
@@ -1,5 +1,108 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
|
||||
// Exercise the real return consumer in both approval modes. A late-success
|
||||
// audit rollback does not prove that return claims and freeze closure roll back.
|
||||
async function verifyReturnGuards({ prisma, pool, call, finance, batchId, lenderId, loanId, parentId, voidedEvidenceId, fields, upload, review, apply, prefix }) {
|
||||
const base = `/payment-batches/${batchId}`;
|
||||
const checks = [];
|
||||
const remaining = async () => {
|
||||
const settlements = await prisma.loanSupplementalSettlement.findMany({ where: { event_id: parentId } });
|
||||
const returns = await prisma.loanBankResultEvent.findMany({ where: { parent_id: parentId, kind: 'return', status: 'booked' } });
|
||||
return settlements.reduce((n, r) => n + Number(r.receivable_amount), 0) - returns.reduce((n, r) => n + Number(r.amount), 0);
|
||||
};
|
||||
async function snapshot() {
|
||||
const loan = await call(finance, 'GET', `/company-loans/${loanId}`);
|
||||
const batch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batchId } });
|
||||
const repayment = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: batch.loan_repayment_id }, include: { allocations: true } });
|
||||
const freezes = await prisma.loanFreezeReason.findMany({ where: { lender_id: lenderId }, orderBy: { id: 'asc' } });
|
||||
return {
|
||||
outstandingReturn: (await remaining()).toFixed(2), outstanding: loan.outstanding, reserved: loan.reserved,
|
||||
batchCount: await prisma.paymentBatch.count(), batchStatus: batch.status, bankState: repayment.bank_result_state,
|
||||
allocations: repayment.allocations.map(r => [r.id, r.amount.toFixed(2), r.reservation_state]).sort(),
|
||||
claims: await prisma.loanBankEventClaim.count({ where: { event: { payment_batch_id: batchId } } }),
|
||||
freezes: freezes.map(r => [r.id, r.kind, r.status]),
|
||||
supplementalCount: await prisma.loanSupplementalSettlement.count({ where: { event_id: parentId } }),
|
||||
};
|
||||
}
|
||||
const body = async (amount, overrides = {}) => ({ kind: 'return', parentId, evidenceId: await upload('incoming_return'), requestKey: randomUUID(),
|
||||
fields: fields(amount, true), attributionNote: '合成退款原件唯一定位原批次', exclusionNote: '合成逐笔排除其余付款及退款交易', ...overrides });
|
||||
const propose = (input, status = 200) => call(finance, 'POST', `${base}/loan-bank-results`, input, status,
|
||||
status === 409 ? 'loan.bank_result_reconcile_required' : undefined);
|
||||
const initial = await snapshot();
|
||||
assert.equal(initial.outstandingReturn, '40.00');
|
||||
const wrongKind = await upload('late_success');
|
||||
assert.ok((await prisma.loanBankTerminalEvidence.findUniqueOrThrow({ where: { id: voidedEvidenceId } })).voided_at);
|
||||
for (const [name, input] of [
|
||||
['wrong_direction', await body('15.00', { fields: fields('15.00', false) })],
|
||||
['wrong_evidence_kind', await body('15.00', { evidenceId: wrongKind })],
|
||||
['voided_evidence', await body('15.00', { evidenceId: voidedEvidenceId })],
|
||||
['over_remaining', await body('40.01')],
|
||||
]) {
|
||||
const eventCount = await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } });
|
||||
await propose(input, 409);
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } }), eventCount);
|
||||
assert.deepEqual(await snapshot(), initial, `${name} must not change money, claims, freezes or original facts`);
|
||||
checks.push(`${prefix}_return_${name}_refused_without_money_change`);
|
||||
}
|
||||
const input = await body('15.00');
|
||||
const partial = await propose(input);
|
||||
const eventCount = await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } });
|
||||
assert.equal((await propose(input)).id, partial.id);
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } }), eventCount);
|
||||
await propose({ ...input, fields: fields('14.00', true) }, 409);
|
||||
await review(partial); await apply(partial);
|
||||
const afterPartial = await snapshot(); assert.equal(afterPartial.outstandingReturn, '25.00');
|
||||
assert.ok(afterPartial.freezes.some(r => r[1] === 'receivable' && r[2] === 'active'));
|
||||
await apply(partial); assert.deepEqual(await snapshot(), afterPartial, 'booked replay does not count a return twice');
|
||||
checks.push(`${prefix}_return_request_key_and_booked_replay_idempotent`);
|
||||
await propose(await body('25.01'), 409);
|
||||
assert.deepEqual(await snapshot(), afterPartial, 'remaining limit is recomputed after partial return');
|
||||
checks.push(`${prefix}_return_partial_recomputes_remaining_limit`);
|
||||
|
||||
// Re-uploading the same bank transaction may create auditable proposal
|
||||
// history, but it must never gain approval or a second claim.
|
||||
const duplicate = await propose({ ...input, evidenceId: await upload('incoming_return'), requestKey: randomUUID() });
|
||||
await review(duplicate, 409); assert.deepEqual(await snapshot(), afterPartial);
|
||||
checks.push(`${prefix}_return_same_bank_transaction_cannot_be_booked_twice`);
|
||||
const rejected = await propose(await body('1.00'));
|
||||
await review(rejected, 200, { decision: 'reject' });
|
||||
await apply(rejected, 409); assert.deepEqual(await snapshot(), afterPartial);
|
||||
checks.push(`${prefix}_return_rejected_review_cannot_apply`);
|
||||
|
||||
// Keep a separate unresolved late-success cause while fully returning this
|
||||
// receivable. Closing one cause must not unfreeze the whole lender.
|
||||
const other = await propose({ ...await body('80.00'), kind: 'late_success', parentId: undefined,
|
||||
evidenceId: wrongKind, fields: fields('80.00', false) });
|
||||
const full = await propose(await body('25.00')); await review(full);
|
||||
const beforeRollback = await snapshot();
|
||||
await pool.query(`CREATE FUNCTION synthetic_return_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF NEW.action='LOAN_RETURN_BOOKED' THEN RAISE EXCEPTION 'synthetic return audit failure'; END IF; RETURN NEW; END $$`);
|
||||
await pool.query('CREATE TRIGGER synthetic_return_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_return_audit_failure()');
|
||||
try { await apply(full, 500); }
|
||||
finally {
|
||||
await pool.query('DROP TRIGGER synthetic_return_audit_failure ON audit_logs');
|
||||
await pool.query('DROP FUNCTION synthetic_return_audit_failure()');
|
||||
}
|
||||
assert.deepEqual(await snapshot(), beforeRollback, 'return audit failure rolls back claims, amount and freeze closure');
|
||||
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: full.id } })).status, 'approved');
|
||||
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: full.id } }), 0);
|
||||
checks.push(`${prefix}_return_audit_failure_rolls_back_claim_amount_and_freeze`);
|
||||
await apply(full);
|
||||
const afterFull = await snapshot(); assert.equal(afterFull.outstandingReturn, '0.00');
|
||||
assert.ok(!afterFull.freezes.some(r => r[1] === 'receivable' && r[2] === 'active'));
|
||||
assert.ok(afterFull.freezes.some(r => r[1] === 'late_success' && r[2] === 'active'));
|
||||
assert.equal(afterFull.outstanding, initial.outstanding); assert.equal(afterFull.reserved, initial.reserved);
|
||||
assert.equal(afterFull.batchCount, initial.batchCount); assert.equal(afterFull.batchStatus, initial.batchStatus);
|
||||
assert.equal(afterFull.bankState, initial.bankState); assert.deepEqual(afterFull.allocations, initial.allocations);
|
||||
assert.equal(afterFull.supplementalCount, initial.supplementalCount);
|
||||
await apply(full); assert.deepEqual(await snapshot(), afterFull);
|
||||
await review(other, 200, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: await upload('bank_terminal') });
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: lenderId, status: 'active' } }), 0);
|
||||
checks.push(`${prefix}_return_full_closes_only_receivable_and_preserves_original_facts`);
|
||||
return checks;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -14,7 +117,7 @@ module.exports = async function bankResultsFixture(h) {
|
||||
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) {
|
||||
async function setup(cancelled = true, prepareVoidedReturn = false) {
|
||||
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');
|
||||
@@ -24,9 +127,14 @@ module.exports = async function bankResultsFixture(h) {
|
||||
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);
|
||||
let voidedEvidenceId;
|
||||
if (prepareVoidedReturn) {
|
||||
voidedEvidenceId = await upload(batch.id, 'incoming_return');
|
||||
await call(finance, 'POST', `/payment-batches/${batch.id}/loan-terminal-evidences/${voidedEvidenceId}/void`, { reason: '合成取消前作废未消费退款材料' });
|
||||
}
|
||||
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) };
|
||||
return { lender, loan, batch, voidedEvidenceId, 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 },
|
||||
@@ -65,7 +173,7 @@ module.exports = async function bankResultsFixture(h) {
|
||||
}
|
||||
checks.push('s6_reserved_conflict_is_not_overpayment_and_blocks_until_resolved');
|
||||
{
|
||||
const s = await setup();
|
||||
const s = await setup(true, true);
|
||||
// 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);
|
||||
@@ -76,10 +184,9 @@ module.exports = async function bankResultsFixture(h) {
|
||||
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);
|
||||
checks.push(...await verifyReturnGuards({ prisma, pool, call, finance, batchId: s.batch.id, lenderId: s.lender.id, loanId: s.loan.id, parentId: e.id, voidedEvidenceId: s.voidedEvidenceId,
|
||||
fields: (amount, incoming) => fields(s, amount, incoming), upload: kind => upload(s.batch.id, kind),
|
||||
review: (event, status = 200, body = {}) => review(s, event, independent, body, status), apply: (event, status = 200) => apply(s, event, status), prefix: 'independent' }));
|
||||
assert.equal((await balances(s)).outstanding, '0.00');
|
||||
}
|
||||
checks.push('s6_s7_overpayment_is_difference_partial_return_keeps_frozen_full_return_closes');
|
||||
@@ -276,3 +383,4 @@ module.exports = async function bankResultsFixture(h) {
|
||||
checks.push('s5_consumed_false_alarm_correction_cannot_be_voided_in_service_or_database');
|
||||
return checks;
|
||||
};
|
||||
module.exports.verifyReturnGuards = verifyReturnGuards;
|
||||
|
||||
@@ -157,11 +157,15 @@ module.exports = async function employeeBrowser(h) {
|
||||
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
|
||||
dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached();
|
||||
// D1: a response arriving is not the page being rendered. Step only after
|
||||
// the option list has loaded a different page (also covers cache hits).
|
||||
const lenderOptions = () => dialog.getByLabel('授权出借人').locator('option').evaluateAll(os => os.map(o => o.value).join(','));
|
||||
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${lender.id}"]`).count()); i++) {
|
||||
assert.ok(i < 20);
|
||||
// Await both together so a timeout reaches the catch below (and its screenshot).
|
||||
await Promise.all([page.waitForResponse(r => r.url().includes('/loan-lenders?page=')),
|
||||
dialog.getByRole('button', { name: '下一页出借人', exact: true }).click({ timeout: 30000 })]);
|
||||
assert.ok(i < 20, 'authorized lender not found on any page');
|
||||
const before = await lenderOptions();
|
||||
const next = dialog.getByRole('button', { name: '下一页出借人', exact: true });
|
||||
await expect(next).toBeEnabled({ timeout: 10000 }); await next.click();
|
||||
await expect.poll(async () => { const now = await lenderOptions(); return now.split(',').length > 1 && now !== before; }, { timeout: 15000 }).toBe(true);
|
||||
}
|
||||
await dialog.getByLabel('授权出借人').selectOption(lender.id);
|
||||
await dialog.getByRole('checkbox', { name: new RegExp(loan.loan_no) }).check();
|
||||
|
||||
@@ -421,6 +421,10 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
// Incoming-receipt scenarios add lenders; in a full run they go last so they
|
||||
// cannot shift the pagination other browser scenarios rely on.
|
||||
const runIncoming = () => require('./company-loan-incoming-fixture.cjs')({ ...employeeHarness, verifiedLender });
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'authorization-pagination') {
|
||||
await require('./company-loan-pagination-diagnostic.cjs')({ ...employeeHarness, verifiedLender, confirmedLoan });
|
||||
return;
|
||||
}
|
||||
const runLenderAck = () => require('./company-loan-lender-ack-fixture.cjs')({ ...employeeHarness, verifiedLender, admin, system });
|
||||
if (process.env.RUN_ISOLATED_LOAN_ACK_ONLY === '1') {
|
||||
const lenderAck = await runLenderAck();
|
||||
@@ -1389,7 +1393,9 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
await receipt(target.id, '700', reference, { payeeAccount: failLender.bankAccount, transactionStatus: 'failed' });
|
||||
reconciliationUi.failures.push({ id: target.id, repaymentId: target.loanRepaymentId, reference });
|
||||
|
||||
const lateLender = await verifiedLender(`合成追回界面-${mode}`, null, '8');
|
||||
// The company pays from the synthetic 8-prefix account. Keep the
|
||||
// lender different so reversing a return is a genuine counterexample.
|
||||
const lateLender = await verifiedLender(`合成追回界面-${mode}`, null, '9');
|
||||
const lateLoan = await confirmedLoan(lateLender.id, '100.00', `合成晚到追回-${mode}`);
|
||||
const original = await repay(lateLender.id, lateLoan.id, '80.00');
|
||||
await call(manager, 'POST', `/payment-batches/${original.id}/review`, {}); await exportOrder(original.id);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { mkdtempSync, mkdirSync } = require('node:fs');
|
||||
const { createRequire } = require('node:module');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const clientRequire = createRequire(path.join(root, 'client/package.json'));
|
||||
const { chromium, expect } = clientRequire('@playwright/test');
|
||||
|
||||
// D1 diagnostic: the company-representative authorization dialog pages its
|
||||
// lender list (30 per page). With the target lender on page 3, compare the old
|
||||
// test loop (response-only wait) with a loop that waits for the option list to
|
||||
// change, under CPU throttling that models a loaded host. Synthetic data only.
|
||||
async function authorizationDialog(page) {
|
||||
await page.getByRole('tab', { name: '公司代表授权', exact: true }).click({ timeout: 30000 });
|
||||
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached({ timeout: 30000 });
|
||||
return dialog;
|
||||
}
|
||||
const optionValues = (dialog) => dialog.getByLabel('授权出借人').locator('option').evaluateAll((os) => os.map((o) => o.value).join(','));
|
||||
|
||||
// The original loop from company-loan-employee-browser.cjs (before D1).
|
||||
async function oldLoop(page, dialog, targetId, timeout) {
|
||||
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${targetId}"]`).count()); i++) {
|
||||
assert.ok(i < 20);
|
||||
await Promise.all([page.waitForResponse((r) => r.url().includes('/loan-lenders?page='), { timeout }),
|
||||
dialog.getByRole('button', { name: '下一页出借人', exact: true }).click({ timeout })]);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the rendered options to change after each page step (works for
|
||||
// network responses and cache hits alike).
|
||||
async function stepTo(dialog, direction) {
|
||||
const before = await optionValues(dialog);
|
||||
const button = dialog.getByRole('button', { name: direction === 'next' ? '下一页出借人' : '上一页出借人', exact: true });
|
||||
await expect(button).toBeEnabled({ timeout: 10000 });
|
||||
await button.click();
|
||||
// The list empties while a page loads; only a loaded, different page counts.
|
||||
await expect.poll(async () => { const now = await optionValues(dialog); return now.split(',').length > 1 && now !== before; },
|
||||
{ timeout: 15000 }).toBe(true);
|
||||
}
|
||||
async function newLoop(dialog, targetId) {
|
||||
const target = dialog.getByLabel('授权出借人').locator(`option[value="${targetId}"]`);
|
||||
while (await dialog.getByRole('button', { name: '上一页出借人', exact: true }).isEnabled()) await stepTo(dialog, 'previous');
|
||||
for (let i = 0; !(await target.count()); i++) { assert.ok(i < 20, 'target lender not found on any page'); await stepTo(dialog, 'next'); }
|
||||
}
|
||||
|
||||
module.exports = async function paginationDiagnostic(h) {
|
||||
const { app, prisma, call, finance, manager, password, config, verifiedLender, confirmedLoan } = h;
|
||||
const checks = [];
|
||||
// Fillers are eligible for the dialog (verified, linked to their own employee),
|
||||
// so the target is reached only by real paging, with or without a server filter.
|
||||
for (let n = 0; n < 64; n++) {
|
||||
const staff = await h.actor(['employee']);
|
||||
await verifiedLender(`合成翻页-${String(n).padStart(2, '0')}`, staff.id, '5');
|
||||
}
|
||||
const target = await verifiedLender('合成翻页-目标', manager.id, '3');
|
||||
const loan = await confirmedLoan(target.id, '100.00', '合成翻页目标借款');
|
||||
// Explicit exclusions prove that filtering is applied before pagination and
|
||||
// count, including the current finance actor and an unverified linked lender.
|
||||
const own = await verifiedLender('合成翻页-本人排除', finance.id, '4');
|
||||
const unverified = await call(finance, 'POST', '/loan-lenders', { legalEntityId: h.company.id, name: '合成翻页-未核实排除',
|
||||
bankInstitutionId: h.bank.id, bankName: '合成', bankAccount: '9'.repeat(16), accountName: '合成翻页-未核实排除', requestKey: randomUUID() });
|
||||
// The dialog pages the server-filtered list of lenders it can offer.
|
||||
const pages = [];
|
||||
for (let p = 1; ; p++) {
|
||||
const r = await call(finance, 'GET', `/loan-lenders?page=${p}&pageSize=30&eligible=related_authority`);
|
||||
assert.ok(r.items.every((i) => i.identityStatus === 'verified' && i.employeeId && i.employeeId !== finance.id), 'filter returns only offerable lenders');
|
||||
pages.push(r.items.map((i) => i.id)); if (p * 30 >= r.total) break;
|
||||
}
|
||||
const all = await call(finance, 'GET', '/loan-lenders?page=1&pageSize=30');
|
||||
await call(finance, 'GET', '/loan-lenders?page=1&pageSize=30&eligible=other', undefined, 400);
|
||||
for (const who of [manager, h.ordinary]) await call(who, 'GET', '/loan-lenders?eligible=related_authority', undefined, 403);
|
||||
assert.ok(!pages.flat().includes(own.id) && !pages.flat().includes(unverified.id));
|
||||
assert.equal(pages.flat().length, 65, 'eligible total excludes unlinked, unverified and self lenders');
|
||||
checks.push(`eligible_filter_total_${pages.flat().length}_of_${all.total}`);
|
||||
const targetPage = pages.findIndex((ids) => ids.includes(target.id)) + 1;
|
||||
assert.ok(targetPage >= 3, `target lender must be on page 3 or later, observed ${targetPage}`);
|
||||
checks.push(`target_on_page_${targetPage}_of_${pages.length}`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
|
||||
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const base = path.join(root, 'artifacts/qa-flow/loan-browser-diagnostics-20261010/authorization-pagination'); mkdirSync(base, { recursive: true });
|
||||
const output = mkdtempSync(path.join(base, 'run-'));
|
||||
const oldOrigin = config.appOrigin; config.appOrigin = origin;
|
||||
const results = [];
|
||||
const record = (entry) => { results.push(entry); console.log(JSON.stringify({ stage: 'loan_authorization_pagination_step', ...entry })); };
|
||||
try {
|
||||
for (const mode of ['desktop', 'mobile']) {
|
||||
const context = await browser.newContext({ viewport: mode === 'mobile' ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
||||
await context.route('**/*', (route) => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
|
||||
await context.tracing.start({ screenshots: true, snapshots: true });
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
page.on('request', (r) => { if (r.url().includes('/loan-lenders?page=')) requests.push(new URL(r.url()).searchParams.get('page')); });
|
||||
try {
|
||||
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mode === 'mobile' ? '/mobile' : ''}/company-loans`)}`);
|
||||
await page.getByTestId('login-identifier').fill(finance.email); await page.getByTestId('login-password').fill(password);
|
||||
await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
|
||||
// 0. Counterexample before any paging (only page 1 cached): page 2 is answered
|
||||
// with page 1's data, so the list never changes and the new wait must fail.
|
||||
let dialog = await authorizationDialog(page);
|
||||
await page.route('**/api/loan-lenders?page=*', (route) => {
|
||||
const url = new URL(route.request().url()); url.searchParams.set('page', '1');
|
||||
return route.continue({ url: url.toString() });
|
||||
});
|
||||
await stepTo(dialog, 'next').then(() => { throw new Error('a page that never changes must not satisfy the wait'); },
|
||||
(error) => { assert.ok(/toBe|Timeout|expect/i.test(String(error.message)), String(error.message).slice(0, 200)); });
|
||||
await page.unroute('**/api/loan-lenders?page=*');
|
||||
record({ mode, step: 'unchanged_page_counterexample', outcome: 'new wait fails as required' });
|
||||
await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await page.reload(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible({ timeout: 30000 });
|
||||
requests.length = 0;
|
||||
|
||||
// 1. Old loop under a loaded host: does it reproduce the timeout?
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 20 });
|
||||
dialog = await authorizationDialog(page);
|
||||
let oldOutcome = 'pass';
|
||||
try { await oldLoop(page, dialog, target.id, 8000); } catch (error) { oldOutcome = error.name === 'TimeoutError' ? 'timeout' : `error:${error.name}`; }
|
||||
const landed = await dialog.getByLabel('授权出借人').locator(`option[value="${target.id}"]`).count();
|
||||
await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
record({ mode, step: 'old_loop_throttled', outcome: oldOutcome, targetVisibleAfter: landed > 0, requests: [...requests] });
|
||||
|
||||
// 2. Reopen: which lender page does the dialog start on?
|
||||
requests.length = 0;
|
||||
dialog = await authorizationDialog(page);
|
||||
const firstPage = (await optionValues(dialog)).split(',').filter(Boolean);
|
||||
const startsOnPage = pages.findIndex((ids) => ids.some((id) => firstPage.includes(id))) + 1;
|
||||
record({ mode, step: 'reopen_start_page', startsOnPage });
|
||||
assert.equal(startsOnPage, 1, 'reopening the dialog starts from the first lender page');
|
||||
assert.deepEqual(firstPage, pages[0], 'reopening renders precisely the first page');
|
||||
|
||||
// 3. New loop, throttled, including returning to page 1 first.
|
||||
await newLoop(dialog, target.id);
|
||||
assert.equal(await dialog.getByLabel('授权出借人').locator(`option[value="${target.id}"]`).count(), 1);
|
||||
assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 1]);
|
||||
// 4. Cache: step back and forward again; the forward step may be served from cache.
|
||||
const beforeCache = requests.length;
|
||||
await stepTo(dialog, 'previous');
|
||||
assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 2]);
|
||||
await stepTo(dialog, 'next');
|
||||
assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 1]);
|
||||
record({ mode, step: 'new_loop_throttled_with_cache', outcome: 'pass', requestsDuringBackForward: requests.length - beforeCache });
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
|
||||
|
||||
// The third-page target must be usable, not merely visible. Finance
|
||||
// proposes this scope and its designated representative confirms it.
|
||||
await dialog.getByLabel('授权出借人').selectOption(target.id);
|
||||
await dialog.getByRole('checkbox', { name: new RegExp(loan.loanNo) }).check();
|
||||
await dialog.getByLabel('代表权及授权范围依据').fill('合成第三页目标公司代表权及借款范围');
|
||||
const proposed = page.waitForResponse(r => new URL(r.url()).pathname === '/api/loan-related-authorizations' && r.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
const proposalResponse = await proposed; assert.equal(proposalResponse.status(), 200);
|
||||
const authorizationId = (await proposalResponse.json()).data.id;
|
||||
await expect(dialog).not.toBeVisible();
|
||||
const representative = await browser.newContext({ viewport: mode === 'mobile' ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
||||
try {
|
||||
await representative.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
|
||||
const rp = await representative.newPage();
|
||||
await rp.goto(`${origin}/login?redirect=${encodeURIComponent(`${mode === 'mobile' ? '/mobile' : ''}/company-loans`)}`);
|
||||
await rp.getByTestId('login-identifier').fill(manager.email); await rp.getByTestId('login-password').fill(password);
|
||||
await rp.getByTestId('login-submit').click(); await expect(rp.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
|
||||
await rp.getByRole('button', { name: '代表公司确认授权范围', exact: true }).click();
|
||||
const rd = rp.getByRole('dialog'); await expect(rd).toContainText(loan.loanNo);
|
||||
await rd.getByLabel('确认或停止的理由').fill('合成本人核对第三页借款及公司代表范围');
|
||||
const confirmed = rp.waitForResponse(r => new URL(r.url()).pathname === `/api/loan-related-authorizations/${authorizationId}/actions` && r.request().method() === 'POST');
|
||||
await rd.getByRole('button', { name: '确认', exact: true }).click();
|
||||
assert.equal((await confirmed).status(), 200); await expect(rd).not.toBeVisible();
|
||||
} finally { await representative.close(); }
|
||||
const saved = await prisma.loanRelatedAuthorization.findUniqueOrThrow({ where: { id: authorizationId }, include: { events: true, loans: true } });
|
||||
assert.equal(saved.approver_id, manager.id); assert.equal(saved.proposed_by, finance.id);
|
||||
assert.deepEqual(saved.loans.map(r => r.loan_id), [loan.id]);
|
||||
assert.ok(saved.events.some(r => r.action === 'confirm' && r.actor_id === manager.id));
|
||||
assert.ok(await prisma.auditLog.count({ where: { action: 'LOAN_RELATED_AUTHORIZATION_DECIDED', target_id: authorizationId } }));
|
||||
assert.equal(await prisma.loanRepayment.count({ where: { lender_id: target.id } }), 0);
|
||||
record({ mode, step: 'third_page_scope_proposed_and_confirmed', outcome: 'pass', zeroRepayments: true });
|
||||
|
||||
checks.push(`${mode}_pagination_diagnosed`);
|
||||
} finally {
|
||||
await context.tracing.stop({ path: path.join(output, `trace-${mode}.zip`) });
|
||||
await page.screenshot({ path: path.join(output, `final-${mode}.png`), fullPage: true }).catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
} finally { config.appOrigin = oldOrigin; await browser.close(); }
|
||||
assert.ok(loan.id);
|
||||
console.log(JSON.stringify({ stage: 'loan_authorization_pagination_diagnostic', status: 'PASS', checks, results, output: path.relative(root, output) }));
|
||||
return results;
|
||||
};
|
||||
@@ -10,12 +10,23 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
for (const mobile of [false, true]) {
|
||||
const mode = mobile ? 'mobile' : 'desktop';
|
||||
let view;
|
||||
let traceNumber = 0;
|
||||
async function closeView() {
|
||||
if (!view) return;
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund') {
|
||||
await view.context.tracing.stop({ path: path.join(output, `${mode}-refund-${++traceNumber}.zip`) });
|
||||
}
|
||||
await view.context.close(); view = null;
|
||||
}
|
||||
const uploadedByKind = new Map();
|
||||
async function login(who, batch) {
|
||||
if (view) await view.context.close();
|
||||
await closeView();
|
||||
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();
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund') {
|
||||
await context.tracing.start({ screenshots: true, snapshots: true });
|
||||
}
|
||||
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();
|
||||
@@ -63,6 +74,91 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
await d.getByLabel('回单流水号').fill(reference);
|
||||
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成官方查询逐一排除其他同金额付款和退款交易');
|
||||
}
|
||||
async function diagnoseRefund(late) {
|
||||
const page = view.page;
|
||||
const endpoint = `/api/payment-batches/${late.id}/loan-bank-results`;
|
||||
const detail = url => new URL(url).pathname === `/api/payment-batches/${late.id}`;
|
||||
const post = r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST';
|
||||
const record = (step, extra = {}) => console.log(JSON.stringify({ stage: 'loan_refund_submission_diagnostic', mode, step, batchId: late.id, ...extra }));
|
||||
// Hold the actual refresh, rather than hoping an arbitrary sleep outlasts
|
||||
// the browser. The old strategy must select the sole placeholder.
|
||||
let releaseRefresh;
|
||||
const refreshGate = new Promise(resolve => { releaseRefresh = resolve; });
|
||||
const started = page.waitForRequest(r => detail(r.url()) && r.method() === 'GET');
|
||||
const slow = async route => { if (route.request().method() === 'GET') await refreshGate; await route.continue(); };
|
||||
await page.route(detail, slow);
|
||||
let submitted = 0;
|
||||
const countPost = request => { if (new URL(request.url()).pathname === endpoint && request.method() === 'POST') submitted++; };
|
||||
page.on('request', countPost);
|
||||
try {
|
||||
await upload('incoming_return'); await started;
|
||||
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click();
|
||||
const d = page.getByRole('dialog'); const select = d.getByLabel('对应的成功银行回单');
|
||||
const values = await select.locator('option').evaluateAll(os => os.map(o => o.value));
|
||||
assert.deepEqual(values, ['']); await select.selectOption(values.at(-1));
|
||||
await d.getByLabel('回单付款账号').fill(late.payee); await d.getByLabel('回单收款账号').fill(late.payer);
|
||||
await d.getByLabel('回单人民币金额').fill('15.00'); await d.getByLabel('回单流水号').fill(`${late.reference}-OLD-PICK`);
|
||||
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成原选项策略对照'); await d.getByLabel('依据与说明').fill('合成旧策略原生表单校验探针');
|
||||
assert.deepEqual(await d.locator('input:invalid, select:invalid, textarea:invalid').evaluateAll(es => es.map(el => el.getAttribute('aria-label'))), ['对应的成功银行回单']);
|
||||
await select.evaluate(el => { el.dataset.syntheticInvalid = '0'; el.addEventListener('invalid', () => { el.dataset.syntheticInvalid = '1'; }); });
|
||||
await d.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(select).toHaveAttribute('data-synthetic-invalid', '1');
|
||||
assert.equal(submitted, 0); await expect(d).toBeVisible(); assert.deepEqual(await d.getByRole('alert').allTextContents(), []);
|
||||
record('native_validation_no_post', { oldSelectionPicks: 'placeholder', postCount: submitted, formBlocked: true });
|
||||
releaseRefresh();
|
||||
const uploadedId = uploadedByKind.get('incoming_return');
|
||||
await expect(select.locator(`option[value="${uploadedId}"]`)).toBeAttached({ timeout: 20000 });
|
||||
await page.keyboard.press('Escape'); await expect(d).not.toBeVisible();
|
||||
} finally { releaseRefresh(); page.off('request', countPost); await page.unroute(detail, slow); }
|
||||
|
||||
// A real business refusal is distinguishable from native validation and
|
||||
// leaves a nonempty error tied to this exact POST and server request ID.
|
||||
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click();
|
||||
let d = page.getByRole('dialog');
|
||||
await bankFields(d, late, '15.00', `${late.reference}-WRONG-DIRECTION`, true);
|
||||
await d.getByLabel('回单付款账号').fill(late.payer); await d.getByLabel('回单收款账号').fill(late.payee);
|
||||
await d.getByLabel('依据与说明').fill('合成错误方向拒绝诊断');
|
||||
const refused = page.waitForResponse(post);
|
||||
await d.getByRole('button', { name: '确认', exact: true }).click();
|
||||
const refusal = await refused; assert.equal(refusal.status(), 409);
|
||||
const safeError = await refusal.json(); assert.equal(safeError.error.details.reasonCode, 'loan.bank_result_reconcile_required'); assert.ok(safeError.requestId);
|
||||
await expect(d.getByRole('alert')).toContainText(/\S/);
|
||||
record('server_refused', { method: 'POST', status: 409, requestId: safeError.requestId, reasonCode: safeError.error.details.reasonCode });
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: late.id, kind: 'return' } }), 0);
|
||||
await page.keyboard.press('Escape'); await expect(d).not.toBeVisible();
|
||||
|
||||
// Hold the registration request, then hold the refresh after a real 200.
|
||||
// Database checks separate request pending from committed-but-stale UI.
|
||||
let releasePost;
|
||||
const postGate = new Promise(resolve => { releasePost = resolve; });
|
||||
const eventRoute = url => new URL(url).pathname === endpoint;
|
||||
const atPost = page.waitForRequest(r => eventRoute(r.url()) && r.method() === 'POST');
|
||||
const delayedPost = async route => { await postGate; await route.continue(); };
|
||||
await page.route(eventRoute, delayedPost);
|
||||
let releaseAfter;
|
||||
const afterGate = new Promise(resolve => { releaseAfter = resolve; });
|
||||
const atAfter = page.waitForRequest(r => detail(r.url()) && r.method() === 'GET');
|
||||
const delayedAfter = async route => { if (route.request().method() === 'GET') await afterGate; await route.continue(); };
|
||||
await page.route(detail, delayedAfter);
|
||||
try {
|
||||
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click(); d = page.getByRole('dialog');
|
||||
await bankFields(d, late, '15.00', `${late.reference}-RETURN-15.00`, true); await d.getByLabel('依据与说明').fill('合成请求与成功后刷新延迟诊断');
|
||||
const succeeded = page.waitForResponse(post);
|
||||
await d.getByRole('button', { name: '确认', exact: true }).click(); await atPost;
|
||||
await expect(d.getByRole('button', { name: '处理中…', exact: true })).toBeDisabled();
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: late.id, kind: 'return' } }), 0);
|
||||
record('post_pending', { method: 'POST', eventCount: 0 });
|
||||
releasePost(); const response = await succeeded; assert.equal(response.status(), 200);
|
||||
const result = await response.json(); const event = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: result.data.id } });
|
||||
assert.equal(event.evidence_id, uploadedByKind.get('incoming_return')); assert.equal(event.amount.toFixed(2), '15.00'); assert.equal(event.status, 'proposed');
|
||||
const parent = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: event.parent_id } });
|
||||
assert.equal(parent.payment_batch_id, late.id); assert.equal(parent.kind, 'late_success'); assert.equal(parent.status, 'booked');
|
||||
await atAfter; await expect(d).not.toBeVisible();
|
||||
record('post_200_refresh_pending', { method: 'POST', status: 200, requestId: result.requestId, eventId: event.id, evidenceId: event.evidence_id });
|
||||
releaseAfter(); await expect(view.panel).toContainText(/追回到账 \d+ · ¥15\.00 · 待复核/);
|
||||
record('refresh_rendered', { eventId: event.id });
|
||||
} finally { releasePost(); releaseAfter(); await page.unroute(eventRoute, delayedPost); await page.unroute(detail, delayedAfter); }
|
||||
}
|
||||
try {
|
||||
const failed = fixtures.failures[Number(mobile)];
|
||||
await login(finance, failed);
|
||||
@@ -111,8 +207,12 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
await review(); await login(finance, late); await action('登记补记或追回');
|
||||
await expect(view.panel).toContainText('待追回 ¥40.00');
|
||||
for (const [amount, remaining] of [['15.00', '25.00'], ['25.00', '0.00']]) {
|
||||
await upload('incoming_return');
|
||||
await action('登记追回到账', d => bankFields(d, late, amount, `${late.reference}-RETURN-${amount}`, true));
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund' && amount === '15.00') {
|
||||
await diagnoseRefund(late);
|
||||
} else {
|
||||
await upload('incoming_return');
|
||||
await action('登记追回到账', d => bankFields(d, late, amount, `${late.reference}-RETURN-${amount}`, true));
|
||||
}
|
||||
await login(manager, late); await review(); await login(finance, late); await action('登记补记或追回');
|
||||
await expect(view.panel).toContainText(`待追回 ¥${remaining}`);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lenderId, status: 'active' } }), remaining === '0.00' ? 0 : 1);
|
||||
@@ -137,7 +237,7 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
|
||||
await view.page.screenshot({ path: path.join(output, `${mode}-late-return-and-dismissal.png`), fullPage: true });
|
||||
checks.push(`${mode}_evidenced_duplicate_false_alarm_closed_by_independent_reviewer_via_ui`);
|
||||
} finally { if (view) await view.context.close(); }
|
||||
} finally { await closeView(); }
|
||||
}
|
||||
return checks;
|
||||
};
|
||||
|
||||
@@ -471,7 +471,13 @@ module.exports = async function relatedFixture(h) {
|
||||
}
|
||||
checks.push('rp_only_payee_conflict_waived_multiple_roles_tokens_and_q6_refused');
|
||||
for (const [cancelled, paidAmount, reserveAmount] of [[false, '0.00', '0.00'], [true, '0.00', '70.00'], [true, '60.00', '0.00'], [true, '100.00', '0.00']]) {
|
||||
const s = await setup('100.00'); const b = await batch(s, '80.00'); await terminate(s, b, cancelled);
|
||||
const s = await setup('100.00'); const b = await batch(s, '80.00');
|
||||
let voidedEvidenceId;
|
||||
if (paidAmount === '60.00') {
|
||||
voidedEvidenceId = await evidence(b, 'incoming_return');
|
||||
await call(finance, 'POST', `${base(b)}/loan-terminal-evidences/${voidedEvidenceId}/void`, { reason: '合成取消前作废未消费退款材料' });
|
||||
}
|
||||
await terminate(s, b, cancelled);
|
||||
if (paidAmount !== '0.00') {
|
||||
const p = await batch(s, paidAmount); await receipt(p.id, String(Math.round(Number(paidAmount) * 100)), `RP-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
||||
await call(finance, 'POST', `${base(p)}/execute`, {});
|
||||
@@ -489,7 +495,12 @@ module.exports = async function relatedFixture(h) {
|
||||
assert.equal((await balance(s)).reserved, '0.00');
|
||||
if (excess) {
|
||||
await authorization(s.lender, [s.loan]);
|
||||
for (const value of [15, excess - 15]) {
|
||||
if (excess === 40) checks.push(...await require('./company-loan-bank-results-fixture.cjs').verifyReturnGuards({
|
||||
prisma, pool, call, finance, batchId: b.id, lenderId: s.lender.id, loanId: s.loan.id, parentId: e.id, voidedEvidenceId,
|
||||
fields: (amount, incoming) => fields(s, amount, `RP-RETURN-GUARD-${randomUUID()}`, incoming), upload: kind => evidence(b, kind),
|
||||
review: (event, status = 200, more = {}) => review(b, event, 'loan-bank-results', status, manager, more),
|
||||
apply: (event, status = 200) => apply(b, event, status), prefix: 'related' }));
|
||||
else for (const value of [15, excess - 15]) {
|
||||
const ret = await bankEvent(s, b, 'return', value.toFixed(2), e.id); await review(b, ret, 'loan-bank-results'); await apply(b, ret);
|
||||
if (value === 15) assert.ok(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ const { Pool } = serverRequire('pg');
|
||||
|
||||
async function main() {
|
||||
assert.equal(process.env.RUN_ISOLATED_LOAN_DB_TEST, '1');
|
||||
const diagnostic = process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC;
|
||||
if (diagnostic !== undefined) {
|
||||
assert.ok(['authorization-pagination', 'refund'].includes(diagnostic), 'unknown loan browser diagnostic');
|
||||
assert.equal(process.env.RUN_ISOLATED_LOAN_BROWSER, '1', 'browser diagnostic requires the browser switch');
|
||||
assert.ok(!['RUN_ISOLATED_LOAN_RELATED_ONLY', 'RUN_ISOLATED_LOAN_ACK_ONLY', 'RUN_ISOLATED_LOAN_INCOMING_ONLY', 'RUN_ISOLATED_LOAN_EMPLOYEE_ONLY']
|
||||
.some(key => process.env[key] === '1'), 'diagnostic must not be shadowed by an ONLY switch');
|
||||
}
|
||||
const scratch = mkdtempSync(path.join(tmpdir(), 'hyb-loan-synthetic-'));
|
||||
const socket = path.join(scratch, 'socket');
|
||||
const dockerConfig = path.join(scratch, 'docker');
|
||||
|
||||
在新工单中引用
屏蔽一个用户