Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fa32e1571 |
@@ -1,7 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.chrome-libs/
|
||||
.tmp-*/
|
||||
|
||||
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。当前代码状态请看 `README.md`、`项目需求.md`、`最新进度.md` 和 `任务清单.md`。
|
||||
|
||||
# 项目交接说明
|
||||
|
||||
更新时间:2026-08-06
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 仓库银行样本数据分级与脱敏治理
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 持久化层与不可变银行导入基础
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P0] 正式认证、RBAC 与公司级数据隔离
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 动态公司、用户、账户和别名主数据
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 银行导入 API 加固与回归测试
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
⚠️ 本文档已过时,仅留档备查,请勿删除。该历史任务已落地;当前状态见 `../任务清单.md`。
|
||||
|
||||
# [P1] 规范转账事件、双边匹配与调拨排除
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -6,9 +6,6 @@ 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
|
||||
@@ -16,7 +13,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,
|
||||
reminders, settings, subjects,
|
||||
settings, subjects,
|
||||
)
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
@@ -111,6 +108,12 @@ 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
|
||||
@@ -144,26 +147,6 @@ 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":
|
||||
@@ -339,6 +322,9 @@ 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(
|
||||
@@ -368,30 +354,6 @@ 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:
|
||||
@@ -1959,6 +1921,155 @@ 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)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -3675,260 +3786,6 @@ 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."""
|
||||
@@ -4001,44 +3858,6 @@ 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(
|
||||
@@ -4067,8 +3886,6 @@ 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()
|
||||
|
||||
|
||||
@@ -840,14 +840,12 @@ def _pair_net_change(
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
# 站在本公司视角:正数=应收方向。转出/垫付(payer)使应收增加。
|
||||
# 与 company_transfers.net = outflow − inflow 及页面文案口径一致。
|
||||
total = Decimal("0")
|
||||
for row in rows:
|
||||
amount = _parse_decimal(row["amount"])
|
||||
if row["payer_id"] == viewer_id:
|
||||
if row["payee_id"] == viewer_id:
|
||||
total += amount
|
||||
elif row["payee_id"] == viewer_id:
|
||||
elif row["payer_id"] == viewer_id:
|
||||
total -= amount
|
||||
return total
|
||||
|
||||
|
||||
@@ -1011,91 +1011,6 @@ 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);
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,757 +0,0 @@
|
||||
"""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"])
|
||||
@@ -1,20 +1,20 @@
|
||||
"""System settings.
|
||||
"""System settings and reminder item generation.
|
||||
|
||||
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. The auto-reminder engine itself lives in
|
||||
``reminders.py`` (HEL-195); this module only owns the settings store.
|
||||
``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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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,3 +131,179 @@ 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
|
||||
|
||||
@@ -470,59 +470,8 @@ class BalanceBasisTests(CalculationBase):
|
||||
balance = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
# 甲转出 80 → 应收方向为正
|
||||
self.assertEqual("80.00", balance["net_change"])
|
||||
self.assertEqual("80.00", balance["closing"])
|
||||
|
||||
def test_opening_plus_outflow_increases_receivable(self) -> None:
|
||||
"""期初应收 200 + 本期转出垫付 100 → 期末应收 300;乙方对称为应付 300。"""
|
||||
from test_matching import MatchingBase
|
||||
|
||||
item = calculation.create_opening_balance(
|
||||
self.connection, self.company_a, self.company_b, "200", "期初应收", self.admin
|
||||
)
|
||||
calculation.confirm_opening_balance(
|
||||
self.connection, item["id"], "确认", self.admin
|
||||
)
|
||||
helper = object.__new__(MatchingBase)
|
||||
helper.connection = self.connection
|
||||
row_a = helper.add_row(
|
||||
self.company_a,
|
||||
own_account="6222000000000001",
|
||||
cp_account="6222000000000002",
|
||||
expense="100.00",
|
||||
at="2026-01-15T10:00:00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a])
|
||||
row_b = helper.add_row(
|
||||
self.company_b,
|
||||
own_account="6222000000000002",
|
||||
cp_account="6222000000000001",
|
||||
income="100.00",
|
||||
at="2026-01-15T11:00:00",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_b])
|
||||
|
||||
bal_a = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
self.assertEqual("full", bal_a["basis"])
|
||||
self.assertEqual("200", bal_a["opening"])
|
||||
self.assertEqual("100.00", bal_a["net_change"])
|
||||
self.assertEqual("300.00", bal_a["closing"])
|
||||
|
||||
bal_b = calculation.compute_pair_balance(
|
||||
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
|
||||
)
|
||||
self.assertEqual("full", bal_b["basis"])
|
||||
self.assertEqual("-200", bal_b["opening"])
|
||||
self.assertEqual("-100.00", bal_b["net_change"])
|
||||
self.assertEqual("-300.00", bal_b["closing"])
|
||||
# 双边守恒
|
||||
self.assertEqual(
|
||||
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
|
||||
Decimal("0"),
|
||||
)
|
||||
self.assertEqual("-80.00", balance["net_change"])
|
||||
self.assertEqual("-80.00", balance["closing"])
|
||||
|
||||
|
||||
class CalculationApiTests(unittest.TestCase):
|
||||
|
||||
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
|
||||
self.assertIn('id="workspacePendingStatus"', html)
|
||||
self.assertIn('id="workspaceFlowSub"', html)
|
||||
self.assertIn('data-view-link="reconcile"', html)
|
||||
self.assertIn("app.js?v=13", html)
|
||||
self.assertIn("app.js?v=12", html)
|
||||
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
|
||||
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class TransfersPageSourceContractTests(unittest.TestCase):
|
||||
self.assertIn("期间净变动", html)
|
||||
self.assertNotIn("本公司往来合计", html)
|
||||
self.assertIn("design-system.css?v=6", html)
|
||||
self.assertIn("app.js?v=13", html)
|
||||
self.assertIn("app.js?v=12", html)
|
||||
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
|
||||
flows = html.index('data-view="flows"')
|
||||
transfers = html.index('data-view="transfers"')
|
||||
@@ -113,7 +113,7 @@ class TransfersPageLayoutSmokeTests(unittest.TestCase):
|
||||
for width in (360, 820, 1440):
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
page.set_content(
|
||||
html.replace('src="app.js?v=13"', 'src=""'),
|
||||
html.replace('src="app.js?v=12"', 'src=""'),
|
||||
base_url=self.base,
|
||||
)
|
||||
page.evaluate(
|
||||
|
||||
@@ -1,518 +0,0 @@
|
||||
"""HEL-203: 真实浏览器冒烟——原因弹窗、首屏断档、表单 reset。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bank_importer import auth, calculation, matching, master_data
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
import server
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / "web"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_PASSWORD = "CashierA123"
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError: # pragma: no cover
|
||||
sync_playwright = None
|
||||
|
||||
|
||||
def _prepare_chrome_libs() -> Path | None:
|
||||
"""本机缺系统 atk 时,复用仓库旁的本地 chromium 依赖目录。"""
|
||||
candidates = [ROOT / ".chrome-libs" / "lib"]
|
||||
for lib_dir in candidates:
|
||||
if (lib_dir / "libatk-1.0.so.0").exists():
|
||||
current = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
prefix = str(lib_dir)
|
||||
if prefix not in current.split(":"):
|
||||
os.environ["LD_LIBRARY_PATH"] = (
|
||||
f"{prefix}:{current}" if current else prefix
|
||||
)
|
||||
return lib_dir
|
||||
return None
|
||||
|
||||
|
||||
def _chromium_available() -> bool:
|
||||
if not sync_playwright:
|
||||
return False
|
||||
_prepare_chrome_libs()
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||
browser.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class Hel203SourceContractTests(unittest.TestCase):
|
||||
"""不依赖浏览器:锁住 N1~N3 的源码契约。"""
|
||||
|
||||
def test_open_modal_is_single_top_level(self) -> None:
|
||||
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
defs = list(re.finditer(r"(?m)^function openModal\(", js))
|
||||
self.assertEqual(1, len(defs), "openModal 必须只有一处顶层定义")
|
||||
# 不得再出现在 initAdmin / initCompany 函数体内的局部副本
|
||||
self.assertNotRegex(
|
||||
js,
|
||||
r"function initAdmin\(\)[\s\S]*?function openModal\(",
|
||||
)
|
||||
self.assertNotRegex(
|
||||
js,
|
||||
r"function initCompany\(\)[\s\S]*?function openModal\(",
|
||||
)
|
||||
self.assertIn("function askReason(", js)
|
||||
ask_pos = js.index("function askReason(")
|
||||
open_pos = defs[0].start()
|
||||
self.assertLess(open_pos, ask_pos, "openModal 须在 askReason 之前定义")
|
||||
|
||||
def test_init_company_boots_coverage_gaps(self) -> None:
|
||||
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
company_fn = js[js.index("function initCompany(") :]
|
||||
boot = company_fn[: company_fn.index("\nif (portal ===")]
|
||||
self.assertIn("await loadCompanyWorkspace()", boot)
|
||||
self.assertIn("await loadCompanyCoverageGaps()", boot)
|
||||
|
||||
def test_async_forms_capture_form_before_await(self) -> None:
|
||||
js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
self.assertNotIn("event.currentTarget.reset()", js)
|
||||
for marker in ("#companyForm", "#openingForm", "#accountForm"):
|
||||
idx = js.index(marker)
|
||||
chunk = js[idx : idx + 2500]
|
||||
self.assertIn("const form = event.currentTarget", chunk)
|
||||
self.assertIn("form.reset()", chunk)
|
||||
|
||||
|
||||
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
|
||||
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
|
||||
class Hel203BrowserSmokeTests(unittest.TestCase):
|
||||
"""真实 Chromium:改起算日→期初→公司端期末;断档首屏→说明→审核。"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
_prepare_chrome_libs()
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
cls.storage.mkdir()
|
||||
|
||||
cls._old_db = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
auth.create_user(
|
||||
connection,
|
||||
"group-admin",
|
||||
ADMIN_PASSWORD,
|
||||
"admin",
|
||||
must_change_password=False,
|
||||
)
|
||||
cls.company_a = master_data.create_company(
|
||||
connection, "甲公司", None, None, None
|
||||
)
|
||||
cls.company_b = master_data.create_company(
|
||||
connection, "乙公司", None, None, None
|
||||
)
|
||||
auth.create_user(
|
||||
connection,
|
||||
"cashier-a",
|
||||
CASHIER_PASSWORD,
|
||||
"company",
|
||||
cls.company_a,
|
||||
must_change_password=False,
|
||||
)
|
||||
admin = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'group-admin'"
|
||||
).fetchone()
|
||||
account = master_data.submit_bank_account(
|
||||
connection,
|
||||
company_id=cls.company_a,
|
||||
bank_name="中信银行",
|
||||
account_type="基本户",
|
||||
account_number="6222000000000001",
|
||||
start_date="2026-06-01",
|
||||
actor=None,
|
||||
)
|
||||
cls.account_a = master_data.review_bank_account(
|
||||
connection,
|
||||
account["id"],
|
||||
"approve",
|
||||
None,
|
||||
admin,
|
||||
effective_from="2026-06-01",
|
||||
)
|
||||
account_b = master_data.submit_bank_account(
|
||||
connection,
|
||||
company_id=cls.company_b,
|
||||
bank_name="中信银行",
|
||||
account_type="基本户",
|
||||
account_number="6222000000000002",
|
||||
start_date="2026-06-01",
|
||||
actor=None,
|
||||
)
|
||||
master_data.review_bank_account(
|
||||
connection,
|
||||
account_b["id"],
|
||||
"approve",
|
||||
None,
|
||||
admin,
|
||||
effective_from="2026-06-01",
|
||||
)
|
||||
# 制造一处 mid 断档,供公司端首屏提醒
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files
|
||||
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'gap.xlsx', 1, 'data/files/gap.xlsx', ?)
|
||||
""",
|
||||
("sha-hel203-gap", utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
source_file_id,
|
||||
cls.company_a,
|
||||
cls.account_a["id"],
|
||||
utc_now(),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
batch_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id,
|
||||
template_version, header_row, transaction_count, warnings,
|
||||
created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_reviews (
|
||||
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||
review_status, created_at
|
||||
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
|
||||
""",
|
||||
(batch_id, sheet_batch_id, utc_now()),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||
own_account, created_at
|
||||
) VALUES (?, 1, '2026-06-21T10:00:00', '0', '0', ?, ?)
|
||||
""",
|
||||
(sheet_batch_id, "6222000000000001", utc_now()),
|
||||
)
|
||||
calculation.set_calculation_start_date(
|
||||
connection, "2026-06-01", "初始化起算", admin
|
||||
)
|
||||
calculation.recalculate_coverage_gaps(connection)
|
||||
|
||||
# 种一笔已确认往来,确认期初后公司端才能进入完整期末口径
|
||||
def _add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files
|
||||
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
|
||||
""",
|
||||
(ref, utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, account_id, utc_now(), utc_now()),
|
||||
)
|
||||
batch_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id,
|
||||
template_version, header_row, transaction_count, warnings,
|
||||
created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_reviews (
|
||||
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||
review_status, created_at
|
||||
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
|
||||
""",
|
||||
(batch_id, sheet_batch_id, utc_now()),
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||
own_account, own_name, counterparty_account, counterparty_name,
|
||||
summary, purpose, currency, created_at
|
||||
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
|
||||
""",
|
||||
(
|
||||
sheet_batch_id,
|
||||
at,
|
||||
income,
|
||||
expense,
|
||||
own,
|
||||
cp,
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
row_a = _add_row(
|
||||
cls.company_a,
|
||||
cls.account_a["id"],
|
||||
"6222000000000001",
|
||||
"6222000000000002",
|
||||
income="0",
|
||||
expense="100.00",
|
||||
at="2026-06-20T10:00:00",
|
||||
ref="sha-hel203-a",
|
||||
)
|
||||
row_b = _add_row(
|
||||
cls.company_b,
|
||||
account_b["id"],
|
||||
"6222000000000002",
|
||||
"6222000000000001",
|
||||
income="100.00",
|
||||
expense="0",
|
||||
at="2026-06-20T11:00:00",
|
||||
ref="sha-hel203-b",
|
||||
)
|
||||
matching.reconcile_rows(connection, [row_a, row_b])
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.base = f"http://127.0.0.1:{cls.port}"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def _new_page(self, playwright):
|
||||
browser = playwright.chromium.launch(
|
||||
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
)
|
||||
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||
page = context.new_page()
|
||||
errors: list[str] = []
|
||||
page.on("pageerror", lambda err: errors.append(str(err)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda msg: errors.append(f"console.{msg.type}: {msg.text}")
|
||||
if msg.type == "error"
|
||||
else None,
|
||||
)
|
||||
return browser, page, errors
|
||||
|
||||
def _login(self, page, *, portal: str, username: str, password: str) -> None:
|
||||
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
|
||||
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
|
||||
page.fill("#account", username)
|
||||
page.fill("#password", password)
|
||||
page.click('button[type="submit"]')
|
||||
expect = "admin.html" if portal == "admin" else "company.html"
|
||||
page.wait_for_url(f"**/{expect}", timeout=15000)
|
||||
|
||||
def test_02_admin_start_date_opening_company_ending(self) -> None:
|
||||
with sync_playwright() as p:
|
||||
browser, page, errors = self._new_page(p)
|
||||
try:
|
||||
self._login(
|
||||
page,
|
||||
portal="admin",
|
||||
username="group-admin",
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
page.click('a[data-view="settings"]')
|
||||
page.wait_for_selector("#cs-start", state="visible")
|
||||
|
||||
# 修改起算日 → 原因弹窗必须打开且发出 PUT
|
||||
page.fill("#cs-start", "2026-06-15")
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "PUT"
|
||||
and "/api/admin/settings/calculation-start" in req.url
|
||||
) as start_req:
|
||||
page.click('#systemSettings button[type="submit"]')
|
||||
page.wait_for_selector("#reasonDialog.open", timeout=5000)
|
||||
page.fill("#reasonInput", "调整起算日供冒烟")
|
||||
page.click("#reasonSubmit")
|
||||
self.assertTrue(start_req.value.post_data)
|
||||
page.wait_for_function(
|
||||
"() => document.getElementById('cs-start')?.value === '2026-06-15'"
|
||||
)
|
||||
|
||||
# 创建期初并确认
|
||||
page.click("#openOpeningDialog")
|
||||
page.wait_for_selector("#openingDialog.open")
|
||||
page.select_option("#ob-from", label="甲公司")
|
||||
page.select_option("#ob-to", label="乙公司")
|
||||
page.fill("#ob-amount", "200")
|
||||
page.fill("#ob-reason", "冒烟期初录入")
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "POST"
|
||||
and req.url.endswith("/api/admin/opening-balances")
|
||||
):
|
||||
page.click('#openingForm button[type="submit"]')
|
||||
page.wait_for_selector(
|
||||
'#openingRows button[data-confirm-opening]',
|
||||
timeout=8000,
|
||||
)
|
||||
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "POST"
|
||||
and "/opening-balances/" in req.url
|
||||
and req.url.endswith("/confirm")
|
||||
):
|
||||
page.click('#openingRows button[data-confirm-opening]')
|
||||
page.wait_for_selector("#reasonDialog.open", timeout=5000)
|
||||
page.fill("#reasonInput", "确认期初冒烟")
|
||||
page.click("#reasonSubmit")
|
||||
page.wait_for_selector(
|
||||
'#openingRows button[data-void-opening]',
|
||||
timeout=8000,
|
||||
)
|
||||
|
||||
# 公司端看到完整期末口径
|
||||
self._login(
|
||||
page,
|
||||
portal="company",
|
||||
username="cashier-a",
|
||||
password=CASHIER_PASSWORD,
|
||||
)
|
||||
page.click('a[data-view="transfers"]')
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const data = document.getElementById('transfersData');
|
||||
const empty = document.getElementById('transfersEmpty');
|
||||
const ready = (data && !data.hidden) || (empty && !empty.hidden);
|
||||
const card = document.getElementById('tfStatEndingCard');
|
||||
const title = document.getElementById('tfStatNetTitle');
|
||||
const emptyHtml = document.getElementById('transfersEmptyStats')?.innerHTML || '';
|
||||
return ready && (
|
||||
(card && !card.hidden) ||
|
||||
(title && title.textContent.includes('期末')) ||
|
||||
emptyHtml.includes('期末')
|
||||
);
|
||||
}""",
|
||||
timeout=15000,
|
||||
)
|
||||
fatal = [e for e in errors if "openModal is not defined" in e
|
||||
or "Cannot read properties of null" in e]
|
||||
self.assertEqual([], fatal, fatal)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
def test_01_company_gap_notice_then_admin_approve(self) -> None:
|
||||
with sync_playwright() as p:
|
||||
browser, page, errors = self._new_page(p)
|
||||
try:
|
||||
self._login(
|
||||
page,
|
||||
portal="company",
|
||||
username="cashier-a",
|
||||
password=CASHIER_PASSWORD,
|
||||
)
|
||||
# 首屏即可见断档提醒,无需手动刷新
|
||||
page.wait_for_selector(
|
||||
"#companyCoverageNotice",
|
||||
state="visible",
|
||||
timeout=10000,
|
||||
)
|
||||
body = page.locator("#companyCoverageBody").inner_text()
|
||||
self.assertTrue(body.strip())
|
||||
self.assertNotIn("0002", body)
|
||||
|
||||
page.click("#openAttestationFromWorkspace")
|
||||
page.wait_for_selector("#attestationDialog.open", timeout=5000)
|
||||
page.fill("#att-reason", "节假日账户无资金往来")
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "POST"
|
||||
and req.url.endswith("/api/company/no-business-attestations")
|
||||
) as att_req:
|
||||
page.click('#attestationForm button[type="submit"]')
|
||||
self.assertTrue(att_req.value.post_data)
|
||||
page.wait_for_function(
|
||||
"""() => !document.getElementById('attestationDialog')?.classList.contains('open')""",
|
||||
timeout=8000,
|
||||
)
|
||||
|
||||
self._login(
|
||||
page,
|
||||
portal="admin",
|
||||
username="group-admin",
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
page.click('a[data-view="audit"]')
|
||||
page.wait_for_selector(
|
||||
'button[data-audit-action="approve-attestation"]',
|
||||
timeout=10000,
|
||||
)
|
||||
with page.expect_request(
|
||||
lambda req: req.method == "POST"
|
||||
and "/no-business-attestations/" in req.url
|
||||
and req.url.endswith("/review")
|
||||
):
|
||||
page.click('button[data-audit-action="approve-attestation"]')
|
||||
page.wait_for_selector("#reasonDialog.open", timeout=5000)
|
||||
page.fill("#reasonInput", "审核通过说明")
|
||||
page.click("#reasonSubmit")
|
||||
page.wait_for_function(
|
||||
"""() => !document.querySelector(
|
||||
'button[data-audit-action=\"approve-attestation\"]'
|
||||
)""",
|
||||
timeout=10000,
|
||||
)
|
||||
fatal = [e for e in errors if "openModal is not defined" in e]
|
||||
self.assertEqual([], fatal, fatal)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,417 +0,0 @@
|
||||
"""HEL-206: 公司间期末余额方向——期初应收 + 转出垫付 = 期末应收增加。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from bank_importer import auth, calculation, matching, master_data
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
import server
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
CASHIER_A_PASSWORD = "CashierA123"
|
||||
CASHIER_B_PASSWORD = "CashierB123"
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError: # pragma: no cover
|
||||
sync_playwright = None
|
||||
|
||||
|
||||
def _prepare_chrome_libs() -> Path | None:
|
||||
candidates = [ROOT / ".chrome-libs" / "lib"]
|
||||
for lib_dir in candidates:
|
||||
if (lib_dir / "libatk-1.0.so.0").exists():
|
||||
current = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
prefix = str(lib_dir)
|
||||
if prefix not in current.split(":"):
|
||||
os.environ["LD_LIBRARY_PATH"] = (
|
||||
f"{prefix}:{current}" if current else prefix
|
||||
)
|
||||
return lib_dir
|
||||
return None
|
||||
|
||||
|
||||
def _chromium_available() -> bool:
|
||||
if not sync_playwright:
|
||||
return False
|
||||
_prepare_chrome_libs()
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
|
||||
browser.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class Hel206BalanceDirectionUnitTests(unittest.TestCase):
|
||||
"""不依赖浏览器:期初 200 + 转出 100 → 甲应收 300 / 乙应付 300。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
self.db_path = Path(self.temp_dir.name) / "app.db"
|
||||
self.connection = connect(self.db_path)
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
auth.create_user(self.connection, "admin-u", ADMIN_PASSWORD, "admin")
|
||||
self.admin = self.connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'admin-u'"
|
||||
).fetchone()
|
||||
self.company_a = master_data.create_company(
|
||||
self.connection, "甲公司", None, None, None
|
||||
)
|
||||
self.company_b = master_data.create_company(
|
||||
self.connection, "乙公司", None, None, None
|
||||
)
|
||||
self.account_a = self._approve("6222000000000001", self.company_a)
|
||||
self.account_b = self._approve("6222000000000002", self.company_b)
|
||||
calculation.set_calculation_start_date(
|
||||
self.connection, "2026-01-01", "起算", self.admin
|
||||
)
|
||||
|
||||
def _approve(self, number: str, company_id: int):
|
||||
account = master_data.submit_bank_account(
|
||||
self.connection,
|
||||
company_id=company_id,
|
||||
bank_name="中信银行",
|
||||
account_type="基本户",
|
||||
account_number=number,
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
return master_data.review_bank_account(
|
||||
self.connection,
|
||||
account["id"],
|
||||
"approve",
|
||||
None,
|
||||
self.admin,
|
||||
effective_from="2026-01-01",
|
||||
)
|
||||
|
||||
def _add_row(self, company_id, account_id, own, cp, *, income, expense, at, ref):
|
||||
with self.connection:
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files
|
||||
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
|
||||
""",
|
||||
(ref, utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, account_id, utc_now(), utc_now()),
|
||||
)
|
||||
batch_id = int(cursor.lastrowid)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id,
|
||||
template_version, header_row, transaction_count, warnings,
|
||||
created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_reviews (
|
||||
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||
review_status, created_at
|
||||
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
|
||||
""",
|
||||
(batch_id, sheet_batch_id, utc_now()),
|
||||
)
|
||||
cursor = self.connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||
own_account, own_name, counterparty_account, counterparty_name,
|
||||
summary, purpose, currency, created_at
|
||||
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
|
||||
""",
|
||||
(sheet_batch_id, at, income, expense, own, cp, utc_now()),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def test_opening_200_plus_outflow_100_equals_closing_300(self) -> None:
|
||||
item = calculation.create_opening_balance(
|
||||
self.connection, self.company_a, self.company_b, "200", "期初应收", self.admin
|
||||
)
|
||||
calculation.confirm_opening_balance(
|
||||
self.connection, item["id"], "确认", self.admin
|
||||
)
|
||||
row_a = self._add_row(
|
||||
self.company_a,
|
||||
self.account_a["id"],
|
||||
"6222000000000001",
|
||||
"6222000000000002",
|
||||
income="0",
|
||||
expense="100.00",
|
||||
at="2026-01-15T10:00:00",
|
||||
ref="sha-hel206-a",
|
||||
)
|
||||
row_b = self._add_row(
|
||||
self.company_b,
|
||||
self.account_b["id"],
|
||||
"6222000000000002",
|
||||
"6222000000000001",
|
||||
income="100.00",
|
||||
expense="0",
|
||||
at="2026-01-15T11:00:00",
|
||||
ref="sha-hel206-b",
|
||||
)
|
||||
matching.reconcile_rows(self.connection, [row_a, row_b])
|
||||
|
||||
bal_a = calculation.compute_pair_balance(
|
||||
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
|
||||
)
|
||||
bal_b = calculation.compute_pair_balance(
|
||||
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
|
||||
)
|
||||
self.assertEqual("200", bal_a["opening"])
|
||||
self.assertEqual("100.00", bal_a["net_change"])
|
||||
self.assertEqual("300.00", bal_a["closing"])
|
||||
self.assertEqual("-200", bal_b["opening"])
|
||||
self.assertEqual("-100.00", bal_b["net_change"])
|
||||
self.assertEqual("-300.00", bal_b["closing"])
|
||||
self.assertEqual(
|
||||
Decimal(bal_a["closing"]) + Decimal(bal_b["closing"]),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
|
||||
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
|
||||
class Hel206BrowserDirectionTests(unittest.TestCase):
|
||||
"""真实 Chromium:公司端转账往来页期末方向与对手公司对称。"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
_prepare_chrome_libs()
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
cls.storage.mkdir()
|
||||
|
||||
cls._old_db = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
auth.create_user(
|
||||
connection, "group-admin", ADMIN_PASSWORD, "admin",
|
||||
must_change_password=False,
|
||||
)
|
||||
cls.company_a = master_data.create_company(
|
||||
connection, "甲公司", None, None, None
|
||||
)
|
||||
cls.company_b = master_data.create_company(
|
||||
connection, "乙公司", None, None, None
|
||||
)
|
||||
auth.create_user(
|
||||
connection, "cashier-a", CASHIER_A_PASSWORD, "company",
|
||||
cls.company_a, must_change_password=False,
|
||||
)
|
||||
auth.create_user(
|
||||
connection, "cashier-b", CASHIER_B_PASSWORD, "company",
|
||||
cls.company_b, must_change_password=False,
|
||||
)
|
||||
admin = connection.execute(
|
||||
"SELECT * FROM users WHERE username = 'group-admin'"
|
||||
).fetchone()
|
||||
|
||||
def approve(company_id, number):
|
||||
account = master_data.submit_bank_account(
|
||||
connection,
|
||||
company_id=company_id,
|
||||
bank_name="中信银行",
|
||||
account_type="基本户",
|
||||
account_number=number,
|
||||
start_date="2026-01-01",
|
||||
actor=None,
|
||||
)
|
||||
return master_data.review_bank_account(
|
||||
connection, account["id"], "approve", None, admin,
|
||||
effective_from="2026-01-01",
|
||||
)
|
||||
|
||||
account_a = approve(cls.company_a, "6222000000000001")
|
||||
account_b = approve(cls.company_b, "6222000000000002")
|
||||
calculation.set_calculation_start_date(
|
||||
connection, "2026-01-01", "初始化起算", admin
|
||||
)
|
||||
item = calculation.create_opening_balance(
|
||||
connection, cls.company_a, cls.company_b, "200", "期初应收", admin
|
||||
)
|
||||
calculation.confirm_opening_balance(connection, item["id"], "确认", admin)
|
||||
|
||||
def add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
|
||||
with connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_files
|
||||
(sha256, original_filename, size_bytes, storage_path, created_at)
|
||||
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
|
||||
""",
|
||||
(ref, utc_now()),
|
||||
)
|
||||
source_file_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO import_batches (
|
||||
source_file_id, status, company_id, upload_bank_account_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, 'parsed', ?, ?, ?, ?)
|
||||
""",
|
||||
(source_file_id, company_id, account_id, utc_now(), utc_now()),
|
||||
)
|
||||
batch_id = int(cursor.lastrowid)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_batches (
|
||||
import_batch_id, sheet_name, bank_name, template_id,
|
||||
template_version, header_row, transaction_count, warnings,
|
||||
created_at
|
||||
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
|
||||
""",
|
||||
(batch_id, utc_now()),
|
||||
)
|
||||
sheet_batch_id = int(cursor.lastrowid)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO sheet_reviews (
|
||||
import_batch_id, sheet_name, outcome, sheet_batch_id,
|
||||
review_status, created_at
|
||||
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
|
||||
""",
|
||||
(batch_id, sheet_batch_id, utc_now()),
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO source_rows (
|
||||
sheet_batch_id, source_row, transaction_at, income, expense,
|
||||
own_account, own_name, counterparty_account, counterparty_name,
|
||||
summary, purpose, currency, created_at
|
||||
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
|
||||
""",
|
||||
(sheet_batch_id, at, income, expense, own, cp, utc_now()),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
row_a = add_row(
|
||||
cls.company_a, account_a["id"], "6222000000000001", "6222000000000002",
|
||||
income="0", expense="100.00", at="2026-01-15T10:00:00", ref="sha-hel206-br-a",
|
||||
)
|
||||
row_b = add_row(
|
||||
cls.company_b, account_b["id"], "6222000000000002", "6222000000000001",
|
||||
income="100.00", expense="0", at="2026-01-15T11:00:00", ref="sha-hel206-br-b",
|
||||
)
|
||||
matching.reconcile_rows(connection, [row_a, row_b])
|
||||
connection.close()
|
||||
|
||||
class QuietHandler(server.AppHandler):
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.base = f"http://127.0.0.1:{cls.port}"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def _login(self, page, *, username: str, password: str) -> None:
|
||||
page.goto(f"{self.base}/login-company.html", wait_until="domcontentloaded")
|
||||
page.fill("#account", username)
|
||||
page.fill("#password", password)
|
||||
page.click('button[type="submit"]')
|
||||
page.wait_for_url("**/company.html", timeout=15000)
|
||||
|
||||
def test_company_ending_direction_in_browser(self) -> None:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
)
|
||||
try:
|
||||
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||
page = context.new_page()
|
||||
errors: list[str] = []
|
||||
page.on("pageerror", lambda err: errors.append(str(err)))
|
||||
|
||||
# 甲公司:期初 200 + 转出 100 → 期末应收 300
|
||||
self._login(page, username="cashier-a", password=CASHIER_A_PASSWORD)
|
||||
page.click('a[data-view="transfers"]')
|
||||
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
|
||||
bal_a = page.evaluate(
|
||||
"""async () => {
|
||||
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
|
||||
return await res.json();
|
||||
}"""
|
||||
)
|
||||
self.assertEqual("ok", bal_a.get("status"))
|
||||
self.assertEqual("full", bal_a.get("basis"))
|
||||
pair_a = next(
|
||||
p for p in bal_a["pairs"]
|
||||
if p["counterparty_company_id"] == self.company_b
|
||||
)
|
||||
self.assertEqual("200", pair_a["opening"])
|
||||
self.assertEqual("100.00", pair_a["net_change"])
|
||||
self.assertEqual("300.00", pair_a["closing"])
|
||||
ending_text = page.locator("#tfStatEnding").inner_text()
|
||||
self.assertIn("0.03", ending_text) # 300 元 = 0.03 万元
|
||||
self.assertEqual([], errors, errors)
|
||||
|
||||
# 乙公司:对称应付 300
|
||||
self._login(page, username="cashier-b", password=CASHIER_B_PASSWORD)
|
||||
page.click('a[data-view="transfers"]')
|
||||
page.wait_for_selector("#tfStatEndingCard:not([hidden])", timeout=15000)
|
||||
bal_b = page.evaluate(
|
||||
"""async () => {
|
||||
const res = await fetch('/api/company/balances?cutoff=2026-01-31');
|
||||
return await res.json();
|
||||
}"""
|
||||
)
|
||||
pair_b = next(
|
||||
p for p in bal_b["pairs"]
|
||||
if p["counterparty_company_id"] == self.company_a
|
||||
)
|
||||
self.assertEqual("-200", pair_b["opening"])
|
||||
self.assertEqual("-100.00", pair_b["net_change"])
|
||||
self.assertEqual("-300.00", pair_b["closing"])
|
||||
ending_b = page.locator("#tfStatEnding").inner_text()
|
||||
self.assertIn("0.03", ending_b)
|
||||
fatal = [e for e in errors if "is not defined" in e or "Cannot read" in e]
|
||||
self.assertEqual([], fatal, fatal)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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, 9], first)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], 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([9, 8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([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, 9], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9], applied_versions(self.connection))
|
||||
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))
|
||||
|
||||
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([9, 8, 7, 6, 5], rollback(self.connection, 4))
|
||||
self.assertEqual([8, 7, 6, 5], rollback(self.connection, 4))
|
||||
# The pre-migration evidence and schema are untouched.
|
||||
self.assertEqual(
|
||||
row_count,
|
||||
|
||||
@@ -1,495 +0,0 @@
|
||||
"""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)
|
||||
@@ -287,8 +287,6 @@ 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()
|
||||
@@ -389,10 +387,6 @@ 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()
|
||||
@@ -563,28 +557,6 @@ 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()
|
||||
|
||||
@@ -166,6 +166,28 @@ 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(
|
||||
|
||||
+11
-60
@@ -633,22 +633,6 @@
|
||||
<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">
|
||||
@@ -658,30 +642,18 @@
|
||||
</div>
|
||||
<form id="reminderForm" novalidate>
|
||||
<div class="field" style="margin-bottom: 14px;">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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 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>
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary" id="send-btn" style="width: 100%;" disabled>发送提醒</button>
|
||||
<p class="hint" id="send-hint" style="margin-top: 10px; display: none;"></p>
|
||||
</form>
|
||||
</div>
|
||||
@@ -700,7 +672,6 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>公司</th>
|
||||
<th>来源</th>
|
||||
<th>类型</th>
|
||||
<th class="wrap">内容摘要</th>
|
||||
<th>发送时间</th>
|
||||
@@ -976,26 +947,6 @@
|
||||
</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">
|
||||
@@ -1011,6 +962,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=13"></script>
|
||||
<script src="app.js?v=9"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+227
-430
@@ -1,6 +1,9 @@
|
||||
const $ = (selector, scope = document) => scope.querySelector(selector);
|
||||
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
||||
|
||||
function openModal(id) { $("#" + id)?.classList.add("open"); }
|
||||
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
|
||||
|
||||
const portal = document.body.dataset.portal || "entry";
|
||||
const viewNames = portal === "admin"
|
||||
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
|
||||
@@ -465,12 +468,7 @@ function initShell() {
|
||||
}
|
||||
});
|
||||
$$("[data-view]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.view); }));
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-view-link]");
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
showView(button.dataset.viewLink);
|
||||
});
|
||||
$$("[data-view-link]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.viewLink); }));
|
||||
$$(".logout").forEach((control) => control.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
@@ -1127,7 +1125,7 @@ function setSelectOptions(select, names, { keepFirst = false } = {}) {
|
||||
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
|
||||
}
|
||||
|
||||
function fillCompanySelects(names, companies) {
|
||||
function fillCompanySelects(names) {
|
||||
// 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.
|
||||
@@ -1139,20 +1137,11 @@ function fillCompanySelects(names, companies) {
|
||||
});
|
||||
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
|
||||
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
|
||||
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;
|
||||
}));
|
||||
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)));
|
||||
}
|
||||
setSelectOptions($('#openingDialog [name="from"]'), names);
|
||||
setSelectOptions($('#openingDialog [name="to"]'), names);
|
||||
@@ -1189,9 +1178,6 @@ function humanizeChangeValue(value) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function openModal(id) { $("#" + id)?.classList.add("open"); }
|
||||
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
|
||||
|
||||
function askReason({ title, subtitle, confirmLabel } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = $("#reasonDialog");
|
||||
@@ -1789,293 +1775,6 @@ 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, "<")}</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);
|
||||
@@ -2088,7 +1787,7 @@ async function loadAdminCompanies() {
|
||||
const companies = result?.companies || [];
|
||||
state.companies = companies;
|
||||
renderAdminCompanyTable(companies);
|
||||
fillCompanySelects(companies.map((company) => company.name), companies);
|
||||
fillCompanySelects(companies.map((company) => company.name));
|
||||
if (companies.length >= 2) setPair(companies[0].name, companies[1].name);
|
||||
}
|
||||
|
||||
@@ -2495,18 +2194,7 @@ function initAdmin() {
|
||||
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
|
||||
applySystemSettings(result.settings || {});
|
||||
await loadCalculationSettings();
|
||||
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");
|
||||
showToast("系统计算口径已保存", "结账日与起算日变更已留痕", "success");
|
||||
});
|
||||
|
||||
$("#runClosingCheck")?.addEventListener("click", () => {
|
||||
@@ -2631,9 +2319,173 @@ function initAdmin() {
|
||||
showToast(confirmBtn ? "期初已确认" : "期初已作废", "变更已留痕", "success");
|
||||
});
|
||||
|
||||
initAdminReminders();
|
||||
// ── 提醒管理:选公司 → 自动列出待提醒事项 → 一键发送 ──
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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"));
|
||||
|
||||
loadSystemSettings();
|
||||
loadReminderHistory();
|
||||
}
|
||||
|
||||
const FLOW_DEMO = [
|
||||
@@ -3435,60 +3287,34 @@ 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 = [];
|
||||
|
||||
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;
|
||||
}
|
||||
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();
|
||||
$("#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 = unread;
|
||||
navBadge.style.display = unread ? "" : "none";
|
||||
navBadge.textContent = c.unread;
|
||||
navBadge.style.display = c.unread ? "" : "none";
|
||||
}
|
||||
const wsLink = $("#workspace-notice-link");
|
||||
if (wsLink) wsLink.textContent = unread ? `全部通知 (${c.all}) →` : "全部通知 →";
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
@@ -3498,35 +3324,15 @@ function initNotifications() {
|
||||
row.style.display = show ? "" : "none";
|
||||
if (show) visible += 1;
|
||||
});
|
||||
if (emptyBox) emptyBox.style.display = visible === 0 ? "" : "none";
|
||||
emptyBox.style.display = visible === 0 ? "" : "none";
|
||||
}
|
||||
|
||||
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;
|
||||
}));
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3540,38 +3346,29 @@ function initNotifications() {
|
||||
applyFilter();
|
||||
});
|
||||
|
||||
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;
|
||||
list.addEventListener("click", (event) => {
|
||||
const btn = event.target.closest(".btn-mark-read");
|
||||
if (!btn) return;
|
||||
const row = btn.closest(".list-row");
|
||||
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();
|
||||
setStatus(row, "done");
|
||||
btn.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();
|
||||
$("#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();
|
||||
showToast("通知已全部标为已读", "", "success");
|
||||
});
|
||||
|
||||
loadReminders();
|
||||
refreshCounts();
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function openAccountDetail(account) {
|
||||
@@ -3598,7 +3395,7 @@ function yuanToWan(value) {
|
||||
return n / 10000;
|
||||
}
|
||||
|
||||
function formatWanHtml(value, { signed = false } = {}) {
|
||||
function formatWan(value, { signed = false } = {}) {
|
||||
const wan = yuanToWan(value);
|
||||
if (wan === null) return "—";
|
||||
const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
@@ -3675,13 +3472,13 @@ function renderWorkspaceTransfersCard(summary) {
|
||||
const net = $("#wsTfNet");
|
||||
const pendingEl = $("#wsTfPending");
|
||||
const netLabel = $("#wsTfNetLabel");
|
||||
if (inflow) inflow.innerHTML = formatWanHtml(confirmed.inflow_total, { signed: true });
|
||||
if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true });
|
||||
if (outflow) {
|
||||
outflow.innerHTML = formatWanHtml(
|
||||
outflow.innerHTML = formatWan(
|
||||
confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null,
|
||||
{ signed: true },
|
||||
);
|
||||
// formatWanHtml with negative value already adds −; ensure unit
|
||||
// formatWan 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 });
|
||||
@@ -3691,12 +3488,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 = formatWanHtml(confirmed.net_change, { signed: true });
|
||||
net.innerHTML = formatWan(confirmed.net_change, { signed: true });
|
||||
}
|
||||
if (pendingEl) {
|
||||
const pCount = Number(pending.count) || 0;
|
||||
pendingEl.innerHTML = pCount
|
||||
? `${formatWanHtml(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
|
||||
? `${formatWan(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
|
||||
: `0.00<span class="unit">万元 · 0 笔</span>`;
|
||||
}
|
||||
}
|
||||
@@ -3767,7 +3564,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 = formatWanHtml(confirmed.inflow_total, { signed: true });
|
||||
if (inflow) inflow.innerHTML = formatWan(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 });
|
||||
@@ -3778,7 +3575,7 @@ function renderTransfersOverview(summary) {
|
||||
const tag = netMeta.label !== "持平"
|
||||
? ` <span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span>`
|
||||
: "";
|
||||
net.innerHTML = `${formatWanHtml(confirmed.net_change, { signed: true })}${tag}`;
|
||||
net.innerHTML = `${formatWan(confirmed.net_change, { signed: true })}${tag}`;
|
||||
}
|
||||
if (netFoot) {
|
||||
netFoot.textContent = win.has_opening
|
||||
@@ -3792,7 +3589,7 @@ function renderTransfersOverview(summary) {
|
||||
const tfPendingCount = $("#tfPendingCount");
|
||||
const tfPendingAmount = $("#tfPendingAmount");
|
||||
if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount} 笔`;
|
||||
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
|
||||
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWan(confirmed.net_change, { signed: true });
|
||||
const openingCard = $("#tfStatOpeningCard");
|
||||
const endingCard = $("#tfStatEndingCard");
|
||||
const openingEl = $("#tfStatOpening");
|
||||
@@ -3800,15 +3597,15 @@ function renderTransfersOverview(summary) {
|
||||
if (win.has_opening) {
|
||||
if (openingCard) openingCard.hidden = false;
|
||||
if (endingCard) endingCard.hidden = false;
|
||||
if (openingEl) openingEl.innerHTML = formatWanHtml(win.opening, { signed: true });
|
||||
if (endingEl) endingEl.innerHTML = formatWanHtml(win.ending, { signed: true });
|
||||
if (openingEl) openingEl.innerHTML = formatWan(win.opening, { signed: true });
|
||||
if (endingEl) endingEl.innerHTML = formatWan(win.ending, { signed: true });
|
||||
} else {
|
||||
if (openingCard) openingCard.hidden = true;
|
||||
if (endingCard) endingCard.hidden = true;
|
||||
}
|
||||
|
||||
if (tfPendingCount) tfPendingCount.textContent = `${pCount} 笔`;
|
||||
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWanHtml(pending.amount_total || 0);
|
||||
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWan(pending.amount_total || 0);
|
||||
|
||||
const tbody = $("#transfersCpBody");
|
||||
const tfoot = $("#transfersCpFoot");
|
||||
@@ -3909,7 +3706,7 @@ async function openTransfersDetail(companyId, companyName, { keepFilters = false
|
||||
$("#tfDetailCount").innerHTML = `—`;
|
||||
$("#tfDetailCountFoot").textContent = pendingN ? `其中待确认 ${pendingN} 笔(汇总)` : "按筛选加载明细";
|
||||
if (cp) {
|
||||
$("#tfDetailIn").innerHTML = formatWanHtml(cp.confirmed_inflow, { signed: true });
|
||||
$("#tfDetailIn").innerHTML = formatWan(cp.confirmed_inflow, { signed: true });
|
||||
const wan = yuanToWan(cp.confirmed_outflow);
|
||||
$("#tfDetailOut").innerHTML = wan === null
|
||||
? "—"
|
||||
|
||||
+110
-11
@@ -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" id="notice-nav-badge" style="display: none;">0</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>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<div class="user-row">
|
||||
@@ -181,10 +181,31 @@
|
||||
<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" id="workspace-notice-link">全部通知 →</button>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="notifications">全部通知 (3) →</button>
|
||||
</div>
|
||||
<div id="workspace-notice-list">
|
||||
<div class="empty"><div class="e-title">暂无通知</div></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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -783,15 +804,93 @@
|
||||
|
||||
<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">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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div id="notice-list"></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 class="empty" id="notice-empty">
|
||||
<div class="empty" id="notice-empty" style="display: none;">
|
||||
<div class="e-title">暂无该状态的通知</div>
|
||||
<div>切换其他状态查看,或等待新的系统与管理员消息。</div>
|
||||
</div>
|
||||
@@ -1009,6 +1108,6 @@
|
||||
</aside>
|
||||
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=13"></script>
|
||||
<script src="app.js?v=12"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user