Compare commits

..
Author SHA1 Message Date
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
9 changed files with 2164 additions and 271 deletions
+342 -1
View File
@@ -6,11 +6,14 @@ import json
import os
from pathlib import Path
import re
import threading
import time
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import auth, importing, master_data, matching, multipart, personal_transit
from bank_importer import auth, importing, master_data, matching, multipart, personal_transit, reminders
from bank_importer.db import connect, migrate, utc_now
@@ -100,6 +103,26 @@ class AppHandler(SimpleHTTPRequestHandler):
if company_event_match:
self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
return
if path == "/api/admin/reminders/pending":
self._handle_admin_reminders_pending()
return
if path == "/api/admin/reminders":
self._handle_admin_reminders(query)
return
if path == "/api/admin/reminder-settings":
self._handle_admin_reminder_settings_get()
return
admin_reminder_match = re.fullmatch(r"/api/admin/reminders/(\d+)", path)
if admin_reminder_match:
self._handle_admin_reminder_detail(int(admin_reminder_match.group(1)))
return
if path == "/api/company/reminders":
self._handle_company_reminders()
return
company_reminder_match = re.fullmatch(r"/api/company/reminders/(\d+)", path)
if company_reminder_match:
self._handle_company_reminder_detail(int(company_reminder_match.group(1)))
return
if path == "/admin.html" and not self._guard_page("admin"):
return
@@ -167,6 +190,30 @@ class AppHandler(SimpleHTTPRequestHandler):
if mapping_review:
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
return
if path == "/api/admin/reminders/scan":
self._handle_admin_reminders_scan()
return
if path == "/api/admin/reminders/send":
self._handle_admin_reminders_send()
return
if path == "/api/admin/reminders/manual":
self._handle_admin_reminders_manual()
return
admin_resend_match = re.fullmatch(r"/api/admin/reminders/(\d+)/resend", path)
if admin_resend_match:
self._handle_admin_reminder_resend(int(admin_resend_match.group(1)))
return
if path == "/api/admin/reminder-settings":
self._handle_admin_reminder_settings_put()
return
company_status_match = re.fullmatch(
r"/api/company/reminders/(\d+)/(acknowledge|resolve)", path
)
if company_status_match:
self._handle_company_reminder_status(
int(company_status_match.group(1)), company_status_match.group(2)
)
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
# ------------------------------------------------------------------
@@ -1996,6 +2043,260 @@ class AppHandler(SimpleHTTPRequestHandler):
return None
return data
# ------------------------------------------------------------------
# Reminders
# ------------------------------------------------------------------
def _handle_admin_reminders_pending(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
findings = reminders.scan_findings(connection)
items = [reminders.finding_to_dict(item) for item in findings]
companies = len({item["company_id"] for item in items})
self._send_json(
200,
{
"status": "ok",
"findings": items,
"summary": {"companies": companies, "items": len(items)},
},
)
finally:
connection.close()
def _handle_admin_reminders_scan(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
result = reminders.run_scan(connection, actor=user, ip=self._client_ip)
items = [reminders.finding_to_dict(item) for item in result["findings"]]
companies = len({item["company_id"] for item in items})
self._send_json(
200,
{
"status": "ok",
"findings": items,
"counts": result["counts"],
"summary": {"companies": companies, "items": len(items)},
},
)
finally:
connection.close()
def _handle_admin_reminders_send(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
raw_keys = data.get("dedupe_keys")
if not isinstance(raw_keys, list) or not raw_keys:
self._send_json(400, {"status": "error", "message": "请指定 dedupe_keys。"})
return
dedupe_keys = [str(key) for key in raw_keys]
sent = reminders.deliver_many(
connection, dedupe_keys, actor=user, ip=self._client_ip
)
self._send_json(200, {"status": "ok", "sent": sent, "count": len(sent)})
finally:
connection.close()
def _handle_admin_reminders_manual(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
try:
company_id = int(data.get("company_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "company_id 无效。"})
return
display_type = str(data.get("display_type") or data.get("type") or "").strip()
content = str(data.get("content") or "").strip()
deadline = str(data.get("deadline") or "").strip() or None
if not display_type or not content:
self._send_json(400, {"status": "error", "message": "类型与内容不能为空。"})
return
try:
reminder_id = reminders.send_manual(
connection,
company_id=company_id,
display_type=display_type,
content=content,
deadline=deadline,
actor=user,
ip=self._client_ip,
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "reminder_id": reminder_id})
finally:
connection.close()
def _handle_admin_reminder_resend(self, reminder_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
try:
reminders.resend_reminder(
connection, reminder_id, actor=user, ip=self._client_ip
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok"})
finally:
connection.close()
def _handle_admin_reminders(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
source = (query.get("source") or [None])[0]
if source not in {None, "auto", "manual", "system"}:
self._send_json(400, {"status": "error", "message": "source 参数无效。"})
return
filter_source = "auto" if source == "system" else source
items = reminders.list_admin_reminders(connection, source=filter_source)
stats = {"unread": 0, "doing": 0, "done": 0}
for item in items:
stats[item["status_ui"]] += 1
self._send_json(200, {"status": "ok", "reminders": items, "stats": stats})
finally:
connection.close()
def _handle_admin_reminder_detail(self, reminder_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
detail = reminders.get_reminder_detail(connection, reminder_id)
if detail is None:
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
return
self._send_json(200, {"status": "ok", "reminder": detail})
finally:
connection.close()
def _handle_admin_reminder_settings_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
self._send_json(200, {"status": "ok", "settings": reminders.get_settings(connection)})
finally:
connection.close()
def _handle_admin_reminder_settings_put(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
updates = data.get("settings") if isinstance(data.get("settings"), dict) else data
if not isinstance(updates, dict):
self._send_json(400, {"status": "error", "message": "settings 格式无效。"})
return
try:
settings = reminders.update_settings(connection, updates)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
auth.audit(
connection,
"reminder_settings_update",
actor=user,
detail=json.dumps(settings, ensure_ascii=False),
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "settings": settings})
finally:
connection.close()
def _handle_company_reminders(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
items = reminders.list_company_reminders(connection, int(user["company_id"]))
unread = reminders.company_unread_count(connection, int(user["company_id"]))
stats = {"unread": 0, "doing": 0, "done": 0}
for item in items:
stats[item["status_ui"]] += 1
self._send_json(
200,
{"status": "ok", "reminders": items, "unread_count": unread, "stats": stats},
)
finally:
connection.close()
def _handle_company_reminder_detail(self, reminder_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
detail = reminders.get_reminder_detail(connection, reminder_id)
if detail is None or int(detail["company_id"]) != int(user["company_id"]):
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
return
self._send_json(200, {"status": "ok", "reminder": detail})
finally:
connection.close()
def _handle_company_reminder_status(self, reminder_id: int, action: str) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
new_status = "acknowledged" if action == "acknowledge" else "resolved"
ok = reminders.update_reminder_status(
connection,
reminder_id,
new_status,
company_id=int(user["company_id"]),
actor=user,
)
if not ok:
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
return
self._send_json(200, {"status": "ok"})
finally:
connection.close()
@staticmethod
def _sheet_payload(row) -> dict[str, object]:
"""Serialize one persisted per-sheet review record."""
@@ -2068,6 +2369,44 @@ class AppHandler(SimpleHTTPRequestHandler):
self.wfile.write(content)
def _parse_scan_time(value: str) -> tuple[int, int]:
parts = value.strip().split(":")
if len(parts) != 2:
return 8, 0
try:
return int(parts[0]), int(parts[1])
except ValueError:
return 8, 0
def _seconds_until_scan(scan_time: str) -> float:
hour, minute = _parse_scan_time(scan_time)
now = datetime.now(timezone.utc)
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return (target - now).total_seconds()
def _scheduled_reminder_scan_loop() -> None:
"""Daily scan thread; audit-only, does not auto-deliver to companies."""
while True:
connection = connect(DB_PATH)
try:
settings = reminders.get_settings(connection)
scan_time = settings.get("scan_time", "08:00")
finally:
connection.close()
time.sleep(max(1.0, _seconds_until_scan(scan_time)))
connection = connect(DB_PATH)
try:
reminders.run_scan(connection, actor=None, ip=None)
except Exception:
pass
finally:
connection.close()
def ensure_bootstrap_admin(connection) -> str | None:
"""Create the first admin when none exists; returns the generated password."""
existing = connection.execute(
@@ -2096,6 +2435,8 @@ def main() -> None:
# Printed once to stdout; never written to any log file.
print(f"Bootstrap admin initial password (shown once): {initial_password}")
server = ThreadingHTTPServer((host, port), AppHandler)
scan_thread = threading.Thread(target=_scheduled_reminder_scan_loop, daemon=True)
scan_thread.start()
print(f"Serving on http://{host}:{port}")
server.serve_forever()
+72
View File
@@ -569,6 +569,78 @@ MIGRATIONS: tuple[Migration, ...] = (
ALTER TABLE import_batches DROP COLUMN upload_bank_account_id;
""",
),
Migration(
version=6,
name="0006_reminders",
up="""
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;
""",
),
)
+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"])
+8 -5
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], first)
self.assertEqual([1, 2, 3, 4, 5, 6], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -73,19 +73,22 @@ class MigrationTests(PersistenceTestCase):
"transfer_match_candidates",
"current_transfer_decisions",
"transfer_observation_claims",
"reminder_settings",
"reminders",
"reminder_events",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([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], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -93,7 +96,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([5], rollback(self.connection, 4))
self.assertEqual([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(6, versions[-1]["version"])
connection.execute("DELETE FROM schema_migrations WHERE version = 6")
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()
+37
View File
@@ -713,6 +713,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">
@@ -764,6 +780,7 @@
<thead>
<tr>
<th>公司</th>
<th>来源</th>
<th>类型</th>
<th class="wrap">内容摘要</th>
<th>发送时间</th>
@@ -1035,6 +1052,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">
+415 -156
View File
@@ -446,7 +446,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 {
@@ -963,7 +968,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.
@@ -976,7 +981,20 @@ function fillCompanySelects(names) {
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
const reminderList = $("#reminderCompanyList");
if (reminderList) {
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;
}));
} else if (reminderList) {
reminderList.replaceChildren(...names.map((name) => {
const label = document.createElement("label");
label.className = "row";
@@ -994,6 +1012,293 @@ function fillCompanySelects(names) {
setSelectOptions($('#openingDialog [name="to"]'), names);
}
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);
@@ -1005,7 +1310,7 @@ async function loadAdminCompanies() {
const result = await response.json().catch(() => null);
const companies = result?.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);
}
@@ -1227,7 +1532,7 @@ function initAdmin() {
remind?.addEventListener("change", renderSwitch);
renderSwitch();
$("#systemSettings")?.addEventListener("submit", (event) => {
$("#systemSettings")?.addEventListener("submit", async (event) => {
event.preventDefault();
const day = parseInt($("#cs-day")?.value, 10);
const tip = $("#cs-save-tip");
@@ -1235,8 +1540,19 @@ function initAdmin() {
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; }
return;
}
const gapDays = parseInt($("#cs-remind-days")?.value, 10) || 5;
const response = 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 (response?.status === 401) { window.location.href = "index.html"; return; }
if (!response?.ok) {
showToast("设置保存失败", "", "danger");
return;
}
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
showToast("系统计算口径已保存", "正式系统将记录修改前后值与操作人", "success");
showToast("系统计算口径已保存", "提醒扫描参数已同步更新", "success");
});
$("#runClosingCheck")?.addEventListener("click", () => {
@@ -1309,119 +1625,7 @@ function initAdmin() {
showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人", "success");
});
$("#reminderForm")?.addEventListener("submit", (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;
const companies = checked.map((c) => c.value).join("、");
const summary = content.length > 46 ? content.slice(0, 46) + "…" : content;
const row = document.createElement("tr");
row.dataset.source = "manual";
row.innerHTML =
'<td class="cell-main">' + companies + '</td>' +
'<td><span class="tag">' + type + '</span></td>' +
'<td class="wrap">' + summary.replace(/</g, "&lt;") + '</td>' +
'<td class="meta">刚刚<span class="cell-sub">人工 · 系统管理员</span></td>' +
'<td class="meta">' + (deadline || "—") + '</td>' +
'<td><span class="pill pill-danger">未读</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-history" data-company="' + checked[0].value + '">查看历史</button></div></td>';
$("#reminder-tbody").insertBefore(row, $("#reminder-tbody").firstChild);
refreshReminderCounts();
form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; });
$("#reminder-content").value = "请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。";
$("#reminder-deadline").value = "2026-08-29";
const sendHint = $("#send-hint");
sendHint.style.display = "";
sendHint.textContent = "已发送给 " + companies + ",共 " + checked.length + " 家公司。";
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
});
// 提醒历史 tabs / 计数
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++; });
$("#count-all").textContent = all;
$("#count-system").textContent = sys;
$("#count-manual").textContent = man;
$("#table-foot-count").textContent = "共 " + 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");
const filter = btn.dataset.filter;
$$("#reminder-tbody tr").forEach((r) => { r.style.display = (filter === "all" || r.dataset.source === filter) ? "" : "none"; });
});
$("#reminder-tbody")?.addEventListener("click", (event) => {
const remindBtn = event.target.closest(".act-remind");
if (remindBtn) {
const tr = remindBtn.closest("tr");
const timeCell = tr.children[3];
const sub = timeCell.querySelector(".cell-sub");
timeCell.firstChild.textContent = "刚刚";
if (sub) sub.textContent = sub.textContent.replace(/\s*·?\s*第 \d+ 次/, "") + " · 再提醒";
else timeCell.insertAdjacentHTML("beforeend", '<span class="cell-sub">再提醒</span>');
remindBtn.textContent = "已再提醒";
remindBtn.disabled = true;
return;
}
const historyBtn = event.target.closest(".act-history");
if (historyBtn) openReminderHistory(historyBtn.dataset.company);
});
const reminderHISTORIES = {
"金牛农业": [
{ time: "2026-08-18 09:02", src: "系统 · 第 2 次", type: "流水未提交", status: "danger", statusText: "未读", body: "贵公司 7 月全部银行账户流水尚未提交,距 7 月结账顺延截止日仅剩 11 天,请尽快上传。" },
{ time: "2026-08-11 09:00", src: "系统 · 第 1 次", type: "流水未提交", status: "danger", statusText: "未读", body: "2026 年 7 月银行流水上传提醒:请于截止日前完成全部账户流水提交。" },
],
"金牛新能源": [
{ time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "warn", statusText: "处理中", body: "与金牛贸易 3 笔单边流水合计 ¥4,860,000.00 待确认,请选择对方银行流水佐证。" },
],
"金牛置业": [
{ time: "2026-08-14 16:40", src: "人工 · 张维", type: "流水未提交", status: "warn", statusText: "处理中", body: "中行尾号 8821 账户 7 月 6—16 日流水断档,请补传该期间银行回单或对账单。" },
],
"金牛贸易": [
{ time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "success", statusText: "已完成", body: "与金牛新能源往来 3 笔单边流水待确认,请核对 7 月销售回款记录。" },
],
"金牛煤业": [
{ time: "2026-08-12 09:00", src: "系统", type: "科目待确认", status: "warn", statusText: "处理中", body: "2 笔手工记录往来科目待确认(应收 / 其他应收),涉及煤炭运费结算。" },
],
"金牛物流": [
{ time: "2026-08-10 11:25", src: "人工 · 张维", type: "科目待确认", status: "success", statusText: "已完成", body: "1 笔运费结算手工记录科目待确认(其他应付待判定),请于结账前完成。" },
],
};
function openReminderHistory(company) {
$("#history-title").textContent = company + " · 提醒历史";
$("#history-sub").textContent = "2026 年 7 月账期以来的全部触达记录,按时间倒序。";
const list = $("#history-list");
const items = reminderHISTORIES[company] || [];
if (!items.length) {
list.innerHTML = '<div class="empty"><div class="e-title">暂无历史记录</div>该公司在 7 月账期内尚未收到过提醒。</div>';
} else {
list.innerHTML = items.map((it) => {
return '<div class="list-row">' +
'<span class="tag">' + it.type + '</span>' +
'<div class="lr-main"><div class="lr-title">' + it.body + '</div><div class="lr-sub"><span class="meta">' + it.time + ' · ' + it.src + '</span></div></div>' +
'<div class="lr-side"><span class="pill pill-' + it.status + '">' + it.statusText + '</span></div></div>';
}).join("");
}
openModal("history-modal");
}
$("#history-close")?.addEventListener("click", () => closeModal("history-modal"));
$("#history-ok")?.addEventListener("click", () => closeModal("history-modal"));
refreshReminderCounts();
initAdminReminders();
}
const FLOW_DEMO = [
@@ -2068,34 +2272,60 @@ function initReconcile() {
refreshCounts();
}
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() {
@@ -2105,15 +2335,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;
}));
}
}
}
@@ -2127,29 +2377,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) {
+10 -109
View File
@@ -25,7 +25,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" style="display: none;">0</span></a>
</nav>
<div class="side-foot">
<div class="user-row">
@@ -200,31 +200,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>
@@ -707,93 +686,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>