Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
815b68c1fc | ||
|
|
609ebffdc9 | ||
|
|
77fc625ded |
@@ -19,3 +19,6 @@ nul
|
||||
.agent_context/
|
||||
.kimi/
|
||||
|
||||
vendor_pkgs/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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()
|
||||
+98
-172
@@ -56,9 +56,6 @@
|
||||
<h1>管理总览</h1>
|
||||
<p class="page-sub">2026-01-01 至 2026-08-20,集团 6 家成员公司相互往来累计发生 8.11 亿元、392 笔明细。页面上任意数字与公司均可逐级穿透,直至银行原始流水。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn" data-view-link="pair">往来查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-4">
|
||||
@@ -72,10 +69,10 @@
|
||||
<div class="stat-value">39,040.30<span class="unit">万元</span></div>
|
||||
<div class="stat-foot">6 家公司合计 · 与借方同源互证</div>
|
||||
</div>
|
||||
<div class="card stat-card warn">
|
||||
<div class="stat-label">借贷轧差 · 未匹配</div>
|
||||
<div class="stat-value">3,000.00<span class="unit">万元</span></div>
|
||||
<div class="stat-foot">由单边流水与待确认往来形成,可在往来查询核对</div>
|
||||
<div class="card stat-card warn" data-view-link="audit" style="cursor: pointer;">
|
||||
<div class="stat-label">待审核事项 · 审核中心</div>
|
||||
<div class="stat-value" id="dashAuditTotal">0<span class="unit">项</span></div>
|
||||
<div class="stat-foot" id="dashAuditFoot">高 0 项 · 中 0 项 · 其余 0 项低风险</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">7 月账期确认进度</div>
|
||||
@@ -85,176 +82,118 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-3-2" style="margin-top: 14px;">
|
||||
<div class="stack">
|
||||
<div class="card" id="companyTotals">
|
||||
<div class="card-head">
|
||||
<span class="card-title">各公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 单位:万元 · 点击任意行查看该公司往来明细</span></span>
|
||||
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索公司…" style="width: 170px;" />
|
||||
<div class="grid grid-2" style="margin-top: 14px;">
|
||||
<div class="card">
|
||||
<div class="card-head" style="margin-bottom: 10px;">
|
||||
<span class="card-title">各公司往来分布<span class="sub">期末净值 · 单位:万元</span></span>
|
||||
</div>
|
||||
<div id="dashBarChart" style="height: 220px; min-width: 0;" aria-label="各公司往来分布柱状图"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head" style="margin-bottom: 10px;">
|
||||
<span class="card-title">本周资金流入流出<span class="sub">集团内往来 · 单位:万元</span></span>
|
||||
</div>
|
||||
<div id="dashLineChart" style="height: 220px; min-width: 0;" aria-label="本周资金流入流出折线图"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="companyMasterDetail" style="margin-top: 14px; padding: 0; overflow: hidden;">
|
||||
<div class="grid grid-1-2" style="gap: 0; align-items: stretch; height: 492px;">
|
||||
<div style="border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; min-height: 0;">
|
||||
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0; gap: 8px;">
|
||||
<span class="card-title" style="min-width: 0;">公司往来合计<span class="sub" id="dashListSub" style="display: inline; margin-left: 6px;">单位:万元</span></span>
|
||||
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索…" style="width: 120px; min-height: 28px; padding: 4px 8px; flex: none;" />
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<div class="table-wrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
|
||||
<table class="ds-table" id="dashCompanyTable" style="min-width: 0;">
|
||||
<thead>
|
||||
<tr><th>公司</th><th class="num-col">借方合计</th><th class="num-col">贷方合计</th><th class="num-col">明细笔数</th><th class="num-col">期末净往来</th><th>7 月状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛煤业<span class="cell-sub">河南金牛煤业有限公司</span></td>
|
||||
<td class="num-col">18,420.50</td>
|
||||
<td class="num-col">12,386.00</td>
|
||||
<td class="num-col">128</td>
|
||||
<td class="num-col amt-in">+6,034.50</td>
|
||||
<td><span class="pill pill-success">已完成</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛物流<span class="cell-sub">河南金牛物流有限公司</span></td>
|
||||
<td class="num-col">6,208.75</td>
|
||||
<td class="num-col">7,914.30</td>
|
||||
<td class="num-col">74</td>
|
||||
<td class="num-col amt-out">-1,705.55</td>
|
||||
<td><span class="pill pill-success">已完成</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛贸易<span class="cell-sub">河南金牛贸易有限公司</span></td>
|
||||
<td class="num-col">9,850.00</td>
|
||||
<td class="num-col">9,102.40</td>
|
||||
<td class="num-col">86</td>
|
||||
<td class="num-col amt-in">+747.60</td>
|
||||
<td><span class="pill pill-success">已完成</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛置业<span class="cell-sub">河南金牛置业有限公司</span></td>
|
||||
<td class="num-col">3,960.20</td>
|
||||
<td class="num-col">5,730.00</td>
|
||||
<td class="num-col">52</td>
|
||||
<td class="num-col amt-out">-1,769.80</td>
|
||||
<td><span class="pill pill-warn">流水待补</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛新能源<span class="cell-sub">河南金牛新能源有限公司</span></td>
|
||||
<td class="num-col">2,130.00</td>
|
||||
<td class="num-col">2,886.00</td>
|
||||
<td class="num-col">31</td>
|
||||
<td class="num-col amt-out">-756.00</td>
|
||||
<td><span class="pill pill-warn">确认中</span></td>
|
||||
</tr>
|
||||
<tr class="clickable" data-view-link="pair">
|
||||
<td class="cell-main">金牛农业<span class="cell-sub">河南金牛农业科技发展有限公司</span></td>
|
||||
<td class="num-col">1,470.85</td>
|
||||
<td class="num-col">1,021.60</td>
|
||||
<td class="num-col">21</td>
|
||||
<td class="num-col amt-in">+449.25</td>
|
||||
<td><span class="pill pill-danger">未提交</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>合计 · 6 家</td>
|
||||
<td class="num-col">42,040.30</td>
|
||||
<td class="num-col">39,040.30</td>
|
||||
<td class="num-col">392</td>
|
||||
<td class="num-col">+3,000.00</td>
|
||||
<td>—</td>
|
||||
<th style="padding: 6px 12px;">公司</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">明细笔数</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">期末净值</th>
|
||||
<th id="dashStatusHead" style="padding: 6px 12px;">状态</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</thead>
|
||||
<tbody id="dashCompanyRows">
|
||||
<tr class="loading-row"><td colspan="4">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">公司对查<span class="sub">选择两家公司,直达它们之间的往来明细</span></span>
|
||||
<div style="display: flex; flex-direction: column; min-width: 0; min-height: 0;">
|
||||
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0;">
|
||||
<span class="card-title" id="dashDetailTitle">公司往来明细<span class="sub" id="dashDetailSub">选择左侧公司查看会计式分级明细</span></span>
|
||||
</div>
|
||||
<div class="row" style="align-items: flex-end; flex-wrap: wrap;">
|
||||
<div class="field" style="flex: 1 1 190px; margin-bottom: 0;">
|
||||
<label for="ql-self">本方公司</label>
|
||||
<select class="select" id="ql-self">
|
||||
<option>河南金牛煤业有限公司</option>
|
||||
<option>河南金牛物流有限公司</option>
|
||||
<option>河南金牛贸易有限公司</option>
|
||||
<option>河南金牛置业有限公司</option>
|
||||
<option>河南金牛新能源有限公司</option>
|
||||
<option selected>河南金牛农业科技发展有限公司</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="meta" style="padding: 0 4px 9px; flex: none;">↔</span>
|
||||
<div class="field" style="flex: 1 1 190px; margin-bottom: 0;">
|
||||
<label for="ql-peer">对方公司</label>
|
||||
<select class="select" id="ql-peer">
|
||||
<option selected>河南金牛煤业有限公司</option>
|
||||
<option>河南金牛物流有限公司</option>
|
||||
<option>河南金牛贸易有限公司</option>
|
||||
<option>河南金牛置业有限公司</option>
|
||||
<option>河南金牛新能源有限公司</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-box row-between" style="margin-top: 12px;">
|
||||
<div>
|
||||
<div style="font-weight: 650;">金牛农业 → 应收 386.24 万元</div>
|
||||
<div class="meta" style="margin-top: 2px;">14 笔(借 9 / 贷 5)· 最早未结 2026-03-12</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="pair">查看逐笔往来</button>
|
||||
<div class="table-wrap" id="dashDetailWrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
|
||||
<table class="ds-table" id="dashDetailTable" style="min-width: 0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 6px 12px;">对方 / 摘要</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">笔数</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">期初</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">本期借方</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">本期贷方</th>
|
||||
<th class="num-col" style="padding: 6px 12px;">期末</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashDetailRows">
|
||||
<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">待办队列<span class="sub">12 项待处理 · 下方为影响结账的前 4 项,从第 1 项开始</span></span>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="audit">审核中心 →</button>
|
||||
<div class="grid grid-2" style="margin-top: 14px;">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">公司对查<span class="sub">选择两家公司,直达它们之间的往来明细</span></span>
|
||||
</div>
|
||||
<div class="row" style="align-items: flex-end; flex-wrap: wrap;">
|
||||
<div class="field" style="flex: 1 1 190px; margin-bottom: 0;">
|
||||
<label for="ql-self">本方公司</label>
|
||||
<select class="select" id="ql-self">
|
||||
<option>河南金牛煤业有限公司</option>
|
||||
<option>河南金牛物流有限公司</option>
|
||||
<option>河南金牛贸易有限公司</option>
|
||||
<option>河南金牛置业有限公司</option>
|
||||
<option>河南金牛新能源有限公司</option>
|
||||
<option selected>河南金牛农业科技发展有限公司</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="list-row" data-detail="admin-gap">
|
||||
<span class="pill pill-danger">高</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">金牛置业 · 中行尾号 8821 账户 7 月流水断档 11 天</div>
|
||||
<div class="lr-sub">07-06 至 07-16 无流水,影响与金牛煤业 320 万元往来的归集</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="audit">去审核</button>
|
||||
</div>
|
||||
<div class="list-row" data-detail="admin-unsubmitted">
|
||||
<span class="pill pill-danger">高</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">金牛农业 7 月全部账户未提交流水</div>
|
||||
<div class="lr-sub">已发 2 次系统提醒,最近一次 08-18,仍未响应</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="reminders">再提醒</button>
|
||||
</div>
|
||||
<div class="list-row" data-detail="admin-unilateral">
|
||||
<span class="pill pill-warn">中</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">金牛新能源 ↔ 金牛贸易 3 笔单边流水待对方证据</div>
|
||||
<div class="lr-sub">合计 486 万元,需新能源侧选择对方银行流水佐证</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="audit">去审核</button>
|
||||
</div>
|
||||
<div class="list-row" data-detail="admin-subject">
|
||||
<span class="pill pill-warn">中</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">5 笔手工记录待确认往来科目</div>
|
||||
<div class="lr-sub">应收 / 其他应收待判定,涉及煤业、物流、贸易</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="audit">去审核</button>
|
||||
</div>
|
||||
<div class="table-foot">
|
||||
<span>其余 8 项为低风险与已挂起事项</span>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="audit" style="color: var(--fg); font-weight: 550;">进入审核中心 →</button>
|
||||
<span class="meta" style="padding: 0 4px 9px; flex: none;">↔</span>
|
||||
<div class="field" style="flex: 1 1 190px; margin-bottom: 0;">
|
||||
<label for="ql-peer">对方公司</label>
|
||||
<select class="select" id="ql-peer">
|
||||
<option selected>河南金牛煤业有限公司</option>
|
||||
<option>河南金牛物流有限公司</option>
|
||||
<option>河南金牛贸易有限公司</option>
|
||||
<option>河南金牛置业有限公司</option>
|
||||
<option>河南金牛新能源有限公司</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账期与结账<span class="sub" id="dashboardPeriodSub">每月 5 日结账 · 全局起算日 2026-01-01</span></span>
|
||||
<div class="detail-box row-between" style="margin-top: 12px;">
|
||||
<div>
|
||||
<div style="font-weight: 650;">金牛农业 → 应收 386.24 万元</div>
|
||||
<div class="meta" style="margin-top: 2px;">14 笔(借 9 / 贷 5)· 最早未结 2026-03-12</div>
|
||||
</div>
|
||||
<dl class="kv">
|
||||
<dt>当前账期</dt><dd>2026-07 · 进行中</dd>
|
||||
<dt>结账日</dt><dd style="color: color-mix(in oklch, var(--warn) 78%, black); font-weight: 650;">2026-08-29(顺延)· 距今 9 天</dd>
|
||||
<dt>上一账期</dt><dd>2026-06 · 已于 07-03 结账</dd>
|
||||
</dl>
|
||||
<button class="btn" data-view-link="settings" style="width: 100%; margin-top: 14px;">进入结账检查</button>
|
||||
<button class="btn btn-sm" data-view-link="pair">查看逐笔往来</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账期与结账<span class="sub" id="dashboardPeriodSub">每月 5 日结账 · 全局起算日 2026-01-01</span></span>
|
||||
</div>
|
||||
<dl class="kv">
|
||||
<dt>当前账期</dt><dd>2026-07 · 进行中</dd>
|
||||
<dt>结账日</dt><dd style="color: color-mix(in oklch, var(--warn) 78%, black); font-weight: 650;">2026-08-29(顺延)· 距今 9 天</dd>
|
||||
<dt>上一账期</dt><dd>2026-06 · 已于 07-03 结账</dd>
|
||||
</dl>
|
||||
<button class="btn" data-view-link="settings" style="width: 100%; margin-top: 14px;">进入结账检查</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1039,18 +978,5 @@
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=5"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var input = document.getElementById("company-search");
|
||||
if (!input) return;
|
||||
input.addEventListener("input", function () {
|
||||
var q = this.value.trim();
|
||||
var rows = document.querySelectorAll("#companyTotals tbody tr");
|
||||
rows.forEach(function (tr) {
|
||||
tr.style.display = (!q || tr.textContent.indexOf(q) > -1) ? "" : "none";
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+309
@@ -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,305 @@ 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 '<span class="meta">—</span>';
|
||||
const map = {
|
||||
"已完成": "pill-success",
|
||||
"流水待补": "pill-warn",
|
||||
"确认中": "pill-warn",
|
||||
"未提交": "pill-danger",
|
||||
};
|
||||
return `<span class="pill ${map[label] || "pill-muted"}">${label}</span>`;
|
||||
}
|
||||
|
||||
function updateDashAuditCard(audit) {
|
||||
const totalEl = $("#dashAuditTotal");
|
||||
const footEl = $("#dashAuditFoot");
|
||||
if (!totalEl || !footEl || !audit) return;
|
||||
totalEl.innerHTML = `${audit.total ?? 0}<span class="unit">项</span>`;
|
||||
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 = '<div class="empty" style="padding: 36px 12px;"><div class="e-title">暂无往来分布</div><div>归集后将显示各公司期末净值</div></div>';
|
||||
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 `<line x1="${padL}" y1="${y}" x2="${w - padR}" y2="${y}" stroke="var(--border)" stroke-width="1"/>
|
||||
<text x="${padL - 6}" y="${y + 3}" text-anchor="end" fill="var(--muted)" font-size="10" font-family="var(--font-mono)">${label}</text>`;
|
||||
}).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 `<rect x="${x}" y="${y}" width="${barW}" height="${Math.max(bh, v === 0 ? 0 : 2)}" fill="${color}" rx="2"/>
|
||||
<text x="${x + barW / 2}" y="${valY}" text-anchor="middle" fill="${color}" font-size="10" font-family="var(--font-mono)">${formatWan(v).replace(".00", "")}</text>
|
||||
<text x="${x + barW / 2}" y="${h - 10}" text-anchor="middle" fill="var(--muted)" font-size="10">${label}</text>`;
|
||||
}).join("");
|
||||
host.innerHTML = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="100%" role="img">${tickLines}<line x1="${padL}" y1="${zeroY}" x2="${w - padR}" y2="${zeroY}" stroke="var(--fg)" stroke-width="1" opacity="0.35"/>${bars}</svg>`;
|
||||
}
|
||||
|
||||
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 = '<div class="empty" style="padding: 36px 12px;"><div class="e-title">暂无周度流水</div><div>有归集数据后显示近 7 日流入流出</div></div>';
|
||||
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 `<line x1="${padL}" y1="${y}" x2="${w - padR}" y2="${y}" stroke="var(--border)"/><text x="${padL - 6}" y="${y + 3}" text-anchor="end" fill="var(--muted)" font-size="10" font-family="var(--font-mono)">${Math.round(maxV * t)}</text>`;
|
||||
}).join("");
|
||||
const xLabels = labels.map((lab, i) => `<text x="${xAt(i)}" y="${h - 8}" text-anchor="middle" fill="var(--muted)" font-size="10" font-family="var(--font-mono)">${lab}</text>`).join("");
|
||||
host.innerHTML = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="100%" role="img">${grid}
|
||||
<path d="${pathOf(inflow)}" fill="none" stroke="var(--success)" stroke-width="2"/>
|
||||
<path d="${pathOf(outflow)}" fill="none" stroke="var(--danger)" stroke-width="2"/>
|
||||
${inflow.map((v, i) => `<circle cx="${xAt(i)}" cy="${yAt(v)}" r="2.5" fill="var(--success)"/>`).join("")}
|
||||
${outflow.map((v, i) => `<circle cx="${xAt(i)}" cy="${yAt(v)}" r="2.5" fill="var(--danger)"/>`).join("")}
|
||||
${xLabels}
|
||||
<text x="${w - padR}" y="14" text-anchor="end" fill="var(--success)" font-size="11">流入</text>
|
||||
<text x="${w - padR - 40}" y="14" text-anchor="end" fill="var(--danger)" font-size="11">流出</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function renderDashCompanyRows(companies, selectedId) {
|
||||
const tbody = $("#dashCompanyRows");
|
||||
if (!tbody) return;
|
||||
if (!companies.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="muted">暂无公司或尚无归集往来</td></tr>';
|
||||
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 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m6 9 6 6 6-6"/></svg>';
|
||||
}
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m9 18 6-6-6-6"/></svg>';
|
||||
}
|
||||
|
||||
function renderDashDetail(payload, { expandFirst = false } = {}) {
|
||||
const tbody = $("#dashDetailRows");
|
||||
const title = $("#dashDetailTitle");
|
||||
if (!tbody) return;
|
||||
if (!payload) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>';
|
||||
return;
|
||||
}
|
||||
if (title) {
|
||||
title.innerHTML = `公司往来明细 · ${payload.company_name}<span class="sub" id="dashDetailSub">按对方公司分组 · 二级默认收起 · 期初待 B-45</span>`;
|
||||
}
|
||||
const groups = payload.groups || [];
|
||||
if (!groups.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">该公司区间内暂无已归集往来</td></tr>';
|
||||
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 = `
|
||||
<td style="font-weight:550;">${chevronSvg(expanded)}<span data-peer-label>${group.peer_name}</span></td>
|
||||
<td class="num-col">${group.count}</td>
|
||||
<td class="num-col meta">—</td>
|
||||
<td class="num-col">${Number(group.debit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td class="num-col">${Number(group.credit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td class="num-col ${netClass(group.ending_wan)}">${formatWan(group.ending_wan)}</td>`;
|
||||
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"
|
||||
? '<span class="tag">借</span>'
|
||||
: '<span class="tag">贷</span>';
|
||||
lineTr.innerHTML = `
|
||||
<td style="padding-left: 28px;">${dirTag} <span class="meta">${line.date}</span> ${line.summary}</td>
|
||||
<td class="num-col">1</td>
|
||||
<td class="num-col meta">—</td>
|
||||
<td class="num-col">${line.direction === "debit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
|
||||
<td class="num-col">${line.direction === "credit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
|
||||
<td class="num-col meta">—</td>`;
|
||||
rows.push(lineTr);
|
||||
});
|
||||
});
|
||||
tbody.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
async function resolveDashStartDate() {
|
||||
if (state.dashFrom) return state.dashFrom;
|
||||
const response = await fetch("/api/admin/settings").catch(() => null);
|
||||
if (response?.ok) {
|
||||
const result = await response.json().catch(() => null);
|
||||
const startDate = result?.settings?.start_date;
|
||||
if (startDate) {
|
||||
state.dashFrom = startDate;
|
||||
return startDate;
|
||||
}
|
||||
}
|
||||
return "2026-01-01";
|
||||
}
|
||||
|
||||
async function loadDashCompanyDetail(companyId) {
|
||||
if (!companyId) return;
|
||||
state.dashSelectedId = companyId;
|
||||
renderDashCompanyRows(state.dashCompanies || [], companyId);
|
||||
const tbody = $("#dashDetailRows");
|
||||
if (tbody) tbody.innerHTML = '<tr class="loading-row"><td colspan="6">加载中…</td></tr>';
|
||||
const from = await resolveDashStartDate();
|
||||
const response = await fetch(`/api/admin/dashboard/companies/${companyId}?from=${encodeURIComponent(from)}`).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">明细加载失败</td></tr>';
|
||||
return;
|
||||
}
|
||||
const result = await response.json().catch(() => null);
|
||||
renderDashDetail(result);
|
||||
}
|
||||
|
||||
async function loadAdminDashboard() {
|
||||
if (!$("#dashCompanyRows")) return;
|
||||
const from = await resolveDashStartDate();
|
||||
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
|
||||
return;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || data.status !== "ok") {
|
||||
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
|
||||
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)}<span data-peer-label>${tr.dataset.peerName || ""}</span>`;
|
||||
}
|
||||
$$('#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 +1312,7 @@ function initAdmin() {
|
||||
renderStoredAdminReviews();
|
||||
updateAuditCounts();
|
||||
loadAdminCompanies();
|
||||
initDashboard();
|
||||
|
||||
let activeAuditType = "all";
|
||||
function filterAuditRows() {
|
||||
|
||||
Reference in New Issue
Block a user