- 迁移链追加 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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user