From e04f47b9d239121bea3b4c7a8cb080b84f8e671d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= Date: Wed, 26 Aug 2026 10:45:03 +0000 Subject: [PATCH] =?UTF-8?q?HEL-157:=20=E5=AE=A1=E6=A0=B8=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E4=B8=89=E7=B1=BB=E5=BE=85=E5=8A=9E=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将待复核账户、待审手工单、匹配异常统一渲染到审核中心列表, 处置走既有 decision API,并补三类种子一致性与处置减一集成测试。 Co-authored-by: Cursor Co-authored-by: multica-agent --- tests/test_dashboard.py | 161 +++++++++++++++++++++++ web/app.js | 275 +++++++++++++++++++++++++++++++--------- 2 files changed, 379 insertions(+), 57 deletions(-) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 026a952..2830685 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -313,6 +313,167 @@ class DashboardApiTests(unittest.TestCase): after["total"], after["high"] + after["medium"] + after["low"] ) + def test_three_queue_audit_parity_and_dispose_sync(self) -> None: + """Seed account+manual+exception: list sizes == dashboard.audit; dispose syncs -1.""" + from bank_importer.db import connect as db_connect + from bank_importer import auth, manual_records, master_data as md + + status, _, raw = self.admin.get("/api/admin/companies") + company_id = as_json(raw)["companies"][0]["id"] + + # Second company for manual counterparty. + status, _, raw = self.admin.post_json( + "/api/admin/companies", {"name": "乙公司", "username": "cashier-b-hel157"} + ) + self.assertEqual(200, status, raw) + company_b = as_json(raw).get("company_id") or as_json(raw).get("id") + self.assertIsNotNone(company_b) + + connection = db_connect(self.db_path) + try: + account = md.submit_bank_account( + connection, + company_id=company_id, + bank_name="工行", + account_type="一般户", + account_number="6222020000000157", + start_date="2026-01-01", + actor=None, + ) + account_id = account["id"] + + cashier = connection.execute( + "SELECT * FROM users WHERE username = 'cashier-a'" + ).fetchone() + if cashier is None: + auth.create_user( + connection, + "cashier-a", + "CashierPass123", + "company", + company_id=company_id, + ) + cashier = connection.execute( + "SELECT * FROM users WHERE username = 'cashier-a'" + ).fetchone() + manual = manual_records.submit( + connection, + company_id=company_id, + counterparty_company_id=int(company_b), + occurred_at="2026-02-01T09:00:00", + direction="incoming", + amount="100.00", + currency="CNY", + funding_source="other", + requested_subject="receivable", + request_key="hel157-queue-manual", + actor=cashier, + ) + + now = md.utc_now() + event_id = connection.execute( + "INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)", + (now,), + ).lastrowid + decision_id = connection.execute( + """ + INSERT INTO transfer_match_decisions ( + event_id, revision, effective_at, amount, currency, classification, + pairing, locked, mode, rule_version, created_at + ) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'unresolved', + 'single', 0, 'manual', 'test', ?) + """, + (event_id, now), + ).lastrowid + connection.execute( + "INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)", + (event_id, decision_id), + ) + connection.commit() + manual_id = manual["id"] + manual_decision_id = manual["decision_id"] + finally: + connection.close() + + def queue_sizes(): + st, _, body = self.admin.get("/api/admin/accounts?status=pending") + self.assertEqual(200, st, body) + accounts_n = len(as_json(body)["accounts"]) + st, _, body = self.admin.get("/api/admin/manual-records?state=pending") + self.assertEqual(200, st, body) + manuals_n = len(as_json(body)["records"]) + st, _, body = self.admin.get("/api/admin/match-exceptions") + self.assertEqual(200, st, body) + exceptions_n = len(as_json(body)["exceptions"]) + return accounts_n, manuals_n, exceptions_n, accounts_n + manuals_n + exceptions_n + + def assert_parity(expected_total: int) -> dict: + st, _, body = self.admin.get("/api/admin/dashboard") + self.assertEqual(200, st, body) + audit = as_json(body)["audit"] + accounts_n, manuals_n, exceptions_n, list_total = queue_sizes() + self.assertEqual(expected_total, audit["total"]) + self.assertEqual(expected_total, list_total) + self.assertEqual( + audit["total"], audit["high"] + audit["medium"] + audit["low"] + ) + self.assertGreaterEqual(accounts_n, 1 if expected_total >= 3 else 0) + return { + "audit": audit, + "accounts": accounts_n, + "manuals": manuals_n, + "exceptions": exceptions_n, + } + + before = assert_parity(3) + self.assertEqual(1, before["accounts"]) + self.assertEqual(1, before["manuals"]) + self.assertEqual(1, before["exceptions"]) + self.assertEqual(1, before["audit"]["high"]) + self.assertEqual(2, before["audit"]["medium"]) + + # Dispose account → total 2 + st, _, body = self.admin.post_json( + f"/api/admin/accounts/{account_id}/review", + {"decision": "approve", "reason": "HEL-157 three-queue dispose account"}, + ) + self.assertEqual(200, st, body) + after_account = assert_parity(2) + + # Dispose manual → total 1 + st, _, body = self.admin.post_json( + f"/api/admin/manual-records/{manual_id}/decisions", + { + "action": "approve_new", + "reason": "HEL-157 three-queue dispose manual", + "expected_decision_id": manual_decision_id, + "request_key": "hel157-dispose-manual", + }, + ) + self.assertEqual(200, st, body) + after_manual = assert_parity(1) + self.assertEqual(0, after_manual["manuals"]) + + # Dispose match exception via reverse → total 0 + st, _, body = self.admin.get("/api/admin/match-exceptions") + exceptions = as_json(body)["exceptions"] + self.assertEqual(1, len(exceptions)) + target = exceptions[0] + st, _, body = self.admin.post_json( + f"/api/admin/transfer-events/{target['event_id']}/decisions", + { + "action": "reverse", + "reason": "HEL-157 three-queue dispose match", + "expected_revision": target["revision"], + "request_key": "hel157-dispose-match", + }, + ) + self.assertEqual(200, st, body) + after_match = assert_parity(0) + self.assertEqual(0, after_match["exceptions"]) + self.assertEqual(after_account["audit"]["total"] - 1, after_manual["audit"]["total"]) + self.assertEqual(after_manual["audit"]["total"] - 1, after_match["audit"]["total"]) + def test_company_detail_missing(self) -> None: status, _, raw = self.admin.get( "/api/admin/dashboard/companies/999999?from=2026-01-01&cutoff=2026-08-20" diff --git a/web/app.js b/web/app.js index e341523..b981355 100644 --- a/web/app.js +++ b/web/app.js @@ -793,23 +793,48 @@ async function loadCompanyAccounts() { else showTableError(tbody, 6); } +const manualSubjectLabels = { + receivable: "应收", + payable: "应付", + other_receivable: "其他应收", + other_payable: "其他应付", +}; + +const manualDirectionLabels = { + incoming: "收入", + outgoing: "支出", +}; + function appendAdminReviewRow(record, kind) { const tbody = $("#auditRows"); if (!tbody) return; const isAccount = kind === "account"; - const statusLabel = isAccount ? accountStatusLabel(record.status) : record.status; + const isManual = kind === "manual"; + const statusLabel = isAccount + ? accountStatusLabel(record.status) + : (record.state === "pending" ? "待管理复核" : (record.state || "待复核")); const row = document.createElement("tr"); - row.dataset.storedReview = record.id; - row.dataset.recordId = record.id; + row.dataset.storedReview = String(record.id); + row.dataset.recordId = String(record.id); row.dataset.recordKind = kind; - if (isAccount) row.dataset.accountId = record.id; + if (isAccount) row.dataset.accountId = String(record.id); + if (isManual) { + row.dataset.decisionId = String(record.decision_id || ""); + row.dataset.requestedSubject = record.requested_subject || ""; + } row.dataset.auditType = isAccount ? "账户" : "手工"; - row.dataset.company = isAccount ? record.company_name : record.company; + row.dataset.company = isAccount + ? (record.company_name || "") + : (record.company_name || record.company || ""); row.dataset.evidence = isAccount ? "公司提交资料、开户行、账号、账户类型与启用日期" : "公司手工记录、关联银行流水号、证明附件与提交说明"; - if (statusLabel !== "待复核" && statusLabel !== "待管理复核") row.dataset.resolved = "true"; - if (isAccount) row.dataset.accountStatus = record.status; + if (isAccount) { + if (record.status !== "pending") row.dataset.resolved = "true"; + row.dataset.accountStatus = record.status; + } else if (record.state && record.state !== "pending") { + row.dataset.resolved = "true"; + } const riskCell = document.createElement("td"); riskCell.innerHTML = ''; @@ -821,8 +846,11 @@ function appendAdminReviewRow(record, kind) { identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`; detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`; } else { - identity.textContent = `${record.company} · ${record.id}`; - detail.textContent = `${record.direction} ${formatCurrency(record.amount)} 元 · ${record.counterparty} · ${record.subject}`; + const subject = manualSubjectLabels[record.requested_subject] || record.requested_subject || "—"; + const direction = manualDirectionLabels[record.direction] || record.direction || ""; + const counterparty = record.counterparty_company_name || record.counterparty || "—"; + identity.textContent = `${row.dataset.company} · 手工单 #${record.id}`; + detail.textContent = `${direction} ${formatCurrency(record.amount)} 元 · ${counterparty} · ${subject}`; } identityCell.append(identity, detail); @@ -830,11 +858,18 @@ function appendAdminReviewRow(record, kind) { typeCell.textContent = isAccount ? "账户登记" : "手工记录"; const periodCell = document.createElement("td"); - periodCell.textContent = isAccount ? (record.effective_from || "待审核确定") : record.transactionDate; + if (isAccount) { + periodCell.textContent = record.effective_from || "待审核确定"; + } else { + periodCell.className = "num"; + periodCell.textContent = String(record.occurred_at || record.transactionDate || "").slice(0, 10) || "—"; + } const impactCell = document.createElement("td"); impactCell.className = "wrap"; - impactCell.textContent = isAccount ? "账户识别与流水上传" : `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`; + impactCell.textContent = isAccount + ? "账户识别与流水上传" + : `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`; const statusCell = document.createElement("td"); const status = recordStatus(statusLabel); @@ -848,7 +883,7 @@ function appendAdminReviewRow(record, kind) { } else { const resolvedLabel = isAccount ? ({ returned: "已退回", disabled: "已停用" })[record.status] || "已通过" - : (statusLabel === "已确认" ? "已通过" : "已驳回"); + : (statusLabel === "已确认" || record.state === "approved" ? "已通过" : "已驳回"); actionCell.innerHTML = `${resolvedLabel} · 系统管理员`; } } else { @@ -859,41 +894,108 @@ function appendAdminReviewRow(record, kind) { tbody.append(row); } -async function renderAdminAccountReviews() { +function appendMatchExceptionRow(item) { const tbody = $("#auditRows"); if (!tbody) return; - $$('[data-stored-review][data-record-kind="account"]', tbody).forEach((row) => row.remove()); - const response = await fetch("/api/admin/accounts").catch(() => null); - if (!response?.ok) { - showToast("审核数据加载失败", "请稍后重试", "danger"); + const payer = item.payer_company_name || "—"; + const payee = item.payee_company_name || "—"; + const companyLabel = payer !== "—" ? payer : payee; + const row = document.createElement("tr"); + row.dataset.storedReview = `match-${item.event_id}`; + row.dataset.recordId = String(item.event_id); + row.dataset.recordKind = "match"; + row.dataset.eventId = String(item.event_id); + row.dataset.revision = String(item.revision ?? ""); + row.dataset.auditType = "单边"; + row.dataset.company = companyLabel; + row.dataset.evidence = "匹配异常事件、观察流水、参与方与历史决定"; + row.dataset.classification = item.classification || ""; + + const riskCell = document.createElement("td"); + riskCell.innerHTML = ''; + + const identityCell = document.createElement("td"); + const identity = document.createElement("span"); identity.className = "cell-main"; + const detail = document.createElement("span"); detail.className = "cell-sub"; + identity.textContent = `${payer} ↔ ${payee} · 事件 #${item.event_id}`; + detail.textContent = `${item.classification || item.status || "unresolved"} · ${formatCurrency(item.amount)} ${item.currency || "CNY"} · 证据 ${item.evidence_count ?? 0} 条`; + identityCell.append(identity, detail); + + const typeCell = document.createElement("td"); + typeCell.textContent = "单边匹配"; + + const periodCell = document.createElement("td"); + periodCell.className = "num"; + periodCell.textContent = String(item.effective_at || "").slice(0, 10) || "—"; + + const impactCell = document.createElement("td"); + impactCell.className = "wrap"; + impactCell.textContent = `匹配异常 ${(Number(item.amount) / 10000).toFixed(2)} 万元 · 阻断往来归集`; + + const statusCell = document.createElement("td"); + statusCell.innerHTML = '待处理'; + + const actionCell = document.createElement("td"); + actionCell.innerHTML = '
'; + + row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell); + tbody.append(row); +} + +async function loadAdminAuditQueue() { + const tbody = $("#auditRows"); + if (!tbody) return; + $$('[data-stored-review]', tbody).forEach((row) => row.remove()); + showTableLoading(tbody, 7); + + const [accountsRes, manualsRes, exceptionsRes] = await Promise.all([ + fetch("/api/admin/accounts?status=pending").catch(() => null), + fetch("/api/admin/manual-records?state=pending").catch(() => null), + fetch("/api/admin/match-exceptions").catch(() => null), + ]); + + if ([accountsRes, manualsRes, exceptionsRes].some((res) => res?.status === 401)) { + window.location.href = "index.html"; return; } - const result = await response.json().catch(() => null); - (result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account")); + if (![accountsRes, manualsRes, exceptionsRes].every((res) => res?.ok)) { + showTableError(tbody, 7); + showToast("审核队列加载失败", "请稍后重试", "danger"); + return; + } + + const [accountsPayload, manualsPayload, exceptionsPayload] = await Promise.all([ + accountsRes.json().catch(() => null), + manualsRes.json().catch(() => null), + exceptionsRes.json().catch(() => null), + ]); + + tbody.replaceChildren(); + (accountsPayload?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account")); + (manualsPayload?.records || []).forEach((record) => appendAdminReviewRow(record, "manual")); + (exceptionsPayload?.exceptions || []).forEach((item) => appendMatchExceptionRow(item)); + updateAuditCounts(); updatePendingAccountNotice(); await refreshAuditCountsFromApi(); + // 列表条数必须与后端口径一致:不一致时以真实列表为准覆盖标题/角标,避免再出现「数字 3 / 列表 1」。 + const unresolved = $$("#auditRows tr").filter((row) => row.dataset.resolved !== "true"); + if (!state.dashAudit || Number(state.dashAudit.total) !== unresolved.length) { + const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length; + const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length; + const low = Math.max(0, unresolved.length - high - medium); + applyAuditCounts({ total: unresolved.length, high, medium, low }); + } } function renderStoredAdminReviews() { if (!$("#auditRows")) return; - $$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove()); - // 管理端待审列表只展示后端权威队列(当前为待复核银行账户等), - // 不再混入 localStorage 演示手工单,避免角标/首页/列表三处口径分裂。 - renderAdminAccountReviews(); -} - -function updateStoredReview(kind, id, status, decision, reviewReason, reviewedAt) { - if (kind !== "manual" || !id) return; - const records = readStoredRecords(storageKeys.manual); - const record = records.find((item) => item.id === id); - if (!record) return; - Object.assign(record, { status, decision, reviewReason, reviewedAt }); - writeStoredRecords(storageKeys.manual, records); + // 完整队列:待复核账户 + 待审手工单 + 匹配异常,三处口径同源。 + loadAdminAuditQueue(); } function updateAuditCounts() { - const rows = $$("#auditRows tr"); + const rows = $$("#auditRows tr").filter((row) => !row.classList.contains("loading-row")); const unresolved = rows.filter((row) => row.dataset.resolved !== "true"); $$('[data-audit-filter]').forEach((button) => { const type = button.dataset.auditFilter; @@ -902,14 +1004,16 @@ function updateAuditCounts() { if (badge) badge.textContent = count; }); const foot = $("#auditFoot"); - if (foot) foot.textContent = `共 ${rows.length} 项 · 待审核 ${unresolved.length} 项`; - // 角标 / 首页待审核卡 / 审核中心标题数统一走后端口径(见 applyAuditCounts)。 - // 此处仅在尚无 API 结果时,用真实列表行数作短暂回退,避免演示写死数字。 + if (foot) foot.textContent = `共 ${unresolved.length} 项 · 待审核 ${unresolved.length} 项`; + // 角标 / 首页待审核卡 / 审核中心标题数:优先后端口径;列表加载完成后由 loadAdminAuditQueue 再对齐。 if (!state.dashAuditFromApi) { const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length; const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length; const low = Math.max(0, unresolved.length - high - medium); applyAuditCounts({ total: unresolved.length, high, medium, low }); + } else { + const pending = $("#pending-count"); + if (pending) pending.textContent = String(unresolved.length); } } @@ -1283,12 +1387,13 @@ function initAdmin() { async function submitAuditResult(row) { const decision = state.auditDecision; const reason = state.auditReason || ""; - const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用"); + const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用") || decision.includes("关闭异常"); const returned = decision.includes("退回"); + const kind = row.dataset.recordKind; let storedStatus; let reviewedAccount = null; - if (row.dataset.recordKind === "account" && row.dataset.accountId) { - // Server-side review: the account only becomes usable after this succeeds. + + if (kind === "account" && row.dataset.accountId) { const apiDecision = approved ? "approve" : returned ? "return" : "disable"; const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, { method: "POST", @@ -1306,26 +1411,72 @@ function initAdmin() { } reviewedAccount = result.account; storedStatus = accountStatusLabel(result.account?.status); + } else if (kind === "manual" && row.dataset.recordId) { + const action = approved ? "approve_new" : "return"; + const response = await fetch(`/api/admin/manual-records/${row.dataset.recordId}/decisions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + reason: reason || decision, + expected_decision_id: row.dataset.decisionId ? Number(row.dataset.decisionId) : null, + request_key: `audit-manual-${row.dataset.recordId}-${Date.now()}`, + subject_code: row.dataset.requestedSubject || undefined, + }), + }).catch(() => null); + if (response?.status === 401) { + window.location.href = "index.html"; + return; + } + const result = await response?.json().catch(() => ({})); + if (!response || !response.ok) { + showToast("手工单审核失败", result?.message || "请稍后重试", "danger"); + return; + } + storedStatus = approved ? "已确认" : "已退回"; + } else if (kind === "match" && row.dataset.eventId) { + // 既有 decision:撤销当前匹配决定,使异常退出待办队列(与 reverse 语义一致)。 + const response = await fetch(`/api/admin/transfer-events/${row.dataset.eventId}/decisions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "reverse", + reason: reason || decision || "审核中心关闭匹配异常", + expected_revision: row.dataset.revision ? Number(row.dataset.revision) : null, + request_key: `audit-match-${row.dataset.eventId}-${Date.now()}`, + }), + }).catch(() => null); + if (response?.status === 401) { + window.location.href = "index.html"; + return; + } + const result = await response?.json().catch(() => ({})); + if (!response || !response.ok) { + showToast("匹配异常处置失败", result?.message || "请稍后重试", "danger"); + return; + } + storedStatus = approved ? "已确认" : "已退回"; } else { - storedStatus = approved - ? (row.dataset.recordKind === "account" ? "已启用" : "已确认") - : (returned ? "已退回" : "异常待处理"); - updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, reason, new Date().toLocaleString("zh-CN", { hour12: false })); - } - const status = recordStatus(storedStatus); - const statusCell = row.children[5]; - statusCell.innerHTML = `${status.label}`; - row.dataset.resolved = "true"; - if (reviewedAccount) row.dataset.accountStatus = reviewedAccount.status; - const actionCell = row.children[6]; - if (reviewedAccount && reviewedAccount.status === "active") { - actionCell.innerHTML = ''; - } else { - actionCell.innerHTML = `${approved ? "已通过" : "已驳回"} · 系统管理员`; + showToast("审核结果提交失败", "未知待办类型", "danger"); + return; } + + // 处置后从列表移除并重拉三队列,保证页脚/过滤/标题与角标同步减一。 + row.remove(); updateAuditCounts(); await refreshAuditCountsFromApi(); - showToast("审核结果已记录", approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算", "success"); + const unresolved = $$("#auditRows tr").filter((r) => r.dataset.resolved !== "true"); + const high = unresolved.filter((r) => r.querySelector(".pill-danger")).length; + const medium = unresolved.filter((r) => r.querySelector(".pill-warn")).length; + const low = Math.max(0, unresolved.length - high - medium); + applyAuditCounts({ total: unresolved.length, high, medium, low }, { fromApi: true }); + updatePendingAccountNotice(); + filterAuditRows(); + showToast( + "审核结果已记录", + approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算", + "success", + ); } $("#auditRows")?.addEventListener("click", (event) => { @@ -1359,7 +1510,12 @@ function initAdmin() { $("#approve-confirm")?.addEventListener("click", () => { if (!state.auditRow) return; closeModal("modal-approve"); - state.auditDecision = state.auditRow.dataset.recordKind === "account" ? "复核通过并启用账户" : "确认并纳入计算"; + const kind = state.auditRow.dataset.recordKind; + state.auditDecision = kind === "account" + ? "复核通过并启用账户" + : kind === "match" + ? "关闭异常" + : "确认并纳入计算"; state.auditReason = state.auditDecision; submitAuditResult(state.auditRow); }); @@ -1369,7 +1525,12 @@ function initAdmin() { const reason = $("#reject-reason").value.trim(); if (reason.length < 5) { $("#reject-hint").style.display = ""; return; } closeModal("modal-reject"); - state.auditDecision = state.auditRow.dataset.recordKind === "account" ? "退回公司修改" : "退回公司补充材料"; + const kind = state.auditRow.dataset.recordKind; + state.auditDecision = kind === "account" + ? "退回公司修改" + : kind === "match" + ? "退回重匹配" + : "退回公司补充材料"; state.auditReason = reason; submitAuditResult(state.auditRow); });