追回证据刷新期间禁用选择并修复员工资料登录误报(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 行删除
@@ -1,3 +1,15 @@
## 2026-10-10 E:追回证据刷新提示与员工资料误报(已实施、本地验证完成;未提交)
用户批准两项方案后实施,基线 `91fe31f0`;未提交、推送、部署或连接生产,无新迁移。资料拦截规则先由实施会话自查;2026-10-11 另一会话独立只读复核结论 `REVIEWED_NO_BLOCKER`,无阻断项,补跑 12 组服务层内存探针与电脑/手机 4 组组件浏览器检查,两轮 247 项回归与 11 项变异只核对日志未重跑;回执 `review-20261011.json` 中 7 个文件摘要与提交前工作区一致。复核限制:组件补验的 API、身份为合成替身;上线前打开的旧 v1 预览在上线后保存会被要求重新核对(有意拒绝旧版本);未跑完整发布门禁。
- E1 追回证据刷新(原 D2 的 P3):上传成功后对话框仍立即关闭;后台刷新完成前,对账弹窗内所有证据下拉框禁用、占位项显示“正在刷新证据列表…”、下方 `role=status` 提示“刚上传的证据稍后可选”,确认按钮同时禁用(禁用的必填下拉框不参与原生校验,单禁下拉会让空证据离开浏览器)。刷新结束(成功或失败)自动恢复。未采用 D 阶段撤回的“刷新完再关闭”。
- E1 测试:抽出 `uploadWithHeldRefresh`,用门闩挂起批次详情刷新,断言选项仅占位、下拉与确认均禁用、提示可见、零 POST;放行后下拉恢复、含刚上传证据 ID、确认可用。常规回归在第一笔追回(15.00)执行,诊断模式 `refund` 的首段由“复现旧占位项被原生校验拦截”改为同一新行为断言(旧行为已不可能出现,原证据保留在 D 阶段日志)。
- E2 员工资料误报:根因证实——成功登录会清零失败计数,Prisma `@updatedAt` 刷新员工 `updated_at`,而来源版本包含该时间。版本标签升为 `loan-employee-source-v2`,去掉 `updated_at`(不再查询),保留姓名、所属公司、状态、归档、开户行、银行机构(含启用/归档)与账号密文。`employee_no` 由数据库触发器禁止修改,确认页显示但无需入版本;尝试加入时测试被触发器拒绝,已撤回并在注释写明。
- E2 测试:员工预览后真实错误登录再正确登录,断言 `updated_at` 已变而来源版本不变、保存成功;姓名、开户行、所属公司、银行机构四项在保持 `updated_at` 的情况下逐一修改均 409 `loan.employee_source_changed` 且不建出借人。变异由 9 项增至 11 项:版本漏姓名、恢复修复前指纹(多处变异,查询与哈希同时加回 `updated_at`),后者以 `login_reported_as_source_change` 精确识别。
- 只读复核要点:保存写入的字段(姓名、银行机构、开户行、账号)全部在版本内;状态/归档另在读取时拒绝;确认页的公司名称来自公司表,不入员工版本,与修复前一致;已登记出借人与历史付款快照不受影响。
- 结果:员工专项 + 变异 11/11 通过;`refund-diagnostic.log` 电脑/手机五步诊断全部通过;常规完整回归 `full-regression-r1/r2.log` 各退出 0、83 个迁移、12 个阶段、去重 247 项(244 + 登录误报 1 + 电脑/手机刷新提示 2),自有资源清理 PASS;前端 typecheck/build、Prisma validate 通过。过程日志(缺环境变量、粗粒度变异判定、`employee_no` 触发器拒绝、改码前中止的回归)均保留。证据在 `artifacts/qa-flow/loan-employee-source-fingerprint-20261010/`。
- 剩余:员工与公司组合的数据库唯一约束仍待先查存量重复再单独规划;上线随下次发版,发版前跑完整门禁。
## 2026-10-10 D:本地提交收尾(用户已明确授权)
用户已明确授权本次本地提交,范围为两项遗留失败的测试修复与诊断补验、授权列表的分页前筛选及弹窗重开重置,以及本任务记录和既有 backend spec,共 14 个文件。沿用下节已完成的业务验证,提交前补做最终文档、治理、敏感内容和暂存差异检查;具体提交结果以 Git 回执为准。
@@ -212,7 +224,7 @@ cd ../client && npm run build
| 2:技术排查,可与 N5 并行 | 开发确定性复现授权对话框翻页超时 | 复用本任务,先形成局部排查方案。隔离环境构造超过 30 个符合列表条件的出借人,明确目标位于第二页或以后;电脑/手机均断言实际翻页、请求和选项渲染完成,再选择目标并完成授权。留存失败响应、时序及脱敏截图,区分等待竞争、服务端响应和界面问题。仅修改等待方式或未触发该分支的通过不能结案;若实际办理遇到,提升优先级。 |
| 3:业务主线 N6 | 财务申请、网银提交及代账交接;马经理按公司代表身份确认 | N5 完成后,按真实付款安排明确本次金额、逐借款分配及日期;申请 → 确认 → 导出 → 实际网银提交并登记提交详情 → 正式付款回单核验/必要人工确认 → 核销 → 代账交接。以本次已付本金和可定位的银行交易为验收依据,不从余额推定全额还款,不为测试制造付款。扫描付款回单按付款路径的人工依据与确认规则办理。 |
| 4:技术优先项 | 开发评估将回单重检的文件读取/PDF 解析移到全局锁外 | 沿 N3 已记录的 P3 优先顺序设计;锁内保留原件摘要、版本、权限及全部状态重验。用重检与认领/人工核验/执行并发、附件作废/换版等反例证明不使用过期结果,再独立只读复核。工作线程限时 5 秒不是整个持锁时长的上界;不能只凭耗时变短判定修复完成。 |
| 5:技术近期项 | 开发验证员工来源版本的误报,再评估数据库唯一约束 | 来源指纹包含员工 `updated_at`,登录会更新认证字段;先验证“财务预览后员工登录再保存”的冲突,不泛称每次登录必然提示资料变化。修正时须同时保留姓名、公司、状态和银行资料真实变更的拦截。唯一性当前由代码检查与员工行锁保护、缺少员工和公司组合的数据库唯一约束;新增约束前明确归档/空员工语义并检查存量,不直接加迁移或清理真实记录。 |
| 5:技术近期项 | 开发验证员工来源版本的误报,再评估数据库唯一约束 | (E2 已修复误报并经 2026-10-11 独立复核;唯一约束仍待办)来源指纹包含员工 `updated_at`,登录会更新认证字段;先验证“财务预览后员工登录再保存”的冲突,不泛称每次登录必然提示资料变化。修正时须同时保留姓名、公司、状态和银行资料真实变更的拦截。唯一性当前由代码检查与员工行锁保护、缺少员工和公司组合的数据库唯一约束;新增约束前明确归档/空员工语义并检查存量,不直接加迁移或清理真实记录。 |
| 6:线上验收补证 | 运维准备账号与安全凭据来源,财务/马经理补借款角色验收 | 现有通用 live smoke 需要管理员、员工两类账号;凭据通过受控本地环境或既有秘密配置提供,不写聊天和任务记录。先确认检查范围与授权,再执行登录后只读页面/权限检查。通用 smoke 不覆盖本次借款登记及公司代表确认,后者随 N5/N6 留证。真实云 OCR 单列授权和验收;带 OCR 的准备脚本会上传材料,不能当纯只读 smoke 执行。 |
| 7:较低优先级改进 | 开发与财务明确旧记录重检、记账日期、字段提示规则 | 旧记录缺少原云端限制字段,重检不能重建未保存证据,先明确策略再实施;记账日期下限先区分正常还款与历史补记,不直接统一硬卡审批日;第五轮 N1 可统一 OCR 与本地无效编号的提示,但保持整段校验、多页关联和冲突拦截不变。 |
| 8:文档收尾 | 开发提交发布记录及本次清单 | 内容与检查已具备,可在收到明确本地提交授权后单独提交;提交说明引用当前 task。此次梳理不自动提交,也不再次部署。 |
@@ -1,6 +1,15 @@
# 公司借款台账与本金归还
## 2026-10-10 D:授权翻页与追回到账排查(已实施、产品复核及本地补验完成;未提交)
## 2026-10-10 E:追回证据刷新提示与员工资料误报(已实施、本地验证及独立只读复核完成;未提交)
用户批准两项小问题按方案修复,复用本任务,不含数据库唯一约束(需先查存量重复、单独规划)。结果见 implement 顶部 E 节。
- [x] AC52:上传银行证据后对话框立即关闭;刷新完成前证据下拉框与确认按钮均不可用并有可见提示,不发出提交;刷新后可选中刚上传的证据。电脑/手机浏览器验证。
- [x] AC53:财务预览员工资料后员工登录(含失败再成功),保存不再误报“员工资料已变化”。
- [x] AC54:姓名、所属公司、开户行、银行机构、账号的真实变更,以及状态/归档,仍拦截保存;反向变异能被测试发现。
- [x] AC55:资料拦截规则改动经独立只读复核(2026-10-11 `REVIEWED_NO_BLOCKER`,回执 `review-20261011.json`)。
## 2026-10-10 D:授权翻页与追回到账排查(已本地提交 `91fe31f0`,未发布)
用户要求规划“员工授权翻页超时”和“登记追回到账”两项遗留问题。复用本任务作为执行合同,分成 D1/D2 两个独立验收阶段,不把当前父任务 `in_progress` 当作本轮实施批准。规划基线为 `db919dc6`,开始时工作区干净;线上业务基线仍为 `4a5cd8f2`。本阶段目标是查清失败发生在哪一层,并形成能稳定触发相关分支的回归,避免漏走分支的通过掩盖问题。
@@ -24,6 +24,9 @@ export function LoanBankReconciliation({ repayment: r, attachments }: { repaymen
const [details, setDetails] = useState<Awaited<ReturnType<typeof readLoanBankResultDetails>> | null>(null);
const [action, setAction] = useState<string | null>(null); const [target, setTarget] = useState("");
const [pendingIds, setPendingIds] = useState<string[]>([]);
// The upload dialog closes before the batch detail refetch lands; until then a
// just-uploaded document is missing from the evidence lists.
const [refreshingEvidence, setRefreshingEvidence] = useState(false);
const [values, setValues] = useState<Record<string, string>>({}); const [file, setFile] = useState<File | null>(null);
const value = (key: string) => values[key] ?? "";
const set = (key: string, next: string) => setValues((old) => ({ ...old, [key]: next }));
@@ -59,12 +62,16 @@ export function LoanBankReconciliation({ repayment: r, attachments }: { repaymen
if (action === "event" || action === "refund") await saveLoanAction(`${base}/loan-bank-results`, { kind: action === "refund" ? "return" : "late_success", ...(action === "refund" ? { parentId: target } : {}), evidenceId: value("evidence"), requestKey: crypto.randomUUID(), attributionNote: note, exclusionNote: value("exclusion"), fields: [{ Name: "付款账号", Value: value("payer") }, { Name: "收款账号", Value: value("payee") }, { Name: "金额", Value: value("amount") }, { Name: "币种", Value: "CNY" }, { Name: "交易状态", Value: "交易成功" }, { Name: "流水号", Value: value("reference") }] });
if (action === "reviewEvent") await saveLoanAction(`${base}/loan-bank-results/${target}/review`, { ...reviewBody, ...(decision === "reject" && value("evidence") ? { correctionEvidenceId: value("evidence"), correctionKind: value("correction") || "misassociated" } : {}) });
if (action === "apply") await saveLoanAction(`${base}/loan-bank-results/${target}/apply`, {});
}, onSuccess: async () => { if (action === "details") return; setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); } });
}, onSuccess: async () => {
if (action === "details") return; const uploaded = action === "upload"; setAction(null); if (uploaded) setRefreshingEvidence(true);
try { await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); }
finally { if (uploaded) setRefreshingEvidence(false); }
} });
function open(name: string, id = "") { setAction(name); setTarget(id); setValues({}); setPendingIds([]); setDetails(null); setFile(null); mutation.reset(); }
const button = (name: string, id = "") => <Button key={`${name}:${id}`} type="button" variant="outline" onClick={() => open(name, id)}>{actionLabel(name)}</Button>;
const input = (key: string, label: string, type = "text") => <label className="grid gap-2 text-sm" key={key}>{label}<Input required type={type} value={value(key)} onChange={(e) => set(key, e.target.value)} /></label>;
const select = (key: string, label: string, choices: Array<[string, string]>, required = true) => <label className="grid gap-2 text-sm" key={key}>{label}<select aria-label={label} required={required} className="min-h-11 min-w-0 w-full rounded border px-3" value={value(key)} onChange={(e) => set(key, e.target.value)}><option value="">请选择{required ? "" : "(可不选)"}</option>{choices.map(([id, title]) => <option key={id} value={id}>{title}</option>)}</select></label>;
const evidenceSelect = (key: string, label: string, kind: string, required = true) => select(key, label, evidences.filter((e) => !e.voided && e.evidenceKind === kind).map((e) => [e.id, e.originalName]), required);
const select = (key: string, label: string, choices: Array<[string, string]>, required = true, refreshing = false) => <label className="grid gap-2 text-sm" key={key}>{label}<select aria-label={label} required={required} disabled={refreshing} aria-busy={refreshing || undefined} className="min-h-11 min-w-0 w-full rounded border px-3 disabled:opacity-60" value={value(key)} onChange={(e) => set(key, e.target.value)}><option value="">{refreshing ? "正在刷新证据列表…" : `请选择${required ? "" : "(可不选)"}`}</option>{choices.map(([id, title]) => <option key={id} value={id}>{title}</option>)}</select>{refreshing ? <span role="status" className="text-sm text-amber-800">正在刷新证据列表,刚上传的证据稍后可选</span> : null}</label>;
const evidenceSelect = (key: string, label: string, kind: string, required = true) => select(key, label, evidences.filter((e) => !e.voided && e.evidenceKind === kind).map((e) => [e.id, e.originalName]), required, refreshingEvidence);
const exportLabel = (id: string | null) => id ? `第 ${exports.find((e) => e.id === id)?.sequence ?? "未知"} 次导出` : "系统外手工提交";
const submissionLabel = (id: string) => submissions.find((s) => s.id === id)?.bankInstructionRef ?? "提交记录已不可用";
const evidenceLink = (id: string) => <Button type="button" variant="outline" onClick={() => open("download", id)}>证据 {evidences.findIndex((e) => e.id === id) + 1}:{evidences.find((e) => e.id === id)?.originalName ?? "不可用"}</Button>;
@@ -105,7 +112,7 @@ export function LoanBankReconciliation({ repayment: r, attachments }: { repaymen
{action === "reviewEvent" && value("decision") === "reject" ? <>{evidenceSelect("evidence", "误关联或重复登记的更正证据(晚到成功必填)", "bank_terminal", false)}{select("correction", "关闭原因", [["misassociated", "误关联"], ["duplicate", "重复登记"]], false)}</> : null}
{action === "event" || action === "refund" ? <>{evidenceSelect("evidence", "对应的成功银行回单", action === "refund" ? "incoming_return" : "late_success")}{input("payer", "回单付款账号")}{input("payee", "回单收款账号")}{input("amount", "回单人民币金额")}{input("reference", "回单流水号")}{input("exclusion", "为什么不属于其他候选批次或交易")}</> : null}
{!['upload', 'submitRecovery', 'confirm', 'apply'].includes(action ?? '') ? <label className="grid gap-2 text-sm">依据与说明<textarea required minLength={10} maxLength={500} className="min-h-24 rounded border px-3 py-2" value={value("note")} onChange={(e) => set("note", e.target.value)} /></label> : null}
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? "处理中…" : "确认"}</Button>
<Button type="submit" disabled={mutation.isPending || refreshingEvidence}>{mutation.isPending ? "处理中…" : "确认"}</Button>
</form>
</DialogContent></Dialog>
</section>;
@@ -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,
+16 -4
查看文件
@@ -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.
+7 -4
查看文件
@@ -42,7 +42,7 @@ async function assertUniqueEmployeeLender(tx, employeeId, legalEntityId, lenderI
async function readEmployeeSource(db, config, employeeId, legalEntityId) {
const employee = await db.employee.findUnique({ where: { id: employeeId }, select: {
id: true, name: true, employee_no: true, legal_entity_id: true, status: true, archived_at: true, updated_at: true,
id: true, name: true, employee_no: true, legal_entity_id: true, status: true, archived_at: true,
bank_name: true, bank_institution_id: true, bank_account_encrypted: true,
legal_entity: { select: { name: true } },
bank_institution: { select: { id: true, code: true, name: true, enabled: true, archived_at: true } },
@@ -67,10 +67,13 @@ async function readEmployeeSource(db, config, employeeId, legalEntityId) {
else if (!/^\d{10,30}$/.test(bankAccount)) problems.push("account_invalid");
} catch { problems.push("account_unavailable"); }
}
// Bind to all source fields, not just the millisecond timestamp. Ciphertext is
// hashed without exposing it or making a plaintext account hash enumerable.
// Bind to every mutable employee field the preview shows or the save copies
// (employee_no is immutable in the database). updated_at is left out: a
// successful login resets the failure counters and touches it without
// changing any of these. Ciphertext is hashed without exposing it or making
// a plaintext account hash enumerable.
const sourceVersion = createHash("sha256").update(JSON.stringify([
"loan-employee-source-v1", legalEntityId, employee.id, employee.updated_at,
"loan-employee-source-v2", legalEntityId, employee.id,
employee.name, employee.legal_entity_id, employee.status, employee.archived_at,
employee.bank_name, employee.bank_institution_id, employee.bank_account_encrypted, bank,
])).digest("hex");