diff --git a/server.py b/server.py
index 71110e9..7843de1 100644
--- a/server.py
+++ b/server.py
@@ -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)
# ------------------------------------------------------------------
diff --git a/src/bank_importer/db.py b/src/bank_importer/db.py
index 02775bc..e729171 100644
--- a/src/bank_importer/db.py
+++ b/src/bank_importer/db.py
@@ -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;
+ """,
+ ),
)
diff --git a/src/bank_importer/master_data.py b/src/bank_importer/master_data.py
index 71dee00..60cddbd 100644
--- a/src/bank_importer/master_data.py
+++ b/src/bank_importer/master_data.py
@@ -262,7 +262,7 @@ def submit_bank_account(
if existing["company_id"] == company_id:
raise ConflictError("该银行账号已登记,请等待现有申请处理。")
- raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。")
+ raise ConflictError("该银行账号已被其他公司登记,请联系管理员核对。")
def review_bank_account(
diff --git a/src/bank_importer/settings.py b/src/bank_importer/settings.py
new file mode 100644
index 0000000..8bff033
--- /dev/null
+++ b/src/bank_importer/settings.py
@@ -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
diff --git a/tests/b44_ui_check.js b/tests/b44_ui_check.js
deleted file mode 100644
index b801e9a..0000000
--- a/tests/b44_ui_check.js
+++ /dev/null
@@ -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");
diff --git a/tests/fixtures/b44-balance-directory.html b/tests/fixtures/b44-balance-directory.html
deleted file mode 100644
index 541c33d..0000000
--- a/tests/fixtures/b44-balance-directory.html
+++ /dev/null
@@ -1,138 +0,0 @@
-
-
-
-
-
- B-44 余额目录布局回归
-
-
-
-
-
-
-
-
-
-
- 公司余额目录
每行余额都附带截止日、期初状态、本期借贷、结果与未决金额
- 公司借方合计贷方合计期末结果未决截止日
-
-
-
- 甲甲公司CNY
- CNY1,280.00
- CNY320.00
- 应收CNY960.00
- 未决 0.00
- 截止 2026.07.31
-
-
-
-
-
- 东东南沿海综合贸易与供应链管理股份有限公司CNY
- CNY123,456,789,012,345.00
- CNY100,000,000,000,000.00
- 应收CNY23,456,789,012,345.00
- 未决 2,150.00 · 3 笔手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔
- 截止 2026.07.31
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/test_b44_frontend.py b/tests/test_b44_frontend.py
deleted file mode 100644
index b7aba20..0000000
--- a/tests/test_b44_frontend.py
+++ /dev/null
@@ -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]*?往来查询')
- self.assertIn("往来查询
", self.admin)
- self.assertIn("转为异常后,该记录暂不纳入余额计算", self.admin)
- self.assertIn('id="auditExceptionNote"', self.admin)
-
- def test_company_balance_groups_and_nav(self) -> None:
- self.assertIn('id="companyBalanceGroups"', self.company)
- self.assertNotIn('id="companyBalanceLine"', self.company)
- self.assertRegex(self.company, r'data-view="balances"[^>]*>[\s\S]*?往来余额')
- self.assertIn("往来余额
", self.company)
-
- def test_css_directory_and_drawer_breakpoints(self) -> None:
- self.assertIn("@media (max-width: 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" None:
- result = subprocess.run(
- ["node", str(ROOT / "tests" / "b44_ui_check.js")],
- capture_output=True,
- text=True,
- cwd=str(ROOT),
- )
- self.assertEqual(0, result.returncode, result.stdout + result.stderr)
- self.assertIn("b44_ui_check ok", result.stdout)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_b44_layout.py b/tests/test_b44_layout.py
deleted file mode 100644
index 4323a66..0000000
--- a/tests/test_b44_layout.py
+++ /dev/null
@@ -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()
-
diff --git a/tests/test_persistence.py b/tests/test_persistence.py
index 27b7c26..b6b38eb 100644
--- a/tests/test_persistence.py
+++ b/tests/test_persistence.py
@@ -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,
diff --git a/tests/test_settings_reminders.py b/tests/test_settings_reminders.py
new file mode 100644
index 0000000..6e20694
--- /dev/null
+++ b/tests/test_settings_reminders.py
@@ -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()
diff --git a/web/admin.html b/web/admin.html
index 1aa72b4..0204cd6 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -3,18 +3,18 @@
-
- 总账管理端 · 金牛集团
-
+
+ 管理端 · 金牛集团
+
跳到主要内容
-
@@ -41,7 +41,7 @@
- 总账管理端 / 管理总览
+ 管理端 / 管理总览
账期 2026-07
统计截止 2026-08-20
@@ -245,7 +245,7 @@
- 账期与结账每月 5 日结账 · 全局起算日 2026-01-01
+ 账期与结账每月 5 日结账 · 全局起算日 2026-01-01
- 当前账期
- 2026-07 · 进行中
@@ -524,7 +524,7 @@
@@ -570,7 +570,7 @@
@@ -585,7 +585,7 @@
- 账期时间轴全局起算日 2026-01-01 起,每月 5 日结账
+ 账期时间轴全局起算日 2026-01-01 起,每月 5 日结账
@@ -643,7 +643,7 @@
- 期初余额管理2026-01-01 起算的公司间往来期初数
+ 期初余额管理2026-01-01 起算的公司间往来期初数
@@ -718,34 +718,22 @@
- 发送处理提醒将以人工提醒形式送达所选公司出纳与财务负责人
+ 发送处理提醒选择公司后系统自动列出待提醒事项,核对后一键发送
- 登记后系统将生成公司端登录账号,初始密码由集团总账统一发放。
+ 登记后系统将生成公司端登录账号,初始密码由管理员统一发放。
-
+
+
|