Compare commits

...
Author SHA1 Message Date
c4f7573653 HEL-162: 修复宽屏主从列表无法内部滚动
为 .dash-master-split 增加 grid-template-rows: minmax(0,1fr),使 max-height 真正约束行高,左侧 7 家公司可滚可选;顺手删除无调用的 statusPill。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 16:14:37 +00:00
5b3ebad757 HEL-162: 按方案A重做首页一体卡片主从区域
合并公司往来合计/明细为一张共享头卡片;左侧改列表行选中态,右侧加汇总条与克制空态;取消固定大高度,模块内滚动。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 15:40:07 +00:00
总工andmultica-agent b42b6481f5 HEL-158 部署:app.js 缓存版本升至 v=6
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 10:59:20 +00:00
e04f47b9d2 HEL-157: 审核中心接入三类待办完整队列
将待复核账户、待审手工单、匹配异常统一渲染到审核中心列表,
处置走既有 decision API,并补三类种子一致性与处置减一集成测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 10:45:03 +00:00
7fb97119ba HEL-157: 删除首页图表、校正主从模块并统一待审核口径
移除柱状图/折线图死代码;主从改为 480px+明细宽栏;待审核首页卡、侧栏角标与后端口径同源。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 10:09:01 +00:00
7 changed files with 1051 additions and 308 deletions
+4
View File
@@ -22,3 +22,7 @@ nul
vendor_pkgs/
node_modules/
package-lock.json
# local vendor for agent test env (not shipped)
.vendor/
vendor_wheels/
+15 -2
View File
@@ -42,8 +42,11 @@ def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
Priority mapping (aligned with current admin audit UI semantics):
- high: unresolved / needs_review match exceptions
- medium: pending bank-account registrations
- 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(
"""
@@ -55,9 +58,19 @@ def audit_counts(connection: sqlite3.Connection) -> dict[str, int]:
AND d.classification IN ('unresolved', 'needs_review')
"""
).fetchone()["n"]
medium = connection.execute(
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),
+294
View File
@@ -57,6 +57,96 @@ class DashboardUnitTests(unittest.TestCase):
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(
@@ -180,6 +270,210 @@ class DashboardApiTests(unittest.TestCase):
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"
+66 -101
View File
@@ -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>
@@ -82,64 +82,74 @@
</div>
</div>
<div class="grid grid-2" style="margin-top: 14px;">
<div class="card">
<div class="card-head" style="margin-bottom: 10px;">
<span class="card-title">各公司往来分布<span class="sub">期末净值 · 单位:万元</span></span>
<div 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 id="dashBarChart" style="height: 220px; min-width: 0;" aria-label="各公司往来分布柱状图"></div>
</div>
<div class="card">
<div class="card-head" style="margin-bottom: 10px;">
<span class="card-title">本周资金流入流出<span class="sub">集团内往来 · 单位:万元</span></span>
</div>
<div id="dashLineChart" style="height: 220px; min-width: 0;" aria-label="本周资金流入流出折线图"></div>
</div>
</div>
<div class="card" id="companyMasterDetail" style="margin-top: 14px; padding: 0; overflow: hidden;">
<div class="grid grid-1-2" style="gap: 0; align-items: stretch; height: 492px;">
<div style="border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; min-height: 0;">
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0; gap: 8px;">
<span class="card-title" style="min-width: 0;">公司往来合计<span class="sub" id="dashListSub" style="display: inline; margin-left: 6px;">单位:万元</span></span>
<input class="input" id="company-search" aria-label="搜索公司" placeholder="搜索…" style="width: 120px; min-height: 28px; padding: 4px 8px; flex: none;" />
</div>
<div class="table-wrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
<table class="ds-table" id="dashCompanyTable" style="min-width: 0;">
<thead>
<tr>
<th style="padding: 6px 12px;">公司</th>
<th class="num-col" style="padding: 6px 12px;">明细笔数</th>
<th class="num-col" style="padding: 6px 12px;">期末净值</th>
<th id="dashStatusHead" style="padding: 6px 12px;">状态</th>
</tr>
</thead>
<tbody id="dashCompanyRows">
<tr class="loading-row"><td colspan="4">加载中…</td></tr>
</tbody>
</table>
<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 style="display: flex; flex-direction: column; min-width: 0; min-height: 0;">
<div class="card-head" style="padding: 10px 14px 8px; margin-bottom: 0;">
<span class="card-title" id="dashDetailTitle">公司往来明细<span class="sub" id="dashDetailSub">选择左侧公司查看会计式分级明细</span></span>
<div 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="table-wrap" id="dashDetailWrap" style="border: 0; border-radius: 0; flex: 1; min-height: 0; overflow: auto;">
<table class="ds-table" id="dashDetailTable" style="min-width: 0;">
<thead>
<tr>
<th style="padding: 6px 12px;">对方 / 摘要</th>
<th class="num-col" style="padding: 6px 12px;">笔数</th>
<th class="num-col" style="padding: 6px 12px;">期初</th>
<th class="num-col" style="padding: 6px 12px;">本期借方</th>
<th class="num-col" style="padding: 6px 12px;">本期贷方</th>
<th class="num-col" style="padding: 6px 12px;">期末</th>
</tr>
</thead>
<tbody id="dashDetailRows">
<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>
</tbody>
</table>
<div 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>
</div>
</div>
</div>
@@ -347,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">
@@ -977,6 +942,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=8"></script>
</body>
</html>
+400 -203
View File
@@ -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,26 +1003,53 @@ function updateAuditCounts() {
const badge = $(".tab-count", button);
if (badge) badge.textContent = count;
});
const badge = $('.side-nav a[data-view="audit"] .nav-badge');
if (badge) {
badge.textContent = unresolved.length;
badge.style.display = unresolved.length ? "" : "none";
}
const pending = $("#pending-count");
if (pending) pending.textContent = unresolved.length;
const foot = $("#auditFoot");
if (foot) foot.textContent = `${rows.length} 项 · 待审核 ${unresolved.length}`;
// Keep dashboard audit card in sync with the live audit table when possible.
// Prefer the authoritative /api/admin/dashboard payload; this is a best-effort
// DOM fallback only when the card already exists and dashboard reload has not run.
if ($("#dashAuditTotal") && !state.dashAuditFromApi) {
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);
updateDashAuditCard({ total: unresolved.length, high, medium, low });
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 = String(total);
badge.style.display = total > 0 ? "" : "none";
}
const pending = $("#pending-count");
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) {
if (status === "preparing") return { className: "neutral", label: "筹备中" };
if (status === "disabled") return { className: "danger", label: "已停用" };
@@ -1014,133 +1145,48 @@ function netClass(value) {
return "";
}
function statusPill(label) {
if (!label || label === "—") return '<span class="meta">—</span>';
const map = {
"已完成": "pill-success",
"流水待补": "pill-warn",
"确认中": "pill-warn",
"未提交": "pill-danger",
};
return `<span class="pill ${map[label] || "pill-muted"}">${label}</span>`;
}
function updateDashAuditCard(audit) {
const totalEl = $("#dashAuditTotal");
const footEl = $("#dashAuditFoot");
if (!totalEl || !footEl || !audit) return;
totalEl.innerHTML = `${audit.total ?? 0}<span class="unit">项</span>`;
footEl.textContent = `${audit.high ?? 0} 项 · 中 ${audit.medium ?? 0} 项 · 其余 ${audit.low ?? 0} 项低风险`;
}
function renderBarChart(companies) {
const host = $("#dashBarChart");
if (!host) return;
if (!companies.length) {
host.innerHTML = '<div class="empty" style="padding: 36px 12px;"><div class="e-title">暂无往来分布</div><div>归集后将显示各公司期末净值</div></div>';
return;
}
const values = companies.map((c) => Number(c.net_wan) || 0);
const maxAbs = Math.max(1, ...values.map((v) => Math.abs(v)));
const w = 560;
const h = 220;
const padL = 44;
const padR = 12;
const padT = 24;
const padB = 36;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const zeroY = padT + plotH / 2;
const gap = 10;
const barW = Math.max(12, (plotW - gap * companies.length) / companies.length);
const ticks = [maxAbs, maxAbs / 2, 0, -maxAbs / 2, -maxAbs];
const tickLines = ticks.map((t) => {
const y = zeroY - (t / maxAbs) * (plotH / 2);
const label = t === 0 ? "0" : (t > 0 ? `+${Math.round(t)}` : `${Math.round(t)}`);
return `<line x1="${padL}" y1="${y}" x2="${w - padR}" y2="${y}" stroke="var(--border)" stroke-width="1"/>
<text x="${padL - 6}" y="${y + 3}" text-anchor="end" fill="var(--muted)" font-size="10" font-family="var(--font-mono)">${label}</text>`;
}).join("");
const bars = companies.map((c, i) => {
const v = Number(c.net_wan) || 0;
const x = padL + gap / 2 + i * (barW + gap);
const bh = (Math.abs(v) / maxAbs) * (plotH / 2);
const y = v >= 0 ? zeroY - bh : zeroY;
const color = v >= 0 ? "var(--success)" : "var(--danger)";
const label = (c.name || "").replace(/河南|有限公司|科技发展/g, "").slice(0, 4) || c.name;
const valY = v >= 0 ? y - 4 : y + bh + 11;
return `<rect x="${x}" y="${y}" width="${barW}" height="${Math.max(bh, v === 0 ? 0 : 2)}" fill="${color}" rx="2"/>
<text x="${x + barW / 2}" y="${valY}" text-anchor="middle" fill="${color}" font-size="10" font-family="var(--font-mono)">${formatWan(v).replace(".00", "")}</text>
<text x="${x + barW / 2}" y="${h - 10}" text-anchor="middle" fill="var(--muted)" font-size="10">${label}</text>`;
}).join("");
host.innerHTML = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="100%" role="img">${tickLines}<line x1="${padL}" y1="${zeroY}" x2="${w - padR}" y2="${zeroY}" stroke="var(--fg)" stroke-width="1" opacity="0.35"/>${bars}</svg>`;
}
function renderLineChart(flow) {
const host = $("#dashLineChart");
if (!host) return;
const labels = flow?.labels || [];
const inflow = (flow?.inflow_wan || []).map(Number);
const outflow = (flow?.outflow_wan || []).map(Number);
if (!labels.length) {
host.innerHTML = '<div class="empty" style="padding: 36px 12px;"><div class="e-title">暂无周度流水</div><div>有归集数据后显示近 7 日流入流出</div></div>';
return;
}
const w = 560;
const h = 220;
const padL = 40;
const padR = 12;
const padT = 20;
const padB = 32;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const maxV = Math.max(1, ...inflow, ...outflow);
const xAt = (i) => padL + (labels.length === 1 ? plotW / 2 : (i / (labels.length - 1)) * plotW);
const yAt = (v) => padT + plotH - (v / maxV) * plotH;
const pathOf = (series) => series.map((v, i) => `${i ? "L" : "M"}${xAt(i)},${yAt(v)}`).join(" ");
const grid = [0, 0.5, 1].map((t) => {
const y = yAt(maxV * t);
return `<line x1="${padL}" y1="${y}" x2="${w - padR}" y2="${y}" stroke="var(--border)"/><text x="${padL - 6}" y="${y + 3}" text-anchor="end" fill="var(--muted)" font-size="10" font-family="var(--font-mono)">${Math.round(maxV * t)}</text>`;
}).join("");
const xLabels = labels.map((lab, i) => `<text x="${xAt(i)}" y="${h - 8}" text-anchor="middle" fill="var(--muted)" font-size="10" font-family="var(--font-mono)">${lab}</text>`).join("");
host.innerHTML = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="100%" role="img">${grid}
<path d="${pathOf(inflow)}" fill="none" stroke="var(--success)" stroke-width="2"/>
<path d="${pathOf(outflow)}" fill="none" stroke="var(--danger)" stroke-width="2"/>
${inflow.map((v, i) => `<circle cx="${xAt(i)}" cy="${yAt(v)}" r="2.5" fill="var(--success)"/>`).join("")}
${outflow.map((v, i) => `<circle cx="${xAt(i)}" cy="${yAt(v)}" r="2.5" fill="var(--danger)"/>`).join("")}
${xLabels}
<text x="${w - padR}" y="14" text-anchor="end" fill="var(--success)" font-size="11">流入</text>
<text x="${w - padR - 40}" y="14" text-anchor="end" fill="var(--danger)" font-size="11">流出</text>
</svg>`;
applyAuditCounts(audit, { fromApi: true });
}
function renderDashCompanyRows(companies, selectedId) {
const tbody = $("#dashCompanyRows");
if (!tbody) return;
const list = $("#dashCompanyRows");
if (!list) return;
if (!companies.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">暂无公司或尚无归集往来</td></tr>';
list.innerHTML = '<div class="dash-company-empty muted">暂无公司或尚无归集往来</div>';
return;
}
tbody.replaceChildren(...companies.map((company) => {
const tr = document.createElement("tr");
tr.className = "clickable";
tr.dataset.companyId = company.id;
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)) {
tr.style.background = "var(--accent-soft)";
item.classList.add("is-selected");
item.setAttribute("aria-selected", "true");
} else {
item.setAttribute("aria-selected", "false");
}
const nameTd = document.createElement("td");
nameTd.style.cssText = "white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;";
nameTd.title = company.name;
nameTd.textContent = company.name;
const countTd = document.createElement("td");
countTd.className = "num-col";
countTd.textContent = String(company.detail_count ?? 0);
const netTd = document.createElement("td");
netTd.className = `num-col ${netClass(company.net_wan)}`;
netTd.textContent = formatWan(company.net_wan);
const statusTd = document.createElement("td");
statusTd.innerHTML = statusPill(company.period_status_label);
tr.append(nameTd, countTd, netTd, statusTd);
return tr;
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;
}));
}
@@ -1152,22 +1198,94 @@ function chevronSvg(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="m9 18 6-6-6-6"/></svg>';
}
function renderDashDetail(payload, { expandFirst = false } = {}) {
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.innerHTML = `公司往来明细 · ${payload.company_name}<span class="sub" id="dashDetailSub">按对方公司分组 · 二级默认收起 · 期初待 B-45</span>`;
}
if (title) title.textContent = payload.company_name || selected?.name || "公司往来明细";
const sub = $("#dashDetailSub");
if (sub) sub.textContent = "按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列";
const groups = payload.groups || [];
if (!groups.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">该公司区间内暂无已归集往来</td></tr>';
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;
@@ -1221,7 +1339,12 @@ async function resolveDashStartDate() {
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();
@@ -1231,7 +1354,7 @@ async function loadDashCompanyDetail(companyId) {
return;
}
const result = await response.json().catch(() => null);
renderDashDetail(result);
renderDashDetail(result, { company });
}
async function loadAdminDashboard() {
@@ -1239,25 +1362,30 @@ async function loadAdminDashboard() {
const from = await resolveDashStartDate();
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
if (!response?.ok) {
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
return;
}
const data = await response.json().catch(() => null);
if (!data || data.status !== "ok") {
$("#dashCompanyRows").innerHTML = '<tr><td colspan="4" class="muted">总览加载失败</td></tr>';
$("#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);
state.dashAuditFromApi = true;
const statusHead = $("#dashStatusHead");
if (statusHead && data.period_month) statusHead.textContent = `${data.period_month}月状态`;
const listSub = $("#dashListSub");
if (listSub) listSub.textContent = `${data.from_date}${data.cutoff} · 单位:万元`;
renderBarChart(state.dashCompanies);
renderLineChart(data.weekly_flow);
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);
@@ -1265,10 +1393,21 @@ async function loadAdminDashboard() {
function initDashboard() {
if (!$("#companyMasterDetail")) return;
const selectCompany = (companyId) => {
if (!companyId) return;
loadDashCompanyDetail(companyId);
};
$("#dashCompanyRows")?.addEventListener("click", (event) => {
const tr = event.target.closest("tr[data-company-id]");
if (!tr) return;
loadDashCompanyDetail(tr.dataset.companyId);
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]");
@@ -1286,8 +1425,8 @@ function initDashboard() {
});
$("#company-search")?.addEventListener("input", function () {
const q = this.value.trim();
$$("#dashCompanyRows tr[data-company-id]").forEach((tr) => {
tr.style.display = (!q || tr.textContent.includes(q)) ? "" : "none";
$$("#dashCompanyRows .dash-company-item[data-company-id]").forEach((item) => {
item.style.display = (!q || item.textContent.includes(q)) ? "" : "none";
});
});
loadAdminDashboard();
@@ -1343,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",
@@ -1366,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) => {
@@ -1418,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);
});
@@ -1428,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);
});
+1 -1
View File
@@ -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
View File
@@ -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);
}
}
/* ─── 数据表 ────────────────────────────────────────────────────── */