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 <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-08-28 13:33:06 +00:00
co-authored by Cursor multica-agent
parent 951b353765
commit f17636183d
8 changed files with 1934 additions and 270 deletions
+342 -1
View File
@@ -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()