"""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"])