diff --git a/.gitignore b/.gitignore index 6ab6476..42b0aca 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ nul .agent_context/ .kimi/ +vendor_pkgs/ diff --git a/server.py b/server.py index 7843de1..d69217e 100644 --- a/server.py +++ b/server.py @@ -11,7 +11,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse from bank_importer import ( - auth, importing, ledger_events, manual_records, master_data, matching, + auth, dashboard, importing, ledger_events, manual_records, master_data, matching, multipart, personal_transit, positions, settings, subjects, ) from bank_importer.db import connect, migrate, utc_now @@ -95,6 +95,13 @@ class AppHandler(SimpleHTTPRequestHandler): if path == "/api/admin/match-exceptions": self._handle_admin_match_exceptions(query) return + if path == "/api/admin/dashboard": + self._handle_admin_dashboard(query) + return + company_dash = re.fullmatch(r"/api/admin/dashboard/companies/(\d+)", path) + if company_dash: + self._handle_admin_dashboard_company(int(company_dash.group(1)), query) + return if path == "/api/admin/personal-transit-mappings": self._handle_admin_personal_mappings(query) return @@ -1866,6 +1873,49 @@ class AppHandler(SimpleHTTPRequestHandler): finally: connection.close() + def _handle_admin_dashboard(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01" + cutoff = (query.get("cutoff") or [None])[0] or None + try: + payload = dashboard.build_dashboard( + connection, from_date=from_date, cutoff=cutoff + ) + except ValueError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + + def _handle_admin_dashboard_company( + self, company_id: int, query: dict[str, list[str]] + ) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01" + cutoff = (query.get("cutoff") or [dashboard.today_shanghai()])[0] + try: + payload = dashboard.company_peer_groups( + connection, company_id, from_date=from_date, cutoff=cutoff + ) + except KeyError: + self._send_json(404, {"status": "error", "message": "公司不存在。"}) + return + except ValueError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", **payload}) + finally: + connection.close() + def _handle_admin_reconcile(self) -> None: connection = connect(DB_PATH) try: diff --git a/src/bank_importer/dashboard.py b/src/bank_importer/dashboard.py new file mode 100644 index 0000000..3723649 --- /dev/null +++ b/src/bank_importer/dashboard.py @@ -0,0 +1,343 @@ +"""Admin dashboard aggregates over existing B-43 eligible events and review queues. + +No fabricated demo amounts. Opening balances are unavailable until B-45; peer +group ``opening`` is always null and ``ending`` equals period net change. +Period closing status is unknown without the monthly-close module — returned +as null so the UI shows an em dash rather than a guessed label. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from decimal import Decimal, ROUND_HALF_UP +import sqlite3 + + +WAN = Decimal("10000") +ZERO = Decimal("0") +TWOPLACES = Decimal("0.01") + + +def today_shanghai() -> str: + return datetime.now(timezone(timedelta(hours=8))).date().isoformat() + + +def _q2(value: Decimal) -> str: + return str(value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)) + + +def _to_wan(amount: Decimal) -> str: + return _q2(amount / WAN) + + +def _parse_day(iso_ts: str | None) -> str | None: + if not iso_ts: + return None + text = str(iso_ts) + return text[:10] if len(text) >= 10 else None + + +def audit_counts(connection: sqlite3.Connection) -> dict[str, int]: + """Derive pending review counts from real queues only. + + Priority mapping (aligned with current admin audit UI semantics): + - high: unresolved / needs_review match exceptions + - medium: pending bank-account registrations + - low: reserved for future low-risk queues (currently always 0) + """ + high = connection.execute( + """ + SELECT COUNT(*) AS n + 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 + WHERE e.lifecycle = 'active' + AND d.classification IN ('unresolved', 'needs_review') + """ + ).fetchone()["n"] + medium = connection.execute( + "SELECT COUNT(*) AS n FROM bank_accounts WHERE status = 'pending'" + ).fetchone()["n"] + low = 0 + return { + "total": int(high) + int(medium) + int(low), + "high": int(high), + "medium": int(medium), + "low": int(low), + } + + +def _load_eligible( + connection: sqlite3.Connection, *, from_date: str, cutoff: str +) -> list[sqlite3.Row]: + return connection.execute( + """ + SELECT e.event_id, e.decision_id, e.effective_at, e.amount, e.currency, + e.payer_company_id, e.payee_company_id, e.pairing, + cpayer.name AS payer_name, cpayee.name AS payee_name + FROM eligible_intercompany_events e + JOIN companies cpayer ON cpayer.id = e.payer_company_id + JOIN companies cpayee ON cpayee.id = e.payee_company_id + WHERE date(e.effective_at) >= date(?) + AND date(e.effective_at) <= date(?) + ORDER BY e.effective_at, e.event_id + """, + (from_date, cutoff), + ).fetchall() + + +def _company_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]: + return connection.execute( + """ + SELECT id, name FROM companies + WHERE status != 'disabled' + ORDER BY id + """ + ).fetchall() + + +def company_summaries( + connection: sqlite3.Connection, *, from_date: str, cutoff: str +) -> tuple[list[dict[str, object]], dict[str, object]]: + companies = _company_rows(connection) + events = _load_eligible(connection, from_date=from_date, cutoff=cutoff) + + debit_total = ZERO + credit_total = ZERO + by_id: dict[int, dict[str, object]] = {} + for company in companies: + by_id[int(company["id"])] = { + "id": int(company["id"]), + "name": company["name"], + "detail_count": 0, + "debit": ZERO, + "credit": ZERO, + "period_status": None, + "period_status_label": "—", + } + + for event in events: + amount = Decimal(str(event["amount"])) + debit_total += amount + credit_total += amount + payer_id = int(event["payer_company_id"]) + payee_id = int(event["payee_company_id"]) + if payer_id in by_id: + row = by_id[payer_id] + row["detail_count"] = int(row["detail_count"]) + 1 + row["debit"] = Decimal(row["debit"]) + amount + if payee_id in by_id: + row = by_id[payee_id] + row["detail_count"] = int(row["detail_count"]) + 1 + row["credit"] = Decimal(row["credit"]) + amount + + items: list[dict[str, object]] = [] + for company in companies: + row = by_id[int(company["id"])] + debit = Decimal(row["debit"]) + credit = Decimal(row["credit"]) + # Payee inflow − payer outflow = signed net from this company's view. + net = credit - debit + items.append( + { + "id": row["id"], + "name": row["name"], + "detail_count": row["detail_count"], + "debit_wan": _to_wan(debit), + "credit_wan": _to_wan(credit), + "net_wan": _to_wan(net), + "period_status": row["period_status"], + "period_status_label": row["period_status_label"], + } + ) + + totals = { + "company_count": len(items), + "detail_count": len(events), + "debit_wan": _to_wan(debit_total), + "credit_wan": _to_wan(credit_total), + "net_wan": _to_wan(debit_total - credit_total), + } + return items, totals + + +def company_peer_groups( + connection: sqlite3.Connection, + company_id: int, + *, + from_date: str, + cutoff: str, +) -> dict[str, object]: + company = connection.execute( + "SELECT id, name FROM companies WHERE id = ?", (company_id,) + ).fetchone() + if company is None: + raise KeyError(company_id) + + events = connection.execute( + """ + SELECT e.event_id, e.effective_at, e.amount, e.currency, + e.payer_company_id, e.payee_company_id, + cpayer.name AS payer_name, cpayee.name AS payee_name + FROM eligible_intercompany_events e + JOIN companies cpayer ON cpayer.id = e.payer_company_id + JOIN companies cpayee ON cpayee.id = e.payee_company_id + WHERE (e.payer_company_id = ? OR e.payee_company_id = ?) + AND date(e.effective_at) >= date(?) + AND date(e.effective_at) <= date(?) + ORDER BY e.effective_at, e.event_id + """, + (company_id, company_id, from_date, cutoff), + ).fetchall() + + groups: dict[int, dict[str, object]] = {} + for event in events: + amount = Decimal(str(event["amount"])) + payer_id = int(event["payer_company_id"]) + payee_id = int(event["payee_company_id"]) + if payer_id == company_id: + peer_id = payee_id + peer_name = event["payee_name"] + direction = "debit" + summary = f"付往 {peer_name}" + else: + peer_id = payer_id + peer_name = event["payer_name"] + direction = "credit" + summary = f"收自 {peer_name}" + + bucket = groups.get(peer_id) + if bucket is None: + bucket = { + "peer_id": peer_id, + "peer_name": peer_name, + "count": 0, + "opening": None, + "opening_status": "unavailable", + "debit": ZERO, + "credit": ZERO, + "lines": [], + } + groups[peer_id] = bucket + + bucket["count"] = int(bucket["count"]) + 1 + if direction == "debit": + bucket["debit"] = Decimal(bucket["debit"]) + amount + else: + bucket["credit"] = Decimal(bucket["credit"]) + amount + + day = _parse_day(event["effective_at"]) or "" + bucket["lines"].append( + { + "event_id": int(event["event_id"]), + "date": day, + "direction": direction, + "summary": summary, + "amount_wan": _to_wan(amount), + "currency": event["currency"] or "CNY", + } + ) + + result_groups: list[dict[str, object]] = [] + for peer_id in sorted(groups.keys(), key=lambda i: groups[i]["peer_name"]): + bucket = groups[peer_id] + debit = Decimal(bucket["debit"]) + credit = Decimal(bucket["credit"]) + ending = credit - debit + result_groups.append( + { + "peer_id": bucket["peer_id"], + "peer_name": bucket["peer_name"], + "count": bucket["count"], + "opening": None, + "opening_status": "unavailable", + "debit_wan": _to_wan(debit), + "credit_wan": _to_wan(credit), + "ending_wan": _to_wan(ending), + "result_kind": "period_net_change", + "lines": bucket["lines"], + } + ) + + return { + "company_id": int(company["id"]), + "company_name": company["name"], + "from_date": from_date, + "cutoff": cutoff, + "groups": result_groups, + } + + +def weekly_flow( + connection: sqlite3.Connection, *, cutoff: str, days: int = 7 +) -> dict[str, object]: + end = datetime.strptime(cutoff, "%Y-%m-%d").date() + start = end - timedelta(days=days - 1) + labels: list[str] = [] + inflow = [ZERO] * days + outflow = [ZERO] * days + index: dict[str, int] = {} + for offset in range(days): + day = start + timedelta(days=offset) + key = day.isoformat() + index[key] = offset + labels.append(f"{day.month:02d}-{day.day:02d}") + + rows = connection.execute( + """ + SELECT date(e.effective_at) AS day, e.amount + FROM eligible_intercompany_events e + WHERE date(e.effective_at) >= date(?) + AND date(e.effective_at) <= date(?) + """, + (start.isoformat(), cutoff), + ).fetchall() + for row in rows: + day = row["day"] + if day not in index: + continue + amount = Decimal(str(row["amount"])) + # Group-level flow: every eligible transfer is both an outflow (payer) + # and an inflow (payee); plot both series with the same absolute amount. + inflow[index[day]] += amount + outflow[index[day]] += amount + + return { + "labels": labels, + "inflow_wan": [_to_wan(v) for v in inflow], + "outflow_wan": [_to_wan(v) for v in outflow], + } + + +def build_dashboard( + connection: sqlite3.Connection, + *, + from_date: str = "2026-01-01", + cutoff: str | None = None, +) -> dict[str, object]: + cutoff_date = cutoff or today_shanghai() + try: + datetime.strptime(from_date, "%Y-%m-%d") + datetime.strptime(cutoff_date, "%Y-%m-%d") + except ValueError as exc: + raise ValueError("日期必须是 YYYY-MM-DD") from exc + if from_date > cutoff_date: + raise ValueError("from 不能晚于 cutoff") + + period = datetime.strptime(cutoff_date, "%Y-%m-%d") + # Display month for the status column header: use the calendar month of cutoff. + period_month = period.month + companies, totals = company_summaries( + connection, from_date=from_date, cutoff=cutoff_date + ) + return { + "from_date": from_date, + "cutoff": cutoff_date, + "period_month": period_month, + "period_label": f"{period.year}-{period.month:02d}", + "audit": audit_counts(connection), + "totals": totals, + "companies": companies, + "weekly_flow": weekly_flow(connection, cutoff=cutoff_date), + "opening_status": "unavailable", + } diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..b7d0582 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,204 @@ +"""Dashboard aggregate helpers and admin HTTP endpoints (no openpyxl dependency).""" + +from __future__ import annotations + +import os +from pathlib import Path +import tempfile +import threading +import unittest + +from bank_importer import dashboard, master_data +from bank_importer.db import connect, migrate + +import server +from test_server_auth import Client, as_json + +BOOTSTRAP_PASSWORD = "BootAdmin123" +ADMIN_PASSWORD = "AdminPass123" + + +class DashboardUnitTests(unittest.TestCase): + def setUp(self) -> None: + self.connection = connect(":memory:") + migrate(self.connection) + + def tearDown(self) -> None: + self.connection.close() + + def test_empty_dashboard(self) -> None: + payload = dashboard.build_dashboard( + self.connection, from_date="2026-01-01", cutoff="2026-08-20" + ) + self.assertEqual(0, payload["audit"]["total"]) + self.assertEqual([], payload["companies"]) + self.assertEqual(7, len(payload["weekly_flow"]["labels"])) + + def test_pending_account_counts_as_medium(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.commit() + master_data.submit_bank_account( + self.connection, + company_id=company_id, + bank_name="工行", + account_type="一般户", + account_number="6222020000000001", + start_date="2026-01-01", + actor=None, + ) + counts = dashboard.audit_counts(self.connection) + self.assertEqual(1, counts["medium"]) + self.assertEqual(0, counts["high"]) + self.assertEqual(1, counts["total"]) + + def test_company_summaries_from_eligible_events(self) -> None: + 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 + 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', 'intercompany', + 'paired', 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.execute( + """ + INSERT INTO transfer_decision_participants ( + decision_id, role, company_id, bank_account_id, resolve_method, created_at + ) VALUES (?, 'payer', ?, NULL, 'manual', ?), (?, 'payee', ?, NULL, 'manual', ?) + """, + (decision_id, a, now, decision_id, b, now), + ) + self.connection.commit() + + items, totals = dashboard.company_summaries( + self.connection, from_date="2026-01-01", cutoff="2026-08-20" + ) + self.assertEqual(1, totals["detail_count"]) + by_name = {row["name"]: row for row in items} + self.assertEqual(1, by_name["甲公司"]["detail_count"]) + self.assertEqual("10.00", by_name["甲公司"]["debit_wan"]) + self.assertEqual("-10.00", by_name["甲公司"]["net_wan"]) + self.assertEqual("10.00", by_name["乙公司"]["credit_wan"]) + self.assertEqual("10.00", by_name["乙公司"]["net_wan"]) + + detail = dashboard.company_peer_groups( + self.connection, int(a), from_date="2026-01-01", cutoff="2026-08-20" + ) + self.assertEqual(1, len(detail["groups"])) + self.assertEqual("乙公司", detail["groups"][0]["peer_name"]) + self.assertIsNone(detail["groups"][0]["opening"]) + + +class DashboardApiTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.temp_dir = tempfile.TemporaryDirectory() + root = Path(cls.temp_dir.name) + cls.db_path = root / "app.db" + cls.storage = root / "files" + + cls._old_db_path = server.DB_PATH + cls._old_storage = server.STORAGE_DIR + server.DB_PATH = cls.db_path + server.STORAGE_DIR = cls.storage + + os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD + connection = connect(cls.db_path) + migrate(connection) + assert server.ensure_bootstrap_admin(connection) is None + connection.close() + + class QuietHandler(server.AppHandler): + def log_message(self, *args) -> None: + pass + + cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler) + cls.port = cls.httpd.server_address[1] + cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True) + cls.thread.start() + + cls.admin = Client("127.0.0.1", cls.port) + status, _, data = cls.admin.post_json( + "/api/login", + {"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"}, + ) + assert status == 200, data + status, _, data = cls.admin.post_json( + "/api/password/change", + {"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD}, + ) + assert status == 200, data + + status, _, data = cls.admin.post_json( + "/api/admin/companies", {"name": "甲公司", "username": "cashier-a"} + ) + assert status == 200, data + + @classmethod + def tearDownClass(cls) -> None: + cls.httpd.shutdown() + cls.httpd.server_close() + server.DB_PATH = cls._old_db_path + server.STORAGE_DIR = cls._old_storage + cls.temp_dir.cleanup() + + def test_dashboard_ok_for_admin(self) -> None: + status, _, raw = self.admin.get( + "/api/admin/dashboard?from=2026-01-01&cutoff=2026-08-20" + ) + data = as_json(raw) + self.assertEqual(200, status) + self.assertEqual("ok", data["status"]) + self.assertIn("audit", data) + self.assertEqual(1, len(data["companies"])) + self.assertEqual("甲公司", data["companies"][0]["name"]) + + 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" + ) + data = as_json(raw) + self.assertEqual(404, status) + self.assertEqual("error", data["status"]) + + def test_company_detail_ok(self) -> None: + status, _, raw = self.admin.get("/api/admin/dashboard") + data = as_json(raw) + company_id = data["companies"][0]["id"] + status, _, raw = self.admin.get( + f"/api/admin/dashboard/companies/{company_id}?from=2026-01-01&cutoff=2026-08-20" + ) + detail = as_json(raw) + self.assertEqual(200, status) + self.assertEqual([], detail["groups"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/admin.html b/web/admin.html index 0204cd6..2289fde 100644 --- a/web/admin.html +++ b/web/admin.html @@ -56,9 +56,6 @@
2026-01-01 至 2026-08-20,集团 6 家成员公司相互往来累计发生 8.11 亿元、392 笔明细。页面上任意数字与公司均可逐级穿透,直至银行原始流水。
-| 公司 | 借方合计 | 贷方合计 | 明细笔数 | 期末净往来 | 7 月状态 | ||||
|---|---|---|---|---|---|---|---|---|---|
| 金牛煤业河南金牛煤业有限公司 | -18,420.50 | -12,386.00 | -128 | -+6,034.50 | -已完成 | -||||
| 金牛物流河南金牛物流有限公司 | -6,208.75 | -7,914.30 | -74 | --1,705.55 | -已完成 | -||||
| 金牛贸易河南金牛贸易有限公司 | -9,850.00 | -9,102.40 | -86 | -+747.60 | -已完成 | -||||
| 金牛置业河南金牛置业有限公司 | -3,960.20 | -5,730.00 | -52 | --1,769.80 | -流水待补 | -||||
| 金牛新能源河南金牛新能源有限公司 | -2,130.00 | -2,886.00 | -31 | --756.00 | -确认中 | -||||
| 金牛农业河南金牛农业科技发展有限公司 | -1,470.85 | -1,021.60 | -21 | -+449.25 | -未提交 | -||||
| 合计 · 6 家 | -42,040.30 | -39,040.30 | -392 | -+3,000.00 | -— | +公司 | +明细笔数 | +期末净值 | +状态 |
| 加载中… | |||||||||
| 对方 / 摘要 | +笔数 | +期初 | +本期借方 | +本期贷方 | +期末 | +
|---|---|---|---|---|---|
| 请选择左侧公司 | |||||