HEL-222: 整合自动提醒引擎(212b1f9)到现行测试版 d6f39e0

- 迁移链追加 0009_reminders_engine:旧 manual reminders 表改名
  reminders_legacy_manual 保留历史,新建 reminders/reminder_events/
  reminder_settings(append-only 触发器)
- server.py:新提醒 API(pending/scan/send/manual/resend/settings/
  公司端收件与状态流转)替换旧 manual-send 端点
- web:admin 待提醒清单+扫描+详情抽屉,公司端通知动态化+
  去处理事件委托;设置保存同步提醒扫描参数
- 移除被取代的 settings.pending_items/send_reminders 与旧 UI 逻辑
- 全量测试 346 项通过(5 项浏览器跳过与历史一致)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-08-28 15:21:27 +00:00
co-authored by multica-agent
18 changed files with 2215 additions and 698 deletions
+342 -159
View File
@@ -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()