HEL-144 返工A:四件事(改名/登录端标识/结账设置真保存/提醒回改)+ 迁移升为 7 + 移除废弃 B-44 前端样式测试

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
leefer
2026-08-25 18:41:01 +08:00
co-authored by multica-agent
parent 5816e8aa71
commit ece3e53472
17 changed files with 1142 additions and 1050 deletions
+216 -3
View File
@@ -12,7 +12,7 @@ from urllib.parse import parse_qs, urlparse
from bank_importer import (
auth, importing, ledger_events, manual_records, master_data, matching,
multipart, personal_transit, positions, subjects,
multipart, personal_transit, positions, settings, subjects,
)
from bank_importer.db import connect, migrate, utc_now
@@ -80,6 +80,15 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/admin/audit-log":
self._handle_admin_audit_log(query)
return
if path == "/api/admin/settings":
self._handle_admin_settings()
return
if path == "/api/admin/reminders":
self._handle_admin_reminders(query)
return
if path == "/api/admin/reminders/pending":
self._handle_admin_reminder_pending(query)
return
if path == "/api/admin/transfer-events":
self._handle_admin_transfer_events(query)
return
@@ -232,6 +241,12 @@ class AppHandler(SimpleHTTPRequestHandler):
if mapping_review:
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
return
if path == "/api/admin/settings":
self._handle_admin_update_settings()
return
if path == "/api/admin/reminders/send":
self._handle_admin_send_reminders()
return
# B-44 intercompany positions (admin writes)
subject_decision = re.fullmatch(
@@ -297,7 +312,7 @@ class AppHandler(SimpleHTTPRequestHandler):
if user is None:
return None
if user["role"] != "admin":
self._send_json(403, {"status": "error", "message": "该操作仅限总账管理员。"})
self._send_json(403, {"status": "error", "message": "该操作仅限管理员。"})
return None
return user
@@ -354,7 +369,7 @@ class AppHandler(SimpleHTTPRequestHandler):
return
if reason == "disabled":
self._send_json(
403, {"status": "error", "message": "账号已停用,请联系总账管理员。"}
403, {"status": "error", "message": "账号已停用,请联系管理员。"}
)
return
if user is None:
@@ -1404,6 +1419,204 @@ class AppHandler(SimpleHTTPRequestHandler):
finally:
connection.close()
# ------------------------------------------------------------------
# System settings (admin read/write, persisted + audited)
# ------------------------------------------------------------------
def _handle_admin_settings(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
values = settings.get_settings(connection)
self._send_json(200, {"status": "ok", "settings": values})
finally:
connection.close()
def _handle_admin_update_settings(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
before = settings.get_settings(connection)
try:
updated = settings.update_settings(connection, data, user)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
changed = {
key: updated[key]
for key in updated
if before.get(key) != updated[key]
}
auth.audit(
connection,
"settings_update",
actor=user,
target="system_settings",
detail=";".join(
f"{key}:{before.get(key)}->{updated[key]}" for key in changed
),
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "settings": updated})
finally:
connection.close()
# ------------------------------------------------------------------
# Reminder management (admin)
# ------------------------------------------------------------------
@staticmethod
def _reminder_payload(row) -> dict[str, object]:
payload = {
"id": row["id"],
"company_id": row["company_id"],
"company_name": row["company_name"],
"kind": row["kind"],
"content": row["content"],
"deadline": row["deadline"],
"source": row["source"],
"status": row["status"],
"actor_username": row["actor_username"],
"created_at": row["created_at"],
}
return payload
def _handle_admin_reminders(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
conditions: list[str] = []
params: list[object] = []
raw_company = (query.get("company_id") or [None])[0]
if raw_company:
try:
params.append(int(raw_company))
except ValueError:
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
return
conditions.append("r.company_id = ?")
raw_limit = (query.get("limit") or ["200"])[0]
try:
limit = max(1, min(int(raw_limit), 500))
except ValueError:
limit = 200
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
rows = connection.execute(
f"""
SELECT r.id, r.company_id, c.name AS company_name, r.kind,
r.content, r.deadline, r.source, r.status,
r.actor_username, r.created_at
FROM reminders r
JOIN companies c ON c.id = r.company_id
{where}
ORDER BY r.id DESC
LIMIT ?
""",
(*params, limit),
).fetchall()
self._send_json(
200,
{"status": "ok",
"reminders": [self._reminder_payload(row) for row in rows]},
)
finally:
connection.close()
def _handle_admin_reminder_pending(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
raw_company = (query.get("company_id") or [None])[0]
try:
company_id = int(raw_company)
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
return
company = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
self._send_json(404, {"status": "error", "message": "公司不存在。"})
return
items = settings.pending_items(connection, company_id)
self._send_json(
200,
{
"status": "ok",
"company_id": company_id,
"company_name": company["name"],
"items": items,
},
)
finally:
connection.close()
def _handle_admin_send_reminders(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
try:
company_id = int(str(data.get("company_id")))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
return
company = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
self._send_json(404, {"status": "error", "message": "公司不存在。"})
return
try:
created, deadline = settings.send_reminders(
connection, company_id, user
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
if not created:
self._send_json(
400,
{"status": "error",
"message": "该公司当前没有待提醒事项,无需发送。"},
)
return
auth.audit(
connection,
"reminder_send",
actor=user,
target=f"company:{company_id}",
detail=f"items:{len(created)}",
ip=self._client_ip,
)
self._send_json(
200,
{
"status": "ok",
"company_id": company_id,
"company_name": company["name"],
"deadline": deadline,
"reminders": created,
},
)
finally:
connection.close()
# ------------------------------------------------------------------
# Canonical transfer events (admin)
# ------------------------------------------------------------------
+48
View File
@@ -798,6 +798,54 @@ MIGRATIONS: tuple[Migration, ...] = (
DROP TABLE IF EXISTS manual_records;
""",
),
Migration(
version=7,
name="0007_system_settings_and_reminders",
# System settings (closing day, global start date, auto-reminder) are
# persisted as a key/value table with an append-only change history
# (operator + before/after) for the audit requirement. Reminders are a
# separate append-only table so reminder history survives re-sends.
up="""
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_by INTEGER REFERENCES users (id),
updated_at TEXT NOT NULL
);
CREATE TABLE system_setting_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
before_value TEXT,
after_value TEXT NOT NULL,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
kind TEXT NOT NULL,
content TEXT NOT NULL,
deadline TEXT,
source TEXT NOT NULL CHECK (source IN ('system', 'manual')),
status TEXT NOT NULL DEFAULT 'unread'
CHECK (status IN ('unread', 'done')),
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_reminders_company ON reminders (company_id);
""",
down="""
DROP INDEX IF EXISTS idx_reminders_company;
DROP TABLE IF EXISTS reminders;
DROP TABLE IF EXISTS system_setting_changes;
DROP TABLE IF EXISTS system_settings;
""",
),
)
+1 -1
View File
@@ -262,7 +262,7 @@ def submit_bank_account(
if existing["company_id"] == company_id:
raise ConflictError("该银行账号已登记,请等待现有申请处理。")
raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。")
raise ConflictError("该银行账号已被其他公司登记,请联系管理员核对。")
def review_bank_account(
+309
View File
@@ -0,0 +1,309 @@
"""System settings and reminder item generation.
System-wide parameters (closing day, global start date, auto-reminder toggle
and lead days) are persisted in ``system_settings`` with an append-only
``system_setting_changes`` trail. Reminder pending items are derived from real
backend data (per-sheet reviews, bank accounts, canonical transfer decisions)
rather than hard-coded rosters.
"""
from __future__ import annotations
import re
import sqlite3
from datetime import datetime, timezone
from .db import utc_now
# Defaults are applied when a key is absent; the value type is always string.
DEFAULT_SETTINGS: dict[str, str] = {
"closing_day": "5",
"start_date": "2026-01-01",
"auto_remind": "1",
"remind_days": "3",
}
_SETTING_KEYS = frozenset({"closing_day", "start_date", "auto_remind", "remind_days"})
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _valid_date(value: str) -> bool:
if not _DATE_RE.match(value):
return False
try:
datetime.strptime(value, "%Y-%m-%d")
except ValueError:
return False
return True
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
settings = dict(DEFAULT_SETTINGS)
rows = connection.execute(
"SELECT key, value FROM system_settings"
).fetchall()
for row in rows:
settings[row["key"]] = row["value"]
return settings
def validate_settings(values: dict[str, object]) -> tuple[dict[str, str], str | None]:
"""Return ``(cleaned, error)``; ``cleaned`` holds only recognized keys."""
cleaned: dict[str, str] = {}
if "closing_day" in values:
raw = str(values["closing_day"]).strip()
try:
day = int(raw)
except ValueError:
return cleaned, "每月结账日须为 1-28 之间的整数。"
if day < 1 or day > 28:
return cleaned, "每月结账日须为 1-28 之间的整数。"
cleaned["closing_day"] = str(day)
if "start_date" in values:
raw = str(values["start_date"]).strip()
if not _valid_date(raw):
return cleaned, "全局起算日须为有效日期(YYYY-MM-DD)。"
cleaned["start_date"] = raw
if "auto_remind" in values:
raw = str(values["auto_remind"]).strip()
if raw not in {"0", "1"}:
return cleaned, "自动提醒开关须为 0 或 1。"
cleaned["auto_remind"] = raw
if "remind_days" in values:
raw = str(values["remind_days"]).strip()
try:
days = int(raw)
except ValueError:
return cleaned, "提前提醒天数须为不小于 1 的整数。"
if days < 1 or days > 30:
return cleaned, "提前提醒天数须为 1-30 之间的整数。"
cleaned["remind_days"] = str(days)
return cleaned, None
def update_settings(
connection: sqlite3.Connection,
values: dict[str, object],
actor: sqlite3.Row,
) -> dict[str, str]:
cleaned, error = validate_settings(values)
if error is not None:
raise ValueError(error)
if not cleaned:
raise ValueError("没有需要保存的设置项。")
current = get_settings(connection)
with connection:
for key, new_value in cleaned.items():
old_value = current.get(key)
if old_value == new_value:
continue
connection.execute(
"""
INSERT INTO system_settings (key, value, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_by = excluded.updated_by,
updated_at = excluded.updated_at
""",
(key, new_value, actor["id"], utc_now()),
)
connection.execute(
"""
INSERT INTO system_setting_changes (
key, before_value, after_value,
actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
key,
old_value,
new_value,
actor["id"],
actor["username"],
utc_now(),
),
)
return get_settings(connection)
# ----------------------------------------------------------------------
# Reminder pending items
# ----------------------------------------------------------------------
def _current_period() -> tuple[str, str]:
"""Return the current calendar month as ``(start, end_exclusive)`` dates."""
today = datetime.now(timezone.utc)
start = today.strftime("%Y-%m-01")
year, month = today.year, today.month
if month == 12:
end = f"{year + 1}-01-01"
else:
end = f"{year}-{month + 1:02d}-01"
return start, end
def pending_items(connection: sqlite3.Connection, company_id: int) -> list[dict[str, str]]:
"""Derive the list of pending reminder items for one company."""
items: list[dict[str, str]] = []
company = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
return items
period_start, _ = _current_period()
# 1) 本月流水未提交:有已启用账户,但本月没有任何已确认的工作表。
active_accounts = connection.execute(
"""
SELECT id, account_number FROM bank_accounts
WHERE company_id = ? AND status = 'active'
""",
(company_id,),
).fetchall()
confirmed_this_period = connection.execute(
"""
SELECT COUNT(*) AS n
FROM sheet_reviews rv
JOIN import_batches b ON b.id = rv.import_batch_id
WHERE b.company_id = ? AND rv.review_status = 'confirmed'
AND rv.sheet_batch_id IN (
SELECT id FROM sheet_batches s
WHERE s.period_end >= ?
)
""",
(company_id, period_start),
).fetchone()["n"]
if active_accounts and confirmed_this_period == 0:
items.append(
{
"kind": "流水未提交",
"content": "本月各银行账户流水尚未提交,请尽快上传本月银行流水。",
}
)
# 2) 待确认工作表:仍有未确认的解析结果。
pending_sheets = connection.execute(
"""
SELECT COUNT(*) AS n
FROM sheet_reviews rv
JOIN import_batches b ON b.id = rv.import_batch_id
WHERE b.company_id = ? AND rv.review_status = 'pending'
""",
(company_id,),
).fetchone()["n"]
if pending_sheets:
items.append(
{
"kind": "待确认工作表",
"content": f"{pending_sheets} 个导入工作表尚未确认,请核对后确认。",
}
)
# 3) 待审核账户登记:处于待复核状态的银行账户。
pending_accounts = connection.execute(
"""
SELECT COUNT(*) AS n FROM bank_accounts
WHERE company_id = ? AND status = 'pending'
""",
(company_id,),
).fetchone()["n"]
if pending_accounts:
items.append(
{
"kind": "账户登记",
"content": f"{pending_accounts} 个银行账户登记待审核。",
}
)
# 4) 待确认往来事项:尚未解决的往来匹配。
pending_transfers = connection.execute(
"""
SELECT COUNT(*) AS n
FROM current_transfer_decisions c
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants p
ON p.decision_id = d.id AND p.company_id = ?
WHERE d.classification IN ('unresolved', 'needs_review')
""",
(company_id,),
).fetchone()["n"]
if pending_transfers:
items.append(
{
"kind": "往来待确认",
"content": f"{pending_transfers} 项往来流水待确认,请核对对方银行流水佐证。",
}
)
return items
def send_reminders(
connection: sqlite3.Connection,
company_id: int,
actor: sqlite3.Row,
) -> tuple[list[dict[str, str]], str | None]:
"""Create reminder rows for a company's pending items.
Returns ``(created, deadline)``. ``created`` is the list of persisted
reminder payloads; ``deadline`` is derived from the closing-day setting.
"""
items = pending_items(connection, company_id)
if not items:
return [], None
settings = get_settings(connection)
try:
closing_day = int(settings["closing_day"])
except ValueError:
closing_day = 5
today = datetime.now(timezone.utc)
# Deadline: next month's closing day (current month if today is before it).
if today.day < closing_day:
deadline = today.strftime(f"%Y-%m-{closing_day:02d}")
else:
year, month = today.year, today.month
if month == 12:
year, month = year + 1, 1
else:
month += 1
deadline = f"{year}-{month:02d}-{closing_day:02d}"
created: list[dict[str, str]] = []
with connection:
for item in items:
cursor = connection.execute(
"""
INSERT INTO reminders (
company_id, kind, content, deadline, source,
actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, 'manual', ?, ?, ?)
""",
(
company_id,
item["kind"],
item["content"],
deadline,
actor["id"],
actor["username"],
utc_now(),
),
)
created.append(
{
"id": cursor.lastrowid,
"company_id": company_id,
"kind": item["kind"],
"content": item["content"],
"deadline": deadline,
"source": "manual",
"status": "unread",
"actor_username": actor["username"],
"created_at": utc_now(),
}
)
return created, deadline
-86
View File
@@ -1,86 +0,0 @@
"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.strictEqual(ui.isCompactAmount(1280), false);
assert.strictEqual(ui.isCompactAmount(999999999), false);
assert.strictEqual(ui.isCompactAmount(1000000000), true);
assert.strictEqual(ui.isCompactAmount("123456789012345"), true);
assert.ok(ui.amountWithCurrency("123456789012345", "CNY").includes("is-compact"));
assert.ok(!ui.amountWithCurrency(1280, "CNY").includes("is-compact"));
assert.strictEqual(ui.cashDirectionLabel("outgoing"), "转出");
assert.strictEqual(ui.cashDirectionLabel("incoming"), "转入");
assert.strictEqual(ui.relatedFlowLabel(null), "无关联流水");
assert.strictEqual(ui.relatedFlowLabel(""), "无关联流水");
assert.strictEqual(ui.relatedFlowLabel("42"), "42");
const subjectFields = ui.auditEvidenceFields("subject-review", {
direction: "outgoing",
effectiveAt: "2026-07-18T09:00:00",
amount: "600",
currency: "CNY",
summary: "资金调拨",
});
assert.deepStrictEqual(subjectFields.map((item) => item[0]), ["方向", "日期", "金额", "摘要"]);
assert.strictEqual(subjectFields[0][1], "转出");
const manualFields = ui.auditEvidenceFields("manual-review", {
counterpartyName: "乙公司",
direction: "incoming",
relatedSourceRowId: "",
submittedBy: "出纳甲",
attachmentName: "",
amount: "80",
currency: "CNY",
summary: "补记",
});
assert.deepStrictEqual(
manualFields.map((item) => item[0]),
["对方公司名", "方向", "关联流水号", "提交人", "附件", "摘要", "金额"],
);
assert.strictEqual(manualFields[0][1], "乙公司");
assert.strictEqual(manualFields[1][1], "转入");
assert.strictEqual(manualFields[2][1], "无关联流水");
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");
-138
View File
@@ -1,138 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>B-44 余额目录布局回归</title>
<link rel="stylesheet" href="../../web/styles.css" />
</head>
<body data-portal="admin">
<div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
<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" type="button"><span>往来查询</span></button>
</nav>
</aside>
<div class="workspace">
<main id="main-content">
<section class="app-view is-active">
<header class="page-heading"><div><h1>往来查询</h1><p>公司间往来余额目录</p></div></header>
<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 class="ledger-hide-md">借方合计</span><span class="ledger-hide-md">贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
<div class="company-ledgers">
<details class="company-ledger is-balances">
<summary>
<span class="company-name" title="甲公司"><i></i><b>甲公司</b><small class="currency-tag">CNY</small></span>
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>1,280.00</span></strong>
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong>
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency"><span class="currency-code">CNY</span>960.00</span></b></span>
<span class="ledger-unresolved is-empty"><span class="status neutral">未决 0.00</span></span>
<span class="ledger-cutoff">截止 2026.07.31</span>
<svg></svg>
</summary>
</details>
<details class="company-ledger is-balances" id="stressRow">
<summary>
<span class="company-name" title="东南沿海综合贸易与供应链管理股份有限公司"><i></i><b>东南沿海综合贸易与供应链管理股份有限公司</b><small class="currency-tag">CNY</small></span>
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong>
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>100,000,000,000,000.00</span></strong>
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></b></span>
<span class="ledger-unresolved is-active"><span class="status warning">未决 2,150.00 · 3 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px">手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔</small></span>
<span class="ledger-cutoff">截止 2026.07.31</span>
<svg></svg>
</summary>
</details>
</div>
</section>
</section>
</main>
</div>
</div>
<aside class="drawer is-open" id="evidenceDrawer">
<header class="drawer-header">
<div>
<div class="drawer-breadcrumb"><button type="button">往来查询</button> / 甲公司 ↔ 乙公司</div>
<h2>甲公司 ↔ 乙公司</h2>
</div>
<button type="button" class="icon-button" aria-label="关闭">×</button>
</header>
<div class="drawer-body">
<div class="pair-balance-line is-six" id="drawerSix">
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong></div>
<div><span>本期借方</span><strong><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong></div>
<div><span>本期贷方</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong></div>
<div class="pair-final"><span>期末结果</span><strong><em class="status success">应收</em> <span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></strong></div>
<div class="pair-unresolved is-empty"><span>未决金额</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>0.00</span></strong></div>
<div><span>截止日</span><strong class="amount-neutral">2026.07.31</strong></div>
</div>
</div>
</aside>
<pre id="b175-metrics" hidden></pre>
<script>
function box(el) {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
}
function overlap(a, b) {
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(Math.min(dx, dy) === dy ? dx : dx);
}
function clipped(el) {
return el.scrollWidth > el.clientWidth + 1;
}
function measure() {
const row = document.querySelector("#stressRow");
const summary = row.querySelector("summary");
const amount = row.querySelector(".ledger-result .amount-with-currency");
const direction = row.querySelector(".ledger-result .status");
const unresolved = row.querySelector(".ledger-unresolved .status");
const cutoff = row.querySelector(".ledger-cutoff");
const chevron = row.querySelector("summary > svg");
const head = document.querySelector(".ledger-head.is-balances");
const drawerAmount = document.querySelector("#drawerSix .pair-final .amount-with-currency");
const amountBox = box(amount);
const unresolvedBox = box(unresolved);
const cutoffBox = box(cutoff);
const chevronBox = box(chevron);
const directionBox = box(direction);
const metrics = {
viewport: window.innerWidth,
clientWidth: document.documentElement.clientWidth,
fiveColVisible: getComputedStyle(head).display !== "none",
amountText: amount.textContent.replace(/\s+/g, " ").trim(),
unresolvedText: unresolved.textContent.replace(/\s+/g, " ").trim(),
cutoffText: cutoff.textContent.replace(/\s+/g, " ").trim(),
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
amountCutoffOverlap: overlap(amountBox, cutoffBox),
unresolvedCutoffOverlap: overlap(unresolvedBox, cutoffBox),
unresolvedChevronOverlap: overlap(unresolvedBox, chevronBox),
cutoffChevronOverlap: overlap(cutoffBox, chevronBox),
directionUnresolvedOverlap: overlap(directionBox, unresolvedBox),
amountClipped: clipped(amount),
unresolvedClipped: clipped(unresolved),
cutoffClipped: clipped(cutoff),
amountVisible: amountBox.width > 4 && amountBox.height > 4,
unresolvedVisible: unresolvedBox.width > 4 && unresolvedBox.height > 4,
cutoffVisible: cutoffBox.width > 4 && cutoffBox.height > 4,
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
drawerAmountClipped: clipped(drawerAmount),
summaryWidth: Math.round(box(summary).width),
};
const node = document.getElementById("b175-metrics");
node.hidden = false;
node.textContent = JSON.stringify(metrics);
document.title = "B175 " + node.textContent;
}
window.measureB175 = measure;
if (document.readyState === "complete") {
measure();
} else {
window.addEventListener("load", measure);
}
</script>
</body>
</html>
-109
View File
@@ -1,109 +0,0 @@
"""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: 375px)", self.css)
self.assertIn("@media (max-width: 1179px)", self.css)
self.assertIn("@media (min-width: 376px) and (max-width: 900px)", self.css)
self.assertIn(
"minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px",
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)
self.assertIn(".company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }", self.css)
self.assertIn(".amount-with-currency.is-compact { font-size: 11px; }", self.css)
self.assertIn("minmax(88px, 1fr) minmax(0, max-content) 24px", self.css)
self.assertIn("grid-row: 1 / span 2", self.css)
self.assertIn("flex-direction: column; align-items: flex-end; gap: 2px;", 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)
self.assertIn("function isCompactAmount(", self.js)
self.assertIn("function auditEvidenceFields(", self.js)
self.assertIn("无关联流水", self.js)
self.assertIn('row.dataset.direction = "outgoing"', self.js)
self.assertIn("row.dataset.counterpartyName", self.js)
self.assertIn("row.dataset.relatedSourceRowId", 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()
-530
View File
@@ -1,530 +0,0 @@
"""Chromium layout regression for the B-44 admin balance directory.
Measures 15-digit period-end amounts against the unresolved chip using
getBoundingClientRect — not static text presence.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import socket
import subprocess
import tempfile
import threading
import time
from urllib.parse import urlparse
from urllib.request import urlopen
import unittest
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
FIXTURE = "/tests/fixtures/b44-balance-directory.html"
CHROME_CANDIDATES = [
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "Google/Chrome/Application/chrome.exe",
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
/ "Microsoft/Edge/Application/msedge.exe",
]
VIEWPORTS = (720, 768, 800, 900, 375, 1024, 1440)
def chrome_bin() -> Path:
for path in CHROME_CANDIDATES:
if path.exists():
return path
raise FileNotFoundError("Chrome/Edge not found for layout regression")
class QuietHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, directory=str(ROOT), **kwargs)
def log_message(self, *_args) -> None:
pass
def handle(self) -> None:
try:
super().handle()
except (ConnectionResetError, BrokenPipeError, TimeoutError):
pass
class _Cdp:
def __init__(self, ws_url: str) -> None:
parsed = urlparse(ws_url)
self._sock = socket.create_connection((parsed.hostname, parsed.port or 80), timeout=10)
self._sock.settimeout(10)
key = base64.b64encode(os.urandom(16)).decode()
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
self._sock.sendall(
(
f"GET {path} HTTP/1.1\r\n"
f"Host: {parsed.netloc}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n"
).encode()
)
header = b""
while b"\r\n\r\n" not in header:
chunk = self._sock.recv(4096)
if not chunk:
raise ConnectionError("CDP websocket handshake failed")
header += chunk
expected = base64.b64encode(
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
).decode()
if expected not in header.decode("latin1"):
raise ConnectionError("CDP websocket accept mismatch")
leftover = header.split(b"\r\n\r\n", 1)[1]
self._buf = leftover
self._next_id = 1
def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict:
msg_id = self._next_id
self._next_id += 1
self._send({"id": msg_id, "method": method, "params": params or {}})
deadline = time.time() + timeout
while time.time() < deadline:
payload = self._recv()
if payload.get("id") == msg_id:
if "error" in payload:
raise RuntimeError(f"{method}: {payload['error']}")
return payload.get("result") or {}
raise TimeoutError(method)
def wait_event(self, name: str, timeout: float = 15) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
payload = self._recv()
if payload.get("method") == name:
return payload.get("params") or {}
raise TimeoutError(name)
def close(self) -> None:
try:
self._sock.close()
except OSError:
pass
def _send(self, obj: dict) -> None:
data = json.dumps(obj, separators=(",", ":")).encode()
mask = os.urandom(4)
header = bytearray([0x81])
length = len(data)
if length < 126:
header.append(0x80 | length)
elif length < 65536:
header.append(0x80 | 126)
header.extend(length.to_bytes(2, "big"))
else:
header.append(0x80 | 127)
header.extend(length.to_bytes(8, "big"))
header.extend(mask)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
self._sock.sendall(header + masked)
def _recv(self) -> dict:
while True:
opcode, payload = self._read_frame()
if opcode == 0x9:
self._send_pong(payload)
continue
if opcode == 0xA:
continue
if opcode == 0x8:
raise ConnectionError("CDP websocket closed")
return json.loads(payload.decode())
def _send_pong(self, payload: bytes) -> None:
mask = os.urandom(4)
header = bytearray([0x8A, 0x80 | len(payload)])
header.extend(mask)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self._sock.sendall(header + masked)
def _read_frame(self) -> tuple[int, bytes]:
header = self._read_exact(2)
opcode = header[0] & 0x0F
length = header[1] & 0x7F
masked = bool(header[1] & 0x80)
if length == 126:
length = int.from_bytes(self._read_exact(2), "big")
elif length == 127:
length = int.from_bytes(self._read_exact(8), "big")
mask = self._read_exact(4) if masked else b""
payload = self._read_exact(length)
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return opcode, payload
def _read_exact(self, size: int) -> bytes:
while len(self._buf) < size:
chunk = self._sock.recv(4096)
if not chunk:
raise ConnectionError("CDP websocket closed")
self._buf += chunk
data, self._buf = self._buf[:size], self._buf[size:]
return data
class B44LayoutRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.chrome = chrome_bin()
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.http_thread.start()
cls.port = cls.httpd.server_address[1]
cls.url = f"http://127.0.0.1:{cls.port}{FIXTURE}"
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-layout-", ignore_cleanup_errors=True)
user_dir = cls.tmp.name
cls.proc = subprocess.Popen(
[
str(cls.chrome),
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--disable-extensions",
"--remote-debugging-port=0",
f"--user-data-dir={user_dir}",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
port_file = Path(user_dir) / "DevToolsActivePort"
deadline = time.time() + 15
listing = None
while time.time() < deadline:
if port_file.exists() and port_file.stat().st_size:
text = port_file.read_text(encoding="utf-8").strip().splitlines()
if text:
cls.debug_port = int(text[0])
try:
listing = json.loads(
urlopen(
f"http://127.0.0.1:{cls.debug_port}/json/list",
timeout=5,
).read()
)
except Exception:
listing = None
if listing and any(item.get("type") == "page" for item in listing):
break
time.sleep(0.05)
else:
raise RuntimeError("Chrome DevTools port not ready")
page = next(item for item in listing if item.get("type") == "page")
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
cls.cdp.call("Runtime.enable")
cls.cdp.call("Page.enable")
@classmethod
def tearDownClass(cls) -> None:
if getattr(cls, "cdp", None):
cls.cdp.close()
if getattr(cls, "proc", None):
cls.proc.terminate()
try:
cls.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
cls.proc.kill()
cls.proc.wait(timeout=5)
time.sleep(0.2)
if getattr(cls, "httpd", None):
cls.httpd.shutdown()
cls.httpd.server_close()
if getattr(cls, "tmp", None):
cls.tmp.cleanup()
def _metrics(self, width: int) -> dict:
self.cdp.call("Emulation.setDeviceMetricsOverride", {
"width": width,
"height": 1100,
"deviceScaleFactor": 1,
"mobile": False,
})
self.cdp.call("Page.navigate", {"url": f"{self.url}?w={width}"})
deadline = time.time() + 10
last = None
while time.time() < deadline:
result = self.cdp.call(
"Runtime.evaluate",
{
"expression": (
"typeof window.measureB175 === 'function' "
"? (window.measureB175(), document.getElementById('b175-metrics').textContent) "
": null"
),
"returnByValue": True,
},
)
last = result.get("result", {}).get("value")
if last:
return json.loads(last)
time.sleep(0.1)
raise TimeoutError(f"layout metrics not ready: {last}")
def test_fifteen_digit_amount_clear_of_unresolved_chip(self) -> None:
for width in VIEWPORTS:
with self.subTest(width=width):
metrics = self._metrics(width)
self.assertEqual(width, metrics["viewport"], metrics)
self.assertIn("23,456,789,012,345.00", metrics["amountText"])
self.assertIn("未决", metrics["unresolvedText"])
self.assertIn("2026.07.31", metrics["cutoffText"])
self.assertTrue(metrics["amountVisible"])
self.assertTrue(metrics["unresolvedVisible"])
self.assertTrue(metrics["cutoffVisible"])
self.assertFalse(metrics["amountClipped"], metrics)
self.assertFalse(metrics["unresolvedClipped"], metrics)
self.assertFalse(metrics["cutoffClipped"], metrics)
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
self.assertEqual(0, metrics["unresolvedCutoffOverlap"], metrics)
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["cutoffChevronOverlap"], metrics)
self.assertFalse(metrics["drawerAmountClipped"], metrics)
if 376 <= width <= 1179:
self.assertTrue(metrics["fiveColVisible"], metrics)
if 376 <= width <= 900:
self.assertTrue(metrics["stackedResult"], metrics)
if width <= 375:
self.assertFalse(metrics["fiveColVisible"], metrics)
LIVE_MEASURE = r"""
(() => {
const pair = document.querySelector('[data-view="pair"]');
if (pair) pair.click();
const rows = [...document.querySelectorAll("#balanceLedgers .company-ledger.is-balances")];
if (!rows.length) return null;
const row = rows.find((item) => (item.textContent || "").includes("123,456,789,012,345")) || rows[0];
const amount = row.querySelector(".ledger-result .amount-with-currency");
const direction = row.querySelector(".ledger-result .status");
const unresolved = row.querySelector(".ledger-unresolved .status") || row.querySelector(".ledger-unresolved");
const cutoff = row.querySelector(".ledger-cutoff");
const chevron = row.querySelector("summary > svg");
const box = (el) => {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
};
const overlap = (a, b) => {
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(dx);
};
const clipped = (el) => el.scrollWidth > el.clientWidth + 1;
const amountBox = box(amount);
const unresolvedBox = box(unresolved);
return {
viewport: window.innerWidth,
amountText: (amount.textContent || "").replace(/\s+/g, " ").trim(),
unresolvedText: (unresolved.textContent || "").replace(/\s+/g, " ").trim(),
cutoffText: (cutoff.textContent || "").replace(/\s+/g, " ").trim(),
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
directionUnresolvedOverlap: overlap(box(direction), unresolvedBox),
amountCutoffOverlap: overlap(amountBox, box(cutoff)),
unresolvedCutoffOverlap: overlap(unresolvedBox, box(cutoff)),
cutoffChevronOverlap: overlap(box(cutoff), box(chevron)),
amountClipped: clipped(amount),
unresolvedClipped: clipped(unresolved),
cutoffClipped: clipped(cutoff),
fiveColVisible: getComputedStyle(document.querySelector(".ledger-head.is-balances")).display !== "none",
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
rowCount: rows.length,
};
})()
"""
COMPANY_MEASURE = r"""
(() => {
const balances = document.querySelector('[data-view="balances"]');
if (balances) balances.click();
const row = document.querySelector(".company-row.is-balance-counterparty");
if (!row) return null;
const amount = row.querySelector(".amount-with-currency") || row.querySelector(".company-row-figure strong");
const direction = row.querySelector(".status");
const cutoff = row.querySelector(".company-row-figure small");
const box = (el) => {
const r = el.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
};
const overlap = (a, b) => {
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
if (dx <= 0 || dy <= 0) return 0;
return Math.round(dx);
};
return {
viewport: window.innerWidth,
amountText: (amount?.textContent || "").replace(/\s+/g, " ").trim(),
directionText: (direction?.textContent || "").replace(/\s+/g, " ").trim(),
cutoffText: (cutoff?.textContent || "").replace(/\s+/g, " ").trim(),
amountDirectionOverlap: amount && direction ? overlap(box(amount), box(direction)) : 0,
visible: Boolean(amount && amount.getBoundingClientRect().width > 4),
};
})()
"""
class LiveAdminDirectoryLayoutTests(unittest.TestCase):
"""Chromium against a real login/API page with B-168-scale 15-digit amounts."""
@classmethod
def setUpClass(cls) -> None:
from test_positions_api import IntercompanyApiTests
cls.api = IntercompanyApiTests("test_admin_balances_directory")
cls.api.setUp()
confirmed = cls.api._fresh_pair("123456789012345.00")
cls.api._confirm(confirmed["ledger_event_id"])
cls.api._fresh_pair("2150.00")
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-live-", ignore_cleanup_errors=True)
cls.proc = subprocess.Popen(
[
str(chrome_bin()),
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--disable-extensions",
"--remote-debugging-port=0",
f"--user-data-dir={cls.tmp.name}",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
port_file = Path(cls.tmp.name) / "DevToolsActivePort"
deadline = time.time() + 15
listing = None
while time.time() < deadline:
if port_file.exists() and port_file.stat().st_size:
text = port_file.read_text(encoding="utf-8").strip().splitlines()
if text:
cls.debug_port = int(text[0])
try:
listing = json.loads(
urlopen(
f"http://127.0.0.1:{cls.debug_port}/json/list",
timeout=5,
).read()
)
except Exception:
listing = None
if listing and any(item.get("type") == "page" for item in listing):
break
time.sleep(0.05)
else:
raise RuntimeError("Chrome DevTools port not ready")
page = next(item for item in listing if item.get("type") == "page")
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
cls.cdp.call("Runtime.enable")
cls.cdp.call("Page.enable")
cls.cdp.call("Network.enable")
token = cls.api.admin.cookies["cw_session"]
cls.cdp.call(
"Network.setCookie",
{
"name": "cw_session",
"value": token,
"url": f"http://127.0.0.1:{cls.api.port}/",
},
)
@classmethod
def tearDownClass(cls) -> None:
if getattr(cls, "cdp", None):
cls.cdp.close()
if getattr(cls, "proc", None):
cls.proc.terminate()
try:
cls.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
cls.proc.kill()
cls.proc.wait(timeout=5)
time.sleep(0.2)
if getattr(cls, "api", None):
cls.api.tearDown()
cls.api.doCleanups()
if getattr(cls, "tmp", None):
cls.tmp.cleanup()
def _eval(self, expression: str):
deadline = time.time() + 12
last = None
while time.time() < deadline:
result = self.cdp.call(
"Runtime.evaluate",
{"expression": expression, "returnByValue": True},
)
last = result.get("result", {}).get("value")
if last:
return last
time.sleep(0.15)
raise TimeoutError(f"live page metrics not ready: {last}")
def _open(self, path: str, width: int) -> None:
self.cdp.call("Emulation.setDeviceMetricsOverride", {
"width": width,
"height": 1100,
"deviceScaleFactor": 1,
"mobile": False,
})
self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.api.port}/{path}"})
time.sleep(0.4)
def test_live_admin_directory_viewports(self) -> None:
for width in (720, 768, 800, 900, 375, 1024, 1440):
with self.subTest(width=width):
self._open("admin.html", width)
metrics = self._eval(LIVE_MEASURE)
self.assertEqual(width, metrics["viewport"], metrics)
self.assertGreaterEqual(metrics["rowCount"], 1, metrics)
self.assertIn("123,456,789,012,345", metrics["amountText"], metrics)
self.assertIn("未决", metrics["unresolvedText"], metrics)
self.assertIn("2026.07.31", metrics["cutoffText"], metrics)
self.assertFalse(metrics["amountClipped"], metrics)
self.assertFalse(metrics["unresolvedClipped"], metrics)
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
if 376 <= width <= 900:
self.assertTrue(metrics["stackedResult"], metrics)
if width <= 375:
self.assertFalse(metrics["fiveColVisible"], metrics)
def test_live_company_balance_rows(self) -> None:
token = self.api.cashier_a.cookies["cw_session"]
self.cdp.call(
"Network.setCookie",
{
"name": "cw_session",
"value": token,
"url": f"http://127.0.0.1:{self.api.port}/",
},
)
for width in (375, 1440):
with self.subTest(width=width):
self._open("company.html", width)
metrics = self._eval(COMPANY_MEASURE)
self.assertTrue(metrics["visible"], metrics)
self.assertTrue(metrics["amountText"], metrics)
self.assertTrue(metrics["directionText"], metrics)
self.assertIn("截止", metrics["cutoffText"], metrics)
self.assertEqual(0, metrics["amountDirectionOverlap"], metrics)
if __name__ == "__main__":
unittest.main()
+8 -5
View File
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
class MigrationTests(PersistenceTestCase):
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
first = applied_versions(self.connection)
self.assertEqual([1, 2, 3, 4, 5, 6], first)
self.assertEqual([1, 2, 3, 4, 5, 6, 7], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -82,19 +82,22 @@ class MigrationTests(PersistenceTestCase):
"ledger_event_bank_sources",
"ledger_event_manual_sources",
"ledger_subject_suggestions",
"system_settings",
"system_setting_changes",
"reminders",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone()
self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -102,7 +105,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([6, 5], rollback(self.connection, 4))
self.assertEqual([7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
+258
View File
@@ -0,0 +1,258 @@
"""Integration tests for system settings persistence and reminder flow."""
from __future__ import annotations
from http.client import HTTPConnection
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from bank_importer.db import connect, migrate
from bank_importer import auth, settings
import server
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
class Client:
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
self.cookies: dict[str, str] = {}
def request(self, method, path, body=None, headers=None):
connection = HTTPConnection(self.host, self.port)
request_headers = dict(headers or {})
if self.cookies:
request_headers["Cookie"] = "; ".join(
f"{k}={v}" for k, v in self.cookies.items()
)
connection.request(method, path, body=body, headers=request_headers)
response = connection.getresponse()
data = response.read()
set_cookie = dict(response.getheaders()).get("Set-Cookie")
if set_cookie:
cookie = SimpleCookie()
cookie.load(set_cookie)
for key, morsel in cookie.items():
if morsel.value:
self.cookies[key] = morsel.value
else:
self.cookies.pop(key, None)
status = response.status
connection.close()
return status, data
def get(self, path):
return self.request("GET", path)
def post_json(self, path, payload):
return self.request(
"POST", path, body=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
def as_json(data: bytes):
return json.loads(data.decode("utf-8"))
class SettingsAndRemindersTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
server.ensure_bootstrap_admin(connection)
# One active account for company 1 so pending items are generated.
with connection:
connection.execute(
"INSERT INTO companies (name, credit_code, status, created_at, updated_at) "
"VALUES ('甲公司', NULL, 'active', 't', 't')"
)
company_id = connection.execute(
"SELECT id FROM companies WHERE name = '甲公司'"
).fetchone()["id"]
connection.execute(
"INSERT INTO bank_accounts (company_id, account_number, bank_name, status, created_at, updated_at) "
"VALUES (?, '11112222', '工行', 'active', 't', 't')",
(company_id,),
)
cls.company_id = company_id
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
def test_settings_roundtrip_persists(self) -> None:
status, data = self.admin.post_json(
"/api/admin/settings",
{"closing_day": "1", "start_date": "2026-01-05", "auto_remind": "0", "remind_days": "5"},
)
self.assertEqual(200, status, data)
updated = as_json(data)["settings"]
self.assertEqual("1", updated["closing_day"])
self.assertEqual("2026-01-05", updated["start_date"])
self.assertEqual("0", updated["auto_remind"])
self.assertEqual("5", updated["remind_days"])
status, data = self.admin.get("/api/admin/settings")
self.assertEqual(200, status)
self.assertEqual("1", as_json(data)["settings"]["closing_day"])
def test_invalid_closing_day_rejected(self) -> None:
for bad in ("0", "29", "abc"):
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": bad})
self.assertEqual(400, status, (bad, data))
def test_setting_change_is_audited(self) -> None:
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": "7"})
self.assertEqual(200, status)
connection = connect(self.db_path)
try:
rows = connection.execute(
"SELECT key, before_value, after_value, actor_username "
"FROM system_setting_changes WHERE key = 'closing_day' "
"ORDER BY id DESC LIMIT 1"
).fetchall()
finally:
connection.close()
self.assertTrue(rows)
self.assertEqual("7", rows[0]["after_value"])
self.assertNotEqual(rows[0]["before_value"], rows[0]["after_value"])
self.assertEqual("group-admin", rows[0]["actor_username"])
def test_reminder_pending_and_send(self) -> None:
status, data = self.admin.get(
f"/api/admin/reminders/pending?company_id={self.company_id}"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(items)
status, data = self.admin.post_json(
"/api/admin/reminders/send", {"company_id": self.company_id}
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertGreaterEqual(len(payload["reminders"]), 1)
self.assertTrue(payload["deadline"])
status, data = self.admin.get("/api/admin/reminders")
self.assertEqual(200, status)
history = as_json(data)["reminders"]
self.assertGreaterEqual(len(history), 1)
self.assertEqual(self.company_id, history[0]["company_id"])
def test_company_user_forbidden_on_settings_and_reminders(self) -> None:
# A company user must not be able to read or write admin settings.
status, data = self.admin.post_json(
"/api/admin/users", {"username": "cashier-x", "company_id": self.company_id}
)
self.assertEqual(200, status, data)
initial = as_json(data)["initial_password"]
cashier = Client("127.0.0.1", self.port)
status, data = cashier.post_json(
"/api/login",
{"username": "cashier-x", "password": initial, "portal": "company"},
)
self.assertEqual(200, status)
status, data = cashier.post_json(
"/api/password/change",
{"old_password": initial, "new_password": "Changed123"},
)
self.assertEqual(200, status)
for path in ("/api/admin/settings", "/api/admin/reminders"):
status, _ = cashier.get(path)
self.assertEqual(403, status, path)
class SettingsModuleTests(unittest.TestCase):
"""Unit tests for the settings module on a fresh in-memory database."""
def setUp(self) -> None:
self.connection = connect(":memory:")
self.addCleanup(self.connection.close)
migrate(self.connection)
def test_defaults_applied_when_no_row_exists(self) -> None:
values = settings.get_settings(self.connection)
self.assertEqual("5", values["closing_day"])
self.assertEqual("2026-01-01", values["start_date"])
self.assertEqual("1", values["auto_remind"])
self.assertEqual("3", values["remind_days"])
def test_validate_rejects_bad_values(self) -> None:
for bad in ({"closing_day": "0"}, {"closing_day": "29"}, {"closing_day": "abc"}):
_, error = settings.validate_settings(bad)
self.assertIsNotNone(error)
_, error = settings.validate_settings({"start_date": "2026-13-40"})
self.assertIsNotNone(error)
_, error = settings.validate_settings({"auto_remind": "2"})
self.assertIsNotNone(error)
def test_update_writes_value_and_trail(self) -> None:
user_id = auth.create_user(self.connection, "group-admin", "AdminPass123", "admin")
user = self.connection.execute(
"SELECT id, username FROM users WHERE id = ?", (user_id,)
).fetchone()
updated = settings.update_settings(
self.connection, {"closing_day": "1", "auto_remind": "0"}, user
)
self.assertEqual("1", updated["closing_day"])
self.assertEqual("0", updated["auto_remind"])
changes = self.connection.execute(
"SELECT key, before_value, after_value FROM system_setting_changes ORDER BY id"
).fetchall()
self.assertEqual(2, len(changes))
self.assertEqual("5", changes[0]["before_value"])
self.assertEqual("1", changes[0]["after_value"])
if __name__ == "__main__":
unittest.main()
+25 -37
View File
@@ -3,18 +3,18 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团总账管理端" />
<title>总账管理端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=4" />
<meta name="description" content="金牛集团管理端" />
<title>管理端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=5" />
</head>
<body data-portal="admin">
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="shell">
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
<aside class="sidebar" id="sidebar" aria-label="管理导航">
<div class="side-brand">
<div class="brand-name">金牛实业 · 资金往来</div>
<div class="brand-sub">HENAN JINNIU INDUSTRIAL GROUP</div>
<span class="side-role">总账管理端</span>
<span class="side-role">管理端</span>
</div>
<nav class="side-nav">
<div class="nav-group">日常</div>
@@ -32,7 +32,7 @@
<span class="avatar"></span>
<div>
<div class="user-name">系统管理员</div>
<div class="user-meta">集团总账会计</div>
<div class="user-meta">管理员</div>
</div>
<button class="logout" type="button">退出</button>
</div>
@@ -41,7 +41,7 @@
<div class="main">
<div class="topbar">
<span class="crumb">总账管理端 / <b id="currentViewName">管理总览</b></span>
<span class="crumb">管理端 / <b id="currentViewName">管理总览</b></span>
<div class="topbar-right">
<span class="tag">账期 2026-07</span>
<span class="tag">统计截止 2026-08-20</span>
@@ -245,7 +245,7 @@
<div class="card">
<div class="card-head">
<span class="card-title">账期与结账<span class="sub">每月 5 日结账 · 全局起算日 2026-01-01</span></span>
<span class="card-title">账期与结账<span class="sub" id="dashboardPeriodSub">每月 5 日结账 · 全局起算日 2026-01-01</span></span>
</div>
<dl class="kv">
<dt>当前账期</dt><dd>2026-07 · 进行中</dd>
@@ -524,7 +524,7 @@
</table>
<div class="table-foot">
<span id="flowCount">共 0 笔</span>
<span class="meta">数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)</span>
<span class="meta" id="flowsRangeMeta">数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)</span>
</div>
</div>
</section>
@@ -570,7 +570,7 @@
</div>
<div class="table-foot" style="border: 1px solid var(--border); border-radius: 0 0 var(--radius-lg) var(--radius-lg);">
<span id="companyFoot">共 0 家公司</span>
<span class="meta">登录账号初始密码由集团总账统一发放</span>
<span class="meta">登录账号初始密码由管理员统一发放</span>
</div>
</div>
</section>
@@ -585,7 +585,7 @@
<div class="card">
<div class="card-head">
<span class="card-title">账期时间轴<span class="sub">全局起算日 2026-01-01 起,每月 5 日结账</span></span>
<span class="card-title">账期时间轴<span class="sub" id="timelineSub">全局起算日 2026-01-01 起,每月 5 日结账</span></span>
</div>
<div class="timeline">
<div class="tl-cell closed"><div class="tl-month">2026-01</div><div class="tl-state">已结账</div></div>
@@ -643,7 +643,7 @@
<div class="card">
<div class="card-head">
<span class="card-title">期初余额管理<span class="sub">2026-01-01 起算的公司间往来期初数</span></span>
<span class="card-title">期初余额管理<span class="sub" id="openingSub">2026-01-01 起算的公司间往来期初数</span></span>
<button class="btn btn-sm" id="openOpeningDialog">新增</button>
</div>
<div class="table-wrap" style="border: 0;">
@@ -718,34 +718,22 @@
<div class="grid grid-1-2">
<div class="card">
<div class="card-head">
<span class="card-title">发送处理提醒<span class="sub">将以人工提醒形式送达所选公司出纳与财务负责人</span></span>
<span class="card-title">发送处理提醒<span class="sub">选择公司后系统自动列出待提醒事项,核对后一键发送</span></span>
</div>
<form id="reminderForm" novalidate>
<div class="field" style="margin-bottom: 14px;">
<label>接收公司(可多选)</label>
<div class="row" id="reminderCompanyList" style="flex-wrap: wrap; gap: 8px 16px;"></div>
<span class="hint error" id="company-error" style="display: none;">至少选择一家接收公司</span>
<label for="reminder-company">接收公司</label>
<select class="select" id="reminder-company"><option value="">请选择公司</option></select>
<span class="hint error" id="company-error" style="display: none;">请选择一家接收公司</span>
</div>
<div class="field" style="margin-bottom: 14px;">
<label for="reminder-type">提醒类型</label>
<select class="select" id="reminder-type">
<option selected>流水未提交</option>
<option>单边待确认</option>
<option>科目待确认</option>
<option>账户登记</option>
<option>其他</option>
</select>
<div class="field" style="margin-bottom: 16px;">
<label>提醒事项(系统自动列出)</label>
<div id="reminder-items">
<div class="empty">请先选择公司,系统将自动列出该公司待提醒事项。</div>
</div>
<span class="hint error" id="reminder-error" style="display: none;">该公司当前没有待提醒事项</span>
</div>
<div class="field" style="margin-bottom: 14px;">
<label for="reminder-content">提醒内容</label>
<textarea class="textarea" id="reminder-content">请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。</textarea>
<span class="hint error" id="content-error" style="display: none;">提醒内容不能为空</span>
</div>
<div class="field" style="margin-bottom: 18px;">
<label for="reminder-deadline">截止日期</label>
<input class="input" type="date" id="reminder-deadline" value="2026-08-29" min="2026-08-20" />
</div>
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;">发送提醒</button>
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;" disabled>发送提醒</button>
<p class="hint" id="send-hint" style="margin-top: 10px; display: none;"></p>
</form>
</div>
@@ -793,7 +781,7 @@
<span class="modal-title">新增成员公司</span>
<button type="button" class="modal-close" data-close="companyDialog" aria-label="关闭">×</button>
</div>
<p class="modal-sub">登记后系统将生成公司端登录账号,初始密码由集团总账统一发放。</p>
<p class="modal-sub">登记后系统将生成公司端登录账号,初始密码由管理员统一发放。</p>
<form id="companyForm" novalidate>
<div class="field" style="margin-bottom: 12px;">
<label for="f-name">公司全称</label>
@@ -1050,7 +1038,7 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=4"></script>
<script src="app.js?v=5"></script>
<script>
(function () {
var input = document.getElementById("company-search");
+211 -108
View File
@@ -316,7 +316,7 @@ function initEntry() {
function updateRole() {
const role = $('input[name="role"]:checked', form).value;
action.textContent = role === "admin" ? "进入总账管理端" : "进入公司业务端";
action.textContent = role === "admin" ? "进入管理端" : "进入公司业务端";
}
roleInputs.forEach((input) => input.addEventListener("change", updateRole));
@@ -408,7 +408,7 @@ function applyCompanyIdentity(me) {
const nameEl = $(".user-name", row);
if (nameEl) nameEl.textContent = name;
const metaEl = $(".user-meta", row);
if (metaEl) metaEl.textContent = portal === "company" ? (me.company_name || "公司业务端") : "集团总账会计";
if (metaEl) metaEl.textContent = portal === "company" ? (me.company_name || "公司业务端") : "管理员";
}
// The company portal always shows the session-bound company in page copy.
if (portal === "company" && me.company_name) {
@@ -692,7 +692,7 @@ function renderCompanyManualRecords() {
statusCell.innerHTML = `<span class="pill ${statusMeta.cls}">${statusMeta.label}</span>`;
const action = document.createElement("td");
if (record.status === "待总账复核") {
if (record.status === "待管理复核") {
action.innerHTML = '<button type="button" class="btn btn-sm btn-danger" data-action="withdraw">撤回</button>';
} else {
action.innerHTML = '<span class="muted">—</span>';
@@ -706,7 +706,7 @@ function renderCompanyManualRecords() {
function updateManualCounts() {
const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司");
const pending = records.filter((record) => record.status === "待总账复核").length;
const pending = records.filter((record) => record.status === "待管理复核").length;
const pendingEl = $("#manualPendingStatus");
if (pendingEl) pendingEl.textContent = pending;
const foot = $("#manualFoot");
@@ -808,7 +808,7 @@ function appendAdminReviewRow(record, kind) {
row.dataset.evidence = isAccount
? "公司提交资料、开户行、账号、账户类型与启用日期"
: "公司手工记录、关联银行流水号、证明附件与提交说明";
if (statusLabel !== "待复核" && statusLabel !== "待总账复核") row.dataset.resolved = "true";
if (statusLabel !== "待复核" && statusLabel !== "待管理复核") row.dataset.resolved = "true";
if (isAccount) row.dataset.accountStatus = record.status;
const riskCell = document.createElement("td");
@@ -975,20 +975,11 @@ function fillCompanySelects(names) {
});
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
const reminderList = $("#reminderCompanyList");
if (reminderList) {
reminderList.replaceChildren(...names.map((name) => {
const label = document.createElement("label");
label.className = "row";
label.style.cssText = "gap:6px;font-size:13px;";
const input = document.createElement("input");
input.type = "checkbox";
input.name = "company";
input.value = name;
input.style.width = "auto";
label.append(input, document.createTextNode(name));
return label;
}));
const reminderSelect = $("#reminder-company");
if (reminderSelect) {
const kept = reminderSelect.querySelector('option[value=""]') ? [reminderSelect.options[0].cloneNode(true)] : [];
const companies = state.companies || [];
reminderSelect.replaceChildren(...kept, ...companies.map((company) => new Option(company.name, company.id)));
}
setSelectOptions($('#openingDialog [name="from"]'), names);
setSelectOptions($('#openingDialog [name="to"]'), names);
@@ -1227,16 +1218,74 @@ function initAdmin() {
remind?.addEventListener("change", renderSwitch);
renderSwitch();
$("#systemSettings")?.addEventListener("submit", (event) => {
function applySystemSettings(values) {
if (!values) return;
const start = $("#cs-start");
if (start && values.start_date) start.value = values.start_date;
const dayInput = $("#cs-day");
if (dayInput && values.closing_day) dayInput.value = values.closing_day;
const remindInput = $("#cs-remind");
if (remindInput && "auto_remind" in values) {
remindInput.checked = values.auto_remind === "1";
renderSwitch();
}
const daysSelect = $("#cs-remind-days");
if (daysSelect && values.remind_days) daysSelect.value = values.remind_days;
const closingDay = values.closing_day || "5";
const startDate = values.start_date || "2026-01-01";
const dash = $("#dashboardPeriodSub");
if (dash) dash.textContent = `每月 ${closingDay} 日结账 · 全局起算日 ${startDate}`;
const timeline = $("#timelineSub");
if (timeline) timeline.textContent = `全局起算日 ${startDate} 起,每月 ${closingDay} 日结账`;
const flows = $("#flowsRangeMeta");
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账(7 月顺延至 08-29`;
const opening = $("#openingSub");
if (opening) opening.textContent = `${startDate} 起算的公司间往来期初数`;
}
async function loadSystemSettings() {
const response = await fetch("/api/admin/settings").catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (response?.ok && result.settings) applySystemSettings(result.settings);
}
$("#systemSettings")?.addEventListener("submit", async (event) => {
event.preventDefault();
const day = parseInt($("#cs-day")?.value, 10);
const tip = $("#cs-save-tip");
if (day && (day < 1 || day > 28)) {
const day = parseInt($("#cs-day")?.value, 10);
if (!day || day < 1 || day > 28) {
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; }
return;
}
const payload = {
closing_day: String(day),
start_date: $("#cs-start")?.value || "",
auto_remind: $("#cs-remind")?.checked ? "1" : "0",
remind_days: $("#cs-remind-days")?.value || "3",
};
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
const message = result?.message || "保存失败,请稍后重试";
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = message; }
showToast("设置保存失败", message, "danger");
return;
}
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
showToast("系统计算口径已保存", "正式系统将记录修改前后值与操作人", "success");
applySystemSettings(result.settings || {});
showToast("系统计算口径已保存", "修改前后值与操作人已留痕", "success");
});
$("#runClosingCheck")?.addEventListener("click", () => {
@@ -1309,111 +1358,164 @@ function initAdmin() {
showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人", "success");
});
$("#reminderForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const checked = Array.prototype.slice.call(form.querySelectorAll('input[name="company"]:checked'));
const type = $("#reminder-type").value;
const content = $("#reminder-content").value.trim();
const deadline = $("#reminder-deadline").value;
$("#company-error").style.display = checked.length ? "none" : "";
$("#content-error").style.display = content ? "none" : "";
if (!checked.length || !content) return;
const companies = checked.map((c) => c.value).join("、");
const summary = content.length > 46 ? content.slice(0, 46) + "…" : content;
const row = document.createElement("tr");
row.dataset.source = "manual";
row.innerHTML =
'<td class="cell-main">' + companies + '</td>' +
'<td><span class="tag">' + type + '</span></td>' +
'<td class="wrap">' + summary.replace(/</g, "&lt;") + '</td>' +
'<td class="meta">刚刚<span class="cell-sub">人工 · 系统管理员</span></td>' +
'<td class="meta">' + (deadline || "—") + '</td>' +
'<td><span class="pill pill-danger">未读</span></td>' +
'<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm act-remind">再提醒</button><button type="button" class="btn btn-sm btn-ghost act-history" data-company="' + checked[0].value + '">查看历史</button></div></td>';
$("#reminder-tbody").insertBefore(row, $("#reminder-tbody").firstChild);
refreshReminderCounts();
form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; });
$("#reminder-content").value = "请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。";
$("#reminder-deadline").value = "2026-08-29";
const sendHint = $("#send-hint");
sendHint.style.display = "";
sendHint.textContent = "已发送给 " + companies + ",共 " + checked.length + " 家公司。";
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
// ── 提醒管理:选公司 → 自动列出待提醒事项 → 一键发送 ──
const reminderSelect = $("#reminder-company");
const reminderItems = $("#reminder-items");
const reminderError = $("#reminder-error");
const companyError = $("#company-error");
const sendBtn = $("#send-btn");
const esc = (text) => String(text ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const fmtReminderTime = (iso) => String(iso || "").replace("T", " ").slice(5, 16);
let pendingReminderItems = [];
function renderPendingItems(items) {
pendingReminderItems = items || [];
if (!reminderItems) return;
if (!pendingReminderItems.length) {
reminderItems.innerHTML = '<div class="empty">该公司当前没有待提醒事项。</div>';
if (sendBtn) sendBtn.disabled = true;
return;
}
reminderItems.replaceChildren(...pendingReminderItems.map((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.innerHTML =
'<span class="pill pill-warn" style="flex:none;">待处理</span>' +
'<div class="lr-main"><div class="lr-title">' + esc(item.kind) + '</div>' +
'<div class="lr-sub">' + esc(item.content) + '</div></div>';
return row;
}));
if (sendBtn) sendBtn.disabled = false;
}
async function loadPendingReminders(companyId) {
if (!companyId) { renderPendingItems([]); return; }
if (reminderItems) reminderItems.innerHTML = '<div class="loading-row">正在列出待提醒事项…</div>';
if (sendBtn) sendBtn.disabled = true;
const response = await fetch("/api/admin/reminders/pending?company_id=" + encodeURIComponent(companyId)).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) { renderPendingItems([]); return; }
renderPendingItems(result.items || []);
}
reminderSelect?.addEventListener("change", () => {
if (reminderError) reminderError.style.display = "none";
loadPendingReminders(reminderSelect.value);
});
// 提醒历史 tabs / 计数
$("#reminderForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const companyId = reminderSelect?.value;
if (companyError) companyError.style.display = companyId ? "none" : "";
if (!companyId) return;
if (!pendingReminderItems.length) {
if (reminderError) reminderError.style.display = "";
return;
}
if (reminderError) reminderError.style.display = "none";
if (sendBtn) sendBtn.disabled = true;
const response = await fetch("/api/admin/reminders/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ company_id: Number(companyId) }),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
if (sendBtn) sendBtn.disabled = false;
showToast("发送失败", result?.message || "请稍后重试", "danger");
return;
}
const sendHint = $("#send-hint");
if (sendHint) {
sendHint.style.display = "";
sendHint.textContent = `已向 ${result.company_name} 发送 ${(result.reminders || []).length} 项提醒,截止 ${result.deadline || "—"}`;
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
}
await loadReminderHistory();
await loadPendingReminders(companyId);
showToast("提醒已发送", `已生成 ${(result.reminders || []).length} 条提醒记录`, "success");
});
// ── 提醒历史(实时从后端读取) ──
let reminderFilter = "all";
function refreshReminderCounts() {
const rows = $$("#reminder-tbody tr");
let all = rows.length, sys = 0, man = 0;
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
$("#count-all").textContent = all;
$("#count-system").textContent = sys;
$("#count-manual").textContent = man;
$("#table-foot-count").textContent = "共 " + all + " 条提醒记录";
const countAll = $("#count-all"), countSys = $("#count-system"), countMan = $("#count-manual");
if (countAll) countAll.textContent = all;
if (countSys) countSys.textContent = sys;
if (countMan) countMan.textContent = man;
const foot = $("#table-foot-count");
if (foot) foot.textContent = "共 " + all + " 条提醒记录";
const state = $("#table-foot-state");
if (state) state.textContent = "未读 " + all + " · 处理中 0 · 已完成 0";
}
function applyReminderFilter() {
$$("#reminder-tbody tr").forEach((r) => {
r.style.display = (reminderFilter === "all" || r.dataset.source === reminderFilter) ? "" : "none";
});
}
async function loadReminderHistory() {
const tbody = $("#reminder-tbody");
if (!tbody) return;
const response = await fetch("/api/admin/reminders").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
const reminders = result?.reminders || [];
tbody.replaceChildren(...reminders.map((reminder) => {
const row = document.createElement("tr");
row.dataset.source = reminder.source;
row.innerHTML =
'<td class="cell-main">' + esc(reminder.company_name) + '</td>' +
'<td><span class="tag">' + esc(reminder.kind) + '</span></td>' +
'<td class="wrap">' + esc(reminder.content) + '</td>' +
'<td class="meta">' + fmtReminderTime(reminder.created_at) + '<span class="cell-sub">' + (reminder.source === "manual" ? "人工" : "系统") + (reminder.actor_username ? " · " + esc(reminder.actor_username) : "") + '</span></td>' +
'<td class="meta">' + (reminder.deadline || "—") + '</td>' +
'<td><span class="pill pill-danger">未读</span></td>' +
'<td><button type="button" class="btn btn-sm btn-ghost act-history" data-company-id="' + reminder.company_id + '" data-company="' + esc(reminder.company_name) + '">查看历史</button></td>';
return row;
}));
applyReminderFilter();
refreshReminderCounts();
}
$("#reminder-tabs")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
const filter = btn.dataset.filter;
$$("#reminder-tbody tr").forEach((r) => { r.style.display = (filter === "all" || r.dataset.source === filter) ? "" : "none"; });
reminderFilter = btn.dataset.filter;
applyReminderFilter();
});
$("#reminder-tbody")?.addEventListener("click", (event) => {
const remindBtn = event.target.closest(".act-remind");
if (remindBtn) {
const tr = remindBtn.closest("tr");
const timeCell = tr.children[3];
const sub = timeCell.querySelector(".cell-sub");
timeCell.firstChild.textContent = "刚刚";
if (sub) sub.textContent = sub.textContent.replace(/\s*·?\s*第 \d+ 次/, "") + " · 再提醒";
else timeCell.insertAdjacentHTML("beforeend", '<span class="cell-sub">再提醒</span>');
remindBtn.textContent = "已再提醒";
remindBtn.disabled = true;
return;
}
const historyBtn = event.target.closest(".act-history");
if (historyBtn) openReminderHistory(historyBtn.dataset.company);
if (historyBtn) openReminderHistory(historyBtn.dataset.companyId, historyBtn.dataset.company);
});
const reminderHISTORIES = {
"金牛农业": [
{ time: "2026-08-18 09:02", src: "系统 · 第 2 次", type: "流水未提交", status: "danger", statusText: "未读", body: "贵公司 7 月全部银行账户流水尚未提交,距 7 月结账顺延截止日仅剩 11 天,请尽快上传。" },
{ time: "2026-08-11 09:00", src: "系统 · 第 1 次", type: "流水未提交", status: "danger", statusText: "未读", body: "2026 年 7 月银行流水上传提醒:请于截止日前完成全部账户流水提交。" },
],
"金牛新能源": [
{ time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "warn", statusText: "处理中", body: "与金牛贸易 3 笔单边流水合计 ¥4,860,000.00 待确认,请选择对方银行流水佐证。" },
],
"金牛置业": [
{ time: "2026-08-14 16:40", src: "人工 · 张维", type: "流水未提交", status: "warn", statusText: "处理中", body: "中行尾号 8821 账户 7 月 6—16 日流水断档,请补传该期间银行回单或对账单。" },
],
"金牛贸易": [
{ time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "success", statusText: "已完成", body: "与金牛新能源往来 3 笔单边流水待确认,请核对 7 月销售回款记录。" },
],
"金牛煤业": [
{ time: "2026-08-12 09:00", src: "系统", type: "科目待确认", status: "warn", statusText: "处理中", body: "2 笔手工记录往来科目待确认(应收 / 其他应收),涉及煤炭运费结算。" },
],
"金牛物流": [
{ time: "2026-08-10 11:25", src: "人工 · 张维", type: "科目待确认", status: "success", statusText: "已完成", body: "1 笔运费结算手工记录科目待确认(其他应付待判定),请于结账前完成。" },
],
};
function openReminderHistory(company) {
$("#history-title").textContent = company + " · 提醒历史";
$("#history-sub").textContent = "2026 年 7 月账期以来的全部触达记录,按时间倒序。";
async function openReminderHistory(companyId, companyName) {
$("#history-title").textContent = (companyName || "") + " · 提醒历史";
$("#history-sub").textContent = "该公司的全部提醒触达记录,按时间倒序。";
const list = $("#history-list");
const items = reminderHISTORIES[company] || [];
list.innerHTML = '<div class="loading-row">加载中…</div>';
const response = await fetch("/api/admin/reminders?company_id=" + encodeURIComponent(companyId || "")).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
const items = result?.reminders || [];
if (!items.length) {
list.innerHTML = '<div class="empty"><div class="e-title">暂无历史记录</div>该公司在 7 月账期内尚未收到过提醒。</div>';
list.innerHTML = '<div class="empty"><div class="e-title">暂无历史记录</div>该公司尚未收到过提醒。</div>';
} else {
list.innerHTML = items.map((it) => {
return '<div class="list-row">' +
'<span class="tag">' + it.type + '</span>' +
'<div class="lr-main"><div class="lr-title">' + it.body + '</div><div class="lr-sub"><span class="meta">' + it.time + ' · ' + it.src + '</span></div></div>' +
'<div class="lr-side"><span class="pill pill-' + it.status + '">' + it.statusText + '</span></div></div>';
'<span class="tag">' + esc(it.kind) + '</span>' +
'<div class="lr-main"><div class="lr-title">' + esc(it.content) + '</div><div class="lr-sub"><span class="meta">' + fmtReminderTime(it.created_at) + ' · ' + (it.source === "manual" ? "人工" : "系统") + (it.actor_username ? " · " + esc(it.actor_username) : "") + '</span></div></div>' +
'<div class="lr-side"><span class="pill pill-danger">未读</span></div></div>';
}).join("");
}
openModal("history-modal");
@@ -1421,7 +1523,8 @@ function initAdmin() {
$("#history-close")?.addEventListener("click", () => closeModal("history-modal"));
$("#history-ok")?.addEventListener("click", () => closeModal("history-modal"));
refreshReminderCounts();
loadSystemSettings();
loadReminderHistory();
}
const FLOW_DEMO = [
@@ -2268,14 +2371,14 @@ function initCompany() {
remark: String(data.get("remark")).trim(),
bankReference: String(data.get("bankReference") || "").trim(),
evidenceName: evidence instanceof File ? evidence.name : "",
status: "待总账复核",
status: "待管理复核",
createdAt: new Date().toLocaleString("zh-CN", { hour12: false }),
};
records.push(record);
if (!writeStoredRecords(storageKeys.manual, records)) return;
renderCompanyManualRecords();
form.reset();
showToast("手工记录已提交", "总账复核前不会纳入公司间往来计算", "success");
showToast("手工记录已提交", "管理员复核前不会纳入公司间往来计算", "success");
});
let manualWithdrawRow = null;
+21 -21
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="金牛集团公司业务端" />
<title>公司业务端 · 金牛集团</title>
<link rel="stylesheet" href="design-system.css?v=4" />
<link rel="stylesheet" href="design-system.css?v=5" />
</head>
<body data-portal="company">
<a class="skip-link" href="#main-content">跳到主要内容</a>
@@ -83,7 +83,7 @@
<div class="fs-meta" id="workspaceConfirmMeta">单边流水 3 笔 · 科目确认 2 笔</div>
</a>
<div class="flow-step wait">
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">总账复核</span></div>
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
<div class="fs-state">等待集团</div>
<div class="fs-meta">2 笔复核在途 · 无需本公司操作</div>
</div>
@@ -174,7 +174,7 @@
<div class="stack">
<div class="card">
<div class="card-head">
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与集团总账口径一致 · 单位:万元</span></span>
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与管理端口径一致 · 单位:万元</span></span>
</div>
<div class="mini-stats">
<div class="mini-stat">
@@ -199,7 +199,7 @@
<div class="card">
<div class="card-head">
<span class="card-title">最新通知<span class="sub">来自集团总账</span></span>
<span class="card-title">最新通知<span class="sub">来自管理端</span></span>
<button class="btn btn-sm btn-ghost" data-view-link="notifications">全部通知 (3) →</button>
</div>
<div class="list-row">
@@ -325,7 +325,7 @@
<div class="page-head">
<div>
<h1>手工记录</h1>
<p class="page-sub">登记未通过银行流水体现的往来业务,如个人过账、现金收付与未入账回单。提交后由集团总账会计审核,通过后计入本公司往来台账。</p>
<p class="page-sub">登记未通过银行流水体现的往来业务,如个人过账、现金收付与未入账回单。提交后由管理员审核,通过后计入本公司往来台账。</p>
</div>
</div>
@@ -353,15 +353,15 @@
</div>
<div class="notice info" style="margin-top: 14px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
<div><div class="n-title">手工记录与银行原始流水分开留痕</div><div class="n-body">总账复核通过后才纳入往来计算,撤回后需重新登记提交。</div></div>
<div><div class="n-title">手工记录与银行原始流水分开留痕</div><div class="n-body">管理员复核通过后才纳入往来计算,撤回后需重新登记提交。</div></div>
</div>
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 16px;">提交总账复核</button>
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 16px;">提交管理复核</button>
</form>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">已提交记录<span class="sub">账期 2026-07 内本公司提交的手工往来,<span class="num" id="manualPendingStatus">0</span> 笔待总账复核</span></span>
<span class="card-title">已提交记录<span class="sub">账期 2026-07 内本公司提交的手工往来,<span class="num" id="manualPendingStatus">0</span> 笔待管理复核</span></span>
</div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table">
@@ -380,7 +380,7 @@
</div>
<div class="table-foot">
<span id="manualFoot">共 0 条</span>
<span class="meta">审核由集团总账会计在 1 个工作日内完成</span>
<span class="meta">审核由管理员在 1 个工作日内完成</span>
</div>
</div>
</div>
@@ -659,8 +659,8 @@
<div class="notice info" style="margin-bottom: 14px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
<div>
<div class="n-title">新账户需总账管理员审核通过后方可上传流水</div>
<div class="n-body">提交登记后由集团总账管理员核对开户信息,审核周期一般 1 个工作日;审核结果将通过站内通知告知。</div>
<div class="n-title">新账户需管理员审核通过后方可上传流水</div>
<div class="n-body">提交登记后由管理员核对开户信息,审核周期一般 1 个工作日;审核结果将通过站内通知告知。</div>
</div>
</div>
@@ -698,7 +698,7 @@
<div class="page-head">
<div>
<h1>通知</h1>
<p class="page-sub">系统与总账管理员发送给本公司的全部消息。7 月账期结账因故顺延至 2026-08-29,请优先处理未读与处理中事项。</p>
<p class="page-sub">系统与管理员发送给本公司的全部消息。7 月账期结账因故顺延至 2026-08-29,请优先处理未读与处理中事项。</p>
</div>
<div class="page-actions">
<button class="btn btn-ghost" id="mark-all-read">全部标为已读</button>
@@ -734,9 +734,9 @@
</div>
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">总账管理员</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">总账管理员提醒:请于 08-29 前完成待确认事项</div>
<div class="lr-title">管理员提醒:请于 08-29 前完成待确认事项</div>
<div class="lr-sub"><span class="meta">2026-08-18 16:40</span> · 7 月账期结账顺延至 08-29,贵公司当前仍有 5 项往来确认未完成,请合理安排时间。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
@@ -746,13 +746,13 @@
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">手工记录科目待确认:其他应收 ¥86,500.00</div>
<div class="lr-sub"><span class="meta">2026-08-15 10:02</span> · 07-28 录入的矿区备用金垫付记录,往来科目「其他应收」待总账管理员复核。</div>
<div class="lr-sub"><span class="meta">2026-08-15 10:02</span> · 07-28 录入的矿区备用金垫付记录,往来科目「其他应收」待管理员复核。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="manual">去处理</button></div>
</div>
<div class="list-row" data-status="doing">
<span class="pill pill-warn" style="flex: none;">处理中</span>
<span class="tag">总账管理员</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">往来确认回复:置业已确认 320 万煤炭采购款</div>
<div class="lr-sub"><span class="meta">2026-08-12 11:26</span> · 金牛置业已确认 07-14 煤炭采购款 ¥3,200,000.00,请补充本方工行 3305 账户流水证据完成闭环。</div>
@@ -777,10 +777,10 @@
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">总账管理员</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">交行 尾号 7710 账户开户资料已受理</div>
<div class="lr-sub"><span class="meta">2026-07-22 13:10</span> · 新账户开户资料已提交总账管理员,审核通过后方可启用并上传流水。</div>
<div class="lr-sub"><span class="meta">2026-07-22 13:10</span> · 新账户开户资料已提交管理员,审核通过后方可启用并上传流水。</div>
</div>
</div>
<div class="list-row" data-status="done">
@@ -928,7 +928,7 @@
<span class="modal-title">登记银行账户</span>
<button type="button" class="modal-close" data-close="accountDialog" aria-label="关闭">×</button>
</div>
<p class="modal-sub">账户提交后由总账管理员复核,复核通过前不能上传流水,也不参与账户识别与覆盖计算。</p>
<p class="modal-sub">账户提交后由管理员复核,复核通过前不能上传流水,也不参与账户识别与覆盖计算。</p>
<form id="accountForm" novalidate>
<div class="grid grid-2">
<div class="field">
@@ -943,7 +943,7 @@
<div class="field" style="margin-top: 12px;">
<label>银行账号</label>
<input class="input num-input" name="accountNumber" inputmode="numeric" required />
<span class="hint">仅尾号对外显示,完整账号仅总账管理员可见</span>
<span class="hint">仅尾号对外显示,完整账号仅管理员可见</span>
</div>
<div class="field" style="margin-top: 12px;">
<label>启用日期</label>
@@ -957,6 +957,6 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=4"></script>
<script src="app.js?v=5"></script>
</body>
</html>
+27
View File
@@ -459,6 +459,33 @@ p { margin: 0; }
.login-foot { margin-top: 16px; font-size: 12px; color: var(--muted); text-align: center; }
.login-foot a { color: var(--accent); font-weight: 550; }
.login-foot a:hover { text-decoration: underline; }
/* 端标识:登录页顶部大字区分管理端 / 公司端 */
.login-role-title {
font-size: 34px;
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1.15;
margin-bottom: 6px;
}
.login-role-title.admin { color: var(--accent); }
.login-role-title.company { color: var(--info); }
.login-role-caption { color: var(--muted); font-size: 13px; margin-bottom: 22px; }
/* 管理端青绿 / 公司端品蓝侧栏色块 */
.login-aside.company { background: var(--info); }
.login-aside.company .aside-item .tick { color: var(--surface); opacity: 0.85; }
.login-back {
display: inline-flex; align-items: center; gap: 6px;
margin-bottom: 20px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--fg);
font-size: 12.5px; font-weight: 600;
}
.login-back:hover { border-color: var(--accent); color: var(--accent); }
.login-back.company:hover { border-color: var(--info); color: var(--info); }
@media (max-width: 900px) {
.login-wrap { grid-template-columns: 1fr; }
.login-aside { display: none; }
+4 -4
View File
@@ -4,20 +4,20 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>金牛实业资金往来管理系统 · 入口</title>
<link rel="stylesheet" href="design-system.css?v=4" />
<link rel="stylesheet" href="design-system.css?v=5" />
</head>
<body>
<div class="portal-wrap">
<div class="portal-inner">
<p class="meta" style="letter-spacing: 0.1em;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
<h1 style="font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin-top: 10px;">河南金牛实业集团有限公司 · 资金往来管理系统</h1>
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含总账管理端与公司业务端两套界面。</p>
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含管理端与公司业务端两套界面。</p>
<div class="portal-grid">
<a class="portal-card" href="login-admin.html">
<span class="pc-role">总账管理端 · 7 个页面</span>
<span class="pc-role">管理端 · 7 个页面</span>
<h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 提醒管理</h3>
<p>面向集团总账会计:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
<p>面向管理员:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
<span class="pc-go">去管理端登录 →</span>
</a>
<a class="portal-card" href="login-company.html">
+7 -4
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 · 金牛实业资金往来管理系统</title>
<link rel="stylesheet" href="design-system.css?v=4" />
<link rel="stylesheet" href="design-system.css?v=5" />
</head>
<body data-role="admin">
<div class="login-wrap">
@@ -22,8 +22,11 @@
<main class="login-panel">
<div class="login-card">
<a class="login-back" href="index.html">← 返回入口页</a>
<div class="login-role-title admin">管理端</div>
<p class="login-role-caption">管理员统一监控集团各公司资金往来,执行月度结账。</p>
<h2>登录系统</h2>
<p class="login-sub">请输入账号密码登录总账管理端。首次登录需修改初始密码。</p>
<p class="login-sub">请输入账号密码登录管理端。首次登录需修改初始密码。</p>
<form id="login-form" novalidate>
<div class="field">
@@ -42,7 +45,7 @@
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
</form>
<p class="login-foot">忘记密码请联系集团总账管理员重置 · <a href="index.html">返回入口页</a></p>
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
</div>
</main>
</div>
@@ -72,6 +75,6 @@
</div>
</div>
<script src="login.js?v=4"></script>
<script src="login.js?v=5"></script>
</body>
</html>
+7 -4
View File
@@ -4,11 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 · 金牛实业资金往来管理系统</title>
<link rel="stylesheet" href="design-system.css?v=4" />
<link rel="stylesheet" href="design-system.css?v=5" />
</head>
<body data-role="company">
<div class="login-wrap">
<aside class="login-aside">
<aside class="login-aside company">
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
@@ -22,6 +22,9 @@
<main class="login-panel">
<div class="login-card">
<a class="login-back company" href="index.html">← 返回入口页</a>
<div class="login-role-title company">公司端</div>
<p class="login-role-caption">成员公司出纳上传流水、登记手工往来、确认单边匹配。</p>
<h2>登录系统</h2>
<p class="login-sub">请输入账号密码登录公司业务端。首次登录需修改初始密码。</p>
@@ -42,7 +45,7 @@
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
</form>
<p class="login-foot">忘记密码请联系集团总账管理员重置 · <a href="index.html">返回入口页</a></p>
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
</div>
</main>
</div>
@@ -72,6 +75,6 @@
</div>
</div>
<script src="login.js?v=4"></script>
<script src="login.js?v=5"></script>
</body>
</html>