From 7fb97119bae67d29055d530f988c64a542603c8c 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:09:01 +0000 Subject: [PATCH] =?UTF-8?q?HEL-157:=20=E5=88=A0=E9=99=A4=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E3=80=81=E6=A0=A1=E6=AD=A3=E4=B8=BB=E4=BB=8E?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=B9=B6=E7=BB=9F=E4=B8=80=E5=BE=85=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除柱状图/折线图死代码;主从改为 480px+明细宽栏;待审核首页卡、侧栏角标与后端口径同源。 Co-authored-by: Cursor Co-authored-by: multica-agent --- .gitignore | 4 + src/bank_importer/dashboard.py | 17 +++- tests/test_dashboard.py | 133 +++++++++++++++++++++++++++++ web/admin.html | 102 +++++------------------ web/app.js | 147 ++++++++++----------------------- web/design-system.css | 52 +++++++++++- 6 files changed, 268 insertions(+), 187 deletions(-) diff --git a/.gitignore b/.gitignore index e3171da..e06217b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ nul vendor_pkgs/ node_modules/ package-lock.json + +# local vendor for agent test env (not shipped) +.vendor/ +vendor_wheels/ diff --git a/src/bank_importer/dashboard.py b/src/bank_importer/dashboard.py index 3723649..7dd40bc 100644 --- a/src/bank_importer/dashboard.py +++ b/src/bank_importer/dashboard.py @@ -42,8 +42,11 @@ def audit_counts(connection: sqlite3.Connection) -> dict[str, int]: Priority mapping (aligned with current admin audit UI semantics): - high: unresolved / needs_review match exceptions - - medium: pending bank-account registrations + - medium: pending bank-account registrations + pending manual records - low: reserved for future low-risk queues (currently always 0) + + Homepage card, sidebar badge and audit-center heading must all read this + same payload (via ``/api/admin/dashboard`` → ``audit``). """ high = connection.execute( """ @@ -55,9 +58,19 @@ def audit_counts(connection: sqlite3.Connection) -> dict[str, int]: AND d.classification IN ('unresolved', 'needs_review') """ ).fetchone()["n"] - medium = connection.execute( + medium_accounts = connection.execute( "SELECT COUNT(*) AS n FROM bank_accounts WHERE status = 'pending'" ).fetchone()["n"] + medium_manuals = connection.execute( + """ + SELECT COUNT(*) AS n + FROM manual_records m + JOIN current_manual_record_decisions c ON c.record_id = m.id + JOIN manual_record_decisions d ON d.id = c.decision_id + WHERE d.state = 'pending' + """ + ).fetchone()["n"] + medium = int(medium_accounts) + int(medium_manuals) low = 0 return { "total": int(high) + int(medium) + int(low), diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index b7d0582..026a952 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -57,6 +57,96 @@ class DashboardUnitTests(unittest.TestCase): self.assertEqual(0, counts["high"]) self.assertEqual(1, counts["total"]) + def test_pending_manual_counts_as_medium(self) -> None: + from bank_importer import auth, manual_records + + now = master_data.utc_now() + a = self.connection.execute( + "INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) " + "VALUES ('甲公司', NULL, NULL, 'active', ?, ?)", + (now, now), + ).lastrowid + b = self.connection.execute( + "INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) " + "VALUES ('乙公司', NULL, NULL, 'active', ?, ?)", + (now, now), + ).lastrowid + self.connection.commit() + auth.create_user( + self.connection, "cashier-a", "CashierPass123", "company", company_id=int(a) + ) + actor = self.connection.execute( + "SELECT * FROM users WHERE username = 'cashier-a'" + ).fetchone() + manual_records.submit( + self.connection, + company_id=int(a), + counterparty_company_id=int(b), + occurred_at="2026-02-01T09:00:00", + direction="incoming", + amount="100.00", + currency="CNY", + funding_source="other", + requested_subject="receivable", + request_key="hel157-manual-1", + actor=actor, + ) + counts = dashboard.audit_counts(self.connection) + self.assertEqual(1, counts["medium"]) + self.assertEqual(0, counts["high"]) + self.assertEqual(1, counts["total"]) + + def test_audit_total_equals_queue_sum(self) -> None: + """Homepage / badge / audit heading must share one backend total.""" + now = master_data.utc_now() + a = self.connection.execute( + "INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) " + "VALUES ('甲公司', NULL, NULL, 'active', ?, ?)", + (now, now), + ).lastrowid + self.connection.commit() + master_data.submit_bank_account( + self.connection, + company_id=int(a), + bank_name="工行", + account_type="一般户", + account_number="6222020000000002", + start_date="2026-01-01", + actor=None, + ) + event_id = self.connection.execute( + "INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)", + (now,), + ).lastrowid + decision_id = self.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 + self.connection.execute( + "INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)", + (event_id, decision_id), + ) + self.connection.commit() + + counts = dashboard.audit_counts(self.connection) + payload = dashboard.build_dashboard( + self.connection, from_date="2026-01-01", cutoff="2026-08-20" + ) + self.assertEqual(counts, payload["audit"]) + self.assertEqual( + counts["total"], + counts["high"] + counts["medium"] + counts["low"], + ) + self.assertEqual(1, counts["high"]) + self.assertEqual(1, counts["medium"]) + self.assertEqual(2, counts["total"]) + def test_company_summaries_from_eligible_events(self) -> None: now = master_data.utc_now() a = self.connection.execute( @@ -180,6 +270,49 @@ class DashboardApiTests(unittest.TestCase): self.assertEqual(1, len(data["companies"])) self.assertEqual("甲公司", data["companies"][0]["name"]) + def test_audit_counts_sync_after_account_review(self) -> None: + """Pending account raises dashboard.audit; approve brings total back down.""" + from bank_importer.db import connect as db_connect + from bank_importer import master_data as md + + status, _, raw = self.admin.get("/api/admin/companies") + company_id = as_json(raw)["companies"][0]["id"] + + connection = db_connect(self.db_path) + try: + account = md.submit_bank_account( + connection, + company_id=company_id, + bank_name="工行", + account_type="一般户", + account_number="6222020000000099", + start_date="2026-01-01", + actor=None, + ) + account_id = account["id"] + finally: + connection.close() + + status, _, raw = self.admin.get("/api/admin/dashboard") + before = as_json(raw)["audit"] + self.assertEqual(200, status) + self.assertGreaterEqual(before["medium"], 1) + self.assertGreaterEqual(before["total"], 1) + + status, _, raw = self.admin.post_json( + f"/api/admin/accounts/{account_id}/review", + {"decision": "approve", "reason": "HEL-157 sync test"}, + ) + self.assertEqual(200, status, raw) + + status, _, raw = self.admin.get("/api/admin/dashboard") + after = as_json(raw)["audit"] + self.assertEqual(after["medium"], before["medium"] - 1) + self.assertEqual(after["total"], before["total"] - 1) + self.assertEqual( + after["total"], after["high"] + after["medium"] + after["low"] + ) + 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/admin.html b/web/admin.html index 2289fde..395e633 100644 --- a/web/admin.html +++ b/web/admin.html @@ -5,7 +5,7 @@ 管理端 · 金牛集团 - + @@ -20,7 +20,7 @@ 管理总览 往来查询 - 审核中心6 + 审核中心 流水管理 公司与账号 @@ -82,36 +82,21 @@ -
-
-
- 各公司往来分布期末净值 · 单位:万元 -
-
-
-
-
- 本周资金流入流出集团内往来 · 单位:万元 -
-
-
-
-
-
-
-
+
+
+
公司往来合计单位:万元
-
- +
+
- - - - + + + + @@ -120,20 +105,20 @@
公司明细笔数期末净值状态公司明细笔数期末净值状态
-
-
+
+
公司往来明细选择左侧公司查看会计式分级明细
-
- +
+
- - - - - - + + + + + + @@ -347,51 +332,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
对方 / 摘要笔数期初本期借方本期贷方期末对方 / 摘要笔数期初本期借方本期贷方期末
金牛置业 · 中行尾号 882107-06 至 07-16 无流水流水断档2026-0711 天无流水,与金牛煤业 320 万煤炭采购款往来无法归集,阻断 7 月结账待审核
金牛新能源 ↔ 金牛贸易3 笔单边合计 486 万单边匹配2026-07单边支付无对方对应流水,往来差异挂账,待对方选择流水佐证待审核
金牛煤业 · 手工记录场地押金 42 万科目确认2026-07「应收 / 其他应收」科目待判定,影响科目汇总口径待审核
金牛物流 · 工行基本户期初与上年结转差 28.6 万起算区间校准2026-01 起算期初余额与上年结转不衔接,全年累计数失真,需校准起算区间待审核
金牛贸易 · 手工补录现金缴存 64 万公司手工记录2026-07无银行回单附件,需人工核实后方可入账待审核
diff --git a/web/app.js b/web/app.js index a8eb85c..e341523 100644 --- a/web/app.js +++ b/web/app.js @@ -872,12 +872,14 @@ async function renderAdminAccountReviews() { (result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account")); updateAuditCounts(); updatePendingAccountNotice(); + await refreshAuditCountsFromApi(); } function renderStoredAdminReviews() { if (!$("#auditRows")) return; $$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove()); - readStoredRecords(storageKeys.manual).forEach((record) => appendAdminReviewRow(record, "manual")); + // 管理端待审列表只展示后端权威队列(当前为待复核银行账户等), + // 不再混入 localStorage 演示手工单,避免角标/首页/列表三处口径分裂。 renderAdminAccountReviews(); } @@ -899,26 +901,51 @@ function updateAuditCounts() { const badge = $(".tab-count", button); if (badge) badge.textContent = count; }); - const badge = $('.side-nav a[data-view="audit"] .nav-badge'); - if (badge) { - badge.textContent = unresolved.length; - badge.style.display = unresolved.length ? "" : "none"; - } - const pending = $("#pending-count"); - if (pending) pending.textContent = unresolved.length; const foot = $("#auditFoot"); if (foot) foot.textContent = `共 ${rows.length} 项 · 待审核 ${unresolved.length} 项`; - // Keep dashboard audit card in sync with the live audit table when possible. - // Prefer the authoritative /api/admin/dashboard payload; this is a best-effort - // DOM fallback only when the card already exists and dashboard reload has not run. - if ($("#dashAuditTotal") && !state.dashAuditFromApi) { + // 角标 / 首页待审核卡 / 审核中心标题数统一走后端口径(见 applyAuditCounts)。 + // 此处仅在尚无 API 结果时,用真实列表行数作短暂回退,避免演示写死数字。 + 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); - updateDashAuditCard({ total: unresolved.length, high, medium, low }); + applyAuditCounts({ total: unresolved.length, high, medium, low }); } } +function applyAuditCounts(audit, { fromApi = false } = {}) { + if (!audit) return; + const total = Number(audit.total) || 0; + const high = Number(audit.high) || 0; + const medium = Number(audit.medium) || 0; + const low = Number(audit.low) || 0; + state.dashAudit = { total, high, medium, low }; + if (fromApi) state.dashAuditFromApi = true; + + const totalEl = $("#dashAuditTotal"); + const footEl = $("#dashAuditFoot"); + if (totalEl) totalEl.innerHTML = `${total}`; + if (footEl) footEl.textContent = `高 ${high} 项 · 中 ${medium} 项 · 其余 ${low} 项低风险`; + + const badge = $("#auditNavBadge") || $('.side-nav a[data-view="audit"] .nav-badge'); + if (badge) { + badge.textContent = String(total); + badge.style.display = total > 0 ? "" : "none"; + } + const pending = $("#pending-count"); + if (pending) pending.textContent = String(total); +} + +async function refreshAuditCountsFromApi() { + const from = await resolveDashStartDate(); + const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null); + if (!response?.ok) return null; + const data = await response.json().catch(() => null); + if (!data || data.status !== "ok" || !data.audit) return null; + applyAuditCounts(data.audit, { fromApi: true }); + return data.audit; +} + function companyStatusBadge(status) { if (status === "preparing") return { className: "neutral", label: "筹备中" }; if (status === "disabled") return { className: "danger", label: "已停用" }; @@ -1026,91 +1053,7 @@ function statusPill(label) { } function updateDashAuditCard(audit) { - const totalEl = $("#dashAuditTotal"); - const footEl = $("#dashAuditFoot"); - if (!totalEl || !footEl || !audit) return; - totalEl.innerHTML = `${audit.total ?? 0}`; - footEl.textContent = `高 ${audit.high ?? 0} 项 · 中 ${audit.medium ?? 0} 项 · 其余 ${audit.low ?? 0} 项低风险`; -} - -function renderBarChart(companies) { - const host = $("#dashBarChart"); - if (!host) return; - if (!companies.length) { - host.innerHTML = '
暂无往来分布
归集后将显示各公司期末净值
'; - return; - } - const values = companies.map((c) => Number(c.net_wan) || 0); - const maxAbs = Math.max(1, ...values.map((v) => Math.abs(v))); - const w = 560; - const h = 220; - const padL = 44; - const padR = 12; - const padT = 24; - const padB = 36; - const plotW = w - padL - padR; - const plotH = h - padT - padB; - const zeroY = padT + plotH / 2; - const gap = 10; - const barW = Math.max(12, (plotW - gap * companies.length) / companies.length); - const ticks = [maxAbs, maxAbs / 2, 0, -maxAbs / 2, -maxAbs]; - const tickLines = ticks.map((t) => { - const y = zeroY - (t / maxAbs) * (plotH / 2); - const label = t === 0 ? "0" : (t > 0 ? `+${Math.round(t)}` : `${Math.round(t)}`); - return ` - ${label}`; - }).join(""); - const bars = companies.map((c, i) => { - const v = Number(c.net_wan) || 0; - const x = padL + gap / 2 + i * (barW + gap); - const bh = (Math.abs(v) / maxAbs) * (plotH / 2); - const y = v >= 0 ? zeroY - bh : zeroY; - const color = v >= 0 ? "var(--success)" : "var(--danger)"; - const label = (c.name || "").replace(/河南|有限公司|科技发展/g, "").slice(0, 4) || c.name; - const valY = v >= 0 ? y - 4 : y + bh + 11; - return ` - ${formatWan(v).replace(".00", "")} - ${label}`; - }).join(""); - host.innerHTML = `${tickLines}${bars}`; -} - -function renderLineChart(flow) { - const host = $("#dashLineChart"); - if (!host) return; - const labels = flow?.labels || []; - const inflow = (flow?.inflow_wan || []).map(Number); - const outflow = (flow?.outflow_wan || []).map(Number); - if (!labels.length) { - host.innerHTML = '
暂无周度流水
有归集数据后显示近 7 日流入流出
'; - return; - } - const w = 560; - const h = 220; - const padL = 40; - const padR = 12; - const padT = 20; - const padB = 32; - const plotW = w - padL - padR; - const plotH = h - padT - padB; - const maxV = Math.max(1, ...inflow, ...outflow); - const xAt = (i) => padL + (labels.length === 1 ? plotW / 2 : (i / (labels.length - 1)) * plotW); - const yAt = (v) => padT + plotH - (v / maxV) * plotH; - const pathOf = (series) => series.map((v, i) => `${i ? "L" : "M"}${xAt(i)},${yAt(v)}`).join(" "); - const grid = [0, 0.5, 1].map((t) => { - const y = yAt(maxV * t); - return `${Math.round(maxV * t)}`; - }).join(""); - const xLabels = labels.map((lab, i) => `${lab}`).join(""); - host.innerHTML = `${grid} - - - ${inflow.map((v, i) => ``).join("")} - ${outflow.map((v, i) => ``).join("")} - ${xLabels} - 流入 - 流出 - `; + applyAuditCounts(audit, { fromApi: true }); } function renderDashCompanyRows(companies, selectedId) { @@ -1125,10 +1068,10 @@ function renderDashCompanyRows(companies, selectedId) { tr.className = "clickable"; tr.dataset.companyId = company.id; if (String(company.id) === String(selectedId)) { - tr.style.background = "var(--accent-soft)"; + tr.classList.add("is-selected"); } const nameTd = document.createElement("td"); - nameTd.style.cssText = "white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;"; + nameTd.className = "dash-company-name"; nameTd.title = company.name; nameTd.textContent = company.name; const countTd = document.createElement("td"); @@ -1251,13 +1194,10 @@ async function loadAdminDashboard() { state.dashFrom = data.from_date; state.dashCutoff = data.cutoff; updateDashAuditCard(data.audit); - state.dashAuditFromApi = true; const statusHead = $("#dashStatusHead"); if (statusHead && data.period_month) statusHead.textContent = `${data.period_month}月状态`; const listSub = $("#dashListSub"); if (listSub) listSub.textContent = `${data.from_date} 至 ${data.cutoff} · 单位:万元`; - renderBarChart(state.dashCompanies); - renderLineChart(data.weekly_flow); const selected = state.dashSelectedId || state.dashCompanies[0]?.id; renderDashCompanyRows(state.dashCompanies, selected); if (selected) await loadDashCompanyDetail(selected); @@ -1384,6 +1324,7 @@ function initAdmin() { actionCell.innerHTML = `${approved ? "已通过" : "已驳回"} · 系统管理员`; } updateAuditCounts(); + await refreshAuditCountsFromApi(); showToast("审核结果已记录", approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算", "success"); } diff --git a/web/design-system.css b/web/design-system.css index 86bfb50..a10f46d 100644 --- a/web/design-system.css +++ b/web/design-system.css @@ -213,6 +213,53 @@ p { margin: 0; } .grid-2-1 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); align-items: start; } .grid-1-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); align-items: start; } .grid-3-2 { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; } +/* 管理端首页主从:左栏约 480px(定稿 HEL-153),右侧明细更宽 */ +.grid-480-1 { grid-template-columns: minmax(0, 480px) minmax(0, 1fr); align-items: stretch; } + +.dash-master-split { + gap: 0; + height: 492px; +} +.dash-master-pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.dash-master-pane--list { + border-right: 1px solid var(--border); +} +.dash-master-head { + padding: 10px 14px 8px; + margin-bottom: 0; + gap: 8px; +} +.dash-master-scroll { + border: 0; + border-radius: 0; + flex: 1; + min-height: 0; + overflow: auto; +} +.ds-table.ds-table--compact { + min-width: 0; +} +.ds-table.ds-table--compact th, +.ds-table.ds-table--compact td { + padding: 6px 12px; +} +.ds-table tbody tr.is-selected { + background: var(--accent-soft); +} +.ds-table tbody tr.is-selected:hover { + background: var(--accent-soft); +} +.dash-company-name { + white-space: nowrap; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; +} .row { display: flex; align-items: center; gap: 10px; } .row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .stack { display: flex; flex-direction: column; gap: 14px; } @@ -223,7 +270,10 @@ p { margin: 0; } @media (max-width: 1100px) { .grid-4, .grid-5 { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .grid-3, .grid-2-1, .grid-1-2, .grid-3-2 { grid-template-columns: minmax(0, 1fr); } + .grid-3, .grid-2-1, .grid-1-2, .grid-3-2, .grid-480-1 { grid-template-columns: minmax(0, 1fr); } + .dash-master-split { height: auto; max-height: none; } + .dash-master-pane--list { border-right: 0; border-bottom: 1px solid var(--border); } + .dash-master-pane { max-height: 420px; } } @media (max-width: 860px) { .shell { grid-template-columns: 1fr; }