将待复核账户、待审手工单、匹配异常统一渲染到审核中心列表, 处置走既有 decision API,并补三类种子一致性与处置减一集成测试。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
499 lines
20 KiB
Python
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()
|