Compare commits

..
Author SHA1 Message Date
总工andmultica-agent 85e11a4f72 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>
2026-08-28 15:21:27 +00:00
212b1f9b9b Fix reminder resend, settings validation, and notice navigation.
Reuse resolved reminder rows instead of inserting duplicates, skip failed keys in batch send, reject invalid scan settings with 400, and bind 去处理 via event delegation after async render.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 14:31:43 +00:00
f17636183d 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>
2026-08-28 13:33:06 +00:00
总工andmultica-agent d6f39e018d HEL-208: 整合 b49892e 的 formatWan 拆分修复到 2fb1f2b(丢弃其对 HEL-206 方向修复与测试的误回退)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 11:49:23 +00:00
leefer 951b353765 Merge feature/ui-opendesign-shell: OpenDesign UI shell (HEL-120~125) 2026-08-25 12:17:03 +08:00
20d967a4a4 HEL-125: bump web asset ?v= to 4 for test-env deploy cache bust
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 12:15:24 +08:00
leeferandmultica-agent 31fdc2e456 HEL-124 返工:解析后恢复 #parseButton 可点 + detailContent 加「原型占位」注释
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 12:03:54 +08:00
leeferandmultica-agent 1d07812fa8 HEL-124: 第5批——缺口态补画 + 响应式扫尾 + 清理旧黑绿残留
- design-system.css: 补 .app-view/.is-active/.sr-only/.skip-link 结构类、Toast(带 kind)、侧滑详情抽屉、加载骨架/加载中行、.notice.success、窄屏侧栏折叠(汉堡菜单)
- app.js: 重写 showToast(带 kind) 与 initDetailDrawer 用新 token;fetch 补加载中/错误/空态;detailContent 改真实文案并接线 data-detail 触发;清理 animateView/initMotion/initShell 旧类名残留;COMPANY_FLOWS 加「原型占位」注释
- admin.html/company.html: 移除 styles.css 引用、bump ?v=3;待办列表接 data-detail
- 删除 web/styles.css(源头删净 #050505/#37eb89/--color-*/--jn-* 等旧黑绿残留)
- index/login 页 bump ?v=2

Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 11:49:38 +08:00
leeferandmultica-agent 2fe06b74ae HEL-123: 第4批——公司端剩余6页 + 对应弹窗换原型壳
- upload 流水导入: 多步流程(选文件→解析→分sheet审核→确认)完整保留, 内联卡片化 + 批次详情弹窗 #modal-batch
- manual 手工记录: 两栏布局 + 撤回确认弹窗 #withdraw-modal
- flows 流水管理: 公司端数据集 + 待确认/已确认状态口径 + 详情弹窗
- reconcile 往来确认: 候选匹配/匹配度/断档提示 + 未选候选禁用态 + 单边全部匹配空态
- accounts 银行账户: 表格 + 账户明细弹窗 + 新账户登记弹窗
- notifications 通知: tabs/已读/空态/角标
- 跨批依赖: 往来确认完成后回写工作台待办区(#workspacePendingStatus/待办行/账期流程条)

Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 11:03:17 +08:00
leeferandmultica-agent 307f9ba76f HEL-122: 第3批——管理端剩余6页 + 对应弹窗换原型壳
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 10:14:17 +08:00
leeferandmultica-agent 020b556801 HEL-121: 第2批——两端外壳 + 管理总览 + 公司工作台(按原型换壳,保留 showView/角标/退出逻辑)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 09:32:40 +08:00
leeferandmultica-agent 7938a274f2 HEL-120: 第1批门面——导引入口 + 两张登录页 + 首登改密
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 08:42:11 +08:00
leefer dc0fb76f8f Merge pull request 'HEL-56: 整理项目说明手册' (#8) from agent/agent/e89028e267fd into main
Reviewed-on: http://192.168.200.36:3200/leefer/caiwuzongzhang/pulls/8
2026-08-23 16:56:12 +08:00
Multica 总管andmultica-agent 5a6a629597 HEL-56: 整理项目说明手册
Co-authored-by: multica-agent <github@multica.ai>
2026-08-23 14:26:55 +08:00
leefer 5f215271af Merge pull request 'HEL-34: 登录页按「深海军蓝 × 账房金」方案改造' (#7) from feat/HEL-34-login-redesign into main
Reviewed-on: http://192.168.200.36:3200/leefer/caiwuzongzhang/pulls/7
2026-08-22 22:57:49 +08:00
腾讯WorkBuddyandmultica-agent 3b10a58d93 HEL-34: 登录页按「深海军蓝 × 账房金」方案改造
Co-authored-by: multica-agent <github@multica.ai>
2026-08-22 22:18:24 +08:00
leefer 6d23711e6c Merge pull request 'B-114: B-43 双边流水归并与规范事件层(匹配引擎、个人过账映射、事件API)' (#4) from leefer/b43-canonical-transfer-matching into main
Reviewed-on: http://192.168.200.36:3200/leefer/caiwuzongzhang/pulls/4
2026-08-19 00:55:52 +08:00
leefer a621ebc323 Merge pull request 'B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据' (#2) from feat/b42-import-api-hardening into main
Reviewed-on: http://192.168.200.36:3200/leefer/caiwuzongzhang/pulls/2
2026-08-17 08:53:37 +08:00
18 changed files with 2228 additions and 711 deletions
+2
View File
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。当前代码状态请看 `README.md``项目需求.md``最新进度.md``任务清单.md`
# 项目交接说明
更新时间:2026-08-06
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P0] 仓库银行样本数据分级与脱敏治理
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P0] 持久化层与不可变银行导入基础
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P0] 正式认证、RBAC 与公司级数据隔离
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P1] 动态公司、用户、账户和别名主数据
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P1] 银行导入 API 加固与回归测试
## 背景
@@ -1,3 +1,5 @@
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`
# [P1] 规范转账事件、双边匹配与调拨排除
## 背景
+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()
+85
View File
@@ -1011,6 +1011,91 @@ MIGRATIONS: tuple[Migration, ...] = (
DROP TABLE IF EXISTS closed_periods;
""",
),
Migration(
version=9,
name="0009_reminders_engine",
# HEL-195/215 auto-reminder engine. The old manual reminders table
# from 0007 is preserved verbatim under reminders_legacy_manual so
# existing test-env history stays queryable; the new engine uses its
# own append-only reminders/reminder_events schema.
up="""
ALTER TABLE reminders RENAME TO reminders_legacy_manual;
DROP INDEX IF EXISTS idx_reminders_company;
CREATE INDEX idx_reminders_legacy_company
ON reminders_legacy_manual (company_id);
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;
DROP INDEX IF EXISTS idx_reminders_legacy_company;
ALTER TABLE reminders_legacy_manual RENAME TO reminders;
CREATE INDEX idx_reminders_company ON reminders (company_id);
""",
),
)
+757
View File
@@ -0,0 +1,757 @@
"""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"])
+5 -181
View File
@@ -1,20 +1,20 @@
"""System settings and reminder item generation.
"""System settings.
System-wide parameters (closing day, global start date, auto-reminder toggle
and lead days) are persisted in ``system_settings`` with an append-only
``system_setting_changes`` trail. Reminder pending items are derived from real
backend data (per-sheet reviews, bank accounts, canonical transfer decisions)
rather than hard-coded rosters.
``system_setting_changes`` trail. The auto-reminder engine itself lives in
``reminders.py`` (HEL-195); this module only owns the settings store.
"""
from __future__ import annotations
import re
import sqlite3
from datetime import datetime, timezone
from datetime import datetime
from .db import utc_now
# Defaults are applied when a key is absent; the value type is always string.
DEFAULT_SETTINGS: dict[str, str] = {
"closing_day": "5",
@@ -131,179 +131,3 @@ def update_settings(
# ----------------------------------------------------------------------
# Reminder pending items
# ----------------------------------------------------------------------
def _current_period() -> tuple[str, str]:
"""Return the current calendar month as ``(start, end_exclusive)`` dates."""
today = datetime.now(timezone.utc)
start = today.strftime("%Y-%m-01")
year, month = today.year, today.month
if month == 12:
end = f"{year + 1}-01-01"
else:
end = f"{year}-{month + 1:02d}-01"
return start, end
def pending_items(connection: sqlite3.Connection, company_id: int) -> list[dict[str, str]]:
"""Derive the list of pending reminder items for one company."""
items: list[dict[str, str]] = []
company = connection.execute(
"SELECT id, name FROM companies WHERE id = ?", (company_id,)
).fetchone()
if company is None:
return items
period_start, _ = _current_period()
# 1) 本月流水未提交:有已启用账户,但本月没有任何已确认的工作表。
active_accounts = connection.execute(
"""
SELECT id, account_number FROM bank_accounts
WHERE company_id = ? AND status = 'active'
""",
(company_id,),
).fetchall()
confirmed_this_period = connection.execute(
"""
SELECT COUNT(*) AS n
FROM sheet_reviews rv
JOIN import_batches b ON b.id = rv.import_batch_id
WHERE b.company_id = ? AND rv.review_status = 'confirmed'
AND rv.sheet_batch_id IN (
SELECT id FROM sheet_batches s
WHERE s.period_end >= ?
)
""",
(company_id, period_start),
).fetchone()["n"]
if active_accounts and confirmed_this_period == 0:
items.append(
{
"kind": "流水未提交",
"content": "本月各银行账户流水尚未提交,请尽快上传本月银行流水。",
}
)
# 2) 待确认工作表:仍有未确认的解析结果。
pending_sheets = connection.execute(
"""
SELECT COUNT(*) AS n
FROM sheet_reviews rv
JOIN import_batches b ON b.id = rv.import_batch_id
WHERE b.company_id = ? AND rv.review_status = 'pending'
""",
(company_id,),
).fetchone()["n"]
if pending_sheets:
items.append(
{
"kind": "待确认工作表",
"content": f"{pending_sheets} 个导入工作表尚未确认,请核对后确认。",
}
)
# 3) 待审核账户登记:处于待复核状态的银行账户。
pending_accounts = connection.execute(
"""
SELECT COUNT(*) AS n FROM bank_accounts
WHERE company_id = ? AND status = 'pending'
""",
(company_id,),
).fetchone()["n"]
if pending_accounts:
items.append(
{
"kind": "账户登记",
"content": f"{pending_accounts} 个银行账户登记待审核。",
}
)
# 4) 待确认往来事项:尚未解决的往来匹配。
pending_transfers = connection.execute(
"""
SELECT COUNT(*) AS n
FROM current_transfer_decisions c
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants p
ON p.decision_id = d.id AND p.company_id = ?
WHERE d.classification IN ('unresolved', 'needs_review')
""",
(company_id,),
).fetchone()["n"]
if pending_transfers:
items.append(
{
"kind": "往来待确认",
"content": f"{pending_transfers} 项往来流水待确认,请核对对方银行流水佐证。",
}
)
return items
def send_reminders(
connection: sqlite3.Connection,
company_id: int,
actor: sqlite3.Row,
) -> tuple[list[dict[str, str]], str | None]:
"""Create reminder rows for a company's pending items.
Returns ``(created, deadline)``. ``created`` is the list of persisted
reminder payloads; ``deadline`` is derived from the closing-day setting.
"""
items = pending_items(connection, company_id)
if not items:
return [], None
settings = get_settings(connection)
try:
closing_day = int(settings["closing_day"])
except ValueError:
closing_day = 5
today = datetime.now(timezone.utc)
# Deadline: next month's closing day (current month if today is before it).
if today.day < closing_day:
deadline = today.strftime(f"%Y-%m-{closing_day:02d}")
else:
year, month = today.year, today.month
if month == 12:
year, month = year + 1, 1
else:
month += 1
deadline = f"{year}-{month:02d}-{closing_day:02d}"
created: list[dict[str, str]] = []
with connection:
for item in items:
cursor = connection.execute(
"""
INSERT INTO reminders (
company_id, kind, content, deadline, source,
actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, 'manual', ?, ?, ?)
""",
(
company_id,
item["kind"],
item["content"],
deadline,
actor["id"],
actor["username"],
utc_now(),
),
)
created.append(
{
"id": cursor.lastrowid,
"company_id": company_id,
"kind": item["kind"],
"content": item["content"],
"deadline": deadline,
"source": "manual",
"status": "unread",
"actor_username": actor["username"],
"created_at": utc_now(),
}
)
return created, deadline
+6 -6
View File
@@ -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, 6, 7, 8], first)
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -84,24 +84,24 @@ class MigrationTests(PersistenceTestCase):
"ledger_subject_suggestions",
"system_settings",
"system_setting_changes",
"reminders",
"closed_periods",
"opening_balance_revisions",
"coverage_gaps",
"no_business_attestations",
"reminders_legacy_manual",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([9, 8, 7, 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, 6, 7, 8], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -109,7 +109,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([8, 7, 6, 5], rollback(self.connection, 4))
self.assertEqual([9, 8, 7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
+495
View File
@@ -0,0 +1,495 @@
"""Tests for automatic reminder detection, delivery, isolation and audit trail."""
from __future__ import annotations
from datetime import date, timedelta
from pathlib import Path
import json
import sqlite3
import unittest
from unittest.mock import patch
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_resolved_reminder_reopens_same_row(self) -> None:
self._insert_pending_account(self.company_a)
finding = next(
item
for item in reminders.scan_findings(self.connection)
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
)
first = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
self.assertIsNotNone(first)
reminders.update_reminder_status(
self.connection, first, "resolved", company_id=self.company_a, actor=self.actor_a
)
second = reminders.deliver_finding(self.connection, finding.dedupe_key, actor=self.admin)
self.assertEqual(first, second)
row = self.connection.execute(
"SELECT send_count, status, dedupe_key FROM reminders WHERE id = ?",
(first,),
).fetchone()
self.assertEqual(2, row["send_count"])
self.assertEqual("open", row["status"])
count = self.connection.execute(
"SELECT COUNT(*) FROM reminders WHERE dedupe_key = ?", (finding.dedupe_key,)
).fetchone()[0]
self.assertEqual(1, count)
events = [
item["event_type"]
for item in self.connection.execute(
"SELECT event_type FROM reminder_events WHERE reminder_id = ? ORDER BY id",
(first,),
).fetchall()
]
self.assertEqual(["sent", "resolved", "sent"], events)
def test_deliver_many_continues_after_one_failure(self) -> None:
self._insert_pending_account(self.company_a)
finding = next(
item
for item in reminders.scan_findings(self.connection)
if item.rule_key == reminders.RULE_PENDING and item.company_id == self.company_a
)
original = reminders.deliver_finding
def flaky(connection, key, **kwargs):
if key == "boom":
raise sqlite3.IntegrityError("UNIQUE constraint failed: reminders.dedupe_key")
return original(connection, key, **kwargs)
with patch.object(reminders, "deliver_finding", side_effect=flaky):
sent = reminders.deliver_many(
self.connection, ["boom", finding.dedupe_key], actor=self.admin
)
self.assertEqual(1, len(sent))
self.assertEqual(
1,
self.connection.execute("SELECT COUNT(*) FROM reminders").fetchone()[0],
)
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"})
def test_invalid_monthly_start_day_rejected(self) -> None:
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"monthly_start_day": "0"})
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"monthly_start_day": "29"})
self.assertEqual("5", reminders.get_settings(self.connection)["monthly_start_day"])
def test_invalid_gap_days_rejected(self) -> None:
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"gap_days": "0"})
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"gap_days": "abc"})
self.assertEqual("5", reminders.get_settings(self.connection)["gap_days"])
def test_invalid_scan_time_rejected(self) -> None:
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"scan_time": "25:00"})
with self.assertRaises(ValueError):
reminders.update_settings(self.connection, {"scan_time": "8:0"})
self.assertEqual("08:00", reminders.get_settings(self.connection)["scan_time"])
def test_corrupt_settings_do_not_break_scan(self) -> None:
with self.connection:
self.connection.execute(
"UPDATE reminder_settings SET value = 'not-a-number' WHERE key = 'monthly_start_day'"
)
self.connection.execute(
"UPDATE reminder_settings SET value = '0' WHERE key = 'gap_days'"
)
findings = reminders.scan_findings(self.connection)
self.assertIsInstance(findings, list)
class NoticeListDelegationTests(unittest.TestCase):
def test_async_notice_go_handle_uses_event_delegation(self) -> None:
source = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text(
encoding="utf-8"
)
init_body = source.split("function initNotifications()", 1)[1].split("\nfunction ", 1)[0]
self.assertIn('list.addEventListener("click"', init_body)
self.assertIn('closest("[data-view-link]")', init_body)
self.assertIn("showView(viewLink.dataset.viewLink)", init_body)
self.assertIn('document.addEventListener("click"', source)
self.assertNotIn(
'$$("[data-view-link]").forEach((button) => button.addEventListener("click"',
source,
)
def test_delegated_lookup_finds_button_inserted_after_init(self) -> None:
"""Simulate #notice-list after async replaceChildren: click target is the new button."""
list_root = {"id": "notice-list", "parent": None, "attrs": {}}
side = {"id": "lr-side", "parent": list_root, "attrs": {}}
button = {
"id": "go",
"parent": side,
"attrs": {"data-view-link": "reconcile"},
}
list_root["children"] = [side]
side["children"] = [button]
def closest(node, attr):
current = node
while current is not None:
if attr in current.get("attrs", {}):
return current
current = current.get("parent")
return None
clicked = closest(button, "data-view-link")
self.assertIsNotNone(clicked)
self.assertEqual("reconcile", clicked["attrs"]["data-view-link"])
self.assertIs(list_root, clicked["parent"]["parent"])
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(9, versions[-1]["version"])
connection.execute("DELETE FROM schema_migrations WHERE version = 9")
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)
+28
View File
@@ -287,6 +287,8 @@ class ServerAuthMatrixTests(unittest.TestCase):
lambda: anon.get("/api/admin/users"),
lambda: anon.get("/api/admin/companies"),
lambda: anon.get("/api/admin/audit-log"),
lambda: anon.get("/api/admin/reminders"),
lambda: anon.get("/api/company/reminders"),
lambda: anon.get("/api/me"),
):
status, _, data = method_check()
@@ -387,6 +389,10 @@ class ServerAuthMatrixTests(unittest.TestCase):
lambda: self.cashier_a.request("POST", "/api/admin/users/1/enable"),
lambda: self.cashier_a.request("POST", "/api/admin/users/1/reset-password"),
lambda: self.cashier_a.get("/api/admin/audit-log"),
lambda: self.cashier_a.get("/api/admin/reminders"),
lambda: self.cashier_a.get("/api/admin/reminders/pending"),
lambda: self.cashier_a.post_json("/api/admin/reminder-settings", {"gap_days": "3"}),
lambda: self.cashier_a.post_json("/api/admin/reminders/send", {"dedupe_keys": ["x"]}),
)
for call in calls:
status, _, data = call()
@@ -557,6 +563,28 @@ class ServerAuthMatrixTests(unittest.TestCase):
self.assertNotIn(password, row["detail"] or "")
self.assertNotIn(password, row["target"] or "")
def test_invalid_reminder_settings_return_400_and_do_not_persist(self) -> None:
cases = (
{"monthly_start_day": "0"},
{"gap_days": "0"},
{"scan_time": "25:99"},
)
for payload in cases:
with self.subTest(payload=payload):
status, _, data = self.admin.post_json(
"/api/admin/reminder-settings", {"settings": payload}
)
self.assertEqual(400, status, data)
self.assertEqual("error", as_json(data)["status"])
status, _, data = self.admin.get("/api/admin/reminder-settings")
self.assertEqual(200, status)
settings = as_json(data)["settings"]
self.assertEqual("5", settings["monthly_start_day"])
self.assertEqual("5", settings["gap_days"])
self.assertEqual("08:00", settings["scan_time"])
status, _, data = self.admin.get("/api/admin/reminders/pending")
self.assertEqual(200, status, data)
if __name__ == "__main__":
unittest.main()
-22
View File
@@ -166,28 +166,6 @@ class SettingsAndRemindersTests(unittest.TestCase):
self.assertNotEqual(rows[0]["before_value"], rows[0]["after_value"])
self.assertEqual("group-admin", rows[0]["actor_username"])
def test_reminder_pending_and_send(self) -> None:
status, data = self.admin.get(
f"/api/admin/reminders/pending?company_id={self.company_id}"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(items)
status, data = self.admin.post_json(
"/api/admin/reminders/send", {"company_id": self.company_id}
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertGreaterEqual(len(payload["reminders"]), 1)
self.assertTrue(payload["deadline"])
status, data = self.admin.get("/api/admin/reminders")
self.assertEqual(200, status)
history = as_json(data)["reminders"]
self.assertGreaterEqual(len(history), 1)
self.assertEqual(self.company_id, history[0]["company_id"])
def test_company_user_forbidden_on_settings_and_reminders(self) -> None:
# A company user must not be able to read or write admin settings.
status, data = self.admin.post_json(
+59 -10
View File
@@ -633,6 +633,22 @@
<h1>提醒管理</h1>
<p class="page-sub">向成员公司发送处理提醒,并跟踪系统提醒与人工提醒的触达与处理状态。</p>
</div>
<div class="page-actions">
<button type="button" class="btn btn-sm" id="reminder-scan-btn">立即扫描</button>
</div>
</div>
<div class="card" id="pending-reminders-card" style="margin-bottom: 16px;">
<div class="card-head">
<span class="card-title">待提醒清单<span class="sub">系统按流水提交、断档、待确认自动发现,点发送即送达对应公司</span></span>
<div class="row" style="gap: 10px; align-items: center;">
<span class="meta" id="pending-summary"></span>
<button type="button" class="btn btn-primary btn-sm" id="pending-send-all" disabled>全部一键发送</button>
</div>
</div>
<div id="pending-list">
<div class="empty" id="pending-empty">暂无需要提醒的事项 · 各公司流水与确认进度正常</div>
</div>
</div>
<div class="grid grid-1-2">
@@ -642,18 +658,30 @@
</div>
<form id="reminderForm" novalidate>
<div class="field" style="margin-bottom: 14px;">
<label for="reminder-company">接收公司</label>
<select class="select" id="reminder-company"><option value="">请选择公司</option></select>
<span class="hint error" id="company-error" style="display: none;">请选择一家接收公司</span>
<label>接收公司(可多选)</label>
<div class="row" id="reminderCompanyList" style="flex-wrap: wrap; gap: 8px 16px;"></div>
<span class="hint error" id="company-error" style="display: none;">至少选择一家接收公司</span>
</div>
<div class="field" style="margin-bottom: 16px;">
<label>提醒事项(系统自动列出)</label>
<div id="reminder-items">
<div class="empty">请先选择公司,系统将自动列出该公司待提醒事项。</div>
</div>
<span class="hint error" id="reminder-error" style="display: none;">该公司当前没有待提醒事项</span>
<div class="field" style="margin-bottom: 14px;">
<label for="reminder-type">提醒类型</label>
<select class="select" id="reminder-type">
<option selected>流水未提交</option>
<option>单边待确认</option>
<option>科目待确认</option>
<option>账户登记</option>
<option>其他</option>
</select>
</div>
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;" disabled>发送提醒</button>
<div class="field" style="margin-bottom: 14px;">
<label for="reminder-content">提醒内容</label>
<textarea class="textarea" id="reminder-content">请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。</textarea>
<span class="hint error" id="content-error" style="display: none;">提醒内容不能为空</span>
</div>
<div class="field" style="margin-bottom: 18px;">
<label for="reminder-deadline">截止日期</label>
<input class="input" type="date" id="reminder-deadline" value="2026-08-29" min="2026-08-20" />
</div>
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;">发送提醒</button>
<p class="hint" id="send-hint" style="margin-top: 10px; display: none;"></p>
</form>
</div>
@@ -672,6 +700,7 @@
<thead>
<tr>
<th>公司</th>
<th>来源</th>
<th>类型</th>
<th class="wrap">内容摘要</th>
<th>发送时间</th>
@@ -947,6 +976,26 @@
</div>
</div>
<!-- 提醒详情抽屉 -->
<aside class="drawer" id="reminder-detail-drawer" aria-label="提醒详情">
<div class="drawer-head">
<div>
<span class="pill pill-info" id="rd-source-pill">系统</span>
<h2 class="d-title" id="rd-title">提醒详情</h2>
<p class="d-desc" id="rd-sub"></p>
</div>
<button type="button" class="icon-button" data-close-rd aria-label="关闭详情" title="关闭详情">×</button>
</div>
<div class="drawer-body">
<dl class="kv" id="rd-fields"></dl>
<h3 style="font-size: 13px; margin: 16px 0 8px; color: var(--muted);">事件留痕</h3>
<div id="rd-events"></div>
</div>
<div class="drawer-foot">
<button type="button" class="btn" data-close-rd>关闭</button>
</div>
</aside>
<!-- 提醒历史弹窗 -->
<div class="modal-backdrop" id="history-modal">
<div class="modal">
+427 -224
View File
@@ -465,7 +465,12 @@ function initShell() {
}
});
$$("[data-view]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.view); }));
$$("[data-view-link]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.viewLink); }));
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-view-link]");
if (!button) return;
event.preventDefault();
showView(button.dataset.viewLink);
});
$$(".logout").forEach((control) => control.addEventListener("click", async (event) => {
event.preventDefault();
try {
@@ -1122,7 +1127,7 @@ function setSelectOptions(select, names, { keepFirst = false } = {}) {
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
}
function fillCompanySelects(names) {
function fillCompanySelects(names, companies) {
// Every company picker is driven by master data: a newly created company
// appears in pair queries, audit filters, flow filters and reminders
// without any code change.
@@ -1134,11 +1139,20 @@ function fillCompanySelects(names) {
});
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
const reminderSelect = $("#reminder-company");
if (reminderSelect) {
const kept = reminderSelect.querySelector('option[value=""]') ? [reminderSelect.options[0].cloneNode(true)] : [];
const companies = state.companies || [];
reminderSelect.replaceChildren(...kept, ...companies.map((company) => new Option(company.name, company.id)));
const reminderList = $("#reminderCompanyList");
if (reminderList && companies?.length) {
reminderList.replaceChildren(...companies.map((company) => {
const label = document.createElement("label");
label.className = "row";
label.style.cssText = "gap:6px;font-size:13px;";
const input = document.createElement("input");
input.type = "checkbox";
input.name = "company";
input.value = String(company.id);
input.style.width = "auto";
label.append(input, document.createTextNode(company.name));
return label;
}));
}
setSelectOptions($('#openingDialog [name="from"]'), names);
setSelectOptions($('#openingDialog [name="to"]'), names);
@@ -1775,6 +1789,293 @@ function initDashboard() {
loadAdminDashboard();
}
function formatReminderTime(value) {
if (!value) return "—";
const text = String(value).replace("T", " ").slice(0, 16);
return text;
}
function reminderTypePill(ruleKey) {
if (ruleKey === "pending_review") return "pill-warn";
if (ruleKey === "manual") return "pill-warn";
return "pill-danger";
}
function reminderStatusPill(statusUi) {
return { unread: "pill-danger", doing: "pill-warn", done: "pill-success" }[statusUi] || "pill-muted";
}
function reminderStatusLabel(statusUi) {
return { unread: "未读", doing: "处理中", done: "已完成" }[statusUi] || statusUi;
}
async function loadAdminRemindersPending() {
const list = $("#pending-list");
const empty = $("#pending-empty");
const summary = $("#pending-summary");
const sendAll = $("#pending-send-all");
if (!list) return;
const response = await fetch("/api/admin/reminders/pending").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const findings = result.findings || [];
list.querySelectorAll(".list-row").forEach((row) => row.remove());
if (!findings.length) {
if (empty) empty.style.display = "";
if (summary) summary.textContent = "0 家公司 · 0 项";
if (sendAll) { sendAll.disabled = true; sendAll.textContent = "全部一键发送"; }
return;
}
if (empty) empty.style.display = "none";
const companies = new Set(findings.map((item) => item.company_id)).size;
if (summary) summary.textContent = `${companies} 家公司 · ${findings.length}`;
if (sendAll) {
sendAll.disabled = false;
sendAll.textContent = "全部一键发送";
sendAll.dataset.keys = JSON.stringify(findings.map((item) => item.dedupe_key));
}
findings.forEach((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.dedupeKey = item.dedupe_key;
const sentHint = item.send_count > 0 ? ` · 已提醒 ${item.send_count}` : "";
row.innerHTML =
'<label class="row" style="gap:6px;flex:none;"><input type="checkbox" class="pending-check" style="width:auto;" /></label>' +
`<span class="pill ${reminderTypePill(item.rule_key)}">${item.rule_label}</span>` +
`<div class="lr-main"><div class="lr-title">${item.title}</div>` +
`<div class="lr-sub"><span class="meta">${item.reason}${sentHint}</span></div></div>` +
`<span class="pill ${item.duration_pill}" style="flex:none;font-family:var(--font-mono);">${item.days_open} 天</span>` +
`<div class="lr-side"><button type="button" class="btn btn-sm btn-primary pending-send-one">发送提醒</button></div>`;
list.append(row);
});
}
async function loadAdminRemindersHistory(sourceFilter) {
const tbody = $("#reminder-tbody");
if (!tbody) return;
const query = sourceFilter && sourceFilter !== "all" ? `?source=${sourceFilter === "system" ? "auto" : "manual"}` : "";
const response = await fetch(`/api/admin/reminders${query}`).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const items = result.reminders || [];
tbody.replaceChildren(...items.map((item) => {
const tr = document.createElement("tr");
tr.dataset.source = item.source === "auto" ? "system" : "manual";
tr.dataset.reminderId = String(item.id);
const summary = item.content.length > 46 ? item.content.slice(0, 46) + "…" : item.content;
const sourceCell = item.source === "auto"
? '<span class="pill pill-info">系统</span>'
: '<span class="tag">人工</span>';
tr.innerHTML =
`<td class="cell-main">${item.company_name}</td>` +
`<td>${sourceCell}</td>` +
`<td><span class="pill ${reminderTypePill(item.rule_key)}">${item.display_type}</span></td>` +
`<td class="wrap">${summary.replace(/</g, "&lt;")}</td>` +
`<td class="num-col">${formatReminderTime(item.last_sent_at)}</td>` +
`<td class="num-col">${item.deadline || "—"}</td>` +
`<td><span class="pill ${reminderStatusPill(item.status_ui)}">${reminderStatusLabel(item.status_ui)}</span></td>` +
'<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm act-remind">再提醒</button><button type="button" class="btn btn-sm btn-ghost act-detail">详情</button></div></td>';
return tr;
}));
refreshReminderCounts(result.stats || {});
}
function refreshReminderCounts(stats) {
const rows = $$("#reminder-tbody tr");
let all = rows.length, sys = 0, man = 0;
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
$("#count-all").textContent = all;
$("#count-system").textContent = sys;
$("#count-manual").textContent = man;
$("#table-foot-count").textContent = `${all} 条提醒记录`;
if (stats) {
$("#table-foot-state").textContent = `未读 ${stats.unread || 0} · 处理中 ${stats.doing || 0} · 已完成 ${stats.done || 0}`;
}
}
async function sendPendingReminders(keys) {
const response = await fetch("/api/admin/reminders/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dedupe_keys: keys }),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return null; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger");
return null;
}
return result;
}
function openReminderDetailDrawer(reminderId) {
fetch(`/api/admin/reminders/${reminderId}`)
.then((response) => response.json())
.then((result) => {
const item = result.reminder;
if (!item) return;
$("#rd-title").textContent = item.title;
$("#rd-sub").textContent = item.content;
const sourcePill = $("#rd-source-pill");
if (sourcePill) {
sourcePill.textContent = item.source === "auto" ? "系统" : "人工";
sourcePill.className = item.source === "auto" ? "pill pill-info" : "tag";
}
$("#rd-fields").innerHTML =
`<dt>公司</dt><dd>${item.company_name}</dd>` +
`<dt>类型</dt><dd>${item.display_type}</dd>` +
`<dt>触发原因</dt><dd>${item.rule_params?.pending_count ? `待处理 ${item.rule_params.pending_count}` : (item.rule_params?.period || "—")}</dd>` +
`<dt>发送时间</dt><dd>${formatReminderTime(item.last_sent_at)}</dd>` +
`<dt>催办次数</dt><dd>${item.send_count}</dd>` +
`<dt>处理状态</dt><dd>${reminderStatusLabel(item.status_ui)}</dd>`;
$("#rd-events").innerHTML = (item.events || []).map((ev) =>
`<div class="list-row"><div class="lr-main"><div class="lr-title">${ev.event_type}</div><div class="lr-sub meta">${formatReminderTime(ev.created_at)} · ${ev.actor}${ev.detail ? " · " + ev.detail : ""}</div></div></div>`,
).join("") || '<div class="empty"><div class="e-title">暂无事件</div></div>';
$("#reminder-detail-drawer")?.classList.add("is-open");
})
.catch(() => showToast("加载详情失败", "", "danger"));
}
function initAdminReminders() {
loadAdminRemindersPending();
loadAdminRemindersHistory("all");
$("#reminder-scan-btn")?.addEventListener("click", async () => {
const response = await fetch("/api/admin/reminders/scan", { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("扫描失败", result?.message || "请稍后重试", "danger");
return;
}
await loadAdminRemindersPending();
showToast("扫描完成", `发现 ${result.summary?.items || 0} 项待提醒`, "success");
});
$("#pending-send-all")?.addEventListener("click", async () => {
const checked = $$(".pending-check:checked");
let keys;
if (checked.length) {
keys = checked.map((input) => input.closest(".list-row")?.dataset.dedupeKey).filter(Boolean);
} else {
keys = JSON.parse($("#pending-send-all").dataset.keys || "[]");
}
if (!keys.length) return;
const result = await sendPendingReminders(keys);
if (!result) return;
showToast(checked.length ? "已发送选中提醒" : "已全部发送", `${result.count}`, "success");
await loadAdminRemindersPending();
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#pending-list")?.addEventListener("click", async (event) => {
const btn = event.target.closest(".pending-send-one");
if (!btn) return;
const row = btn.closest(".list-row");
const key = row?.dataset.dedupeKey;
if (!key) return;
const result = await sendPendingReminders([key]);
if (!result) return;
if (!motionQuery.matches) {
row.style.transition = "opacity 0.2s ease";
row.style.opacity = "0";
setTimeout(() => row.remove(), 200);
} else {
row.remove();
}
showToast("已发送提醒", "", "success");
await loadAdminRemindersPending();
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#pending-list")?.addEventListener("change", (event) => {
if (!event.target.classList.contains("pending-check")) return;
const checked = $$(".pending-check:checked").length;
const sendAll = $("#pending-send-all");
if (!sendAll) return;
sendAll.textContent = checked ? `发送选中(${checked}` : "全部一键发送";
});
$("#reminderForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const checked = Array.prototype.slice.call(form.querySelectorAll('input[name="company"]:checked'));
const type = $("#reminder-type").value;
const content = $("#reminder-content").value.trim();
const deadline = $("#reminder-deadline").value;
$("#company-error").style.display = checked.length ? "none" : "";
$("#content-error").style.display = content ? "none" : "";
if (!checked.length || !content) return;
for (const input of checked) {
const response = await fetch("/api/admin/reminders/manual", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
company_id: Number(input.value),
display_type: type,
content,
deadline: deadline || null,
}),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger");
return;
}
}
const companies = checked.map((c) => {
const company = (state.companies || []).find((item) => String(item.id) === c.value);
return company?.name || c.value;
}).join("、");
form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; });
const sendHint = $("#send-hint");
sendHint.style.display = "";
sendHint.textContent = `已发送给 ${companies},共 ${checked.length} 家公司。`;
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
showToast("人工提醒已发送", companies, "success");
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#reminder-tabs")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
loadAdminRemindersHistory(btn.dataset.filter);
});
$("#reminder-tbody")?.addEventListener("click", async (event) => {
const detailBtn = event.target.closest(".act-detail");
if (detailBtn) {
const tr = detailBtn.closest("tr");
openReminderDetailDrawer(tr.dataset.reminderId);
return;
}
const remindBtn = event.target.closest(".act-remind");
if (!remindBtn) return;
const tr = remindBtn.closest("tr");
const response = await fetch(`/api/admin/reminders/${tr.dataset.reminderId}/resend`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("再提醒失败", result?.message || "请稍后重试", "danger");
return;
}
remindBtn.textContent = "已再提醒";
remindBtn.disabled = true;
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
showToast("已再次发送提醒", "", "success");
});
$$("[data-close-rd]").forEach((btn) => btn.addEventListener("click", () => {
$("#reminder-detail-drawer")?.classList.remove("is-open");
}));
}
async function loadAdminCompanies() {
const tbody = $("#companyTable tbody");
showTableLoading(tbody, 6);
@@ -1787,7 +2088,7 @@ async function loadAdminCompanies() {
const companies = result?.companies || [];
state.companies = companies;
renderAdminCompanyTable(companies);
fillCompanySelects(companies.map((company) => company.name));
fillCompanySelects(companies.map((company) => company.name), companies);
if (companies.length >= 2) setPair(companies[0].name, companies[1].name);
}
@@ -2194,7 +2495,18 @@ function initAdmin() {
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
applySystemSettings(result.settings || {});
await loadCalculationSettings();
showToast("系统计算口径已保存", "结账日与起算日变更已留痕", "success");
const gapDays = parseInt($("#cs-remind-days")?.value, 10) || 5;
const reminderResp = await fetch("/api/admin/reminder-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: { monthly_start_day: String(day || 5), gap_days: String(gapDays) } }),
}).catch(() => null);
if (reminderResp?.status === 401) { window.location.href = "index.html"; return; }
if (!reminderResp?.ok) {
showToast("提醒扫描参数保存失败", "", "danger");
return;
}
showToast("系统计算口径已保存", "结账日与起算日变更已留痕 · 提醒扫描参数已同步更新", "success");
});
$("#runClosingCheck")?.addEventListener("click", () => {
@@ -2319,173 +2631,9 @@ function initAdmin() {
showToast(confirmBtn ? "期初已确认" : "期初已作废", "变更已留痕", "success");
});
// ── 提醒管理:选公司 → 自动列出待提醒事项 → 一键发送 ──
const reminderSelect = $("#reminder-company");
const reminderItems = $("#reminder-items");
const reminderError = $("#reminder-error");
const companyError = $("#company-error");
const sendBtn = $("#send-btn");
const esc = (text) => String(text ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const fmtReminderTime = (iso) => String(iso || "").replace("T", " ").slice(5, 16);
let pendingReminderItems = [];
function renderPendingItems(items) {
pendingReminderItems = items || [];
if (!reminderItems) return;
if (!pendingReminderItems.length) {
reminderItems.innerHTML = '<div class="empty">该公司当前没有待提醒事项。</div>';
if (sendBtn) sendBtn.disabled = true;
return;
}
reminderItems.replaceChildren(...pendingReminderItems.map((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.innerHTML =
'<span class="pill pill-warn" style="flex:none;">待处理</span>' +
'<div class="lr-main"><div class="lr-title">' + esc(item.kind) + '</div>' +
'<div class="lr-sub">' + esc(item.content) + '</div></div>';
return row;
}));
if (sendBtn) sendBtn.disabled = false;
}
async function loadPendingReminders(companyId) {
if (!companyId) { renderPendingItems([]); return; }
if (reminderItems) reminderItems.innerHTML = '<div class="loading-row">正在列出待提醒事项…</div>';
if (sendBtn) sendBtn.disabled = true;
const response = await fetch("/api/admin/reminders/pending?company_id=" + encodeURIComponent(companyId)).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) { renderPendingItems([]); return; }
renderPendingItems(result.items || []);
}
reminderSelect?.addEventListener("change", () => {
if (reminderError) reminderError.style.display = "none";
loadPendingReminders(reminderSelect.value);
});
$("#reminderForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const companyId = reminderSelect?.value;
if (companyError) companyError.style.display = companyId ? "none" : "";
if (!companyId) return;
if (!pendingReminderItems.length) {
if (reminderError) reminderError.style.display = "";
return;
}
if (reminderError) reminderError.style.display = "none";
if (sendBtn) sendBtn.disabled = true;
const response = await fetch("/api/admin/reminders/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ company_id: Number(companyId) }),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
if (sendBtn) sendBtn.disabled = false;
showToast("发送失败", result?.message || "请稍后重试", "danger");
return;
}
const sendHint = $("#send-hint");
if (sendHint) {
sendHint.style.display = "";
sendHint.textContent = `已向 ${result.company_name} 发送 ${(result.reminders || []).length} 项提醒,截止 ${result.deadline || "—"}`;
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
}
await loadReminderHistory();
await loadPendingReminders(companyId);
showToast("提醒已发送", `已生成 ${(result.reminders || []).length} 条提醒记录`, "success");
});
// ── 提醒历史(实时从后端读取) ──
let reminderFilter = "all";
function refreshReminderCounts() {
const rows = $$("#reminder-tbody tr");
let all = rows.length, sys = 0, man = 0;
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
const countAll = $("#count-all"), countSys = $("#count-system"), countMan = $("#count-manual");
if (countAll) countAll.textContent = all;
if (countSys) countSys.textContent = sys;
if (countMan) countMan.textContent = man;
const foot = $("#table-foot-count");
if (foot) foot.textContent = "共 " + all + " 条提醒记录";
const state = $("#table-foot-state");
if (state) state.textContent = "未读 " + all + " · 处理中 0 · 已完成 0";
}
function applyReminderFilter() {
$$("#reminder-tbody tr").forEach((r) => {
r.style.display = (reminderFilter === "all" || r.dataset.source === reminderFilter) ? "" : "none";
});
}
async function loadReminderHistory() {
const tbody = $("#reminder-tbody");
if (!tbody) return;
const response = await fetch("/api/admin/reminders").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
const reminders = result?.reminders || [];
tbody.replaceChildren(...reminders.map((reminder) => {
const row = document.createElement("tr");
row.dataset.source = reminder.source;
row.innerHTML =
'<td class="cell-main">' + esc(reminder.company_name) + '</td>' +
'<td><span class="tag">' + esc(reminder.kind) + '</span></td>' +
'<td class="wrap">' + esc(reminder.content) + '</td>' +
'<td class="meta">' + fmtReminderTime(reminder.created_at) + '<span class="cell-sub">' + (reminder.source === "manual" ? "人工" : "系统") + (reminder.actor_username ? " · " + esc(reminder.actor_username) : "") + '</span></td>' +
'<td class="meta">' + (reminder.deadline || "—") + '</td>' +
'<td><span class="pill pill-danger">未读</span></td>' +
'<td><button type="button" class="btn btn-sm btn-ghost act-history" data-company-id="' + reminder.company_id + '" data-company="' + esc(reminder.company_name) + '">查看历史</button></td>';
return row;
}));
applyReminderFilter();
refreshReminderCounts();
}
$("#reminder-tabs")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
reminderFilter = btn.dataset.filter;
applyReminderFilter();
});
$("#reminder-tbody")?.addEventListener("click", (event) => {
const historyBtn = event.target.closest(".act-history");
if (historyBtn) openReminderHistory(historyBtn.dataset.companyId, historyBtn.dataset.company);
});
async function openReminderHistory(companyId, companyName) {
$("#history-title").textContent = (companyName || "") + " · 提醒历史";
$("#history-sub").textContent = "该公司的全部提醒触达记录,按时间倒序。";
const list = $("#history-list");
list.innerHTML = '<div class="loading-row">加载中…</div>';
const response = await fetch("/api/admin/reminders?company_id=" + encodeURIComponent(companyId || "")).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
const items = result?.reminders || [];
if (!items.length) {
list.innerHTML = '<div class="empty"><div class="e-title">暂无历史记录</div>该公司尚未收到过提醒。</div>';
} else {
list.innerHTML = items.map((it) => {
return '<div class="list-row">' +
'<span class="tag">' + esc(it.kind) + '</span>' +
'<div class="lr-main"><div class="lr-title">' + esc(it.content) + '</div><div class="lr-sub"><span class="meta">' + fmtReminderTime(it.created_at) + ' · ' + (it.source === "manual" ? "人工" : "系统") + (it.actor_username ? " · " + esc(it.actor_username) : "") + '</span></div></div>' +
'<div class="lr-side"><span class="pill pill-danger">未读</span></div></div>';
}).join("");
}
openModal("history-modal");
}
$("#history-close")?.addEventListener("click", () => closeModal("history-modal"));
$("#history-ok")?.addEventListener("click", () => closeModal("history-modal"));
initAdminReminders();
loadSystemSettings();
loadReminderHistory();
}
const FLOW_DEMO = [
@@ -3287,34 +3435,60 @@ function initReconcile() {
tabSubject?.addEventListener("click", () => switchTab("subject"));
}
function renderCompanyNoticeRow(item) {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.status = item.status_ui;
row.dataset.reminderId = String(item.id);
const titleWeight = item.status_ui === "unread" ? "650" : "400";
const titleColor = item.status_ui === "unread" ? "var(--fg)" : "var(--muted)";
const sourceTag = item.source === "auto" ? '<span class="pill pill-info">系统</span>' : '<span class="tag">管理员</span>';
const actionLink = item.action_link;
let side = "";
if (item.status_ui === "unread") {
side = '<button class="btn btn-sm btn-mark-read">标记已读</button>';
} else if (item.status_ui === "doing" && actionLink) {
side = `<button class="btn btn-sm btn-primary" data-view-link="${actionLink}">去处理</button>`;
} else if (item.status_ui === "doing") {
side = '<button class="btn btn-sm btn-ghost btn-mark-done">标记完成</button>';
}
row.innerHTML =
`<span class="pill ${reminderStatusPill(item.status_ui)}" style="flex:none;">${reminderStatusLabel(item.status_ui)}</span>` +
sourceTag +
`<div class="lr-main"><div class="lr-title" style="font-weight:${titleWeight};color:${titleColor};">${item.title}</div>` +
`<div class="lr-sub"><span class="meta">${formatReminderTime(item.last_sent_at)}</span> · ${item.content}</div></div>` +
`<div class="lr-side">${side}</div>`;
return row;
}
function initNotifications() {
const tabs = $("#notice-tabs");
const list = $("#notice-list");
if (!tabs || !list) return;
const rows = $$(".list-row", list);
const emptyBox = $("#notice-empty");
let currentFilter = "all";
let rows = [];
const STATUS_PILL = { unread: "pill-danger", doing: "pill-warn", done: "pill-success" };
const STATUS_LABEL = { unread: "未读", doing: "处理中", done: "已完成" };
function counts() {
const c = { all: rows.length, unread: 0, doing: 0, done: 0 };
rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; });
return c;
}
function refreshCounts() {
const c = counts();
function refreshCounts(stats, unreadCount) {
const c = stats || { all: rows.length, unread: 0, doing: 0, done: 0 };
if (!stats) {
c.all = rows.length;
rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; });
} else {
c.all = rows.length;
}
$("#count-all").textContent = c.all;
$("#count-unread").textContent = c.unread;
$("#count-doing").textContent = c.doing;
$("#count-done").textContent = c.done;
const navBadge = $('.side-nav a[data-view="notifications"] .nav-badge');
const unread = unreadCount ?? c.unread;
if (navBadge) {
navBadge.textContent = c.unread;
navBadge.style.display = c.unread ? "" : "none";
navBadge.textContent = unread;
navBadge.style.display = unread ? "" : "none";
}
const wsLink = $("#workspace-notice-link");
if (wsLink) wsLink.textContent = unread ? `全部通知 (${c.all}) →` : "全部通知 →";
}
function applyFilter() {
@@ -3324,15 +3498,35 @@ function initNotifications() {
row.style.display = show ? "" : "none";
if (show) visible += 1;
});
emptyBox.style.display = visible === 0 ? "" : "none";
if (emptyBox) emptyBox.style.display = visible === 0 ? "" : "none";
}
function setStatus(row, status) {
row.setAttribute("data-status", status);
const pill = $(".pill", row);
if (pill) {
pill.className = `pill ${STATUS_PILL[status]}`;
pill.textContent = STATUS_LABEL[status];
async function loadReminders() {
const response = await fetch("/api/company/reminders").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const items = result.reminders || [];
list.replaceChildren(...items.map((item) => renderCompanyNoticeRow(item)));
rows = $$(".list-row", list);
refreshCounts(result.stats, result.unread_count);
applyFilter();
const wsList = $("#workspace-notice-list");
if (wsList) {
const preview = items.slice(0, 3);
if (!preview.length) {
wsList.innerHTML = '<div class="empty"><div class="e-title">暂无通知</div></div>';
} else {
wsList.replaceChildren(...preview.map((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.innerHTML =
`<span class="pill ${reminderTypePill(item.rule_key)}">${item.display_type}</span>` +
`<div class="lr-main"><div class="lr-title">${item.title}</div><div class="lr-sub">${item.content.slice(0, 48)}</div></div>` +
`<span class="lr-side meta">${formatReminderTime(item.last_sent_at).slice(5, 10)}</span>`;
return row;
}));
}
}
}
@@ -3346,29 +3540,38 @@ function initNotifications() {
applyFilter();
});
list.addEventListener("click", (event) => {
const btn = event.target.closest(".btn-mark-read");
list.addEventListener("click", async (event) => {
const viewLink = event.target.closest("[data-view-link]");
if (viewLink && list.contains(viewLink)) {
event.preventDefault();
showView(viewLink.dataset.viewLink);
return;
}
const readBtn = event.target.closest(".btn-mark-read");
const doneBtn = event.target.closest(".btn-mark-done");
const btn = readBtn || doneBtn;
if (!btn) return;
const row = btn.closest(".list-row");
setStatus(row, "done");
btn.remove();
refreshCounts();
applyFilter();
const id = row?.dataset.reminderId;
if (!id) return;
const action = readBtn ? "acknowledge" : "resolve";
const response = await fetch(`/api/company/reminders/${id}/${action}`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
if (!response?.ok) return;
await loadReminders();
});
$("#mark-all-read")?.addEventListener("click", () => {
rows.forEach((row) => {
if (row.getAttribute("data-status") !== "unread") return;
setStatus(row, "done");
$(".btn-mark-read", row)?.remove();
});
refreshCounts();
applyFilter();
$("#mark-all-read")?.addEventListener("click", async () => {
const unreadRows = rows.filter((row) => row.getAttribute("data-status") === "unread");
for (const row of unreadRows) {
const id = row.dataset.reminderId;
await fetch(`/api/company/reminders/${id}/acknowledge`, { method: "POST" }).catch(() => null);
}
await loadReminders();
showToast("通知已全部标为已读", "", "success");
});
refreshCounts();
applyFilter();
loadReminders();
}
function openAccountDetail(account) {
@@ -3395,7 +3598,7 @@ function yuanToWan(value) {
return n / 10000;
}
function formatWan(value, { signed = false } = {}) {
function formatWanHtml(value, { signed = false } = {}) {
const wan = yuanToWan(value);
if (wan === null) return "—";
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -3472,13 +3675,13 @@ function renderWorkspaceTransfersCard(summary) {
const net = $("#wsTfNet");
const pendingEl = $("#wsTfPending");
const netLabel = $("#wsTfNetLabel");
if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true });
if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true });
if (outflow) {
outflow.innerHTML = formatWan(
outflow.innerHTML = formatWanHtml(
confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null,
{ signed: true },
);
// formatWan with negative value already adds ; ensure unit
// formatWanHtml with negative value already adds ; ensure unit
if (confirmed.outflow_total != null) {
const wan = yuanToWan(confirmed.outflow_total);
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -3488,12 +3691,12 @@ function renderWorkspaceTransfersCard(summary) {
if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`;
if (net) {
net.className = `ms-value ${netMeta.signedClass}`.trim();
net.innerHTML = formatWan(confirmed.net_change, { signed: true });
net.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
}
if (pendingEl) {
const pCount = Number(pending.count) || 0;
pendingEl.innerHTML = pCount
? `${formatWan(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
? `${formatWanHtml(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
: `0.00<span class="unit">万元 · 0 笔</span>`;
}
}
@@ -3564,7 +3767,7 @@ function renderTransfersOverview(summary) {
const netFoot = $("#tfStatNetFoot");
const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction);
if (companies) companies.innerHTML = `${cps.length}<span class="unit">家</span>`;
if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true });
if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true });
if (outflow) {
const wan = yuanToWan(confirmed.outflow_total);
const abs = wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -3575,7 +3778,7 @@ function renderTransfersOverview(summary) {
const tag = netMeta.label !== "持平"
? ` <span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span>`
: "";
net.innerHTML = `${formatWan(confirmed.net_change, { signed: true })}${tag}`;
net.innerHTML = `${formatWanHtml(confirmed.net_change, { signed: true })}${tag}`;
}
if (netFoot) {
netFoot.textContent = win.has_opening
@@ -3589,7 +3792,7 @@ function renderTransfersOverview(summary) {
const tfPendingCount = $("#tfPendingCount");
const tfPendingAmount = $("#tfPendingAmount");
if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount}`;
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWan(confirmed.net_change, { signed: true });
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
const openingCard = $("#tfStatOpeningCard");
const endingCard = $("#tfStatEndingCard");
const openingEl = $("#tfStatOpening");
@@ -3597,15 +3800,15 @@ function renderTransfersOverview(summary) {
if (win.has_opening) {
if (openingCard) openingCard.hidden = false;
if (endingCard) endingCard.hidden = false;
if (openingEl) openingEl.innerHTML = formatWan(win.opening, { signed: true });
if (endingEl) endingEl.innerHTML = formatWan(win.ending, { signed: true });
if (openingEl) openingEl.innerHTML = formatWanHtml(win.opening, { signed: true });
if (endingEl) endingEl.innerHTML = formatWanHtml(win.ending, { signed: true });
} else {
if (openingCard) openingCard.hidden = true;
if (endingCard) endingCard.hidden = true;
}
if (tfPendingCount) tfPendingCount.textContent = `${pCount}`;
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWan(pending.amount_total || 0);
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWanHtml(pending.amount_total || 0);
const tbody = $("#transfersCpBody");
const tfoot = $("#transfersCpFoot");
@@ -3706,7 +3909,7 @@ async function openTransfersDetail(companyId, companyName, { keepFilters = false
$("#tfDetailCount").innerHTML = ``;
$("#tfDetailCountFoot").textContent = pendingN ? `其中待确认 ${pendingN} 笔(汇总)` : "按筛选加载明细";
if (cp) {
$("#tfDetailIn").innerHTML = formatWan(cp.confirmed_inflow, { signed: true });
$("#tfDetailIn").innerHTML = formatWanHtml(cp.confirmed_inflow, { signed: true });
const wan = yuanToWan(cp.confirmed_outflow);
$("#tfDetailOut").innerHTML = wan === null
? "—"
+10 -109
View File
@@ -26,7 +26,7 @@
<a data-view="reconcile" href="#reconcile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 11.5l2 2 4-4.5"/><rect x="4" y="3" width="16" height="18" rx="2"/></svg><span class="nav-label">往来确认</span><span class="nav-badge">5</span></a>
<div class="nav-group">账户与消息</div>
<a data-view="accounts" href="#accounts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg><span class="nav-label">银行账户</span></a>
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge">3</span></a>
<a data-view="notifications" href="#notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg><span class="nav-label">通知</span><span class="nav-badge" id="notice-nav-badge" style="display: none;">0</span></a>
</nav>
<div class="side-foot">
<div class="user-row">
@@ -181,31 +181,10 @@
<div class="card">
<div class="card-head">
<span class="card-title">最新通知<span class="sub">来自管理端</span></span>
<button class="btn btn-sm btn-ghost" data-view-link="notifications">全部通知 (3)</button>
<button class="btn btn-sm btn-ghost" data-view-link="notifications" id="workspace-notice-link">全部通知 →</button>
</div>
<div class="list-row">
<span class="pill pill-warn">提醒</span>
<div class="lr-main">
<div class="lr-title">7 月账期结账日顺延至 08-29</div>
<div class="lr-sub">请在此之前完成往来确认事项</div>
</div>
<span class="lr-side meta">08-18</span>
</div>
<div class="list-row">
<span class="pill pill-warn">提醒</span>
<div class="lr-main">
<div class="lr-title">3 笔单边流水待确认</div>
<div class="lr-sub">涉及金牛置业、金牛贸易往来</div>
</div>
<span class="lr-side meta">08-16</span>
</div>
<div class="list-row">
<span class="pill pill-success">通过</span>
<div class="lr-main">
<div class="lr-title">中行尾号 9916 流水导入成功</div>
<div class="lr-sub">流水覆盖至 08-12</div>
</div>
<span class="lr-side meta">08-12</span>
<div id="workspace-notice-list">
<div class="empty"><div class="e-title">暂无通知</div></div>
</div>
</div>
</div>
@@ -804,93 +783,15 @@
<div class="card">
<div class="tabs" id="notice-tabs">
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">9</span></button>
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">3</span></button>
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">2</span></button>
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">4</span></button>
<button type="button" class="active" data-filter="all" aria-pressed="true">全部<span class="tab-count" id="count-all">0</span></button>
<button type="button" data-filter="unread" aria-pressed="false">未读<span class="tab-count" id="count-unread">0</span></button>
<button type="button" data-filter="doing" aria-pressed="false">处理中<span class="tab-count" id="count-doing">0</span></button>
<button type="button" data-filter="done" aria-pressed="false">已完成<span class="tab-count" id="count-done">0</span></button>
</div>
<div id="notice-list">
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">7 月流水存在断档风险:交行 尾号 7710 未上传</div>
<div class="lr-sub"><span class="meta">2026-08-20 14:32</span> · 交行 7710 账户(待审核)7 月流水尚未上传,距 7 月结账日(顺延至 08-29)仅剩 9 天,请尽快补传。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">您有 2 笔单边流水待选择对方证据</div>
<div class="lr-sub"><span class="meta">2026-08-19 09:15</span> · 与金牛置业的煤炭采购款 ¥3,200,000.00 等 2 笔流水仅有本方记录,需选择对方银行流水佐证。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="unread">
<span class="pill pill-danger" style="flex: none;">未读</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">管理员提醒:请于 08-29 前完成待确认事项</div>
<div class="lr-sub"><span class="meta">2026-08-18 16:40</span> · 7 月账期结账顺延至 08-29,贵公司当前仍有 5 项往来确认未完成,请合理安排时间。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-mark-read">标记已读</button></div>
</div>
<div class="list-row" data-status="doing">
<span class="pill pill-warn" style="flex: none;">处理中</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">手工记录科目待确认:其他应收 ¥86,500.00</div>
<div class="lr-sub"><span class="meta">2026-08-15 10:02</span> · 07-28 录入的矿区备用金垫付记录,往来科目「其他应收」待管理员复核。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="manual">去处理</button></div>
</div>
<div class="list-row" data-status="doing">
<span class="pill pill-warn" style="flex: none;">处理中</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">往来确认回复:置业已确认 320 万煤炭采购款</div>
<div class="lr-sub"><span class="meta">2026-08-12 11:26</span> · 金牛置业已确认 07-14 煤炭采购款 ¥3,200,000.00,请补充本方工行 3305 账户流水证据完成闭环。</div>
</div>
<div class="lr-side"><button class="btn btn-sm btn-primary" data-view-link="reconcile">去处理</button></div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">流水导入成功:中行 尾号 9916 · 42 笔</div>
<div class="lr-sub"><span class="meta">2026-08-06 15:52</span> · 7 月中行一般户流水已导入,42 笔全部校验通过,无重复记录。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">流水导入成功:工行 尾号 3305 · 86 笔</div>
<div class="lr-sub"><span class="meta">2026-08-06 15:48</span> · 7 月工行基本户流水已导入,86 笔全部校验通过,无重复记录。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">管理员</span>
<div class="lr-main">
<div class="lr-title">交行 尾号 7710 账户开户资料已受理</div>
<div class="lr-sub"><span class="meta">2026-07-22 13:10</span> · 新账户开户资料已提交管理员,审核通过后方可启用并上传流水。</div>
</div>
</div>
<div class="list-row" data-status="done">
<span class="pill pill-success" style="flex: none;">已完成</span>
<span class="tag">系统</span>
<div class="lr-main">
<div class="lr-title">2026 年 6 月账期已结账</div>
<div class="lr-sub"><span class="meta">2026-07-05 09:00</span> · 6 月账期已按每月 5 日结账规则完成结账,期末数据已锁定,不可再修改。</div>
</div>
</div>
</div>
<div id="notice-list"></div>
<div class="empty" id="notice-empty" style="display: none;">
<div class="empty" id="notice-empty">
<div class="e-title">暂无该状态的通知</div>
<div>切换其他状态查看,或等待新的系统与管理员消息。</div>
</div>