diff --git a/server.py b/server.py
index 3ad02b8..71110e9 100644
--- a/server.py
+++ b/server.py
@@ -2305,16 +2305,28 @@ class AppHandler(SimpleHTTPRequestHandler):
)
return
try:
- payload = subjects.confirm_subject(
- connection,
- event_id,
- perspective_company_id=perspective_company_id,
- subject_code=str(data.get("subject_code") or ""),
- reason=str(data.get("reason") or ""),
- expected_revision=expected_revision,
- request_key=str(data.get("request_key") or "") or None,
- actor=user,
- )
+ action = str(data.get("action") or "confirm")
+ if action in ("return", "exception"):
+ payload = subjects.park_subject(
+ connection,
+ event_id,
+ disposition=action,
+ reason=str(data.get("reason") or ""),
+ expected_revision=expected_revision,
+ request_key=str(data.get("request_key") or "") or None,
+ actor=user,
+ )
+ else:
+ payload = subjects.confirm_subject(
+ connection,
+ event_id,
+ perspective_company_id=perspective_company_id,
+ subject_code=str(data.get("subject_code") or ""),
+ reason=str(data.get("reason") or ""),
+ expected_revision=expected_revision,
+ request_key=str(data.get("request_key") or "") or None,
+ actor=user,
+ )
except subjects.SubjectConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
diff --git a/src/bank_importer/manual_records.py b/src/bank_importer/manual_records.py
index f8eaca4..bb81059 100644
--- a/src/bank_importer/manual_records.py
+++ b/src/bank_importer/manual_records.py
@@ -683,12 +683,14 @@ def list_records(
SELECT m.*, d.id AS decision_id, d.state AS state, d.revision AS decision_revision,
d.action AS action, d.reason AS decision_reason,
d.actor_username AS decision_actor, d.created_at AS decision_at,
- c.name AS company_name, cc.name AS counterparty_company_name
+ c.name AS company_name, cc.name AS counterparty_company_name,
+ u.username AS submitted_by_username
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
LEFT JOIN companies c ON c.id = m.company_id
LEFT JOIN companies cc ON cc.id = m.counterparty_company_id
+ LEFT JOIN users u ON u.id = m.submitted_by
{where}
ORDER BY m.id DESC
LIMIT ?
@@ -752,6 +754,7 @@ def _record_payload(connection: sqlite3.Connection, record_id: int, *, idempoten
def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
+ evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {}
return {
"id": row["id"],
"company_id": row["company_id"],
@@ -772,6 +775,8 @@ def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str,
"request_key": row["request_key"],
"supersedes_record_id": row["supersedes_record_id"],
"submitted_by": row["submitted_by"],
+ "submitted_by_username": row["submitted_by_username"] if "submitted_by_username" in row.keys() else None,
+ "attachment_name": evidence.get("attachment_name"),
"created_at": row["created_at"],
"state": row["state"],
"decision_id": row["decision_id"],
diff --git a/src/bank_importer/positions.py b/src/bank_importer/positions.py
index 3016a3e..9c484b9 100644
--- a/src/bank_importer/positions.py
+++ b/src/bank_importer/positions.py
@@ -832,9 +832,106 @@ def event_payload(
if int(event["payer_company_id"]) == int(viewer_company_id)
else event["payer_company_name"]
)
+ item.update(_event_line_display(connection, event, viewer_company_id))
return item
+_REPAY_MARKERS = ("还款", "归还借款", "归还往来款")
+
+
+def bank_short_name(name: str | None) -> str:
+ text = str(name or "").strip()
+ if text.startswith("中国"):
+ text = text[2:]
+ if text.endswith("银行"):
+ text = text[:-2]
+ return text or "银行"
+
+
+def _account_chip(visibility: str, bank_name: str | None, account: str | None) -> dict[str, object]:
+ if visibility == "missing":
+ return {"visibility": "missing", "label": None}
+ if visibility == "masked":
+ return {"visibility": "masked", "label": "按对方授权不可见"}
+ number = str(account or "")
+ tail = number[-4:] if number else ""
+ short = bank_short_name(bank_name)
+ label = f"{short} {tail}".strip() if tail else short
+ return {"visibility": "visible", "label": label}
+
+
+def _event_line_display(
+ connection: sqlite3.Connection,
+ event: sqlite3.Row,
+ viewer_company_id: int | None,
+) -> dict[str, object]:
+ """Account chips, summary and repayment flag for the event table."""
+ missing = _account_chip("missing", None, None)
+ payer_chip, payee_chip = missing, missing
+ summary = None
+ texts: list[str] = []
+ sides = _event_source_sides(connection, event["ledger_event_id"])
+ for side in sides:
+ company_id = side["company_id"]
+ own = viewer_company_id is None or int(company_id) == int(viewer_company_id)
+ visibility = "visible" if own else "masked"
+ chip = _account_chip(visibility, side.get("bank_name"), side.get("own_account"))
+ if int(company_id) == int(event["payer_company_id"]):
+ payer_chip = chip
+ elif int(company_id) == int(event["payee_company_id"]):
+ payee_chip = chip
+ if own or viewer_company_id is None:
+ if side.get("summary"):
+ texts.append(str(side["summary"]))
+ if side.get("reason"):
+ texts.append(str(side["reason"]))
+ if texts:
+ summary = texts[0]
+ blob = " ".join(texts)
+ is_repayment = any(marker in blob for marker in _REPAY_MARKERS)
+ return {
+ "payer_account": payer_chip,
+ "payee_account": payee_chip,
+ "summary": summary,
+ "is_repayment": is_repayment,
+ }
+
+
+def _event_source_sides(
+ connection: sqlite3.Connection, ledger_event_id: int
+) -> list[dict[str, object]]:
+ rows = connection.execute(
+ """
+ SELECT b.company_id AS company_id, s.bank_name AS bank_name,
+ r.own_account AS own_account, r.summary AS summary,
+ r.purpose AS purpose, NULL AS reason
+ FROM ledger_event_bank_sources bs
+ JOIN current_transfer_decisions cur ON cur.event_id = bs.bank_event_id
+ JOIN transfer_decision_observations o ON o.decision_id = cur.decision_id
+ JOIN source_rows r ON r.id = o.source_row_id
+ JOIN sheet_batches s ON s.id = r.sheet_batch_id
+ JOIN import_batches b ON b.id = s.import_batch_id
+ WHERE bs.ledger_event_id = ?
+ """,
+ (ledger_event_id,),
+ ).fetchall()
+ if rows:
+ return [_row(row) for row in rows]
+ manuals = connection.execute(
+ """
+ SELECT m.company_id AS company_id, ba.bank_name AS bank_name,
+ ba.account_number AS own_account, m.summary AS summary,
+ NULL AS purpose, m.reason AS reason
+ FROM ledger_event_manual_sources ms
+ JOIN manual_records m ON m.id = ms.manual_record_id
+ LEFT JOIN bank_accounts ba ON ba.id = m.bank_account_id
+ WHERE ms.ledger_event_id = ?
+ """,
+ (ledger_event_id,),
+ ).fetchall()
+ return [_row(row) for row in manuals]
+
+
_DETAIL_SELECT = """
SELECT p.ledger_event_id, p.id AS ledger_revision_id, p.effective_at, p.amount,
p.amount_scale, p.currency, p.payer_company_id, p.payee_company_id,
@@ -939,7 +1036,7 @@ def subject_review_queue(
SELECT r.ledger_event_id, r.effective_at, r.amount, r.currency,
r.payer_company_id, r.payee_company_id,
cpayer.name AS payer_company_name, cpayee.name AS payee_company_name,
- r.id AS revision_id
+ r.id AS revision_id, r.evidence_json AS evidence_json
FROM current_ledger_event_revisions cur
JOIN ledger_event_revisions r ON r.id = cur.revision_id
JOIN companies cpayer ON cpayer.id = r.payer_company_id
@@ -957,6 +1054,9 @@ def subject_review_queue(
cursor_date, cursor_id = None, None
filtered = []
for row in rows:
+ evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {}
+ if evidence.get("admin_disposition") == "exception":
+ continue
if cursor_date is not None:
if row["effective_at"] > cursor_date or (
row["effective_at"] == cursor_date and row["ledger_event_id"] >= cursor_id
@@ -982,6 +1082,7 @@ def subject_review_queue(
""",
(row["ledger_event_id"],),
).fetchall()
+ display = _event_line_display(connection, row, None)
items.append(
{
"ledger_event_id": row["ledger_event_id"],
@@ -993,6 +1094,8 @@ def subject_review_queue(
"payer_company_name": row["payer_company_name"],
"payee_company_id": row["payee_company_id"],
"payee_company_name": row["payee_company_name"],
+ "summary": display.get("summary"),
+ "is_repayment": display.get("is_repayment"),
"suggestions": [
{
"suggested_perspective_company_id": sug["suggested_perspective_company_id"],
diff --git a/src/bank_importer/subjects.py b/src/bank_importer/subjects.py
index 2312af8..b891331 100644
--- a/src/bank_importer/subjects.py
+++ b/src/bank_importer/subjects.py
@@ -281,6 +281,96 @@ def confirm_subject(
return _revision_payload(connection, row)
+def park_subject(
+ connection: sqlite3.Connection,
+ ledger_event_id: int,
+ *,
+ disposition: str,
+ reason: str,
+ expected_revision: int | None,
+ request_key: str | None,
+ actor: sqlite3.Row,
+) -> dict[str, object]:
+ """Record 退回/转异常 without confirming a statutory subject.
+
+ The event stays ``pending_subject`` so it never enters confirmed balances.
+ ``exception`` is hidden from the active review queue; ``return`` remains
+ visible so the company can supplement materials.
+ """
+ from .ledger_events import append_revision, current_revision
+
+ if disposition not in ("return", "exception"):
+ raise SubjectInputError("科目处理只能是退回或转异常。")
+ reason = (reason or "").strip()
+ if not reason:
+ raise SubjectInputError("必须填写处理依据。")
+
+ began = False
+ if not connection.in_transaction:
+ connection.execute("BEGIN IMMEDIATE")
+ began = True
+ try:
+ if request_key:
+ existing = connection.execute(
+ """
+ SELECT * FROM ledger_event_revisions
+ WHERE ledger_event_id = ? AND idempotency_key = ?
+ ORDER BY id LIMIT 1
+ """,
+ (ledger_event_id, request_key),
+ ).fetchone()
+ if existing is not None:
+ if began:
+ connection.commit()
+ return _revision_payload(connection, existing)
+
+ current = current_revision(connection, ledger_event_id)
+ if current is None:
+ raise SubjectConflictError("该事件不存在或没有当前修订。")
+ if current["state"] != "pending_subject":
+ raise SubjectConflictError("只有待确认科目的事件可以退回或转异常。")
+ if expected_revision is not None and int(expected_revision) not in (
+ current["id"], current["revision"],
+ ):
+ raise SubjectConflictError("事件已发生变更,请刷新后重试。")
+ evidence = json.loads(current["evidence_json"] or "{}") if current["evidence_json"] else {}
+ evidence["admin_disposition"] = disposition
+ revision_id = append_revision(
+ connection,
+ ledger_event_id,
+ state="pending_subject",
+ effective_at=current["effective_at"],
+ amount=current["amount"],
+ currency=current["currency"],
+ payer_company_id=current["payer_company_id"],
+ payee_company_id=current["payee_company_id"],
+ perspective_company_id=None,
+ subject_code=None,
+ source_kind=current["source_kind"],
+ source_revision_token=current["source_revision_token"],
+ posting_kind=current["posting_kind"],
+ reverses_ledger_event_id=current["reverses_ledger_event_id"],
+ adjusts_ledger_event_id=current["adjusts_ledger_event_id"],
+ rule_version=current["rule_version"] or SUBJECT_RULE_VERSION,
+ evidence_json=json.dumps(evidence, ensure_ascii=False),
+ idempotency_key=request_key,
+ actor=actor,
+ reason=reason,
+ supersedes_revision_id=current["id"],
+ )
+ row = connection.execute(
+ "SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,)
+ ).fetchone()
+ except Exception:
+ if began:
+ connection.rollback()
+ raise
+ else:
+ if began:
+ connection.commit()
+ return _revision_payload(connection, row)
+
+
def _revision_payload(connection: sqlite3.Connection, revision: sqlite3.Row) -> dict[str, object]:
company = connection.execute(
"SELECT name FROM companies WHERE id = ?", (revision["perspective_company_id"],)
diff --git a/tests/b44_ui_check.js b/tests/b44_ui_check.js
new file mode 100644
index 0000000..dd658a6
--- /dev/null
+++ b/tests/b44_ui_check.js
@@ -0,0 +1,45 @@
+"use strict";
+
+const path = require("path");
+const assert = require("assert");
+const ui = require(path.join(__dirname, "..", "web", "app.js"));
+
+assert.strictEqual(ui.fmtAbsMoney(-1280), "1,280.00");
+assert.strictEqual(ui.fmtAbsMoney(1280), "1,280.00");
+assert.strictEqual(ui.fmtAbsMoney("60.5"), "60.50");
+
+assert.strictEqual(ui.eventIsNegative({ posting_kind: "reversal" }), true);
+assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: true }), true);
+assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: false }), false);
+
+assert.strictEqual(ui.cycleTab(0, 4, false), 1);
+assert.strictEqual(ui.cycleTab(3, 4, false), 0);
+assert.strictEqual(ui.cycleTab(0, 4, true), 3);
+assert.strictEqual(ui.cycleTab(2, 5, true), 1);
+assert.strictEqual(ui.cycleTab(0, 0, false), 0);
+
+assert.strictEqual(ui.drawerEscAction(1), "close");
+assert.strictEqual(ui.drawerEscAction(0), "close");
+assert.strictEqual(ui.drawerEscAction(2), "back");
+assert.strictEqual(ui.drawerEscAction(3), "back");
+
+const abs = ui.amountWithCurrency(1280, "CNY");
+assert.ok(abs.includes("CNY"));
+assert.ok(abs.includes("1,280.00"));
+assert.ok(!abs.includes("+"));
+assert.ok(!abs.includes("−"));
+
+const repay = ui.amountWithCurrency(3200000, "CNY", { signed: true, negative: true });
+assert.ok(repay.includes("−"));
+assert.ok(!repay.includes("+"));
+assert.ok(repay.includes("3,200,000.00"));
+
+assert.strictEqual(ui.resultDirection(10).label, "应收");
+assert.strictEqual(ui.resultDirection(-10).label, "应付");
+assert.strictEqual(ui.resultDirection(0).label, "持平");
+
+assert.ok(ui.accountCell({ visibility: "visible", label: "中信 5316" }).includes("中信 5316"));
+assert.ok(ui.accountCell({ visibility: "masked" }).includes("按对方授权不可见"));
+assert.ok(ui.accountCell({ visibility: "missing" }).includes("源行缺失"));
+
+console.log("b44_ui_check ok");
diff --git a/tests/test_b44_frontend.py b/tests/test_b44_frontend.py
new file mode 100644
index 0000000..516b1cb
--- /dev/null
+++ b/tests/test_b44_frontend.py
@@ -0,0 +1,94 @@
+"""Static + Node checks for the B-44 visual rework.
+
+Covers unique ids, nav copy, drawer/directory breakpoints, eight-column
+event table, and the exported keyboard/amount helpers.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+import re
+import subprocess
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+WEB = ROOT / "web"
+
+
+class B44FrontendContractTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ cls.admin = (WEB / "admin.html").read_text(encoding="utf-8")
+ cls.company = (WEB / "company.html").read_text(encoding="utf-8")
+ cls.css = (WEB / "styles.css").read_text(encoding="utf-8")
+ cls.js = (WEB / "app.js").read_text(encoding="utf-8")
+
+ def test_admin_ids_are_unique(self) -> None:
+ self.assertEqual(1, self.admin.count('id="companyLedgers"'))
+ self.assertEqual(1, self.admin.count('id="balanceLedgers"'))
+ self.assertIn('data-view="pair"', self.admin)
+ self.assertRegex(self.admin, r'data-view="pair"[^>]*>[\s\S]*?往来查询')
+ self.assertIn("
往来查询
", self.admin)
+ self.assertIn("转为异常后,该记录暂不纳入余额计算", self.admin)
+ self.assertIn('id="auditExceptionNote"', self.admin)
+
+ def test_company_balance_groups_and_nav(self) -> None:
+ self.assertIn('id="companyBalanceGroups"', self.company)
+ self.assertNotIn('id="companyBalanceLine"', self.company)
+ self.assertRegex(self.company, r'data-view="balances"[^>]*>[\s\S]*?往来余额')
+ self.assertIn("往来余额
", self.company)
+
+ def test_css_directory_and_drawer_breakpoints(self) -> None:
+ self.assertIn("@media (max-width: 768px)", self.css)
+ self.assertIn("@media (max-width: 375px)", self.css)
+ self.assertIn("@media (max-width: 1179px)", self.css)
+ self.assertIn("@media (max-width: 767px)", self.css)
+ self.assertIn(".drawer {", self.css)
+ self.assertIn("width: 640px", self.css)
+ self.assertIn(".drawer { width: 480px; }", self.css)
+ self.assertIn(".drawer { width: 100%; }", self.css)
+ self.assertIn(".drawer .pair-balance-line.is-six", self.css)
+ self.assertIn("repeat(3, 1fr)", self.css)
+ self.assertIn(".drawer .event-table { min-width: 860px; }", self.css)
+ self.assertIn(".company-row.is-balance-counterparty", self.css)
+ self.assertIn(".ledger-hide-md", self.css)
+
+ def test_js_selectors_and_event_table(self) -> None:
+ self.assertIn('loadAdminBalances($("#balanceLedgers"))', self.js)
+ self.assertNotIn('loadAdminBalances($("#companyLedgers"))', self.js)
+ self.assertIn("交易日期", self.js)
+ self.assertIn("本方账户", self.js)
+ self.assertIn("对方账户", self.js)
+ self.assertIn("摘要", self.js)
+ self.assertIn("匹配状态", self.js)
+ self.assertIn("function eventTableHead()", self.js)
+ self.assertIn("colspan=\"8\"", self.js)
+ self.assertIn("function cycleTab(", self.js)
+ self.assertIn("function drawerEscAction(", self.js)
+ self.assertIn("function eventIsNegative(", self.js)
+ self.assertIn("fmtAbsMoney", self.js)
+ self.assertIn("is-balance-counterparty", self.js)
+ heads = re.search(
+ r"function eventTableHead\(\) \{\s*return `([^`]+)`",
+ self.js,
+ )
+ self.assertIsNotNone(heads)
+ markup = heads.group(1)
+ self.assertNotIn("来源", markup)
+ for label in ("交易日期", "方向", "科目", "本方账户", "对方账户", "摘要", "匹配状态", "金额"):
+ self.assertIn(label, markup)
+ self.assertEqual(8, len(re.findall(r" None:
+ result = subprocess.run(
+ ["node", str(ROOT / "tests" / "b44_ui_check.js")],
+ capture_output=True,
+ text=True,
+ cwd=str(ROOT),
+ )
+ self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+ self.assertIn("b44_ui_check ok", result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_positions.py b/tests/test_positions.py
index 54cbf56..3fb42f5 100644
--- a/tests/test_positions.py
+++ b/tests/test_positions.py
@@ -359,6 +359,72 @@ class PaginationTests(LedgerBase):
{(self.company_a, "CNY"), (self.company_b, "CNY")}, seen
)
+ def test_event_payload_account_chip_and_repayment_flag(self) -> None:
+ self.pair(
+ self.company_a, self.company_b, "40.00",
+ summary="归还往来款", purpose="还款",
+ )
+ ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
+ event_id = int(self.ledger_events()[0]["id"])
+ detail = positions.event_detail(self.connection, event_id)
+ event = detail["event"]
+ self.assertEqual("visible", event["payer_account"]["visibility"])
+ self.assertEqual("测试 0001", event["payer_account"]["label"])
+ self.assertEqual("visible", event["payee_account"]["visibility"])
+ self.assertEqual("测试 0002", event["payee_account"]["label"])
+ self.assertEqual("归还往来款", event["summary"])
+ self.assertTrue(event["is_repayment"])
+
+ company_view = positions.event_payload(
+ self.connection,
+ self.connection.execute(
+ positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?",
+ (event_id,),
+ ).fetchone(),
+ viewer_company_id=self.company_a,
+ )
+ self.assertEqual("visible", company_view["payer_account"]["visibility"])
+ self.assertEqual("masked", company_view["payee_account"]["visibility"])
+ self.assertEqual("按对方授权不可见", company_view["payee_account"]["label"])
+
+ def test_park_subject_exception_hides_from_review_queue(self) -> None:
+ self.pair(self.company_a, self.company_b, "100.00")
+ ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
+ event_id = int(self.ledger_events()[0]["id"])
+ current = self.current(event_id)
+ subjects.park_subject(
+ self.connection, event_id,
+ disposition="exception", reason="转异常核查",
+ expected_revision=current["id"], request_key="park-exc-1",
+ actor=self.admin,
+ )
+ queue = positions.subject_review_queue(
+ self.connection, from_="2026-01-01", cutoff="2026-07-31",
+ )
+ self.assertFalse(
+ any(item["ledger_event_id"] == event_id for item in queue["items"])
+ )
+ revision = self.current(event_id)
+ self.assertEqual("pending_subject", revision["state"])
+
+ def test_park_subject_return_stays_in_review_queue(self) -> None:
+ self.pair(self.company_a, self.company_b, "80.00")
+ ledger_events.reconcile_bank_events(self.connection, actor=self.admin)
+ event_id = int(self.ledger_events()[0]["id"])
+ current = self.current(event_id)
+ subjects.park_subject(
+ self.connection, event_id,
+ disposition="return", reason="退回补充摘要",
+ expected_revision=current["id"], request_key="park-ret-1",
+ actor=self.admin,
+ )
+ queue = positions.subject_review_queue(
+ self.connection, from_="2026-01-01", cutoff="2026-07-31",
+ )
+ self.assertTrue(
+ any(item["ledger_event_id"] == event_id for item in queue["items"])
+ )
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_positions_api.py b/tests/test_positions_api.py
index 6ce2cfc..58ba707 100644
--- a/tests/test_positions_api.py
+++ b/tests/test_positions_api.py
@@ -550,6 +550,45 @@ class IntercompanyApiTests(unittest.TestCase):
self.assertEqual("2026-06-15", reversals[0]["effective_at"][:10])
self.assertEqual(event_id, reversals[0]["reverses_ledger_event_id"])
+ def test_admin_events_include_account_chips(self) -> None:
+ pending = self._fresh_pair("100.00")
+ revision = self._confirm(pending["ledger_event_id"])
+ status, _, data = self.admin.get(
+ "/api/admin/intercompany/events?from=2026-01-01&cutoff=2026-12-31"
+ )
+ self.assertEqual(200, status, data)
+ event = next(
+ item for item in as_json(data)["items"]
+ if item["ledger_event_id"] == revision["ledger_event_id"]
+ )
+ self.assertIn("payer_account", event)
+ self.assertIn("payee_account", event)
+ self.assertEqual("visible", event["payer_account"]["visibility"])
+ self.assertTrue(event["payer_account"]["label"])
+ self.assertIn("summary", event)
+ self.assertIn("is_repayment", event)
+
+ def test_subject_exception_drops_from_review_queue(self) -> None:
+ pending = self._fresh_pair("77.00")
+ status, _, data = self.admin.post_json(
+ f"/api/admin/intercompany/events/{pending['ledger_event_id']}/subject-decisions",
+ {
+ "perspective_company_id": pending["payer_company_id"],
+ "action": "exception",
+ "reason": "转异常待核查",
+ "expected_revision": pending["revision_id"],
+ "request_key": "park-exc-api",
+ },
+ )
+ self.assertEqual(200, status, data)
+ self.assertEqual("pending_subject", as_json(data)["revision"]["state"])
+ status, _, data = self.admin.get(
+ "/api/admin/subject-reviews?from=2026-01-01&cutoff=2026-12-31"
+ )
+ self.assertEqual(200, status, data)
+ ids = [item["ledger_event_id"] for item in as_json(data)["items"]]
+ self.assertNotIn(pending["ledger_event_id"], ids)
+
if __name__ == "__main__":
unittest.main()
diff --git a/web/admin.html b/web/admin.html
index 53bb995..3c84c21 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -22,7 +22,7 @@
金金牛集团总账管理端
|