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 @@ @@ -73,20 +73,20 @@2026-01-01 至 2026-08-20,集团 6 家成员公司相互往来累计发生 8.11 亿元、392 笔明细。页面上任意数字与公司均可逐级穿透,直至银行原始流水。
+正在读取集团往来汇总…