From ece3e53472082aa87c2c0335e6adc96a721b62d5 Mon Sep 17 00:00:00 2001 From: leefer Date: Tue, 25 Aug 2026 18:41:01 +0800 Subject: [PATCH] =?UTF-8?q?HEL-144=20=E8=BF=94=E5=B7=A5A=EF=BC=9A=E5=9B=9B?= =?UTF-8?q?=E4=BB=B6=E4=BA=8B=EF=BC=88=E6=94=B9=E5=90=8D/=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=AB=AF=E6=A0=87=E8=AF=86/=E7=BB=93=E8=B4=A6?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E7=9C=9F=E4=BF=9D=E5=AD=98/=E6=8F=90?= =?UTF-8?q?=E9=86=92=E5=9B=9E=E6=94=B9=EF=BC=89+=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=8D=87=E4=B8=BA=207=20+=20=E7=A7=BB=E9=99=A4=E5=BA=9F?= =?UTF-8?q?=E5=BC=83=20B-44=20=E5=89=8D=E7=AB=AF=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- server.py | 219 ++++++++- src/bank_importer/db.py | 48 ++ src/bank_importer/master_data.py | 2 +- src/bank_importer/settings.py | 309 +++++++++++++ tests/b44_ui_check.js | 86 ---- tests/fixtures/b44-balance-directory.html | 138 ------ tests/test_b44_frontend.py | 109 ----- tests/test_b44_layout.py | 530 ---------------------- tests/test_persistence.py | 13 +- tests/test_settings_reminders.py | 258 +++++++++++ web/admin.html | 62 +-- web/app.js | 319 ++++++++----- web/company.html | 42 +- web/design-system.css | 27 ++ web/index.html | 8 +- web/login-admin.html | 11 +- web/login-company.html | 11 +- 17 files changed, 1142 insertions(+), 1050 deletions(-) create mode 100644 src/bank_importer/settings.py delete mode 100644 tests/b44_ui_check.js delete mode 100644 tests/fixtures/b44-balance-directory.html delete mode 100644 tests/test_b44_frontend.py delete mode 100644 tests/test_b44_layout.py create mode 100644 tests/test_settings_reminders.py 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 @@
共 0 笔 - 数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29) + 数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)
@@ -570,7 +570,7 @@
共 0 家公司 - 登录账号初始密码由集团总账统一发放 + 登录账号初始密码由管理员统一发放
@@ -585,7 +585,7 @@
- 账期时间轴全局起算日 2026-01-01 起,每月 5 日结账 + 账期时间轴全局起算日 2026-01-01 起,每月 5 日结账
2026-01
已结账
@@ -643,7 +643,7 @@
- 期初余额管理2026-01-01 起算的公司间往来期初数 + 期初余额管理2026-01-01 起算的公司间往来期初数
@@ -718,34 +718,22 @@
- 发送处理提醒将以人工提醒形式送达所选公司出纳与财务负责人 + 发送处理提醒选择公司后系统自动列出待提醒事项,核对后一键发送
- -
- + + +
-
- - +
+ +
+
请先选择公司,系统将自动列出该公司待提醒事项。
+
+
-
- - - -
-
- - -
- +
@@ -793,7 +781,7 @@ 新增成员公司
- +
@@ -1050,7 +1038,7 @@
- + + diff --git a/web/design-system.css b/web/design-system.css index a849891..86bfb50 100644 --- a/web/design-system.css +++ b/web/design-system.css @@ -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; } diff --git a/web/index.html b/web/index.html index 6e276ca..ce6ad0f 100644 --- a/web/index.html +++ b/web/index.html @@ -4,20 +4,20 @@ 金牛实业资金往来管理系统 · 入口 - +

JINNIU GROUP · INTERCOMPANY TREASURY

河南金牛实业集团有限公司 · 资金往来管理系统

-

集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含总账管理端与公司业务端两套界面。

+

集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含管理端与公司业务端两套界面。

- + diff --git a/web/login-company.html b/web/login-company.html index 90c48b4..3b213ae 100644 --- a/web/login-company.html +++ b/web/login-company.html @@ -4,11 +4,11 @@ 登录 · 金牛实业资金往来管理系统 - + @@ -72,6 +75,6 @@
- +