From 77fc625ded74893f072f0354fe0efdea818e9433 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 03:06:45 +0000 Subject: [PATCH] =?UTF-8?q?HEL-155:=20=E6=8C=89=E7=AC=AC=E4=B8=89=E8=BD=AE?= =?UTF-8?q?=E6=95=88=E6=9E=9C=E5=9B=BE=E6=94=B9=E9=80=A0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换借贷轧差为待审核统计卡;公司往来合计/明细合成主从模块(折叠明细); 新增柱状图与折线图位;接入 /api/admin/dashboard 真实归集与审核队列数据。 Co-authored-by: Cursor Co-authored-by: multica-agent --- .gitignore | 1 + server.py | 52 ++++- src/bank_importer/dashboard.py | 343 +++++++++++++++++++++++++++++++++ tests/test_dashboard.py | 204 ++++++++++++++++++++ web/admin.html | 270 ++++++++++---------------- web/app.js | 295 ++++++++++++++++++++++++++++ 6 files changed, 992 insertions(+), 173 deletions(-) create mode 100644 src/bank_importer/dashboard.py create mode 100644 tests/test_dashboard.py 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 笔明细。页面上任意数字与公司均可逐级穿透,直至银行原始流水。

-
- -
@@ -72,10 +69,10 @@
39,040.30万元
6 家公司合计 · 与借方同源互证
-
-
借贷轧差 · 未匹配
-
3,000.00万元
-
由单边流水与待确认往来形成,可在往来查询核对
+
+
待审核事项 · 审核中心
+
0
+
高 0 项 · 中 0 项 · 其余 0 项低风险
7 月账期确认进度
@@ -85,176 +82,118 @@
-
-
-
-
- 各公司往来合计2026-01-01 至 2026-08-20 · 单位:万元 · 点击任意行查看该公司往来明细 - +
+
+
+ 各公司往来分布期末净值 · 单位:万元 +
+
+
+
+
+ 本周资金流入流出集团内往来 · 单位:万元 +
+
+
+
+ +
+
+
+
+ 公司往来合计单位:万元 +
-
- +
+
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - + + + +
公司借方合计贷方合计明细笔数期末净往来7 月状态
金牛煤业河南金牛煤业有限公司18,420.5012,386.00128+6,034.50已完成
金牛物流河南金牛物流有限公司6,208.757,914.3074-1,705.55已完成
金牛贸易河南金牛贸易有限公司9,850.009,102.4086+747.60已完成
金牛置业河南金牛置业有限公司3,960.205,730.0052-1,769.80流水待补
金牛新能源河南金牛新能源有限公司2,130.002,886.0031-756.00确认中
金牛农业河南金牛农业科技发展有限公司1,470.851,021.6021+449.25未提交
合计 · 6 家42,040.3039,040.30392+3,000.00公司明细笔数期末净值状态
加载中…
- -
-
- 公司对查选择两家公司,直达它们之间的往来明细 +
+
+ 公司往来明细选择左侧公司查看会计式分级明细
-
-
- - -
- -
- - -
-
-
-
-
金牛农业 → 应收 386.24 万元
-
14 笔(借 9 / 贷 5)· 最早未结 2026-03-12
-
- +
+ + + + + + + + + + + + + + +
对方 / 摘要笔数期初本期借方本期贷方期末
请选择左侧公司
+
-
-
-
- 待办队列12 项待处理 · 下方为影响结账的前 4 项,从第 1 项开始 - +
+
+
+ 公司对查选择两家公司,直达它们之间的往来明细 +
+
+
+ +
-
- -
-
金牛置业 · 中行尾号 8821 账户 7 月流水断档 11 天
-
07-06 至 07-16 无流水,影响与金牛煤业 320 万元往来的归集
-
- -
-
- -
-
金牛农业 7 月全部账户未提交流水
-
已发 2 次系统提醒,最近一次 08-18,仍未响应
-
- -
-
- -
-
金牛新能源 ↔ 金牛贸易 3 笔单边流水待对方证据
-
合计 486 万元,需新能源侧选择对方银行流水佐证
-
- -
-
- -
-
5 笔手工记录待确认往来科目
-
应收 / 其他应收待判定,涉及煤业、物流、贸易
-
- -
-
- 其余 8 项为低风险与已挂起事项 - + +
+ +
- -
-
- 账期与结账每月 5 日结账 · 全局起算日 2026-01-01 +
+
+
金牛农业 → 应收 386.24 万元
+
14 笔(借 9 / 贷 5)· 最早未结 2026-03-12
-
-
当前账期
2026-07 · 进行中
-
结账日
2026-08-29(顺延)· 距今 9 天
-
上一账期
2026-06 · 已于 07-03 结账
-
- +
+ +
+
+ 账期与结账每月 5 日结账 · 全局起算日 2026-01-01 +
+
+
当前账期
2026-07 · 进行中
+
结账日
2026-08-29(顺延)· 距今 9 天
+
上一账期
2026-06 · 已于 07-03 结账
+
+ +
@@ -1039,18 +978,5 @@
- diff --git a/web/app.js b/web/app.js index c29a044..bab4d89 100644 --- a/web/app.js +++ b/web/app.js @@ -908,6 +908,15 @@ function updateAuditCounts() { 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) { + 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 }); + } } function companyStatusBadge(status) { @@ -983,6 +992,291 @@ function fillCompanySelects(names) { } setSelectOptions($('#openingDialog [name="from"]'), names); setSelectOptions($('#openingDialog [name="to"]'), names); + setSelectOptions($("#ql-self"), names); + setSelectOptions($("#ql-peer"), names); + if ($("#ql-peer") && names.length > 1) $("#ql-peer").value = names[0]; + if ($("#ql-self") && names.length > 1) $("#ql-self").value = names[names.length - 1]; +} + +function formatWan(value) { + const num = Number(value); + if (!Number.isFinite(num)) return "0.00"; + const abs = Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + if (num > 0) return `+${abs}`; + if (num < 0) return `-${abs}`; + return abs; +} + +function netClass(value) { + const num = Number(value); + if (num > 0) return "amt-in"; + if (num < 0) return "amt-out"; + return ""; +} + +function statusPill(label) { + if (!label || label === "—") return ''; + const map = { + "已完成": "pill-success", + "流水待补": "pill-warn", + "确认中": "pill-warn", + "未提交": "pill-danger", + }; + return `${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} + 流入 + 流出 + `; +} + +function renderDashCompanyRows(companies, selectedId) { + const tbody = $("#dashCompanyRows"); + if (!tbody) return; + if (!companies.length) { + tbody.innerHTML = '暂无公司或尚无归集往来'; + return; + } + tbody.replaceChildren(...companies.map((company) => { + const tr = document.createElement("tr"); + tr.className = "clickable"; + tr.dataset.companyId = company.id; + if (String(company.id) === String(selectedId)) { + tr.style.background = "var(--accent-soft)"; + } + const nameTd = document.createElement("td"); + nameTd.style.cssText = "white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;"; + nameTd.title = company.name; + nameTd.textContent = company.name; + const countTd = document.createElement("td"); + countTd.className = "num-col"; + countTd.textContent = String(company.detail_count ?? 0); + const netTd = document.createElement("td"); + netTd.className = `num-col ${netClass(company.net_wan)}`; + netTd.textContent = formatWan(company.net_wan); + const statusTd = document.createElement("td"); + statusTd.innerHTML = statusPill(company.period_status_label); + tr.append(nameTd, countTd, netTd, statusTd); + return tr; + })); +} + +function chevronSvg(expanded) { + // Same inline chevron style used elsewhere in the deployed admin shell. + if (expanded) { + return ''; + } + return ''; +} + +function renderDashDetail(payload, { expandFirst = true } = {}) { + const tbody = $("#dashDetailRows"); + const title = $("#dashDetailTitle"); + if (!tbody) return; + if (!payload) { + tbody.innerHTML = '请选择左侧公司'; + return; + } + if (title) { + title.innerHTML = `公司往来明细 · ${payload.company_name}按对方公司分组 · 二级默认收起 · 期初待 B-45`; + } + const groups = payload.groups || []; + if (!groups.length) { + tbody.innerHTML = '该公司区间内暂无已归集往来'; + return; + } + const rows = []; + groups.forEach((group, index) => { + const expanded = expandFirst && index === 0; + const groupTr = document.createElement("tr"); + groupTr.className = "clickable"; + groupTr.dataset.peerGroup = String(group.peer_id); + groupTr.dataset.peerName = group.peer_name; + groupTr.dataset.expanded = expanded ? "true" : "false"; + groupTr.innerHTML = ` + ${chevronSvg(expanded)}${group.peer_name} + ${group.count} + — + ${Number(group.debit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + ${Number(group.credit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + ${formatWan(group.ending_wan)}`; + rows.push(groupTr); + (group.lines || []).forEach((line) => { + const lineTr = document.createElement("tr"); + lineTr.dataset.peerChild = String(group.peer_id); + lineTr.style.display = expanded ? "" : "none"; + const dirTag = line.direction === "debit" + ? '' + : ''; + lineTr.innerHTML = ` + ${dirTag} ${line.date} ${line.summary} + 1 + — + ${line.direction === "debit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"} + ${line.direction === "credit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"} + —`; + rows.push(lineTr); + }); + }); + tbody.replaceChildren(...rows); +} + +async function loadDashCompanyDetail(companyId) { + if (!companyId) return; + state.dashSelectedId = companyId; + renderDashCompanyRows(state.dashCompanies || [], companyId); + const tbody = $("#dashDetailRows"); + if (tbody) tbody.innerHTML = '加载中…'; + const from = state.dashFrom || "2026-01-01"; + const cutoff = state.dashCutoff || "2026-08-20"; + const response = await fetch(`/api/admin/dashboard/companies/${companyId}?from=${encodeURIComponent(from)}&cutoff=${encodeURIComponent(cutoff)}`).catch(() => null); + if (!response?.ok) { + if (tbody) tbody.innerHTML = '明细加载失败'; + return; + } + const result = await response.json().catch(() => null); + renderDashDetail(result); +} + +async function loadAdminDashboard() { + if (!$("#dashCompanyRows")) return; + const response = await fetch("/api/admin/dashboard?from=2026-01-01&cutoff=2026-08-20").catch(() => null); + if (!response?.ok) { + $("#dashCompanyRows").innerHTML = '总览加载失败'; + return; + } + const data = await response.json().catch(() => null); + if (!data || data.status !== "ok") { + $("#dashCompanyRows").innerHTML = '总览加载失败'; + return; + } + state.dashCompanies = data.companies || []; + 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); +} + +function initDashboard() { + if (!$("#companyMasterDetail")) return; + $("#dashCompanyRows")?.addEventListener("click", (event) => { + const tr = event.target.closest("tr[data-company-id]"); + if (!tr) return; + loadDashCompanyDetail(tr.dataset.companyId); + }); + $("#dashDetailRows")?.addEventListener("click", (event) => { + const tr = event.target.closest("tr[data-peer-group]"); + if (!tr) return; + const peerId = tr.dataset.peerGroup; + const open = tr.dataset.expanded !== "true"; + tr.dataset.expanded = open ? "true" : "false"; + const nameCell = tr.children[0]; + if (nameCell) { + nameCell.innerHTML = `${chevronSvg(open)}${tr.dataset.peerName || ""}`; + } + $$('#dashDetailRows tr[data-peer-child]').forEach((child) => { + if (child.dataset.peerChild === peerId) child.style.display = open ? "" : "none"; + }); + }); + $("#company-search")?.addEventListener("input", function () { + const q = this.value.trim(); + $$("#dashCompanyRows tr[data-company-id]").forEach((tr) => { + tr.style.display = (!q || tr.textContent.includes(q)) ? "" : "none"; + }); + }); + loadAdminDashboard(); } async function loadAdminCompanies() { @@ -1004,6 +1298,7 @@ function initAdmin() { renderStoredAdminReviews(); updateAuditCounts(); loadAdminCompanies(); + initDashboard(); let activeAuditType = "all"; function filterAuditRows() {