diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 4fc513f..2da6ee5 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,3 +1,5 @@ +⚠️ 本文档已过时,仅留档备查,请勿删除。当前代码状态请看 `README.md`、`项目需求.md`、`最新进度.md` 和 `任务清单.md`。 + # 项目交接说明 更新时间:2026-08-06 diff --git a/docs/issues/001-p0-repository-data-hygiene.md b/docs/issues/001-p0-repository-data-hygiene.md index a448933..31b1466 100644 --- a/docs/issues/001-p0-repository-data-hygiene.md +++ b/docs/issues/001-p0-repository-data-hygiene.md @@ -1,3 +1,5 @@ +⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。 + # [P0] 仓库银行样本数据分级与脱敏治理 ## 背景 diff --git a/docs/issues/002-p0-persistence-and-immutable-imports.md b/docs/issues/002-p0-persistence-and-immutable-imports.md index a933df8..8361134 100644 --- a/docs/issues/002-p0-persistence-and-immutable-imports.md +++ b/docs/issues/002-p0-persistence-and-immutable-imports.md @@ -1,3 +1,5 @@ +⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。 + # [P0] 持久化层与不可变银行导入基础 ## 背景 diff --git a/docs/issues/003-p0-auth-and-tenant-isolation.md b/docs/issues/003-p0-auth-and-tenant-isolation.md index c5f375e..9ba6e2a 100644 --- a/docs/issues/003-p0-auth-and-tenant-isolation.md +++ b/docs/issues/003-p0-auth-and-tenant-isolation.md @@ -1,3 +1,5 @@ +⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。 + # [P0] 正式认证、RBAC 与公司级数据隔离 ## 背景 diff --git a/docs/issues/004-p1-dynamic-master-data.md b/docs/issues/004-p1-dynamic-master-data.md index 824f70b..f89e98b 100644 --- a/docs/issues/004-p1-dynamic-master-data.md +++ b/docs/issues/004-p1-dynamic-master-data.md @@ -1,3 +1,5 @@ +⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。 + # [P1] 动态公司、用户、账户和别名主数据 ## 背景 diff --git a/docs/issues/005-p1-import-api-hardening.md b/docs/issues/005-p1-import-api-hardening.md index 9738e3a..f9a927b 100644 --- a/docs/issues/005-p1-import-api-hardening.md +++ b/docs/issues/005-p1-import-api-hardening.md @@ -1,3 +1,5 @@ +⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。 + # [P1] 银行导入 API 加固与回归测试 ## 背景 diff --git a/docs/issues/006-p1-canonical-transfer-matching.md b/docs/issues/006-p1-canonical-transfer-matching.md index f42b896..53333a2 100644 --- a/docs/issues/006-p1-canonical-transfer-matching.md +++ b/docs/issues/006-p1-canonical-transfer-matching.md @@ -1,3 +1,5 @@ +⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。 + # [P1] 规范转账事件、双边匹配与调拨排除 ## 背景 diff --git a/server.py b/server.py index b86020d..1fb1cdc 100644 --- a/server.py +++ b/server.py @@ -6,6 +6,9 @@ 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 @@ -13,7 +16,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, - settings, subjects, + reminders, settings, subjects, ) from bank_importer.db import connect, migrate, utc_now @@ -108,12 +111,6 @@ 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 @@ -147,6 +144,26 @@ 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": @@ -322,9 +339,6 @@ 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( @@ -354,6 +368,30 @@ 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: @@ -1921,155 +1959,6 @@ 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) # ------------------------------------------------------------------ @@ -3786,6 +3675,260 @@ 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.""" @@ -3858,6 +4001,44 @@ 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( @@ -3886,6 +4067,8 @@ 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() diff --git a/src/bank_importer/db.py b/src/bank_importer/db.py index f1f6ee2..589e7ad 100644 --- a/src/bank_importer/db.py +++ b/src/bank_importer/db.py @@ -1011,6 +1011,91 @@ 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); + """, + ), ) diff --git a/src/bank_importer/reminders.py b/src/bank_importer/reminders.py new file mode 100644 index 0000000..7f4e3ff --- /dev/null +++ b/src/bank_importer/reminders.py @@ -0,0 +1,757 @@ +"""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"]) diff --git a/src/bank_importer/settings.py b/src/bank_importer/settings.py index 8bff033..8700601 100644 --- a/src/bank_importer/settings.py +++ b/src/bank_importer/settings.py @@ -1,20 +1,20 @@ -"""System settings and reminder item generation. +"""System settings. System-wide parameters (closing day, global start date, auto-reminder toggle and lead days) are persisted in ``system_settings`` with an append-only -``system_setting_changes`` trail. Reminder pending items are derived from real -backend data (per-sheet reviews, bank accounts, canonical transfer decisions) -rather than hard-coded rosters. +``system_setting_changes`` trail. The auto-reminder engine itself lives in +``reminders.py`` (HEL-195); this module only owns the settings store. """ from __future__ import annotations import re import sqlite3 -from datetime import datetime, timezone +from datetime import datetime 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,179 +131,3 @@ 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 diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 683d1fa..4998f99 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase): class MigrationTests(PersistenceTestCase): def test_migrate_creates_schema_and_is_idempotent(self) -> None: first = applied_versions(self.connection) - self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], first) + self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], 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([8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0)) + self.assertEqual([9, 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], migrate(self.connection)) - self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], applied_versions(self.connection)) + 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)) 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([8, 7, 6, 5], rollback(self.connection, 4)) + self.assertEqual([9, 8, 7, 6, 5], rollback(self.connection, 4)) # The pre-migration evidence and schema are untouched. self.assertEqual( row_count, diff --git a/tests/test_reminders.py b/tests/test_reminders.py new file mode 100644 index 0000000..abe0664 --- /dev/null +++ b/tests/test_reminders.py @@ -0,0 +1,495 @@ +"""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) diff --git a/tests/test_server_auth.py b/tests/test_server_auth.py index 36039fe..9478c0d 100644 --- a/tests/test_server_auth.py +++ b/tests/test_server_auth.py @@ -287,6 +287,8 @@ 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() @@ -387,6 +389,10 @@ 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() @@ -557,6 +563,28 @@ 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() diff --git a/tests/test_settings_reminders.py b/tests/test_settings_reminders.py index 8212497..6b9024a 100644 --- a/tests/test_settings_reminders.py +++ b/tests/test_settings_reminders.py @@ -166,28 +166,6 @@ 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( diff --git a/web/admin.html b/web/admin.html index 412c0b1..92e0191 100644 --- a/web/admin.html +++ b/web/admin.html @@ -633,6 +633,22 @@
向成员公司发送处理提醒,并跟踪系统提醒与人工提醒的触达与处理状态。
+