diff --git a/src/bank_importer/dashboard.py b/src/bank_importer/dashboard.py index 46e7a42..bd0243c 100644 --- a/src/bank_importer/dashboard.py +++ b/src/bank_importer/dashboard.py @@ -8,6 +8,7 @@ as null so the UI shows an em dash rather than a guessed label. from __future__ import annotations +from calendar import monthrange from datetime import datetime, timedelta, timezone from decimal import Decimal, ROUND_HALF_UP import sqlite3 @@ -22,6 +23,12 @@ def today_shanghai() -> str: return datetime.now(timezone(timedelta(hours=8))).date().isoformat() +def _month_bounds(year_month: str) -> tuple[str, str]: + year, month = (int(part) for part in year_month.split("-")) + last = monthrange(year, month)[1] + return f"{year_month}-01", f"{year_month}-{last:02d}" + + def _q2(value: Decimal) -> str: return str(value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)) @@ -209,6 +216,108 @@ def company_summaries( return items, totals +def period_progress( + connection: sqlite3.Connection, *, year_month: str +) -> dict[str, object]: + """Per-company coverage of the cutoff month: done / in progress / unsubmitted. + + Only companies with at least one active bank account are counted — the same + rule as monthly-close submission checks. Covered = confirmed source rows in + the month, or an approved no-business attestation that spans the month. + """ + start, end = _month_bounds(year_month) + enabled_rows = connection.execute( + "SELECT DISTINCT company_id FROM bank_accounts WHERE status = 'active'" + ).fetchall() + enabled_ids = {int(row["company_id"]) for row in enabled_rows} + empty = { + "year_month": year_month, + "enabled_count": 0, + "done": 0, + "in_progress": 0, + "unsubmitted": 0, + "percent": 0, + } + if not enabled_ids: + return empty + + submitted = { + int(row["company_id"]) + for row in connection.execute( + """ + SELECT DISTINCT b.company_id AS company_id + FROM source_rows r + JOIN sheet_batches s ON s.id = r.sheet_batch_id + JOIN import_batches b ON b.id = s.import_batch_id + JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed' + WHERE date(r.transaction_at) >= date(?) AND date(r.transaction_at) <= date(?) + """, + (start, end), + ).fetchall() + } + attested = { + int(row["company_id"]) + for row in connection.execute( + """ + SELECT DISTINCT a.company_id AS company_id + FROM no_business_attestations a + WHERE a.status = 'approved' + AND date(a.gap_start) <= date(?) + AND date(a.gap_end) >= date(?) + """, + (end, start), + ).fetchall() + } + covered = (submitted | attested) & enabled_ids + + pending: set[int] = set() + for row in connection.execute( + """ + SELECT DISTINCT p.company_id AS company_id + FROM current_transfer_decisions c + JOIN transfer_match_decisions d ON d.id = c.decision_id + JOIN canonical_transfer_events e ON e.id = c.event_id + JOIN transfer_decision_participants p ON p.decision_id = d.id + WHERE e.lifecycle = 'active' + AND d.classification IN ('unresolved', 'needs_review') + AND date(d.effective_at) >= date(?) AND date(d.effective_at) <= date(?) + """, + (start, end), + ): + pending.add(int(row["company_id"])) + for row in connection.execute( + """ + SELECT DISTINCT m.company_id AS company_id + 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' + AND date(m.occurred_at) >= date(?) AND date(m.occurred_at) <= date(?) + """, + (start, end), + ): + pending.add(int(row["company_id"])) + for row in connection.execute( + "SELECT DISTINCT company_id FROM bank_accounts WHERE status = 'pending'" + ): + pending.add(int(row["company_id"])) + + pending_enabled = pending & enabled_ids + done = covered - pending_enabled + in_progress = pending_enabled + unsubmitted = enabled_ids - covered - pending_enabled + enabled_count = len(enabled_ids) + percent = round(100 * len(done) / enabled_count) if enabled_count else 0 + return { + "year_month": year_month, + "enabled_count": enabled_count, + "done": len(done), + "in_progress": len(in_progress), + "unsubmitted": len(unsubmitted), + "percent": percent, + } + + def company_peer_groups( connection: sqlite3.Connection, company_id: int, @@ -378,13 +487,15 @@ def build_dashboard( companies, totals = company_summaries( connection, from_date=from_date, cutoff=cutoff_date ) + period_label = f"{period.year}-{period.month:02d}" return { "from_date": from_date, "cutoff": cutoff_date, "period_month": period_month, - "period_label": f"{period.year}-{period.month:02d}", + "period_label": period_label, "audit": audit_counts(connection), "totals": totals, + "period_progress": period_progress(connection, year_month=period_label), "companies": companies, "weekly_flow": weekly_flow(connection, cutoff=cutoff_date), "opening_status": "unavailable", diff --git a/tests/test_company_confirm_status_color.py b/tests/test_company_confirm_status_color.py index 6253336..ec3c497 100644 --- a/tests/test_company_confirm_status_color.py +++ b/tests/test_company_confirm_status_color.py @@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase): self.assertIn('id="workspacePendingStatus"', html) self.assertIn('id="workspaceFlowSub"', html) self.assertIn('data-view-link="reconcile"', html) - self.assertIn("app.js?v=16", html) + self.assertIn("app.js?v=17", html) # 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"') diff --git a/tests/test_company_transfers_page.py b/tests/test_company_transfers_page.py index 91493a3..2a0cf6b 100644 --- a/tests/test_company_transfers_page.py +++ b/tests/test_company_transfers_page.py @@ -31,8 +31,8 @@ class TransfersPageSourceContractTests(unittest.TestCase): self.assertIn('id="transferEvidenceDrawer"', html) self.assertIn("期间净变动", html) self.assertNotIn("本公司往来合计", html) - self.assertIn("design-system.css?v=11", html) - self.assertIn("app.js?v=16", html) + self.assertIn("design-system.css?v=12", html) + self.assertIn("app.js?v=17", html) # 侧栏顺序:流水管理 → 转账往来 → 往来确认 flows = html.index('data-view="flows"') transfers = html.index('data-view="transfers"') diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2830685..294eab9 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -33,6 +33,42 @@ class DashboardUnitTests(unittest.TestCase): self.assertEqual(0, payload["audit"]["total"]) self.assertEqual([], payload["companies"]) self.assertEqual(7, len(payload["weekly_flow"]["labels"])) + self.assertEqual(0, payload["totals"]["company_count"]) + self.assertEqual(0, payload["totals"]["detail_count"]) + self.assertEqual("0.00", payload["totals"]["debit_wan"]) + self.assertEqual("0.00", payload["totals"]["credit_wan"]) + progress = payload["period_progress"] + self.assertEqual("2026-08", progress["year_month"]) + self.assertEqual(0, progress["enabled_count"]) + self.assertEqual(0, progress["done"]) + self.assertEqual(0, progress["in_progress"]) + self.assertEqual(0, progress["unsubmitted"]) + self.assertEqual(0, progress["percent"]) + + def test_period_progress_unsubmitted_when_account_active_no_flows(self) -> None: + now = master_data.utc_now() + cursor = self.connection.execute( + "INSERT INTO companies (name, credit_code, cashier_name, status, created_at, updated_at) " + "VALUES ('甲公司', NULL, NULL, 'active', ?, ?)", + (now, now), + ) + company_id = int(cursor.lastrowid) + self.connection.execute( + """ + INSERT INTO bank_accounts ( + company_id, account_number, bank_name, account_type, status, + created_at, updated_at + ) VALUES (?, '6222020000000099', '工行', '一般户', 'active', ?, ?) + """, + (company_id, now, now), + ) + self.connection.commit() + progress = dashboard.period_progress(self.connection, year_month="2026-08") + self.assertEqual(1, progress["enabled_count"]) + self.assertEqual(0, progress["done"]) + self.assertEqual(0, progress["in_progress"]) + self.assertEqual(1, progress["unsubmitted"]) + self.assertEqual(0, progress["percent"]) def test_pending_account_counts_as_medium(self) -> None: now = master_data.utc_now() @@ -267,6 +303,9 @@ class DashboardApiTests(unittest.TestCase): self.assertEqual(200, status) self.assertEqual("ok", data["status"]) self.assertIn("audit", data) + self.assertIn("totals", data) + self.assertIn("period_progress", data) + self.assertIn("debit_wan", data["totals"]) self.assertEqual(1, len(data["companies"])) self.assertEqual("甲公司", data["companies"][0]["name"]) @@ -494,5 +533,39 @@ class DashboardApiTests(unittest.TestCase): self.assertEqual([], detail["groups"]) +class DashboardPageContractTests(unittest.TestCase): + """HEL-343 阻断项:管理总览 KPI 不得写死演示金额。""" + + def test_admin_html_kpi_placeholders_not_demo_amounts(self) -> None: + html = (Path(__file__).resolve().parents[1] / "web" / "admin.html").read_text( + encoding="utf-8" + ) + self.assertIn('id="dashPageSub"', html) + self.assertIn('id="dashDebitValue"', html) + self.assertIn('id="dashCreditValue"', html) + self.assertIn('id="dashPeriodPercent"', html) + self.assertIn('id="dashPeriodBar"', html) + self.assertIn('id="dashPeriodFoot"', html) + self.assertNotIn("42,040.30", html) + self.assertNotIn("39,040.30", html) + self.assertNotIn("8.11 亿", html) + self.assertNotIn("392 笔", html) + self.assertNotIn("3 家已完成 · 2 家在途 · 1 家未提交", html) + self.assertNotRegex(html, r'id="flowStart"[^>]*value="2026-07-01"') + self.assertIn('id="pending-check-all"', html) + self.assertIn("pending-check-all", html) + + def test_app_js_binds_dashboard_totals(self) -> None: + js = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text( + encoding="utf-8" + ) + self.assertIn("function applyDashKpis(", js) + self.assertIn("applyDashKpis(data)", js) + self.assertIn("dashDebitValue", js) + self.assertIn("dashCreditValue", js) + self.assertIn("period_progress", js) + self.assertIn("master.disabled = boxes.length === 0", js) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_reminders_page.py b/tests/test_reminders_page.py index 5858360..fd99ddb 100644 --- a/tests/test_reminders_page.py +++ b/tests/test_reminders_page.py @@ -37,8 +37,8 @@ class RemindersPageSourceContractTests(unittest.TestCase): self.assertIn('id="reminder-tbody"', html) self.assertIn('id="reminder-tabs"', html) self.assertIn('id="reminder-detail-drawer"', html) - self.assertIn("design-system.css?v=11", html) - self.assertIn("app.js?v=16", html) + self.assertIn("design-system.css?v=12", html) + self.assertIn("app.js?v=17", html) pending = html.index('id="pending-reminders-card"') history = html.index('id="reminder-history-card"') send = html.index('id="send-reminder-card"') @@ -135,7 +135,7 @@ class RemindersPageLayoutSmokeTests(unittest.TestCase): def test_send_flow_columns_and_no_page_overflow(self) -> None: html = (WEB / "admin.html").read_text(encoding="utf-8") - self.assertIn("app.js?v=16", html) + self.assertIn("app.js?v=17", html) with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() diff --git a/web/admin.html b/web/admin.html index cb5a916..115294a 100644 --- a/web/admin.html +++ b/web/admin.html @@ -11,7 +11,7 @@ document.documentElement.classList.add("v-fusion"); })(); - + @@ -62,8 +62,8 @@
管理端 / 管理总览
- 账期 2026-07 - 统计截止 2026-08-20 + 账期 — + 统计截止 —
@@ -73,20 +73,20 @@

管理总览

-

2026-01-01 至 2026-08-20,集团 6 家成员公司相互往来累计发生 8.11 亿元、392 笔明细。页面上任意数字与公司均可逐级穿透,直至银行原始流水。

+

正在读取集团往来汇总…

往来借方总额 · 年初至今
-
42,040.30万元
-
6 家公司合计 · 明细 392 笔
+
万元
+
加载中…
往来贷方总额 · 年初至今
-
39,040.30万元
-
6 家公司合计 · 与借方同源互证
+
万元
+
加载中…
待审核事项 · 审核中心
@@ -94,10 +94,10 @@
高 0 项 · 中 0 项 · 其余 0 项低风险
-
7 月账期确认进度
-
50%
-
-
3 家已完成 · 2 家在途 · 1 家未提交
+
账期确认进度
+
%
+
+
加载中…
@@ -428,11 +428,11 @@
- +
- +
@@ -766,7 +766,7 @@
待提醒清单系统按流水提交、断档、待确认自动发现,点发送即送达对应公司
- +
@@ -1228,6 +1228,6 @@
- + diff --git a/web/app.js b/web/app.js index ae50eae..2a4c4c8 100644 --- a/web/app.js +++ b/web/app.js @@ -1467,6 +1467,63 @@ function updateDashAuditCard(audit) { applyAuditCounts(audit, { fromApi: true }); } +function formatDashWan(value) { + const num = Number(value); + if (!Number.isFinite(num)) return "0.00"; + return Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function applyDashKpis(data) { + const totals = data?.totals || {}; + const from = data?.from_date || "—"; + const cutoff = data?.cutoff || "—"; + const periodLabel = data?.period_label || ""; + const companyCount = Number(totals.company_count) || 0; + const detailCount = Number(totals.detail_count) || 0; + const debit = formatDashWan(totals.debit_wan); + const credit = formatDashWan(totals.credit_wan); + + const pageSub = $("#dashPageSub"); + if (pageSub) { + if (!companyCount && !detailCount) { + pageSub.textContent = `${from} 至 ${cutoff},暂无成员公司往来数据。导入并归集银行流水后,金额将显示在此。页面上任意数字与公司均可逐级穿透,直至银行原始流水。`; + } else { + pageSub.textContent = `${from} 至 ${cutoff},集团 ${companyCount} 家成员公司相互往来累计发生 ${debit} 万元、${detailCount} 笔明细。页面上任意数字与公司均可逐级穿透,直至银行原始流水。`; + } + } + const debitVal = $("#dashDebitValue"); + if (debitVal) debitVal.innerHTML = `${debit}万元`; + const debitFoot = $("#dashDebitFoot"); + if (debitFoot) debitFoot.textContent = `${companyCount} 家公司合计 · 明细 ${detailCount} 笔`; + const creditVal = $("#dashCreditValue"); + if (creditVal) creditVal.innerHTML = `${credit}万元`; + const creditFoot = $("#dashCreditFoot"); + if (creditFoot) creditFoot.textContent = `${companyCount} 家公司合计 · 与借方同源互证`; + + const progress = data?.period_progress || {}; + const ym = progress.year_month || periodLabel; + const monthNum = ym ? Number(String(ym).slice(5, 7)) : 0; + const labelEl = $("#dashPeriodLabel"); + if (labelEl) labelEl.textContent = monthNum ? `${monthNum} 月账期确认进度` : "账期确认进度"; + const percent = Number(progress.percent) || 0; + const pctEl = $("#dashPeriodPercent"); + if (pctEl) pctEl.innerHTML = `${percent}%`; + const bar = $("#dashPeriodBar"); + if (bar) bar.style.width = `${Math.max(0, Math.min(100, percent))}%`; + const periodFoot = $("#dashPeriodFoot"); + if (periodFoot) { + const enabled = Number(progress.enabled_count) || 0; + periodFoot.textContent = enabled + ? `${progress.done || 0} 家已完成 · ${progress.in_progress || 0} 家在途 · ${progress.unsubmitted || 0} 家未提交` + : "尚无已启用银行账户"; + } + + const tbPeriod = $("#topbarPeriod"); + if (tbPeriod) tbPeriod.textContent = periodLabel ? `账期 ${periodLabel}` : "账期 —"; + const tbCutoff = $("#topbarCutoff"); + if (tbCutoff) tbCutoff.textContent = cutoff && cutoff !== "—" ? `统计截止 ${cutoff}` : "统计截止 —"; +} + function renderDashCompanyRows(companies, selectedId) { const list = $("#dashCompanyRows"); if (!list) return; @@ -1681,11 +1738,15 @@ async function loadAdminDashboard() { const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null); if (!response?.ok) { $("#dashCompanyRows").innerHTML = '
总览加载失败
'; + const pageSub = $("#dashPageSub"); + if (pageSub) pageSub.textContent = "总览加载失败,请刷新后重试。"; return; } const data = await response.json().catch(() => null); if (!data || data.status !== "ok") { $("#dashCompanyRows").innerHTML = '
总览加载失败
'; + const pageSub = $("#dashPageSub"); + if (pageSub) pageSub.textContent = "总览加载失败,请刷新后重试。"; return; } state.dashCompanies = data.companies || []; @@ -1693,6 +1754,7 @@ async function loadAdminDashboard() { state.dashCutoff = data.cutoff; state.dashPeriodMonth = data.period_month; updateDashAuditCard(data.audit); + applyDashKpis(data); const listSub = $("#dashListSub"); if (listSub) { listSub.textContent = `${data.from_date} 至 ${data.cutoff} · 单位:万元 · 左侧选择公司,右侧查看其往来明细`; @@ -1837,8 +1899,13 @@ function syncPendingCheckAll() { if (!master) return; const boxes = $$(".pending-check"); const checked = boxes.filter((el) => el.checked).length; + master.disabled = boxes.length === 0; master.checked = boxes.length > 0 && checked === boxes.length; master.indeterminate = checked > 0 && checked < boxes.length; + if (boxes.length === 0) { + master.checked = false; + master.indeterminate = false; + } } async function loadAdminRemindersHistory(sourceFilter) { diff --git a/web/company.html b/web/company.html index 4279745..9225a8a 100644 --- a/web/company.html +++ b/web/company.html @@ -11,7 +11,7 @@ document.documentElement.classList.add("v-fusion"); })(); - + @@ -62,8 +62,8 @@
公司业务端 / 工作台
- 账期 2026-07 - 统计截止 2026-08-20 + 账期 — + 统计截止 —
@@ -415,11 +415,11 @@
- +
- +
@@ -1044,6 +1044,6 @@
- + diff --git a/web/design-system.css b/web/design-system.css index 1ad8ace..4651717 100644 --- a/web/design-system.css +++ b/web/design-system.css @@ -1111,7 +1111,7 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans /* ─── 入口页(index) ────────────────────────────────────────────── */ .portal-wrap { min-height: 100vh; display: grid; place-items: center; padding: 40px 24px; } -.portal-inner { width: 100%; max-width: 880px; } +.portal-inner { width: 100%; max-width: 880px; position: relative; } .portal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 28px; } .portal-card { display: block; diff --git a/web/index.html b/web/index.html index d2e6bc6..bc3b1f4 100644 --- a/web/index.html +++ b/web/index.html @@ -10,12 +10,12 @@ document.documentElement.classList.add("v-fusion"); })(); - +
-