HEL-155: 按第三轮效果图改造管理端首页

替换借贷轧差为待审核统计卡;公司往来合计/明细合成主从模块(折叠明细);
新增柱状图与折线图位;接入 /api/admin/dashboard 真实归集与审核队列数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-08-26 03:32:47 +00:00
co-authored by Cursor multica-agent
parent ece3e53472
commit 77fc625ded
6 changed files with 992 additions and 173 deletions
+343
View File
@@ -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",
}