Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eefdc92ab6 | ||
|
|
fdc5616115 | ||
|
|
c4f7573653 | ||
|
|
5b3ebad757 | ||
|
|
b42b6481f5 | ||
|
|
e04f47b9d2 | ||
|
|
7fb97119ba | ||
|
|
815b68c1fc | ||
|
|
609ebffdc9 | ||
|
|
77fc625ded |
@@ -19,3 +19,10 @@ nul
|
||||
.agent_context/
|
||||
.kimi/
|
||||
|
||||
vendor_pkgs/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# local vendor for agent test env (not shipped)
|
||||
.vendor/
|
||||
vendor_wheels/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 总账项目手册
|
||||
|
||||
最后核对:2026-08-23。核对依据是当前 `main` 最新代码(`5f21527`)、数据库迁移、接口、测试和近期提交记录。
|
||||
|
||||
这是接手本项目的第一站。阅读顺序如下:
|
||||
|
||||
1. [项目需求](项目需求.md):先知道系统要解决什么问题,以及哪些规则绝不能碰。
|
||||
2. [最新进度](最新进度.md):再确认哪些能力已经真实完成,哪些仍未完成。
|
||||
3. [任务清单](任务清单.md):最后挑选下一项工作,避免重复做已经完成的事。
|
||||
4. 需要技术细节时,再查看 `decisions/` 中的技术决定和 `issues/` 中保留的历史任务规格。
|
||||
|
||||
## 维护规矩
|
||||
|
||||
- 每新增、开始、完成、取消一项任务,负责的智能体都要在同一次提交里更新 `最新进度.md` 和 `任务清单.md`。
|
||||
- 新功能改变产品规则时,同时更新 `项目需求.md`;技术方案有变化时,补充 `decisions/` 的决定记录。
|
||||
- 写进手册的“已完成”必须能在代码、测试或已合并提交中找到依据;只有计划、页面演示或口头讨论的内容,一律写为“未完成”。
|
||||
- 不删除历史文档。旧内容与当前代码不一致时,在旧文件开头加“已过时,仅留档备查”提示,并在本目录写新版说明。
|
||||
|
||||
## 旧交接说明
|
||||
|
||||
`HANDOFF.md` 是 2026-08-06 的历史交接稿,已经不反映当前实现,保留仅作对照。当前事实以本手册三份正文为准。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 任务清单
|
||||
|
||||
最后核对:2026-08-23。状态以当前代码和已合并提交为准。
|
||||
|
||||
## 正在做
|
||||
|
||||
- 当前没有已确认正在进行的业务功能开发。下一项工作开始前,先在此处写明负责人、范围和验收标准。
|
||||
|
||||
## 已做完
|
||||
|
||||
- 六类银行流水模板识别与解析。
|
||||
- 数据库迁移、不可变原始证据、导入批次和重复导入处理。
|
||||
- 管理员/公司用户登录、密码策略、会话、限流、服务端公司隔离和审计。
|
||||
- 动态公司、用户、银行账户、别名与账户审核流程。
|
||||
- 导入接口加固、逐工作表确认、失败诊断和导出。
|
||||
- 规范转账事件、双边匹配、同公司调拨排除与个人过账映射。
|
||||
- 登录页视觉融合改版。
|
||||
|
||||
## 还没安排
|
||||
|
||||
- 公司间余额和四类往来科目的正式计算,已批准手工记录入账,以及从余额逐层查回原始流水。
|
||||
- 起算日、期初余额、流水覆盖断档检测和无业务校准。
|
||||
- 月结、重开、调整/冲销审批与审计报告。
|
||||
- 服务端流水查询、筛选、分页和可追溯导出。
|
||||
- 站内提醒与状态流转;外部通知只预留扩展位置,不默认启用。
|
||||
- 前端全面接入真实接口,移除模拟金额、静态业务记录和 `localStorage` 业务状态。
|
||||
- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。
|
||||
|
||||
每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 最新进度
|
||||
|
||||
最后核对:2026-08-23。以下“已完成”均以当前代码、数据库迁移、接口和自动化测试为依据,不把页面演示当作真实功能。
|
||||
|
||||
## 已经真实完成
|
||||
|
||||
- 支持中信、农行、工行、建行、河南农商行、郑州银行六类样本的 `.xls` / `.xlsx` 流水解析;能识别变动的表头位置和列顺序,并校验余额连续性。
|
||||
- 已有 SQLite 数据库和 5 次版本迁移。原始文件按内容哈希保存,导入批次、工作表、源行和异常都有记录;原始文件、工作表和源行被数据库规则保护,不能直接改或删。
|
||||
- 已实现管理员与公司用户登录、首次改密、会话失效、登录失败限流、服务端权限隔离和审计记录。
|
||||
- 已实现公司、用户、银行账户、别名等主数据管理;公司提交的银行账户需管理员审核后才能参与上传和识别。
|
||||
- 已实现导入、逐工作表确认或忽略、失败诊断、重复上传处理和 CSV 导出;未确认的工作表不进入后续处理。
|
||||
- 已实现规范转账事件和双边流水匹配。匹配决定保留历史,无法自动判断的记录进入人工审核;同公司调拨、外部流水和未锁定的单边记录不进入已确认的公司间往来。
|
||||
- 已有登录页、总账端和公司端页面,最近一次合并完成了登录页视觉改版。
|
||||
|
||||
## 仍未完成或不能当成已完成
|
||||
|
||||
- 公司间余额与会计科目的正式计算、已批准手工记录入账和逐层余额追溯尚未完成。
|
||||
- 全局起算日、期初余额、流水覆盖断档、无业务校准、月结、重开和调整审批尚未完成。
|
||||
- 提醒、完整的服务端查询导出,以及前端彻底移除演示数据和浏览器本地业务状态尚未完成。
|
||||
- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。
|
||||
|
||||
## 最近验证
|
||||
|
||||
2026-08-23 已运行完整 Python 自动化测试:解析、持久化、认证与权限、导入接口、主数据和双边匹配相关测试均通过。后续修改功能时,必须再次运行完整测试并在本文件记录结果。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 项目需求
|
||||
|
||||
## 这个系统做什么
|
||||
|
||||
这是河南金牛实业集团有限公司内部各公司之间的资金往来管理系统。它把各公司的银行流水集中起来,识别同一笔公司间转账的两边记录,形成可以一路查回原始银行流水的往来结果。
|
||||
|
||||
系统有两个独立入口:
|
||||
|
||||
- 总账管理端:管理公司、公司账号、银行账户、导入记录、异常和公司间转账匹配。
|
||||
- 公司出纳端:只处理本公司的账户登记、流水上传、工作表确认和本公司相关异常。
|
||||
|
||||
## 必须做到的业务规则
|
||||
|
||||
- 银行原始文件和原始流水是证据,导入后不能直接修改或删除;修正必须通过可追溯的审核决定、冲销或调整完成。
|
||||
- 一笔公司间转账的收、付两边只能算作一个经济事件,不能因为有两条银行流水就重复计算。
|
||||
- 同一家公司不同银行账户之间调拨,不计入公司间往来。
|
||||
- 账号、金额、方向、日期和余额等可核对信息优先于户名;信息不完整或有歧义时进入异常审核,不能猜。
|
||||
- 已确认金额和待确认金额必须分开;所有余额都要说明统计截止日期和未决金额。
|
||||
- 公司用户只能查看、上传和导出本公司的数据。这个限制由服务端执行,页面上“看不见”不等于有权限限制。
|
||||
- 起算日前的流水必须保留,但不参与期内计算;期初余额、流水覆盖断档、无业务校准、月结和调整都要留痕。
|
||||
|
||||
## 当前技术边界
|
||||
|
||||
项目使用 Python 标准库服务、SQLite 数据库和浏览器页面。银行模板支持 `.xls` 与 `.xlsx`,按表头识别而非依赖固定行列。金额使用精确十进制,真实运行数据放在 `data/`,不进入代码仓库。
|
||||
|
||||
系统目前仍是测试环境项目,不能当作已完成生产部署:正式上线前仍需补齐核算、期初与断档、月结、备份、监控和 HTTPS 等能力。
|
||||
@@ -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,356 @@
|
||||
"""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 + pending manual records
|
||||
- low: reserved for future low-risk queues (currently always 0)
|
||||
|
||||
Homepage card, sidebar badge and audit-center heading must all read this
|
||||
same payload (via ``/api/admin/dashboard`` → ``audit``).
|
||||
"""
|
||||
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_accounts = connection.execute(
|
||||
"SELECT COUNT(*) AS n FROM bank_accounts WHERE status = 'pending'"
|
||||
).fetchone()["n"]
|
||||
medium_manuals = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM manual_records m
|
||||
JOIN current_manual_record_decisions c ON c.record_id = m.id
|
||||
JOIN manual_record_decisions d ON d.id = c.decision_id
|
||||
WHERE d.state = 'pending'
|
||||
"""
|
||||
).fetchone()["n"]
|
||||
medium = int(medium_accounts) + int(medium_manuals)
|
||||
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,498 @@
|
||||
"""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_pending_manual_counts_as_medium(self) -> None:
|
||||
from bank_importer import auth, manual_records
|
||||
|
||||
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
|
||||
self.connection.commit()
|
||||
auth.create_user(
|
||||
self.connection, "cashier-a", "CashierPass123", "company", company_id=int(a)
|
||||
)
|
||||
actor = self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
manual_records.submit(
|
||||
self.connection,
|
||||
company_id=int(a),
|
||||
counterparty_company_id=int(b),
|
||||
occurred_at="2026-02-01T09:00:00",
|
||||
direction="incoming",
|
||||
amount="100.00",
|
||||
currency="CNY",
|
||||
funding_source="other",
|
||||
requested_subject="receivable",
|
||||
request_key="hel157-manual-1",
|
||||
actor=actor,
|
||||
)
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(0, counts["high"])
|
||||
self.assertEqual(1, counts["total"])
|
||||
|
||||
def test_audit_total_equals_queue_sum(self) -> None:
|
||||
"""Homepage / badge / audit heading must share one backend total."""
|
||||
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
|
||||
self.connection.commit()
|
||||
master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=int(a),
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000002",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
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', 'unresolved',
|
||||
'single', 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.commit()
|
||||
|
||||
counts = dashboard.audit_counts(self.connection)
|
||||
payload = dashboard.build_dashboard(
|
||||
self.connection, from_date="2026-01-01", cutoff="2026-08-20"
|
||||
)
|
||||
self.assertEqual(counts, payload["audit"])
|
||||
self.assertEqual(
|
||||
counts["total"],
|
||||
counts["high"] + counts["medium"] + counts["low"],
|
||||
)
|
||||
self.assertEqual(1, counts["high"])
|
||||
self.assertEqual(1, counts["medium"])
|
||||
self.assertEqual(2, 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_audit_counts_sync_after_account_review(self) -> None:
|
||||
"""Pending account raises dashboard.audit; approve brings total back down."""
|
||||
from bank_importer.db import connect as db_connect
|
||||
from bank_importer import master_data as md
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/companies")
|
||||
company_id = as_json(raw)["companies"][0]["id"]
|
||||
|
||||
connection = db_connect(self.db_path)
|
||||
try:
|
||||
account = md.submit_bank_account(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000099",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
account_id = account["id"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
before = as_json(raw)["audit"]
|
||||
self.assertEqual(200, status)
|
||||
self.assertGreaterEqual(before["medium"], 1)
|
||||
self.assertGreaterEqual(before["total"], 1)
|
||||
|
||||
status, _, raw = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "HEL-157 sync test"},
|
||||
)
|
||||
self.assertEqual(200, status, raw)
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/dashboard")
|
||||
after = as_json(raw)["audit"]
|
||||
self.assertEqual(after["medium"], before["medium"] - 1)
|
||||
self.assertEqual(after["total"], before["total"] - 1)
|
||||
self.assertEqual(
|
||||
after["total"], after["high"] + after["medium"] + after["low"]
|
||||
)
|
||||
|
||||
def test_three_queue_audit_parity_and_dispose_sync(self) -> None:
|
||||
"""Seed account+manual+exception: list sizes == dashboard.audit; dispose syncs -1."""
|
||||
from bank_importer.db import connect as db_connect
|
||||
from bank_importer import auth, manual_records, master_data as md
|
||||
|
||||
status, _, raw = self.admin.get("/api/admin/companies")
|
||||
company_id = as_json(raw)["companies"][0]["id"]
|
||||
|
||||
# Second company for manual counterparty.
|
||||
status, _, raw = self.admin.post_json(
|
||||
"/api/admin/companies", {"name": "乙公司", "username": "cashier-b-hel157"}
|
||||
)
|
||||
self.assertEqual(200, status, raw)
|
||||
company_b = as_json(raw).get("company_id") or as_json(raw).get("id")
|
||||
self.assertIsNotNone(company_b)
|
||||
|
||||
connection = db_connect(self.db_path)
|
||||
try:
|
||||
account = md.submit_bank_account(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
bank_name="工行",
|
||||
account_type="一般户",
|
||||
account_number="6222020000000157",
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
account_id = account["id"]
|
||||
|
||||
cashier = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
if cashier is None:
|
||||
auth.create_user(
|
||||
connection,
|
||||
"cashier-a",
|
||||
"CashierPass123",
|
||||
"company",
|
||||
company_id=company_id,
|
||||
)
|
||||
cashier = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||
).fetchone()
|
||||
manual = manual_records.submit(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
counterparty_company_id=int(company_b),
|
||||
occurred_at="2026-02-01T09:00:00",
|
||||
direction="incoming",
|
||||
amount="100.00",
|
||||
currency="CNY",
|
||||
funding_source="other",
|
||||
requested_subject="receivable",
|
||||
request_key="hel157-queue-manual",
|
||||
actor=cashier,
|
||||
)
|
||||
|
||||
now = md.utc_now()
|
||||
event_id = connection.execute(
|
||||
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||
(now,),
|
||||
).lastrowid
|
||||
decision_id = 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', 'unresolved',
|
||||
'single', 0, 'manual', 'test', ?)
|
||||
""",
|
||||
(event_id, now),
|
||||
).lastrowid
|
||||
connection.execute(
|
||||
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||
(event_id, decision_id),
|
||||
)
|
||||
connection.commit()
|
||||
manual_id = manual["id"]
|
||||
manual_decision_id = manual["decision_id"]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def queue_sizes():
|
||||
st, _, body = self.admin.get("/api/admin/accounts?status=pending")
|
||||
self.assertEqual(200, st, body)
|
||||
accounts_n = len(as_json(body)["accounts"])
|
||||
st, _, body = self.admin.get("/api/admin/manual-records?state=pending")
|
||||
self.assertEqual(200, st, body)
|
||||
manuals_n = len(as_json(body)["records"])
|
||||
st, _, body = self.admin.get("/api/admin/match-exceptions")
|
||||
self.assertEqual(200, st, body)
|
||||
exceptions_n = len(as_json(body)["exceptions"])
|
||||
return accounts_n, manuals_n, exceptions_n, accounts_n + manuals_n + exceptions_n
|
||||
|
||||
def assert_parity(expected_total: int) -> dict:
|
||||
st, _, body = self.admin.get("/api/admin/dashboard")
|
||||
self.assertEqual(200, st, body)
|
||||
audit = as_json(body)["audit"]
|
||||
accounts_n, manuals_n, exceptions_n, list_total = queue_sizes()
|
||||
self.assertEqual(expected_total, audit["total"])
|
||||
self.assertEqual(expected_total, list_total)
|
||||
self.assertEqual(
|
||||
audit["total"], audit["high"] + audit["medium"] + audit["low"]
|
||||
)
|
||||
self.assertGreaterEqual(accounts_n, 1 if expected_total >= 3 else 0)
|
||||
return {
|
||||
"audit": audit,
|
||||
"accounts": accounts_n,
|
||||
"manuals": manuals_n,
|
||||
"exceptions": exceptions_n,
|
||||
}
|
||||
|
||||
before = assert_parity(3)
|
||||
self.assertEqual(1, before["accounts"])
|
||||
self.assertEqual(1, before["manuals"])
|
||||
self.assertEqual(1, before["exceptions"])
|
||||
self.assertEqual(1, before["audit"]["high"])
|
||||
self.assertEqual(2, before["audit"]["medium"])
|
||||
|
||||
# Dispose account → total 2
|
||||
st, _, body = self.admin.post_json(
|
||||
f"/api/admin/accounts/{account_id}/review",
|
||||
{"decision": "approve", "reason": "HEL-157 three-queue dispose account"},
|
||||
)
|
||||
self.assertEqual(200, st, body)
|
||||
after_account = assert_parity(2)
|
||||
|
||||
# Dispose manual → total 1
|
||||
st, _, body = self.admin.post_json(
|
||||
f"/api/admin/manual-records/{manual_id}/decisions",
|
||||
{
|
||||
"action": "approve_new",
|
||||
"reason": "HEL-157 three-queue dispose manual",
|
||||
"expected_decision_id": manual_decision_id,
|
||||
"request_key": "hel157-dispose-manual",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, st, body)
|
||||
after_manual = assert_parity(1)
|
||||
self.assertEqual(0, after_manual["manuals"])
|
||||
|
||||
# Dispose match exception via reverse → total 0
|
||||
st, _, body = self.admin.get("/api/admin/match-exceptions")
|
||||
exceptions = as_json(body)["exceptions"]
|
||||
self.assertEqual(1, len(exceptions))
|
||||
target = exceptions[0]
|
||||
st, _, body = self.admin.post_json(
|
||||
f"/api/admin/transfer-events/{target['event_id']}/decisions",
|
||||
{
|
||||
"action": "reverse",
|
||||
"reason": "HEL-157 three-queue dispose match",
|
||||
"expected_revision": target["revision"],
|
||||
"request_key": "hel157-dispose-match",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, st, body)
|
||||
after_match = assert_parity(0)
|
||||
self.assertEqual(0, after_match["exceptions"])
|
||||
self.assertEqual(after_account["audit"]["total"] - 1, after_manual["audit"]["total"])
|
||||
self.assertEqual(after_manual["audit"]["total"] - 1, after_match["audit"]["total"])
|
||||
|
||||
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()
|
||||
@@ -211,6 +211,44 @@ class SettingsAndRemindersTests(unittest.TestCase):
|
||||
status, _ = cashier.get(path)
|
||||
self.assertEqual(403, status, path)
|
||||
|
||||
# Company session must get 403 on admin write endpoints (settings /
|
||||
# reminders / review decisions), not just GETs.
|
||||
write_calls = (
|
||||
("POST /api/admin/settings", lambda: cashier.post_json(
|
||||
"/api/admin/settings",
|
||||
{"closing_day": "10", "start_date": "2026-01-01", "auto_remind": "0", "remind_days": "3"},
|
||||
)),
|
||||
("POST /api/admin/reminders/send", lambda: cashier.post_json(
|
||||
"/api/admin/reminders/send", {"company_id": self.company_id}
|
||||
)),
|
||||
("POST /api/admin/accounts/1/review", lambda: cashier.post_json(
|
||||
"/api/admin/accounts/1/review", {"decision": "approve"}
|
||||
)),
|
||||
("POST /api/admin/transfer-events/1/decisions", lambda: cashier.post_json(
|
||||
"/api/admin/transfer-events/1/decisions",
|
||||
{"action": "confirm_single", "reason": "越权"},
|
||||
)),
|
||||
("POST /api/admin/manual-records/1/decisions", lambda: cashier.post_json(
|
||||
"/api/admin/manual-records/1/decisions",
|
||||
{"action": "approve_new", "reason": "越权"},
|
||||
)),
|
||||
("POST /api/admin/intercompany/events/1/subject-decisions", lambda: cashier.post_json(
|
||||
"/api/admin/intercompany/events/1/subject-decisions",
|
||||
{"subject": "other_receivable", "reason": "越权"},
|
||||
)),
|
||||
("POST /api/admin/intercompany/events/1/adjustments", lambda: cashier.post_json(
|
||||
"/api/admin/intercompany/events/1/adjustments",
|
||||
{"action": "reverse", "reason": "越权"},
|
||||
)),
|
||||
)
|
||||
for label, call in write_calls:
|
||||
status, data = call()
|
||||
self.assertEqual(403, status, f"{label}: {data}")
|
||||
# Settings must be unchanged after the forbidden write attempt.
|
||||
status, data = self.admin.get("/api/admin/settings")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("5", as_json(data)["settings"]["closing_day"])
|
||||
|
||||
|
||||
class SettingsModuleTests(unittest.TestCase):
|
||||
"""Unit tests for the settings module on a fresh in-memory database."""
|
||||
|
||||
+116
-225
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="金牛集团管理端" />
|
||||
<title>管理端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
<link rel="stylesheet" href="design-system.css?v=8" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="nav-group">日常</div>
|
||||
<a class="active" data-view="dashboard" href="#dashboard"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="10" width="8" height="11" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/></svg><span class="nav-label">管理总览</span></a>
|
||||
<a data-view="pair" href="#pair"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M7 8h13l-3.5-3.5M17 16H4l3.5 3.5"/></svg><span class="nav-label">往来查询</span></a>
|
||||
<a data-view="audit" href="#audit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">审核中心</span><span class="nav-badge">6</span></a>
|
||||
<a data-view="audit" href="#audit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">审核中心</span><span class="nav-badge" id="auditNavBadge" style="display: none;">0</span></a>
|
||||
<a data-view="flows" href="#flows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span class="nav-label">流水管理</span></a>
|
||||
<div class="nav-group">基础与结账</div>
|
||||
<a data-view="companies" href="#companies"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M4 21V8l8-5 8 5v13"/><path d="M9 21v-6h6v6"/></svg><span class="nav-label">公司与账号</span></a>
|
||||
@@ -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,128 @@
|
||||
</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>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
<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>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div class="card dash-master" id="companyMasterDetail">
|
||||
<div class="card-head dash-master-head">
|
||||
<span class="card-title" style="min-width: 0;">
|
||||
公司往来合计 · 往来明细
|
||||
<span class="sub" id="dashListSub">单位:万元 · 左侧选择公司,右侧查看其往来明细</span>
|
||||
</span>
|
||||
<div class="dash-master-head-actions">
|
||||
<span class="meta" id="dashMasterMeta">—</span>
|
||||
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索公司…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-480-1 dash-master-split">
|
||||
<div class="dash-master-pane dash-master-pane--list">
|
||||
<div class="dash-company-list" id="dashCompanyRows" role="listbox" aria-label="公司往来合计">
|
||||
<div class="dash-company-loading muted">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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 class="dash-master-pane dash-master-pane--detail">
|
||||
<div class="dash-detail-toolbar">
|
||||
<div class="dash-detail-heading">
|
||||
<span class="dash-detail-name" id="dashDetailTitle">请选择左侧公司</span>
|
||||
<span class="meta" id="dashDetailSub">按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列</span>
|
||||
</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 class="dash-detail-summary" id="dashDetailSummary" hidden>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">笔数</span>
|
||||
<span class="dash-summary-value num" id="dashSumCount">0</span>
|
||||
</div>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">期初</span>
|
||||
<span class="dash-summary-value num" id="dashSumOpening">—</span>
|
||||
</div>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">本期借方</span>
|
||||
<span class="dash-summary-value num" id="dashSumDebit">0.00</span>
|
||||
</div>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">本期贷方</span>
|
||||
<span class="dash-summary-value num" id="dashSumCredit">0.00</span>
|
||||
</div>
|
||||
<div class="dash-summary-item">
|
||||
<span class="dash-summary-label">期末</span>
|
||||
<span class="dash-summary-value num" id="dashSumEnding">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-detail-body" id="dashDetailBody">
|
||||
<div class="table-wrap dash-master-scroll" id="dashDetailWrap">
|
||||
<table class="ds-table ds-table--compact" id="dashDetailTable">
|
||||
<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 class="num-col">期末</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashDetailRows">
|
||||
<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dash-detail-empty" id="dashDetailEmpty" hidden>
|
||||
<div class="e-title">该公司期间内暂无已归集往来</div>
|
||||
<div class="e-desc" id="dashDetailEmptyDesc">期初 — · 导入流水并归集后在此展示</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" data-view-link="pair">查看逐笔往来</button>
|
||||
</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>
|
||||
|
||||
@@ -408,51 +357,6 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="auditRows">
|
||||
<tr data-audit-type="断档" data-company="金牛置业">
|
||||
<td><span class="pill pill-danger">高</span></td>
|
||||
<td><span class="cell-main">金牛置业 · 中行尾号 8821</span><span class="cell-sub">07-06 至 07-16 无流水</span></td>
|
||||
<td>流水断档</td>
|
||||
<td>2026-07</td>
|
||||
<td class="wrap">11 天无流水,与金牛煤业 320 万煤炭采购款往来无法归集,阻断 7 月结账</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
<tr data-audit-type="单边" data-company="金牛新能源">
|
||||
<td><span class="pill pill-warn">中</span></td>
|
||||
<td><span class="cell-main">金牛新能源 ↔ 金牛贸易</span><span class="cell-sub">3 笔单边合计 486 万</span></td>
|
||||
<td>单边匹配</td>
|
||||
<td>2026-07</td>
|
||||
<td class="wrap">单边支付无对方对应流水,往来差异挂账,待对方选择流水佐证</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
<tr data-audit-type="科目" data-company="金牛煤业">
|
||||
<td><span class="pill pill-warn">中</span></td>
|
||||
<td><span class="cell-main">金牛煤业 · 手工记录</span><span class="cell-sub">场地押金 42 万</span></td>
|
||||
<td>科目确认</td>
|
||||
<td>2026-07</td>
|
||||
<td class="wrap">「应收 / 其他应收」科目待判定,影响科目汇总口径</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
<tr data-audit-type="起算" data-company="金牛物流">
|
||||
<td><span class="pill pill-danger">高</span></td>
|
||||
<td><span class="cell-main">金牛物流 · 工行基本户</span><span class="cell-sub">期初与上年结转差 28.6 万</span></td>
|
||||
<td>起算区间校准</td>
|
||||
<td>2026-01 起算</td>
|
||||
<td class="wrap">期初余额与上年结转不衔接,全年累计数失真,需校准起算区间</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
<tr data-audit-type="手工" data-company="金牛贸易">
|
||||
<td><span class="pill pill-warn">中</span></td>
|
||||
<td><span class="cell-main">金牛贸易 · 手工补录</span><span class="cell-sub">现金缴存 64 万</span></td>
|
||||
<td>公司手工记录</td>
|
||||
<td>2026-07</td>
|
||||
<td class="wrap">无银行回单附件,需人工核实后方可入账</td>
|
||||
<td><span class="pill pill-info">待审核</span></td>
|
||||
<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-foot">
|
||||
@@ -524,7 +428,7 @@
|
||||
</table>
|
||||
<div class="table-foot">
|
||||
<span id="flowCount">共 0 笔</span>
|
||||
<span class="meta" id="flowsRangeMeta">数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)</span>
|
||||
<span class="meta" id="flowsRangeMeta">数据范围:加载中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -614,7 +518,7 @@
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<label for="cs-day">每月结账日</label>
|
||||
<input class="input num-input" type="number" name="closingDay" id="cs-day" min="1" max="28" value="5" />
|
||||
<span class="hint">每月几号执行上月结账 · 2026-07 因故顺延至 08-29</span>
|
||||
<span class="hint">每月几号执行上月结账</span>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 16px;">
|
||||
<label>自动提醒</label>
|
||||
@@ -1038,19 +942,6 @@
|
||||
</div>
|
||||
</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>
|
||||
<script src="app.js?v=9"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+566
-60
@@ -793,23 +793,48 @@ async function loadCompanyAccounts() {
|
||||
else showTableError(tbody, 6);
|
||||
}
|
||||
|
||||
const manualSubjectLabels = {
|
||||
receivable: "应收",
|
||||
payable: "应付",
|
||||
other_receivable: "其他应收",
|
||||
other_payable: "其他应付",
|
||||
};
|
||||
|
||||
const manualDirectionLabels = {
|
||||
incoming: "收入",
|
||||
outgoing: "支出",
|
||||
};
|
||||
|
||||
function appendAdminReviewRow(record, kind) {
|
||||
const tbody = $("#auditRows");
|
||||
if (!tbody) return;
|
||||
const isAccount = kind === "account";
|
||||
const statusLabel = isAccount ? accountStatusLabel(record.status) : record.status;
|
||||
const isManual = kind === "manual";
|
||||
const statusLabel = isAccount
|
||||
? accountStatusLabel(record.status)
|
||||
: (record.state === "pending" ? "待管理复核" : (record.state || "待复核"));
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.storedReview = record.id;
|
||||
row.dataset.recordId = record.id;
|
||||
row.dataset.storedReview = String(record.id);
|
||||
row.dataset.recordId = String(record.id);
|
||||
row.dataset.recordKind = kind;
|
||||
if (isAccount) row.dataset.accountId = record.id;
|
||||
if (isAccount) row.dataset.accountId = String(record.id);
|
||||
if (isManual) {
|
||||
row.dataset.decisionId = String(record.decision_id || "");
|
||||
row.dataset.requestedSubject = record.requested_subject || "";
|
||||
}
|
||||
row.dataset.auditType = isAccount ? "账户" : "手工";
|
||||
row.dataset.company = isAccount ? record.company_name : record.company;
|
||||
row.dataset.company = isAccount
|
||||
? (record.company_name || "")
|
||||
: (record.company_name || record.company || "");
|
||||
row.dataset.evidence = isAccount
|
||||
? "公司提交资料、开户行、账号、账户类型与启用日期"
|
||||
: "公司手工记录、关联银行流水号、证明附件与提交说明";
|
||||
if (statusLabel !== "待复核" && statusLabel !== "待管理复核") row.dataset.resolved = "true";
|
||||
if (isAccount) row.dataset.accountStatus = record.status;
|
||||
if (isAccount) {
|
||||
if (record.status !== "pending") row.dataset.resolved = "true";
|
||||
row.dataset.accountStatus = record.status;
|
||||
} else if (record.state && record.state !== "pending") {
|
||||
row.dataset.resolved = "true";
|
||||
}
|
||||
|
||||
const riskCell = document.createElement("td");
|
||||
riskCell.innerHTML = '<span class="pill pill-warn">中</span>';
|
||||
@@ -821,8 +846,11 @@ function appendAdminReviewRow(record, kind) {
|
||||
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
|
||||
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
|
||||
} else {
|
||||
identity.textContent = `${record.company} · ${record.id}`;
|
||||
detail.textContent = `${record.direction} ${formatCurrency(record.amount)} 元 · ${record.counterparty} · ${record.subject}`;
|
||||
const subject = manualSubjectLabels[record.requested_subject] || record.requested_subject || "—";
|
||||
const direction = manualDirectionLabels[record.direction] || record.direction || "";
|
||||
const counterparty = record.counterparty_company_name || record.counterparty || "—";
|
||||
identity.textContent = `${row.dataset.company} · 手工单 #${record.id}`;
|
||||
detail.textContent = `${direction} ${formatCurrency(record.amount)} 元 · ${counterparty} · ${subject}`;
|
||||
}
|
||||
identityCell.append(identity, detail);
|
||||
|
||||
@@ -830,11 +858,18 @@ function appendAdminReviewRow(record, kind) {
|
||||
typeCell.textContent = isAccount ? "账户登记" : "手工记录";
|
||||
|
||||
const periodCell = document.createElement("td");
|
||||
periodCell.textContent = isAccount ? (record.effective_from || "待审核确定") : record.transactionDate;
|
||||
if (isAccount) {
|
||||
periodCell.textContent = record.effective_from || "待审核确定";
|
||||
} else {
|
||||
periodCell.className = "num";
|
||||
periodCell.textContent = String(record.occurred_at || record.transactionDate || "").slice(0, 10) || "—";
|
||||
}
|
||||
|
||||
const impactCell = document.createElement("td");
|
||||
impactCell.className = "wrap";
|
||||
impactCell.textContent = isAccount ? "账户识别与流水上传" : `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`;
|
||||
impactCell.textContent = isAccount
|
||||
? "账户识别与流水上传"
|
||||
: `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`;
|
||||
|
||||
const statusCell = document.createElement("td");
|
||||
const status = recordStatus(statusLabel);
|
||||
@@ -848,7 +883,7 @@ function appendAdminReviewRow(record, kind) {
|
||||
} else {
|
||||
const resolvedLabel = isAccount
|
||||
? ({ returned: "已退回", disabled: "已停用" })[record.status] || "已通过"
|
||||
: (statusLabel === "已确认" ? "已通过" : "已驳回");
|
||||
: (statusLabel === "已确认" || record.state === "approved" ? "已通过" : "已驳回");
|
||||
actionCell.innerHTML = `<span class="meta">${resolvedLabel} · 系统管理员</span>`;
|
||||
}
|
||||
} else {
|
||||
@@ -859,39 +894,108 @@ function appendAdminReviewRow(record, kind) {
|
||||
tbody.append(row);
|
||||
}
|
||||
|
||||
async function renderAdminAccountReviews() {
|
||||
function appendMatchExceptionRow(item) {
|
||||
const tbody = $("#auditRows");
|
||||
if (!tbody) return;
|
||||
$$('[data-stored-review][data-record-kind="account"]', tbody).forEach((row) => row.remove());
|
||||
const response = await fetch("/api/admin/accounts").catch(() => null);
|
||||
if (!response?.ok) {
|
||||
showToast("审核数据加载失败", "请稍后重试", "danger");
|
||||
const payer = item.payer_company_name || "—";
|
||||
const payee = item.payee_company_name || "—";
|
||||
const companyLabel = payer !== "—" ? payer : payee;
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.storedReview = `match-${item.event_id}`;
|
||||
row.dataset.recordId = String(item.event_id);
|
||||
row.dataset.recordKind = "match";
|
||||
row.dataset.eventId = String(item.event_id);
|
||||
row.dataset.revision = String(item.revision ?? "");
|
||||
row.dataset.auditType = "单边";
|
||||
row.dataset.company = companyLabel;
|
||||
row.dataset.evidence = "匹配异常事件、观察流水、参与方与历史决定";
|
||||
row.dataset.classification = item.classification || "";
|
||||
|
||||
const riskCell = document.createElement("td");
|
||||
riskCell.innerHTML = '<span class="pill pill-danger">高</span>';
|
||||
|
||||
const identityCell = document.createElement("td");
|
||||
const identity = document.createElement("span"); identity.className = "cell-main";
|
||||
const detail = document.createElement("span"); detail.className = "cell-sub";
|
||||
identity.textContent = `${payer} ↔ ${payee} · 事件 #${item.event_id}`;
|
||||
detail.textContent = `${item.classification || item.status || "unresolved"} · ${formatCurrency(item.amount)} ${item.currency || "CNY"} · 证据 ${item.evidence_count ?? 0} 条`;
|
||||
identityCell.append(identity, detail);
|
||||
|
||||
const typeCell = document.createElement("td");
|
||||
typeCell.textContent = "单边匹配";
|
||||
|
||||
const periodCell = document.createElement("td");
|
||||
periodCell.className = "num";
|
||||
periodCell.textContent = String(item.effective_at || "").slice(0, 10) || "—";
|
||||
|
||||
const impactCell = document.createElement("td");
|
||||
impactCell.className = "wrap";
|
||||
impactCell.textContent = `匹配异常 ${(Number(item.amount) / 10000).toFixed(2)} 万元 · 阻断往来归集`;
|
||||
|
||||
const statusCell = document.createElement("td");
|
||||
statusCell.innerHTML = '<span class="pill pill-danger">待处理</span>';
|
||||
|
||||
const actionCell = document.createElement("td");
|
||||
actionCell.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">关闭异常</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">退回重匹配</button></div>';
|
||||
|
||||
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
|
||||
tbody.append(row);
|
||||
}
|
||||
|
||||
async function loadAdminAuditQueue() {
|
||||
const tbody = $("#auditRows");
|
||||
if (!tbody) return;
|
||||
$$('[data-stored-review]', tbody).forEach((row) => row.remove());
|
||||
showTableLoading(tbody, 7);
|
||||
|
||||
const [accountsRes, manualsRes, exceptionsRes] = await Promise.all([
|
||||
fetch("/api/admin/accounts?status=pending").catch(() => null),
|
||||
fetch("/api/admin/manual-records?state=pending").catch(() => null),
|
||||
fetch("/api/admin/match-exceptions").catch(() => null),
|
||||
]);
|
||||
|
||||
if ([accountsRes, manualsRes, exceptionsRes].some((res) => res?.status === 401)) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response.json().catch(() => null);
|
||||
(result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
|
||||
if (![accountsRes, manualsRes, exceptionsRes].every((res) => res?.ok)) {
|
||||
showTableError(tbody, 7);
|
||||
showToast("审核队列加载失败", "请稍后重试", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
const [accountsPayload, manualsPayload, exceptionsPayload] = await Promise.all([
|
||||
accountsRes.json().catch(() => null),
|
||||
manualsRes.json().catch(() => null),
|
||||
exceptionsRes.json().catch(() => null),
|
||||
]);
|
||||
|
||||
tbody.replaceChildren();
|
||||
(accountsPayload?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
|
||||
(manualsPayload?.records || []).forEach((record) => appendAdminReviewRow(record, "manual"));
|
||||
(exceptionsPayload?.exceptions || []).forEach((item) => appendMatchExceptionRow(item));
|
||||
|
||||
updateAuditCounts();
|
||||
updatePendingAccountNotice();
|
||||
await refreshAuditCountsFromApi();
|
||||
// 列表条数必须与后端口径一致:不一致时以真实列表为准覆盖标题/角标,避免再出现「数字 3 / 列表 1」。
|
||||
const unresolved = $$("#auditRows tr").filter((row) => row.dataset.resolved !== "true");
|
||||
if (!state.dashAudit || Number(state.dashAudit.total) !== unresolved.length) {
|
||||
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);
|
||||
applyAuditCounts({ total: unresolved.length, high, medium, low });
|
||||
}
|
||||
}
|
||||
|
||||
function renderStoredAdminReviews() {
|
||||
if (!$("#auditRows")) return;
|
||||
$$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove());
|
||||
readStoredRecords(storageKeys.manual).forEach((record) => appendAdminReviewRow(record, "manual"));
|
||||
renderAdminAccountReviews();
|
||||
}
|
||||
|
||||
function updateStoredReview(kind, id, status, decision, reviewReason, reviewedAt) {
|
||||
if (kind !== "manual" || !id) return;
|
||||
const records = readStoredRecords(storageKeys.manual);
|
||||
const record = records.find((item) => item.id === id);
|
||||
if (!record) return;
|
||||
Object.assign(record, { status, decision, reviewReason, reviewedAt });
|
||||
writeStoredRecords(storageKeys.manual, records);
|
||||
// 完整队列:待复核账户 + 待审手工单 + 匹配异常,三处口径同源。
|
||||
loadAdminAuditQueue();
|
||||
}
|
||||
|
||||
function updateAuditCounts() {
|
||||
const rows = $$("#auditRows tr");
|
||||
const rows = $$("#auditRows tr").filter((row) => !row.classList.contains("loading-row"));
|
||||
const unresolved = rows.filter((row) => row.dataset.resolved !== "true");
|
||||
$$('[data-audit-filter]').forEach((button) => {
|
||||
const type = button.dataset.auditFilter;
|
||||
@@ -899,15 +1003,51 @@ function updateAuditCounts() {
|
||||
const badge = $(".tab-count", button);
|
||||
if (badge) badge.textContent = count;
|
||||
});
|
||||
const badge = $('.side-nav a[data-view="audit"] .nav-badge');
|
||||
const foot = $("#auditFoot");
|
||||
if (foot) foot.textContent = `共 ${unresolved.length} 项 · 待审核 ${unresolved.length} 项`;
|
||||
// 角标 / 首页待审核卡 / 审核中心标题数:优先后端口径;列表加载完成后由 loadAdminAuditQueue 再对齐。
|
||||
if (!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);
|
||||
applyAuditCounts({ total: unresolved.length, high, medium, low });
|
||||
} else {
|
||||
const pending = $("#pending-count");
|
||||
if (pending) pending.textContent = String(unresolved.length);
|
||||
}
|
||||
}
|
||||
|
||||
function applyAuditCounts(audit, { fromApi = false } = {}) {
|
||||
if (!audit) return;
|
||||
const total = Number(audit.total) || 0;
|
||||
const high = Number(audit.high) || 0;
|
||||
const medium = Number(audit.medium) || 0;
|
||||
const low = Number(audit.low) || 0;
|
||||
state.dashAudit = { total, high, medium, low };
|
||||
if (fromApi) state.dashAuditFromApi = true;
|
||||
|
||||
const totalEl = $("#dashAuditTotal");
|
||||
const footEl = $("#dashAuditFoot");
|
||||
if (totalEl) totalEl.innerHTML = `${total}<span class="unit">项</span>`;
|
||||
if (footEl) footEl.textContent = `高 ${high} 项 · 中 ${medium} 项 · 其余 ${low} 项低风险`;
|
||||
|
||||
const badge = $("#auditNavBadge") || $('.side-nav a[data-view="audit"] .nav-badge');
|
||||
if (badge) {
|
||||
badge.textContent = unresolved.length;
|
||||
badge.style.display = unresolved.length ? "" : "none";
|
||||
badge.textContent = String(total);
|
||||
badge.style.display = total > 0 ? "" : "none";
|
||||
}
|
||||
const pending = $("#pending-count");
|
||||
if (pending) pending.textContent = unresolved.length;
|
||||
const foot = $("#auditFoot");
|
||||
if (foot) foot.textContent = `共 ${rows.length} 项 · 待审核 ${unresolved.length} 项`;
|
||||
if (pending) pending.textContent = String(total);
|
||||
}
|
||||
|
||||
async function refreshAuditCountsFromApi() {
|
||||
const from = await resolveDashStartDate();
|
||||
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
|
||||
if (!response?.ok) return null;
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || data.status !== "ok" || !data.audit) return null;
|
||||
applyAuditCounts(data.audit, { fromApi: true });
|
||||
return data.audit;
|
||||
}
|
||||
|
||||
function companyStatusBadge(status) {
|
||||
@@ -983,6 +1123,313 @@ 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 updateDashAuditCard(audit) {
|
||||
applyAuditCounts(audit, { fromApi: true });
|
||||
}
|
||||
|
||||
function renderDashCompanyRows(companies, selectedId) {
|
||||
const list = $("#dashCompanyRows");
|
||||
if (!list) return;
|
||||
if (!companies.length) {
|
||||
list.innerHTML = '<div class="dash-company-empty muted">暂无公司或尚无归集往来</div>';
|
||||
return;
|
||||
}
|
||||
const monthLabel = state.dashPeriodMonth ? `${state.dashPeriodMonth}月` : "";
|
||||
list.replaceChildren(...companies.map((company) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "dash-company-item";
|
||||
item.setAttribute("role", "option");
|
||||
item.dataset.companyId = company.id;
|
||||
item.tabIndex = 0;
|
||||
if (String(company.id) === String(selectedId)) {
|
||||
item.classList.add("is-selected");
|
||||
item.setAttribute("aria-selected", "true");
|
||||
} else {
|
||||
item.setAttribute("aria-selected", "false");
|
||||
}
|
||||
const main = document.createElement("div");
|
||||
main.className = "dash-company-main";
|
||||
const name = document.createElement("span");
|
||||
name.className = "dash-company-name";
|
||||
name.title = company.name;
|
||||
name.textContent = company.name;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "dash-company-meta";
|
||||
const statusLabel = company.period_status_label || "—";
|
||||
meta.textContent = monthLabel
|
||||
? `${company.detail_count ?? 0}笔 · ${monthLabel} ${statusLabel}`
|
||||
: `${company.detail_count ?? 0}笔 · ${statusLabel}`;
|
||||
main.append(name, meta);
|
||||
const net = document.createElement("span");
|
||||
net.className = `dash-company-net ${netClass(company.net_wan)}`;
|
||||
net.textContent = formatWan(company.net_wan);
|
||||
item.append(main, net);
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
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 formatPlainWan(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return "0.00";
|
||||
return Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function updateDashDetailSummary(company, payload) {
|
||||
const bar = $("#dashDetailSummary");
|
||||
if (!bar) return;
|
||||
if (!company) {
|
||||
bar.hidden = true;
|
||||
return;
|
||||
}
|
||||
bar.hidden = false;
|
||||
const count = payload?.groups
|
||||
? payload.groups.reduce((sum, g) => sum + Number(g.count || 0), 0)
|
||||
: Number(company.detail_count || 0);
|
||||
const debit = payload?.groups
|
||||
? payload.groups.reduce((sum, g) => sum + Number(g.debit_wan || 0), 0)
|
||||
: Number(company.debit_wan || 0);
|
||||
const credit = payload?.groups
|
||||
? payload.groups.reduce((sum, g) => sum + Number(g.credit_wan || 0), 0)
|
||||
: Number(company.credit_wan || 0);
|
||||
const ending = payload?.groups
|
||||
? payload.groups.reduce((sum, g) => sum + Number(g.ending_wan || 0), 0)
|
||||
: Number(company.net_wan || 0);
|
||||
const setText = (id, text, className = "") => {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = `dash-summary-value num ${className}`.trim();
|
||||
};
|
||||
setText("#dashSumCount", String(count));
|
||||
setText("#dashSumOpening", "—", "meta");
|
||||
setText("#dashSumDebit", formatPlainWan(debit));
|
||||
setText("#dashSumCredit", formatPlainWan(credit));
|
||||
setText("#dashSumEnding", formatWan(ending), netClass(ending));
|
||||
}
|
||||
|
||||
function showDashDetailEmpty(company) {
|
||||
const wrap = $("#dashDetailWrap");
|
||||
const empty = $("#dashDetailEmpty");
|
||||
const desc = $("#dashDetailEmptyDesc");
|
||||
if (wrap) wrap.hidden = true;
|
||||
if (empty) empty.hidden = false;
|
||||
if (desc) {
|
||||
const cutoff = state.dashCutoff || "—";
|
||||
// 方案 A 空态:两行克制说明;期初口径仍为不可用(—),不伪造 0.00
|
||||
desc.textContent = `期初 — · 截至 ${cutoff} 无明细记录 · 导入流水并归集后在此展示`;
|
||||
}
|
||||
updateDashDetailSummary(null);
|
||||
}
|
||||
|
||||
function showDashDetailTable() {
|
||||
const wrap = $("#dashDetailWrap");
|
||||
const empty = $("#dashDetailEmpty");
|
||||
if (wrap) wrap.hidden = false;
|
||||
if (empty) empty.hidden = true;
|
||||
}
|
||||
|
||||
function renderDashDetail(payload, { expandFirst = false, company = null } = {}) {
|
||||
const tbody = $("#dashDetailRows");
|
||||
const title = $("#dashDetailTitle");
|
||||
if (!tbody) return;
|
||||
const selected = company || (state.dashCompanies || []).find(
|
||||
(c) => String(c.id) === String(payload?.company_id || state.dashSelectedId)
|
||||
) || null;
|
||||
|
||||
if (!payload) {
|
||||
if (title) title.textContent = "请选择左侧公司";
|
||||
showDashDetailTable();
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>';
|
||||
updateDashDetailSummary(null);
|
||||
return;
|
||||
}
|
||||
if (title) title.textContent = payload.company_name || selected?.name || "公司往来明细";
|
||||
const sub = $("#dashDetailSub");
|
||||
if (sub) sub.textContent = "按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列";
|
||||
|
||||
const groups = payload.groups || [];
|
||||
if (!groups.length) {
|
||||
showDashDetailEmpty(selected || { detail_count: 0, debit_wan: 0, credit_wan: 0, net_wan: 0 });
|
||||
tbody.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
showDashDetailTable();
|
||||
updateDashDetailSummary(selected, payload);
|
||||
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;
|
||||
const company = (state.dashCompanies || []).find((c) => String(c.id) === String(companyId)) || null;
|
||||
renderDashCompanyRows(state.dashCompanies || [], companyId);
|
||||
const title = $("#dashDetailTitle");
|
||||
if (title && company) title.textContent = company.name;
|
||||
updateDashDetailSummary(company, null);
|
||||
showDashDetailTable();
|
||||
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, { company });
|
||||
}
|
||||
|
||||
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 = '<div class="dash-company-empty muted">总览加载失败</div>';
|
||||
return;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || data.status !== "ok") {
|
||||
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
|
||||
return;
|
||||
}
|
||||
state.dashCompanies = data.companies || [];
|
||||
state.dashFrom = data.from_date;
|
||||
state.dashCutoff = data.cutoff;
|
||||
state.dashPeriodMonth = data.period_month;
|
||||
updateDashAuditCard(data.audit);
|
||||
const listSub = $("#dashListSub");
|
||||
if (listSub) {
|
||||
listSub.textContent = `${data.from_date} 至 ${data.cutoff} · 单位:万元 · 左侧选择公司,右侧查看其往来明细`;
|
||||
}
|
||||
const meta = $("#dashMasterMeta");
|
||||
if (meta) {
|
||||
const totals = data.totals || {};
|
||||
const companyCount = totals.company_count ?? state.dashCompanies.length;
|
||||
const detailCount = totals.detail_count ?? 0;
|
||||
meta.textContent = `${companyCount} 家公司 · 明细 ${detailCount} 笔`;
|
||||
}
|
||||
const selected = state.dashSelectedId || state.dashCompanies[0]?.id;
|
||||
renderDashCompanyRows(state.dashCompanies, selected);
|
||||
if (selected) await loadDashCompanyDetail(selected);
|
||||
}
|
||||
|
||||
function initDashboard() {
|
||||
if (!$("#companyMasterDetail")) return;
|
||||
const selectCompany = (companyId) => {
|
||||
if (!companyId) return;
|
||||
loadDashCompanyDetail(companyId);
|
||||
};
|
||||
$("#dashCompanyRows")?.addEventListener("click", (event) => {
|
||||
const item = event.target.closest(".dash-company-item[data-company-id]");
|
||||
if (!item) return;
|
||||
selectCompany(item.dataset.companyId);
|
||||
});
|
||||
$("#dashCompanyRows")?.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
const item = event.target.closest(".dash-company-item[data-company-id]");
|
||||
if (!item) return;
|
||||
event.preventDefault();
|
||||
selectCompany(item.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 .dash-company-item[data-company-id]").forEach((item) => {
|
||||
item.style.display = (!q || item.textContent.includes(q)) ? "" : "none";
|
||||
});
|
||||
});
|
||||
loadAdminDashboard();
|
||||
}
|
||||
|
||||
async function loadAdminCompanies() {
|
||||
@@ -1004,6 +1451,7 @@ function initAdmin() {
|
||||
renderStoredAdminReviews();
|
||||
updateAuditCounts();
|
||||
loadAdminCompanies();
|
||||
initDashboard();
|
||||
|
||||
let activeAuditType = "all";
|
||||
function filterAuditRows() {
|
||||
@@ -1034,12 +1482,13 @@ function initAdmin() {
|
||||
async function submitAuditResult(row) {
|
||||
const decision = state.auditDecision;
|
||||
const reason = state.auditReason || "";
|
||||
const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用");
|
||||
const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用") || decision.includes("关闭异常");
|
||||
const returned = decision.includes("退回");
|
||||
const kind = row.dataset.recordKind;
|
||||
let storedStatus;
|
||||
let reviewedAccount = null;
|
||||
if (row.dataset.recordKind === "account" && row.dataset.accountId) {
|
||||
// Server-side review: the account only becomes usable after this succeeds.
|
||||
|
||||
if (kind === "account" && row.dataset.accountId) {
|
||||
const apiDecision = approved ? "approve" : returned ? "return" : "disable";
|
||||
const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, {
|
||||
method: "POST",
|
||||
@@ -1057,25 +1506,72 @@ function initAdmin() {
|
||||
}
|
||||
reviewedAccount = result.account;
|
||||
storedStatus = accountStatusLabel(result.account?.status);
|
||||
} else if (kind === "manual" && row.dataset.recordId) {
|
||||
const action = approved ? "approve_new" : "return";
|
||||
const response = await fetch(`/api/admin/manual-records/${row.dataset.recordId}/decisions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
reason: reason || decision,
|
||||
expected_decision_id: row.dataset.decisionId ? Number(row.dataset.decisionId) : null,
|
||||
request_key: `audit-manual-${row.dataset.recordId}-${Date.now()}`,
|
||||
subject_code: row.dataset.requestedSubject || undefined,
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showToast("手工单审核失败", result?.message || "请稍后重试", "danger");
|
||||
return;
|
||||
}
|
||||
storedStatus = approved ? "已确认" : "已退回";
|
||||
} else if (kind === "match" && row.dataset.eventId) {
|
||||
// 既有 decision:撤销当前匹配决定,使异常退出待办队列(与 reverse 语义一致)。
|
||||
const response = await fetch(`/api/admin/transfer-events/${row.dataset.eventId}/decisions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "reverse",
|
||||
reason: reason || decision || "审核中心关闭匹配异常",
|
||||
expected_revision: row.dataset.revision ? Number(row.dataset.revision) : null,
|
||||
request_key: `audit-match-${row.dataset.eventId}-${Date.now()}`,
|
||||
}),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
showToast("匹配异常处置失败", result?.message || "请稍后重试", "danger");
|
||||
return;
|
||||
}
|
||||
storedStatus = approved ? "已确认" : "已退回";
|
||||
} else {
|
||||
storedStatus = approved
|
||||
? (row.dataset.recordKind === "account" ? "已启用" : "已确认")
|
||||
: (returned ? "已退回" : "异常待处理");
|
||||
updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, reason, new Date().toLocaleString("zh-CN", { hour12: false }));
|
||||
}
|
||||
const status = recordStatus(storedStatus);
|
||||
const statusCell = row.children[5];
|
||||
statusCell.innerHTML = `<span class="pill ${pillClass(status.className)}">${status.label}</span>`;
|
||||
row.dataset.resolved = "true";
|
||||
if (reviewedAccount) row.dataset.accountStatus = reviewedAccount.status;
|
||||
const actionCell = row.children[6];
|
||||
if (reviewedAccount && reviewedAccount.status === "active") {
|
||||
actionCell.innerHTML = '<button type="button" class="btn btn-sm btn-danger" data-audit-action="disable">停用</button>';
|
||||
} else {
|
||||
actionCell.innerHTML = `<span class="meta">${approved ? "已通过" : "已驳回"} · 系统管理员</span>`;
|
||||
showToast("审核结果提交失败", "未知待办类型", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
// 处置后从列表移除并重拉三队列,保证页脚/过滤/标题与角标同步减一。
|
||||
row.remove();
|
||||
updateAuditCounts();
|
||||
showToast("审核结果已记录", approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算", "success");
|
||||
await refreshAuditCountsFromApi();
|
||||
const unresolved = $$("#auditRows tr").filter((r) => r.dataset.resolved !== "true");
|
||||
const high = unresolved.filter((r) => r.querySelector(".pill-danger")).length;
|
||||
const medium = unresolved.filter((r) => r.querySelector(".pill-warn")).length;
|
||||
const low = Math.max(0, unresolved.length - high - medium);
|
||||
applyAuditCounts({ total: unresolved.length, high, medium, low }, { fromApi: true });
|
||||
updatePendingAccountNotice();
|
||||
filterAuditRows();
|
||||
showToast(
|
||||
"审核结果已记录",
|
||||
approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算",
|
||||
"success",
|
||||
);
|
||||
}
|
||||
|
||||
$("#auditRows")?.addEventListener("click", (event) => {
|
||||
@@ -1109,7 +1605,12 @@ function initAdmin() {
|
||||
$("#approve-confirm")?.addEventListener("click", () => {
|
||||
if (!state.auditRow) return;
|
||||
closeModal("modal-approve");
|
||||
state.auditDecision = state.auditRow.dataset.recordKind === "account" ? "复核通过并启用账户" : "确认并纳入计算";
|
||||
const kind = state.auditRow.dataset.recordKind;
|
||||
state.auditDecision = kind === "account"
|
||||
? "复核通过并启用账户"
|
||||
: kind === "match"
|
||||
? "关闭异常"
|
||||
: "确认并纳入计算";
|
||||
state.auditReason = state.auditDecision;
|
||||
submitAuditResult(state.auditRow);
|
||||
});
|
||||
@@ -1119,7 +1620,12 @@ function initAdmin() {
|
||||
const reason = $("#reject-reason").value.trim();
|
||||
if (reason.length < 5) { $("#reject-hint").style.display = ""; return; }
|
||||
closeModal("modal-reject");
|
||||
state.auditDecision = state.auditRow.dataset.recordKind === "account" ? "退回公司修改" : "退回公司补充材料";
|
||||
const kind = state.auditRow.dataset.recordKind;
|
||||
state.auditDecision = kind === "account"
|
||||
? "退回公司修改"
|
||||
: kind === "match"
|
||||
? "退回重匹配"
|
||||
: "退回公司补充材料";
|
||||
state.auditReason = reason;
|
||||
submitAuditResult(state.auditRow);
|
||||
});
|
||||
@@ -1238,7 +1744,7 @@ function initAdmin() {
|
||||
const timeline = $("#timelineSub");
|
||||
if (timeline) timeline.textContent = `全局起算日 ${startDate} 起,每月 ${closingDay} 日结账`;
|
||||
const flows = $("#flowsRangeMeta");
|
||||
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账(7 月顺延至 08-29)`;
|
||||
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账`;
|
||||
const opening = $("#openingSub");
|
||||
if (opening) opening.textContent = `${startDate} 起算的公司间往来期初数`;
|
||||
}
|
||||
|
||||
+1
-1
@@ -957,6 +957,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=5"></script>
|
||||
<script src="app.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+271
-1
@@ -213,6 +213,233 @@ p { margin: 0; }
|
||||
.grid-2-1 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); align-items: start; }
|
||||
.grid-1-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); align-items: start; }
|
||||
.grid-3-2 { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: start; }
|
||||
/* 管理端首页主从:左栏约 480px(定稿 HEL-153),右侧明细更宽 */
|
||||
.grid-480-1 { grid-template-columns: minmax(0, 480px) minmax(0, 1fr); align-items: stretch; }
|
||||
|
||||
/* 方案 A:一体卡片 · 分栏主从(HEL-162) */
|
||||
.dash-master {
|
||||
margin-top: 14px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-master-head {
|
||||
padding: 12px 16px 10px;
|
||||
margin-bottom: 0;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dash-master-head .card-title .sub {
|
||||
display: inline;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.dash-master-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.dash-master-head-actions .input {
|
||||
width: 132px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
flex: none;
|
||||
}
|
||||
.dash-master-split {
|
||||
gap: 0;
|
||||
/* 少数据自适应;整卡(含共享头)目标约 340px;多数据模块内滚动 */
|
||||
max-height: 300px;
|
||||
/* max-height alone 无法约束隐式行高;补可收缩行后 pane 内 overflow:auto 才会生效 */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-master-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash-master-pane--list {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.dash-company-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.dash-company-loading,
|
||||
.dash-company-empty {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dash-company-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.dash-company-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.dash-company-item:hover {
|
||||
background: var(--fg-soft);
|
||||
}
|
||||
.dash-company-item.is-selected {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
padding-left: 13px;
|
||||
}
|
||||
.dash-company-item.is-selected:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.dash-company-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.dash-company-name {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13.5px;
|
||||
font-weight: 550;
|
||||
color: var(--fg);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.dash-company-meta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dash-company-net {
|
||||
flex: none;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
min-width: 4.5em;
|
||||
}
|
||||
.dash-detail-toolbar {
|
||||
padding: 10px 14px 0;
|
||||
flex: none;
|
||||
}
|
||||
.dash-detail-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dash-detail-name {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dash-detail-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin: 10px 14px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
flex: none;
|
||||
}
|
||||
.dash-summary-item {
|
||||
padding: 8px 10px;
|
||||
border-right: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
.dash-summary-item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.dash-summary-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.dash-summary-value {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dash-detail-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dash-master-scroll {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.dash-detail-empty {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 20px 24px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.dash-detail-empty .e-title {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.dash-detail-empty .e-desc {
|
||||
max-width: 360px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.dash-master-scroll[hidden],
|
||||
.dash-detail-empty[hidden],
|
||||
.dash-detail-summary[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.ds-table.ds-table--compact {
|
||||
min-width: 0;
|
||||
}
|
||||
.ds-table.ds-table--compact th,
|
||||
.ds-table.ds-table--compact td {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.ds-table.ds-table--compact th {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ds-table tbody tr.is-selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.ds-table tbody tr.is-selected:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.row { display: flex; align-items: center; gap: 10px; }
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
@@ -223,13 +450,56 @@ p { margin: 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid-4, .grid-5 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-3, .grid-2-1, .grid-1-2, .grid-3-2 { grid-template-columns: minmax(0, 1fr); }
|
||||
.grid-3, .grid-2-1, .grid-1-2, .grid-3-2, .grid-480-1 { grid-template-columns: minmax(0, 1fr); }
|
||||
.dash-master-split {
|
||||
max-height: none;
|
||||
grid-template-rows: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.dash-master-pane--list {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 280px;
|
||||
}
|
||||
.dash-master-pane--detail {
|
||||
max-height: 420px;
|
||||
}
|
||||
.dash-detail-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.dash-summary-item:nth-child(3) {
|
||||
border-right: 0;
|
||||
}
|
||||
.dash-summary-item:nth-child(n+4) {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.content { padding: 16px 16px 48px; }
|
||||
.dash-master-head-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dash-master-head-actions .input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
.dash-detail-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.dash-summary-item:nth-child(2n) {
|
||||
border-right: 0;
|
||||
}
|
||||
.dash-summary-item:nth-child(3) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.dash-summary-item:nth-child(n+3) {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 数据表 ────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user