追回证据刷新期间禁用选择并修复员工资料登录误报(09-17-company-loan-principal-repayment)
- 上传银行证据后、刷新完成前,证据下拉框与确认按钮禁用并提示,避免空证据提交 - 员工来源版本去掉 updated_at(登录会刷新),保留全部真实资料字段的拦截 - 补登录误报回归、字段变更拦截、两项变异及刷新挂起的浏览器检查 - 任务记录 E 节与 AC52–55,独立只读复核无阻断项 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -103,6 +103,30 @@ module.exports = async function employeeFixture(h) {
|
||||
await link({ ...input(stale, fresh), requestKey: body.requestKey }, 409, 'loan.duplicate_registration');
|
||||
checks.push('employee_source_version_covers_fields_and_request_key_conflicts');
|
||||
|
||||
// A real login between preview and save (a failed attempt, then a successful
|
||||
// one resetting the counters) touches updated_at only and must not block.
|
||||
const loginWith = (who, password) => app.inject({ method: 'POST', url: '/api/auth/login',
|
||||
headers: { 'x-device-fingerprint': randomUUID() }, payload: { identifier: who.email, password } });
|
||||
const loggedIn = await employee('预览后登录');
|
||||
const beforeLogin = await source(loggedIn);
|
||||
const touched = (await prisma.employee.findUniqueOrThrow({ where: { id: loggedIn.id } })).updated_at;
|
||||
assert.notEqual((await loginWith(loggedIn, `${h.password}-wrong`)).statusCode, 200);
|
||||
assert.equal((await loginWith(loggedIn, h.password)).statusCode, 200);
|
||||
assert.notEqual((await prisma.employee.findUniqueOrThrow({ where: { id: loggedIn.id } })).updated_at.getTime(), touched.getTime(), 'login touches updated_at');
|
||||
assert.equal((await source(loggedIn)).sourceVersion, beforeLogin.sourceVersion);
|
||||
assert.equal((await link(input(loggedIn, beforeLogin))).lender.employeeId, loggedIn.id);
|
||||
// Every real source field still invalidates a preview, with updated_at held.
|
||||
for (const [field, patch] of [['name', { name: '合成员工-改名' }], ['bank_name', { bank_name: '合成建行员工收款资料-改' }],
|
||||
['legal_entity_id', { legal_entity_id: otherCompany.id }], ['bank_institution_id', { bank_institution_id: null }]]) {
|
||||
const who = await employee(`字段-${field}`);
|
||||
const seen = await source(who);
|
||||
const at = (await prisma.employee.findUniqueOrThrow({ where: { id: who.id } })).updated_at;
|
||||
await prisma.employee.update({ where: { id: who.id }, data: { ...patch, updated_at: at } });
|
||||
await link(input(who, seen), 409, 'loan.employee_source_changed');
|
||||
assert.equal(await prisma.loanLender.count({ where: { employee_id: who.id } }), 0, `${field} change blocks the save`);
|
||||
}
|
||||
checks.push('employee_login_between_preview_and_save_allowed_real_field_changes_blocked');
|
||||
|
||||
async function manualFor(who) {
|
||||
const e = await prisma.employee.findUniqueOrThrow({ where: { id: who.id } });
|
||||
return call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name: e.name,
|
||||
|
||||
@@ -12,6 +12,10 @@ module.exports = async function employeeMutations(h, runFixture) {
|
||||
const mutations = [
|
||||
['bank_permission', 'if (!fastify.hasPermission(request.employee, "employee.bank.read") &&\n !fastify.hasPermission(request.employee, "employee.bank.manage"))', 'if (false)'],
|
||||
['source_version', 'if (source.sourceVersion !== expectedSourceVersion)', 'if (false)'],
|
||||
['source_version_name', ' employee.name, employee.legal_entity_id,', ' employee.legal_entity_id,'],
|
||||
// Restores the pre-fix fingerprint (updated_at selected and hashed).
|
||||
['source_version_updated_at', [['status: true, archived_at: true,\n', 'status: true, archived_at: true, updated_at: true,\n'],
|
||||
['"loan-employee-source-v2", legalEntityId, employee.id,', '"loan-employee-source-v2", legalEntityId, employee.id, employee.updated_at,']]],
|
||||
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)'],
|
||||
['employee_lock', 'if (unique.length) await tx.$queryRaw', 'if (false) await tx.$queryRaw'],
|
||||
['employee_lock_fk_compatibility', 'ORDER BY id FOR NO KEY UPDATE', 'ORDER BY id FOR UPDATE'],
|
||||
@@ -22,10 +26,15 @@ module.exports = async function employeeMutations(h, runFixture) {
|
||||
];
|
||||
const results = [];
|
||||
for (const [name, from, to] of mutations) {
|
||||
assert.equal(source.split(from).length, 2, `mutation ${name} must have exactly one target`);
|
||||
const edits = Array.isArray(from) ? from : [[from, to]];
|
||||
let mutated = source;
|
||||
for (const [target, replacement] of edits) {
|
||||
assert.equal(source.split(target).length, 2, `mutation ${name} must have exactly one target`);
|
||||
mutated = mutated.replace(target, replacement);
|
||||
}
|
||||
const mutant = new Module(filename, module); mutant.filename = filename;
|
||||
mutant.paths = Module._nodeModulePaths(path.dirname(filename));
|
||||
mutant._compile(source.replace(from, to), filename);
|
||||
mutant._compile(mutated, filename);
|
||||
let failure;
|
||||
Object.assign(live, mutant.exports);
|
||||
try { await runFixture(h); } catch (error) { failure = error; }
|
||||
@@ -38,10 +47,13 @@ module.exports = async function employeeMutations(h, runFixture) {
|
||||
/^[0-9a-f-]{36}$/.test(failure.actual) && /^[0-9a-f-]{36}$/.test(failure.expected) && failure.actual !== failure.expected;
|
||||
const missingLock = name === 'employee_lock' && (failure.message === 'synthetic employee operation did not actually wait for the row lock' ||
|
||||
failure.code === 'EMPLOYEE_SOURCE_LOCK_NOT_HELD');
|
||||
// Login-only change reported as an employee source change (two differing versions).
|
||||
const loginFalseAlarm = name === 'source_version_updated_at' && failure.code === 'ERR_ASSERTION' &&
|
||||
/^[0-9a-f]{64}$/.test(String(failure.actual)) && /^[0-9a-f]{64}$/.test(String(failure.expected)) && failure.actual !== failure.expected;
|
||||
const fkDeadlock = name === 'employee_lock_fk_compatibility' && failure.code === 'EMPLOYEE_SOURCE_FK_DEADLOCK';
|
||||
const hint = failure.code === 'ERR_ASSERTION' ? String(failure.message).split('\n')[0] : failure.name;
|
||||
assert.ok(wrongAcceptance || duplicateIdentity || missingLock || fkDeadlock, `mutation ${name} failed outside its guard: ${hint} ${failure.code ?? ''}`);
|
||||
results.push({ name, caught: true, witness: wrongAcceptance ? 'unsafe_http_acceptance' : duplicateIdentity ? 'duplicate_employee_lender' : fkDeadlock ? 'observed_postgres_deadlock' : 'missing_row_lock' });
|
||||
assert.ok(wrongAcceptance || duplicateIdentity || missingLock || fkDeadlock || loginFalseAlarm, `mutation ${name} failed outside its guard: ${hint} ${failure.code ?? ''}`);
|
||||
results.push({ name, caught: true, witness: wrongAcceptance ? 'unsafe_http_acceptance' : loginFalseAlarm ? 'login_reported_as_source_change' : duplicateIdentity ? 'duplicate_employee_lender' : fkDeadlock ? 'observed_postgres_deadlock' : 'missing_row_lock' });
|
||||
console.log(JSON.stringify({ stage: 'loan_employee_mutation', ...results.at(-1) }));
|
||||
}
|
||||
console.log(JSON.stringify({ stage: 'loan_employee_mutations', status: 'PASS', count: results.length, sourceFilesChanged: false }));
|
||||
|
||||
@@ -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.
|
||||
|
||||
在新工单中引用
屏蔽一个用户