Compare commits

..
Author SHA1 Message Date
0fa32e1571 HEL-204: 修复弹窗作用域、断档首屏与期初表单 reset
将 openModal/closeModal 提升为顶层定义;公司端启动调用 loadCompanyCoverageGaps;异步提交前保存 form 引用。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:25:21 +00:00
25 changed files with 723 additions and 3229 deletions
-1
View File
@@ -1,7 +1,6 @@
__pycache__/
*.py[cod]
.venv/
.chrome-libs/
.tmp-*/
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
-2
View File
@@ -1,5 +1,3 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。当前代码状态请看 `README.md``项目需求.md``最新进度.md``任务清单.md`
# 项目交接说明
更新时间:2026-08-06
@@ -1,5 +1,3 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P0] 仓库银行样本数据分级与脱敏治理
## 背景
@@ -1,5 +1,3 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P0] 持久化层与不可变银行导入基础
## 背景
@@ -1,5 +1,3 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P0] 正式认证、RBAC 与公司级数据隔离
## 背景
@@ -1,5 +1,3 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P1] 动态公司、用户、账户和别名主数据
## 背景
@@ -1,5 +1,3 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P1] 银行导入 API 加固与回归测试
## 背景
@@ -1,5 +1,3 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P1] 规范转账事件、双边匹配与调拨排除
## 背景
+159 -342
View File
@@ -6,9 +6,6 @@ import json
import os
from pathlib import Path
import re
import threading
import time
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
@@ -16,7 +13,7 @@ from urllib.parse import parse_qs, urlparse
from bank_importer import (
auth, calculation, company_transfers, dashboard, importing, ledger_events,
manual_records, master_data, matching, multipart, personal_transit, positions,
reminders, settings, subjects,
settings, subjects,
)
from bank_importer.db import connect, migrate, utc_now
@@ -111,6 +108,12 @@ class AppHandler(SimpleHTTPRequestHandler):
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
@@ -144,26 +147,6 @@ class AppHandler(SimpleHTTPRequestHandler):
if company_event_match:
self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
return
if path == "/api/admin/reminders/pending":
self._handle_admin_reminders_pending()
return
if path == "/api/admin/reminders":
self._handle_admin_reminders(query)
return
if path == "/api/admin/reminder-settings":
self._handle_admin_reminder_settings_get()
return
admin_reminder_match = re.fullmatch(r"/api/admin/reminders/(\d+)", path)
if admin_reminder_match:
self._handle_admin_reminder_detail(int(admin_reminder_match.group(1)))
return
if path == "/api/company/reminders":
self._handle_company_reminders()
return
company_reminder_match = re.fullmatch(r"/api/company/reminders/(\d+)", path)
if company_reminder_match:
self._handle_company_reminder_detail(int(company_reminder_match.group(1)))
return
# B-44 intercompany positions (admin)
if path == "/api/admin/intercompany/balances":
@@ -339,6 +322,9 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/company/no-business-attestations":
self._handle_company_submit_attestation()
return
if path == "/api/admin/reminders/send":
self._handle_admin_send_reminders()
return
# B-44 intercompany positions (admin writes)
subject_decision = re.fullmatch(
@@ -368,30 +354,6 @@ class AppHandler(SimpleHTTPRequestHandler):
if company_confirm:
self._handle_company_transfer_confirm(int(company_confirm.group(1)))
return
if path == "/api/admin/reminders/scan":
self._handle_admin_reminders_scan()
return
if path == "/api/admin/reminders/send":
self._handle_admin_reminders_send()
return
if path == "/api/admin/reminders/manual":
self._handle_admin_reminders_manual()
return
admin_resend_match = re.fullmatch(r"/api/admin/reminders/(\d+)/resend", path)
if admin_resend_match:
self._handle_admin_reminder_resend(int(admin_resend_match.group(1)))
return
if path == "/api/admin/reminder-settings":
self._handle_admin_reminder_settings_put()
return
company_status_match = re.fullmatch(
r"/api/company/reminders/(\d+)/(acknowledge|resolve)", path
)
if company_status_match:
self._handle_company_reminder_status(
int(company_status_match.group(1)), company_status_match.group(2)
)
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
def do_PUT(self) -> None:
@@ -1959,6 +1921,155 @@ class AppHandler(SimpleHTTPRequestHandler):
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)
# ------------------------------------------------------------------
@@ -3675,260 +3786,6 @@ class AppHandler(SimpleHTTPRequestHandler):
return None
return data
# ------------------------------------------------------------------
# Reminders
# ------------------------------------------------------------------
def _handle_admin_reminders_pending(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
findings = reminders.scan_findings(connection)
items = [reminders.finding_to_dict(item) for item in findings]
companies = len({item["company_id"] for item in items})
self._send_json(
200,
{
"status": "ok",
"findings": items,
"summary": {"companies": companies, "items": len(items)},
},
)
finally:
connection.close()
def _handle_admin_reminders_scan(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
result = reminders.run_scan(connection, actor=user, ip=self._client_ip)
items = [reminders.finding_to_dict(item) for item in result["findings"]]
companies = len({item["company_id"] for item in items})
self._send_json(
200,
{
"status": "ok",
"findings": items,
"counts": result["counts"],
"summary": {"companies": companies, "items": len(items)},
},
)
finally:
connection.close()
def _handle_admin_reminders_send(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
raw_keys = data.get("dedupe_keys")
if not isinstance(raw_keys, list) or not raw_keys:
self._send_json(400, {"status": "error", "message": "请指定 dedupe_keys。"})
return
dedupe_keys = [str(key) for key in raw_keys]
sent = reminders.deliver_many(
connection, dedupe_keys, actor=user, ip=self._client_ip
)
self._send_json(200, {"status": "ok", "sent": sent, "count": len(sent)})
finally:
connection.close()
def _handle_admin_reminders_manual(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(data.get("company_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "company_id 无效。"})
return
display_type = str(data.get("display_type") or data.get("type") or "").strip()
content = str(data.get("content") or "").strip()
deadline = str(data.get("deadline") or "").strip() or None
if not display_type or not content:
self._send_json(400, {"status": "error", "message": "类型与内容不能为空。"})
return
try:
reminder_id = reminders.send_manual(
connection,
company_id=company_id,
display_type=display_type,
content=content,
deadline=deadline,
actor=user,
ip=self._client_ip,
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "reminder_id": reminder_id})
finally:
connection.close()
def _handle_admin_reminder_resend(self, reminder_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
try:
reminders.resend_reminder(
connection, reminder_id, actor=user, ip=self._client_ip
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok"})
finally:
connection.close()
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
source = (query.get("source") or [None])[0]
if source not in {None, "auto", "manual", "system"}:
self._send_json(400, {"status": "error", "message": "source 参数无效。"})
return
filter_source = "auto" if source == "system" else source
items = reminders.list_admin_reminders(connection, source=filter_source)
stats = {"unread": 0, "doing": 0, "done": 0}
for item in items:
stats[item["status_ui"]] += 1
self._send_json(200, {"status": "ok", "reminders": items, "stats": stats})
finally:
connection.close()
def _handle_admin_reminder_detail(self, reminder_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
detail = reminders.get_reminder_detail(connection, reminder_id)
if detail is None:
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
return
self._send_json(200, {"status": "ok", "reminder": detail})
finally:
connection.close()
def _handle_admin_reminder_settings_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
self._send_json(200, {"status": "ok", "settings": reminders.get_settings(connection)})
finally:
connection.close()
def _handle_admin_reminder_settings_put(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
updates = data.get("settings") if isinstance(data.get("settings"), dict) else data
if not isinstance(updates, dict):
self._send_json(400, {"status": "error", "message": "settings 格式无效。"})
return
try:
settings = reminders.update_settings(connection, updates)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
auth.audit(
connection,
"reminder_settings_update",
actor=user,
detail=json.dumps(settings, ensure_ascii=False),
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "settings": settings})
finally:
connection.close()
def _handle_company_reminders(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
items = reminders.list_company_reminders(connection, int(user["company_id"]))
unread = reminders.company_unread_count(connection, int(user["company_id"]))
stats = {"unread": 0, "doing": 0, "done": 0}
for item in items:
stats[item["status_ui"]] += 1
self._send_json(
200,
{"status": "ok", "reminders": items, "unread_count": unread, "stats": stats},
)
finally:
connection.close()
def _handle_company_reminder_detail(self, reminder_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
detail = reminders.get_reminder_detail(connection, reminder_id)
if detail is None or int(detail["company_id"]) != int(user["company_id"]):
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
return
self._send_json(200, {"status": "ok", "reminder": detail})
finally:
connection.close()
def _handle_company_reminder_status(self, reminder_id: int, action: str) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
new_status = "acknowledged" if action == "acknowledge" else "resolved"
ok = reminders.update_reminder_status(
connection,
reminder_id,
new_status,
company_id=int(user["company_id"]),
actor=user,
)
if not ok:
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
return
self._send_json(200, {"status": "ok"})
finally:
connection.close()
@staticmethod
def _sheet_payload(row) -> dict[str, object]:
"""Serialize one persisted per-sheet review record."""
@@ -4001,44 +3858,6 @@ class AppHandler(SimpleHTTPRequestHandler):
self.wfile.write(content)
def _parse_scan_time(value: str) -> tuple[int, int]:
parts = value.strip().split(":")
if len(parts) != 2:
return 8, 0
try:
return int(parts[0]), int(parts[1])
except ValueError:
return 8, 0
def _seconds_until_scan(scan_time: str) -> float:
hour, minute = _parse_scan_time(scan_time)
now = datetime.now(timezone.utc)
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return (target - now).total_seconds()
def _scheduled_reminder_scan_loop() -> None:
"""Daily scan thread; audit-only, does not auto-deliver to companies."""
while True:
connection = connect(DB_PATH)
try:
settings = reminders.get_settings(connection)
scan_time = settings.get("scan_time", "08:00")
finally:
connection.close()
time.sleep(max(1.0, _seconds_until_scan(scan_time)))
connection = connect(DB_PATH)
try:
reminders.run_scan(connection, actor=None, ip=None)
except Exception:
pass
finally:
connection.close()
def ensure_bootstrap_admin(connection) -> str | None:
"""Create the first admin when none exists; returns the generated password."""
existing = connection.execute(
@@ -4067,8 +3886,6 @@ def main() -> None:
# Printed once to stdout; never written to any log file.
print(f"Bootstrap admin initial password (shown once): {initial_password}")
server = ThreadingHTTPServer((host, port), AppHandler)
scan_thread = threading.Thread(target=_scheduled_reminder_scan_loop, daemon=True)
scan_thread.start()
print(f"Serving on http://{host}:{port}")
server.serve_forever()
+2 -4
View File
@@ -840,14 +840,12 @@ def _pair_net_change(
""",
params,
).fetchall()
# 站在本公司视角:正数=应收方向。转出/垫付(payer)使应收增加。
# 与 company_transfers.net = outflow inflow 及页面文案口径一致。
total = Decimal("0")
for row in rows:
amount = _parse_decimal(row["amount"])
if row["payer_id"] == viewer_id:
if row["payee_id"] == viewer_id:
total += amount
elif row["payee_id"] == viewer_id:
elif row["payer_id"] == viewer_id:
total -= amount
return total
-85
View File
@@ -1011,91 +1011,6 @@ MIGRATIONS: tuple[Migration, ...] = (
DROP TABLE IF EXISTS closed_periods;
""",
),
Migration(
version=9,
name="0009_reminders_engine",
# HEL-195/215 auto-reminder engine. The old manual reminders table
# from 0007 is preserved verbatim under reminders_legacy_manual so
# existing test-env history stays queryable; the new engine uses its
# own append-only reminders/reminder_events schema.
up="""
ALTER TABLE reminders RENAME TO reminders_legacy_manual;
DROP INDEX IF EXISTS idx_reminders_company;
CREATE INDEX idx_reminders_legacy_company
ON reminders_legacy_manual (company_id);
CREATE TABLE reminder_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO reminder_settings (key, value, updated_at) VALUES
('monthly_start_day', '5', datetime('now')),
('gap_days', '5', datetime('now')),
('scan_time', '08:00', datetime('now'));
CREATE TABLE reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
rule_key TEXT NOT NULL CHECK (rule_key IN (
'unsubmitted', 'gap', 'pending_review', 'manual'
)),
dedupe_key TEXT NOT NULL UNIQUE,
rule_params TEXT NOT NULL DEFAULT '{}',
title TEXT NOT NULL,
content TEXT NOT NULL,
deadline TEXT,
source TEXT NOT NULL CHECK (source IN ('auto', 'manual')),
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'acknowledged', 'resolved')),
send_count INTEGER NOT NULL DEFAULT 0,
first_sent_at TEXT,
last_sent_at TEXT,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
action_link TEXT
);
CREATE INDEX idx_reminders_company ON reminders (company_id);
CREATE INDEX idx_reminders_status ON reminders (status);
CREATE TABLE reminder_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders (id),
event_type TEXT NOT NULL CHECK (event_type IN (
'sent', 'acknowledged', 'resolved', 'escalated'
)),
actor TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_reminder_events_reminder ON reminder_events (reminder_id);
CREATE TRIGGER reminder_events_no_update BEFORE UPDATE ON reminder_events
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
CREATE TRIGGER reminder_events_no_delete BEFORE DELETE ON reminder_events
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
CREATE TRIGGER reminders_no_delete BEFORE DELETE ON reminders
BEGIN SELECT RAISE (ABORT, 'reminders rows are immutable history'); END;
""",
down="""
DROP TRIGGER IF EXISTS reminders_no_delete;
DROP TRIGGER IF EXISTS reminder_events_no_delete;
DROP TRIGGER IF EXISTS reminder_events_no_update;
DROP INDEX IF EXISTS idx_reminder_events_reminder;
DROP TABLE IF EXISTS reminder_events;
DROP INDEX IF EXISTS idx_reminders_status;
DROP INDEX IF EXISTS idx_reminders_company;
DROP TABLE IF EXISTS reminders;
DROP TABLE IF EXISTS reminder_settings;
DROP INDEX IF EXISTS idx_reminders_legacy_company;
ALTER TABLE reminders_legacy_manual RENAME TO reminders;
CREATE INDEX idx_reminders_company ON reminders (company_id);
""",
),
)
-757
View File
@@ -1,757 +0,0 @@
"""Read-only reminder rule engine and delivery helpers.
Scans existing tables to surface pending items; delivery writes append-only
``reminders`` / ``reminder_events`` rows without touching bank or business data.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
import json
import sqlite3
from typing import Any
import uuid
from bank_importer import auth
from bank_importer.db import utc_now
RULE_UNSUBMITTED = "unsubmitted"
RULE_GAP = "gap"
RULE_PENDING = "pending_review"
RULE_MANUAL = "manual"
SETTING_MONTHLY_START_DAY = "monthly_start_day"
SETTING_GAP_DAYS = "gap_days"
SETTING_SCAN_TIME = "scan_time"
DEFAULT_SETTINGS: dict[str, str] = {
SETTING_MONTHLY_START_DAY: "5",
SETTING_GAP_DAYS: "5",
SETTING_SCAN_TIME: "08:00",
}
RULE_LABELS = {
RULE_UNSUBMITTED: "流水未提交",
RULE_GAP: "流水断档",
RULE_PENDING: "待确认/待审核",
}
ACTION_LINKS = {
RULE_UNSUBMITTED: "upload",
RULE_GAP: "flows",
RULE_PENDING: "reconcile",
}
@dataclass(frozen=True)
class ScanFinding:
company_id: int
company_name: str
rule_key: str
dedupe_key: str
rule_params: dict[str, Any]
title: str
content: str
reason: str
days_open: int
deadline: str | None
action_link: str
existing_reminder_id: int | None
send_count: int
def _today() -> date:
return datetime.now(timezone.utc).date()
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
rows = connection.execute("SELECT key, value FROM reminder_settings").fetchall()
settings = dict(DEFAULT_SETTINGS)
for row in rows:
settings[row["key"]] = row["value"]
return settings
def _valid_scan_time(value: str) -> bool:
parts = value.strip().split(":")
if len(parts) != 2 or not parts[0].isdigit() or not parts[1].isdigit():
return False
if len(parts[1]) != 2:
return False
hour, minute = int(parts[0]), int(parts[1])
return 0 <= hour <= 23 and 0 <= minute <= 59
def validate_setting(key: str, value: str) -> str:
raw = str(value).strip()
if key == SETTING_MONTHLY_START_DAY:
try:
day = int(raw)
except ValueError as exc:
raise ValueError("每月起始日须为 1 到 28 的整数。") from exc
if day < 1 or day > 28:
raise ValueError("每月起始日须为 1 到 28 的整数。")
return str(day)
if key == SETTING_GAP_DAYS:
try:
days = int(raw)
except ValueError as exc:
raise ValueError("断档天数须为大于等于 1 的整数。") from exc
if days < 1:
raise ValueError("断档天数须为大于等于 1 的整数。")
return str(days)
if key == SETTING_SCAN_TIME:
if not _valid_scan_time(raw):
raise ValueError("扫描时间须为合法的 HH:MM。")
hour, minute = raw.split(":")
return f"{int(hour):02d}:{minute}"
raise ValueError(f"unknown setting: {key}")
def update_settings(connection: sqlite3.Connection, updates: dict[str, str]) -> dict[str, str]:
allowed = set(DEFAULT_SETTINGS)
cleaned: dict[str, str] = {}
for key, value in updates.items():
if key not in allowed:
raise ValueError(f"unknown setting: {key}")
cleaned[key] = validate_setting(key, value)
now = utc_now()
with connection:
for key, value in cleaned.items():
connection.execute(
"""
INSERT INTO reminder_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
""",
(key, value, now),
)
return get_settings(connection)
def _setting_int(settings: dict[str, str], key: str) -> int:
try:
return int(validate_setting(key, settings.get(key, DEFAULT_SETTINGS[key])))
except (TypeError, ValueError):
return int(DEFAULT_SETTINGS[key])
def _month_period(day: date | None = None) -> str:
ref = day or _today()
return f"{ref.year:04d}-{ref.month:02d}"
def _parse_date(value: str | None) -> date | None:
if not value:
return None
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
def _days_between(start: date, end: date) -> int:
return max(0, (end - start).days)
def _duration_pill_class(days: int) -> str:
if days >= 7:
return "pill-danger"
if days >= 3:
return "pill-warn"
return "pill-muted"
def _existing_reminder(connection: sqlite3.Connection, dedupe_key: str) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT id, send_count, status, last_sent_at
FROM reminders
WHERE dedupe_key = ? AND status != 'resolved'
""",
(dedupe_key,),
).fetchone()
def _company_has_month_batch(connection: sqlite3.Connection, company_id: int, period: str) -> bool:
year, month = period.split("-")
prefix = f"{year}-{month}"
row = connection.execute(
"""
SELECT 1
FROM import_batches b
JOIN sheet_batches s ON s.import_batch_id = b.id
WHERE b.company_id = ?
AND (
substr(s.period_start, 1, 7) = ?
OR substr(s.period_end, 1, 7) = ?
OR (s.period_start <= ? || '-31' AND s.period_end >= ? || '-01')
)
LIMIT 1
""",
(company_id, prefix, prefix, prefix, prefix),
).fetchone()
return row is not None
def _latest_batch_end(connection: sqlite3.Connection, company_id: int) -> date | None:
row = connection.execute(
"""
SELECT MAX(s.period_end) AS latest_end
FROM import_batches b
JOIN sheet_batches s ON s.import_batch_id = b.id
WHERE b.company_id = ?
""",
(company_id,),
).fetchone()
return _parse_date(row["latest_end"] if row else None)
def _pending_review_count(connection: sqlite3.Connection, company_id: int) -> int:
match_count = connection.execute(
"""
SELECT COUNT(DISTINCT e.id)
FROM canonical_transfer_events e
JOIN current_transfer_decisions c ON c.event_id = e.id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active'
AND d.classification IN ('unresolved', 'needs_review')
AND (payer.company_id = ? OR payee.company_id = ?)
""",
(company_id, company_id),
).fetchone()[0]
sheet_count = connection.execute(
"""
SELECT COUNT(*)
FROM sheet_reviews r
JOIN import_batches b ON b.id = r.import_batch_id
WHERE b.company_id = ?
AND r.outcome = 'parsed'
AND r.review_status = 'pending'
""",
(company_id,),
).fetchone()[0]
account_count = connection.execute(
"""
SELECT COUNT(*) FROM bank_accounts
WHERE company_id = ? AND status = 'pending'
""",
(company_id,),
).fetchone()[0]
return int(match_count) + int(sheet_count) + int(account_count)
def _active_companies(connection: sqlite3.Connection) -> list[sqlite3.Row]:
return connection.execute(
"""
SELECT id, name FROM companies
WHERE COALESCE(status, 'active') != 'disabled'
ORDER BY name
"""
).fetchall()
def scan_findings(connection: sqlite3.Connection) -> list[ScanFinding]:
settings = get_settings(connection)
today = _today()
period = _month_period(today)
monthly_start = _setting_int(settings, SETTING_MONTHLY_START_DAY)
gap_days = _setting_int(settings, SETTING_GAP_DAYS)
findings: list[ScanFinding] = []
for company in _active_companies(connection):
company_id = int(company["id"])
company_name = company["name"]
if today.day >= monthly_start and not _company_has_month_batch(connection, company_id, period):
dedupe_key = f"{company_id}:{RULE_UNSUBMITTED}:{period}"
days_open = _days_between(date(today.year, today.month, monthly_start), today)
existing = _existing_reminder(connection, dedupe_key)
findings.append(
ScanFinding(
company_id=company_id,
company_name=company_name,
rule_key=RULE_UNSUBMITTED,
dedupe_key=dedupe_key,
rule_params={"period": period},
title=f"{company_name} · {today.month} 月流水未提交",
content=(
f"贵公司 {period} 银行流水尚未提交。"
f"请于截止日前完成全部账户流水上传。"
),
reason=f"应交 {today.month:02d}-{monthly_start:02d} · 已逾期 {days_open}",
days_open=days_open,
deadline=None,
action_link=ACTION_LINKS[RULE_UNSUBMITTED],
existing_reminder_id=int(existing["id"]) if existing else None,
send_count=int(existing["send_count"]) if existing else 0,
)
)
latest_end = _latest_batch_end(connection, company_id)
if latest_end is not None:
gap = _days_between(latest_end, today)
if gap > gap_days:
dedupe_key = f"{company_id}:{RULE_GAP}:{latest_end.isoformat()}"
existing = _existing_reminder(connection, dedupe_key)
findings.append(
ScanFinding(
company_id=company_id,
company_name=company_name,
rule_key=RULE_GAP,
dedupe_key=dedupe_key,
rule_params={"latest_end": latest_end.isoformat(), "gap_days": gap},
title=f"{company_name} · 流水断档 {gap}",
content=(
f"最近流水截止日为 {latest_end.isoformat()}"
f"已连续 {gap} 天无新数据,请补传断档期间银行流水。"
),
reason=f"最近截止 {latest_end.strftime('%m-%d')} · 断档 {gap}",
days_open=gap,
deadline=None,
action_link=ACTION_LINKS[RULE_GAP],
existing_reminder_id=int(existing["id"]) if existing else None,
send_count=int(existing["send_count"]) if existing else 0,
)
)
pending_count = _pending_review_count(connection, company_id)
if pending_count > 0:
dedupe_key = f"{company_id}:{RULE_PENDING}:active"
existing = _existing_reminder(connection, dedupe_key)
first_seen = _parse_date(existing["last_sent_at"][:10] if existing and existing["last_sent_at"] else None)
days_open = _days_between(first_seen, today) if first_seen else 0
findings.append(
ScanFinding(
company_id=company_id,
company_name=company_name,
rule_key=RULE_PENDING,
dedupe_key=dedupe_key,
rule_params={"pending_count": pending_count},
title=f"{company_name} · {pending_count} 项待确认/待审核",
content=(
f"贵公司当前有 {pending_count} 项往来确认、流水审核或账户登记待处理,"
f"请尽快完成确认以免影响结账。"
),
reason=f"待处理 {pending_count}",
days_open=days_open,
deadline=None,
action_link=ACTION_LINKS[RULE_PENDING],
existing_reminder_id=int(existing["id"]) if existing else None,
send_count=int(existing["send_count"]) if existing else 0,
)
)
findings.sort(key=lambda item: item.days_open, reverse=True)
return findings
def run_scan(
connection: sqlite3.Connection,
*,
actor: sqlite3.Row | None = None,
ip: str | None = None,
) -> dict[str, Any]:
findings = scan_findings(connection)
counts = {
RULE_UNSUBMITTED: sum(1 for f in findings if f.rule_key == RULE_UNSUBMITTED),
RULE_GAP: sum(1 for f in findings if f.rule_key == RULE_GAP),
RULE_PENDING: sum(1 for f in findings if f.rule_key == RULE_PENDING),
}
auth.audit(
connection,
"reminder_scan",
actor=actor,
detail=json.dumps({"total": len(findings), "by_rule": counts}, ensure_ascii=False),
ip=ip,
)
return {"findings": findings, "counts": counts}
def finding_to_dict(finding: ScanFinding) -> dict[str, Any]:
return {
"company_id": finding.company_id,
"company_name": finding.company_name,
"rule_key": finding.rule_key,
"rule_label": RULE_LABELS.get(finding.rule_key, finding.rule_key),
"dedupe_key": finding.dedupe_key,
"rule_params": finding.rule_params,
"title": finding.title,
"content": finding.content,
"reason": finding.reason,
"days_open": finding.days_open,
"duration_pill": _duration_pill_class(finding.days_open),
"deadline": finding.deadline,
"action_link": finding.action_link,
"existing_reminder_id": finding.existing_reminder_id,
"send_count": finding.send_count,
}
def _append_event(
connection: sqlite3.Connection,
reminder_id: int,
event_type: str,
actor: str,
detail: str | None = None,
) -> None:
connection.execute(
"""
INSERT INTO reminder_events (reminder_id, event_type, actor, detail, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(reminder_id, event_type, actor, detail, utc_now()),
)
def _actor_label(actor: sqlite3.Row | None) -> str:
if actor is None:
return "system"
return str(actor["id"])
def deliver_finding(
connection: sqlite3.Connection,
dedupe_key: str,
*,
actor: sqlite3.Row | None = None,
ip: str | None = None,
) -> int | None:
findings = {item.dedupe_key: item for item in scan_findings(connection)}
finding = findings.get(dedupe_key)
if finding is None:
return None
return _deliver(connection, finding, actor=actor, ip=ip)
def _deliver(
connection: sqlite3.Connection,
finding: ScanFinding,
*,
actor: sqlite3.Row | None = None,
ip: str | None = None,
) -> int:
now = utc_now()
actor_ref = _actor_label(actor)
existing = connection.execute(
"SELECT id, send_count, status FROM reminders WHERE dedupe_key = ?",
(finding.dedupe_key,),
).fetchone()
with connection:
if existing is not None:
reminder_id = int(existing["id"])
send_count = int(existing["send_count"]) + 1
connection.execute(
"""
UPDATE reminders
SET send_count = ?, last_sent_at = ?, status = 'open',
title = ?, content = ?, rule_params = ?, action_link = ?
WHERE id = ?
""",
(
send_count,
now,
finding.title,
finding.content,
json.dumps(finding.rule_params, ensure_ascii=False),
finding.action_link,
reminder_id,
),
)
event_type = "sent" if existing["status"] == "resolved" else (
"escalated" if send_count > 1 else "sent"
)
_append_event(
connection,
reminder_id,
event_type,
actor_ref,
f"{send_count} 次催办",
)
else:
connection.execute(
"""
INSERT INTO reminders (
company_id, rule_key, dedupe_key, rule_params, title, content,
deadline, source, status, send_count, first_sent_at, last_sent_at,
created_by, created_at, action_link
) VALUES (?, ?, ?, ?, ?, ?, ?, 'auto', 'open', 1, ?, ?, ?, ?, ?)
""",
(
finding.company_id,
finding.rule_key,
finding.dedupe_key,
json.dumps(finding.rule_params, ensure_ascii=False),
finding.title,
finding.content,
finding.deadline,
now,
now,
actor_ref,
now,
finding.action_link,
),
)
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
_append_event(connection, reminder_id, "sent", actor_ref, finding.reason)
auth.audit(
connection,
"reminder_send",
actor=actor,
target=f"reminder:{reminder_id}",
detail=finding.dedupe_key,
ip=ip,
)
return reminder_id
def deliver_many(
connection: sqlite3.Connection,
dedupe_keys: list[str],
*,
actor: sqlite3.Row | None = None,
ip: str | None = None,
) -> list[int]:
sent: list[int] = []
for key in dedupe_keys:
try:
reminder_id = deliver_finding(connection, key, actor=actor, ip=ip)
except Exception:
continue
if reminder_id is not None:
sent.append(reminder_id)
return sent
def send_manual(
connection: sqlite3.Connection,
*,
company_id: int,
display_type: str,
content: str,
deadline: str | None,
actor: sqlite3.Row,
ip: str | None = None,
) -> int:
company = connection.execute(
"SELECT name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
raise ValueError("company not found")
now = utc_now()
dedupe_key = f"{company_id}:{RULE_MANUAL}:{uuid.uuid4().hex}"
title = f"{company['name']} · {display_type}"
with connection:
connection.execute(
"""
INSERT INTO reminders (
company_id, rule_key, dedupe_key, rule_params, title, content,
deadline, source, status, send_count, first_sent_at, last_sent_at,
created_by, created_at, action_link
) VALUES (?, ?, ?, ?, ?, ?, ?, 'manual', 'open', 1, ?, ?, ?, ?, NULL)
""",
(
company_id,
RULE_MANUAL,
dedupe_key,
json.dumps({"display_type": display_type}, ensure_ascii=False),
title,
content,
deadline,
now,
now,
str(actor["id"]),
now,
),
)
reminder_id = int(connection.execute("SELECT last_insert_rowid()").fetchone()[0])
_append_event(connection, reminder_id, "sent", str(actor["id"]), display_type)
auth.audit(
connection,
"reminder_send_manual",
actor=actor,
target=f"reminder:{reminder_id}",
detail=display_type,
ip=ip,
)
return reminder_id
def resend_reminder(
connection: sqlite3.Connection,
reminder_id: int,
*,
actor: sqlite3.Row,
ip: str | None = None,
) -> None:
row = connection.execute(
"SELECT id, send_count, status FROM reminders WHERE id = ?", (reminder_id,)
).fetchone()
if row is None or row["status"] == "resolved":
raise ValueError("reminder not available")
now = utc_now()
send_count = int(row["send_count"]) + 1
with connection:
connection.execute(
"""
UPDATE reminders
SET send_count = ?, last_sent_at = ?, status = 'open'
WHERE id = ?
""",
(send_count, now, reminder_id),
)
_append_event(
connection,
reminder_id,
"escalated",
str(actor["id"]),
f"{send_count} 次催办",
)
auth.audit(
connection,
"reminder_resend",
actor=actor,
target=f"reminder:{reminder_id}",
ip=ip,
)
def _reminder_row_to_dict(row: sqlite3.Row, *, company_name: str | None = None) -> dict[str, Any]:
params = json.loads(row["rule_params"]) if row["rule_params"] else {}
rule_key = row["rule_key"]
display_type = params.get("display_type") if rule_key == RULE_MANUAL else RULE_LABELS.get(rule_key, rule_key)
status = row["status"]
status_ui = {"open": "unread", "acknowledged": "doing", "resolved": "done"}[status]
return {
"id": row["id"],
"company_id": row["company_id"],
"company_name": company_name or row["company_name"],
"rule_key": rule_key,
"display_type": display_type,
"title": row["title"],
"content": row["content"],
"deadline": row["deadline"],
"source": row["source"],
"status": status,
"status_ui": status_ui,
"send_count": row["send_count"],
"first_sent_at": row["first_sent_at"],
"last_sent_at": row["last_sent_at"],
"created_by": row["created_by"],
"created_at": row["created_at"],
"action_link": row["action_link"],
"rule_params": params,
}
def list_admin_reminders(
connection: sqlite3.Connection,
*,
source: str | None = None,
) -> list[dict[str, Any]]:
conditions = ["r.send_count > 0"]
params: list[Any] = []
if source in {"auto", "manual"}:
conditions.append("r.source = ?")
params.append(source)
where = " AND ".join(conditions)
rows = connection.execute(
f"""
SELECT r.*, c.name AS company_name
FROM reminders r
JOIN companies c ON c.id = r.company_id
WHERE {where}
ORDER BY r.last_sent_at DESC, r.id DESC
""",
params,
).fetchall()
return [_reminder_row_to_dict(row) for row in rows]
def list_company_reminders(connection: sqlite3.Connection, company_id: int) -> list[dict[str, Any]]:
rows = connection.execute(
"""
SELECT r.*, c.name AS company_name
FROM reminders r
JOIN companies c ON c.id = r.company_id
WHERE r.company_id = ? AND r.send_count > 0
ORDER BY
CASE r.status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END,
r.last_sent_at DESC
""",
(company_id,),
).fetchall()
return [_reminder_row_to_dict(row) for row in rows]
def get_reminder_detail(connection: sqlite3.Connection, reminder_id: int) -> dict[str, Any] | None:
row = connection.execute(
"""
SELECT r.*, c.name AS company_name
FROM reminders r
JOIN companies c ON c.id = r.company_id
WHERE r.id = ?
""",
(reminder_id,),
).fetchone()
if row is None:
return None
events = connection.execute(
"""
SELECT event_type, actor, detail, created_at
FROM reminder_events
WHERE reminder_id = ?
ORDER BY id
""",
(reminder_id,),
).fetchall()
payload = _reminder_row_to_dict(row)
payload["events"] = [dict(item) for item in events]
return payload
def update_reminder_status(
connection: sqlite3.Connection,
reminder_id: int,
new_status: str,
*,
company_id: int | None = None,
actor: sqlite3.Row | None = None,
) -> bool:
if new_status not in {"acknowledged", "resolved"}:
raise ValueError("invalid status")
row = connection.execute(
"SELECT id, company_id, status FROM reminders WHERE id = ?",
(reminder_id,),
).fetchone()
if row is None:
return False
if company_id is not None and int(row["company_id"]) != company_id:
return False
if row["status"] == "resolved":
return False
event_type = "acknowledged" if new_status == "acknowledged" else "resolved"
actor_ref = _actor_label(actor)
with connection:
connection.execute(
"UPDATE reminders SET status = ? WHERE id = ?",
(new_status, reminder_id),
)
_append_event(connection, reminder_id, event_type, actor_ref, None)
return True
def company_unread_count(connection: sqlite3.Connection, company_id: int) -> int:
row = connection.execute(
"""
SELECT COUNT(*) AS n FROM reminders
WHERE company_id = ? AND status = 'open' AND send_count > 0
""",
(company_id,),
).fetchone()
return int(row["n"])
+181 -5
View File
@@ -1,20 +1,20 @@
"""System settings.
"""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. The auto-reminder engine itself lives in
``reminders.py`` (HEL-195); this module only owns the settings store.
``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
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",
@@ -131,3 +131,179 @@ def update_settings(
# ----------------------------------------------------------------------
# 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
+2 -53
View File
@@ -470,59 +470,8 @@ class BalanceBasisTests(CalculationBase):
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
# 甲转出 80 → 应收方向为正
self.assertEqual("80.00", balance["net_change"])
self.assertEqual("80.00", balance["closing"])
def test_opening_plus_outflow_increases_receivable(self) -> None:
"""期初应收 200 + 本期转出垫付 100 → 期末应收 300;乙方对称为应付 300。"""
from test_matching import MatchingBase
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "200", "期初应收", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
helper = object.__new__(MatchingBase)
helper.connection = self.connection
row_a = helper.add_row(
self.company_a,
own_account="6222000000000001",
cp_account="6222000000000002",
expense="100.00",
at="2026-01-15T10:00:00",
)
matching.reconcile_rows(self.connection, [row_a])
row_b = helper.add_row(
self.company_b,
own_account="6222000000000002",
cp_account="6222000000000001",
income="100.00",
at="2026-01-15T11:00:00",
)
matching.reconcile_rows(self.connection, [row_b])
bal_a = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("full", bal_a["basis"])
self.assertEqual("200", bal_a["opening"])
self.assertEqual("100.00", bal_a["net_change"])
self.assertEqual("300.00", bal_a["closing"])
bal_b = calculation.compute_pair_balance(
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
)
self.assertEqual("full", bal_b["basis"])
self.assertEqual("-200", bal_b["opening"])
self.assertEqual("-100.00", bal_b["net_change"])
self.assertEqual("-300.00", bal_b["closing"])
# 双边守恒
self.assertEqual(
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
Decimal("0"),
)
self.assertEqual("-80.00", balance["net_change"])
self.assertEqual("-80.00", balance["closing"])
class CalculationApiTests(unittest.TestCase):
+1 -1
View File
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
self.assertIn("app.js?v=13", html)
self.assertIn("app.js?v=12", html)
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
+2 -2
View File
@@ -32,7 +32,7 @@ class TransfersPageSourceContractTests(unittest.TestCase):
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
self.assertIn("design-system.css?v=6", html)
self.assertIn("app.js?v=13", html)
self.assertIn("app.js?v=12", html)
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
flows = html.index('data-view="flows"')
transfers = html.index('data-view="transfers"')
@@ -113,7 +113,7 @@ class TransfersPageLayoutSmokeTests(unittest.TestCase):
for width in (360, 820, 1440):
page.set_viewport_size({"width": width, "height": 900})
page.set_content(
html.replace('src="app.js?v=13"', 'src=""'),
html.replace('src="app.js?v=12"', 'src=""'),
base_url=self.base,
)
page.evaluate(
-518
View File
@@ -1,518 +0,0 @@
"""HEL-203: 真实浏览器冒烟——原因弹窗、首屏断档、表单 reset。"""
from __future__ import annotations
import os
import re
import tempfile
import threading
import unittest
from pathlib import Path
from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now
import server
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "CashierA123"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
"""本机缺系统 atk 时,复用仓库旁的本地 chromium 依赖目录。"""
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
class Hel203SourceContractTests(unittest.TestCase):
"""不依赖浏览器:锁住 N1~N3 的源码契约。"""
def test_open_modal_is_single_top_level(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
defs = list(re.finditer(r"(?m)^function openModal\(", js))
self.assertEqual(1, len(defs), "openModal 必须只有一处顶层定义")
# 不得再出现在 initAdmin / initCompany 函数体内的局部副本
self.assertNotRegex(
js,
r"function initAdmin\(\)[\s\S]*?function openModal\(",
)
self.assertNotRegex(
js,
r"function initCompany\(\)[\s\S]*?function openModal\(",
)
self.assertIn("function askReason(", js)
ask_pos = js.index("function askReason(")
open_pos = defs[0].start()
self.assertLess(open_pos, ask_pos, "openModal 须在 askReason 之前定义")
def test_init_company_boots_coverage_gaps(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
company_fn = js[js.index("function initCompany(") :]
boot = company_fn[: company_fn.index("\nif (portal ===")]
self.assertIn("await loadCompanyWorkspace()", boot)
self.assertIn("await loadCompanyCoverageGaps()", boot)
def test_async_forms_capture_form_before_await(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertNotIn("event.currentTarget.reset()", js)
for marker in ("#companyForm", "#openingForm", "#accountForm"):
idx = js.index(marker)
chunk = js[idx : idx + 2500]
self.assertIn("const form = event.currentTarget", chunk)
self.assertIn("form.reset()", chunk)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
class Hel203BrowserSmokeTests(unittest.TestCase):
"""真实 Chromium:改起算日→期初→公司端期末;断档首屏→说明→审核。"""
@classmethod
def setUpClass(cls) -> None:
_prepare_chrome_libs()
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls.storage.mkdir()
cls._old_db = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection,
"group-admin",
ADMIN_PASSWORD,
"admin",
must_change_password=False,
)
cls.company_a = master_data.create_company(
connection, "甲公司", None, None, None
)
cls.company_b = master_data.create_company(
connection, "乙公司", None, None, None
)
auth.create_user(
connection,
"cashier-a",
CASHIER_PASSWORD,
"company",
cls.company_a,
must_change_password=False,
)
admin = connection.execute(
"SELECT * FROM users WHERE username = 'group-admin'"
).fetchone()
account = master_data.submit_bank_account(
connection,
company_id=cls.company_a,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000001",
start_date="2026-06-01",
actor=None,
)
cls.account_a = master_data.review_bank_account(
connection,
account["id"],
"approve",
None,
admin,
effective_from="2026-06-01",
)
account_b = master_data.submit_bank_account(
connection,
company_id=cls.company_b,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000002",
start_date="2026-06-01",
actor=None,
)
master_data.review_bank_account(
connection,
account_b["id"],
"approve",
None,
admin,
effective_from="2026-06-01",
)
# 制造一处 mid 断档,供公司端首屏提醒
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'gap.xlsx', 1, 'data/files/gap.xlsx', ?)
""",
("sha-hel203-gap", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(
source_file_id,
cls.company_a,
cls.account_a["id"],
utc_now(),
utc_now(),
),
)
batch_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id,
template_version, header_row, transaction_count, warnings,
created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, created_at
) VALUES (?, 1, '2026-06-21T10:00:00', '0', '0', ?, ?)
""",
(sheet_batch_id, "6222000000000001", utc_now()),
)
calculation.set_calculation_start_date(
connection, "2026-06-01", "初始化起算", admin
)
calculation.recalculate_coverage_gaps(connection)
# 种一笔已确认往来,确认期初后公司端才能进入完整期末口径
def _add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
""",
(ref, utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id,
template_version, header_row, transaction_count, warnings,
created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
cursor = connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, own_name, counterparty_account, counterparty_name,
summary, purpose, currency, created_at
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
""",
(
sheet_batch_id,
at,
income,
expense,
own,
cp,
utc_now(),
),
)
return int(cursor.lastrowid)
row_a = _add_row(
cls.company_a,
cls.account_a["id"],
"6222000000000001",
"6222000000000002",
income="0",
expense="100.00",
at="2026-06-20T10:00:00",
ref="sha-hel203-a",
)
row_b = _add_row(
cls.company_b,
account_b["id"],
"6222000000000002",
"6222000000000001",
income="100.00",
expense="0",
at="2026-06-20T11:00:00",
ref="sha-hel203-b",
)
matching.reconcile_rows(connection, [row_a, row_b])
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.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db
server.STORAGE_DIR = cls._old_storage
cls.temp_dir.cleanup()
def _new_page(self, playwright):
browser = playwright.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
context = browser.new_context(viewport={"width": 1440, "height": 900})
page = context.new_page()
errors: list[str] = []
page.on("pageerror", lambda err: errors.append(str(err)))
page.on(
"console",
lambda msg: errors.append(f"console.{msg.type}: {msg.text}")
if msg.type == "error"
else None,
)
return browser, page, errors
def _login(self, page, *, portal: str, username: str, password: str) -> None:
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
page.fill("#account", username)
page.fill("#password", password)
page.click('button[type="submit"]')
expect = "admin.html" if portal == "admin" else "company.html"
page.wait_for_url(f"**/{expect}", timeout=15000)
def test_02_admin_start_date_opening_company_ending(self) -> None:
with sync_playwright() as p:
browser, page, errors = self._new_page(p)
try:
self._login(
page,
portal="admin",
username="group-admin",
password=ADMIN_PASSWORD,
)
page.click('a[data-view="settings"]')
page.wait_for_selector("#cs-start", state="visible")
# 修改起算日 → 原因弹窗必须打开且发出 PUT
page.fill("#cs-start", "2026-06-15")
with page.expect_request(
lambda req: req.method == "PUT"
and "/api/admin/settings/calculation-start" in req.url
) as start_req:
page.click('#systemSettings button[type="submit"]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "调整起算日供冒烟")
page.click("#reasonSubmit")
self.assertTrue(start_req.value.post_data)
page.wait_for_function(
"() => document.getElementById('cs-start')?.value === '2026-06-15'"
)
# 创建期初并确认
page.click("#openOpeningDialog")
page.wait_for_selector("#openingDialog.open")
page.select_option("#ob-from", label="甲公司")
page.select_option("#ob-to", label="乙公司")
page.fill("#ob-amount", "200")
page.fill("#ob-reason", "冒烟期初录入")
with page.expect_request(
lambda req: req.method == "POST"
and req.url.endswith("/api/admin/opening-balances")
):
page.click('#openingForm button[type="submit"]')
page.wait_for_selector(
'#openingRows button[data-confirm-opening]',
timeout=8000,
)
with page.expect_request(
lambda req: req.method == "POST"
and "/opening-balances/" in req.url
and req.url.endswith("/confirm")
):
page.click('#openingRows button[data-confirm-opening]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "确认期初冒烟")
page.click("#reasonSubmit")
page.wait_for_selector(
'#openingRows button[data-void-opening]',
timeout=8000,
)
# 公司端看到完整期末口径
self._login(
page,
portal="company",
username="cashier-a",
password=CASHIER_PASSWORD,
)
page.click('a[data-view="transfers"]')
page.wait_for_function(
"""() => {
const data = document.getElementById('transfersData');
const empty = document.getElementById('transfersEmpty');
const ready = (data && !data.hidden) || (empty && !empty.hidden);
const card = document.getElementById('tfStatEndingCard');
const title = document.getElementById('tfStatNetTitle');
const emptyHtml = document.getElementById('transfersEmptyStats')?.innerHTML || '';
return ready && (
(card && !card.hidden) ||
(title && title.textContent.includes('期末')) ||
emptyHtml.includes('期末')
);
}""",
timeout=15000,
)
fatal = [e for e in errors if "openModal is not defined" in e
or "Cannot read properties of null" in e]
self.assertEqual([], fatal, fatal)
finally:
browser.close()
def test_01_company_gap_notice_then_admin_approve(self) -> None:
with sync_playwright() as p:
browser, page, errors = self._new_page(p)
try:
self._login(
page,
portal="company",
username="cashier-a",
password=CASHIER_PASSWORD,
)
# 首屏即可见断档提醒,无需手动刷新
page.wait_for_selector(
"#companyCoverageNotice",
state="visible",
timeout=10000,
)
body = page.locator("#companyCoverageBody").inner_text()
self.assertTrue(body.strip())
self.assertNotIn("0002", body)
page.click("#openAttestationFromWorkspace")
page.wait_for_selector("#attestationDialog.open", timeout=5000)
page.fill("#att-reason", "节假日账户无资金往来")
with page.expect_request(
lambda req: req.method == "POST"
and req.url.endswith("/api/company/no-business-attestations")
) as att_req:
page.click('#attestationForm button[type="submit"]')
self.assertTrue(att_req.value.post_data)
page.wait_for_function(
"""() => !document.getElementById('attestationDialog')?.classList.contains('open')""",
timeout=8000,
)
self._login(
page,
portal="admin",
username="group-admin",
password=ADMIN_PASSWORD,
)
page.click('a[data-view="audit"]')
page.wait_for_selector(
'button[data-audit-action="approve-attestation"]',
timeout=10000,
)
with page.expect_request(
lambda req: req.method == "POST"
and "/no-business-attestations/" in req.url
and req.url.endswith("/review")
):
page.click('button[data-audit-action="approve-attestation"]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "审核通过说明")
page.click("#reasonSubmit")
page.wait_for_function(
"""() => !document.querySelector(
'button[data-audit-action=\"approve-attestation\"]'
)""",
timeout=10000,
)
fatal = [e for e in errors if "openModal is not defined" in e]
self.assertEqual([], fatal, fatal)
finally:
browser.close()
if __name__ == "__main__":
unittest.main()
-417
View File
@@ -1,417 +0,0 @@
"""HEL-206: 公司间期末余额方向——期初应收 + 转出垫付 = 期末应收增加。"""
from __future__ import annotations
import os
import tempfile
import threading
import unittest
from decimal import Decimal
from pathlib import Path
from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now
import server
ROOT = Path(__file__).resolve().parents[1]
ADMIN_PASSWORD = "AdminPass123"
CASHIER_A_PASSWORD = "CashierA123"
CASHIER_B_PASSWORD = "CashierB123"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
class Hel206BalanceDirectionUnitTests(unittest.TestCase):
"""不依赖浏览器:期初 200 + 转出 100 → 甲应收 300 / 乙应付 300。"""
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.db_path = Path(self.temp_dir.name) / "app.db"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
auth.create_user(self.connection, "admin-u", ADMIN_PASSWORD, "admin")
self.admin = self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
self.company_a = master_data.create_company(
self.connection, "甲公司", None, None, None
)
self.company_b = master_data.create_company(
self.connection, "乙公司", None, None, None
)
self.account_a = self._approve("6222000000000001", self.company_a)
self.account_b = self._approve("6222000000000002", self.company_b)
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "起算", self.admin
)
def _approve(self, number: str, company_id: int):
account = master_data.submit_bank_account(
self.connection,
company_id=company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date="2026-01-01",
actor=None,
)
return master_data.review_bank_account(
self.connection,
account["id"],
"approve",
None,
self.admin,
effective_from="2026-01-01",
)
def _add_row(self, company_id, account_id, own, cp, *, income, expense, at, ref):
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
""",
(ref, utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id,
template_version, header_row, transaction_count, warnings,
created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
cursor = self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, own_name, counterparty_account, counterparty_name,
summary, purpose, currency, created_at
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
""",
(sheet_batch_id, at, income, expense, own, cp, utc_now()),
)
return int(cursor.lastrowid)
def test_opening_200_plus_outflow_100_equals_closing_300(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "200", "期初应收", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
row_a = self._add_row(
self.company_a,
self.account_a["id"],
"6222000000000001",
"6222000000000002",
income="0",
expense="100.00",
at="2026-01-15T10:00:00",
ref="sha-hel206-a",
)
row_b = self._add_row(
self.company_b,
self.account_b["id"],
"6222000000000002",
"6222000000000001",
income="100.00",
expense="0",
at="2026-01-15T11:00:00",
ref="sha-hel206-b",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
bal_a = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
bal_b = calculation.compute_pair_balance(
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
)
self.assertEqual("200", bal_a["opening"])
self.assertEqual("100.00", bal_a["net_change"])
self.assertEqual("300.00", bal_a["closing"])
self.assertEqual("-200", bal_b["opening"])
self.assertEqual("-100.00", bal_b["net_change"])
self.assertEqual("-300.00", bal_b["closing"])
self.assertEqual(
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
Decimal("0"),
)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
class Hel206BrowserDirectionTests(unittest.TestCase):
"""真实 Chromium:公司端转账往来页期末方向与对手公司对称。"""
@classmethod
def setUpClass(cls) -> None:
_prepare_chrome_libs()
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls.storage.mkdir()
cls._old_db = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection, "group-admin", ADMIN_PASSWORD, "admin",
must_change_password=False,
)
cls.company_a = master_data.create_company(
connection, "甲公司", None, None, None
)
cls.company_b = master_data.create_company(
connection, "乙公司", None, None, None
)
auth.create_user(
connection, "cashier-a", CASHIER_A_PASSWORD, "company",
cls.company_a, must_change_password=False,
)
auth.create_user(
connection, "cashier-b", CASHIER_B_PASSWORD, "company",
cls.company_b, must_change_password=False,
)
admin = connection.execute(
"SELECT * FROM users WHERE username = 'group-admin'"
).fetchone()
def approve(company_id, number):
account = master_data.submit_bank_account(
connection,
company_id=company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date="2026-01-01",
actor=None,
)
return master_data.review_bank_account(
connection, account["id"], "approve", None, admin,
effective_from="2026-01-01",
)
account_a = approve(cls.company_a, "6222000000000001")
account_b = approve(cls.company_b, "6222000000000002")
calculation.set_calculation_start_date(
connection, "2026-01-01", "初始化起算", admin
)
item = calculation.create_opening_balance(
connection, cls.company_a, cls.company_b, "200", "期初应收", admin
)
calculation.confirm_opening_balance(connection, item["id"], "确认", admin)
def add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
""",
(ref, utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id,
template_version, header_row, transaction_count, warnings,
created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
cursor = connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, own_name, counterparty_account, counterparty_name,
summary, purpose, currency, created_at
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
""",
(sheet_batch_id, at, income, expense, own, cp, utc_now()),
)
return int(cursor.lastrowid)
row_a = add_row(
cls.company_a, account_a["id"], "6222000000000001", "6222000000000002",
income="0", expense="100.00", at="2026-01-15T10:00:00", ref="sha-hel206-br-a",
)
row_b = add_row(
cls.company_b, account_b["id"], "6222000000000002", "6222000000000001",
income="100.00", expense="0", at="2026-01-15T11:00:00", ref="sha-hel206-br-b",
)
matching.reconcile_rows(connection, [row_a, row_b])
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.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db
server.STORAGE_DIR = cls._old_storage
cls.temp_dir.cleanup()
def _login(self, page, *, username: str, password: str) -> None:
page.goto(f"{self.base}/login-company.html", wait_until="domcontentloaded")
page.fill("#account", username)
page.fill("#password", password)
page.click('button[type="submit"]')
page.wait_for_url("**/company.html", timeout=15000)
def test_company_ending_direction_in_browser(self) -> None:
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
try:
context = browser.new_context(viewport={"width": 1440, "height": 900})
page = context.new_page()
errors: list[str] = []
page.on("pageerror", lambda err: errors.append(str(err)))
# 甲公司:期初 200 + 转出 100 → 期末应收 300
self._login(page, username="cashier-a", password=CASHIER_A_PASSWORD)
page.click('a[data-view="transfers"]')
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
bal_a = page.evaluate(
"""async () => {
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
return await res.json();
}"""
)
self.assertEqual("ok", bal_a.get("status"))
self.assertEqual("full", bal_a.get("basis"))
pair_a = next(
p for p in bal_a["pairs"]
if p["counterparty_company_id"] == self.company_b
)
self.assertEqual("200", pair_a["opening"])
self.assertEqual("100.00", pair_a["net_change"])
self.assertEqual("300.00", pair_a["closing"])
ending_text = page.locator("#tfStatEnding").inner_text()
self.assertIn("0.03", ending_text) # 300 元 = 0.03 万元
self.assertEqual([], errors, errors)
# 乙公司:对称应付 300
self._login(page, username="cashier-b", password=CASHIER_B_PASSWORD)
page.click('a[data-view="transfers"]')
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
bal_b = page.evaluate(
"""async () => {
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
return await res.json();
}"""
)
pair_b = next(
p for p in bal_b["pairs"]
if p["counterparty_company_id"] == self.company_a
)
self.assertEqual("-200", pair_b["opening"])
self.assertEqual("-100.00", pair_b["net_change"])
self.assertEqual("-300.00", pair_b["closing"])
ending_b = page.locator("#tfStatEnding").inner_text()
self.assertIn("0.03", ending_b)
fatal = [e for e in errors if "is not defined" in e or "Cannot read" in e]
self.assertEqual([], fatal, fatal)
finally:
browser.close()
if __name__ == "__main__":
unittest.main()
+6 -6
View File
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
class MigrationTests(PersistenceTestCase):
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
first = applied_versions(self.connection)
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], first)
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -84,24 +84,24 @@ class MigrationTests(PersistenceTestCase):
"ledger_subject_suggestions",
"system_settings",
"system_setting_changes",
"reminders",
"closed_periods",
"opening_balance_revisions",
"coverage_gaps",
"no_business_attestations",
"reminders_legacy_manual",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([9, 8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([8, 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, 7, 8, 9], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -109,7 +109,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([9, 8, 7, 6, 5], rollback(self.connection, 4))
self.assertEqual([8, 7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
-495
View File
@@ -1,495 +0,0 @@
"""Tests for automatic reminder detection, delivery, isolation and audit trail."""
from __future__ import annotations
from datetime import date, timedelta
from pathlib import Path
import json
import sqlite3
import unittest
from unittest.mock import patch
from bank_importer import auth, reminders
from bank_importer.db import connect, migrate, utc_now
class ReminderTestCase(unittest.TestCase):
def setUp(self) -> None:
self.connection = connect(":memory:")
self.addCleanup(self.connection.close)
migrate(self.connection)
now = utc_now()
with self.connection:
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
(now, now),
)
self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES ('乙公司', ?, ?)",
(now, now),
)
self.company_a = int(
self.connection.execute("SELECT id FROM companies WHERE name = '甲公司'").fetchone()["id"]
)
self.company_b = int(
self.connection.execute("SELECT id FROM companies WHERE name = '乙公司'").fetchone()["id"]
)
self.admin_id = auth.create_user(self.connection, "admin1", "AdminPass123", "admin")
self.user_a = auth.create_user(
self.connection, "cashier-a", "CashierA123", "company", company_id=self.company_a
)
self.user_b = auth.create_user(
self.connection, "cashier-b", "CashierB123", "company", company_id=self.company_b
)
self.admin = self.connection.execute(
"SELECT * FROM users WHERE id = ?", (self.admin_id,)
).fetchone()
self.actor_a = self.connection.execute(
"SELECT * FROM users WHERE id = ?", (self.user_a,)
).fetchone()
def _insert_batch(
self,
company_id: int,
*,
period_start: str,
period_end: str,
) -> None:
now = utc_now()
with self.connection:
self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'f.xls', 1, '/tmp/f.xls', ?)
""",
(f"sha-{company_id}-{period_end}", now),
)
file_id = int(self.connection.execute("SELECT last_insert_rowid()").fetchone()[0])
self.connection.execute(
"""
INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at)
VALUES (?, 'parsed', ?, ?, ?)
""",
(file_id, company_id, now, now),
)
batch_id = int(self.connection.execute("SELECT last_insert_rowid()").fetchone()[0])
self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, period_start, period_end, transaction_count, created_at
) VALUES (?, 's1', '工行', 'tpl', 1, 1, ?, ?, 1, ?)
""",
(batch_id, period_start, period_end, now),
)
def _insert_pending_account(self, company_id: int) -> None:
now = utc_now()
with self.connection:
self.connection.execute(
"""
INSERT INTO bank_accounts (
company_id, account_number, bank_name, status, created_at, updated_at
) VALUES (?, ?, '工行', 'pending', ?, ?)
""",
(company_id, f"622{company_id:012d}", now, now),
)
class RuleDetectionTests(ReminderTestCase):
def test_unsubmitted_detected_when_no_current_month_batch(self) -> None:
today = date.today()
if today.day < 5:
self.skipTest("monthly start day gate not reached today")
findings = reminders.scan_findings(self.connection)
keys = {item.company_id: item for item in findings if item.rule_key == reminders.RULE_UNSUBMITTED}
self.assertIn(self.company_a, keys)
self.assertIn(self.company_b, keys)
def test_unsubmitted_not_reported_when_batch_exists(self) -> None:
today = date.today()
if today.day < 5:
self.skipTest("monthly start day gate not reached today")
period = f"{today.year:04d}-{today.month:02d}"
self._insert_batch(self.company_a, period_start=f"{period}-01", period_end=f"{period}-15")
findings = reminders.scan_findings(self.connection)
for item in findings:
if item.rule_key == reminders.RULE_UNSUBMITTED:
self.assertNotEqual(self.company_a, item.company_id)
def test_gap_detected_after_threshold(self) -> None:
old_end = (date.today() - timedelta(days=10)).isoformat()
self._insert_batch(self.company_a, period_start="2026-01-01", period_end=old_end)
findings = reminders.scan_findings(self.connection)
gap = [item for item in findings if item.rule_key == reminders.RULE_GAP and item.company_id == self.company_a]
self.assertEqual(1, len(gap))
def test_gap_not_reported_for_recent_batch(self) -> None:
recent = (date.today() - timedelta(days=1)).isoformat()
self._insert_batch(self.company_a, period_start="2026-07-01", period_end=recent)
findings = reminders.scan_findings(self.connection)
gap = [item for item in findings if item.rule_key == reminders.RULE_GAP and item.company_id == self.company_a]
self.assertEqual(0, len(gap))
def test_pending_review_detected_with_pending_account(self) -> None:
self._insert_pending_account(self.company_a)
findings = reminders.scan_findings(self.connection)
pending = [
item for item in findings
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
]
self.assertEqual(1, len(pending))
self.assertGreater(pending[0].rule_params["pending_count"], 0)
def test_pending_review_not_reported_when_clean(self) -> None:
findings = reminders.scan_findings(self.connection)
pending = [
item for item in findings
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
]
self.assertEqual(0, len(pending))
class DeliveryAndDedupTests(ReminderTestCase):
def test_send_creates_reminder_and_event(self) -> None:
today = date.today()
if today.day < 5:
self.skipTest("monthly start day gate not reached today")
findings = reminders.scan_findings(self.connection)
self.assertTrue(findings)
key = findings[0].dedupe_key
reminder_id = reminders.deliver_finding(self.connection, key, actor=self.admin)
self.assertIsNotNone(reminder_id)
row = self.connection.execute(
"SELECT send_count, status FROM reminders WHERE id = ?", (reminder_id,)
).fetchone()
self.assertEqual(1, row["send_count"])
self.assertEqual("open", row["status"])
events = self.connection.execute(
"SELECT event_type FROM reminder_events WHERE reminder_id = ?", (reminder_id,)
).fetchall()
self.assertEqual(["sent"], [item["event_type"] for item in events])
def test_repeat_send_increments_count_without_new_row(self) -> None:
today = date.today()
if today.day < 5:
self.skipTest("monthly start day gate not reached today")
findings = reminders.scan_findings(self.connection)
key = findings[0].dedupe_key
first = reminders.deliver_finding(self.connection, key, actor=self.admin)
second = reminders.deliver_finding(self.connection, key, actor=self.admin)
self.assertEqual(first, second)
row = self.connection.execute(
"SELECT send_count FROM reminders WHERE dedupe_key = ?", (key,)
).fetchone()
self.assertEqual(2, row["send_count"])
count = self.connection.execute("SELECT COUNT(*) FROM reminders WHERE dedupe_key = ?", (key,)).fetchone()[0]
self.assertEqual(1, count)
events = self.connection.execute(
"SELECT COUNT(*) FROM reminder_events WHERE reminder_id = ?", (first,)
).fetchone()[0]
self.assertEqual(2, events)
def test_resolved_reminder_reopens_same_row(self) -> None:
self._insert_pending_account(self.company_a)
finding = next(
item
for item in reminders.scan_findings(self.connection)
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
)
first = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
self.assertIsNotNone(first)
reminders.update_reminder_status(
self.connection, first, "resolved", company_id=self.company_a, actor=self.actor_a
)
second = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
self.assertEqual(first, second)
row = self.connection.execute(
"SELECT send_count, status, dedupe_key FROM reminders WHERE id = ?",
(first,),
).fetchone()
self.assertEqual(2, row["send_count"])
self.assertEqual("open", row["status"])
count = self.connection.execute(
"SELECT COUNT(*) FROM reminders WHERE dedupe_key = ?", (finding.dedupe_key,)
).fetchone()[0]
self.assertEqual(1, count)
events = [
item["event_type"]
for item in self.connection.execute(
"SELECT event_type FROM reminder_events WHERE reminder_id = ? ORDER BY id",
(first,),
).fetchall()
]
self.assertEqual(["sent", "resolved", "sent"], events)
def test_deliver_many_continues_after_one_failure(self) -> None:
self._insert_pending_account(self.company_a)
finding = next(
item
for item in reminders.scan_findings(self.connection)
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
)
original = reminders.deliver_finding
def flaky(connection, key, **kwargs):
if key == "boom":
raise sqlite3.IntegrityError("UNIQUE constraint failed: reminders.dedupe_key")
return original(connection, key, **kwargs)
with patch.object(reminders, "deliver_finding", side_effect=flaky):
sent = reminders.deliver_many(
self.connection, ["boom", finding.dedupe_key], actor=self.admin
)
self.assertEqual(1, len(sent))
self.assertEqual(
1,
self.connection.execute("SELECT COUNT(*) FROM reminders").fetchone()[0],
)
def test_manual_reminder_each_send_is_separate(self) -> None:
first = reminders.send_manual(
self.connection,
company_id=self.company_a,
display_type="其他",
content="请尽快处理",
deadline="2026-09-01",
actor=self.admin,
)
second = reminders.send_manual(
self.connection,
company_id=self.company_a,
display_type="其他",
content="再次提醒",
deadline=None,
actor=self.admin,
)
self.assertNotEqual(first, second)
class IsolationTests(ReminderTestCase):
def setUp(self) -> None:
super().setUp()
reminders.send_manual(
self.connection,
company_id=self.company_a,
display_type="流水未提交",
content="甲公司专属",
deadline=None,
actor=self.admin,
)
reminders.send_manual(
self.connection,
company_id=self.company_b,
display_type="流水未提交",
content="乙公司专属",
deadline=None,
actor=self.admin,
)
def test_company_sees_only_own_reminders(self) -> None:
items_a = reminders.list_company_reminders(self.connection, self.company_a)
items_b = reminders.list_company_reminders(self.connection, self.company_b)
self.assertEqual(1, len(items_a))
self.assertEqual(1, len(items_b))
self.assertIn("甲公司", items_a[0]["title"])
self.assertIn("乙公司", items_b[0]["title"])
def test_company_cannot_update_other_company_reminder(self) -> None:
other_id = reminders.list_company_reminders(self.connection, self.company_b)[0]["id"]
ok = reminders.update_reminder_status(
self.connection, other_id, "acknowledged", company_id=self.company_a, actor=self.actor_a
)
self.assertFalse(ok)
class AuditTrailTests(ReminderTestCase):
def test_scan_writes_audit_log(self) -> None:
reminders.run_scan(self.connection, actor=self.admin, ip="127.0.0.1")
row = self.connection.execute(
"SELECT action, detail FROM audit_log WHERE action = 'reminder_scan'"
).fetchone()
self.assertIsNotNone(row)
detail = json.loads(row["detail"])
self.assertIn("total", detail)
def test_events_are_append_only(self) -> None:
reminder_id = reminders.send_manual(
self.connection,
company_id=self.company_a,
display_type="测试",
content="内容",
deadline=None,
actor=self.admin,
)
with self.assertRaises(Exception):
with self.connection:
self.connection.execute(
"UPDATE reminder_events SET detail = 'tampered' WHERE reminder_id = ?",
(reminder_id,),
)
with self.assertRaises(Exception):
with self.connection:
self.connection.execute(
"DELETE FROM reminder_events WHERE reminder_id = ?", (reminder_id,)
)
def test_reminders_cannot_be_deleted(self) -> None:
reminder_id = reminders.send_manual(
self.connection,
company_id=self.company_a,
display_type="测试",
content="内容",
deadline=None,
actor=self.admin,
)
with self.assertRaises(Exception):
with self.connection:
self.connection.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
class StatusFlowTests(ReminderTestCase):
def test_acknowledge_and_resolve(self) -> None:
reminder_id = reminders.send_manual(
self.connection,
company_id=self.company_a,
display_type="待确认",
content="请处理",
deadline=None,
actor=self.admin,
)
ok = reminders.update_reminder_status(
self.connection, reminder_id, "acknowledged", company_id=self.company_a, actor=self.actor_a
)
self.assertTrue(ok)
row = self.connection.execute(
"SELECT status FROM reminders WHERE id = ?", (reminder_id,)
).fetchone()
self.assertEqual("acknowledged", row["status"])
ok = reminders.update_reminder_status(
self.connection, reminder_id, "resolved", company_id=self.company_a, actor=self.actor_a
)
self.assertTrue(ok)
events = self.connection.execute(
"SELECT event_type FROM reminder_events WHERE reminder_id = ? ORDER BY id",
(reminder_id,),
).fetchall()
self.assertEqual(
["sent", "acknowledged", "resolved"],
[item["event_type"] for item in events],
)
class SettingsTests(ReminderTestCase):
def test_default_settings_and_update(self) -> None:
settings = reminders.get_settings(self.connection)
self.assertEqual("5", settings["monthly_start_day"])
self.assertEqual("5", settings["gap_days"])
updated = reminders.update_settings(
self.connection, {"gap_days": "7", "monthly_start_day": "6"}
)
self.assertEqual("7", updated["gap_days"])
self.assertEqual("6", updated["monthly_start_day"])
def test_unknown_setting_rejected(self) -> None:
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"unknown_key": "1"})
def test_invalid_monthly_start_day_rejected(self) -> None:
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"monthly_start_day": "0"})
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"monthly_start_day": "29"})
self.assertEqual("5", reminders.get_settings(self.connection)["monthly_start_day"])
def test_invalid_gap_days_rejected(self) -> None:
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"gap_days": "0"})
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"gap_days": "abc"})
self.assertEqual("5", reminders.get_settings(self.connection)["gap_days"])
def test_invalid_scan_time_rejected(self) -> None:
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"scan_time": "25:00"})
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"scan_time": "8:0"})
self.assertEqual("08:00", reminders.get_settings(self.connection)["scan_time"])
def test_corrupt_settings_do_not_break_scan(self) -> None:
with self.connection:
self.connection.execute(
"UPDATE reminder_settings SET value = 'not-a-number' WHERE key = 'monthly_start_day'"
)
self.connection.execute(
"UPDATE reminder_settings SET value = '0' WHERE key = 'gap_days'"
)
findings = reminders.scan_findings(self.connection)
self.assertIsInstance(findings, list)
class NoticeListDelegationTests(unittest.TestCase):
def test_async_notice_go_handle_uses_event_delegation(self) -> None:
source = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text(
encoding="utf-8"
)
init_body = source.split("function initNotifications()", 1)[1].split("\nfunction ", 1)[0]
self.assertIn('list.addEventListener("click"', init_body)
self.assertIn('closest("[data-view-link]")', init_body)
self.assertIn("showView(viewLink.dataset.viewLink)", init_body)
self.assertIn('document.addEventListener("click"', source)
self.assertNotIn(
'$$("[data-view-link]").forEach((button) => button.addEventListener("click"',
source,
)
def test_delegated_lookup_finds_button_inserted_after_init(self) -> None:
"""Simulate #notice-list after async replaceChildren: click target is the new button."""
list_root = {"id": "notice-list", "parent": None, "attrs": {}}
side = {"id": "lr-side", "parent": list_root, "attrs": {}}
button = {
"id": "go",
"parent": side,
"attrs": {"data-view-link": "reconcile"},
}
list_root["children"] = [side]
side["children"] = [button]
def closest(node, attr):
current = node
while current is not None:
if attr in current.get("attrs", {}):
return current
current = current.get("parent")
return None
clicked = closest(button, "data-view-link")
self.assertIsNotNone(clicked)
self.assertEqual("reconcile", clicked["attrs"]["data-view-link"])
self.assertIs(list_root, clicked["parent"]["parent"])
class MigrationTests(unittest.TestCase):
def test_v6_migration_applies_and_rolls_back(self) -> None:
connection = connect(":memory:")
self.addCleanup(connection.close)
migrate(connection)
versions = connection.execute(
"SELECT version FROM schema_migrations ORDER BY version"
).fetchall()
self.assertEqual(9, versions[-1]["version"])
connection.execute("DELETE FROM schema_migrations WHERE version = 9")
connection.executescript(
"""
DROP TRIGGER IF EXISTS reminders_no_delete;
DROP TRIGGER IF EXISTS reminder_events_no_delete;
DROP TRIGGER IF EXISTS reminder_events_no_update;
DROP TABLE IF EXISTS reminder_events;
DROP TABLE IF EXISTS reminders;
DROP TABLE IF EXISTS reminder_settings;
"""
)
row = connection.execute(
"SELECT name FROM sqlite_master WHERE name = 'reminders'"
).fetchone()
self.assertIsNone(row)
-28
View File
@@ -287,8 +287,6 @@ class ServerAuthMatrixTests(unittest.TestCase):
lambda: anon.get("/api/admin/users"),
lambda: anon.get("/api/admin/companies"),
lambda: anon.get("/api/admin/audit-log"),
lambda: anon.get("/api/admin/reminders"),
lambda: anon.get("/api/company/reminders"),
lambda: anon.get("/api/me"),
):
status, _, data = method_check()
@@ -389,10 +387,6 @@ class ServerAuthMatrixTests(unittest.TestCase):
lambda: self.cashier_a.request("POST", "/api/admin/users/1/enable"),
lambda: self.cashier_a.request("POST", "/api/admin/users/1/reset-password"),
lambda: self.cashier_a.get("/api/admin/audit-log"),
lambda: self.cashier_a.get("/api/admin/reminders"),
lambda: self.cashier_a.get("/api/admin/reminders/pending"),
lambda: self.cashier_a.post_json("/api/admin/reminder-settings", {"gap_days": "3"}),
lambda: self.cashier_a.post_json("/api/admin/reminders/send", {"dedupe_keys": ["x"]}),
)
for call in calls:
status, _, data = call()
@@ -563,28 +557,6 @@ class ServerAuthMatrixTests(unittest.TestCase):
self.assertNotIn(password, row["detail"] or "")
self.assertNotIn(password, row["target"] or "")
def test_invalid_reminder_settings_return_400_and_do_not_persist(self) -> None:
cases = (
{"monthly_start_day": "0"},
{"gap_days": "0"},
{"scan_time": "25:99"},
)
for payload in cases:
with self.subTest(payload=payload):
status, _, data = self.admin.post_json(
"/api/admin/reminder-settings", {"settings": payload}
)
self.assertEqual(400, status, data)
self.assertEqual("error", as_json(data)["status"])
status, _, data = self.admin.get("/api/admin/reminder-settings")
self.assertEqual(200, status)
settings = as_json(data)["settings"]
self.assertEqual("5", settings["monthly_start_day"])
self.assertEqual("5", settings["gap_days"])
self.assertEqual("08:00", settings["scan_time"])
status, _, data = self.admin.get("/api/admin/reminders/pending")
self.assertEqual(200, status, data)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -166,6 +166,28 @@ class SettingsAndRemindersTests(unittest.TestCase):
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(
+11 -60
View File
@@ -633,22 +633,6 @@
<h1>提醒管理</h1>
<p class="page-sub">向成员公司发送处理提醒,并跟踪系统提醒与人工提醒的触达与处理状态。</p>
</div>
<div class="page-actions">
<button type="button" class="btn btn-sm" id="reminder-scan-btn">立即扫描</button>
</div>
</div>
<div class="card" id="pending-reminders-card" style="margin-bottom: 16px;">
<div class="card-head">
<span class="card-title">待提醒清单<span class="sub">系统按流水提交、断档、待确认自动发现,点发送即送达对应公司</span></span>
<div class="row" style="gap: 10px; align-items: center;">
<span class="meta" id="pending-summary"></span>
<button type="button" class="btn btn-primary btn-sm" id="pending-send-all" disabled>全部一键发送</button>
</div>
</div>
<div id="pending-list">
<div class="empty" id="pending-empty">暂无需要提醒的事项 · 各公司流水与确认进度正常</div>
</div>
</div>
<div class="grid grid-1-2">
@@ -658,30 +642,18 @@
</div>
<form id="reminderForm" novalidate>
<div class="field" style="margin-bottom: 14px;">
<label>接收公司(可多选)</label>
<div class="row" id="reminderCompanyList" style="flex-wrap: wrap; gap: 8px 16px;"></div>
<span class="hint error" id="company-error" style="display: none;">至少选择一家接收公司</span>
<label for="reminder-company">接收公司</label>
<select class="select" id="reminder-company"><option value="">请选择公司</option></select>
<span class="hint error" id="company-error" style="display: none;">请选择一家接收公司</span>
</div>
<div class="field" style="margin-bottom: 14px;">
<label for="reminder-type">提醒类型</label>
<select class="select" id="reminder-type">
<option selected>流水未提交</option>
<option>单边待确认</option>
<option>科目待确认</option>
<option>账户登记</option>
<option>其他</option>
</select>
<div class="field" style="margin-bottom: 16px;">
<label>提醒事项(系统自动列出)</label>
<div id="reminder-items">
<div class="empty">请先选择公司,系统将自动列出该公司待提醒事项。</div>
</div>
<span class="hint error" id="reminder-error" style="display: none;">该公司当前没有待提醒事项</span>
</div>
<div class="field" style="margin-bottom: 14px;">
<label for="reminder-content">提醒内容</label>
<textarea class="textarea" id="reminder-content">请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。</textarea>
<span class="hint error" id="content-error" style="display: none;">提醒内容不能为空</span>
</div>
<div class="field" style="margin-bottom: 18px;">
<label for="reminder-deadline">截止日期</label>
<input class="input" type="date" id="reminder-deadline" value="2026-08-29" min="2026-08-20" />
</div>
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;">发送提醒</button>
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;" disabled>发送提醒</button>
<p class="hint" id="send-hint" style="margin-top: 10px; display: none;"></p>
</form>
</div>
@@ -700,7 +672,6 @@
<thead>
<tr>
<th>公司</th>
<th>来源</th>
<th>类型</th>
<th class="wrap">内容摘要</th>
<th>发送时间</th>
@@ -976,26 +947,6 @@
</div>
</div>
<!-- 提醒详情抽屉 -->
<aside class="drawer" id="reminder-detail-drawer" aria-label="提醒详情">
<div class="drawer-head">
<div>
<span class="pill pill-info" id="rd-source-pill">系统</span>
<h2 class="d-title" id="rd-title">提醒详情</h2>
<p class="d-desc" id="rd-sub"></p>
</div>
<button type="button" class="icon-button" data-close-rd aria-label="关闭详情" title="关闭详情">×</button>
</div>
<div class="drawer-body">
<dl class="kv" id="rd-fields"></dl>
<h3 style="font-size: 13px; margin: 16px 0 8px; color: var(--muted);">事件留痕</h3>
<div id="rd-events"></div>
</div>
<div class="drawer-foot">
<button type="button" class="btn" data-close-rd>关闭</button>
</div>
</aside>
<!-- 提醒历史弹窗 -->
<div class="modal-backdrop" id="history-modal">
<div class="modal">
@@ -1011,6 +962,6 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=13"></script>
<script src="app.js?v=9"></script>
</body>
</html>
+227 -430
View File
@@ -1,6 +1,9 @@
const $ = (selector, scope = document) => scope.querySelector(selector);
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
const portal = document.body.dataset.portal || "entry";
const viewNames = portal === "admin"
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
@@ -465,12 +468,7 @@ function initShell() {
}
});
$$("[data-view]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.view); }));
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-view-link]");
if (!button) return;
event.preventDefault();
showView(button.dataset.viewLink);
});
$$("[data-view-link]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.viewLink); }));
$$(".logout").forEach((control) => control.addEventListener("click", async (event) => {
event.preventDefault();
try {
@@ -1127,7 +1125,7 @@ function setSelectOptions(select, names, { keepFirst = false } = {}) {
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
}
function fillCompanySelects(names, companies) {
function fillCompanySelects(names) {
// Every company picker is driven by master data: a newly created company
// appears in pair queries, audit filters, flow filters and reminders
// without any code change.
@@ -1139,20 +1137,11 @@ function fillCompanySelects(names, companies) {
});
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
const reminderList = $("#reminderCompanyList");
if (reminderList && companies?.length) {
reminderList.replaceChildren(...companies.map((company) => {
const label = document.createElement("label");
label.className = "row";
label.style.cssText = "gap:6px;font-size:13px;";
const input = document.createElement("input");
input.type = "checkbox";
input.name = "company";
input.value = String(company.id);
input.style.width = "auto";
label.append(input, document.createTextNode(company.name));
return label;
}));
const reminderSelect = $("#reminder-company");
if (reminderSelect) {
const kept = reminderSelect.querySelector('option[value=""]') ? [reminderSelect.options[0].cloneNode(true)] : [];
const companies = state.companies || [];
reminderSelect.replaceChildren(...kept, ...companies.map((company) => new Option(company.name, company.id)));
}
setSelectOptions($('#openingDialog [name="from"]'), names);
setSelectOptions($('#openingDialog [name="to"]'), names);
@@ -1189,9 +1178,6 @@ function humanizeChangeValue(value) {
return String(value);
}
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
function askReason({ title, subtitle, confirmLabel } = {}) {
return new Promise((resolve) => {
const dialog = $("#reasonDialog");
@@ -1789,293 +1775,6 @@ function initDashboard() {
loadAdminDashboard();
}
function formatReminderTime(value) {
if (!value) return "—";
const text = String(value).replace("T", " ").slice(0, 16);
return text;
}
function reminderTypePill(ruleKey) {
if (ruleKey === "pending_review") return "pill-warn";
if (ruleKey === "manual") return "pill-warn";
return "pill-danger";
}
function reminderStatusPill(statusUi) {
return { unread: "pill-danger", doing: "pill-warn", done: "pill-success" }[statusUi] || "pill-muted";
}
function reminderStatusLabel(statusUi) {
return { unread: "未读", doing: "处理中", done: "已完成" }[statusUi] || statusUi;
}
async function loadAdminRemindersPending() {
const list = $("#pending-list");
const empty = $("#pending-empty");
const summary = $("#pending-summary");
const sendAll = $("#pending-send-all");
if (!list) return;
const response = await fetch("/api/admin/reminders/pending").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const findings = result.findings || [];
list.querySelectorAll(".list-row").forEach((row) => row.remove());
if (!findings.length) {
if (empty) empty.style.display = "";
if (summary) summary.textContent = "0 家公司 · 0 项";
if (sendAll) { sendAll.disabled = true; sendAll.textContent = "全部一键发送"; }
return;
}
if (empty) empty.style.display = "none";
const companies = new Set(findings.map((item) => item.company_id)).size;
if (summary) summary.textContent = `${companies} 家公司 · ${findings.length}`;
if (sendAll) {
sendAll.disabled = false;
sendAll.textContent = "全部一键发送";
sendAll.dataset.keys = JSON.stringify(findings.map((item) => item.dedupe_key));
}
findings.forEach((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.dedupeKey = item.dedupe_key;
const sentHint = item.send_count > 0 ? ` · 已提醒 ${item.send_count}` : "";
row.innerHTML =
'<label class="row" style="gap:6px;flex:none;"><input type="checkbox" class="pending-check" style="width:auto;" /></label>' +
`<span class="pill ${reminderTypePill(item.rule_key)}">${item.rule_label}</span>` +
`<div class="lr-main"><div class="lr-title">${item.title}</div>` +
`<div class="lr-sub"><span class="meta">${item.reason}${sentHint}</span></div></div>` +
`<span class="pill ${item.duration_pill}" style="flex:none;font-family:var(--font-mono);">${item.days_open} 天</span>` +
`<div class="lr-side"><button type="button" class="btn btn-sm btn-primary pending-send-one">发送提醒</button></div>`;
list.append(row);
});
}
async function loadAdminRemindersHistory(sourceFilter) {
const tbody = $("#reminder-tbody");
if (!tbody) return;
const query = sourceFilter && sourceFilter !== "all" ? `?source=${sourceFilter === "system" ? "auto" : "manual"}` : "";
const response = await fetch(`/api/admin/reminders${query}`).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const items = result.reminders || [];
tbody.replaceChildren(...items.map((item) => {
const tr = document.createElement("tr");
tr.dataset.source = item.source === "auto" ? "system" : "manual";
tr.dataset.reminderId = String(item.id);
const summary = item.content.length > 46 ? item.content.slice(0, 46) + "…" : item.content;
const sourceCell = item.source === "auto"
? '<span class="pill pill-info">系统</span>'
: '<span class="tag">人工</span>';
tr.innerHTML =
`<td class="cell-main">${item.company_name}</td>` +
`<td>${sourceCell}</td>` +
`<td><span class="pill ${reminderTypePill(item.rule_key)}">${item.display_type}</span></td>` +
`<td class="wrap">${summary.replace(/</g, "&lt;")}</td>` +
`<td class="num-col">${formatReminderTime(item.last_sent_at)}</td>` +
`<td class="num-col">${item.deadline || "—"}</td>` +
`<td><span class="pill ${reminderStatusPill(item.status_ui)}">${reminderStatusLabel(item.status_ui)}</span></td>` +
'<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm act-remind">再提醒</button><button type="button" class="btn btn-sm btn-ghost act-detail">详情</button></div></td>';
return tr;
}));
refreshReminderCounts(result.stats || {});
}
function refreshReminderCounts(stats) {
const rows = $$("#reminder-tbody tr");
let all = rows.length, sys = 0, man = 0;
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
$("#count-all").textContent = all;
$("#count-system").textContent = sys;
$("#count-manual").textContent = man;
$("#table-foot-count").textContent = `${all} 条提醒记录`;
if (stats) {
$("#table-foot-state").textContent = `未读 ${stats.unread || 0} · 处理中 ${stats.doing || 0} · 已完成 ${stats.done || 0}`;
}
}
async function sendPendingReminders(keys) {
const response = await fetch("/api/admin/reminders/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dedupe_keys: keys }),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return null; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger");
return null;
}
return result;
}
function openReminderDetailDrawer(reminderId) {
fetch(`/api/admin/reminders/${reminderId}`)
.then((response) => response.json())
.then((result) => {
const item = result.reminder;
if (!item) return;
$("#rd-title").textContent = item.title;
$("#rd-sub").textContent = item.content;
const sourcePill = $("#rd-source-pill");
if (sourcePill) {
sourcePill.textContent = item.source === "auto" ? "系统" : "人工";
sourcePill.className = item.source === "auto" ? "pill pill-info" : "tag";
}
$("#rd-fields").innerHTML =
`<dt>公司</dt><dd>${item.company_name}</dd>` +
`<dt>类型</dt><dd>${item.display_type}</dd>` +
`<dt>触发原因</dt><dd>${item.rule_params?.pending_count ? `待处理 ${item.rule_params.pending_count}` : (item.rule_params?.period || "—")}</dd>` +
`<dt>发送时间</dt><dd>${formatReminderTime(item.last_sent_at)}</dd>` +
`<dt>催办次数</dt><dd>${item.send_count}</dd>` +
`<dt>处理状态</dt><dd>${reminderStatusLabel(item.status_ui)}</dd>`;
$("#rd-events").innerHTML = (item.events || []).map((ev) =>
`<div class="list-row"><div class="lr-main"><div class="lr-title">${ev.event_type}</div><div class="lr-sub meta">${formatReminderTime(ev.created_at)} · ${ev.actor}${ev.detail ? " · " + ev.detail : ""}</div></div></div>`,
).join("") || '<div class="empty"><div class="e-title">暂无事件</div></div>';
$("#reminder-detail-drawer")?.classList.add("is-open");
})
.catch(() => showToast("加载详情失败", "", "danger"));
}
function initAdminReminders() {
loadAdminRemindersPending();
loadAdminRemindersHistory("all");
$("#reminder-scan-btn")?.addEventListener("click", async () => {
const response = await fetch("/api/admin/reminders/scan", { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("扫描失败", result?.message || "请稍后重试", "danger");
return;
}
await loadAdminRemindersPending();
showToast("扫描完成", `发现 ${result.summary?.items || 0} 项待提醒`, "success");
});
$("#pending-send-all")?.addEventListener("click", async () => {
const checked = $$(".pending-check:checked");
let keys;
if (checked.length) {
keys = checked.map((input) => input.closest(".list-row")?.dataset.dedupeKey).filter(Boolean);
} else {
keys = JSON.parse($("#pending-send-all").dataset.keys || "[]");
}
if (!keys.length) return;
const result = await sendPendingReminders(keys);
if (!result) return;
showToast(checked.length ? "已发送选中提醒" : "已全部发送", `${result.count}`, "success");
await loadAdminRemindersPending();
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#pending-list")?.addEventListener("click", async (event) => {
const btn = event.target.closest(".pending-send-one");
if (!btn) return;
const row = btn.closest(".list-row");
const key = row?.dataset.dedupeKey;
if (!key) return;
const result = await sendPendingReminders([key]);
if (!result) return;
if (!motionQuery.matches) {
row.style.transition = "opacity 0.2s ease";
row.style.opacity = "0";
setTimeout(() => row.remove(), 200);
} else {
row.remove();
}
showToast("已发送提醒", "", "success");
await loadAdminRemindersPending();
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#pending-list")?.addEventListener("change", (event) => {
if (!event.target.classList.contains("pending-check")) return;
const checked = $$(".pending-check:checked").length;
const sendAll = $("#pending-send-all");
if (!sendAll) return;
sendAll.textContent = checked ? `发送选中(${checked}` : "全部一键发送";
});
$("#reminderForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const checked = Array.prototype.slice.call(form.querySelectorAll('input[name="company"]:checked'));
const type = $("#reminder-type").value;
const content = $("#reminder-content").value.trim();
const deadline = $("#reminder-deadline").value;
$("#company-error").style.display = checked.length ? "none" : "";
$("#content-error").style.display = content ? "none" : "";
if (!checked.length || !content) return;
for (const input of checked) {
const response = await fetch("/api/admin/reminders/manual", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
company_id: Number(input.value),
display_type: type,
content,
deadline: deadline || null,
}),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger");
return;
}
}
const companies = checked.map((c) => {
const company = (state.companies || []).find((item) => String(item.id) === c.value);
return company?.name || c.value;
}).join("、");
form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; });
const sendHint = $("#send-hint");
sendHint.style.display = "";
sendHint.textContent = `已发送给 ${companies},共 ${checked.length} 家公司。`;
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
showToast("人工提醒已发送", companies, "success");
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#reminder-tabs")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
loadAdminRemindersHistory(btn.dataset.filter);
});
$("#reminder-tbody")?.addEventListener("click", async (event) => {
const detailBtn = event.target.closest(".act-detail");
if (detailBtn) {
const tr = detailBtn.closest("tr");
openReminderDetailDrawer(tr.dataset.reminderId);
return;
}
const remindBtn = event.target.closest(".act-remind");
if (!remindBtn) return;
const tr = remindBtn.closest("tr");
const response = await fetch(`/api/admin/reminders/${tr.dataset.reminderId}/resend`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("再提醒失败", result?.message || "请稍后重试", "danger");
return;
}
remindBtn.textContent = "已再提醒";
remindBtn.disabled = true;
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
showToast("已再次发送提醒", "", "success");
});
$$("[data-close-rd]").forEach((btn) => btn.addEventListener("click", () => {
$("#reminder-detail-drawer")?.classList.remove("is-open");
}));
}
async function loadAdminCompanies() {
const tbody = $("#companyTable tbody");
showTableLoading(tbody, 6);
@@ -2088,7 +1787,7 @@ async function loadAdminCompanies() {
const companies = result?.companies || [];
state.companies = companies;
renderAdminCompanyTable(companies);
fillCompanySelects(companies.map((company) => company.name), companies);
fillCompanySelects(companies.map((company) => company.name));
if (companies.length >= 2) setPair(companies[0].name, companies[1].name);
}
@@ -2495,18 +2194,7 @@ function initAdmin() {
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
applySystemSettings(result.settings || {});
await loadCalculationSettings();
const gapDays = parseInt($("#cs-remind-days")?.value, 10) || 5;
const reminderResp = await fetch("/api/admin/reminder-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: { monthly_start_day: String(day || 5), gap_days: String(gapDays) } }),
}).catch(() => null);
if (reminderResp?.status === 401) { window.location.href = "index.html"; return; }
if (!reminderResp?.ok) {
showToast("提醒扫描参数保存失败", "", "danger");
return;
}
showToast("系统计算口径已保存", "结账日与起算日变更已留痕 · 提醒扫描参数已同步更新", "success");
showToast("系统计算口径已保存", "结账日与起算日变更已留痕", "success");
});
$("#runClosingCheck")?.addEventListener("click", () => {
@@ -2631,9 +2319,173 @@ function initAdmin() {
showToast(confirmBtn ? "期初已确认" : "期初已作废", "变更已留痕", "success");
});
initAdminReminders();
// ── 提醒管理:选公司 → 自动列出待提醒事项 → 一键发送 ──
const reminderSelect = $("#reminder-company");
const reminderItems = $("#reminder-items");
const reminderError = $("#reminder-error");
const companyError = $("#company-error");
const sendBtn = $("#send-btn");
const esc = (text) => String(text ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const fmtReminderTime = (iso) => String(iso || "").replace("T", " ").slice(5, 16);
let pendingReminderItems = [];
function renderPendingItems(items) {
pendingReminderItems = items || [];
if (!reminderItems) return;
if (!pendingReminderItems.length) {
reminderItems.innerHTML = '<div class="empty">该公司当前没有待提醒事项。</div>';
if (sendBtn) sendBtn.disabled = true;
return;
}
reminderItems.replaceChildren(...pendingReminderItems.map((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.innerHTML =
'<span class="pill pill-warn" style="flex:none;">待处理</span>' +
'<div class="lr-main"><div class="lr-title">' + esc(item.kind) + '</div>' +
'<div class="lr-sub">' + esc(item.content) + '</div></div>';
return row;
}));
if (sendBtn) sendBtn.disabled = false;
}
async function loadPendingReminders(companyId) {
if (!companyId) { renderPendingItems([]); return; }
if (reminderItems) reminderItems.innerHTML = '<div class="loading-row">正在列出待提醒事项…</div>';
if (sendBtn) sendBtn.disabled = true;
const response = await fetch("/api/admin/reminders/pending?company_id=" + encodeURIComponent(companyId)).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) { renderPendingItems([]); return; }
renderPendingItems(result.items || []);
}
reminderSelect?.addEventListener("change", () => {
if (reminderError) reminderError.style.display = "none";
loadPendingReminders(reminderSelect.value);
});
$("#reminderForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const companyId = reminderSelect?.value;
if (companyError) companyError.style.display = companyId ? "none" : "";
if (!companyId) return;
if (!pendingReminderItems.length) {
if (reminderError) reminderError.style.display = "";
return;
}
if (reminderError) reminderError.style.display = "none";
if (sendBtn) sendBtn.disabled = true;
const response = await fetch("/api/admin/reminders/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ company_id: Number(companyId) }),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
if (sendBtn) sendBtn.disabled = false;
showToast("发送失败", result?.message || "请稍后重试", "danger");
return;
}
const sendHint = $("#send-hint");
if (sendHint) {
sendHint.style.display = "";
sendHint.textContent = `已向 ${result.company_name} 发送 ${(result.reminders || []).length} 项提醒,截止 ${result.deadline || "—"}`;
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
}
await loadReminderHistory();
await loadPendingReminders(companyId);
showToast("提醒已发送", `已生成 ${(result.reminders || []).length} 条提醒记录`, "success");
});
// ── 提醒历史(实时从后端读取) ──
let reminderFilter = "all";
function refreshReminderCounts() {
const rows = $$("#reminder-tbody tr");
let all = rows.length, sys = 0, man = 0;
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
const countAll = $("#count-all"), countSys = $("#count-system"), countMan = $("#count-manual");
if (countAll) countAll.textContent = all;
if (countSys) countSys.textContent = sys;
if (countMan) countMan.textContent = man;
const foot = $("#table-foot-count");
if (foot) foot.textContent = "共 " + all + " 条提醒记录";
const state = $("#table-foot-state");
if (state) state.textContent = "未读 " + all + " · 处理中 0 · 已完成 0";
}
function applyReminderFilter() {
$$("#reminder-tbody tr").forEach((r) => {
r.style.display = (reminderFilter === "all" || r.dataset.source === reminderFilter) ? "" : "none";
});
}
async function loadReminderHistory() {
const tbody = $("#reminder-tbody");
if (!tbody) return;
const response = await fetch("/api/admin/reminders").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
const reminders = result?.reminders || [];
tbody.replaceChildren(...reminders.map((reminder) => {
const row = document.createElement("tr");
row.dataset.source = reminder.source;
row.innerHTML =
'<td class="cell-main">' + esc(reminder.company_name) + '</td>' +
'<td><span class="tag">' + esc(reminder.kind) + '</span></td>' +
'<td class="wrap">' + esc(reminder.content) + '</td>' +
'<td class="meta">' + fmtReminderTime(reminder.created_at) + '<span class="cell-sub">' + (reminder.source === "manual" ? "人工" : "系统") + (reminder.actor_username ? " · " + esc(reminder.actor_username) : "") + '</span></td>' +
'<td class="meta">' + (reminder.deadline || "—") + '</td>' +
'<td><span class="pill pill-danger">未读</span></td>' +
'<td><button type="button" class="btn btn-sm btn-ghost act-history" data-company-id="' + reminder.company_id + '" data-company="' + esc(reminder.company_name) + '">查看历史</button></td>';
return row;
}));
applyReminderFilter();
refreshReminderCounts();
}
$("#reminder-tabs")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
reminderFilter = btn.dataset.filter;
applyReminderFilter();
});
$("#reminder-tbody")?.addEventListener("click", (event) => {
const historyBtn = event.target.closest(".act-history");
if (historyBtn) openReminderHistory(historyBtn.dataset.companyId, historyBtn.dataset.company);
});
async function openReminderHistory(companyId, companyName) {
$("#history-title").textContent = (companyName || "") + " · 提醒历史";
$("#history-sub").textContent = "该公司的全部提醒触达记录,按时间倒序。";
const list = $("#history-list");
list.innerHTML = '<div class="loading-row">加载中…</div>';
const response = await fetch("/api/admin/reminders?company_id=" + encodeURIComponent(companyId || "")).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
const items = result?.reminders || [];
if (!items.length) {
list.innerHTML = '<div class="empty"><div class="e-title">暂无历史记录</div>该公司尚未收到过提醒。</div>';
} else {
list.innerHTML = items.map((it) => {
return '<div class="list-row">' +
'<span class="tag">' + esc(it.kind) + '</span>' +
'<div class="lr-main"><div class="lr-title">' + esc(it.content) + '</div><div class="lr-sub"><span class="meta">' + fmtReminderTime(it.created_at) + ' · ' + (it.source === "manual" ? "人工" : "系统") + (it.actor_username ? " · " + esc(it.actor_username) : "") + '</span></div></div>' +
'<div class="lr-side"><span class="pill pill-danger">未读</span></div></div>';
}).join("");
}
openModal("history-modal");
}
$("#history-close")?.addEventListener("click", () => closeModal("history-modal"));
$("#history-ok")?.addEventListener("click", () => closeModal("history-modal"));
loadSystemSettings();
loadReminderHistory();
}
const FLOW_DEMO = [
@@ -3435,60 +3287,34 @@ function initReconcile() {
tabSubject?.addEventListener("click", () => switchTab("subject"));
}
function renderCompanyNoticeRow(item) {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.status = item.status_ui;
row.dataset.reminderId = String(item.id);
const titleWeight = item.status_ui === "unread" ? "650" : "400";
const titleColor = item.status_ui === "unread" ? "var(--fg)" : "var(--muted)";
const sourceTag = item.source === "auto" ? '<span class="pill pill-info">系统</span>' : '<span class="tag">管理员</span>';
const actionLink = item.action_link;
let side = "";
if (item.status_ui === "unread") {
side = '<button class="btn btn-sm btn-mark-read">标记已读</button>';
} else if (item.status_ui === "doing" && actionLink) {
side = `<button class="btn btn-sm btn-primary" data-view-link="${actionLink}">去处理</button>`;
} else if (item.status_ui === "doing") {
side = '<button class="btn btn-sm btn-ghost btn-mark-done">标记完成</button>';
}
row.innerHTML =
`<span class="pill ${reminderStatusPill(item.status_ui)}" style="flex:none;">${reminderStatusLabel(item.status_ui)}</span>` +
sourceTag +
`<div class="lr-main"><div class="lr-title" style="font-weight:${titleWeight};color:${titleColor};">${item.title}</div>` +
`<div class="lr-sub"><span class="meta">${formatReminderTime(item.last_sent_at)}</span> · ${item.content}</div></div>` +
`<div class="lr-side">${side}</div>`;
return row;
}
function initNotifications() {
const tabs = $("#notice-tabs");
const list = $("#notice-list");
if (!tabs || !list) return;
const rows = $$(".list-row", list);
const emptyBox = $("#notice-empty");
let currentFilter = "all";
let rows = [];
function refreshCounts(stats, unreadCount) {
const c = stats || { all: rows.length, unread: 0, doing: 0, done: 0 };
if (!stats) {
c.all = rows.length;
rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; });
} else {
c.all = rows.length;
}
const STATUS_PILL = { unread: "pill-danger", doing: "pill-warn", done: "pill-success" };
const STATUS_LABEL = { unread: "未读", doing: "处理中", done: "已完成" };
function counts() {
const c = { all: rows.length, unread: 0, doing: 0, done: 0 };
rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; });
return c;
}
function refreshCounts() {
const c = counts();
$("#count-all").textContent = c.all;
$("#count-unread").textContent = c.unread;
$("#count-doing").textContent = c.doing;
$("#count-done").textContent = c.done;
const navBadge = $('.side-nav a[data-view="notifications"] .nav-badge');
const unread = unreadCount ?? c.unread;
if (navBadge) {
navBadge.textContent = unread;
navBadge.style.display = unread ? "" : "none";
navBadge.textContent = c.unread;
navBadge.style.display = c.unread ? "" : "none";
}
const wsLink = $("#workspace-notice-link");
if (wsLink) wsLink.textContent = unread ? `全部通知 (${c.all}) →` : "全部通知 →";
}
function applyFilter() {
@@ -3498,35 +3324,15 @@ function initNotifications() {
row.style.display = show ? "" : "none";
if (show) visible += 1;
});
if (emptyBox) emptyBox.style.display = visible === 0 ? "" : "none";
emptyBox.style.display = visible === 0 ? "" : "none";
}
async function loadReminders() {
const response = await fetch("/api/company/reminders").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const items = result.reminders || [];
list.replaceChildren(...items.map((item) => renderCompanyNoticeRow(item)));
rows = $$(".list-row", list);
refreshCounts(result.stats, result.unread_count);
applyFilter();
const wsList = $("#workspace-notice-list");
if (wsList) {
const preview = items.slice(0, 3);
if (!preview.length) {
wsList.innerHTML = '<div class="empty"><div class="e-title">暂无通知</div></div>';
} else {
wsList.replaceChildren(...preview.map((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.innerHTML =
`<span class="pill ${reminderTypePill(item.rule_key)}">${item.display_type}</span>` +
`<div class="lr-main"><div class="lr-title">${item.title}</div><div class="lr-sub">${item.content.slice(0, 48)}</div></div>` +
`<span class="lr-side meta">${formatReminderTime(item.last_sent_at).slice(5, 10)}</span>`;
return row;
}));
}
function setStatus(row, status) {
row.setAttribute("data-status", status);
const pill = $(".pill", row);
if (pill) {
pill.className = `pill ${STATUS_PILL[status]}`;
pill.textContent = STATUS_LABEL[status];
}
}
@@ -3540,38 +3346,29 @@ function initNotifications() {
applyFilter();
});
list.addEventListener("click", async (event) => {
const viewLink = event.target.closest("[data-view-link]");
if (viewLink && list.contains(viewLink)) {
event.preventDefault();
showView(viewLink.dataset.viewLink);
return;
}
const readBtn = event.target.closest(".btn-mark-read");
const doneBtn = event.target.closest(".btn-mark-done");
const btn = readBtn || doneBtn;
list.addEventListener("click", (event) => {
const btn = event.target.closest(".btn-mark-read");
if (!btn) return;
const row = btn.closest(".list-row");
const id = row?.dataset.reminderId;
if (!id) return;
const action = readBtn ? "acknowledge" : "resolve";
const response = await fetch(`/api/company/reminders/${id}/${action}`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
if (!response?.ok) return;
await loadReminders();
setStatus(row, "done");
btn.remove();
refreshCounts();
applyFilter();
});
$("#mark-all-read")?.addEventListener("click", async () => {
const unreadRows = rows.filter((row) => row.getAttribute("data-status") === "unread");
for (const row of unreadRows) {
const id = row.dataset.reminderId;
await fetch(`/api/company/reminders/${id}/acknowledge`, { method: "POST" }).catch(() => null);
}
await loadReminders();
$("#mark-all-read")?.addEventListener("click", () => {
rows.forEach((row) => {
if (row.getAttribute("data-status") !== "unread") return;
setStatus(row, "done");
$(".btn-mark-read", row)?.remove();
});
refreshCounts();
applyFilter();
showToast("通知已全部标为已读", "", "success");
});
loadReminders();
refreshCounts();
applyFilter();
}
function openAccountDetail(account) {
@@ -3598,7 +3395,7 @@ function yuanToWan(value) {
return n / 10000;
}
function formatWanHtml(value, { signed = false } = {}) {
function formatWan(value, { signed = false } = {}) {
const wan = yuanToWan(value);
if (wan === null) return "—";
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -3675,13 +3472,13 @@ function renderWorkspaceTransfersCard(summary) {
const net = $("#wsTfNet");
const pendingEl = $("#wsTfPending");
const netLabel = $("#wsTfNetLabel");
if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true });
if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true });
if (outflow) {
outflow.innerHTML = formatWanHtml(
outflow.innerHTML = formatWan(
confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null,
{ signed: true },
);
// formatWanHtml with negative value already adds ; ensure unit
// formatWan with negative value already adds ; ensure unit
if (confirmed.outflow_total != null) {
const wan = yuanToWan(confirmed.outflow_total);
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -3691,12 +3488,12 @@ function renderWorkspaceTransfersCard(summary) {
if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`;
if (net) {
net.className = `ms-value ${netMeta.signedClass}`.trim();
net.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
net.innerHTML = formatWan(confirmed.net_change, { signed: true });
}
if (pendingEl) {
const pCount = Number(pending.count) || 0;
pendingEl.innerHTML = pCount
? `${formatWanHtml(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
? `${formatWan(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
: `0.00<span class="unit">万元 · 0 笔</span>`;
}
}
@@ -3767,7 +3564,7 @@ function renderTransfersOverview(summary) {
const netFoot = $("#tfStatNetFoot");
const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction);
if (companies) companies.innerHTML = `${cps.length}<span class="unit">家</span>`;
if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true });
if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true });
if (outflow) {
const wan = yuanToWan(confirmed.outflow_total);
const abs = wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -3778,7 +3575,7 @@ function renderTransfersOverview(summary) {
const tag = netMeta.label !== "持平"
? ` <span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span>`
: "";
net.innerHTML = `${formatWanHtml(confirmed.net_change, { signed: true })}${tag}`;
net.innerHTML = `${formatWan(confirmed.net_change, { signed: true })}${tag}`;
}
if (netFoot) {
netFoot.textContent = win.has_opening
@@ -3792,7 +3589,7 @@ function renderTransfersOverview(summary) {
const tfPendingCount = $("#tfPendingCount");
const tfPendingAmount = $("#tfPendingAmount");
if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount}`;
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWan(confirmed.net_change, { signed: true });
const openingCard = $("#tfStatOpeningCard");
const endingCard = $("#tfStatEndingCard");
const openingEl = $("#tfStatOpening");
@@ -3800,15 +3597,15 @@ function renderTransfersOverview(summary) {
if (win.has_opening) {
if (openingCard) openingCard.hidden = false;
if (endingCard) endingCard.hidden = false;
if (openingEl) openingEl.innerHTML = formatWanHtml(win.opening, { signed: true });
if (endingEl) endingEl.innerHTML = formatWanHtml(win.ending, { signed: true });
if (openingEl) openingEl.innerHTML = formatWan(win.opening, { signed: true });
if (endingEl) endingEl.innerHTML = formatWan(win.ending, { signed: true });
} else {
if (openingCard) openingCard.hidden = true;
if (endingCard) endingCard.hidden = true;
}
if (tfPendingCount) tfPendingCount.textContent = `${pCount}`;
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWanHtml(pending.amount_total || 0);
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWan(pending.amount_total || 0);
const tbody = $("#transfersCpBody");
const tfoot = $("#transfersCpFoot");
@@ -3909,7 +3706,7 @@ async function openTransfersDetail(companyId, companyName, { keepFilters = false
$("#tfDetailCount").innerHTML = ``;
$("#tfDetailCountFoot").textContent = pendingN ? `其中待确认 ${pendingN} 笔(汇总)` : "按筛选加载明细";
if (cp) {
$("#tfDetailIn").innerHTML = formatWanHtml(cp.confirmed_inflow, { signed: true });
$("#tfDetailIn").innerHTML = formatWan(cp.confirmed_inflow, { signed: true });
const wan = yuanToWan(cp.confirmed_outflow);
$("#tfDetailOut").innerHTML = wan === null
? "—"
+110 -11
View File
@@ -26,7 +26,7 @@
<a data-view="reconcile" href="#reconcile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">往来确认</span><span class="nav-badge">5</span></a>
<div class="nav-group">账户与消息</div>
<a data-view="accounts" href="#accounts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg><span class="nav-label">银行账户</span></a>
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge" id="notice-nav-badge" style="display: none;">0</span></a>
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge">3</span></a>
</nav>
<div class="side-foot">
<div class="user-row">
@@ -181,10 +181,31 @@
<div class="card">
<div class="card-head">
<span class="card-title">最新通知<span class="sub">来自管理端</span></span>
<button class="btn btn-sm btn-ghost" data-view-link="notifications" id="workspace-notice-link">全部通知 →</button>
<button class="btn btn-sm btn-ghost" data-view-link="notifications">全部通知 (3)</button>
</div>
<div id="workspace-notice-list">
<div class="empty"><div class="e-title">暂无通知</div></div>
<div class="list-row">
<span class="pill pill-warn">提醒</span>
<div class="lr-main">
<div class="lr-title">7 月账期结账日顺延至 08-29</div>
<div class="lr-sub">请在此之前完成往来确认事项</div>
</div>
<span class="lr-side meta">08-18</span>
</div>
<div class="list-row">
<span class="pill pill-warn">提醒</span>
<div class="lr-main">
<div class="lr-title">3 笔单边流水待确认</div>
<div class="lr-sub">涉及金牛置业、金牛贸易往来</div>
</div>
<span class="lr-side meta">08-16</span>
</div>
<div class="list-row">
<span class="pill pill-success">通过</span>
<div class="lr-main">
<div class="lr-title">中行尾号 9916 流水导入成功</div>
<div class="lr-sub">流水覆盖至 08-12</div>
</div>
<span class="lr-side meta">08-12</span>
</div>
</div>
</div>
@@ -783,15 +804,93 @@
<div class="card">
<div class="tabs" id="notice-tabs">
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">0</span></button>
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">0</span></button>
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">0</span></button>
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">0</span></button>
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">9</span></button>
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">3</span></button>
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">2</span></button>
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">4</span></button>
</div>
<div id="notice-list"></div>
<div id="notice-list">
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">7 月流水存在断档风险:交行 尾号 7710 未上传</div>
<div class="lr-sub"><span class="meta">2026-08-20 14:32</span> · 交行 7710 账户(待审核)7 月流水尚未上传,距 7 月结账日(顺延至 08-29)仅剩 9 天,请尽快补传。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">您有 2 笔单边流水待选择对方证据</div>
<div class="lr-sub"><span class="meta">2026-08-19 09:15</span> · 与金牛置业的煤炭采购款 ¥3,200,000.00 等 2 笔流水仅有本方记录,需选择对方银行流水佐证。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">管理员提醒:请于 08-29 前完成待确认事项</div>
<div class="lr-sub"><span class="meta">2026-08-18 16:40</span> · 7 月账期结账顺延至 08-29,贵公司当前仍有 5 项往来确认未完成,请合理安排时间。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="doing">
<span class="pill pill-warn" style="flex: none;">处理中</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">手工记录科目待确认:其他应收 ¥86,500.00</div>
<div class="lr-sub"><span class="meta">2026-08-15 10:02</span> · 07-28 录入的矿区备用金垫付记录,往来科目「其他应收」待管理员复核。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="manual">去处理</button></div>
</div>
<div class="list-row" data-status="doing">
<span class="pill pill-warn" style="flex: none;">处理中</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">往来确认回复:置业已确认 320 万煤炭采购款</div>
<div class="lr-sub"><span class="meta">2026-08-12 11:26</span> · 金牛置业已确认 07-14 煤炭采购款 ¥3,200,000.00,请补充本方工行 3305 账户流水证据完成闭环。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="reconcile">去处理</button></div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">流水导入成功:中行 尾号 9916 · 42 笔</div>
<div class="lr-sub"><span class="meta">2026-08-06 15:52</span> · 7 月中行一般户流水已导入,42 笔全部校验通过,无重复记录。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">流水导入成功:工行 尾号 3305 · 86 笔</div>
<div class="lr-sub"><span class="meta">2026-08-06 15:48</span> · 7 月工行基本户流水已导入,86 笔全部校验通过,无重复记录。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">交行 尾号 7710 账户开户资料已受理</div>
<div class="lr-sub"><span class="meta">2026-07-22 13:10</span> · 新账户开户资料已提交管理员,审核通过后方可启用并上传流水。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">2026 年 6 月账期已结账</div>
<div class="lr-sub"><span class="meta">2026-07-05 09:00</span> · 6 月账期已按每月 5 日结账规则完成结账,期末数据已锁定,不可再修改。</div>
</div>
</div>
</div>
<div class="empty" id="notice-empty">
<div class="empty" id="notice-empty" style="display: none;">
<div class="e-title">暂无该状态的通知</div>
<div>切换其他状态查看,或等待新的系统与管理员消息。</div>
</div>
@@ -1009,6 +1108,6 @@
</aside>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=13"></script>
<script src="app.js?v=12"></script>
</body>
</html>