HEL-144 返工A:四件事(改名/登录端标识/结账设置真保存/提醒回改)+ 迁移升为 7 + 移除废弃 B-44 前端样式测试
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -12,7 +12,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from bank_importer import (
|
||||
auth, importing, ledger_events, manual_records, master_data, matching,
|
||||
multipart, personal_transit, positions, subjects,
|
||||
multipart, personal_transit, positions, settings, subjects,
|
||||
)
|
||||
from bank_importer.db import connect, migrate, utc_now
|
||||
|
||||
@@ -80,6 +80,15 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if path == "/api/admin/audit-log":
|
||||
self._handle_admin_audit_log(query)
|
||||
return
|
||||
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
|
||||
@@ -232,6 +241,12 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if mapping_review:
|
||||
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
|
||||
return
|
||||
if path == "/api/admin/settings":
|
||||
self._handle_admin_update_settings()
|
||||
return
|
||||
if path == "/api/admin/reminders/send":
|
||||
self._handle_admin_send_reminders()
|
||||
return
|
||||
|
||||
# B-44 intercompany positions (admin writes)
|
||||
subject_decision = re.fullmatch(
|
||||
@@ -297,7 +312,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
if user is None:
|
||||
return None
|
||||
if user["role"] != "admin":
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限总账管理员。"})
|
||||
self._send_json(403, {"status": "error", "message": "该操作仅限管理员。"})
|
||||
return None
|
||||
return user
|
||||
|
||||
@@ -354,7 +369,7 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
return
|
||||
if reason == "disabled":
|
||||
self._send_json(
|
||||
403, {"status": "error", "message": "账号已停用,请联系总账管理员。"}
|
||||
403, {"status": "error", "message": "账号已停用,请联系管理员。"}
|
||||
)
|
||||
return
|
||||
if user is None:
|
||||
@@ -1404,6 +1419,204 @@ class AppHandler(SimpleHTTPRequestHandler):
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# System settings (admin read/write, persisted + audited)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_admin_settings(self) -> None:
|
||||
connection = connect(DB_PATH)
|
||||
try:
|
||||
user = self._require_admin(connection)
|
||||
if user is None:
|
||||
return
|
||||
values = settings.get_settings(connection)
|
||||
self._send_json(200, {"status": "ok", "settings": values})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _handle_admin_update_settings(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
|
||||
before = settings.get_settings(connection)
|
||||
try:
|
||||
updated = settings.update_settings(connection, data, user)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"status": "error", "message": str(exc)})
|
||||
return
|
||||
changed = {
|
||||
key: updated[key]
|
||||
for key in updated
|
||||
if before.get(key) != updated[key]
|
||||
}
|
||||
auth.audit(
|
||||
connection,
|
||||
"settings_update",
|
||||
actor=user,
|
||||
target="system_settings",
|
||||
detail=";".join(
|
||||
f"{key}:{before.get(key)}->{updated[key]}" for key in changed
|
||||
),
|
||||
ip=self._client_ip,
|
||||
)
|
||||
self._send_json(200, {"status": "ok", "settings": updated})
|
||||
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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -798,6 +798,54 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
DROP TABLE IF EXISTS manual_records;
|
||||
""",
|
||||
),
|
||||
Migration(
|
||||
version=7,
|
||||
name="0007_system_settings_and_reminders",
|
||||
# System settings (closing day, global start date, auto-reminder) are
|
||||
# persisted as a key/value table with an append-only change history
|
||||
# (operator + before/after) for the audit requirement. Reminders are a
|
||||
# separate append-only table so reminder history survives re-sends.
|
||||
up="""
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_by INTEGER REFERENCES users (id),
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE system_setting_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
before_value TEXT,
|
||||
after_value TEXT NOT NULL,
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies (id),
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
source TEXT NOT NULL CHECK (source IN ('system', 'manual')),
|
||||
status TEXT NOT NULL DEFAULT 'unread'
|
||||
CHECK (status IN ('unread', 'done')),
|
||||
actor_user_id INTEGER REFERENCES users (id),
|
||||
actor_username TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reminders_company ON reminders (company_id);
|
||||
""",
|
||||
down="""
|
||||
DROP INDEX IF EXISTS idx_reminders_company;
|
||||
DROP TABLE IF EXISTS reminders;
|
||||
DROP TABLE IF EXISTS system_setting_changes;
|
||||
DROP TABLE IF EXISTS system_settings;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ def submit_bank_account(
|
||||
|
||||
if existing["company_id"] == company_id:
|
||||
raise ConflictError("该银行账号已登记,请等待现有申请处理。")
|
||||
raise ConflictError("该银行账号已被其他公司登记,请联系总账管理员核对。")
|
||||
raise ConflictError("该银行账号已被其他公司登记,请联系管理员核对。")
|
||||
|
||||
|
||||
def review_bank_account(
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""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. 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, 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",
|
||||
"start_date": "2026-01-01",
|
||||
"auto_remind": "1",
|
||||
"remind_days": "3",
|
||||
}
|
||||
|
||||
_SETTING_KEYS = frozenset({"closing_day", "start_date", "auto_remind", "remind_days"})
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
def _valid_date(value: str) -> bool:
|
||||
if not _DATE_RE.match(value):
|
||||
return False
|
||||
try:
|
||||
datetime.strptime(value, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_settings(connection: sqlite3.Connection) -> dict[str, str]:
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
rows = connection.execute(
|
||||
"SELECT key, value FROM system_settings"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
settings[row["key"]] = row["value"]
|
||||
return settings
|
||||
|
||||
|
||||
def validate_settings(values: dict[str, object]) -> tuple[dict[str, str], str | None]:
|
||||
"""Return ``(cleaned, error)``; ``cleaned`` holds only recognized keys."""
|
||||
cleaned: dict[str, str] = {}
|
||||
if "closing_day" in values:
|
||||
raw = str(values["closing_day"]).strip()
|
||||
try:
|
||||
day = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
if day < 1 or day > 28:
|
||||
return cleaned, "每月结账日须为 1-28 之间的整数。"
|
||||
cleaned["closing_day"] = str(day)
|
||||
if "start_date" in values:
|
||||
raw = str(values["start_date"]).strip()
|
||||
if not _valid_date(raw):
|
||||
return cleaned, "全局起算日须为有效日期(YYYY-MM-DD)。"
|
||||
cleaned["start_date"] = raw
|
||||
if "auto_remind" in values:
|
||||
raw = str(values["auto_remind"]).strip()
|
||||
if raw not in {"0", "1"}:
|
||||
return cleaned, "自动提醒开关须为 0 或 1。"
|
||||
cleaned["auto_remind"] = raw
|
||||
if "remind_days" in values:
|
||||
raw = str(values["remind_days"]).strip()
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError:
|
||||
return cleaned, "提前提醒天数须为不小于 1 的整数。"
|
||||
if days < 1 or days > 30:
|
||||
return cleaned, "提前提醒天数须为 1-30 之间的整数。"
|
||||
cleaned["remind_days"] = str(days)
|
||||
return cleaned, None
|
||||
|
||||
|
||||
def update_settings(
|
||||
connection: sqlite3.Connection,
|
||||
values: dict[str, object],
|
||||
actor: sqlite3.Row,
|
||||
) -> dict[str, str]:
|
||||
cleaned, error = validate_settings(values)
|
||||
if error is not None:
|
||||
raise ValueError(error)
|
||||
if not cleaned:
|
||||
raise ValueError("没有需要保存的设置项。")
|
||||
current = get_settings(connection)
|
||||
with connection:
|
||||
for key, new_value in cleaned.items():
|
||||
old_value = current.get(key)
|
||||
if old_value == new_value:
|
||||
continue
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_settings (key, value, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(key, new_value, actor["id"], utc_now()),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO system_setting_changes (
|
||||
key, before_value, after_value,
|
||||
actor_user_id, actor_username, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
key,
|
||||
old_value,
|
||||
new_value,
|
||||
actor["id"],
|
||||
actor["username"],
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
return get_settings(connection)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 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
|
||||
@@ -1,86 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
const ui = require(path.join(__dirname, "..", "web", "app.js"));
|
||||
|
||||
assert.strictEqual(ui.fmtAbsMoney(-1280), "1,280.00");
|
||||
assert.strictEqual(ui.fmtAbsMoney(1280), "1,280.00");
|
||||
assert.strictEqual(ui.fmtAbsMoney("60.5"), "60.50");
|
||||
|
||||
assert.strictEqual(ui.eventIsNegative({ posting_kind: "reversal" }), true);
|
||||
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: true }), true);
|
||||
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: false }), false);
|
||||
|
||||
assert.strictEqual(ui.cycleTab(0, 4, false), 1);
|
||||
assert.strictEqual(ui.cycleTab(3, 4, false), 0);
|
||||
assert.strictEqual(ui.cycleTab(0, 4, true), 3);
|
||||
assert.strictEqual(ui.cycleTab(2, 5, true), 1);
|
||||
assert.strictEqual(ui.cycleTab(0, 0, false), 0);
|
||||
|
||||
assert.strictEqual(ui.drawerEscAction(1), "close");
|
||||
assert.strictEqual(ui.drawerEscAction(0), "close");
|
||||
assert.strictEqual(ui.drawerEscAction(2), "back");
|
||||
assert.strictEqual(ui.drawerEscAction(3), "back");
|
||||
|
||||
const abs = ui.amountWithCurrency(1280, "CNY");
|
||||
assert.ok(abs.includes("CNY"));
|
||||
assert.ok(abs.includes("1,280.00"));
|
||||
assert.ok(!abs.includes("+"));
|
||||
assert.ok(!abs.includes("−"));
|
||||
|
||||
const repay = ui.amountWithCurrency(3200000, "CNY", { signed: true, negative: true });
|
||||
assert.ok(repay.includes("−"));
|
||||
assert.ok(!repay.includes("+"));
|
||||
assert.ok(repay.includes("3,200,000.00"));
|
||||
|
||||
assert.strictEqual(ui.resultDirection(10).label, "应收");
|
||||
assert.strictEqual(ui.resultDirection(-10).label, "应付");
|
||||
assert.strictEqual(ui.resultDirection(0).label, "持平");
|
||||
|
||||
assert.strictEqual(ui.isCompactAmount(1280), false);
|
||||
assert.strictEqual(ui.isCompactAmount(999999999), false);
|
||||
assert.strictEqual(ui.isCompactAmount(1000000000), true);
|
||||
assert.strictEqual(ui.isCompactAmount("123456789012345"), true);
|
||||
assert.ok(ui.amountWithCurrency("123456789012345", "CNY").includes("is-compact"));
|
||||
assert.ok(!ui.amountWithCurrency(1280, "CNY").includes("is-compact"));
|
||||
|
||||
assert.strictEqual(ui.cashDirectionLabel("outgoing"), "转出");
|
||||
assert.strictEqual(ui.cashDirectionLabel("incoming"), "转入");
|
||||
assert.strictEqual(ui.relatedFlowLabel(null), "无关联流水");
|
||||
assert.strictEqual(ui.relatedFlowLabel(""), "无关联流水");
|
||||
assert.strictEqual(ui.relatedFlowLabel("42"), "42");
|
||||
|
||||
const subjectFields = ui.auditEvidenceFields("subject-review", {
|
||||
direction: "outgoing",
|
||||
effectiveAt: "2026-07-18T09:00:00",
|
||||
amount: "600",
|
||||
currency: "CNY",
|
||||
summary: "资金调拨",
|
||||
});
|
||||
assert.deepStrictEqual(subjectFields.map((item) => item[0]), ["方向", "日期", "金额", "摘要"]);
|
||||
assert.strictEqual(subjectFields[0][1], "转出");
|
||||
|
||||
const manualFields = ui.auditEvidenceFields("manual-review", {
|
||||
counterpartyName: "乙公司",
|
||||
direction: "incoming",
|
||||
relatedSourceRowId: "",
|
||||
submittedBy: "出纳甲",
|
||||
attachmentName: "",
|
||||
amount: "80",
|
||||
currency: "CNY",
|
||||
summary: "补记",
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
manualFields.map((item) => item[0]),
|
||||
["对方公司名", "方向", "关联流水号", "提交人", "附件", "摘要", "金额"],
|
||||
);
|
||||
assert.strictEqual(manualFields[0][1], "乙公司");
|
||||
assert.strictEqual(manualFields[1][1], "转入");
|
||||
assert.strictEqual(manualFields[2][1], "无关联流水");
|
||||
|
||||
assert.ok(ui.accountCell({ visibility: "visible", label: "中信 5316" }).includes("中信 5316"));
|
||||
assert.ok(ui.accountCell({ visibility: "masked" }).includes("按对方授权不可见"));
|
||||
assert.ok(ui.accountCell({ visibility: "missing" }).includes("源行缺失"));
|
||||
|
||||
console.log("b44_ui_check ok");
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>B-44 余额目录布局回归</title>
|
||||
<link rel="stylesheet" href="../../web/styles.css" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
|
||||
<div class="brand"><span class="brand-mark">金</span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
|
||||
<nav class="nav-list">
|
||||
<button class="nav-item is-active" type="button"><span>往来查询</span></button>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<main id="main-content">
|
||||
<section class="app-view is-active">
|
||||
<header class="page-heading"><div><h1>往来查询</h1><p>公司间往来余额目录</p></div></header>
|
||||
<section class="panel company-ledger-panel" aria-label="公司余额目录">
|
||||
<div class="panel-heading"><div><h2>公司余额目录</h2><p>每行余额都附带截止日、期初状态、本期借贷、结果与未决金额</p></div></div>
|
||||
<div class="ledger-head is-balances"><span>公司</span><span class="ledger-hide-md">借方合计</span><span class="ledger-hide-md">贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
|
||||
<div class="company-ledgers">
|
||||
<details class="company-ledger is-balances">
|
||||
<summary>
|
||||
<span class="company-name" title="甲公司"><i>甲</i><b>甲公司</b><small class="currency-tag">CNY</small></span>
|
||||
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>1,280.00</span></strong>
|
||||
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong>
|
||||
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency"><span class="currency-code">CNY</span>960.00</span></b></span>
|
||||
<span class="ledger-unresolved is-empty"><span class="status neutral">未决 0.00</span></span>
|
||||
<span class="ledger-cutoff">截止 2026.07.31</span>
|
||||
<svg></svg>
|
||||
</summary>
|
||||
</details>
|
||||
<details class="company-ledger is-balances" id="stressRow">
|
||||
<summary>
|
||||
<span class="company-name" title="东南沿海综合贸易与供应链管理股份有限公司"><i>东</i><b>东南沿海综合贸易与供应链管理股份有限公司</b><small class="currency-tag">CNY</small></span>
|
||||
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong>
|
||||
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>100,000,000,000,000.00</span></strong>
|
||||
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></b></span>
|
||||
<span class="ledger-unresolved is-active"><span class="status warning">未决 2,150.00 · 3 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px">手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔</small></span>
|
||||
<span class="ledger-cutoff">截止 2026.07.31</span>
|
||||
<svg></svg>
|
||||
</summary>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="drawer is-open" id="evidenceDrawer">
|
||||
<header class="drawer-header">
|
||||
<div>
|
||||
<div class="drawer-breadcrumb"><button type="button">往来查询</button> / 甲公司 ↔ 乙公司</div>
|
||||
<h2>甲公司 ↔ 乙公司</h2>
|
||||
</div>
|
||||
<button type="button" class="icon-button" aria-label="关闭">×</button>
|
||||
</header>
|
||||
<div class="drawer-body">
|
||||
<div class="pair-balance-line is-six" id="drawerSix">
|
||||
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong></div>
|
||||
<div><span>本期借方</span><strong><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong></div>
|
||||
<div><span>本期贷方</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong></div>
|
||||
<div class="pair-final"><span>期末结果</span><strong><em class="status success">应收</em> <span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></strong></div>
|
||||
<div class="pair-unresolved is-empty"><span>未决金额</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>0.00</span></strong></div>
|
||||
<div><span>截止日</span><strong class="amount-neutral">2026.07.31</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<pre id="b175-metrics" hidden></pre>
|
||||
<script>
|
||||
function box(el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
|
||||
}
|
||||
function overlap(a, b) {
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(Math.min(dx, dy) === dy ? dx : dx);
|
||||
}
|
||||
function clipped(el) {
|
||||
return el.scrollWidth > el.clientWidth + 1;
|
||||
}
|
||||
function measure() {
|
||||
const row = document.querySelector("#stressRow");
|
||||
const summary = row.querySelector("summary");
|
||||
const amount = row.querySelector(".ledger-result .amount-with-currency");
|
||||
const direction = row.querySelector(".ledger-result .status");
|
||||
const unresolved = row.querySelector(".ledger-unresolved .status");
|
||||
const cutoff = row.querySelector(".ledger-cutoff");
|
||||
const chevron = row.querySelector("summary > svg");
|
||||
const head = document.querySelector(".ledger-head.is-balances");
|
||||
const drawerAmount = document.querySelector("#drawerSix .pair-final .amount-with-currency");
|
||||
const amountBox = box(amount);
|
||||
const unresolvedBox = box(unresolved);
|
||||
const cutoffBox = box(cutoff);
|
||||
const chevronBox = box(chevron);
|
||||
const directionBox = box(direction);
|
||||
const metrics = {
|
||||
viewport: window.innerWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
fiveColVisible: getComputedStyle(head).display !== "none",
|
||||
amountText: amount.textContent.replace(/\s+/g, " ").trim(),
|
||||
unresolvedText: unresolved.textContent.replace(/\s+/g, " ").trim(),
|
||||
cutoffText: cutoff.textContent.replace(/\s+/g, " ").trim(),
|
||||
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
|
||||
amountCutoffOverlap: overlap(amountBox, cutoffBox),
|
||||
unresolvedCutoffOverlap: overlap(unresolvedBox, cutoffBox),
|
||||
unresolvedChevronOverlap: overlap(unresolvedBox, chevronBox),
|
||||
cutoffChevronOverlap: overlap(cutoffBox, chevronBox),
|
||||
directionUnresolvedOverlap: overlap(directionBox, unresolvedBox),
|
||||
amountClipped: clipped(amount),
|
||||
unresolvedClipped: clipped(unresolved),
|
||||
cutoffClipped: clipped(cutoff),
|
||||
amountVisible: amountBox.width > 4 && amountBox.height > 4,
|
||||
unresolvedVisible: unresolvedBox.width > 4 && unresolvedBox.height > 4,
|
||||
cutoffVisible: cutoffBox.width > 4 && cutoffBox.height > 4,
|
||||
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
|
||||
drawerAmountClipped: clipped(drawerAmount),
|
||||
summaryWidth: Math.round(box(summary).width),
|
||||
};
|
||||
const node = document.getElementById("b175-metrics");
|
||||
node.hidden = false;
|
||||
node.textContent = JSON.stringify(metrics);
|
||||
document.title = "B175 " + node.textContent;
|
||||
}
|
||||
window.measureB175 = measure;
|
||||
if (document.readyState === "complete") {
|
||||
measure();
|
||||
} else {
|
||||
window.addEventListener("load", measure);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Static + Node checks for the B-44 visual rework.
|
||||
|
||||
Covers unique ids, nav copy, drawer/directory breakpoints, eight-column
|
||||
event table, and the exported keyboard/amount helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / "web"
|
||||
|
||||
|
||||
class B44FrontendContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.admin = (WEB / "admin.html").read_text(encoding="utf-8")
|
||||
cls.company = (WEB / "company.html").read_text(encoding="utf-8")
|
||||
cls.css = (WEB / "styles.css").read_text(encoding="utf-8")
|
||||
cls.js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
def test_admin_ids_are_unique(self) -> None:
|
||||
self.assertEqual(1, self.admin.count('id="companyLedgers"'))
|
||||
self.assertEqual(1, self.admin.count('id="balanceLedgers"'))
|
||||
self.assertIn('data-view="pair"', self.admin)
|
||||
self.assertRegex(self.admin, r'data-view="pair"[^>]*>[\s\S]*?<span>往来查询</span>')
|
||||
self.assertIn("<h1>往来查询</h1>", self.admin)
|
||||
self.assertIn("转为异常后,该记录暂不纳入余额计算", self.admin)
|
||||
self.assertIn('id="auditExceptionNote"', self.admin)
|
||||
|
||||
def test_company_balance_groups_and_nav(self) -> None:
|
||||
self.assertIn('id="companyBalanceGroups"', self.company)
|
||||
self.assertNotIn('id="companyBalanceLine"', self.company)
|
||||
self.assertRegex(self.company, r'data-view="balances"[^>]*>[\s\S]*?<span>往来余额</span>')
|
||||
self.assertIn("<h1>往来余额</h1>", self.company)
|
||||
|
||||
def test_css_directory_and_drawer_breakpoints(self) -> None:
|
||||
self.assertIn("@media (max-width: 375px)", self.css)
|
||||
self.assertIn("@media (max-width: 1179px)", self.css)
|
||||
self.assertIn("@media (min-width: 376px) and (max-width: 900px)", self.css)
|
||||
self.assertIn(
|
||||
"minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px",
|
||||
self.css,
|
||||
)
|
||||
self.assertIn("@media (max-width: 767px)", self.css)
|
||||
self.assertIn(".drawer {", self.css)
|
||||
self.assertIn("width: 640px", self.css)
|
||||
self.assertIn(".drawer { width: 480px; }", self.css)
|
||||
self.assertIn(".drawer { width: 100%; }", self.css)
|
||||
self.assertIn(".drawer .pair-balance-line.is-six", self.css)
|
||||
self.assertIn("repeat(3, 1fr)", self.css)
|
||||
self.assertIn(".drawer .event-table { min-width: 860px; }", self.css)
|
||||
self.assertIn(".company-row.is-balance-counterparty", self.css)
|
||||
self.assertIn(".ledger-hide-md", self.css)
|
||||
self.assertIn(".company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }", self.css)
|
||||
self.assertIn(".amount-with-currency.is-compact { font-size: 11px; }", self.css)
|
||||
self.assertIn("minmax(88px, 1fr) minmax(0, max-content) 24px", self.css)
|
||||
self.assertIn("grid-row: 1 / span 2", self.css)
|
||||
self.assertIn("flex-direction: column; align-items: flex-end; gap: 2px;", self.css)
|
||||
|
||||
def test_js_selectors_and_event_table(self) -> None:
|
||||
self.assertIn('loadAdminBalances($("#balanceLedgers"))', self.js)
|
||||
self.assertNotIn('loadAdminBalances($("#companyLedgers"))', self.js)
|
||||
self.assertIn("交易日期", self.js)
|
||||
self.assertIn("本方账户", self.js)
|
||||
self.assertIn("对方账户", self.js)
|
||||
self.assertIn("摘要", self.js)
|
||||
self.assertIn("匹配状态", self.js)
|
||||
self.assertIn("function eventTableHead()", self.js)
|
||||
self.assertIn("colspan=\"8\"", self.js)
|
||||
self.assertIn("function cycleTab(", self.js)
|
||||
self.assertIn("function drawerEscAction(", self.js)
|
||||
self.assertIn("function eventIsNegative(", self.js)
|
||||
self.assertIn("fmtAbsMoney", self.js)
|
||||
self.assertIn("is-balance-counterparty", self.js)
|
||||
self.assertIn("function isCompactAmount(", self.js)
|
||||
self.assertIn("function auditEvidenceFields(", self.js)
|
||||
self.assertIn("无关联流水", self.js)
|
||||
self.assertIn('row.dataset.direction = "outgoing"', self.js)
|
||||
self.assertIn("row.dataset.counterpartyName", self.js)
|
||||
self.assertIn("row.dataset.relatedSourceRowId", self.js)
|
||||
heads = re.search(
|
||||
r"function eventTableHead\(\) \{\s*return `([^`]+)`",
|
||||
self.js,
|
||||
)
|
||||
self.assertIsNotNone(heads)
|
||||
markup = heads.group(1)
|
||||
self.assertNotIn("来源", markup)
|
||||
for label in ("交易日期", "方向", "科目", "本方账户", "对方账户", "摘要", "匹配状态", "金额"):
|
||||
self.assertIn(label, markup)
|
||||
self.assertEqual(8, len(re.findall(r"<th\b", markup)))
|
||||
|
||||
def test_node_keyboard_and_amount_helpers(self) -> None:
|
||||
result = subprocess.run(
|
||||
["node", str(ROOT / "tests" / "b44_ui_check.js")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(ROOT),
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
self.assertIn("b44_ui_check ok", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,530 +0,0 @@
|
||||
"""Chromium layout regression for the B-44 admin balance directory.
|
||||
|
||||
Measures 15-digit period-end amounts against the unresolved chip using
|
||||
getBoundingClientRect — not static text presence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
import unittest
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(Path(__file__).resolve().parent) not in sys.path:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
FIXTURE = "/tests/fixtures/b44-balance-directory.html"
|
||||
CHROME_CANDIDATES = [
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
|
||||
/ "Google/Chrome/Application/chrome.exe",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
|
||||
/ "Microsoft/Edge/Application/msedge.exe",
|
||||
]
|
||||
VIEWPORTS = (720, 768, 800, 900, 375, 1024, 1440)
|
||||
|
||||
|
||||
def chrome_bin() -> Path:
|
||||
for path in CHROME_CANDIDATES:
|
||||
if path.exists():
|
||||
return path
|
||||
raise FileNotFoundError("Chrome/Edge not found for layout regression")
|
||||
|
||||
|
||||
class QuietHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, directory=str(ROOT), **kwargs)
|
||||
|
||||
def log_message(self, *_args) -> None:
|
||||
pass
|
||||
|
||||
def handle(self) -> None:
|
||||
try:
|
||||
super().handle()
|
||||
except (ConnectionResetError, BrokenPipeError, TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
class _Cdp:
|
||||
def __init__(self, ws_url: str) -> None:
|
||||
parsed = urlparse(ws_url)
|
||||
self._sock = socket.create_connection((parsed.hostname, parsed.port or 80), timeout=10)
|
||||
self._sock.settimeout(10)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
||||
self._sock.sendall(
|
||||
(
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {parsed.netloc}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
).encode()
|
||||
)
|
||||
header = b""
|
||||
while b"\r\n\r\n" not in header:
|
||||
chunk = self._sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("CDP websocket handshake failed")
|
||||
header += chunk
|
||||
expected = base64.b64encode(
|
||||
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
|
||||
).decode()
|
||||
if expected not in header.decode("latin1"):
|
||||
raise ConnectionError("CDP websocket accept mismatch")
|
||||
leftover = header.split(b"\r\n\r\n", 1)[1]
|
||||
self._buf = leftover
|
||||
self._next_id = 1
|
||||
|
||||
def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict:
|
||||
msg_id = self._next_id
|
||||
self._next_id += 1
|
||||
self._send({"id": msg_id, "method": method, "params": params or {}})
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
payload = self._recv()
|
||||
if payload.get("id") == msg_id:
|
||||
if "error" in payload:
|
||||
raise RuntimeError(f"{method}: {payload['error']}")
|
||||
return payload.get("result") or {}
|
||||
raise TimeoutError(method)
|
||||
|
||||
def wait_event(self, name: str, timeout: float = 15) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
payload = self._recv()
|
||||
if payload.get("method") == name:
|
||||
return payload.get("params") or {}
|
||||
raise TimeoutError(name)
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _send(self, obj: dict) -> None:
|
||||
data = json.dumps(obj, separators=(",", ":")).encode()
|
||||
mask = os.urandom(4)
|
||||
header = bytearray([0x81])
|
||||
length = len(data)
|
||||
if length < 126:
|
||||
header.append(0x80 | length)
|
||||
elif length < 65536:
|
||||
header.append(0x80 | 126)
|
||||
header.extend(length.to_bytes(2, "big"))
|
||||
else:
|
||||
header.append(0x80 | 127)
|
||||
header.extend(length.to_bytes(8, "big"))
|
||||
header.extend(mask)
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
self._sock.sendall(header + masked)
|
||||
|
||||
def _recv(self) -> dict:
|
||||
while True:
|
||||
opcode, payload = self._read_frame()
|
||||
if opcode == 0x9:
|
||||
self._send_pong(payload)
|
||||
continue
|
||||
if opcode == 0xA:
|
||||
continue
|
||||
if opcode == 0x8:
|
||||
raise ConnectionError("CDP websocket closed")
|
||||
return json.loads(payload.decode())
|
||||
|
||||
def _send_pong(self, payload: bytes) -> None:
|
||||
mask = os.urandom(4)
|
||||
header = bytearray([0x8A, 0x80 | len(payload)])
|
||||
header.extend(mask)
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
self._sock.sendall(header + masked)
|
||||
|
||||
def _read_frame(self) -> tuple[int, bytes]:
|
||||
header = self._read_exact(2)
|
||||
opcode = header[0] & 0x0F
|
||||
length = header[1] & 0x7F
|
||||
masked = bool(header[1] & 0x80)
|
||||
if length == 126:
|
||||
length = int.from_bytes(self._read_exact(2), "big")
|
||||
elif length == 127:
|
||||
length = int.from_bytes(self._read_exact(8), "big")
|
||||
mask = self._read_exact(4) if masked else b""
|
||||
payload = self._read_exact(length)
|
||||
if masked:
|
||||
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
return opcode, payload
|
||||
|
||||
def _read_exact(self, size: int) -> bytes:
|
||||
while len(self._buf) < size:
|
||||
chunk = self._sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("CDP websocket closed")
|
||||
self._buf += chunk
|
||||
data, self._buf = self._buf[:size], self._buf[size:]
|
||||
return data
|
||||
|
||||
|
||||
class B44LayoutRegressionTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.chrome = chrome_bin()
|
||||
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.http_thread.start()
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.url = f"http://127.0.0.1:{cls.port}{FIXTURE}"
|
||||
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-layout-", ignore_cleanup_errors=True)
|
||||
user_dir = cls.tmp.name
|
||||
cls.proc = subprocess.Popen(
|
||||
[
|
||||
str(cls.chrome),
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
"--remote-debugging-port=0",
|
||||
f"--user-data-dir={user_dir}",
|
||||
"about:blank",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
port_file = Path(user_dir) / "DevToolsActivePort"
|
||||
deadline = time.time() + 15
|
||||
listing = None
|
||||
while time.time() < deadline:
|
||||
if port_file.exists() and port_file.stat().st_size:
|
||||
text = port_file.read_text(encoding="utf-8").strip().splitlines()
|
||||
if text:
|
||||
cls.debug_port = int(text[0])
|
||||
try:
|
||||
listing = json.loads(
|
||||
urlopen(
|
||||
f"http://127.0.0.1:{cls.debug_port}/json/list",
|
||||
timeout=5,
|
||||
).read()
|
||||
)
|
||||
except Exception:
|
||||
listing = None
|
||||
if listing and any(item.get("type") == "page" for item in listing):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("Chrome DevTools port not ready")
|
||||
page = next(item for item in listing if item.get("type") == "page")
|
||||
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
|
||||
cls.cdp.call("Runtime.enable")
|
||||
cls.cdp.call("Page.enable")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
if getattr(cls, "cdp", None):
|
||||
cls.cdp.close()
|
||||
if getattr(cls, "proc", None):
|
||||
cls.proc.terminate()
|
||||
try:
|
||||
cls.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
cls.proc.wait(timeout=5)
|
||||
time.sleep(0.2)
|
||||
if getattr(cls, "httpd", None):
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
if getattr(cls, "tmp", None):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def _metrics(self, width: int) -> dict:
|
||||
self.cdp.call("Emulation.setDeviceMetricsOverride", {
|
||||
"width": width,
|
||||
"height": 1100,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": False,
|
||||
})
|
||||
self.cdp.call("Page.navigate", {"url": f"{self.url}?w={width}"})
|
||||
deadline = time.time() + 10
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
result = self.cdp.call(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
"expression": (
|
||||
"typeof window.measureB175 === 'function' "
|
||||
"? (window.measureB175(), document.getElementById('b175-metrics').textContent) "
|
||||
": null"
|
||||
),
|
||||
"returnByValue": True,
|
||||
},
|
||||
)
|
||||
last = result.get("result", {}).get("value")
|
||||
if last:
|
||||
return json.loads(last)
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError(f"layout metrics not ready: {last}")
|
||||
|
||||
def test_fifteen_digit_amount_clear_of_unresolved_chip(self) -> None:
|
||||
for width in VIEWPORTS:
|
||||
with self.subTest(width=width):
|
||||
metrics = self._metrics(width)
|
||||
self.assertEqual(width, metrics["viewport"], metrics)
|
||||
self.assertIn("23,456,789,012,345.00", metrics["amountText"])
|
||||
self.assertIn("未决", metrics["unresolvedText"])
|
||||
self.assertIn("2026.07.31", metrics["cutoffText"])
|
||||
self.assertTrue(metrics["amountVisible"])
|
||||
self.assertTrue(metrics["unresolvedVisible"])
|
||||
self.assertTrue(metrics["cutoffVisible"])
|
||||
self.assertFalse(metrics["amountClipped"], metrics)
|
||||
self.assertFalse(metrics["unresolvedClipped"], metrics)
|
||||
self.assertFalse(metrics["cutoffClipped"], metrics)
|
||||
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["unresolvedCutoffOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["cutoffChevronOverlap"], metrics)
|
||||
self.assertFalse(metrics["drawerAmountClipped"], metrics)
|
||||
if 376 <= width <= 1179:
|
||||
self.assertTrue(metrics["fiveColVisible"], metrics)
|
||||
if 376 <= width <= 900:
|
||||
self.assertTrue(metrics["stackedResult"], metrics)
|
||||
if width <= 375:
|
||||
self.assertFalse(metrics["fiveColVisible"], metrics)
|
||||
|
||||
|
||||
LIVE_MEASURE = r"""
|
||||
(() => {
|
||||
const pair = document.querySelector('[data-view="pair"]');
|
||||
if (pair) pair.click();
|
||||
const rows = [...document.querySelectorAll("#balanceLedgers .company-ledger.is-balances")];
|
||||
if (!rows.length) return null;
|
||||
const row = rows.find((item) => (item.textContent || "").includes("123,456,789,012,345")) || rows[0];
|
||||
const amount = row.querySelector(".ledger-result .amount-with-currency");
|
||||
const direction = row.querySelector(".ledger-result .status");
|
||||
const unresolved = row.querySelector(".ledger-unresolved .status") || row.querySelector(".ledger-unresolved");
|
||||
const cutoff = row.querySelector(".ledger-cutoff");
|
||||
const chevron = row.querySelector("summary > svg");
|
||||
const box = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
|
||||
};
|
||||
const overlap = (a, b) => {
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(dx);
|
||||
};
|
||||
const clipped = (el) => el.scrollWidth > el.clientWidth + 1;
|
||||
const amountBox = box(amount);
|
||||
const unresolvedBox = box(unresolved);
|
||||
return {
|
||||
viewport: window.innerWidth,
|
||||
amountText: (amount.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
unresolvedText: (unresolved.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
cutoffText: (cutoff.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
amountUnresolvedOverlap: overlap(amountBox, unresolvedBox),
|
||||
directionUnresolvedOverlap: overlap(box(direction), unresolvedBox),
|
||||
amountCutoffOverlap: overlap(amountBox, box(cutoff)),
|
||||
unresolvedCutoffOverlap: overlap(unresolvedBox, box(cutoff)),
|
||||
cutoffChevronOverlap: overlap(box(cutoff), box(chevron)),
|
||||
amountClipped: clipped(amount),
|
||||
unresolvedClipped: clipped(unresolved),
|
||||
cutoffClipped: clipped(cutoff),
|
||||
fiveColVisible: getComputedStyle(document.querySelector(".ledger-head.is-balances")).display !== "none",
|
||||
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
|
||||
rowCount: rows.length,
|
||||
};
|
||||
})()
|
||||
"""
|
||||
|
||||
COMPANY_MEASURE = r"""
|
||||
(() => {
|
||||
const balances = document.querySelector('[data-view="balances"]');
|
||||
if (balances) balances.click();
|
||||
const row = document.querySelector(".company-row.is-balance-counterparty");
|
||||
if (!row) return null;
|
||||
const amount = row.querySelector(".amount-with-currency") || row.querySelector(".company-row-figure strong");
|
||||
const direction = row.querySelector(".status");
|
||||
const cutoff = row.querySelector(".company-row-figure small");
|
||||
const box = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
|
||||
};
|
||||
const overlap = (a, b) => {
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(dx);
|
||||
};
|
||||
return {
|
||||
viewport: window.innerWidth,
|
||||
amountText: (amount?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
directionText: (direction?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
cutoffText: (cutoff?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
amountDirectionOverlap: amount && direction ? overlap(box(amount), box(direction)) : 0,
|
||||
visible: Boolean(amount && amount.getBoundingClientRect().width > 4),
|
||||
};
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
class LiveAdminDirectoryLayoutTests(unittest.TestCase):
|
||||
"""Chromium against a real login/API page with B-168-scale 15-digit amounts."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
from test_positions_api import IntercompanyApiTests
|
||||
|
||||
cls.api = IntercompanyApiTests("test_admin_balances_directory")
|
||||
cls.api.setUp()
|
||||
confirmed = cls.api._fresh_pair("123456789012345.00")
|
||||
cls.api._confirm(confirmed["ledger_event_id"])
|
||||
cls.api._fresh_pair("2150.00")
|
||||
cls.tmp = tempfile.TemporaryDirectory(prefix="b44-live-", ignore_cleanup_errors=True)
|
||||
cls.proc = subprocess.Popen(
|
||||
[
|
||||
str(chrome_bin()),
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
"--remote-debugging-port=0",
|
||||
f"--user-data-dir={cls.tmp.name}",
|
||||
"about:blank",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
port_file = Path(cls.tmp.name) / "DevToolsActivePort"
|
||||
deadline = time.time() + 15
|
||||
listing = None
|
||||
while time.time() < deadline:
|
||||
if port_file.exists() and port_file.stat().st_size:
|
||||
text = port_file.read_text(encoding="utf-8").strip().splitlines()
|
||||
if text:
|
||||
cls.debug_port = int(text[0])
|
||||
try:
|
||||
listing = json.loads(
|
||||
urlopen(
|
||||
f"http://127.0.0.1:{cls.debug_port}/json/list",
|
||||
timeout=5,
|
||||
).read()
|
||||
)
|
||||
except Exception:
|
||||
listing = None
|
||||
if listing and any(item.get("type") == "page" for item in listing):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("Chrome DevTools port not ready")
|
||||
page = next(item for item in listing if item.get("type") == "page")
|
||||
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
|
||||
cls.cdp.call("Runtime.enable")
|
||||
cls.cdp.call("Page.enable")
|
||||
cls.cdp.call("Network.enable")
|
||||
token = cls.api.admin.cookies["cw_session"]
|
||||
cls.cdp.call(
|
||||
"Network.setCookie",
|
||||
{
|
||||
"name": "cw_session",
|
||||
"value": token,
|
||||
"url": f"http://127.0.0.1:{cls.api.port}/",
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
if getattr(cls, "cdp", None):
|
||||
cls.cdp.close()
|
||||
if getattr(cls, "proc", None):
|
||||
cls.proc.terminate()
|
||||
try:
|
||||
cls.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
cls.proc.wait(timeout=5)
|
||||
time.sleep(0.2)
|
||||
if getattr(cls, "api", None):
|
||||
cls.api.tearDown()
|
||||
cls.api.doCleanups()
|
||||
if getattr(cls, "tmp", None):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def _eval(self, expression: str):
|
||||
deadline = time.time() + 12
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
result = self.cdp.call(
|
||||
"Runtime.evaluate",
|
||||
{"expression": expression, "returnByValue": True},
|
||||
)
|
||||
last = result.get("result", {}).get("value")
|
||||
if last:
|
||||
return last
|
||||
time.sleep(0.15)
|
||||
raise TimeoutError(f"live page metrics not ready: {last}")
|
||||
|
||||
def _open(self, path: str, width: int) -> None:
|
||||
self.cdp.call("Emulation.setDeviceMetricsOverride", {
|
||||
"width": width,
|
||||
"height": 1100,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": False,
|
||||
})
|
||||
self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.api.port}/{path}"})
|
||||
time.sleep(0.4)
|
||||
|
||||
def test_live_admin_directory_viewports(self) -> None:
|
||||
for width in (720, 768, 800, 900, 375, 1024, 1440):
|
||||
with self.subTest(width=width):
|
||||
self._open("admin.html", width)
|
||||
metrics = self._eval(LIVE_MEASURE)
|
||||
self.assertEqual(width, metrics["viewport"], metrics)
|
||||
self.assertGreaterEqual(metrics["rowCount"], 1, metrics)
|
||||
self.assertIn("123,456,789,012,345", metrics["amountText"], metrics)
|
||||
self.assertIn("未决", metrics["unresolvedText"], metrics)
|
||||
self.assertIn("2026.07.31", metrics["cutoffText"], metrics)
|
||||
self.assertFalse(metrics["amountClipped"], metrics)
|
||||
self.assertFalse(metrics["unresolvedClipped"], metrics)
|
||||
self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics)
|
||||
self.assertEqual(0, metrics["amountCutoffOverlap"], metrics)
|
||||
if 376 <= width <= 900:
|
||||
self.assertTrue(metrics["stackedResult"], metrics)
|
||||
if width <= 375:
|
||||
self.assertFalse(metrics["fiveColVisible"], metrics)
|
||||
|
||||
def test_live_company_balance_rows(self) -> None:
|
||||
token = self.api.cashier_a.cookies["cw_session"]
|
||||
self.cdp.call(
|
||||
"Network.setCookie",
|
||||
{
|
||||
"name": "cw_session",
|
||||
"value": token,
|
||||
"url": f"http://127.0.0.1:{self.api.port}/",
|
||||
},
|
||||
)
|
||||
for width in (375, 1440):
|
||||
with self.subTest(width=width):
|
||||
self._open("company.html", width)
|
||||
metrics = self._eval(COMPANY_MEASURE)
|
||||
self.assertTrue(metrics["visible"], metrics)
|
||||
self.assertTrue(metrics["amountText"], metrics)
|
||||
self.assertTrue(metrics["directionText"], metrics)
|
||||
self.assertIn("截止", metrics["cutoffText"], metrics)
|
||||
self.assertEqual(0, metrics["amountDirectionOverlap"], metrics)
|
||||
|
||||
|
||||
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], first)
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7], first)
|
||||
self.assertEqual([], migrate(self.connection))
|
||||
self.assertEqual(first, applied_versions(self.connection))
|
||||
tables = {
|
||||
@@ -82,19 +82,22 @@ class MigrationTests(PersistenceTestCase):
|
||||
"ledger_event_bank_sources",
|
||||
"ledger_event_manual_sources",
|
||||
"ledger_subject_suggestions",
|
||||
"system_settings",
|
||||
"system_setting_changes",
|
||||
"reminders",
|
||||
"schema_migrations",
|
||||
):
|
||||
self.assertIn(table, tables)
|
||||
|
||||
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
|
||||
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
|
||||
self.assertEqual([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], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
|
||||
self.assertEqual([1, 2, 3, 4, 5, 6, 7], applied_versions(self.connection))
|
||||
|
||||
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
|
||||
self.import_sample()
|
||||
@@ -102,7 +105,7 @@ class MigrationTests(PersistenceTestCase):
|
||||
"SELECT COUNT(*) AS n FROM source_rows"
|
||||
).fetchone()["n"]
|
||||
self.assertGreater(row_count, 0)
|
||||
self.assertEqual([6, 5], rollback(self.connection, 4))
|
||||
self.assertEqual([7, 6, 5], rollback(self.connection, 4))
|
||||
# The pre-migration evidence and schema are untouched.
|
||||
self.assertEqual(
|
||||
row_count,
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Integration tests for system settings persistence and reminder flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http.client import HTTPConnection
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from bank_importer.db import connect, migrate
|
||||
from bank_importer import auth, settings
|
||||
|
||||
import server
|
||||
|
||||
|
||||
BOOTSTRAP_PASSWORD = "BootAdmin123"
|
||||
ADMIN_PASSWORD = "AdminPass123"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.cookies: dict[str, str] = {}
|
||||
|
||||
def request(self, method, path, body=None, headers=None):
|
||||
connection = HTTPConnection(self.host, self.port)
|
||||
request_headers = dict(headers or {})
|
||||
if self.cookies:
|
||||
request_headers["Cookie"] = "; ".join(
|
||||
f"{k}={v}" for k, v in self.cookies.items()
|
||||
)
|
||||
connection.request(method, path, body=body, headers=request_headers)
|
||||
response = connection.getresponse()
|
||||
data = response.read()
|
||||
set_cookie = dict(response.getheaders()).get("Set-Cookie")
|
||||
if set_cookie:
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(set_cookie)
|
||||
for key, morsel in cookie.items():
|
||||
if morsel.value:
|
||||
self.cookies[key] = morsel.value
|
||||
else:
|
||||
self.cookies.pop(key, None)
|
||||
status = response.status
|
||||
connection.close()
|
||||
return status, data
|
||||
|
||||
def get(self, path):
|
||||
return self.request("GET", path)
|
||||
|
||||
def post_json(self, path, payload):
|
||||
return self.request(
|
||||
"POST", path, body=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
def as_json(data: bytes):
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
|
||||
class SettingsAndRemindersTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp_dir.name)
|
||||
cls.db_path = root / "app.db"
|
||||
cls.storage = root / "files"
|
||||
|
||||
cls._old_db_path = server.DB_PATH
|
||||
cls._old_storage = server.STORAGE_DIR
|
||||
server.DB_PATH = cls.db_path
|
||||
server.STORAGE_DIR = cls.storage
|
||||
|
||||
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
|
||||
connection = connect(cls.db_path)
|
||||
migrate(connection)
|
||||
server.ensure_bootstrap_admin(connection)
|
||||
# One active account for company 1 so pending items are generated.
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO companies (name, credit_code, status, created_at, updated_at) "
|
||||
"VALUES ('甲公司', NULL, 'active', 't', 't')"
|
||||
)
|
||||
company_id = connection.execute(
|
||||
"SELECT id FROM companies WHERE name = '甲公司'"
|
||||
).fetchone()["id"]
|
||||
connection.execute(
|
||||
"INSERT INTO bank_accounts (company_id, account_number, bank_name, status, created_at, updated_at) "
|
||||
"VALUES (?, '11112222', '工行', 'active', 't', 't')",
|
||||
(company_id,),
|
||||
)
|
||||
cls.company_id = company_id
|
||||
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.admin = Client("127.0.0.1", cls.port)
|
||||
status, data = cls.admin.post_json(
|
||||
"/api/login",
|
||||
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
|
||||
)
|
||||
assert status == 200, data
|
||||
status, data = cls.admin.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
|
||||
)
|
||||
assert status == 200, data
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
server.DB_PATH = cls._old_db_path
|
||||
server.STORAGE_DIR = cls._old_storage
|
||||
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
def test_settings_roundtrip_persists(self) -> None:
|
||||
status, data = self.admin.post_json(
|
||||
"/api/admin/settings",
|
||||
{"closing_day": "1", "start_date": "2026-01-05", "auto_remind": "0", "remind_days": "5"},
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
updated = as_json(data)["settings"]
|
||||
self.assertEqual("1", updated["closing_day"])
|
||||
self.assertEqual("2026-01-05", updated["start_date"])
|
||||
self.assertEqual("0", updated["auto_remind"])
|
||||
self.assertEqual("5", updated["remind_days"])
|
||||
|
||||
status, data = self.admin.get("/api/admin/settings")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("1", as_json(data)["settings"]["closing_day"])
|
||||
|
||||
def test_invalid_closing_day_rejected(self) -> None:
|
||||
for bad in ("0", "29", "abc"):
|
||||
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": bad})
|
||||
self.assertEqual(400, status, (bad, data))
|
||||
|
||||
def test_setting_change_is_audited(self) -> None:
|
||||
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": "7"})
|
||||
self.assertEqual(200, status)
|
||||
connection = connect(self.db_path)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT key, before_value, after_value, actor_username "
|
||||
"FROM system_setting_changes WHERE key = 'closing_day' "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual("7", rows[0]["after_value"])
|
||||
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(
|
||||
"/api/admin/users", {"username": "cashier-x", "company_id": self.company_id}
|
||||
)
|
||||
self.assertEqual(200, status, data)
|
||||
initial = as_json(data)["initial_password"]
|
||||
cashier = Client("127.0.0.1", self.port)
|
||||
status, data = cashier.post_json(
|
||||
"/api/login",
|
||||
{"username": "cashier-x", "password": initial, "portal": "company"},
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
status, data = cashier.post_json(
|
||||
"/api/password/change",
|
||||
{"old_password": initial, "new_password": "Changed123"},
|
||||
)
|
||||
self.assertEqual(200, status)
|
||||
|
||||
for path in ("/api/admin/settings", "/api/admin/reminders"):
|
||||
status, _ = cashier.get(path)
|
||||
self.assertEqual(403, status, path)
|
||||
|
||||
|
||||
class SettingsModuleTests(unittest.TestCase):
|
||||
"""Unit tests for the settings module on a fresh in-memory database."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.connection = connect(":memory:")
|
||||
self.addCleanup(self.connection.close)
|
||||
migrate(self.connection)
|
||||
|
||||
def test_defaults_applied_when_no_row_exists(self) -> None:
|
||||
values = settings.get_settings(self.connection)
|
||||
self.assertEqual("5", values["closing_day"])
|
||||
self.assertEqual("2026-01-01", values["start_date"])
|
||||
self.assertEqual("1", values["auto_remind"])
|
||||
self.assertEqual("3", values["remind_days"])
|
||||
|
||||
def test_validate_rejects_bad_values(self) -> None:
|
||||
for bad in ({"closing_day": "0"}, {"closing_day": "29"}, {"closing_day": "abc"}):
|
||||
_, error = settings.validate_settings(bad)
|
||||
self.assertIsNotNone(error)
|
||||
_, error = settings.validate_settings({"start_date": "2026-13-40"})
|
||||
self.assertIsNotNone(error)
|
||||
_, error = settings.validate_settings({"auto_remind": "2"})
|
||||
self.assertIsNotNone(error)
|
||||
|
||||
def test_update_writes_value_and_trail(self) -> None:
|
||||
user_id = auth.create_user(self.connection, "group-admin", "AdminPass123", "admin")
|
||||
user = self.connection.execute(
|
||||
"SELECT id, username FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
updated = settings.update_settings(
|
||||
self.connection, {"closing_day": "1", "auto_remind": "0"}, user
|
||||
)
|
||||
self.assertEqual("1", updated["closing_day"])
|
||||
self.assertEqual("0", updated["auto_remind"])
|
||||
changes = self.connection.execute(
|
||||
"SELECT key, before_value, after_value FROM system_setting_changes ORDER BY id"
|
||||
).fetchall()
|
||||
self.assertEqual(2, len(changes))
|
||||
self.assertEqual("5", changes[0]["before_value"])
|
||||
self.assertEqual("1", changes[0]["after_value"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+25
-37
@@ -3,18 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="金牛集团总账管理端" />
|
||||
<title>总账管理端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
<meta name="description" content="金牛集团管理端" />
|
||||
<title>管理端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<div class="shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
|
||||
<aside class="sidebar" id="sidebar" aria-label="管理导航">
|
||||
<div class="side-brand">
|
||||
<div class="brand-name">金牛实业 · 资金往来</div>
|
||||
<div class="brand-sub">HENAN JINNIU INDUSTRIAL GROUP</div>
|
||||
<span class="side-role">总账管理端</span>
|
||||
<span class="side-role">管理端</span>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
<div class="nav-group">日常</div>
|
||||
@@ -32,7 +32,7 @@
|
||||
<span class="avatar">管</span>
|
||||
<div>
|
||||
<div class="user-name">系统管理员</div>
|
||||
<div class="user-meta">集团总账会计</div>
|
||||
<div class="user-meta">管理员</div>
|
||||
</div>
|
||||
<button class="logout" type="button">退出</button>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<div class="main">
|
||||
<div class="topbar">
|
||||
<span class="crumb">总账管理端 / <b id="currentViewName">管理总览</b></span>
|
||||
<span class="crumb">管理端 / <b id="currentViewName">管理总览</b></span>
|
||||
<div class="topbar-right">
|
||||
<span class="tag">账期 2026-07</span>
|
||||
<span class="tag">统计截止 2026-08-20</span>
|
||||
@@ -245,7 +245,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账期与结账<span class="sub">每月 5 日结账 · 全局起算日 2026-01-01</span></span>
|
||||
<span class="card-title">账期与结账<span class="sub" id="dashboardPeriodSub">每月 5 日结账 · 全局起算日 2026-01-01</span></span>
|
||||
</div>
|
||||
<dl class="kv">
|
||||
<dt>当前账期</dt><dd>2026-07 · 进行中</dd>
|
||||
@@ -524,7 +524,7 @@
|
||||
</table>
|
||||
<div class="table-foot">
|
||||
<span id="flowCount">共 0 笔</span>
|
||||
<span class="meta">数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)</span>
|
||||
<span class="meta" id="flowsRangeMeta">数据范围:2026-01-01 起算 · 每月 5 日结账(7 月顺延至 08-29)</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -570,7 +570,7 @@
|
||||
</div>
|
||||
<div class="table-foot" style="border: 1px solid var(--border); border-radius: 0 0 var(--radius-lg) var(--radius-lg);">
|
||||
<span id="companyFoot">共 0 家公司</span>
|
||||
<span class="meta">登录账号初始密码由集团总账统一发放</span>
|
||||
<span class="meta">登录账号初始密码由管理员统一发放</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -585,7 +585,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">账期时间轴<span class="sub">全局起算日 2026-01-01 起,每月 5 日结账</span></span>
|
||||
<span class="card-title">账期时间轴<span class="sub" id="timelineSub">全局起算日 2026-01-01 起,每月 5 日结账</span></span>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="tl-cell closed"><div class="tl-month">2026-01</div><div class="tl-state">已结账</div></div>
|
||||
@@ -643,7 +643,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">期初余额管理<span class="sub">2026-01-01 起算的公司间往来期初数</span></span>
|
||||
<span class="card-title">期初余额管理<span class="sub" id="openingSub">2026-01-01 起算的公司间往来期初数</span></span>
|
||||
<button class="btn btn-sm" id="openOpeningDialog">新增</button>
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
@@ -718,34 +718,22 @@
|
||||
<div class="grid grid-1-2">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">发送处理提醒<span class="sub">将以人工提醒形式送达所选公司出纳与财务负责人</span></span>
|
||||
<span class="card-title">发送处理提醒<span class="sub">选择公司后系统自动列出待提醒事项,核对后一键发送</span></span>
|
||||
</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>
|
||||
@@ -793,7 +781,7 @@
|
||||
<span class="modal-title">新增成员公司</span>
|
||||
<button type="button" class="modal-close" data-close="companyDialog" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub">登记后系统将生成公司端登录账号,初始密码由集团总账统一发放。</p>
|
||||
<p class="modal-sub">登记后系统将生成公司端登录账号,初始密码由管理员统一发放。</p>
|
||||
<form id="companyForm" novalidate>
|
||||
<div class="field" style="margin-bottom: 12px;">
|
||||
<label for="f-name">公司全称</label>
|
||||
@@ -1050,7 +1038,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=4"></script>
|
||||
<script src="app.js?v=5"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var input = document.getElementById("company-search");
|
||||
|
||||
+211
-108
@@ -316,7 +316,7 @@ function initEntry() {
|
||||
|
||||
function updateRole() {
|
||||
const role = $('input[name="role"]:checked', form).value;
|
||||
action.textContent = role === "admin" ? "进入总账管理端" : "进入公司业务端";
|
||||
action.textContent = role === "admin" ? "进入管理端" : "进入公司业务端";
|
||||
}
|
||||
|
||||
roleInputs.forEach((input) => input.addEventListener("change", updateRole));
|
||||
@@ -408,7 +408,7 @@ function applyCompanyIdentity(me) {
|
||||
const nameEl = $(".user-name", row);
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
const metaEl = $(".user-meta", row);
|
||||
if (metaEl) metaEl.textContent = portal === "company" ? (me.company_name || "公司业务端") : "集团总账会计";
|
||||
if (metaEl) metaEl.textContent = portal === "company" ? (me.company_name || "公司业务端") : "管理员";
|
||||
}
|
||||
// The company portal always shows the session-bound company in page copy.
|
||||
if (portal === "company" && me.company_name) {
|
||||
@@ -692,7 +692,7 @@ function renderCompanyManualRecords() {
|
||||
statusCell.innerHTML = `<span class="pill ${statusMeta.cls}">${statusMeta.label}</span>`;
|
||||
|
||||
const action = document.createElement("td");
|
||||
if (record.status === "待总账复核") {
|
||||
if (record.status === "待管理复核") {
|
||||
action.innerHTML = '<button type="button" class="btn btn-sm btn-danger" data-action="withdraw">撤回</button>';
|
||||
} else {
|
||||
action.innerHTML = '<span class="muted">—</span>';
|
||||
@@ -706,7 +706,7 @@ function renderCompanyManualRecords() {
|
||||
|
||||
function updateManualCounts() {
|
||||
const records = readStoredRecords(storageKeys.manual).filter((record) => record.company === "A公司");
|
||||
const pending = records.filter((record) => record.status === "待总账复核").length;
|
||||
const pending = records.filter((record) => record.status === "待管理复核").length;
|
||||
const pendingEl = $("#manualPendingStatus");
|
||||
if (pendingEl) pendingEl.textContent = pending;
|
||||
const foot = $("#manualFoot");
|
||||
@@ -808,7 +808,7 @@ function appendAdminReviewRow(record, kind) {
|
||||
row.dataset.evidence = isAccount
|
||||
? "公司提交资料、开户行、账号、账户类型与启用日期"
|
||||
: "公司手工记录、关联银行流水号、证明附件与提交说明";
|
||||
if (statusLabel !== "待复核" && statusLabel !== "待总账复核") row.dataset.resolved = "true";
|
||||
if (statusLabel !== "待复核" && statusLabel !== "待管理复核") row.dataset.resolved = "true";
|
||||
if (isAccount) row.dataset.accountStatus = record.status;
|
||||
|
||||
const riskCell = document.createElement("td");
|
||||
@@ -975,20 +975,11 @@ function fillCompanySelects(names) {
|
||||
});
|
||||
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
|
||||
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
|
||||
const reminderList = $("#reminderCompanyList");
|
||||
if (reminderList) {
|
||||
reminderList.replaceChildren(...names.map((name) => {
|
||||
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 = name;
|
||||
input.style.width = "auto";
|
||||
label.append(input, document.createTextNode(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);
|
||||
@@ -1227,16 +1218,74 @@ function initAdmin() {
|
||||
remind?.addEventListener("change", renderSwitch);
|
||||
renderSwitch();
|
||||
|
||||
$("#systemSettings")?.addEventListener("submit", (event) => {
|
||||
function applySystemSettings(values) {
|
||||
if (!values) return;
|
||||
const start = $("#cs-start");
|
||||
if (start && values.start_date) start.value = values.start_date;
|
||||
const dayInput = $("#cs-day");
|
||||
if (dayInput && values.closing_day) dayInput.value = values.closing_day;
|
||||
const remindInput = $("#cs-remind");
|
||||
if (remindInput && "auto_remind" in values) {
|
||||
remindInput.checked = values.auto_remind === "1";
|
||||
renderSwitch();
|
||||
}
|
||||
const daysSelect = $("#cs-remind-days");
|
||||
if (daysSelect && values.remind_days) daysSelect.value = values.remind_days;
|
||||
const closingDay = values.closing_day || "5";
|
||||
const startDate = values.start_date || "2026-01-01";
|
||||
const dash = $("#dashboardPeriodSub");
|
||||
if (dash) dash.textContent = `每月 ${closingDay} 日结账 · 全局起算日 ${startDate}`;
|
||||
const timeline = $("#timelineSub");
|
||||
if (timeline) timeline.textContent = `全局起算日 ${startDate} 起,每月 ${closingDay} 日结账`;
|
||||
const flows = $("#flowsRangeMeta");
|
||||
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账(7 月顺延至 08-29)`;
|
||||
const opening = $("#openingSub");
|
||||
if (opening) opening.textContent = `${startDate} 起算的公司间往来期初数`;
|
||||
}
|
||||
|
||||
async function loadSystemSettings() {
|
||||
const response = await fetch("/api/admin/settings").catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (response?.ok && result.settings) applySystemSettings(result.settings);
|
||||
}
|
||||
|
||||
$("#systemSettings")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const day = parseInt($("#cs-day")?.value, 10);
|
||||
const tip = $("#cs-save-tip");
|
||||
if (day && (day < 1 || day > 28)) {
|
||||
const day = parseInt($("#cs-day")?.value, 10);
|
||||
if (!day || day < 1 || day > 28) {
|
||||
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; }
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
closing_day: String(day),
|
||||
start_date: $("#cs-start")?.value || "",
|
||||
auto_remind: $("#cs-remind")?.checked ? "1" : "0",
|
||||
remind_days: $("#cs-remind-days")?.value || "3",
|
||||
};
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
if (response?.status === 401) {
|
||||
window.location.href = "index.html";
|
||||
return;
|
||||
}
|
||||
const result = await response?.json().catch(() => ({}));
|
||||
if (!response || !response.ok) {
|
||||
const message = result?.message || "保存失败,请稍后重试";
|
||||
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = message; }
|
||||
showToast("设置保存失败", message, "danger");
|
||||
return;
|
||||
}
|
||||
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
|
||||
showToast("系统计算口径已保存", "正式系统将记录修改前后值与操作人", "success");
|
||||
applySystemSettings(result.settings || {});
|
||||
showToast("系统计算口径已保存", "修改前后值与操作人已留痕", "success");
|
||||
});
|
||||
|
||||
$("#runClosingCheck")?.addEventListener("click", () => {
|
||||
@@ -1309,111 +1358,164 @@ function initAdmin() {
|
||||
showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人", "success");
|
||||
});
|
||||
|
||||
$("#reminderForm")?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const checked = Array.prototype.slice.call(form.querySelectorAll('input[name="company"]:checked'));
|
||||
const type = $("#reminder-type").value;
|
||||
const content = $("#reminder-content").value.trim();
|
||||
const deadline = $("#reminder-deadline").value;
|
||||
$("#company-error").style.display = checked.length ? "none" : "";
|
||||
$("#content-error").style.display = content ? "none" : "";
|
||||
if (!checked.length || !content) return;
|
||||
const companies = checked.map((c) => c.value).join("、");
|
||||
const summary = content.length > 46 ? content.slice(0, 46) + "…" : content;
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.source = "manual";
|
||||
row.innerHTML =
|
||||
'<td class="cell-main">' + companies + '</td>' +
|
||||
'<td><span class="tag">' + type + '</span></td>' +
|
||||
'<td class="wrap">' + summary.replace(/</g, "<") + '</td>' +
|
||||
'<td class="meta">刚刚<span class="cell-sub">人工 · 系统管理员</span></td>' +
|
||||
'<td class="meta">' + (deadline || "—") + '</td>' +
|
||||
'<td><span class="pill pill-danger">未读</span></td>' +
|
||||
'<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm act-remind">再提醒</button><button type="button" class="btn btn-sm btn-ghost act-history" data-company="' + checked[0].value + '">查看历史</button></div></td>';
|
||||
$("#reminder-tbody").insertBefore(row, $("#reminder-tbody").firstChild);
|
||||
refreshReminderCounts();
|
||||
form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; });
|
||||
$("#reminder-content").value = "请于截止日期前完成 2026 年 7 月银行流水上传与待确认事项处理。";
|
||||
$("#reminder-deadline").value = "2026-08-29";
|
||||
const sendHint = $("#send-hint");
|
||||
sendHint.style.display = "";
|
||||
sendHint.textContent = "已发送给 " + companies + ",共 " + checked.length + " 家公司。";
|
||||
clearTimeout(sendHint._t);
|
||||
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
|
||||
// ── 提醒管理:选公司 → 自动列出待提醒事项 → 一键发送 ──
|
||||
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);
|
||||
});
|
||||
|
||||
// 提醒历史 tabs / 计数
|
||||
$("#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++; });
|
||||
$("#count-all").textContent = all;
|
||||
$("#count-system").textContent = sys;
|
||||
$("#count-manual").textContent = man;
|
||||
$("#table-foot-count").textContent = "共 " + all + " 条提醒记录";
|
||||
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");
|
||||
const filter = btn.dataset.filter;
|
||||
$$("#reminder-tbody tr").forEach((r) => { r.style.display = (filter === "all" || r.dataset.source === filter) ? "" : "none"; });
|
||||
reminderFilter = btn.dataset.filter;
|
||||
applyReminderFilter();
|
||||
});
|
||||
|
||||
$("#reminder-tbody")?.addEventListener("click", (event) => {
|
||||
const remindBtn = event.target.closest(".act-remind");
|
||||
if (remindBtn) {
|
||||
const tr = remindBtn.closest("tr");
|
||||
const timeCell = tr.children[3];
|
||||
const sub = timeCell.querySelector(".cell-sub");
|
||||
timeCell.firstChild.textContent = "刚刚";
|
||||
if (sub) sub.textContent = sub.textContent.replace(/\s*·?\s*第 \d+ 次/, "") + " · 再提醒";
|
||||
else timeCell.insertAdjacentHTML("beforeend", '<span class="cell-sub">再提醒</span>');
|
||||
remindBtn.textContent = "已再提醒";
|
||||
remindBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
const historyBtn = event.target.closest(".act-history");
|
||||
if (historyBtn) openReminderHistory(historyBtn.dataset.company);
|
||||
if (historyBtn) openReminderHistory(historyBtn.dataset.companyId, historyBtn.dataset.company);
|
||||
});
|
||||
|
||||
const reminderHISTORIES = {
|
||||
"金牛农业": [
|
||||
{ time: "2026-08-18 09:02", src: "系统 · 第 2 次", type: "流水未提交", status: "danger", statusText: "未读", body: "贵公司 7 月全部银行账户流水尚未提交,距 7 月结账顺延截止日仅剩 11 天,请尽快上传。" },
|
||||
{ time: "2026-08-11 09:00", src: "系统 · 第 1 次", type: "流水未提交", status: "danger", statusText: "未读", body: "2026 年 7 月银行流水上传提醒:请于截止日前完成全部账户流水提交。" },
|
||||
],
|
||||
"金牛新能源": [
|
||||
{ time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "warn", statusText: "处理中", body: "与金牛贸易 3 笔单边流水合计 ¥4,860,000.00 待确认,请选择对方银行流水佐证。" },
|
||||
],
|
||||
"金牛置业": [
|
||||
{ time: "2026-08-14 16:40", src: "人工 · 张维", type: "流水未提交", status: "warn", statusText: "处理中", body: "中行尾号 8821 账户 7 月 6—16 日流水断档,请补传该期间银行回单或对账单。" },
|
||||
],
|
||||
"金牛贸易": [
|
||||
{ time: "2026-08-15 09:00", src: "系统", type: "单边待确认", status: "success", statusText: "已完成", body: "与金牛新能源往来 3 笔单边流水待确认,请核对 7 月销售回款记录。" },
|
||||
],
|
||||
"金牛煤业": [
|
||||
{ time: "2026-08-12 09:00", src: "系统", type: "科目待确认", status: "warn", statusText: "处理中", body: "2 笔手工记录往来科目待确认(应收 / 其他应收),涉及煤炭运费结算。" },
|
||||
],
|
||||
"金牛物流": [
|
||||
{ time: "2026-08-10 11:25", src: "人工 · 张维", type: "科目待确认", status: "success", statusText: "已完成", body: "1 笔运费结算手工记录科目待确认(其他应付待判定),请于结账前完成。" },
|
||||
],
|
||||
};
|
||||
|
||||
function openReminderHistory(company) {
|
||||
$("#history-title").textContent = company + " · 提醒历史";
|
||||
$("#history-sub").textContent = "2026 年 7 月账期以来的全部触达记录,按时间倒序。";
|
||||
async function openReminderHistory(companyId, companyName) {
|
||||
$("#history-title").textContent = (companyName || "") + " · 提醒历史";
|
||||
$("#history-sub").textContent = "该公司的全部提醒触达记录,按时间倒序。";
|
||||
const list = $("#history-list");
|
||||
const items = reminderHISTORIES[company] || [];
|
||||
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>该公司在 7 月账期内尚未收到过提醒。</div>';
|
||||
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">' + it.type + '</span>' +
|
||||
'<div class="lr-main"><div class="lr-title">' + it.body + '</div><div class="lr-sub"><span class="meta">' + it.time + ' · ' + it.src + '</span></div></div>' +
|
||||
'<div class="lr-side"><span class="pill pill-' + it.status + '">' + it.statusText + '</span></div></div>';
|
||||
'<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");
|
||||
@@ -1421,7 +1523,8 @@ function initAdmin() {
|
||||
$("#history-close")?.addEventListener("click", () => closeModal("history-modal"));
|
||||
$("#history-ok")?.addEventListener("click", () => closeModal("history-modal"));
|
||||
|
||||
refreshReminderCounts();
|
||||
loadSystemSettings();
|
||||
loadReminderHistory();
|
||||
}
|
||||
|
||||
const FLOW_DEMO = [
|
||||
@@ -2268,14 +2371,14 @@ function initCompany() {
|
||||
remark: String(data.get("remark")).trim(),
|
||||
bankReference: String(data.get("bankReference") || "").trim(),
|
||||
evidenceName: evidence instanceof File ? evidence.name : "",
|
||||
status: "待总账复核",
|
||||
status: "待管理复核",
|
||||
createdAt: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
};
|
||||
records.push(record);
|
||||
if (!writeStoredRecords(storageKeys.manual, records)) return;
|
||||
renderCompanyManualRecords();
|
||||
form.reset();
|
||||
showToast("手工记录已提交", "总账复核前不会纳入公司间往来计算", "success");
|
||||
showToast("手工记录已提交", "管理员复核前不会纳入公司间往来计算", "success");
|
||||
});
|
||||
|
||||
let manualWithdrawRow = null;
|
||||
|
||||
+21
-21
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="金牛集团公司业务端" />
|
||||
<title>公司业务端 · 金牛集团</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body data-portal="company">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
@@ -83,7 +83,7 @@
|
||||
<div class="fs-meta" id="workspaceConfirmMeta">单边流水 3 笔 · 科目确认 2 笔</div>
|
||||
</a>
|
||||
<div class="flow-step wait">
|
||||
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">总账复核</span></div>
|
||||
<div class="fs-top"><span class="fs-idx">04</span><span class="fs-dot"></span><span class="fs-name">管理复核</span></div>
|
||||
<div class="fs-state">等待集团</div>
|
||||
<div class="fs-meta">2 笔复核在途 · 无需本公司操作</div>
|
||||
</div>
|
||||
@@ -174,7 +174,7 @@
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与集团总账口径一致 · 单位:万元</span></span>
|
||||
<span class="card-title">本公司往来合计<span class="sub">2026-01-01 至 2026-08-20 · 与管理端口径一致 · 单位:万元</span></span>
|
||||
</div>
|
||||
<div class="mini-stats">
|
||||
<div class="mini-stat">
|
||||
@@ -199,7 +199,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">最新通知<span class="sub">来自集团总账</span></span>
|
||||
<span class="card-title">最新通知<span class="sub">来自管理端</span></span>
|
||||
<button class="btn btn-sm btn-ghost" data-view-link="notifications">全部通知 (3) →</button>
|
||||
</div>
|
||||
<div class="list-row">
|
||||
@@ -325,7 +325,7 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>手工记录</h1>
|
||||
<p class="page-sub">登记未通过银行流水体现的往来业务,如个人过账、现金收付与未入账回单。提交后由集团总账会计审核,通过后计入本公司往来台账。</p>
|
||||
<p class="page-sub">登记未通过银行流水体现的往来业务,如个人过账、现金收付与未入账回单。提交后由管理员审核,通过后计入本公司往来台账。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,15 +353,15 @@
|
||||
</div>
|
||||
<div class="notice info" style="margin-top: 14px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
|
||||
<div><div class="n-title">手工记录与银行原始流水分开留痕</div><div class="n-body">总账复核通过后才纳入往来计算,撤回后需重新登记提交。</div></div>
|
||||
<div><div class="n-title">手工记录与银行原始流水分开留痕</div><div class="n-body">管理员复核通过后才纳入往来计算,撤回后需重新登记提交。</div></div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 16px;">提交总账复核</button>
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 16px;">提交管理复核</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">已提交记录<span class="sub">账期 2026-07 内本公司提交的手工往来,<span class="num" id="manualPendingStatus">0</span> 笔待总账复核</span></span>
|
||||
<span class="card-title">已提交记录<span class="sub">账期 2026-07 内本公司提交的手工往来,<span class="num" id="manualPendingStatus">0</span> 笔待管理复核</span></span>
|
||||
</div>
|
||||
<div class="table-wrap" style="border: 0;">
|
||||
<table class="ds-table">
|
||||
@@ -380,7 +380,7 @@
|
||||
</div>
|
||||
<div class="table-foot">
|
||||
<span id="manualFoot">共 0 条</span>
|
||||
<span class="meta">审核由集团总账会计在 1 个工作日内完成</span>
|
||||
<span class="meta">审核由管理员在 1 个工作日内完成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -659,8 +659,8 @@
|
||||
<div class="notice info" style="margin-bottom: 14px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--info);"><circle cx="12" cy="12" r="8.5"/><path d="M12 8v5M12 16.5v.01"/></svg>
|
||||
<div>
|
||||
<div class="n-title">新账户需总账管理员审核通过后方可上传流水</div>
|
||||
<div class="n-body">提交登记后由集团总账管理员核对开户信息,审核周期一般 1 个工作日;审核结果将通过站内通知告知。</div>
|
||||
<div class="n-title">新账户需管理员审核通过后方可上传流水</div>
|
||||
<div class="n-body">提交登记后由管理员核对开户信息,审核周期一般 1 个工作日;审核结果将通过站内通知告知。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -698,7 +698,7 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>通知</h1>
|
||||
<p class="page-sub">系统与总账管理员发送给本公司的全部消息。7 月账期结账因故顺延至 2026-08-29,请优先处理未读与处理中事项。</p>
|
||||
<p class="page-sub">系统与管理员发送给本公司的全部消息。7 月账期结账因故顺延至 2026-08-29,请优先处理未读与处理中事项。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-ghost" id="mark-all-read">全部标为已读</button>
|
||||
@@ -734,9 +734,9 @@
|
||||
</div>
|
||||
<div class="list-row" data-status="unread">
|
||||
<span class="pill pill-danger" style="flex: none;">未读</span>
|
||||
<span class="tag">总账管理员</span>
|
||||
<span class="tag">管理员</span>
|
||||
<div class="lr-main">
|
||||
<div class="lr-title">总账管理员提醒:请于 08-29 前完成待确认事项</div>
|
||||
<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>
|
||||
@@ -746,13 +746,13 @@
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -777,10 +777,10 @@
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
<span class="pill pill-success" style="flex: none;">已完成</span>
|
||||
<span class="tag">总账管理员</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 class="lr-sub"><span class="meta">2026-07-22 13:10</span> · 新账户开户资料已提交管理员,审核通过后方可启用并上传流水。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-row" data-status="done">
|
||||
@@ -928,7 +928,7 @@
|
||||
<span class="modal-title">登记银行账户</span>
|
||||
<button type="button" class="modal-close" data-close="accountDialog" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<p class="modal-sub">账户提交后由总账管理员复核,复核通过前不能上传流水,也不参与账户识别与覆盖计算。</p>
|
||||
<p class="modal-sub">账户提交后由管理员复核,复核通过前不能上传流水,也不参与账户识别与覆盖计算。</p>
|
||||
<form id="accountForm" novalidate>
|
||||
<div class="grid grid-2">
|
||||
<div class="field">
|
||||
@@ -943,7 +943,7 @@
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<label>银行账号</label>
|
||||
<input class="input num-input" name="accountNumber" inputmode="numeric" required />
|
||||
<span class="hint">仅尾号对外显示,完整账号仅总账管理员可见</span>
|
||||
<span class="hint">仅尾号对外显示,完整账号仅管理员可见</span>
|
||||
</div>
|
||||
<div class="field" style="margin-top: 12px;">
|
||||
<label>启用日期</label>
|
||||
@@ -957,6 +957,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
|
||||
<script src="app.js?v=4"></script>
|
||||
<script src="app.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -459,6 +459,33 @@ p { margin: 0; }
|
||||
.login-foot { margin-top: 16px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||
.login-foot a { color: var(--accent); font-weight: 550; }
|
||||
.login-foot a:hover { text-decoration: underline; }
|
||||
|
||||
/* 端标识:登录页顶部大字区分管理端 / 公司端 */
|
||||
.login-role-title {
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-role-title.admin { color: var(--accent); }
|
||||
.login-role-title.company { color: var(--info); }
|
||||
.login-role-caption { color: var(--muted); font-size: 13px; margin-bottom: 22px; }
|
||||
/* 管理端青绿 / 公司端品蓝侧栏色块 */
|
||||
.login-aside.company { background: var(--info); }
|
||||
.login-aside.company .aside-item .tick { color: var(--surface); opacity: 0.85; }
|
||||
.login-back {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
}
|
||||
.login-back:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.login-back.company:hover { border-color: var(--info); color: var(--info); }
|
||||
@media (max-width: 900px) {
|
||||
.login-wrap { grid-template-columns: 1fr; }
|
||||
.login-aside { display: none; }
|
||||
|
||||
+4
-4
@@ -4,20 +4,20 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>金牛实业资金往来管理系统 · 入口</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="portal-wrap">
|
||||
<div class="portal-inner">
|
||||
<p class="meta" style="letter-spacing: 0.1em;">JINNIU GROUP · INTERCOMPANY TREASURY</p>
|
||||
<h1 style="font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin-top: 10px;">河南金牛实业集团有限公司 · 资金往来管理系统</h1>
|
||||
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含总账管理端与公司业务端两套界面。</p>
|
||||
<p class="muted" style="margin-top: 10px; max-width: 64ch;">集团内部公司间资金往来记账平台。银行流水导入后自动轧算往来余额,支持从集团汇总逐级穿透至银行原始流水。本系统包含管理端与公司业务端两套界面。</p>
|
||||
|
||||
<div class="portal-grid">
|
||||
<a class="portal-card" href="login-admin.html">
|
||||
<span class="pc-role">总账管理端 · 7 个页面</span>
|
||||
<span class="pc-role">管理端 · 7 个页面</span>
|
||||
<h3>管理总览 / 往来查询 / 审核中心 / 流水管理 / 公司与账号 / 结账与期初 / 提醒管理</h3>
|
||||
<p>面向集团总账会计:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
|
||||
<p>面向管理员:全局监控各公司流水提交与往来余额,集中处理审核事项,执行月度结账。</p>
|
||||
<span class="pc-go">去管理端登录 →</span>
|
||||
</a>
|
||||
<a class="portal-card" href="login-company.html">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body data-role="admin">
|
||||
<div class="login-wrap">
|
||||
@@ -22,8 +22,11 @@
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<a class="login-back" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title admin">管理端</div>
|
||||
<p class="login-role-caption">管理员统一监控集团各公司资金往来,执行月度结账。</p>
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录总账管理端。首次登录需修改初始密码。</p>
|
||||
<p class="login-sub">请输入账号密码登录管理端。首次登录需修改初始密码。</p>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
@@ -42,7 +45,7 @@
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系集团总账管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -72,6 +75,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=4"></script>
|
||||
<script src="login.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>登录 · 金牛实业资金往来管理系统</title>
|
||||
<link rel="stylesheet" href="design-system.css?v=4" />
|
||||
<link rel="stylesheet" href="design-system.css?v=5" />
|
||||
</head>
|
||||
<body data-role="company">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-aside">
|
||||
<aside class="login-aside company">
|
||||
<span class="brand-mark">JINNIU GROUP · TREASURY</span>
|
||||
<h1>河南金牛实业集团有限公司<br />资金往来管理系统</h1>
|
||||
<p class="aside-sub">集团内部各公司之间资金往来的统一记账平台。导入银行流水后自动轧算公司间往来余额,从集团汇总可层层下钻至银行原始流水。</p>
|
||||
@@ -22,6 +22,9 @@
|
||||
|
||||
<main class="login-panel">
|
||||
<div class="login-card">
|
||||
<a class="login-back company" href="index.html">← 返回入口页</a>
|
||||
<div class="login-role-title company">公司端</div>
|
||||
<p class="login-role-caption">成员公司出纳上传流水、登记手工往来、确认单边匹配。</p>
|
||||
<h2>登录系统</h2>
|
||||
<p class="login-sub">请输入账号密码登录公司业务端。首次登录需修改初始密码。</p>
|
||||
|
||||
@@ -42,7 +45,7 @@
|
||||
<button class="btn btn-primary" type="submit" style="width: 100%; margin-top: 6px;">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">忘记密码请联系集团总账管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
<p class="login-foot">忘记密码请联系管理员重置 · <a href="index.html">返回入口页</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -72,6 +75,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="login.js?v=4"></script>
|
||||
<script src="login.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user