B-44: 返修——余额目录视觉、八列事件表、抽屉键盘与审核三项决定
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2305,6 +2305,18 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
)
|
||||
return
|
||||
try:
|
||||
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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],)
|
||||
|
||||
@@ -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");
|
||||
@@ -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]*?<span>往来查询</span>')
|
||||
self.assertIn("<h1>往来查询</h1>", 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]*?<span>往来余额</span>')
|
||||
self.assertIn("<h1>往来余额</h1>", 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"<th\b", markup)))
|
||||
|
||||
def test_node_keyboard_and_amount_helpers(self) -> 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+5
-5
@@ -22,7 +22,7 @@
|
||||
<div class="brand"><span class="brand-mark">金</span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
|
||||
<nav class="nav-list">
|
||||
<button class="nav-item is-active" data-view="dashboard"><svg><use href="icons.svg#layout-dashboard"/></svg><span>管理总览</span></button>
|
||||
<button class="nav-item" data-view="pair"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来余额</span></button>
|
||||
<button class="nav-item" data-view="pair"><svg><use href="icons.svg#arrow-left-right"/></svg><span>往来查询</span></button>
|
||||
<button class="nav-item" data-view="audit"><svg><use href="icons.svg#list-checks"/></svg><span>审核中心</span><b>6</b></button>
|
||||
<button class="nav-item" data-view="flows"><svg><use href="icons.svg#file-spreadsheet"/></svg><span>流水管理</span></button>
|
||||
<button class="nav-item" data-view="companies"><svg><use href="icons.svg#users"/></svg><span>公司与账号</span></button>
|
||||
@@ -147,7 +147,7 @@
|
||||
</section>
|
||||
|
||||
<section class="app-view" data-page="pair">
|
||||
<header class="page-heading"><div><h1>往来余额</h1><p>公司间往来余额目录、公司对明细与逐层追溯</p></div>
|
||||
<header class="page-heading"><div><h1>往来查询</h1><p>公司间往来余额目录、公司对明细与逐层追溯</p></div>
|
||||
<span class="status neutral" id="balanceOpeningNote">期初不可用 · 仅显示期间净变动</span>
|
||||
</header>
|
||||
<section class="query-band">
|
||||
@@ -160,8 +160,8 @@
|
||||
</section>
|
||||
<section class="panel company-ledger-panel" aria-label="公司余额目录">
|
||||
<div class="panel-heading"><div><h2>公司余额目录</h2><p>每行余额都附带截止日、期初状态、本期借贷、结果与未决金额</p></div></div>
|
||||
<div class="ledger-head is-balances"><span>公司</span><span>借方合计</span><span>贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
|
||||
<div id="companyLedgers" class="company-ledgers" aria-live="polite"></div>
|
||||
<div class="ledger-head is-balances"><span>公司</span><span class="ledger-hide-md">借方合计</span><span class="ledger-hide-md">贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
|
||||
<div id="balanceLedgers" class="company-ledgers" aria-live="polite"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
<dialog id="auditDialog" class="dialog">
|
||||
<form id="auditForm">
|
||||
<header><div><h2 id="auditDialogTitle">审核事项</h2><p id="auditDialogMeta">原始证据与处理决定将一并留痕</p></div><button type="button" class="icon-button" data-close-audit aria-label="关闭" title="关闭"><svg><use href="icons.svg#x"/></svg></button></header>
|
||||
<div class="dialog-body"><div class="evidence-block" id="auditEvidence"></div><label class="field"><span>处理决定</span><select name="decision" required><option value="">请选择</option><option>确认并纳入计算</option><option>退回公司补充材料</option><option>转为异常待后续处理</option></select></label><label class="field"><span>处理依据</span><textarea name="reason" rows="3" required placeholder="填写核验账号、摘要、回单或说明"></textarea></label></div>
|
||||
<div class="dialog-body"><div class="evidence-block" id="auditEvidence"></div><label class="field"><span>处理决定</span><select name="decision" required><option value="">请选择</option><option>确认并纳入计算</option><option>退回公司补充材料</option><option>转为异常</option></select></label><p class="decision-danger" id="auditExceptionNote" hidden>转为异常后,该记录暂不纳入余额计算,转入异常队列等待人工核查;此操作将写入审计日志。</p><label class="field"><span>处理依据</span><textarea name="reason" rows="3" required placeholder="填写核验账号、摘要、回单或说明"></textarea></label></div>
|
||||
<footer><button type="button" class="button secondary" data-close-audit>取消</button><button class="button primary" type="submit">提交审核结果</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
+451
-216
@@ -1,9 +1,17 @@
|
||||
const $ = (selector, scope = document) => scope.querySelector(selector);
|
||||
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
||||
const $ = (selector, scope) => {
|
||||
const root = scope ?? (typeof document !== "undefined" ? document : null);
|
||||
return root ? root.querySelector(selector) : null;
|
||||
};
|
||||
const $$ = (selector, scope) => {
|
||||
const root = scope ?? (typeof document !== "undefined" ? document : null);
|
||||
return root ? [...root.querySelectorAll(selector)] : [];
|
||||
};
|
||||
|
||||
const portal = document.body.dataset.portal || "entry";
|
||||
const portal = (typeof document !== "undefined" && document.body)
|
||||
? (document.body.dataset.portal || "entry")
|
||||
: "entry";
|
||||
const viewNames = portal === "admin"
|
||||
? { dashboard: "管理总览", pair: "往来余额", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
|
||||
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
|
||||
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", balances: "往来余额", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
|
||||
|
||||
const storageKeys = {
|
||||
@@ -23,7 +31,9 @@ const state = {
|
||||
parseResult: null,
|
||||
};
|
||||
|
||||
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const motionQuery = (typeof window !== "undefined" && window.matchMedia)
|
||||
? window.matchMedia("(prefers-reduced-motion: reduce)")
|
||||
: { matches: true, addEventListener() {}, removeEventListener() {} };
|
||||
|
||||
function animateView(view, { initial = false } = {}) {
|
||||
if (!view || motionQuery.matches || typeof view.animate !== "function") return;
|
||||
@@ -493,6 +503,65 @@ function fmtMoney(value) {
|
||||
return Number.isFinite(num) ? num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—";
|
||||
}
|
||||
|
||||
function fmtAbsMoney(value) {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—";
|
||||
}
|
||||
|
||||
function homeCrumb() {
|
||||
return portal === "admin" ? "往来查询" : "往来余额";
|
||||
}
|
||||
|
||||
function resultDirection(signedOrDirection) {
|
||||
if (signedOrDirection === "receivable" || Number(signedOrDirection) > 0) return { className: "success", label: "应收" };
|
||||
if (signedOrDirection === "payable" || Number(signedOrDirection) < 0) return { className: "danger", label: "应付" };
|
||||
return { className: "neutral", label: "持平" };
|
||||
}
|
||||
|
||||
function eventIsNegative(event) {
|
||||
return event.posting_kind === "reversal" || Boolean(event.is_repayment);
|
||||
}
|
||||
|
||||
function amountWithCurrency(value, currency, { signed = false, negative = false } = {}) {
|
||||
const abs = fmtAbsMoney(value);
|
||||
const sign = (signed && (negative || Number(value) < 0)) ? "−" : "";
|
||||
const code = currency ? `<span class="currency-code">${esc(currency)}</span>` : "";
|
||||
return `<span class="amount-with-currency">${code}${sign}${abs}</span>`;
|
||||
}
|
||||
|
||||
function accountCell(chip) {
|
||||
if (!chip || chip.visibility === "missing") return '<span class="status info">源行缺失</span>';
|
||||
if (chip.visibility === "masked") return `<span class="evidence-masked">${esc(chip.label || "按对方授权不可见")}</span>`;
|
||||
return `<span class="account-cell">${esc(chip.label || "—")}</span>`;
|
||||
}
|
||||
|
||||
function pickAccount(event, companyId, side) {
|
||||
const isPayer = Number(event.payer_company_id) === Number(companyId);
|
||||
if (side === "own") return isPayer ? event.payer_account : event.payee_account;
|
||||
return isPayer ? event.payee_account : event.payer_account;
|
||||
}
|
||||
|
||||
function eventSummaryText(event) {
|
||||
const text = String(event.summary || "").trim();
|
||||
if (text) return text;
|
||||
const reason = String(event.reason || "").trim();
|
||||
return reason ? reason.split(/\r?\n/)[0] : "—";
|
||||
}
|
||||
|
||||
function sixLineHtml({ currency, debit, credit, signed, resultLabel, unresolved, cutoff, conservationNote }) {
|
||||
const direction = resultDirection(signed);
|
||||
const unresolvedBlock = unresolved && "html" in (unresolved || {}) ? unresolved : unresolvedText(unresolved);
|
||||
return `
|
||||
<div class="pair-balance-line is-six">
|
||||
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong><small class="pair-open-note">B-45 前无可靠期初</small></div>
|
||||
<div><span>本期借方</span><strong>${amountWithCurrency(debit, currency)}</strong></div>
|
||||
<div><span>本期贷方</span><strong>${amountWithCurrency(credit, currency)}</strong></div>
|
||||
<div class="pair-final"><span>期末结果</span><strong><em class="status ${direction.className}" style="margin-right:6px">${direction.label}</em>${amountWithCurrency(signed, currency)}</strong><small class="pair-open-note">${esc(resultLabel || "期间净变动")}</small></div>
|
||||
<div class="pair-unresolved ${unresolvedBlock.active ? "" : "is-empty"}"><span>未决金额</span><strong>${amountWithCurrency(unresolvedBlock.gross ?? unresolved?.gross_amount ?? 0, currency)}</strong><small class="pair-open-note">${unresolvedBlock.count ?? unresolved?.count ?? 0} 笔</small></div>
|
||||
<div><span>截止日</span><strong class="amount-neutral">${fmtDate(cutoff)}</strong><small class="pair-cutoff">${esc(conservationNote || "按币种独立展示")}</small></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
const text = String(iso || "");
|
||||
return text.slice(0, 10).replace(/-/g, ".");
|
||||
@@ -531,46 +600,47 @@ function postingLabel(event) {
|
||||
function unresolvedText(unresolved) {
|
||||
const gross = Number(unresolved?.gross_amount || 0);
|
||||
const count = unresolved?.count || 0;
|
||||
if (!count) return { html: '<span class="status neutral">未决 0.00</span>', active: false };
|
||||
if (!count) return { html: '<span class="status neutral">未决 0.00</span>', active: false, gross: 0, count: 0 };
|
||||
const reasons = Object.entries(unresolved.by_reason || {})
|
||||
.map(([key, value]) => `${b44.reasonLabels[key] || key} ${fmtMoney(value.gross_amount)} · ${value.count} 笔`)
|
||||
.map(([key, value]) => `${b44.reasonLabels[key] || key} ${fmtAbsMoney(value.gross_amount)} · ${value.count} 笔`)
|
||||
.join(";");
|
||||
return {
|
||||
html: `<span class="status warning">未决 ${fmtMoney(gross)} · ${count} 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px" title="${esc(reasons)}">${esc(reasons)}</small>`,
|
||||
html: `<span class="status warning">未决 ${fmtAbsMoney(gross)} · ${count} 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px" title="${esc(reasons)}">${esc(reasons)}</small>`,
|
||||
active: true,
|
||||
gross,
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function drawerFocusables(root) {
|
||||
return [...root.querySelectorAll(FOCUSABLE)].filter((node) => !node.hasAttribute("disabled") && node.getAttribute("aria-hidden") !== "true");
|
||||
}
|
||||
|
||||
function cycleTab(index, length, shift) {
|
||||
if (!length) return 0;
|
||||
if (shift) return (index - 1 + length) % length;
|
||||
return (index + 1) % length;
|
||||
}
|
||||
|
||||
function drawerEscAction(depth) {
|
||||
return depth > 1 ? "back" : "close";
|
||||
}
|
||||
|
||||
function drawerState() {
|
||||
const drawer = $("#evidenceDrawer");
|
||||
const scrim = $("#drawerScrim");
|
||||
let trigger = null;
|
||||
const layerStack = [];
|
||||
let opener = null;
|
||||
const stack = [];
|
||||
|
||||
function open(triggerElement) {
|
||||
if (!drawer) return;
|
||||
trigger = triggerElement || trigger;
|
||||
drawer.classList.add("is-open");
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
scrim?.classList.add("is-open");
|
||||
requestAnimationFrame(() => $("#drawerBody", drawer)?.focus({ preventScroll: true }));
|
||||
}
|
||||
|
||||
function close({ restoreFocus = true } = {}) {
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove("is-open");
|
||||
drawer.setAttribute("aria-hidden", "true");
|
||||
scrim?.classList.remove("is-open");
|
||||
layerStack.length = 0;
|
||||
if (restoreFocus && trigger) trigger.focus();
|
||||
}
|
||||
|
||||
function renderLayer(title, crumbs, html, onMount) {
|
||||
if (!drawer) return;
|
||||
$("#drawerTitle", drawer).textContent = title;
|
||||
function paint() {
|
||||
if (!drawer || !stack.length) return;
|
||||
const layer = stack[stack.length - 1];
|
||||
$("#drawerTitle", drawer).textContent = layer.title;
|
||||
const breadcrumb = $("#drawerBreadcrumb", drawer);
|
||||
breadcrumb.replaceChildren();
|
||||
crumbs.forEach((crumb, index) => {
|
||||
layer.crumbs.forEach((crumb, index) => {
|
||||
if (index) breadcrumb.append(document.createTextNode("/"));
|
||||
if (crumb.action) {
|
||||
const button = document.createElement("button");
|
||||
@@ -584,18 +654,84 @@ function drawerState() {
|
||||
breadcrumb.append(span);
|
||||
}
|
||||
});
|
||||
$("#drawerBody", drawer).innerHTML = html;
|
||||
if (typeof onMount === "function") onMount($("#drawerBody", drawer));
|
||||
const body = $("#drawerBody", drawer);
|
||||
body.innerHTML = layer.html;
|
||||
if (typeof layer.onMount === "function") layer.onMount(body);
|
||||
requestAnimationFrame(() => {
|
||||
const nodes = drawerFocusables(drawer);
|
||||
(nodes[0] || body).focus({ preventScroll: true });
|
||||
if (layer.restoreSelector) {
|
||||
const target = body.querySelector(layer.restoreSelector);
|
||||
if (target) target.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function open(triggerElement) {
|
||||
if (!drawer) return;
|
||||
if (!stack.length) opener = triggerElement || opener;
|
||||
drawer.classList.add("is-open");
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
scrim?.classList.add("is-open");
|
||||
}
|
||||
|
||||
function close({ restoreFocus = true } = {}) {
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove("is-open");
|
||||
drawer.setAttribute("aria-hidden", "true");
|
||||
scrim?.classList.remove("is-open");
|
||||
stack.length = 0;
|
||||
if (restoreFocus && opener && document.contains(opener)) opener.focus();
|
||||
opener = null;
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (stack.length <= 1) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const leaving = stack.pop();
|
||||
paint();
|
||||
requestAnimationFrame(() => {
|
||||
const body = $("#drawerBody", drawer);
|
||||
const selector = leaving.triggerSelector;
|
||||
const target = selector ? body?.querySelector(selector) : null;
|
||||
if (target) target.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
function renderLayer(title, crumbs, html, onMount, options = {}) {
|
||||
if (!drawer) return;
|
||||
if (options.replaceTop && stack.length) stack.pop();
|
||||
stack.push({
|
||||
title,
|
||||
crumbs,
|
||||
html,
|
||||
onMount,
|
||||
triggerSelector: options.triggerSelector || null,
|
||||
restoreSelector: options.restoreSelector || null,
|
||||
});
|
||||
paint();
|
||||
}
|
||||
|
||||
$$("[data-close-drawer]").forEach((button) => button.addEventListener("click", () => close()));
|
||||
drawer?.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
close();
|
||||
event.preventDefault();
|
||||
if (drawerEscAction(stack.length) === "back") back();
|
||||
else close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !drawer.classList.contains("is-open")) return;
|
||||
const nodes = drawerFocusables(drawer);
|
||||
if (!nodes.length) return;
|
||||
const current = nodes.indexOf(document.activeElement);
|
||||
const next = cycleTab(current < 0 ? 0 : current, nodes.length, event.shiftKey);
|
||||
event.preventDefault();
|
||||
nodes[next].focus();
|
||||
});
|
||||
return { open, close, renderLayer };
|
||||
return { open, close, back, renderLayer, stack };
|
||||
}
|
||||
|
||||
const drawer = drawerState();
|
||||
@@ -658,21 +794,17 @@ function renderBalanceDirectory(container, items) {
|
||||
const details = document.createElement("details");
|
||||
details.className = "company-ledger is-balances";
|
||||
details.dataset.companyId = item.company_id;
|
||||
const direction = item.result.direction === "receivable"
|
||||
? { className: "success", label: "应收" }
|
||||
: item.result.direction === "payable"
|
||||
? { className: "danger", label: "应付" }
|
||||
: { className: "neutral", label: "持平" };
|
||||
const direction = resultDirection(item.result.direction);
|
||||
const unresolved = unresolvedText(item.unresolved);
|
||||
const name = item.company_name || "未命名公司";
|
||||
const summary = document.createElement("summary");
|
||||
summary.innerHTML = `
|
||||
<span class="company-name"><i>${esc(name.slice(0, 1))}</i><b>${esc(name)}</b><small class="currency-tag">${esc(item.currency)}</small></span>
|
||||
<strong class="amount debit">${fmtMoney(item.period.debit)}</strong>
|
||||
<strong class="amount credit">${fmtMoney(item.period.credit)}</strong>
|
||||
<span class="ledger-result"><em class="status ${direction.className}">${direction.label}</em><b>${fmtMoney(item.result.signed_amount)}</b></span>
|
||||
<span class="company-name" title="${esc(name)}"><i>${esc(name.slice(0, 1))}</i><b>${esc(name)}</b><small class="currency-tag">${esc(item.currency)}</small></span>
|
||||
<strong class="amount debit ledger-hide-md">${amountWithCurrency(item.period.debit, item.currency)}</strong>
|
||||
<strong class="amount credit ledger-hide-md">${amountWithCurrency(item.period.credit, item.currency)}</strong>
|
||||
<span class="ledger-result"><em class="status ${direction.className}">${direction.label}</em><b>${amountWithCurrency(item.result.signed_amount, item.currency)}</b></span>
|
||||
<span class="ledger-unresolved ${unresolved.active ? "is-active" : "is-empty"}">${unresolved.html}</span>
|
||||
<span class="ledger-cutoff ledger-hide-sm">截止 ${fmtDate(item.window.cutoff)}</span>
|
||||
<span class="ledger-cutoff">截止 ${fmtDate(item.window.cutoff)}</span>
|
||||
<svg><use href="icons.svg#chevron-down"/></svg>`;
|
||||
details.append(summary);
|
||||
const breakdown = document.createElement("div");
|
||||
@@ -697,12 +829,13 @@ async function loadCompanyBreakdown(container, item, close) {
|
||||
const byPair = new Map();
|
||||
for (const event of data.items) {
|
||||
if (event.state !== "confirmed") continue;
|
||||
if (event.currency && item.currency && event.currency !== item.currency) continue;
|
||||
const isPayer = event.payer_company_id === item.company_id;
|
||||
const counterpartyId = isPayer ? event.payee_company_id : event.payer_company_id;
|
||||
const counterpartyName = isPayer ? event.payee_company_name : event.payer_company_name;
|
||||
const subject = subjectOf(event, item.company_id);
|
||||
const key = `${counterpartyId}|${subject}`;
|
||||
const bucket = byPair.get(key) || { counterpartyId, counterpartyName, subject, count: 0, amount: 0, isPayer };
|
||||
const key = `${counterpartyId}|${subject}|${event.currency}`;
|
||||
const bucket = byPair.get(key) || { counterpartyId, counterpartyName, subject, currency: event.currency, count: 0, amount: 0, isPayer };
|
||||
bucket.count += 1;
|
||||
bucket.amount += Number(event.amount);
|
||||
byPair.set(key, bucket);
|
||||
@@ -712,18 +845,18 @@ async function loadCompanyBreakdown(container, item, close) {
|
||||
regionEmpty(container, "暂无已确认明细", "该公司的确认往来将在科目确认后出现");
|
||||
return;
|
||||
}
|
||||
const section = (title, total, rows) => `
|
||||
<section><header><h3>${title}</h3><strong>${fmtMoney(total)}</strong></header>
|
||||
const section = (title, total, rows, currency) => `
|
||||
<section><header><h3>${title}</h3><strong>${amountWithCurrency(total, currency)}</strong></header>
|
||||
${rows.map((row) => `
|
||||
<button type="button" class="subject-row" data-open-pair="${row.counterpartyId}" aria-label="查看与 ${esc(row.counterpartyName)} 的往来明细">
|
||||
<span><b>${esc(row.counterpartyName)}</b><small>${esc(b44.subjectLabels[row.subject] || row.subject)} · ${row.count} 笔</small></span>
|
||||
<strong>${fmtMoney(row.amount)}</strong><svg><use href="icons.svg#chevron-right"/></svg>
|
||||
<span><b>${esc(row.counterpartyName)}</b><small>${esc(b44.subjectLabels[row.subject] || row.subject)} · ${esc(row.currency)} · ${row.count} 笔</small></span>
|
||||
<strong>${amountWithCurrency(row.amount, row.currency)}</strong><svg><use href="icons.svg#chevron-right"/></svg>
|
||||
</button>`).join("")}
|
||||
</section>`;
|
||||
const debitRows = [...new Map(debit.map((row) => [row.counterpartyId, row])).values()];
|
||||
const creditRows = [...new Map(credit.map((row) => [row.counterpartyId, row])).values()];
|
||||
container.innerHTML = section("借方明细", debit.reduce((sum, row) => sum + row.amount, 0), debitRows)
|
||||
+ section("贷方明细", credit.reduce((sum, row) => sum + row.amount, 0), creditRows);
|
||||
const debitRows = [...new Map(debit.map((row) => [`${row.counterpartyId}|${row.subject}`, row])).values()];
|
||||
const creditRows = [...new Map(credit.map((row) => [`${row.counterpartyId}|${row.subject}`, row])).values()];
|
||||
container.innerHTML = section("借方明细", debitRows.reduce((sum, row) => sum + row.amount, 0), debitRows, item.currency)
|
||||
+ section("贷方明细", creditRows.reduce((sum, row) => sum + row.amount, 0), creditRows, item.currency);
|
||||
container.querySelectorAll("[data-open-pair]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const counterpartyId = Number(button.dataset.openPair);
|
||||
@@ -736,13 +869,57 @@ async function loadCompanyBreakdown(container, item, close) {
|
||||
}
|
||||
}
|
||||
|
||||
function eventTableHead() {
|
||||
return `<thead><tr><th>交易日期</th><th>方向</th><th>科目</th><th>本方账户</th><th>对方账户</th><th>摘要</th><th>匹配状态</th><th class="number">金额</th></tr></thead>`;
|
||||
}
|
||||
|
||||
function eventRowHtml(event, companyId) {
|
||||
const subject = subjectOf(event, companyId) || event.own_subject;
|
||||
const direction = event.direction || (event.payer_company_id === companyId ? "outgoing" : "incoming");
|
||||
const posting = event.posting_kind !== "normal" ? `<small>${postingLabel(event)}</small>` : "";
|
||||
const otherName = event.counterparty_company_name
|
||||
|| (event.payer_company_id === companyId ? event.payee_company_name : event.payer_company_name);
|
||||
const summary = eventSummaryText(event);
|
||||
return `
|
||||
<tr class="event-row" data-subject-row="${subject || ""}" tabindex="0" role="button"
|
||||
aria-label="查看 ${fmtDate(event.effective_at)} 与 ${esc(otherName)} 的源行证据"
|
||||
data-event-id="${event.ledger_event_id}">
|
||||
<td>${fmtDate(event.effective_at)}${posting}</td>
|
||||
<td>${directionChip(direction)}</td>
|
||||
<td>${event.state === "confirmed" ? esc(b44.subjectLabels[subject] || event.own_subject_label || "") : '<span class="status info">待确认科目</span>'}</td>
|
||||
<td>${accountCell(pickAccount(event, companyId, "own"))}</td>
|
||||
<td>${accountCell(pickAccount(event, companyId, "counterparty"))}</td>
|
||||
<td class="summary-cell" title="${esc(summary)}">${esc(summary)}</td>
|
||||
<td>${eventStateChip(event.state)}</td>
|
||||
<td class="number">${amountWithCurrency(event.amount, event.currency, { signed: true, negative: eventIsNegative(event) })}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function bindEventRows(root, tableId, companyId) {
|
||||
$$(`#${tableId} tbody tr.event-row`, root).forEach((row) => {
|
||||
const open = () => {
|
||||
renderEventEvidence(Number(row.dataset.eventId), companyId, {
|
||||
triggerSelector: `[data-event-id="${row.dataset.eventId}"]`,
|
||||
});
|
||||
};
|
||||
row.addEventListener("click", open);
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Admin: pair detail + events -------------------------------------------
|
||||
|
||||
async function renderAdminPair(companyId, counterpartyId, companyName, extraCrumbs = []) {
|
||||
const otherName = b44.companyNames.get(counterpartyId) || "对方公司";
|
||||
drawer.open();
|
||||
drawer.renderLayer(
|
||||
`${companyName || "公司"} ↔ ${otherName}`,
|
||||
[{ label: "往来余额", action: () => drawer.close() }, { label: `${companyName || companyId} ↔ ${otherName}` }],
|
||||
[{ label: homeCrumb(), action: () => drawer.close() }, { label: `${companyName || companyId} ↔ ${otherName}` }],
|
||||
regionSkeleton(4),
|
||||
);
|
||||
try {
|
||||
@@ -755,43 +932,44 @@ async function renderAdminPair(companyId, counterpartyId, companyName, extraCrum
|
||||
}
|
||||
|
||||
function renderAdminPairBody(data, companyId, counterpartyId) {
|
||||
const item = data.items[0];
|
||||
const a = data.companies.a;
|
||||
const b = data.companies.b;
|
||||
const aResult = item.a.result;
|
||||
const direction = aResult.direction === "receivable" ? "应收" : aResult.direction === "payable" ? "应付" : "持平";
|
||||
const directionClass = aResult.direction === "receivable" ? "success" : aResult.direction === "payable" ? "danger" : "neutral";
|
||||
const unresolved = unresolvedText(item.unresolved);
|
||||
const subjectButtons = Object.values(item.subjects || {}).map((subject) => `
|
||||
const lines = (data.items || []).map((item) => sixLineHtml({
|
||||
currency: item.currency,
|
||||
debit: item.a.period.debit,
|
||||
credit: item.a.period.credit,
|
||||
signed: item.a.result.signed_amount,
|
||||
resultLabel: item.a.result.label,
|
||||
unresolved: item.unresolved,
|
||||
cutoff: data.window.cutoff,
|
||||
conservationNote: "双方守恒已校验",
|
||||
})).join("");
|
||||
const first = data.items[0];
|
||||
const subjectButtons = Object.values(first?.subjects || {}).map((subject) => `
|
||||
<button data-subject="${subject.subject_code}" aria-pressed="false">
|
||||
<span>${esc(subject.label)}</span><strong>${fmtMoney(Number(subject.a_debit) - Number(subject.a_credit))} · ${subject.count} 笔</strong>
|
||||
<span>${esc(subject.label)}</span><strong>${amountWithCurrency(Math.abs(Number(subject.a_debit) - Number(subject.a_credit)), first.currency)} · ${subject.count} 笔</strong>
|
||||
</button>`).join("");
|
||||
const eventCount = (data.items || []).reduce((sum, item) => sum + (item.trace?.event_count || 0), 0);
|
||||
const body = `
|
||||
<div class="pair-balance-line is-six">
|
||||
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong><small class="pair-open-note">B-45 前无可靠期初</small></div>
|
||||
<div><span>本期借方</span><strong>${fmtMoney(item.a.period.debit)}</strong></div>
|
||||
<div><span>本期贷方</span><strong>${fmtMoney(item.a.period.credit)}</strong></div>
|
||||
<div class="pair-final"><span>期末结果</span><strong style="color:var(--color-primary)"><em class="status ${directionClass}" style="margin-right:6px">${direction}</em>${fmtMoney(aResult.signed_amount)}</strong><small class="pair-open-note">${esc(item.a.result.label)}</small></div>
|
||||
<div class="pair-unresolved ${unresolved.active ? "" : "is-empty"}"><span>未决金额</span><strong>${fmtMoney(item.unresolved.gross_amount)}</strong><small class="pair-open-note">${item.unresolved.count} 笔</small></div>
|
||||
<div><span>截止日</span><strong class="amount-neutral">${fmtDate(data.window.cutoff)}</strong><small class="pair-cutoff">双方守恒已校验</small></div>
|
||||
</div>
|
||||
${lines}
|
||||
<div class="subject-strip">
|
||||
<button class="is-active" data-subject="all" aria-pressed="true"><span>全部往来</span><strong>${item.trace.event_count} 笔</strong></button>
|
||||
<button class="is-active" data-subject="all" aria-pressed="true"><span>全部往来</span><strong>${eventCount} 笔</strong></button>
|
||||
${subjectButtons}
|
||||
</div>
|
||||
<div class="table-scroll"><table class="data-table event-table" id="pairEventsTable">
|
||||
<thead><tr><th>交易日期</th><th>方向</th><th>科目</th><th>本方账户</th><th>对方账户</th><th>摘要</th><th>匹配状态</th><th class="number">金额</th></tr></thead>
|
||||
${eventTableHead()}
|
||||
<tbody><tr><td colspan="8"><div class="skeleton-row skeleton-block"><span></span><span></span><span></span><span></span><span></span></div></td></tr></tbody>
|
||||
</table></div>`;
|
||||
drawer.renderLayer(
|
||||
`${a.name} ↔ ${b.name}`,
|
||||
[
|
||||
{ label: "往来余额", action: () => drawer.close() },
|
||||
{ label: homeCrumb(), action: () => drawer.close() },
|
||||
{ label: `${a.name} ↔ ${b.name}` },
|
||||
],
|
||||
body,
|
||||
(root) => {
|
||||
loadPairEvents(root, companyId, counterpartyId, item.trace.events_url);
|
||||
const urls = (data.items || []).map((item) => item.trace.events_url);
|
||||
loadPairEvents(root, companyId, counterpartyId, urls);
|
||||
$$("[data-subject]", root).forEach((button) => button.addEventListener("click", () => {
|
||||
$$("[data-subject]", root).forEach((item) => {
|
||||
const active = item === button;
|
||||
@@ -804,54 +982,26 @@ function renderAdminPairBody(data, companyId, counterpartyId) {
|
||||
});
|
||||
}));
|
||||
},
|
||||
{ replaceTop: true },
|
||||
);
|
||||
}
|
||||
|
||||
async function loadPairEvents(root, companyId, counterpartyId, url) {
|
||||
async function loadPairEvents(root, companyId, counterpartyId, urls) {
|
||||
const tbody = $("#pairEventsTable tbody", root);
|
||||
if (!tbody) return;
|
||||
tbody.setAttribute("aria-busy", "true");
|
||||
const pageSize = 50;
|
||||
try {
|
||||
const data = await apiJson(`${url}&limit=${pageSize}`);
|
||||
const pages = await Promise.all((Array.isArray(urls) ? urls : [urls]).map((url) => apiJson(`${url}&limit=${pageSize}`)));
|
||||
const items = pages.flatMap((page) => page.items || []);
|
||||
tbody.setAttribute("aria-busy", "false");
|
||||
if (!data.items.length) {
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8"><div class="state-panel"><svg class="state-icon"><use href="icons.svg#inbox"/></svg><strong>该区间无往来事件</strong><p>当前公司对在统计区间内没有逐笔事件</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = data.items.map((event) => {
|
||||
const subject = subjectOf(event, companyId);
|
||||
const direction = event.payer_company_id === companyId ? "outgoing" : "incoming";
|
||||
const isReversalOrAdjustment = event.posting_kind !== "normal";
|
||||
const sign = event.payer_company_id === companyId ? 1 : -1;
|
||||
return `
|
||||
<tr class="event-row" data-subject-row="${subject || ""}" tabindex="0" role="button"
|
||||
aria-label="查看 ${fmtDate(event.effective_at)} 与 ${esc(event.payer_company_id === companyId ? event.payee_company_name : event.payer_company_name)} 的源行证据"
|
||||
data-event-id="${event.ledger_event_id}">
|
||||
<td>${fmtDate(event.effective_at)}${isReversalOrAdjustment ? `<small>${postingLabel(event)}</small>` : ""}</td>
|
||||
<td>${directionChip(direction)}</td>
|
||||
<td>${event.state === "confirmed" ? esc(b44.subjectLabels[subject] || "") : '<span class="status info">待确认科目</span>'}</td>
|
||||
<td>${esc(event.payer_company_id === companyId ? event.payee_company_name : event.payer_company_name)}</td>
|
||||
<td>${esc(event.payer_company_id === companyId ? event.payer_company_name : event.payee_company_name)}</td>
|
||||
<td>${esc(event.source_kind === "manual" ? "手工记录" : "银行往来")}</td>
|
||||
<td>${eventStateChip(event.state)}</td>
|
||||
<td class="number">${sign < 0 ? "−" : ""}${fmtMoney(event.amount)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
$$("#pairEventsTable tbody tr.event-row", root).forEach((row) => {
|
||||
const open = () => {
|
||||
drawer.open(row);
|
||||
renderEventEvidence(Number(row.dataset.eventId), companyId, row);
|
||||
};
|
||||
row.addEventListener("click", open);
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
});
|
||||
if (data.has_more) {
|
||||
tbody.innerHTML = items.map((event) => eventRowHtml(event, companyId)).join("");
|
||||
bindEventRows(root, "pairEventsTable", companyId);
|
||||
if (pages.some((page) => page.has_more)) {
|
||||
const footer = document.createElement("tr");
|
||||
footer.innerHTML = '<td colspan="8"><div class="state-panel" style="min-height:80px"><span style="font-size:12px">超过 50 笔,请在服务端按截止日分段查看</span></div></td>';
|
||||
tbody.append(footer);
|
||||
@@ -864,9 +1014,12 @@ async function loadPairEvents(root, companyId, counterpartyId, url) {
|
||||
|
||||
// --- Evidence drawer ---------------------------------------------------------
|
||||
|
||||
async function renderEventEvidence(eventId, companyId, trigger) {
|
||||
async function renderEventEvidence(eventId, companyId, options = {}) {
|
||||
const base = portal === "company" ? "/api/company/intercompany" : "/api/admin/intercompany";
|
||||
drawer.renderLayer("事件证据", [{ label: "往来余额", action: () => drawer.close() }], regionSkeleton(5));
|
||||
drawer.renderLayer("事件证据", [
|
||||
{ label: homeCrumb(), action: () => drawer.close() },
|
||||
{ label: "返回上一层", action: () => drawer.back() },
|
||||
], regionSkeleton(5), null, { triggerSelector: options.triggerSelector });
|
||||
try {
|
||||
const [detail, evidence] = await Promise.all([
|
||||
apiJson(`${base}/events/${eventId}`),
|
||||
@@ -876,27 +1029,26 @@ async function renderEventEvidence(eventId, companyId, trigger) {
|
||||
const direction = companyId ? (event.payer_company_id === companyId ? "outgoing" : "incoming") : null;
|
||||
const subjectLabel = companyId && event.subject_code ? (b44.subjectLabels[subjectOf(event, companyId)] || "") : (b44.subjectLabels[event.subject_code] || "");
|
||||
const blocks = evidence.blocks.map(renderEvidenceBlock).join('<div class="evidence-midline">双边匹配 · 已去重为同一事件</div>');
|
||||
const pairCrumb = companyId
|
||||
? { label: `${event.payer_company_name} ↔ ${event.payee_company_name}`, action: () => { if (portal !== "company") renderAdminPair(event.payer_company_id, event.payee_company_id, event.payer_company_name); } }
|
||||
: { label: `${event.payer_company_name} ↔ ${event.payee_company_name}` };
|
||||
drawer.renderLayer(
|
||||
`${fmtDate(event.effective_at)} 事件`,
|
||||
[
|
||||
{ label: "往来余额", action: () => drawer.close() },
|
||||
pairCrumb,
|
||||
{ label: homeCrumb(), action: () => drawer.close() },
|
||||
{ label: `${event.payer_company_name} ↔ ${event.payee_company_name}`, action: () => drawer.back() },
|
||||
{ label: fmtDate(event.effective_at) },
|
||||
],
|
||||
`
|
||||
<div class="pair-balance-line" style="grid-template-columns:repeat(3,1fr)">
|
||||
<div><span>方向</span><strong>${direction ? directionChip(direction) : ""}${esc(subjectLabel)}</strong></div>
|
||||
<div><span>金额</span><strong>${fmtMoney(event.amount)} <small>${esc(event.currency)}</small></strong></div>
|
||||
<div><span>金额</span><strong>${amountWithCurrency(event.amount, event.currency, { signed: true, negative: eventIsNegative(event) })}</strong></div>
|
||||
<div><span>匹配状态</span><strong>${eventStateChip(detail.state)}</strong></div>
|
||||
</div>
|
||||
<div class="evidence-stack">${blocks}</div>`,
|
||||
null,
|
||||
{ replaceTop: true, triggerSelector: options.triggerSelector },
|
||||
);
|
||||
} catch (error) {
|
||||
$("#drawerBody").innerHTML = `<div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>证据加载失败</strong><p>${esc(error.message)}</p><button type="button" class="button secondary" data-retry>重试</button></div>`;
|
||||
$("#drawerBody [data-retry]")?.addEventListener("click", () => renderEventEvidence(eventId, companyId, trigger));
|
||||
$("#drawerBody [data-retry]")?.addEventListener("click", () => renderEventEvidence(eventId, companyId, options));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -957,6 +1109,11 @@ function appendSubjectReviewRow(item) {
|
||||
row.dataset.expectedRevision = item.revision_id;
|
||||
row.dataset.company = item.payer_company_name;
|
||||
const suggestions = (item.suggestions || []).map((s) => `${s.suggested_company_name} · ${s.suggested_subject_label}`).join(";") || "无规则建议(需人工判定)";
|
||||
row.dataset.suggestions = JSON.stringify(item.suggestions || []);
|
||||
row.dataset.summary = item.summary || "";
|
||||
row.dataset.amount = String(item.amount || "");
|
||||
row.dataset.currency = item.currency || "";
|
||||
row.dataset.effectiveAt = item.effective_at || "";
|
||||
row.innerHTML = `
|
||||
<td><span class="task-level warning">中</span></td>
|
||||
<td><strong>${esc(item.payer_company_name)} ↔ ${esc(item.payee_company_name)}</strong><small>规则建议:${esc(suggestions)}</small></td>
|
||||
@@ -977,6 +1134,12 @@ function appendManualRecordReviewRow(record) {
|
||||
row.dataset.recordId = record.id;
|
||||
row.dataset.expectedDecisionId = record.decision_id;
|
||||
row.dataset.company = record.company_name;
|
||||
row.dataset.submittedBy = record.submitted_by_username || "";
|
||||
row.dataset.attachmentName = record.attachment_name || "";
|
||||
row.dataset.reasonText = record.reason || "";
|
||||
row.dataset.summary = record.summary || "";
|
||||
row.dataset.amount = String(record.amount || "");
|
||||
row.dataset.currency = record.currency || "";
|
||||
row.innerHTML = `
|
||||
<td><span class="task-level warning">中</span></td>
|
||||
<td><strong>${esc(record.company_name)} · MR-${record.id}</strong><small>${esc(record.direction === "outgoing" ? "付款" : "收款")} ${fmtMoney(record.amount)} ${esc(record.currency)} · ${esc(record.counterparty_company_name)} · ${esc(b44.subjectLabels[record.requested_subject] || "")}</small></td>
|
||||
@@ -988,6 +1151,56 @@ function appendManualRecordReviewRow(record) {
|
||||
tbody.append(row);
|
||||
}
|
||||
|
||||
function fillAuditEvidence(evidence, row, cells) {
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = cells[1].querySelector("strong")?.textContent || "";
|
||||
evidence.append(heading);
|
||||
if (row.dataset.recordKind === "subject-review") {
|
||||
const meta = document.createElement("small");
|
||||
meta.textContent = `${fmtDate(row.dataset.effectiveAt)} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim();
|
||||
evidence.append(meta);
|
||||
if (row.dataset.summary) {
|
||||
const summary = document.createElement("small");
|
||||
summary.textContent = `摘要:${row.dataset.summary}`;
|
||||
evidence.append(summary);
|
||||
}
|
||||
const suggestions = JSON.parse(row.dataset.suggestions || "[]");
|
||||
const list = document.createElement("ul");
|
||||
list.className = "candidate-list";
|
||||
if (!suggestions.length) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = "无规则建议(需退回或转异常,不能猜测科目)";
|
||||
list.append(item);
|
||||
} else {
|
||||
suggestions.forEach((suggestion) => {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = `规则建议(只读):${suggestion.suggested_company_name || ""} · ${suggestion.suggested_subject_label || suggestion.suggested_subject_code || ""}`;
|
||||
list.append(item);
|
||||
});
|
||||
}
|
||||
evidence.append(list);
|
||||
return;
|
||||
}
|
||||
if (row.dataset.recordKind === "manual-review") {
|
||||
const meta = document.createElement("small");
|
||||
meta.textContent = `提交人 ${row.dataset.submittedBy || "—"} · 附件 ${row.dataset.attachmentName || "无"}`;
|
||||
evidence.append(meta);
|
||||
const summary = document.createElement("small");
|
||||
summary.textContent = `摘要:${row.dataset.summary || "—"} · ${fmtAbsMoney(row.dataset.amount)} ${row.dataset.currency || ""}`.trim();
|
||||
evidence.append(summary);
|
||||
const reason = document.createElement("p");
|
||||
reason.className = "reason-full";
|
||||
reason.textContent = row.dataset.reasonText || cells[1].querySelector("small")?.textContent || "";
|
||||
evidence.append(reason);
|
||||
return;
|
||||
}
|
||||
const detail = document.createElement("small");
|
||||
detail.textContent = cells[1].querySelector("small")?.textContent || "";
|
||||
const source = document.createElement("small");
|
||||
source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`;
|
||||
evidence.append(detail, source);
|
||||
}
|
||||
|
||||
async function loadAdminAuditQueue() {
|
||||
try {
|
||||
const [subjects, records] = await Promise.all([
|
||||
@@ -1006,7 +1219,7 @@ async function loadAdminAuditQueue() {
|
||||
// --- Company portal: balances ------------------------------------------------
|
||||
|
||||
async function loadCompanyBalances() {
|
||||
const summaryLine = $("#companyBalanceLine");
|
||||
const summaryLine = $("#companyBalanceGroups") || $("#companyBalanceLine");
|
||||
const counterpartyList = $("#companyCounterparties");
|
||||
const alertBox = $("#companyUnresolvedAlert");
|
||||
if (!summaryLine && !counterpartyList) return;
|
||||
@@ -1014,30 +1227,34 @@ async function loadCompanyBalances() {
|
||||
if (counterpartyList) counterpartyList.innerHTML = regionSkeleton(2);
|
||||
try {
|
||||
const data = await apiJson(`/api/company/intercompany/balances?${balanceQuery()}`);
|
||||
const totals = { debit: 0, credit: 0, signed: 0, unresolvedGross: 0, unresolvedCount: 0 };
|
||||
(data.items || []).forEach((item) => {
|
||||
totals.debit += Number(item.period.debit || 0);
|
||||
totals.credit += Number(item.period.credit || 0);
|
||||
totals.signed += Number(item.result.signed_amount || 0);
|
||||
totals.unresolvedGross += Number(item.unresolved.gross_amount || 0);
|
||||
totals.unresolvedCount += item.unresolved.count || 0;
|
||||
});
|
||||
$("#companyBalancePeriod").textContent = `统计口径 ${fmtDate(data.window.from)}—${fmtDate(data.window.cutoff)} · 期初不可用,仅显示期间净变动`;
|
||||
if (summaryLine) {
|
||||
const direction = totals.signed > 0 ? "应收" : totals.signed < 0 ? "应付" : "持平";
|
||||
const directionClass = totals.signed > 0 ? "success" : totals.signed < 0 ? "danger" : "neutral";
|
||||
summaryLine.innerHTML = `
|
||||
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong><small class="pair-open-note">B-45 前无可靠期初</small></div>
|
||||
<div><span>本期借方</span><strong>${fmtMoney(totals.debit)}</strong></div>
|
||||
<div><span>本期贷方</span><strong>${fmtMoney(totals.credit)}</strong></div>
|
||||
<div class="pair-final"><span>期末结果</span><strong style="color:var(--color-primary)"><em class="status ${directionClass}" style="margin-right:6px">${direction}</em>${fmtMoney(totals.signed)}</strong><small class="pair-open-note">期间净变动</small></div>
|
||||
<div class="pair-unresolved ${totals.unresolvedCount ? "" : "is-empty"}"><span>未决金额</span><strong>${fmtMoney(totals.unresolvedGross)}</strong><small class="pair-open-note">${totals.unresolvedCount} 笔</small></div>
|
||||
<div><span>截止日</span><strong class="amount-neutral">${fmtDate(data.window.cutoff)}</strong><small class="pair-cutoff">按币种独立展示</small></div>`;
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
summaryLine.innerHTML = sixLineHtml({
|
||||
currency: "", debit: 0, credit: 0, signed: 0, resultLabel: "期间净变动",
|
||||
unresolved: { gross_amount: "0", count: 0 }, cutoff: data.window.cutoff,
|
||||
});
|
||||
} else {
|
||||
summaryLine.innerHTML = items.map((item) => sixLineHtml({
|
||||
currency: item.currency,
|
||||
debit: item.period.debit,
|
||||
credit: item.period.credit,
|
||||
signed: item.result.signed_amount,
|
||||
resultLabel: item.result.label,
|
||||
unresolved: item.unresolved,
|
||||
cutoff: item.window?.cutoff || data.window.cutoff,
|
||||
})).join("");
|
||||
}
|
||||
}
|
||||
const unresolvedCount = (data.items || []).reduce((sum, item) => sum + (item.unresolved?.count || 0), 0);
|
||||
if (alertBox) {
|
||||
if (totals.unresolvedCount) {
|
||||
if (unresolvedCount) {
|
||||
alertBox.hidden = false;
|
||||
$("#companyUnresolvedText").textContent = `有 ${totals.unresolvedCount} 笔往来未确认,合计 ${fmtMoney(totals.unresolvedGross)},不影响已确认余额`;
|
||||
const parts = (data.items || [])
|
||||
.filter((item) => item.unresolved?.count)
|
||||
.map((item) => `${item.currency} ${fmtAbsMoney(item.unresolved.gross_amount)}`);
|
||||
$("#companyUnresolvedText").textContent = `有 ${unresolvedCount} 笔往来未确认,合计 ${parts.join("、")},不影响已确认余额`;
|
||||
} else {
|
||||
alertBox.hidden = true;
|
||||
}
|
||||
@@ -1057,21 +1274,17 @@ function renderCompanyCounterparties(container, counterparties, cutoff) {
|
||||
}
|
||||
container.replaceChildren(...counterparties.map((row) => {
|
||||
const article = document.createElement("article");
|
||||
article.className = "company-row";
|
||||
article.className = "company-row is-balance-counterparty";
|
||||
article.tabIndex = 0;
|
||||
article.dataset.counterpartyId = row.counterparty_company_id;
|
||||
article.setAttribute("role", "button");
|
||||
article.setAttribute("aria-label", `查看与 ${row.counterparty_company_name} 的往来明细`);
|
||||
const direction = row.result.direction === "receivable"
|
||||
? { className: "success", label: "应收" }
|
||||
: row.result.direction === "payable"
|
||||
? { className: "danger", label: "应付" }
|
||||
: { className: "neutral", label: "持平" };
|
||||
const direction = resultDirection(row.result.direction);
|
||||
const unresolved = unresolvedText(row.unresolved);
|
||||
article.innerHTML = `
|
||||
<span class="company-row-mark">${esc(row.counterparty_company_name.slice(0, 1))}</span>
|
||||
<div class="company-row-body"><strong>${esc(row.counterparty_company_name)}</strong><small class="currency-tag">${esc(row.currency)} · ${unresolved.active ? `未决 ${fmtMoney(row.unresolved.gross_amount)} · ${row.unresolved.count} 笔` : "未决 0.00"}</small></div>
|
||||
<div class="company-row-figure"><strong class="result-direction"><em class="status ${direction.className}">${direction.label}</em> ${fmtMoney(row.result.signed_amount)}</strong><small>截止 ${fmtDate(cutoff)}</small></div>`;
|
||||
<div class="company-row-body"><strong title="${esc(row.counterparty_company_name)}">${esc(row.counterparty_company_name)}</strong><small class="currency-tag">${esc(row.currency)} · ${unresolved.active ? `未决 ${fmtAbsMoney(row.unresolved.gross_amount)} · ${row.unresolved.count} 笔` : "未决 0.00"}</small></div>
|
||||
<div class="company-row-figure"><strong class="result-direction"><em class="status ${direction.className}">${direction.label}</em> ${amountWithCurrency(row.result.signed_amount, row.currency)}</strong><small>截止 ${fmtDate(cutoff)}</small></div>`;
|
||||
const open = () => {
|
||||
drawer.open(article);
|
||||
renderCompanyPair(row.counterparty_company_id, row.counterparty_company_name);
|
||||
@@ -1088,33 +1301,33 @@ function renderCompanyCounterparties(container, counterparties, cutoff) {
|
||||
}
|
||||
|
||||
async function renderCompanyPair(counterpartyId, counterpartyName) {
|
||||
drawer.renderLayer(`${counterpartyName} · 往来明细`, [{ label: "往来余额", action: () => drawer.close() }], regionSkeleton(4));
|
||||
drawer.open();
|
||||
drawer.renderLayer(`${counterpartyName} · 往来明细`, [{ label: homeCrumb(), action: () => drawer.close() }], regionSkeleton(4));
|
||||
try {
|
||||
const data = await apiJson(`/api/company/intercompany/pairs/${counterpartyId}?${balanceQuery()}`);
|
||||
const item = data.items[0];
|
||||
const own = data.companies.a;
|
||||
const aResult = item.a.result;
|
||||
const direction = aResult.direction === "receivable" ? "应收" : aResult.direction === "payable" ? "应付" : "持平";
|
||||
const directionClass = aResult.direction === "receivable" ? "success" : aResult.direction === "payable" ? "danger" : "neutral";
|
||||
const unresolved = unresolvedText(item.unresolved);
|
||||
const lines = (data.items || []).map((item) => sixLineHtml({
|
||||
currency: item.currency,
|
||||
debit: item.a.period.debit,
|
||||
credit: item.a.period.credit,
|
||||
signed: item.a.result.signed_amount,
|
||||
resultLabel: item.a.result.label,
|
||||
unresolved: item.unresolved,
|
||||
cutoff: data.window.cutoff,
|
||||
conservationNote: "双方守恒已校验",
|
||||
})).join("");
|
||||
const body = `
|
||||
<div class="pair-balance-line is-six">
|
||||
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong><small class="pair-open-note">B-45 前无可靠期初</small></div>
|
||||
<div><span>本期借方</span><strong>${fmtMoney(item.a.period.debit)}</strong></div>
|
||||
<div><span>本期贷方</span><strong>${fmtMoney(item.a.period.credit)}</strong></div>
|
||||
<div class="pair-final"><span>期末结果</span><strong style="color:var(--color-primary)"><em class="status ${directionClass}" style="margin-right:6px">${direction}</em>${fmtMoney(aResult.signed_amount)}</strong><small class="pair-open-note">${esc(item.a.result.label)}</small></div>
|
||||
<div class="pair-unresolved ${unresolved.active ? "" : "is-empty"}"><span>未决金额</span><strong>${fmtMoney(item.unresolved.gross_amount)}</strong><small class="pair-open-note">${item.unresolved.count} 笔</small></div>
|
||||
<div><span>截止日</span><strong class="amount-neutral">${fmtDate(data.window.cutoff)}</strong><small class="pair-cutoff">双方守恒已校验</small></div>
|
||||
</div>
|
||||
${lines}
|
||||
<div class="table-scroll"><table class="data-table event-table" id="companyEventsTable">
|
||||
<thead><tr><th>交易日期</th><th>方向</th><th>科目</th><th>对方公司</th><th>来源</th><th>匹配状态</th><th class="number">金额</th></tr></thead>
|
||||
<tbody><tr><td colspan="7"><div class="skeleton-row skeleton-block"><span></span><span></span><span></span><span></span><span></span></div></td></tr></tbody>
|
||||
${eventTableHead()}
|
||||
<tbody><tr><td colspan="8"><div class="skeleton-row skeleton-block"><span></span><span></span><span></span><span></span><span></span></div></td></tr></tbody>
|
||||
</table></div>`;
|
||||
drawer.renderLayer(
|
||||
`${own.name} ↔ ${counterpartyName}`,
|
||||
[{ label: "往来余额", action: () => drawer.close() }, { label: `${own.name} ↔ ${counterpartyName}` }],
|
||||
[{ label: homeCrumb(), action: () => drawer.close() }, { label: `${own.name} ↔ ${counterpartyName}` }],
|
||||
body,
|
||||
(root) => loadCompanyEvents(root, counterpartyId),
|
||||
{ replaceTop: true },
|
||||
);
|
||||
} catch (error) {
|
||||
$("#drawerBody").innerHTML = `<div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>余额计算暂时不可用</strong><p>${esc(error.message)}</p><button type="button" class="button secondary" data-retry>重试</button></div>`;
|
||||
@@ -1130,35 +1343,13 @@ async function loadCompanyEvents(root, counterpartyId) {
|
||||
const ownCompanyId = state.me?.company_id;
|
||||
const filtered = (data.items || []).filter((event) => event.counterparty_company_id === counterpartyId);
|
||||
if (!filtered.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7"><div class="state-panel"><svg class="state-icon"><use href="icons.svg#inbox"/></svg><strong>该区间无往来事件</strong><p>当前统计区间内没有与对方公司的逐笔事件</p></div></td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="8"><div class="state-panel"><svg class="state-icon"><use href="icons.svg#inbox"/></svg><strong>该区间无往来事件</strong><p>当前统计区间内没有与对方公司的逐笔事件</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = filtered.map((event) => `
|
||||
<tr class="event-row" tabindex="0" role="button" data-event-id="${event.ledger_event_id}"
|
||||
aria-label="查看 ${fmtDate(event.effective_at)} 与 ${esc(event.counterparty_company_name)} 的源行证据">
|
||||
<td>${fmtDate(event.effective_at)}</td>
|
||||
<td>${directionChip(event.direction)}</td>
|
||||
<td>${event.state === "confirmed" ? esc(event.own_subject_label || "") : '<span class="status info">待确认科目</span>'}</td>
|
||||
<td>${esc(event.counterparty_company_name)}</td>
|
||||
<td>${esc(event.source_kind === "manual" ? "手工记录" : "银行往来")}</td>
|
||||
<td>${eventStateChip(event.state)}</td>
|
||||
<td class="number">${event.direction === "outgoing" ? "−" : ""}${fmtMoney(event.amount)}</td>
|
||||
</tr>`).join("");
|
||||
$$("#companyEventsTable tbody tr.event-row", root).forEach((row) => {
|
||||
const open = () => {
|
||||
drawer.open(row);
|
||||
renderEventEvidence(Number(row.dataset.eventId), ownCompanyId, row);
|
||||
};
|
||||
row.addEventListener("click", open);
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
});
|
||||
tbody.innerHTML = filtered.map((event) => eventRowHtml(event, ownCompanyId)).join("");
|
||||
bindEventRows(root, "companyEventsTable", ownCompanyId);
|
||||
} catch (error) {
|
||||
tbody.innerHTML = `<tr><td colspan="7"><div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>逐笔事件加载失败</strong><p>${esc(error.message)}</p></div></td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="8"><div class="state-panel is-error"><svg class="state-icon"><use href="icons.svg#circle-alert"/></svg><strong>逐笔事件加载失败</strong><p>${esc(error.message)}</p></div></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1178,7 +1369,7 @@ function initBalanceQueries() {
|
||||
b44.from = from;
|
||||
b44.cutoff = cutoff;
|
||||
b44.currency = currency;
|
||||
loadAdminBalances($("#companyLedgers"));
|
||||
loadAdminBalances($("#balanceLedgers"));
|
||||
showToast("查询结果已更新", `${from} 至 ${cutoff} · 截止日口径`);
|
||||
});
|
||||
});
|
||||
@@ -1255,6 +1446,10 @@ async function submitCompanyManualRecord(form) {
|
||||
reason: String(data.get("remark") || "").trim(),
|
||||
request_key: `mr-${Date.now().toString(36)}`,
|
||||
};
|
||||
const evidenceFile = data.get("evidence");
|
||||
if (evidenceFile instanceof File && evidenceFile.name) {
|
||||
payload.evidence = { attachment_name: evidenceFile.name };
|
||||
}
|
||||
if (!payload.counterparty_company_id) {
|
||||
showToast("请选择对方公司", "手工记录对方必须是集团内部公司");
|
||||
return;
|
||||
@@ -1586,7 +1781,7 @@ function initAdmin() {
|
||||
loadAdminCompanies();
|
||||
initBalanceQueries();
|
||||
loadCompanyOptions().then(() => {
|
||||
loadAdminBalances($("#companyLedgers"));
|
||||
loadAdminBalances($("#balanceLedgers"));
|
||||
});
|
||||
loadAdminAuditQueue();
|
||||
const companySearch = $('[data-filter-target="companyLedgers"]');
|
||||
@@ -1615,8 +1810,15 @@ function initAdmin() {
|
||||
}));
|
||||
$("#auditCompany")?.addEventListener("change", filterAuditRows);
|
||||
|
||||
const TRIAGE_DECISIONS = ["确认并纳入计算", "退回公司补充材料", "转为异常"];
|
||||
const auditDialog = $("#auditDialog");
|
||||
const syncExceptionNote = () => {
|
||||
const note = $("#auditExceptionNote");
|
||||
const decision = $('#auditForm [name="decision"]');
|
||||
if (note) note.hidden = !String(decision?.value || "").includes("转为异常");
|
||||
};
|
||||
$$('[data-close-audit]').forEach((button) => button.addEventListener("click", () => auditDialog.close()));
|
||||
$("#auditForm [name='decision']")?.addEventListener("change", syncExceptionNote);
|
||||
// Delegated: account review rows arrive asynchronously from the API.
|
||||
$("#auditRows")?.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-audit-action]");
|
||||
@@ -1632,11 +1834,9 @@ function initAdmin() {
|
||||
const isActiveAccount = row.dataset.recordKind === "account" && row.dataset.accountStatus === "active";
|
||||
const decisions = row.dataset.recordKind === "account"
|
||||
? (isActiveAccount ? ["停用并驳回"] : ["复核通过并启用账户", "退回公司修改", "停用并驳回"])
|
||||
: row.dataset.recordKind === "subject-review"
|
||||
? ["应收", "应付", "其他应收", "其他应付"]
|
||||
: row.dataset.recordKind === "manual-review"
|
||||
? ["确认并纳入计算", "退回公司补充材料", "转为异常待后续处理"]
|
||||
: ["确认并纳入计算", "退回公司补充材料", "转为异常待后续处理"];
|
||||
: (row.dataset.recordKind === "subject-review" || row.dataset.recordKind === "manual-review")
|
||||
? TRIAGE_DECISIONS
|
||||
: TRIAGE_DECISIONS;
|
||||
decision.replaceChildren(new Option("请选择", ""), ...decisions.map((item) => new Option(item, item)));
|
||||
decision.disabled = false;
|
||||
reason.disabled = false;
|
||||
@@ -1645,10 +1845,8 @@ function initAdmin() {
|
||||
$("#auditDialogMeta").textContent = `${cells[3].innerText.trim()} · 影响 ${cells[4].innerText.trim()}`;
|
||||
const evidence = $("#auditEvidence");
|
||||
evidence.replaceChildren();
|
||||
const heading = document.createElement("strong"); heading.textContent = cells[1].querySelector("strong").textContent;
|
||||
const detail = document.createElement("small"); detail.textContent = cells[1].querySelector("small").textContent;
|
||||
const source = document.createElement("small"); source.textContent = `证据:${row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明"}`;
|
||||
evidence.append(heading, detail, source);
|
||||
fillAuditEvidence(evidence, row, cells);
|
||||
syncExceptionNote();
|
||||
if (button.dataset.record && !isActiveAccount) {
|
||||
decision.value = button.dataset.decision;
|
||||
reason.value = button.dataset.reason;
|
||||
@@ -1659,6 +1857,7 @@ function initAdmin() {
|
||||
const record = document.createElement("small");
|
||||
record.textContent = `处理记录:${button.dataset.record}`;
|
||||
evidence.append(record);
|
||||
syncExceptionNote();
|
||||
}
|
||||
auditDialog.showModal();
|
||||
});
|
||||
@@ -1669,33 +1868,53 @@ function initAdmin() {
|
||||
const decision = String(data.get("decision"));
|
||||
const reason = String(data.get("reason"));
|
||||
|
||||
// B-44 subject review: confirm the statutory subject on a pending event.
|
||||
// B-44 subject review: confirm uses the rule suggestion; return/exception park the event.
|
||||
if (row.dataset.recordKind === "subject-review") {
|
||||
const subjectCode = b44.subjectCodes[decision] || Object.keys(b44.subjectLabels).find((key) => b44.subjectLabels[key] === decision);
|
||||
if (!subjectCode) {
|
||||
showToast("请选择确认科目", "科目必须是应收、应付、其他应收或其他应付之一");
|
||||
const actionMap = {
|
||||
"确认并纳入计算": "confirm",
|
||||
"退回公司补充材料": "return",
|
||||
"转为异常": "exception",
|
||||
};
|
||||
const action = actionMap[decision];
|
||||
if (!action) {
|
||||
showToast("请选择处理决定", "科目审核仅支持确认、退回或转异常");
|
||||
return;
|
||||
}
|
||||
const suggestions = JSON.parse(row.dataset.suggestions || "[]");
|
||||
const suggested = suggestions[0];
|
||||
if (action === "confirm" && !suggested?.suggested_subject_code) {
|
||||
showToast("没有可入账的规则建议", "请退回公司补充材料或转为异常,不要猜测科目");
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
perspective_company_id: Number(suggested?.suggested_perspective_company_id || row.dataset.perspectiveId),
|
||||
reason,
|
||||
expected_revision: Number(row.dataset.expectedRevision),
|
||||
request_key: `subj-${row.dataset.ledgerEventId}-${Date.now().toString(36)}`,
|
||||
};
|
||||
if (action === "confirm") {
|
||||
body.subject_code = suggested.suggested_subject_code;
|
||||
} else {
|
||||
body.action = action;
|
||||
}
|
||||
const response = await apiJson(`/api/admin/intercompany/events/${row.dataset.ledgerEventId}/subject-decisions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
perspective_company_id: Number(row.dataset.perspectiveId),
|
||||
subject_code: subjectCode,
|
||||
reason,
|
||||
expected_revision: Number(row.dataset.expectedRevision),
|
||||
request_key: `subj-${row.dataset.ledgerEventId}-${Date.now().toString(36)}`,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => null);
|
||||
if (!response) {
|
||||
showToast("科目确认失败", "请刷新后重试");
|
||||
showToast("科目处理失败", "请刷新后重试");
|
||||
return;
|
||||
}
|
||||
row.remove();
|
||||
updateAuditCounts();
|
||||
auditDialog.close();
|
||||
event.currentTarget.reset();
|
||||
showToast("科目已确认并纳入计算", `确认科目:${b44.subjectLabels[subjectCode]} · 已写入修订链`);
|
||||
syncExceptionNote();
|
||||
showToast(
|
||||
action === "confirm" ? "科目已确认并纳入计算" : action === "return" ? "已退回公司补充材料" : "已转为异常",
|
||||
action === "confirm" ? `确认科目:${b44.subjectLabels[suggested.suggested_subject_code]} · 已写入修订链` : "当前记录暂不纳入余额计算",
|
||||
);
|
||||
await loadAdminAuditQueue();
|
||||
return;
|
||||
}
|
||||
@@ -1705,6 +1924,7 @@ function initAdmin() {
|
||||
const actionMap = {
|
||||
"确认并纳入计算": "approve_new",
|
||||
"退回公司补充材料": "return",
|
||||
"转为异常": "exception",
|
||||
"转为异常待后续处理": "exception",
|
||||
};
|
||||
const action = actionMap[decision];
|
||||
@@ -2409,6 +2629,7 @@ function initCompany() {
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined" && document.body) {
|
||||
if (portal === "entry") {
|
||||
initEntry();
|
||||
} else {
|
||||
@@ -2423,3 +2644,17 @@ if (portal === "entry") {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = {
|
||||
fmtAbsMoney,
|
||||
fmtMoney,
|
||||
eventIsNegative,
|
||||
cycleTab,
|
||||
drawerEscAction,
|
||||
amountWithCurrency,
|
||||
resultDirection,
|
||||
accountCell,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@
|
||||
</div>
|
||||
<section class="pair-report" id="companyBalanceSummary">
|
||||
<header class="pair-report-heading"><div><h2>本公司往来汇总</h2><p id="companyBalancePeriod">统计口径</p></div></header>
|
||||
<div class="pair-balance-line is-six" id="companyBalanceLine"></div>
|
||||
<div id="companyBalanceGroups" class="balance-currency-groups"></div>
|
||||
</section>
|
||||
<section class="panel company-stack-panel">
|
||||
<div class="panel-heading"><div><h2>对方公司明细</h2><p>点击任意对方公司查看往来事件与允许的源证据</p></div></div>
|
||||
|
||||
+82
-19
@@ -487,8 +487,8 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
|
||||
.pair-query-form .field:nth-of-type(3) { grid-column: 1 / 3; }
|
||||
.pair-query-form .button { grid-column: 3; }
|
||||
.ledger-head { display: none; }
|
||||
.company-ledger summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; }
|
||||
.company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; }
|
||||
.company-ledger:not(.is-balances) summary { grid-template-columns: minmax(180px, 1.4fr) repeat(2, minmax(100px, 0.7fr)) 28px; }
|
||||
.company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; }
|
||||
.ledger-breakdown { grid-template-columns: 1fr; }
|
||||
.ledger-breakdown section + section { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.pair-balance-line { grid-template-columns: repeat(3, 1fr); }
|
||||
@@ -534,8 +534,8 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
|
||||
.task-list button > b { display: none; }
|
||||
.quick-pair-form { grid-template-columns: 1fr 38px 1fr; padding-inline: 12px; }
|
||||
.quick-result { margin-inline: 12px; }
|
||||
.company-ledger summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; }
|
||||
.company-ledger summary > strong:nth-of-type(2), .company-ledger summary > span:nth-child(4), .company-ledger summary > span:nth-child(5) { display: none; }
|
||||
.company-ledger:not(.is-balances) summary { grid-template-columns: minmax(145px, 1fr) 100px 24px; padding-inline: 12px; }
|
||||
.company-ledger:not(.is-balances) summary > strong:nth-of-type(2), .company-ledger:not(.is-balances) summary > span:nth-child(4), .company-ledger:not(.is-balances) summary > span:nth-child(5) { display: none; }
|
||||
.company-name { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.company-name i { width: 28px; height: 28px; }
|
||||
.filter-bar { align-items: stretch; flex-direction: column; }
|
||||
@@ -594,8 +594,8 @@ main { width: min(1560px, 100%); margin: 0 auto; padding: 12px 32px 58px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.company-row { grid-template-columns: 34px minmax(0, 1fr) auto; }
|
||||
.company-row-figure { display: none; }
|
||||
.company-row:not(.is-balance-counterparty) { grid-template-columns: 34px minmax(0, 1fr) auto; }
|
||||
.company-row:not(.is-balance-counterparty) .company-row-figure { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -739,9 +739,20 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ---- B-44 intercompany balances (design tokens only, no overrides) ---- */
|
||||
/* ---- B-44 intercompany balances ---- */
|
||||
.status.info { border-color: rgba(102, 168, 255, 0.18); background: var(--color-info-wash); color: var(--color-info); }
|
||||
.amount-neutral { color: var(--color-ink-muted); }
|
||||
.currency-code { font-size: 10px; color: var(--color-ink-muted); margin-right: 4px; }
|
||||
.amount-with-currency { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.account-cell { white-space: nowrap; }
|
||||
.summary-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.candidate-list { margin: 8px 0 0; padding: 0; list-style: none; color: var(--color-ink-soft); font-size: 12px; }
|
||||
.candidate-list li { padding: 4px 0; border-bottom: 1px dashed var(--color-line); }
|
||||
.candidate-list li:last-child { border-bottom: 0; }
|
||||
.reason-full { max-height: calc(1.6em * 6); overflow: auto; white-space: pre-wrap; }
|
||||
.decision-danger { margin-top: 8px; color: var(--color-danger); font-size: 12px; }
|
||||
.balance-currency-groups { display: grid; gap: 12px; }
|
||||
.balance-currency-groups .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; }
|
||||
|
||||
/* Balance directory: 公司 | 借方 | 贷方 | 期末结果 | 未决 | 截止日 | ▸ */
|
||||
.ledger-head.is-balances, .company-ledger.is-balances summary {
|
||||
@@ -757,11 +768,33 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event
|
||||
.company-ledger.is-balances .currency-tag { color: var(--color-ink-muted); font-size: 10px; }
|
||||
.company-ledger.is-balances .subject-row small { white-space: nowrap; }
|
||||
@media (max-width: 900px) {
|
||||
.ledger-head.is-balances, .company-ledger.is-balances summary { grid-template-columns: minmax(150px, 1fr) minmax(150px, 0.9fr) minmax(110px, 0.7fr) 24px; }
|
||||
.company-ledger.is-balances summary .ledger-hide-sm { display: none; }
|
||||
.ledger-head.is-balances { display: grid; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.ledger-head.is-balances, .company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(140px, 1.2fr) minmax(140px, 0.9fr) minmax(110px, 0.7fr) minmax(96px, 0.55fr) 24px;
|
||||
}
|
||||
.ledger-head.is-balances .ledger-hide-md,
|
||||
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
|
||||
}
|
||||
@media (max-width: 375px) {
|
||||
.ledger-head.is-balances { display: none; }
|
||||
.company-ledger.is-balances summary {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, auto) 24px;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
.company-ledger.is-balances summary .company-name { grid-column: 1; grid-row: 1; }
|
||||
.company-ledger.is-balances summary .ledger-result { grid-column: 2; grid-row: 1; justify-self: end; }
|
||||
.company-ledger.is-balances summary .ledger-unresolved { grid-column: 1; grid-row: 2; }
|
||||
.company-ledger.is-balances summary .ledger-cutoff { grid-column: 2; grid-row: 2; justify-self: end; }
|
||||
.company-ledger.is-balances summary > svg { grid-column: 3; grid-row: 1 / span 2; align-self: center; }
|
||||
.company-ledger.is-balances summary .ledger-hide-md { display: none; }
|
||||
}
|
||||
|
||||
/* Pair report six-cell balance line: 期初 | 本期借 | 本期贷 | 期末 | 未决 | 截止日 */
|
||||
/* Pair report six-cell: page may be six-across; drawer is always two rows of three */
|
||||
.pair-balance-line.is-six { grid-template-columns: repeat(6, 1fr); }
|
||||
.pair-balance-line.is-six .pair-final { grid-column: auto; border-left: 1px solid var(--color-line); }
|
||||
.pair-balance-line .pair-open-note { color: var(--color-ink-muted); font-size: 10px; margin-top: 3px; }
|
||||
@@ -773,26 +806,35 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event
|
||||
.pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.pair-balance-line.is-six div:nth-child(5), .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.pair-balance-line.is-six { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.amount-with-currency { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.amount-with-currency .currency-code { margin-right: 4px; color: var(--color-ink-muted); font-size: 10px; font-weight: 500; }
|
||||
|
||||
/* Evidence drawer (single instance, single-layer replacement) */
|
||||
/* Evidence drawer: <768 full, 768–1179 480px, >=1180 640px */
|
||||
.drawer-scrim { position: fixed; inset: 0; z-index: calc(var(--z-drawer) - 1); background: rgba(5, 5, 5, 0.6); opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-out); }
|
||||
.drawer-scrim.is-open { opacity: 1; pointer-events: auto; }
|
||||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-drawer); width: 640px; max-width: 100%; display: flex; flex-direction: column; background: rgba(17, 17, 17, 0.96); border-left: 1px solid var(--color-line-strong); box-shadow: var(--shadow-panel); transform: translateX(24px); opacity: 0; visibility: hidden; transition: transform var(--duration-standard) var(--ease-out), opacity var(--duration-standard) var(--ease-out), visibility 0s linear var(--duration-standard); }
|
||||
.drawer.is-open { transform: translateX(0); opacity: 1; visibility: visible; transition-delay: 0s; }
|
||||
@media (min-width: 1024px) and (max-width: 1180px) { .drawer { width: 480px; } }
|
||||
@media (max-width: 768px) { .drawer { width: 100%; } }
|
||||
@media (max-width: 1179px) { .drawer { width: 480px; } }
|
||||
@media (max-width: 767px) { .drawer { width: 100%; } }
|
||||
.drawer-header { min-height: 74px; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--color-line); }
|
||||
.drawer-header .drawer-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; color: var(--color-ink-muted); font-size: 11px; }
|
||||
.drawer-header .drawer-breadcrumb button { border: 0; padding: 0; background: none; color: var(--color-info); cursor: pointer; font-size: 11px; }
|
||||
.drawer-header .drawer-breadcrumb button:hover { text-decoration: underline; }
|
||||
.drawer-header h2 { margin-top: 6px; font-size: 17px; }
|
||||
.drawer-body { flex: 1; overflow: auto; padding: 16px 18px 24px; contain: layout paint; }
|
||||
.drawer .pair-balance-line { border: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; }
|
||||
.drawer .pair-balance-line div { min-height: 64px; }
|
||||
.drawer .event-table { min-width: 640px; }
|
||||
.drawer .pair-balance-line { border: 1px solid var(--color-line); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 16px; }
|
||||
.drawer .pair-balance-line div { min-height: 64px; min-width: 0; overflow: hidden; }
|
||||
.drawer .pair-balance-line strong { overflow-wrap: anywhere; }
|
||||
.drawer .pair-balance-line .pair-cutoff,
|
||||
.drawer .pair-balance-line strong.amount-neutral { white-space: nowrap; }
|
||||
.drawer .pair-balance-line.is-six {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.drawer .pair-balance-line.is-six .pair-final { grid-column: auto; }
|
||||
.drawer .pair-balance-line.is-six div:nth-child(4) { border-top: 1px solid var(--color-line); border-left: 0; }
|
||||
.drawer .pair-balance-line.is-six div:nth-child(5),
|
||||
.drawer .pair-balance-line.is-six div:nth-child(6) { border-top: 1px solid var(--color-line); }
|
||||
.drawer .event-table { min-width: 860px; }
|
||||
.drawer .event-table td { height: var(--row-h-evidence); }
|
||||
.drawer .event-row { cursor: pointer; }
|
||||
.drawer .event-row:focus-visible { outline: 2px solid var(--color-primary-strong); outline-offset: -2px; }
|
||||
@@ -807,6 +849,27 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event
|
||||
.evidence-block .evidence-masked { color: var(--color-ink-muted); font-style: normal; }
|
||||
@media (max-width: 720px) { .evidence-block .evidence-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.company-row.is-balance-counterparty { grid-template-columns: 34px minmax(0, 1fr) minmax(0, auto); }
|
||||
.company-row.is-balance-counterparty .company-row-figure {
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 0;
|
||||
}
|
||||
.company-row.is-balance-counterparty .company-row-figure strong { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; }
|
||||
@media (max-width: 900px) {
|
||||
.company-row.is-balance-counterparty {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
}
|
||||
.company-row.is-balance-counterparty .company-row-figure {
|
||||
grid-column: 2;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading skeletons */
|
||||
.skeleton-row { height: var(--row-h-evidence); display: flex; align-items: center; gap: 12px; padding: 0 14px; border-bottom: 1px solid var(--color-line); }
|
||||
.skeleton-row span { height: 12px; border-radius: 6px; background: rgba(255, 255, 255, 0.06); }
|
||||
|
||||
Reference in New Issue
Block a user