Files
caiwuzongzhang/tests/test_dashboard.py
T
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

499 lines
20 KiB
Python

"""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()