追回证据刷新期间禁用选择并修复员工资料登录误报(09-17-company-loan-principal-repayment)

- 上传银行证据后、刷新完成前,证据下拉框与确认按钮禁用并提示,避免空证据提交
- 员工来源版本去掉 updated_at(登录会刷新),保留全部真实资料字段的拦截
- 补登录误报回归、字段变更拦截、两项变异及刷新挂起的浏览器检查
- 任务记录 E 节与 AC52–55,独立只读复核无阻断项

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-11 09:22:34 +08:00
共同撰写人 Claude Opus 5.5
父节点 91fe31f0db
当前提交 3c5cc544f5
共修改 7 个文件,包含 104 行新增和 32 行删除
@@ -74,14 +74,13 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
await d.getByLabel('回单流水号').fill(reference);
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成官方查询逐一排除其他同金额付款和退款交易');
}
async function diagnoseRefund(late) {
// The upload dialog closes before the batch detail refetch lands. Hold that
// refetch: evidence choices and submit stay disabled with a notice (no POST
// can leave with the placeholder) until the uploaded document is selectable.
async function uploadWithHeldRefresh(late, record = () => {}) {
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');
@@ -94,22 +93,27 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
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 });
const confirm = d.getByRole('button', { name: '确认', exact: true });
assert.deepEqual(await select.locator('option').evaluateAll(os => os.map(o => o.value)), [''], 'the held refetch has not landed');
await expect(select).toBeDisabled(); await expect(confirm).toBeDisabled();
await expect(d.getByRole('status').filter({ hasText: '正在刷新证据列表' })).toBeVisible();
assert.equal(submitted, 0);
record('refresh_pending_choices_disabled', { optionCount: 1, selectDisabled: true, submitDisabled: true, postCount: submitted });
releaseRefresh();
const uploadedId = uploadedByKind.get('incoming_return');
await expect(select.locator(`option[value="${uploadedId}"]`)).toBeAttached({ timeout: 20000 });
await expect(select).toBeEnabled({ timeout: 20000 });
await expect(select.locator(`option[value="${uploadedId}"]`)).toBeAttached();
await expect(confirm).toBeEnabled(); assert.equal(submitted, 0);
await page.keyboard.press('Escape'); await expect(d).not.toBeVisible();
} finally { releaseRefresh(); page.off('request', countPost); await page.unroute(detail, slow); }
}
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 }));
await uploadWithHeldRefresh(late, record);
// A real business refusal is distinguishable from native validation and
// leaves a nonempty error tied to this exact POST and server request ID.
@@ -210,7 +214,7 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund' && amount === '15.00') {
await diagnoseRefund(late);
} else {
await upload('incoming_return');
if (amount === '15.00') await uploadWithHeldRefresh(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('登记补记或追回');
@@ -220,6 +224,7 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
assert.equal(await prisma.paymentBatch.count(), batchCount, 'factual entries never create payment batches');
await expect(view.panel.getByRole('button', { name: '登记追回到账', exact: true })).toHaveCount(0);
checks.push(`${mode}_late_success_details_independent_review_partial_and_full_return_via_ui`);
checks.push(`${mode}_evidence_choices_disabled_until_refresh_after_upload_via_ui`);
// Registering a duplicate remains an auditable false alarm; correction
// needs a bank document and independent decision, not a bare dismissal.