From f17636183ddf680af852bf7413951ba0b661308c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Fri, 28 Aug 2026 13:33:06 +0000 Subject: [PATCH] Implement automatic reminder engine with admin and company UI. Add schema v6, read-only scan rules for unsubmitted flows, gaps and pending reviews, deduplicated delivery with append-only event history, daily scan thread, and full API/frontend integration with 18 new tests. Co-authored-by: Cursor Co-authored-by: multica-agent --- server.py | 343 +++++++++++++++- src/bank_importer/db.py | 72 ++++ src/bank_importer/reminders.py | 700 +++++++++++++++++++++++++++++++++ tests/test_persistence.py | 13 +- tests/test_reminders.py | 362 +++++++++++++++++ web/admin.html | 37 ++ web/app.js | 558 ++++++++++++++++++-------- web/company.html | 119 +----- 8 files changed, 1934 insertions(+), 270 deletions(-) create mode 100644 src/bank_importer/reminders.py create mode 100644 tests/test_reminders.py diff --git a/server.py b/server.py index bf89568..c328a02 100644 --- a/server.py +++ b/server.py @@ -6,11 +6,14 @@ 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 -from bank_importer import auth, importing, master_data, matching, multipart, personal_transit +from bank_importer import auth, importing, master_data, matching, multipart, personal_transit, reminders from bank_importer.db import connect, migrate, utc_now @@ -100,6 +103,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 if path == "/admin.html" and not self._guard_page("admin"): return @@ -167,6 +190,30 @@ class AppHandler(SimpleHTTPRequestHandler): if mapping_review: self._handle_admin_review_personal_mapping(int(mapping_review.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": "接口不存在。"}) # ------------------------------------------------------------------ @@ -1996,6 +2043,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.""" @@ -2068,6 +2369,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( @@ -2096,6 +2435,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 b83a618..88fbb50 100644 --- a/src/bank_importer/db.py +++ b/src/bank_importer/db.py @@ -569,6 +569,78 @@ MIGRATIONS: tuple[Migration, ...] = ( ALTER TABLE import_batches DROP COLUMN upload_bank_account_id; """, ), + Migration( + version=6, + name="0006_reminders", + up=""" + 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; + """, + ), ) diff --git a/src/bank_importer/reminders.py b/src/bank_importer/reminders.py new file mode 100644 index 0000000..ee15f83 --- /dev/null +++ b/src/bank_importer/reminders.py @@ -0,0 +1,700 @@ +"""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 update_settings(connection: sqlite3.Connection, updates: dict[str, str]) -> dict[str, str]: + allowed = set(DEFAULT_SETTINGS) + now = utc_now() + with connection: + for key, value in updates.items(): + if key not in allowed: + raise ValueError(f"unknown setting: {key}") + 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, str(value), now), + ) + return get_settings(connection) + + +def _setting_int(settings: dict[str, str], key: str) -> int: + return int(settings.get(key, 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 and existing["status"] != "resolved": + reminder_id = int(existing["id"]) + send_count = int(existing["send_count"]) + 1 + 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" if send_count > 1 else "sent", + 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: + reminder_id = deliver_finding(connection, key, actor=actor, ip=ip) + 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/tests/test_persistence.py b/tests/test_persistence.py index b23c6d5..fe4f057 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], first) + self.assertEqual([1, 2, 3, 4, 5, 6], first) self.assertEqual([], migrate(self.connection)) self.assertEqual(first, applied_versions(self.connection)) tables = { @@ -73,19 +73,22 @@ class MigrationTests(PersistenceTestCase): "transfer_match_candidates", "current_transfer_decisions", "transfer_observation_claims", + "reminder_settings", + "reminders", + "reminder_events", "schema_migrations", ): self.assertIn(table, tables) def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None: - self.assertEqual([5, 4, 3, 2, 1], rollback(self.connection, 0)) + self.assertEqual([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], migrate(self.connection)) - self.assertEqual([1, 2, 3, 4, 5], applied_versions(self.connection)) + self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection)) + self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection)) def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None: self.import_sample() @@ -93,7 +96,7 @@ class MigrationTests(PersistenceTestCase): "SELECT COUNT(*) AS n FROM source_rows" ).fetchone()["n"] self.assertGreater(row_count, 0) - self.assertEqual([5], rollback(self.connection, 4)) + self.assertEqual([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..09714cc --- /dev/null +++ b/tests/test_reminders.py @@ -0,0 +1,362 @@ +"""Tests for automatic reminder detection, delivery, isolation and audit trail.""" + +from __future__ import annotations + +from datetime import date, timedelta +import json +import unittest + +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_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"}) + + +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(6, versions[-1]["version"]) + connection.execute("DELETE FROM schema_migrations WHERE version = 6") + 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/web/admin.html b/web/admin.html index 1aa72b4..440d846 100644 --- a/web/admin.html +++ b/web/admin.html @@ -713,6 +713,22 @@

提醒管理

向成员公司发送处理提醒,并跟踪系统提醒与人工提醒的触达与处理状态。

+
+ +
+ + +
+
+ 待提醒清单系统按流水提交、断档、待确认自动发现,点发送即送达对应公司 +
+ + +
+
+
+
暂无需要提醒的事项 · 各公司流水与确认进度正常
+
@@ -764,6 +780,7 @@ 公司 + 来源 类型 内容摘要 发送时间 @@ -1035,6 +1052,26 @@
+ + +