Compare commits

...
Author SHA1 Message Date
611b09d57c HEL-205: 纠正期末余额应收应付方向
翻转 _pair_net_change 符号与全站「转出为正=应收」口径对齐,并补齐甲乙双边与浏览器方向断言。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:52:48 +00:00
fe6b4e59ec HEL-203: 修复原因弹窗作用域、首屏断档与表单 reset
将 openModal/closeModal 提升为顶层唯一定义,公司端启动即加载断档提醒,
并在 await 前提取表单引用;补充 Playwright 两端冒烟与源码契约测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:29:07 +00:00
27f2b0b69a HEL-202: 基于 b27016d 返工起算日/期初/断档并补齐公司端
以 deploy/hel178 为基线整合 HEL-200 内核:迁移改为 0008 复用
system_settings;修复确认批次 list>int、截止日当天漏算与持久化断言;
公司端余额完整/降级口径与断档说明、管理端审核界面接线。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 16:38:36 +00:00
14 changed files with 3469 additions and 70 deletions
+1
View File
@@ -1,6 +1,7 @@
__pycache__/
*.py[cod]
.venv/
.chrome-libs/
.tmp-*/
# 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份)
+426 -2
View File
@@ -11,8 +11,9 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import (
auth, company_transfers, dashboard, importing, ledger_events, manual_records,
master_data, matching, multipart, personal_transit, positions, settings, subjects,
auth, calculation, company_transfers, dashboard, importing, ledger_events,
manual_records, master_data, matching, multipart, personal_transit, positions,
settings, subjects,
)
from bank_importer.db import connect, migrate, utc_now
@@ -77,6 +78,30 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/company/accounts":
self._handle_company_accounts()
return
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_get()
return
if path == "/api/admin/opening-balances":
self._handle_admin_opening_balances_get()
return
if path == "/api/admin/coverage-gaps":
self._handle_admin_coverage_gaps(query)
return
if path == "/api/admin/calculation-changes":
self._handle_admin_calculation_changes()
return
if path == "/api/admin/balances/pair":
self._handle_admin_pair_balance(query)
return
if path == "/api/company/coverage-gaps":
self._handle_company_coverage_gaps()
return
if path == "/api/company/balances":
self._handle_company_balances(query)
return
if path == "/api/company/balances/pair":
self._handle_company_pair_balance(query)
return
if path == "/api/admin/audit-log":
self._handle_admin_audit_log(query)
return
@@ -267,6 +292,36 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/admin/settings":
self._handle_admin_update_settings()
return
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_put()
return
if path == "/api/admin/opening-balances":
self._handle_admin_opening_balances_post()
return
opening_confirm = re.fullmatch(r"/api/admin/opening-balances/(\d+)/confirm", path)
if opening_confirm:
self._handle_admin_opening_balance_confirm(int(opening_confirm.group(1)))
return
opening_revise = re.fullmatch(r"/api/admin/opening-balances/(\d+)/revisions", path)
if opening_revise:
self._handle_admin_opening_balance_revise(int(opening_revise.group(1)))
return
opening_void = re.fullmatch(r"/api/admin/opening-balances/(\d+)/void", path)
if opening_void:
self._handle_admin_opening_balance_void(int(opening_void.group(1)))
return
if path == "/api/admin/coverage-gaps/recalculate":
self._handle_admin_recalculate_gaps()
return
attestation_review = re.fullmatch(
r"/api/admin/no-business-attestations/(\d+)/review", path
)
if attestation_review:
self._handle_admin_review_attestation(int(attestation_review.group(1)))
return
if path == "/api/company/no-business-attestations":
self._handle_company_submit_attestation()
return
if path == "/api/admin/reminders/send":
self._handle_admin_send_reminders()
return
@@ -301,6 +356,13 @@ class AppHandler(SimpleHTTPRequestHandler):
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
def do_PUT(self) -> None:
path = urlparse(self.path).path
if path == "/api/admin/settings/calculation-start":
self._handle_admin_calculation_start_put()
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
# ------------------------------------------------------------------
# Session helpers
# ------------------------------------------------------------------
@@ -796,6 +858,8 @@ class AppHandler(SimpleHTTPRequestHandler):
}
if outcome.get("matching") is not None:
payload["matching"] = outcome["matching"]
if decision == "confirm" and len(outcome.get("updated") or []) > 0:
calculation.recalculate_coverage_gaps(connection)
self._send_json(200, payload)
finally:
connection.close()
@@ -1452,6 +1516,366 @@ class AppHandler(SimpleHTTPRequestHandler):
# System settings (admin read/write, persisted + audited)
# ------------------------------------------------------------------
def _handle_admin_calculation_start_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
payload = calculation.start_date_payload(connection)
payload["status"] = "ok"
payload["summary"] = calculation.opening_coverage_summary(connection)
self._send_json(200, payload)
finally:
connection.close()
def _handle_admin_calculation_start_put(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
result = calculation.set_calculation_start_date(
connection,
str(data.get("calculation_start_date") or ""),
str(data.get("reason") or ""),
admin,
)
except calculation.LockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **result})
finally:
connection.close()
def _handle_admin_opening_balances_get(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
items = calculation.list_opening_balances(connection)
self._send_json(
200,
{
"status": "ok",
"items": items,
"summary": calculation.opening_coverage_summary(connection),
},
)
finally:
connection.close()
def _handle_admin_opening_balances_post(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
company_a = int(data.get("company_id_low") or data.get("from_company_id"))
company_b = int(data.get("company_id_high") or data.get("to_company_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定有效的公司。"})
return
try:
item = calculation.create_opening_balance(
connection,
company_a,
company_b,
data.get("amount"),
str(data.get("reason") or ""),
admin,
viewer_company_id=company_a,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_confirm(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body() or {}
try:
item = calculation.confirm_opening_balance(
connection, revision_id, str(data.get("reason") or ""), admin
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_revise(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
try:
item = calculation.revise_opening_balance(
connection,
revision_id,
data.get("amount"),
str(data.get("reason") or ""),
admin,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_opening_balance_void(self, revision_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body() or {}
try:
item = calculation.void_opening_balance(
connection, revision_id, str(data.get("reason") or ""), admin
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_coverage_gaps(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
status = (query.get("status") or [None])[0]
items = calculation.list_coverage_gaps(connection, status=status)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_admin_recalculate_gaps(self) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
rebuilt = calculation.recalculate_coverage_gaps(connection)
self._send_json(200, {"status": "ok", "rebuilt": rebuilt})
finally:
connection.close()
def _handle_admin_calculation_changes(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
items = calculation.list_change_log(connection)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _resolve_company_id(
self, connection, query: dict[str, list[str]], field: str
) -> int | None:
raw = (query.get(field) or [None])[0]
if raw is None:
return None
try:
return int(raw)
except ValueError:
self._send_json(400, {"status": "error", "message": f"{field} 参数无效。"})
return -1
def _handle_admin_pair_balance(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
from_id = self._resolve_company_id(connection, query, "from_company_id")
to_id = self._resolve_company_id(connection, query, "to_company_id")
if from_id == -1 or to_id == -1:
return
if from_id is None or to_id is None:
self._send_json(400, {"status": "error", "message": "必须指定 from_company_id 与 to_company_id。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
balance = calculation.compute_pair_balance(
connection, from_id, to_id, cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "balance": balance})
finally:
connection.close()
def _handle_company_coverage_gaps(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
items = calculation.list_coverage_gaps(
connection, company_id=user["company_id"]
)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_company_balances(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
payload = calculation.compute_company_balances(
connection, user["company_id"], cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
payload["status"] = "ok"
self._send_json(200, payload)
finally:
connection.close()
def _handle_company_pair_balance(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
counterparty_id = self._resolve_company_id(connection, query, "counterparty_id")
if counterparty_id == -1:
return
if counterparty_id is None:
self._send_json(400, {"status": "error", "message": "必须指定 counterparty_id。"})
return
cutoff = (query.get("cutoff") or [None])[0]
try:
balance = calculation.compute_pair_balance(
connection, user["company_id"], counterparty_id, cutoff=cutoff
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "balance": balance})
finally:
connection.close()
def _handle_company_submit_attestation(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
if user["role"] != "company" or user["company_id"] is None:
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
return
data = self._read_json_body()
if data is None:
return
try:
bank_account_id = int(data.get("bank_account_id"))
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "必须指定 bank_account_id。"})
return
try:
item = calculation.submit_no_business_attestation(
connection,
company_id=user["company_id"],
bank_account_id=bank_account_id,
gap_start=str(data.get("gap_start") or ""),
gap_end=str(data.get("gap_end") or ""),
reason=str(data.get("reason") or ""),
evidence=str(data.get("evidence") or "") or None,
actor=user,
)
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_review_attestation(self, attestation_id: int) -> None:
connection = connect(DB_PATH)
try:
admin = self._require_admin(connection)
if admin is None:
return
data = self._read_json_body()
if data is None:
return
decision = str(data.get("decision") or "")
try:
item = calculation.review_no_business_attestation(
connection,
attestation_id,
decision,
str(data.get("review_reason") or ""),
admin,
)
except calculation.ConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ValueError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "item": item})
finally:
connection.close()
def _handle_admin_settings(self) -> None:
connection = connect(DB_PATH)
try:
+960
View File
@@ -0,0 +1,960 @@
"""Calculation window: start date, opening balances, coverage gaps and balances."""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from . import master_data, matching
SETTING_START_DATE = "calculation_start_date"
OPENING_STATUSES = ("draft", "confirmed", "superseded", "void")
GAP_KINDS = ("head", "mid", "tail")
GAP_STATUSES = ("open", "closed_attested")
ATTESTATION_STATUSES = ("pending", "approved", "rejected")
class LockedError(ValueError):
"""Start date cannot change after a period is closed."""
class ConflictError(ValueError):
"""Revision or state conflict."""
def utc_today() -> str:
return datetime.now(timezone.utc).date().isoformat()
def _parse_decimal(value: object) -> Decimal:
try:
return Decimal(str(value))
except (InvalidOperation, TypeError):
raise ValueError("金额格式无效。") from None
def normalize_pair(company_a: int, company_b: int) -> tuple[int, int]:
if company_a == company_b:
raise ValueError("两家公司不能相同。")
return (company_a, company_b) if company_a < company_b else (company_b, company_a)
def signed_from_viewer(viewer_id: int, low_id: int, high_id: int, amount: Decimal) -> Decimal:
return amount if viewer_id == low_id else -amount
def get_setting(connection: sqlite3.Connection, key: str) -> str | None:
row = connection.execute(
"SELECT value FROM system_settings WHERE key = ?", (key,)
).fetchone()
return row["value"] if row is not None else None
def get_calculation_start_date(connection: sqlite3.Connection) -> str | None:
return get_setting(connection, SETTING_START_DATE)
def has_closed_periods(connection: sqlite3.Connection) -> bool:
row = connection.execute("SELECT 1 FROM closed_periods LIMIT 1").fetchone()
return row is not None
def is_start_date_locked(connection: sqlite3.Connection) -> bool:
return has_closed_periods(connection)
def set_calculation_start_date(
connection: sqlite3.Connection,
start_date: str,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
start_date = master_data.validate_date(start_date, "起算日", required=True)
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("修改起算日必须填写原因。")
if is_start_date_locked(connection):
raise LockedError("已有结账月份,起算日已锁定。")
before = get_calculation_start_date(connection)
now = utc_now()
with connection:
connection.execute(
"""
INSERT INTO system_settings (key, value, updated_at, updated_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
""",
(SETTING_START_DATE, start_date, now, actor["id"]),
)
connection.execute(
"""
INSERT INTO system_setting_changes (
key, before_value, after_value,
actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
SETTING_START_DATE,
before,
start_date,
actor["id"],
actor["username"],
now,
),
)
master_data.record_change(
connection,
"system_setting",
0,
"update_start_date",
{"calculation_start_date": before},
{"calculation_start_date": start_date},
reason,
actor,
)
recalculate_coverage_gaps(connection)
return {
"calculation_start_date": start_date,
"locked": False,
"previous": before,
}
def start_date_payload(connection: sqlite3.Connection) -> dict[str, object]:
return {
"calculation_start_date": get_calculation_start_date(connection),
"locked": is_start_date_locked(connection),
}
def _current_opening_revision(
connection: sqlite3.Connection, low_id: int, high_id: int
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM opening_balance_revisions
WHERE company_id_low = ? AND company_id_high = ?
AND status IN ('draft', 'confirmed')
ORDER BY revision DESC
LIMIT 1
""",
(low_id, high_id),
).fetchone()
def _next_revision(connection: sqlite3.Connection, low_id: int, high_id: int) -> int:
row = connection.execute(
"""
SELECT MAX(revision) AS max_rev FROM opening_balance_revisions
WHERE company_id_low = ? AND company_id_high = ?
""",
(low_id, high_id),
).fetchone()
return int(row["max_rev"] or 0) + 1
def create_opening_balance(
connection: sqlite3.Connection,
company_a: int,
company_b: int,
amount: object,
reason: str,
actor: sqlite3.Row,
*,
currency: str = "CNY",
viewer_company_id: int | None = None,
) -> dict[str, object]:
low_id, high_id = normalize_pair(company_a, company_b)
decimal_amount = _parse_decimal(amount)
if viewer_company_id is not None and viewer_company_id == high_id:
decimal_amount = -decimal_amount
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("录入期初必须填写原因。")
existing = _current_opening_revision(connection, low_id, high_id)
if existing is not None and existing["status"] == "draft":
raise ConflictError("该公司对已有待确认期初,请先确认或作废后再录入。")
if existing is not None and existing["status"] == "confirmed":
raise ConflictError("该对公司已有确认期初,请使用修订。")
revision = _next_revision(connection, low_id, high_id)
now = utc_now()
with connection:
cursor = connection.execute(
"""
INSERT INTO opening_balance_revisions (
company_id_low, company_id_high, amount, currency, revision,
status, reason, actor_user_id, actor_username, created_at
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)
""",
(
low_id,
high_id,
str(decimal_amount),
currency,
revision,
reason,
actor["id"],
actor["username"],
now,
),
)
row_id = int(cursor.lastrowid)
master_data.record_change(
connection,
"opening_balance",
row_id,
"create_draft",
None,
{"company_id_low": low_id, "company_id_high": high_id, "amount": str(decimal_amount)},
reason,
actor,
)
return opening_balance_payload(connection, row_id)
def confirm_opening_balance(
connection: sqlite3.Connection,
revision_id: int,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
if row["status"] != "draft":
raise ConflictError("只能确认待确认状态的期初。")
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("确认期初必须填写原因。")
with connection:
connection.execute(
"""
UPDATE opening_balance_revisions SET status = 'confirmed', reason = ?
WHERE id = ?
""",
(reason, revision_id),
)
master_data.record_change(
connection,
"opening_balance",
revision_id,
"confirm",
{"status": "draft"},
{"status": "confirmed"},
reason,
actor,
)
return opening_balance_payload(connection, revision_id)
def revise_opening_balance(
connection: sqlite3.Connection,
revision_id: int,
amount: object,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
if row["status"] != "confirmed":
raise ConflictError("只能修订已确认期初。")
decimal_amount = _parse_decimal(amount)
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("修订期初必须填写原因。")
low_id = int(row["company_id_low"])
high_id = int(row["company_id_high"])
revision = _next_revision(connection, low_id, high_id)
now = utc_now()
with connection:
connection.execute(
"UPDATE opening_balance_revisions SET status = 'superseded' WHERE id = ?",
(revision_id,),
)
cursor = connection.execute(
"""
INSERT INTO opening_balance_revisions (
company_id_low, company_id_high, amount, currency, revision,
status, reason, actor_user_id, actor_username, supersedes_id, created_at
) VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
""",
(
low_id,
high_id,
str(decimal_amount),
row["currency"],
revision,
reason,
actor["id"],
actor["username"],
revision_id,
now,
),
)
new_id = int(cursor.lastrowid)
master_data.record_change(
connection,
"opening_balance",
new_id,
"revise",
{"amount": row["amount"], "revision_id": revision_id},
{"amount": str(decimal_amount), "revision_id": new_id},
reason,
actor,
)
return opening_balance_payload(connection, new_id)
def void_opening_balance(
connection: sqlite3.Connection,
revision_id: int,
reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM opening_balance_revisions WHERE id = ?", (revision_id,)
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
if row["status"] not in ("draft", "confirmed"):
raise ConflictError("该期初已作废或已被替代。")
reason = str(reason or "").strip()
if len(reason) < 2:
raise ValueError("作废期初必须填写原因。")
with connection:
connection.execute(
"UPDATE opening_balance_revisions SET status = 'void', reason = ? WHERE id = ?",
(reason, revision_id),
)
master_data.record_change(
connection,
"opening_balance",
revision_id,
"void",
{"status": row["status"]},
{"status": "void"},
reason,
actor,
)
return opening_balance_payload(connection, revision_id)
def opening_balance_payload(connection: sqlite3.Connection, revision_id: int) -> dict[str, object]:
row = connection.execute(
"""
SELECT r.*, cl.name AS company_low_name, ch.name AS company_high_name
FROM opening_balance_revisions r
JOIN companies cl ON cl.id = r.company_id_low
JOIN companies ch ON ch.id = r.company_id_high
WHERE r.id = ?
""",
(revision_id,),
).fetchone()
if row is None:
raise ValueError("期初记录不存在。")
amount = _parse_decimal(row["amount"])
return {
"id": row["id"],
"company_id_low": row["company_id_low"],
"company_id_high": row["company_id_high"],
"company_low_name": row["company_low_name"],
"company_high_name": row["company_high_name"],
"amount": str(amount),
"currency": row["currency"],
"revision": row["revision"],
"status": row["status"],
"reason": row["reason"],
"actor_username": row["actor_username"],
"supersedes_id": row["supersedes_id"],
"created_at": row["created_at"],
"direction_low": "receivable" if amount >= 0 else "payable",
}
def list_opening_balances(connection: sqlite3.Connection) -> list[dict[str, object]]:
rows = connection.execute(
"""
SELECT r.id FROM opening_balance_revisions r
JOIN (
SELECT company_id_low, company_id_high, MAX(revision) AS max_rev
FROM opening_balance_revisions
WHERE status IN ('draft', 'confirmed', 'void')
GROUP BY company_id_low, company_id_high
) latest ON latest.company_id_low = r.company_id_low
AND latest.company_id_high = r.company_id_high
AND latest.max_rev = r.revision
WHERE r.status IN ('draft', 'confirmed', 'void')
ORDER BY r.company_id_low, r.company_id_high
"""
).fetchall()
return [opening_balance_payload(connection, row["id"]) for row in rows]
def confirmed_opening_amount(
connection: sqlite3.Connection, low_id: int, high_id: int
) -> Decimal | None:
row = connection.execute(
"""
SELECT amount FROM opening_balance_revisions
WHERE company_id_low = ? AND company_id_high = ? AND status = 'confirmed'
ORDER BY revision DESC LIMIT 1
""",
(low_id, high_id),
).fetchone()
if row is None:
return None
return _parse_decimal(row["amount"])
def _date_add(day: str, delta: int) -> str:
return (date.fromisoformat(day) + timedelta(days=delta)).isoformat()
def _merge_intervals(intervals: list[tuple[str, str]]) -> list[tuple[str, str]]:
if not intervals:
return []
sorted_intervals = sorted(intervals, key=lambda item: item[0])
merged = [sorted_intervals[0]]
for start, end in sorted_intervals[1:]:
last_start, last_end = merged[-1]
if date.fromisoformat(start) <= date.fromisoformat(_date_add(last_end, 1)):
if date.fromisoformat(end) > date.fromisoformat(last_end):
merged[-1] = (last_start, end)
else:
merged.append((start, end))
return merged
def _account_coverage_intervals(
connection: sqlite3.Connection, account: sqlite3.Row
) -> list[tuple[str, str]]:
rows = connection.execute(
"""
SELECT MIN(substr(r.transaction_at, 1, 10)) AS interval_start,
MAX(substr(r.transaction_at, 1, 10)) AS interval_end
FROM source_rows r
JOIN sheet_batches s ON s.id = r.sheet_batch_id
JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed'
JOIN import_batches b ON b.id = s.import_batch_id
WHERE b.upload_bank_account_id = ?
OR r.own_account = ?
GROUP BY s.id
ORDER BY interval_start
""",
(account["id"], account["account_number"]),
).fetchall()
return [
(row["interval_start"], row["interval_end"])
for row in rows
if row["interval_start"] and row["interval_end"]
]
def _account_transaction_dates(
connection: sqlite3.Connection, account: sqlite3.Row
) -> list[str]:
intervals = _account_coverage_intervals(connection, account)
dates: list[str] = []
for start, end in intervals:
current = date.fromisoformat(start)
end_day = date.fromisoformat(end)
while current <= end_day:
dates.append(current.isoformat())
current += timedelta(days=1)
return dates
def _dates_to_intervals(dates: list[str]) -> list[tuple[str, str]]:
if not dates:
return []
intervals: list[tuple[str, str]] = []
start = dates[0]
prev = dates[0]
for current in dates[1:]:
if date.fromisoformat(current) == date.fromisoformat(prev) + timedelta(days=1):
prev = current
continue
intervals.append((start, prev))
start = current
prev = current
intervals.append((start, prev))
return intervals
def _detect_gaps(
required_start: str,
required_end: str,
covered: list[tuple[str, str]],
) -> list[tuple[str, str, str]]:
if required_start > required_end:
return []
gaps: list[tuple[str, str, str]] = []
if not covered:
gaps.append((required_start, required_end, "head"))
return gaps
merged = _merge_intervals(covered)
first_start, first_end = merged[0]
if required_start < first_start:
gaps.append((required_start, _date_add(first_start, -1), "head"))
for index in range(len(merged) - 1):
_, left_end = merged[index]
right_start, _ = merged[index + 1]
gap_start = _date_add(left_end, 1)
gap_end = _date_add(right_start, -1)
if gap_start <= gap_end:
gaps.append((gap_start, gap_end, "mid"))
last_start, last_end = merged[-1]
tail_start = _date_add(last_end, 1)
gap_from = max(tail_start, required_start)
if gap_from <= required_end:
kind = "tail" if last_end >= required_start else "head"
gaps.append((gap_from, required_end, kind))
return gaps
def detect_account_gaps(
connection: sqlite3.Connection,
account: sqlite3.Row,
*,
start_date: str | None,
today: str | None = None,
) -> list[tuple[str, str, str]]:
if account["status"] != "active":
return []
today = today or utc_today()
effective_from = account["effective_from"] or start_date or today
effective_to = account["effective_to"] or today
required_start = max(filter(None, [start_date, effective_from]))
required_end = min(today, effective_to)
if not master_data.is_usable(account, required_start):
return []
intervals = _account_coverage_intervals(connection, account)
return _detect_gaps(required_start, required_end, intervals)
def recalculate_coverage_gaps(connection: sqlite3.Connection) -> int:
start_date = get_calculation_start_date(connection)
today = utc_today()
accounts = connection.execute(
"SELECT * FROM bank_accounts WHERE status = 'active'"
).fetchall()
rebuilt = 0
with connection:
for account in accounts:
connection.execute(
"""
DELETE FROM coverage_gaps
WHERE bank_account_id = ? AND status = 'open'
""",
(account["id"],),
)
for gap_start, gap_end, gap_kind in detect_account_gaps(
connection, account, start_date=start_date, today=today
):
connection.execute(
"""
INSERT INTO coverage_gaps (
bank_account_id, gap_start, gap_end, gap_kind,
status, first_detected_at
) VALUES (?, ?, ?, ?, 'open', ?)
ON CONFLICT(bank_account_id, gap_start, gap_end) DO UPDATE SET
gap_kind = excluded.gap_kind,
status = CASE coverage_gaps.status
WHEN 'closed_attested' THEN 'closed_attested'
ELSE 'open'
END
""",
(account["id"], gap_start, gap_end, gap_kind, utc_now()),
)
rebuilt += 1
return rebuilt
def coverage_gap_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
account = master_data.get_account(connection, row["bank_account_id"])
if account is None:
raise ValueError("账户不存在。")
company = connection.execute(
"SELECT name FROM companies WHERE id = ?", (account["company_id"],)
).fetchone()
start = date.fromisoformat(row["gap_start"])
end = date.fromisoformat(row["gap_end"])
gap_days = (end - start).days + 1
pending = connection.execute(
"""
SELECT id FROM no_business_attestations
WHERE bank_account_id = ?
AND gap_start = ? AND gap_end = ?
AND status = 'pending'
ORDER BY id DESC LIMIT 1
""",
(row["bank_account_id"], row["gap_start"], row["gap_end"]),
).fetchone()
return {
"id": row["id"],
"bank_account_id": row["bank_account_id"],
"company_id": account["company_id"],
"company_name": company["name"] if company else None,
"account_number_masked": master_data.mask_account_number(account["account_number"]),
"gap_start": row["gap_start"],
"gap_end": row["gap_end"],
"gap_kind": row["gap_kind"],
"gap_days": gap_days,
"day_count": gap_days,
"status": row["status"],
"first_detected_at": row["first_detected_at"],
"pending_attestation_id": pending["id"] if pending else None,
}
def list_coverage_gaps(
connection: sqlite3.Connection,
*,
company_id: int | None = None,
status: str | None = None,
) -> list[dict[str, object]]:
clauses: list[str] = []
params: list[object] = []
if company_id is not None:
clauses.append("ba.company_id = ?")
params.append(company_id)
if status is not None:
clauses.append("g.status = ?")
params.append(status)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
rows = connection.execute(
f"""
SELECT g.* FROM coverage_gaps g
JOIN bank_accounts ba ON ba.id = g.bank_account_id
{where}
ORDER BY g.gap_start, g.bank_account_id
""",
params,
).fetchall()
return [coverage_gap_payload(connection, row) for row in rows]
def submit_no_business_attestation(
connection: sqlite3.Connection,
*,
company_id: int,
bank_account_id: int,
gap_start: str,
gap_end: str,
reason: str,
evidence: str | None,
actor: sqlite3.Row,
) -> dict[str, object]:
gap_start = master_data.validate_date(gap_start, "断档起始日", required=True)
gap_end = master_data.validate_date(gap_end, "断档结束日", required=True)
if gap_start > gap_end:
raise ValueError("断档起始日不能晚于结束日。")
reason = str(reason or "").strip()
if len(reason) < 5:
raise ValueError("无业务说明至少 5 个字符。")
account = master_data.get_account(connection, bank_account_id)
if account["company_id"] != company_id:
raise ValueError("只能为本公司账户提交说明。")
now = utc_now()
with connection:
cursor = connection.execute(
"""
INSERT INTO no_business_attestations (
bank_account_id, gap_start, gap_end, reason, evidence,
submitted_by, company_id, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)
""",
(
bank_account_id,
gap_start,
gap_end,
reason,
evidence,
actor["id"],
company_id,
now,
),
)
attestation_id = int(cursor.lastrowid)
return attestation_payload(connection, attestation_id)
def review_no_business_attestation(
connection: sqlite3.Connection,
attestation_id: int,
decision: str,
review_reason: str,
actor: sqlite3.Row,
) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
).fetchone()
if row is None:
raise ValueError("无业务说明不存在。")
if row["status"] != "pending":
raise ConflictError("该说明已审核。")
review_reason = str(review_reason or "").strip()
if decision not in ("approve", "reject"):
raise ValueError("审核决定无效。")
if len(review_reason) < 2:
raise ValueError("审核必须填写理由。")
status = "approved" if decision == "approve" else "rejected"
now = utc_now()
with connection:
connection.execute(
"""
UPDATE no_business_attestations
SET status = ?, reviewed_by = ?, reviewed_at = ?, review_reason = ?
WHERE id = ?
""",
(status, actor["id"], now, review_reason, attestation_id),
)
if status == "approved":
# Exact match first so the attested row survives recalculate's
# "DELETE ... status='open'" and stays closed_attested.
connection.execute(
"""
UPDATE coverage_gaps
SET status = 'closed_attested'
WHERE bank_account_id = ?
AND gap_start = ? AND gap_end = ?
""",
(row["bank_account_id"], row["gap_start"], row["gap_end"]),
)
recalculate_coverage_gaps(connection)
if status == "approved":
# Overlap match: if recalculate shifts boundaries, still close
# any open gap that intersects the attested interval.
connection.execute(
"""
UPDATE coverage_gaps
SET status = 'closed_attested'
WHERE bank_account_id = ?
AND status = 'open'
AND gap_start <= ?
AND gap_end >= ?
""",
(row["bank_account_id"], row["gap_end"], row["gap_start"]),
)
return attestation_payload(connection, attestation_id)
def attestation_payload(connection: sqlite3.Connection, attestation_id: int) -> dict[str, object]:
row = connection.execute(
"SELECT * FROM no_business_attestations WHERE id = ?", (attestation_id,)
).fetchone()
if row is None:
raise ValueError("无业务说明不存在。")
account = master_data.get_account(connection, row["bank_account_id"])
if account is None:
raise ValueError("账户不存在。")
return {
"id": row["id"],
"bank_account_id": row["bank_account_id"],
"company_id": row["company_id"],
"gap_start": row["gap_start"],
"gap_end": row["gap_end"],
"reason": row["reason"],
"evidence": row["evidence"],
"status": row["status"],
"review_reason": row["review_reason"],
"reviewed_at": row["reviewed_at"],
"created_at": row["created_at"],
"account_number_masked": master_data.mask_account_number(account["account_number"]),
}
def list_change_log(connection: sqlite3.Connection, limit: int = 100) -> list[dict[str, object]]:
rows = connection.execute(
"""
SELECT * FROM master_data_changes
WHERE entity_type IN ('system_setting', 'opening_balance')
ORDER BY id DESC LIMIT ?
""",
(limit,),
).fetchall()
items: list[dict[str, object]] = []
for row in rows:
before = json.loads(row["before_json"]) if row["before_json"] else None
after = json.loads(row["after_json"]) if row["after_json"] else None
target = "起算日"
if row["entity_type"] == "opening_balance":
target = f"期初 #{row['entity_id']}"
items.append(
{
"id": row["id"],
"created_at": row["created_at"],
"actor_username": row["actor_username"],
"action": row["action"],
"target": target,
"before": before,
"after": after,
"reason": row["reason"],
}
)
return items
def _pair_net_change(
connection: sqlite3.Connection,
viewer_id: int,
counterparty_id: int,
*,
cutoff: str | None,
start_date: str | None,
) -> Decimal:
cutoff_where = ""
params: list[object] = [viewer_id, counterparty_id, counterparty_id, viewer_id]
if cutoff:
cutoff_where = "AND substr(d.effective_at, 1, 10) <= ?"
params.append(cutoff)
start_where = ""
if start_date:
start_where = "AND substr(d.effective_at, 1, 10) >= ?"
params.append(start_date)
rows = connection.execute(
f"""
SELECT d.amount, payer.company_id AS payer_id, payee.company_id AS payee_id
FROM eligible_intercompany_events e
JOIN transfer_match_decisions d ON d.id = e.decision_id
JOIN transfer_decision_participants payer ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE (
(payer.company_id = ? AND payee.company_id = ?)
OR (payer.company_id = ? AND payee.company_id = ?)
)
{cutoff_where}
{start_where}
""",
params,
).fetchall()
# 全站往来口径:转出(付款方)为正 = 本方应收增加;转入(收款方)为负 = 本方应付增加。
# 与 company_transfers.net = outflow inflow、期初「正数=应收」一致。
total = Decimal("0")
for row in rows:
amount = _parse_decimal(row["amount"])
if row["payer_id"] == viewer_id:
total += amount
elif row["payee_id"] == viewer_id:
total -= amount
return total
def pair_has_confirmed_opening(
connection: sqlite3.Connection, company_a: int, company_b: int
) -> bool:
low_id, high_id = normalize_pair(company_a, company_b)
return confirmed_opening_amount(connection, low_id, high_id) is not None
def compute_pair_balance(
connection: sqlite3.Connection,
viewer_id: int,
counterparty_id: int,
*,
cutoff: str | None = None,
) -> dict[str, object]:
cutoff = cutoff or utc_today()
cutoff = master_data.validate_date(cutoff, "截止日", required=True)
start_date = get_calculation_start_date(connection)
low_id, high_id = normalize_pair(viewer_id, counterparty_id)
opening_amount: Decimal | None = None
if start_date and pair_has_confirmed_opening(connection, viewer_id, counterparty_id):
stored = confirmed_opening_amount(connection, low_id, high_id)
assert stored is not None
opening_amount = signed_from_viewer(viewer_id, low_id, high_id, stored)
net_change = _pair_net_change(
connection,
viewer_id,
counterparty_id,
cutoff=cutoff,
start_date=start_date,
)
unresolved = matching.unresolved_amounts(connection, viewer_id, cutoff=cutoff)
pending_total = Decimal("0")
for row in unresolved:
pending_total += _parse_decimal(row["amount"] or "0")
basis = "full" if opening_amount is not None and start_date else "net_change"
payload: dict[str, object] = {
"viewer_company_id": viewer_id,
"counterparty_company_id": counterparty_id,
"effective_at": cutoff,
"calculation_start_date": start_date,
"net_change": str(net_change),
"basis": basis,
"pending_unconfirmed": str(pending_total),
"currency": "CNY",
}
if basis == "full" and opening_amount is not None:
closing = opening_amount + net_change
payload["opening"] = str(opening_amount)
payload["closing"] = str(closing)
payload["opening_effective_at"] = _date_add(start_date, -1)
return payload
def list_company_counterparties(
connection: sqlite3.Connection, company_id: int
) -> list[int]:
rows = connection.execute(
"""
SELECT DISTINCT CASE
WHEN payer.company_id = ? THEN payee.company_id
ELSE payer.company_id
END AS other_id
FROM eligible_intercompany_events e
JOIN transfer_decision_participants payer ON payer.decision_id = e.decision_id AND payer.role = 'payer'
JOIN transfer_decision_participants payee ON payee.decision_id = e.decision_id AND payee.role = 'payee'
WHERE payer.company_id = ? OR payee.company_id = ?
""",
(company_id, company_id, company_id),
).fetchall()
return [int(row["other_id"]) for row in rows if row["other_id"] is not None]
def compute_company_balances(
connection: sqlite3.Connection,
company_id: int,
*,
cutoff: str | None = None,
) -> dict[str, object]:
cutoff = cutoff or utc_today()
start_date = get_calculation_start_date(connection)
counterparties = list_company_counterparties(connection, company_id)
pairs = [
compute_pair_balance(connection, company_id, other_id, cutoff=cutoff)
for other_id in counterparties
]
complete = bool(start_date) and all(item["basis"] == "full" for item in pairs)
return {
"company_id": company_id,
"cutoff": cutoff,
"calculation_start_date": start_date,
"basis": "full" if complete and pairs else "net_change",
"pairs": pairs,
}
def opening_coverage_summary(connection: sqlite3.Connection) -> dict[str, int]:
companies = connection.execute(
"SELECT COUNT(*) AS n FROM companies"
).fetchone()["n"]
recorded = connection.execute(
"""
SELECT COUNT(DISTINCT company_id_low || ':' || company_id_high) AS n
FROM opening_balance_revisions WHERE status = 'confirmed'
"""
).fetchone()["n"]
return {"company_count": companies, "confirmed_pair_count": recorded}
+20 -6
View File
@@ -20,7 +20,7 @@ from decimal import Decimal, InvalidOperation
import re
import sqlite3
from . import matching, settings
from . import calculation, matching, settings
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_ZERO = Decimal("0.00")
@@ -72,7 +72,8 @@ def _window_bounds(
connection: sqlite3.Connection, as_of: str | None
) -> tuple[str, str]:
end = _validate_date(as_of, "as_of") if as_of else today_shanghai()
start = settings.get_settings(connection).get("start_date") or "2026-01-01"
calc_start = calculation.get_calculation_start_date(connection)
start = calc_start or settings.get_settings(connection).get("start_date") or "2026-01-01"
start = _validate_date(start, "start_date")
if start > end:
# Opening / start-date plumbing may lag; clamp rather than 500.
@@ -282,15 +283,28 @@ def company_intercompany_summary(
}
)
# Enrich window with calculation-basis opening/ending when configured.
balances = calculation.compute_company_balances(
connection, company_id, cutoff=end
)
has_opening = balances.get("basis") == "full"
opening_total = _ZERO
ending_total = _ZERO
if has_opening:
for pair in balances.get("pairs") or []:
opening_total += _as_decimal(pair.get("opening") or "0")
ending_total += _as_decimal(pair.get("closing") or "0")
return {
"own_company": own,
"window": {
"start": start,
"end": end,
"has_opening": False,
# Reserved for opening-balance rollout; callers must not invent balances.
"opening": None,
"ending": None,
"has_opening": has_opening,
"opening": _money(opening_total) if has_opening else None,
"ending": _money(ending_total) if has_opening else None,
"basis": balances.get("basis"),
"calculation_start_date": balances.get("calculation_start_date"),
},
"confirmed": {
"outflow_total": _money(outflow),
+165
View File
@@ -846,6 +846,171 @@ MIGRATIONS: tuple[Migration, ...] = (
DROP TABLE IF EXISTS system_settings;
""",
),
Migration(
version=8,
name="0008_calculation_window",
# HEL-194/202: opening balances, coverage gaps, no-business attestations.
# Reuses system_settings / system_setting_changes from 0007; does not
# recreate them. Extends master_data_changes CHECK and overlays the
# calculation_start_date filter onto eligible_intercompany_events.
up="""
CREATE TABLE closed_periods (
year_month TEXT PRIMARY KEY,
closed_at TEXT NOT NULL,
closed_by INTEGER REFERENCES users (id)
);
CREATE TABLE opening_balance_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id_low INTEGER NOT NULL REFERENCES companies (id),
company_id_high INTEGER NOT NULL REFERENCES companies (id),
amount TEXT NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
revision INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('draft', 'confirmed', 'superseded', 'void')),
reason TEXT NOT NULL,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
supersedes_id INTEGER REFERENCES opening_balance_revisions (id),
created_at TEXT NOT NULL,
UNIQUE (company_id_low, company_id_high, revision),
CHECK (company_id_low < company_id_high)
);
CREATE TABLE coverage_gaps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
gap_start TEXT NOT NULL,
gap_end TEXT NOT NULL,
gap_kind TEXT NOT NULL CHECK (gap_kind IN ('head', 'mid', 'tail')),
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed_attested')),
first_detected_at TEXT NOT NULL,
UNIQUE (bank_account_id, gap_start, gap_end)
);
CREATE TABLE no_business_attestations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
gap_start TEXT NOT NULL,
gap_end TEXT NOT NULL,
reason TEXT NOT NULL,
evidence TEXT,
submitted_by INTEGER REFERENCES users (id),
company_id INTEGER NOT NULL REFERENCES companies (id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected')),
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
review_reason TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_opening_balance_pair ON opening_balance_revisions (company_id_low, company_id_high);
CREATE INDEX idx_coverage_gaps_account ON coverage_gaps (bank_account_id);
CREATE INDEX idx_attestations_company ON no_business_attestations (company_id);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN (
'company', 'user', 'bank_account', 'account_alias',
'personal_transit_mapping', 'system_setting', 'opening_balance'
)),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
DROP VIEW IF EXISTS eligible_intercompany_events;
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1)
AND (
(SELECT value FROM system_settings WHERE key = 'calculation_start_date') IS NULL
OR substr(d.effective_at, 1, 10) >= (
SELECT value FROM system_settings WHERE key = 'calculation_start_date'
)
);
""",
down="""
DROP VIEW IF EXISTS eligible_intercompany_events;
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN (
'company', 'user', 'bank_account', 'account_alias',
'personal_transit_mapping'
)),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes
WHERE entity_type NOT IN ('system_setting', 'opening_balance');
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
DROP INDEX IF EXISTS idx_attestations_company;
DROP INDEX IF EXISTS idx_coverage_gaps_account;
DROP INDEX IF EXISTS idx_opening_balance_pair;
DROP TABLE IF EXISTS no_business_attestations;
DROP TABLE IF EXISTS coverage_gaps;
DROP TABLE IF EXISTS opening_balance_revisions;
DROP TABLE IF EXISTS closed_periods;
""",
),
)
+1 -1
View File
@@ -1523,7 +1523,7 @@ def unresolved_amounts(
count as unresolved; paired intercompany, same-company and external events
are resolved classifications and never appear here.
"""
cutoff_where = "AND d.effective_at <= ?" if cutoff else ""
cutoff_where = "AND substr(d.effective_at, 1, 10) <= ?" if cutoff else ""
params: list[object] = []
if cutoff:
params.append(cutoff)
+691
View File
@@ -0,0 +1,691 @@
"""Tests for calculation window: start date, opening balances, coverage gaps."""
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
import json
import tempfile
import threading
import unittest
from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now
import server
from test_server_auth import Client, as_json
class CalculationBase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.db_path = Path(self.temp_dir.name) / "app.db"
self.connection = connect(self.db_path)
self.addCleanup(self.connection.close)
migrate(self.connection)
self.admin = self._admin()
self.company_a = self._company("甲公司")
self.company_b = self._company("乙公司")
self.account_a = self._approved_account(self.company_a, "6222000000000001")
self.account_b = self._approved_account(self.company_b, "6222000000000002")
def _admin(self):
auth.create_user(self.connection, "admin-u", "AdminPass123", "admin")
return self.connection.execute(
"SELECT * FROM users WHERE username = 'admin-u'"
).fetchone()
def _company(self, name: str) -> int:
with self.connection:
cursor = self.connection.execute(
"INSERT INTO companies (name, created_at, updated_at) VALUES (?, ?, ?)",
(name, utc_now(), utc_now()),
)
return int(cursor.lastrowid)
def _approved_account(self, company_id: int, number: str, start: str = "2026-01-01"):
account = master_data.submit_bank_account(
self.connection,
company_id=company_id,
bank_name="中信银行",
account_type="基本户",
account_number=number,
start_date=start,
actor=None,
)
return master_data.review_bank_account(
self.connection, account["id"], "approve", None, self.admin,
effective_from=start,
)
def add_confirmed_row(
self,
company_id: int,
*,
account_id: int,
own_account: str,
at: str,
income: str = "0",
expense: str = "0",
cp_account: str | None = None,
sheet: str = "流水",
source_row: int = 1,
) -> int:
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
""",
(f"sha-{at}-{own_account}-{source_row}", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, transaction_count, warnings, created_at
) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, sheet, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, ?, 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet, sheet_batch_id, utc_now()),
)
cursor = self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, counterparty_account, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(sheet_batch_id, source_row, at, income, expense, own_account, cp_account, utc_now()),
)
return int(cursor.lastrowid)
def add_confirmed_batch_range(
self,
company_id: int,
*,
account_id: int,
own_account: str,
start: str,
end: str,
sheet: str,
) -> None:
with self.connection:
cursor = self.connection.execute(
"""
INSERT INTO source_files (sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, '测试.xlsx', 1, 'data/files/测试.xlsx', ?)
""",
(f"sha-batch-{sheet}", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = self.connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id, template_version,
header_row, transaction_count, warnings, created_at
) VALUES (?, ?, '测试银行', 'test-v1', 1, 1, 2, '[]', ?)
""",
(batch_id, sheet, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
self.connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, ?, 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet, sheet_batch_id, utc_now()),
)
for source_row, day in ((1, start), (2, end)):
self.connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, created_at
) VALUES (?, ?, ?, '0', '0', ?, ?)
""",
(sheet_batch_id, source_row, f"{day}T10:00:00", own_account, utc_now()),
)
class StartDateTests(CalculationBase):
def test_set_start_date_records_change(self) -> None:
result = calculation.set_calculation_start_date(
self.connection, "2026-01-01", "首次设定", self.admin
)
self.assertEqual("2026-01-01", result["calculation_start_date"])
row = self.connection.execute(
"SELECT 1 FROM master_data_changes WHERE entity_type = 'system_setting'"
).fetchone()
self.assertIsNotNone(row)
def test_locked_after_closed_period(self) -> None:
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "首次设定", self.admin
)
with self.connection:
self.connection.execute(
"INSERT INTO closed_periods (year_month, closed_at, closed_by) VALUES ('2026-01', ?, ?)",
(utc_now(), self.admin["id"]),
)
with self.assertRaises(calculation.LockedError):
calculation.set_calculation_start_date(
self.connection, "2026-02-01", "尝试修改", self.admin
)
class OpeningBalanceTests(CalculationBase):
def setUp(self) -> None:
super().setUp()
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "测试起算日", self.admin
)
def test_bilateral_conservation_on_storage(self) -> None:
item = calculation.create_opening_balance(
self.connection,
self.company_a,
self.company_b,
"100.00",
"期初录入",
self.admin,
viewer_company_id=self.company_a,
)
low, high = calculation.normalize_pair(self.company_a, self.company_b)
self.assertEqual(low, item["company_id_low"])
stored = calculation.confirmed_opening_amount(self.connection, low, high)
self.assertIsNone(stored)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认期初", self.admin
)
stored = calculation.confirmed_opening_amount(self.connection, low, high)
self.assertEqual(Decimal("100.00"), stored)
from_b = calculation.signed_from_viewer(self.company_b, low, high, stored)
self.assertEqual(Decimal("-100.00"), from_b)
def test_confirmed_requires_revision_not_overwrite(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "50", "录入", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
with self.assertRaises(calculation.ConflictError):
calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "80", "重复录入", self.admin
)
revised = calculation.revise_opening_balance(
self.connection, item["id"], "80", "修订", self.admin
)
self.assertEqual("draft", revised["status"])
class CoverageGapTests(CalculationBase):
def setUp(self) -> None:
super().setUp()
calculation.set_calculation_start_date(
self.connection, "2026-06-01", "起算", self.admin
)
def test_adjacent_intervals_no_mid_gap(self) -> None:
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-06-21",
end="2026-07-21",
sheet="批次A",
)
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-07-22",
end="2026-08-21",
sheet="批次B",
)
calculation.recalculate_coverage_gaps(self.connection)
mids = self.connection.execute(
"SELECT * FROM coverage_gaps WHERE gap_kind = 'mid'"
).fetchall()
self.assertEqual([], mids)
def test_missing_day_mid_gap(self) -> None:
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-06-21",
end="2026-07-21",
sheet="批次A",
)
self.add_confirmed_batch_range(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
start="2026-07-23",
end="2026-08-21",
sheet="批次B",
)
calculation.recalculate_coverage_gaps(self.connection)
gap = self.connection.execute(
"""
SELECT gap_start, gap_end FROM coverage_gaps
WHERE gap_kind = 'mid' AND gap_start = '2026-07-22'
"""
).fetchone()
self.assertIsNotNone(gap)
self.assertEqual("2026-07-22", gap["gap_end"])
def test_attestation_closes_gap_without_bank_row(self) -> None:
self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2026-06-21T10:00:00",
)
calculation.recalculate_coverage_gaps(self.connection)
gap = self.connection.execute(
"SELECT * FROM coverage_gaps WHERE status = 'open'"
).fetchone()
self.assertIsNotNone(gap)
before_rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"]
cashier_id = auth.create_user(
self.connection, "cashier-a", "CashierA123", "company", self.company_a
)
cashier = self.connection.execute(
"SELECT * FROM users WHERE id = ?", (cashier_id,)
).fetchone()
att = calculation.submit_no_business_attestation(
self.connection,
company_id=self.company_a,
bank_account_id=self.account_a["id"],
gap_start=gap["gap_start"],
gap_end=gap["gap_end"],
reason="当日账户无资金往来",
evidence=None,
actor=cashier,
)
calculation.review_no_business_attestation(
self.connection, att["id"], "approve", "审核通过", self.admin
)
after_rows = self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"]
self.assertEqual(before_rows, after_rows)
closed = self.connection.execute(
"SELECT status FROM coverage_gaps WHERE id = ?", (gap["id"],)
).fetchone()
self.assertEqual("closed_attested", closed["status"])
class BalanceBasisTests(CalculationBase):
def setUp(self) -> None:
super().setUp()
calculation.set_calculation_start_date(
self.connection, "2026-01-01", "起算", self.admin
)
def test_without_opening_returns_net_change(self) -> None:
row_a = self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2026-01-05T10:00:00",
expense="100.00",
cp_account="6222000000000002",
)
row_b = self.add_confirmed_row(
self.company_b,
account_id=self.account_b["id"],
own_account="6222000000000002",
at="2026-01-05T11:00:00",
income="100.00",
cp_account="6222000000000001",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("net_change", balance["basis"])
self.assertNotIn("closing", balance)
def test_with_opening_returns_full_basis(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "200", "录入", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("full", balance["basis"])
self.assertEqual("200", balance["opening"])
self.assertEqual("200", balance["closing"])
def test_pre_start_events_excluded(self) -> None:
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "0", "零期初", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
row_a = self.add_confirmed_row(
self.company_a,
account_id=self.account_a["id"],
own_account="6222000000000001",
at="2025-12-31T10:00:00",
expense="50.00",
cp_account="6222000000000002",
)
row_b = self.add_confirmed_row(
self.company_b,
account_id=self.account_b["id"],
own_account="6222000000000002",
at="2025-12-31T11:00:00",
income="50.00",
cp_account="6222000000000001",
)
matching.reconcile_rows(self.connection, [row_a, row_b])
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("0", balance["net_change"])
def test_cutoff_day_event_is_included(self) -> None:
"""effective_at with time on the cutoff date must still count."""
from test_matching import MatchingBase
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "0", "零期初", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
helper = object.__new__(MatchingBase)
helper.connection = self.connection
row_a = helper.add_row(
self.company_a,
own_account="6222000000000001",
cp_account="6222000000000002",
expense="80.00",
at="2026-01-31T10:00:00",
)
matching.reconcile_rows(self.connection, [row_a])
row_b = helper.add_row(
self.company_b,
own_account="6222000000000002",
cp_account="6222000000000001",
income="80.00",
at="2026-01-31T11:00:00",
)
matching.reconcile_rows(self.connection, [row_b])
eligible = self.connection.execute(
"SELECT effective_at, amount FROM eligible_intercompany_events"
).fetchall()
self.assertEqual(1, len(eligible))
self.assertEqual("2026-01-31T10:00:00", eligible[0]["effective_at"])
# Full-timestamp string compare wrongly excludes the cutoff day.
self.assertEqual(
[],
self.connection.execute(
"""
SELECT 1 FROM eligible_intercompany_events
WHERE effective_at <= '2026-01-31'
"""
).fetchall(),
)
balance = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
# 甲为付款方(转出 80)→ 本期变动为正,表示应收增加
self.assertEqual("80.00", balance["net_change"])
self.assertEqual("80.00", balance["closing"])
def test_pair_direction_both_sides_opening_plus_outflow(self) -> None:
"""甲应收乙 200 + 甲向乙转出 100 → 甲 +300 / 乙 300。"""
from test_matching import MatchingBase
item = calculation.create_opening_balance(
self.connection, self.company_a, self.company_b, "200", "应收期初", self.admin
)
calculation.confirm_opening_balance(
self.connection, item["id"], "确认", self.admin
)
helper = object.__new__(MatchingBase)
helper.connection = self.connection
row_a = helper.add_row(
self.company_a,
own_account="6222000000000001",
cp_account="6222000000000002",
expense="100.00",
at="2026-01-10T10:00:00",
)
matching.reconcile_rows(self.connection, [row_a])
row_b = helper.add_row(
self.company_b,
own_account="6222000000000002",
cp_account="6222000000000001",
income="100.00",
at="2026-01-10T11:00:00",
)
matching.reconcile_rows(self.connection, [row_b])
from_a = calculation.compute_pair_balance(
self.connection, self.company_a, self.company_b, cutoff="2026-01-31"
)
self.assertEqual("full", from_a["basis"])
self.assertEqual("200", from_a["opening"])
self.assertEqual("100.00", from_a["net_change"])
self.assertEqual("300.00", from_a["closing"])
from_b = calculation.compute_pair_balance(
self.connection, self.company_b, self.company_a, cutoff="2026-01-31"
)
self.assertEqual("full", from_b["basis"])
self.assertEqual("-200", from_b["opening"])
self.assertEqual("-100.00", from_b["net_change"])
self.assertEqual("-300.00", from_b["closing"])
class CalculationApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
cls.db_path = Path(cls.temp_dir.name) / "app.db"
cls.storage_dir = Path(cls.temp_dir.name) / "files"
cls.storage_dir.mkdir()
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage_dir
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection, "group-admin", "AdminPass123", "admin",
must_change_password=False,
)
cls.company_a = master_data.create_company(connection, "甲公司", None, None, None)
cls.company_b = master_data.create_company(connection, "乙公司", None, None, None)
auth.create_user(
connection, "cashier-a", "CashierA123", "company", cls.company_a,
must_change_password=False,
)
admin = connection.execute(
"SELECT * FROM users WHERE username = 'group-admin'"
).fetchone()
account_a = master_data.submit_bank_account(
connection,
company_id=cls.company_a,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000001",
start_date="2026-01-01",
actor=None,
)
cls.account_a = master_data.review_bank_account(
connection, account_a["id"], "approve", None, admin, effective_from="2026-01-01",
)
account_b = master_data.submit_bank_account(
connection,
company_id=cls.company_b,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000002",
start_date="2026-01-01",
actor=None,
)
cls.account_b = master_data.review_bank_account(
connection, account_b["id"], "approve", None, admin, effective_from="2026-01-01",
)
calculation.set_calculation_start_date(
connection, "2026-01-01", "初始化", admin
)
connection.close()
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), server.AppHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.client = Client("127.0.0.1", cls.port)
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.temp_dir.cleanup()
def _seed_pair_direction_fixture(self) -> None:
"""甲应收乙 200 + 甲转出 100,供 balances/pair 双边断言。"""
from test_matching import MatchingBase
connection = connect(self.db_path)
admin = connection.execute(
"SELECT * FROM users WHERE username = 'group-admin'"
).fetchone()
existing = connection.execute(
"SELECT 1 FROM opening_balance_revisions LIMIT 1"
).fetchone()
if existing is None:
item = calculation.create_opening_balance(
connection,
self.company_a,
self.company_b,
"200",
"应收期初",
admin,
viewer_company_id=self.company_a,
)
calculation.confirm_opening_balance(
connection, item["id"], "确认", admin
)
helper = object.__new__(MatchingBase)
helper.connection = connection
row_a = helper.add_row(
self.company_a,
own_account="6222000000000001",
cp_account="6222000000000002",
expense="100.00",
at="2026-01-10T10:00:00",
)
matching.reconcile_rows(connection, [row_a])
row_b = helper.add_row(
self.company_b,
own_account="6222000000000002",
cp_account="6222000000000001",
income="100.00",
at="2026-01-10T11:00:00",
)
matching.reconcile_rows(connection, [row_b])
connection.close()
def test_admin_balances_pair_direction_both_sides(self) -> None:
self._seed_pair_direction_fixture()
client = Client("127.0.0.1", self.port)
client.post_json("/api/login", {
"username": "group-admin", "password": "AdminPass123", "portal": "admin",
})
status, _, body = client.request(
"GET",
f"/api/admin/balances/pair?from_company_id={self.company_a}"
f"&to_company_id={self.company_b}&cutoff=2026-01-31",
)
self.assertEqual(200, status)
bal_a = as_json(body)["balance"]
self.assertEqual("200", bal_a["opening"])
self.assertEqual("100.00", bal_a["net_change"])
self.assertEqual("300.00", bal_a["closing"])
status, _, body = client.request(
"GET",
f"/api/admin/balances/pair?from_company_id={self.company_b}"
f"&to_company_id={self.company_a}&cutoff=2026-01-31",
)
self.assertEqual(200, status)
bal_b = as_json(body)["balance"]
self.assertEqual("-200", bal_b["opening"])
self.assertEqual("-100.00", bal_b["net_change"])
self.assertEqual("-300.00", bal_b["closing"])
def test_company_cannot_call_admin_start_date(self) -> None:
self.client.post_json("/api/login", {
"username": "cashier-a", "password": "CashierA123", "portal": "company",
})
status, _, _ = self.client.request(
"PUT",
"/api/admin/settings/calculation-start",
body=json.dumps(
{"calculation_start_date": "2026-01-01", "reason": "越权"}
).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
self.assertEqual(403, status)
def test_admin_can_set_start_date(self) -> None:
client = Client("127.0.0.1", self.port)
client.post_json("/api/login", {
"username": "group-admin", "password": "AdminPass123", "portal": "admin",
})
status, _, body = client.request(
"PUT",
"/api/admin/settings/calculation-start",
body=json.dumps(
{"calculation_start_date": "2026-01-01", "reason": "初始化"}
).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
self.assertEqual(200, status)
data = as_json(body)
self.assertEqual("2026-01-01", data["calculation_start_date"])
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
self.assertIn("app.js?v=12", html)
self.assertIn("app.js?v=13", html)
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
+2 -2
View File
@@ -32,7 +32,7 @@ class TransfersPageSourceContractTests(unittest.TestCase):
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
self.assertIn("design-system.css?v=6", html)
self.assertIn("app.js?v=12", html)
self.assertIn("app.js?v=13", html)
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
flows = html.index('data-view="flows"')
transfers = html.index('data-view="transfers"')
@@ -113,7 +113,7 @@ class TransfersPageLayoutSmokeTests(unittest.TestCase):
for width in (360, 820, 1440):
page.set_viewport_size({"width": width, "height": 900})
page.set_content(
html.replace('src="app.js?v=12"', 'src=""'),
html.replace('src="app.js?v=13"', 'src=""'),
base_url=self.base,
)
page.evaluate(
+578
View File
@@ -0,0 +1,578 @@
"""HEL-203: 真实浏览器冒烟——原因弹窗、首屏断档、表单 reset。"""
from __future__ import annotations
import os
import re
import tempfile
import threading
import unittest
from pathlib import Path
from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now
import server
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "CashierA123"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
"""本机缺系统 atk 时,复用仓库旁的本地 chromium 依赖目录。"""
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
class Hel203SourceContractTests(unittest.TestCase):
"""不依赖浏览器:锁住 N1~N3 的源码契约。"""
def test_open_modal_is_single_top_level(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
defs = list(re.finditer(r"(?m)^function openModal\(", js))
self.assertEqual(1, len(defs), "openModal 必须只有一处顶层定义")
# 不得再出现在 initAdmin / initCompany 函数体内的局部副本
self.assertNotRegex(
js,
r"function initAdmin\(\)[\s\S]*?function openModal\(",
)
self.assertNotRegex(
js,
r"function initCompany\(\)[\s\S]*?function openModal\(",
)
self.assertIn("function askReason(", js)
ask_pos = js.index("function askReason(")
open_pos = defs[0].start()
self.assertLess(open_pos, ask_pos, "openModal 须在 askReason 之前定义")
def test_init_company_boots_coverage_gaps(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
company_fn = js[js.index("function initCompany(") :]
boot = company_fn[: company_fn.index("\nif (portal ===")]
self.assertIn("await loadCompanyWorkspace()", boot)
self.assertIn("await loadCompanyCoverageGaps()", boot)
def test_async_forms_capture_form_before_await(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertNotIn("event.currentTarget.reset()", js)
for marker in ("#companyForm", "#openingForm", "#accountForm"):
idx = js.index(marker)
chunk = js[idx : idx + 2500]
self.assertIn("const form = event.currentTarget", chunk)
self.assertIn("form.reset()", chunk)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
class Hel203BrowserSmokeTests(unittest.TestCase):
"""真实 Chromium:改起算日→期初→公司端期末;断档首屏→说明→审核。"""
@classmethod
def setUpClass(cls) -> None:
_prepare_chrome_libs()
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls.storage.mkdir()
cls._old_db = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection,
"group-admin",
ADMIN_PASSWORD,
"admin",
must_change_password=False,
)
cls.company_a = master_data.create_company(
connection, "甲公司", None, None, None
)
cls.company_b = master_data.create_company(
connection, "乙公司", None, None, None
)
auth.create_user(
connection,
"cashier-a",
CASHIER_PASSWORD,
"company",
cls.company_a,
must_change_password=False,
)
auth.create_user(
connection,
"cashier-b",
CASHIER_PASSWORD,
"company",
cls.company_b,
must_change_password=False,
)
admin = connection.execute(
"SELECT * FROM users WHERE username = 'group-admin'"
).fetchone()
account = master_data.submit_bank_account(
connection,
company_id=cls.company_a,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000001",
start_date="2026-06-01",
actor=None,
)
cls.account_a = master_data.review_bank_account(
connection,
account["id"],
"approve",
None,
admin,
effective_from="2026-06-01",
)
account_b = master_data.submit_bank_account(
connection,
company_id=cls.company_b,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000002",
start_date="2026-06-01",
actor=None,
)
cls.account_b = master_data.review_bank_account(
connection,
account_b["id"],
"approve",
None,
admin,
effective_from="2026-06-01",
)
# 制造一处 mid 断档,供公司端首屏提醒
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'gap.xlsx', 1, 'data/files/gap.xlsx', ?)
""",
("sha-hel203-gap", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(
source_file_id,
cls.company_a,
cls.account_a["id"],
utc_now(),
utc_now(),
),
)
batch_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id,
template_version, header_row, transaction_count, warnings,
created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, created_at
) VALUES (?, 1, '2026-06-21T10:00:00', '0', '0', ?, ?)
""",
(sheet_batch_id, "6222000000000001", utc_now()),
)
calculation.set_calculation_start_date(
connection, "2026-06-01", "初始化起算", admin
)
calculation.recalculate_coverage_gaps(connection)
# 种一笔已确认往来,确认期初后公司端才能进入完整期末口径
def _add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
""",
(ref, utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id,
template_version, header_row, transaction_count, warnings,
created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
cursor = connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, own_name, counterparty_account, counterparty_name,
summary, purpose, currency, created_at
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
""",
(
sheet_batch_id,
at,
income,
expense,
own,
cp,
utc_now(),
),
)
return int(cursor.lastrowid)
row_a = _add_row(
cls.company_a,
cls.account_a["id"],
"6222000000000001",
"6222000000000002",
income="0",
expense="100.00",
at="2026-06-20T10:00:00",
ref="sha-hel203-a",
)
row_b = _add_row(
cls.company_b,
cls.account_b["id"],
"6222000000000002",
"6222000000000001",
income="100.00",
expense="0",
at="2026-06-20T11:00:00",
ref="sha-hel203-b",
)
matching.reconcile_rows(connection, [row_a, row_b])
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db
server.STORAGE_DIR = cls._old_storage
cls.temp_dir.cleanup()
def _new_page(self, playwright):
browser = playwright.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
context = browser.new_context(viewport={"width": 1440, "height": 900})
page = context.new_page()
errors: list[str] = []
page.on("pageerror", lambda err: errors.append(str(err)))
page.on(
"console",
lambda msg: errors.append(f"console.{msg.type}: {msg.text}")
if msg.type == "error"
else None,
)
return browser, page, errors
def _login(self, page, *, portal: str, username: str, password: str) -> None:
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
page.fill("#account", username)
page.fill("#password", password)
page.click('button[type="submit"]')
expect = "admin.html" if portal == "admin" else "company.html"
page.wait_for_url(f"**/{expect}", timeout=15000)
def test_02_admin_start_date_opening_company_ending(self) -> None:
with sync_playwright() as p:
browser, page, errors = self._new_page(p)
try:
self._login(
page,
portal="admin",
username="group-admin",
password=ADMIN_PASSWORD,
)
page.click('a[data-view="settings"]')
page.wait_for_selector("#cs-start", state="visible")
# 修改起算日 → 原因弹窗必须打开且发出 PUT
page.fill("#cs-start", "2026-06-15")
with page.expect_request(
lambda req: req.method == "PUT"
and "/api/admin/settings/calculation-start" in req.url
) as start_req:
page.click('#systemSettings button[type="submit"]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "调整起算日供冒烟")
page.click("#reasonSubmit")
self.assertTrue(start_req.value.post_data)
page.wait_for_function(
"() => document.getElementById('cs-start')?.value === '2026-06-15'"
)
# 创建期初并确认
page.click("#openOpeningDialog")
page.wait_for_selector("#openingDialog.open")
page.select_option("#ob-from", label="甲公司")
page.select_option("#ob-to", label="乙公司")
page.fill("#ob-amount", "200")
page.fill("#ob-reason", "冒烟期初录入")
with page.expect_request(
lambda req: req.method == "POST"
and req.url.endswith("/api/admin/opening-balances")
):
page.click('#openingForm button[type="submit"]')
page.wait_for_selector(
'#openingRows button[data-confirm-opening]',
timeout=8000,
)
with page.expect_request(
lambda req: req.method == "POST"
and "/opening-balances/" in req.url
and req.url.endswith("/confirm")
):
page.click('#openingRows button[data-confirm-opening]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "确认期初冒烟")
page.click("#reasonSubmit")
page.wait_for_selector(
'#openingRows button[data-void-opening]',
timeout=8000,
)
# 甲公司端:期初 200 + 转出 100 → 期末 300(应收),与净额卡方向一致
self._login(
page,
portal="company",
username="cashier-a",
password=CASHIER_PASSWORD,
)
page.click('a[data-view="transfers"]')
page.wait_for_function(
"""() => {
const data = document.getElementById('transfersData');
const card = document.getElementById('tfStatEndingCard');
return data && !data.hidden && card && !card.hidden;
}""",
timeout=15000,
)
summary_a = page.evaluate(
"""async () => {
const r = await fetch('/api/company/intercompany/summary');
return r.json();
}"""
)
self.assertEqual("ok", summary_a.get("status"))
self.assertTrue(summary_a["window"]["has_opening"])
self.assertEqual("200.00", summary_a["window"]["opening"])
self.assertEqual("300.00", summary_a["window"]["ending"])
self.assertEqual("100.00", summary_a["confirmed"]["net_change"])
self.assertEqual("receivable", summary_a["confirmed"]["net_direction"])
opening_text = page.locator("#tfStatOpening").inner_text()
ending_text = page.locator("#tfStatEnding").inner_text()
net_text = page.locator("#tfStatNet").inner_text()
self.assertIn("0.02", opening_text)
self.assertIn("0.03", ending_text)
self.assertIn("0.01", net_text)
self.assertIn("应收", net_text)
# 页面不得出现与「正数=应收」矛盾的应付标签套在正净额上
self.assertNotRegex(net_text, r"\+.*应付")
# 乙公司端:期初 −200 + 转入 100 → 期末 −300(应付)
self._login(
page,
portal="company",
username="cashier-b",
password=CASHIER_PASSWORD,
)
page.click('a[data-view="transfers"]')
page.wait_for_function(
"""() => {
const data = document.getElementById('transfersData');
const card = document.getElementById('tfStatEndingCard');
return data && !data.hidden && card && !card.hidden;
}""",
timeout=15000,
)
summary_b = page.evaluate(
"""async () => {
const r = await fetch('/api/company/intercompany/summary');
return r.json();
}"""
)
self.assertEqual("ok", summary_b.get("status"))
self.assertEqual("-200.00", summary_b["window"]["opening"])
self.assertEqual("-300.00", summary_b["window"]["ending"])
self.assertEqual("-100.00", summary_b["confirmed"]["net_change"])
self.assertEqual("payable", summary_b["confirmed"]["net_direction"])
ending_b = page.locator("#tfStatEnding").inner_text()
net_b = page.locator("#tfStatNet").inner_text()
self.assertIn("0.03", ending_b)
self.assertIn("应付", net_b)
fatal = [e for e in errors if "openModal is not defined" in e
or "Cannot read properties of null" in e
or e.startswith("pageerror")
or "pageerror" in e.lower()]
# page.on('pageerror') 已直接 push 字符串,过滤明显运行时错误
runtime = [e for e in errors if not e.startswith("console.")]
self.assertEqual([], runtime, runtime)
self.assertEqual([], fatal, fatal)
finally:
browser.close()
def test_01_company_gap_notice_then_admin_approve(self) -> None:
with sync_playwright() as p:
browser, page, errors = self._new_page(p)
try:
self._login(
page,
portal="company",
username="cashier-a",
password=CASHIER_PASSWORD,
)
# 首屏即可见断档提醒,无需手动刷新
page.wait_for_selector(
"#companyCoverageNotice",
state="visible",
timeout=10000,
)
body = page.locator("#companyCoverageBody").inner_text()
self.assertTrue(body.strip())
self.assertNotIn("0002", body)
page.click("#openAttestationFromWorkspace")
page.wait_for_selector("#attestationDialog.open", timeout=5000)
page.fill("#att-reason", "节假日账户无资金往来")
with page.expect_request(
lambda req: req.method == "POST"
and req.url.endswith("/api/company/no-business-attestations")
) as att_req:
page.click('#attestationForm button[type="submit"]')
self.assertTrue(att_req.value.post_data)
page.wait_for_function(
"""() => !document.getElementById('attestationDialog')?.classList.contains('open')""",
timeout=8000,
)
self._login(
page,
portal="admin",
username="group-admin",
password=ADMIN_PASSWORD,
)
page.click('a[data-view="audit"]')
page.wait_for_selector(
'button[data-audit-action="approve-attestation"]',
timeout=10000,
)
with page.expect_request(
lambda req: req.method == "POST"
and "/no-business-attestations/" in req.url
and req.url.endswith("/review")
):
page.click('button[data-audit-action="approve-attestation"]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "审核通过说明")
page.click("#reasonSubmit")
page.wait_for_function(
"""() => !document.querySelector(
'button[data-audit-action=\"approve-attestation\"]'
)""",
timeout=10000,
)
fatal = [e for e in errors if "openModal is not defined" in e]
self.assertEqual([], fatal, fatal)
finally:
browser.close()
if __name__ == "__main__":
unittest.main()
+9 -5
View File
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
class MigrationTests(PersistenceTestCase):
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
first = applied_versions(self.connection)
self.assertEqual([1, 2, 3, 4, 5, 6, 7], first)
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -85,19 +85,23 @@ class MigrationTests(PersistenceTestCase):
"system_settings",
"system_setting_changes",
"reminders",
"closed_periods",
"opening_balance_revisions",
"coverage_gaps",
"no_business_attestations",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([8, 7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone()
self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -105,7 +109,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([7, 6, 5], rollback(self.connection, 4))
self.assertEqual([8, 7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
+47 -27
View File
@@ -512,8 +512,9 @@
<form id="systemSettings" novalidate>
<div class="field" style="margin-bottom: 14px;">
<label for="cs-start">全局起算日</label>
<input class="input num-input" type="date" name="startDate" id="cs-start" value="2026-01-01" />
<span class="hint">期初余额以此日前一日的期末数为准</span>
<input class="input num-input" type="date" name="startDate" id="cs-start" />
<span class="hint" id="cs-start-hint">期初余额以此日前一日的期末数为准</span>
<span class="pill pill-muted" id="cs-start-locked" style="display: none; margin-top: 8px;">已锁定</span>
</div>
<div class="field" style="margin-bottom: 14px;">
<label for="cs-day">每月结账日</label>
@@ -547,15 +548,30 @@
<div class="card">
<div class="card-head">
<span class="card-title">期初余额管理<span class="sub" id="openingSub">2026-01-01 起算的公司间往来期初数</span></span>
<span class="card-title">期初余额管理<span class="sub" id="openingSub">公司间往来期初数</span></span>
<button class="btn btn-sm" id="openOpeningDialog">新增</button>
</div>
<div class="notice info" id="openingSummary" style="margin: 0 0 14px; display: none;"></div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table">
<thead>
<tr><th>本方公司</th><th>对方公司</th><th>方向</th><th class="num-col">金额</th><th>录入人</th><th>录入时间</th><th>状态</th><th></th></tr>
</thead>
<tbody id="openingRows"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-head">
<span class="card-title">变更记录<span class="sub">起算日与期初余额改动留痕</span></span>
</div>
<div class="table-wrap" style="border: 0;">
<table class="ds-table">
<thead>
<tr><th>本方公司</th><th>对方公司</th><th>科目</th><th>方向</th><th class="num-col">金额(万元)</th><th>生效日</th><th>状态</th></tr>
<tr><th>时间</th><th>操作人</th><th>对象</th><th>变更</th><th>原因</th></tr>
</thead>
<tbody id="openingRows"></tbody>
<tbody id="calculationChangeRows"><tr><td colspan="5" class="empty">暂无变更记录</td></tr></tbody>
</table>
</div>
</div>
@@ -762,31 +778,15 @@
<select class="select" name="to" id="ob-to" required></select>
</div>
<div class="field">
<label for="ob-subject">科目</label>
<select class="select" name="subject" id="ob-subject" required>
<option>应收</option>
<option>应付</option>
<option>其他应收</option>
<option>其他应付</option>
</select>
<label for="ob-amount">金额</label>
<input class="input num-input" name="amount" id="ob-amount" type="number" step="0.01" required />
</div>
<div class="field">
<label for="ob-direction">方向</label>
<select class="select" name="direction" id="ob-direction" required>
<option>借方</option>
<option>贷方</option>
</select>
</div>
<div class="field">
<label for="ob-amount">金额(万元)</label>
<input class="input num-input" name="amount" id="ob-amount" type="number" min="0" step="0.01" required />
</div>
<div class="field">
<label for="ob-effective">生效日</label>
<input class="input" name="effectiveDate" id="ob-effective" type="date" value="2026-01-01" required />
<label for="ob-reason">录入原因</label>
<input class="input" name="reason" id="ob-reason" required placeholder="必填,将写入变更记录" />
</div>
</div>
<p class="hint" style="margin-top: 10px;">提交后进入复核,不能直接修改已确认期初</p>
<p class="hint" style="margin-top: 10px;">正数表示本方对对方为应收;提交后进入待确认,确认后才计入公司端余额</p>
<div class="modal-actions">
<button type="button" class="btn" data-close-opening>取消</button>
<button type="submit" class="btn btn-primary">提交复核</button>
@@ -795,6 +795,26 @@
</div>
</div>
<div class="modal-backdrop" id="reasonDialog">
<div class="modal">
<div class="modal-head">
<span class="modal-title" id="reasonDialogTitle">填写原因</span>
<button type="button" class="modal-close" data-close-reason aria-label="关闭">×</button>
</div>
<p class="modal-sub" id="reasonDialogSub">该操作必须填写原因并留痕。</p>
<form id="reasonForm" novalidate>
<div class="field">
<label for="reasonInput">原因</label>
<textarea class="input" id="reasonInput" name="reason" rows="3" required placeholder="至少 2 个字"></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn" data-close-reason>取消</button>
<button type="submit" class="btn btn-primary" id="reasonSubmit">确认</button>
</div>
</form>
</div>
</div>
<!-- 审核通过弹窗 -->
<div class="modal-backdrop" id="modal-approve">
<div class="modal">
@@ -942,6 +962,6 @@
</div>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=9"></script>
<script src="app.js?v=13"></script>
</body>
</html>
+505 -25
View File
@@ -18,6 +18,9 @@ const accountStatusLabels = {
};
const state = {
coverageGaps: [],
companyCoverageGaps: [],
companies: [],
currentView: portal === "admin" ? "dashboard" : "workspace",
selectedFile: null,
parseResult: null,
@@ -1145,6 +1148,330 @@ function fillCompanySelects(names) {
if ($("#ql-self") && names.length > 1) $("#ql-self").value = names[names.length - 1];
}
function openingStatusPill(status) {
if (status === "confirmed") return { cls: "pill-success", label: "已确认" };
if (status === "void") return { cls: "pill-danger", label: "已作废" };
if (status === "superseded") return { cls: "pill-muted", label: "已替代" };
return { cls: "pill-warn", label: "待确认" };
}
function formatMoneyYuan(value) {
const num = Number(value);
if (Number.isNaN(num)) return "—";
return num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function humanizeChangeValue(value) {
if (value == null) return "—";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (typeof value === "object") {
const entries = Object.entries(value);
if (!entries.length) return "—";
return entries.map(([k, v]) => `${k}=${v == null ? "空" : v}`).join("");
}
return String(value);
}
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
function askReason({ title, subtitle, confirmLabel } = {}) {
return new Promise((resolve) => {
const dialog = $("#reasonDialog");
const form = $("#reasonForm");
const input = $("#reasonInput");
if (!dialog || !form || !input) {
resolve(null);
return;
}
const titleEl = $("#reasonDialogTitle");
const subEl = $("#reasonDialogSub");
const submitBtn = $("#reasonSubmit");
if (titleEl && title) titleEl.textContent = title;
if (subEl && subtitle) subEl.textContent = subtitle;
if (submitBtn && confirmLabel) submitBtn.textContent = confirmLabel;
input.value = "";
const cleanup = () => {
form.removeEventListener("submit", onSubmit);
$$("[data-close-reason]").forEach((btn) => btn.removeEventListener("click", onCancel));
closeModal("reasonDialog");
};
const onCancel = () => { cleanup(); resolve(null); };
const onSubmit = (event) => {
event.preventDefault();
const reason = String(input.value || "").trim();
if (reason.length < 2) {
showToast("原因过短", "请至少填写 2 个字", "warn");
return;
}
cleanup();
resolve(reason);
};
form.addEventListener("submit", onSubmit);
$$("[data-close-reason]").forEach((btn) => btn.addEventListener("click", onCancel));
openModal("reasonDialog");
input.focus();
});
}
async function loadCalculationSettings() {
const response = await fetch("/api/admin/settings/calculation-start").catch(() => null);
if (!response?.ok) return null;
const data = await response.json().catch(() => null);
if (!data) return null;
const startInput = $("#cs-start");
const hint = $("#cs-start-hint");
const locked = $("#cs-start-locked");
if (startInput) {
startInput.value = data.calculation_start_date || "";
startInput.disabled = Boolean(data.locked);
startInput.dataset.locked = data.locked ? "1" : "0";
startInput.dataset.current = data.calculation_start_date || "";
}
if (hint) {
if (!data.calculation_start_date) {
hint.textContent = "未设置起算日,系统暂按期间净变动口径显示";
hint.style.color = "var(--warn)";
} else {
hint.textContent = "期初余额以此日前一日的期末数为准";
hint.style.color = "";
}
}
if (locked) locked.style.display = data.locked ? "" : "none";
const subtitle = $("#openingSub");
if (subtitle && data.calculation_start_date) {
subtitle.textContent = `${data.calculation_start_date} 起算的公司间往来期初数`;
}
const summary = $("#openingSummary");
if (summary && data.summary) {
summary.style.display = "";
summary.className = "notice info";
summary.replaceChildren();
const wrap = document.createElement("div");
const title = document.createElement("div");
title.className = "n-title";
title.textContent = `${data.summary.company_count} 家公司 · 已确认 ${data.summary.confirmed_pair_count} 对公司对期初`;
wrap.append(title);
summary.append(wrap);
}
return data;
}
function appendEmptyRow(tbody, cols, text) {
tbody.replaceChildren();
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = cols;
td.className = "empty";
td.textContent = text;
tr.append(td);
tbody.append(tr);
}
async function loadOpeningBalances() {
const tbody = $("#openingRows");
if (!tbody) return;
const response = await fetch("/api/admin/opening-balances").catch(() => null);
if (!response?.ok) {
appendEmptyRow(tbody, 8, "加载失败");
return;
}
const data = await response.json().catch(() => null);
const items = data?.items || [];
if (!items.length) {
appendEmptyRow(tbody, 8, "暂无期初记录");
return;
}
tbody.replaceChildren();
items.forEach((item) => {
const pill = openingStatusPill(item.status);
const amount = Number(item.amount);
const direction = amount >= 0 ? "应收" : "应付";
const tr = document.createElement("tr");
if (item.status === "void") tr.style.textDecoration = "line-through";
tr.dataset.openingId = String(item.id);
const cells = [
["td", "cell-main", item.company_low_name || "—"],
["td", "", item.company_high_name || "—"],
["td", "", direction],
["td", "num-col", formatMoneyYuan(Math.abs(amount))],
["td", "", item.actor_username || "—"],
["td", "num", (item.created_at || "").slice(0, 10)],
];
cells.forEach(([tag, cls, text]) => {
const td = document.createElement(tag);
if (cls) td.className = cls;
if (cls === "" && text === direction) {
td.style.color = amount >= 0 ? "var(--success)" : "var(--danger)";
}
td.textContent = text;
tr.append(td);
});
const statusTd = document.createElement("td");
const span = document.createElement("span");
span.className = `pill ${pill.cls}`;
span.textContent = pill.label;
statusTd.append(span);
tr.append(statusTd);
const actionTd = document.createElement("td");
if (item.status === "draft") {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm";
btn.dataset.confirmOpening = String(item.id);
btn.textContent = "确认";
actionTd.append(btn);
} else if (item.status === "confirmed") {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm btn-ghost btn-danger";
btn.dataset.voidOpening = String(item.id);
btn.textContent = "作废";
actionTd.append(btn);
}
tr.append(actionTd);
tbody.append(tr);
});
}
async function loadCalculationChanges() {
const tbody = $("#calculationChangeRows");
if (!tbody) return;
const response = await fetch("/api/admin/calculation-changes").catch(() => null);
if (!response?.ok) return;
const data = await response.json().catch(() => null);
const items = data?.items || [];
if (!items.length) {
appendEmptyRow(tbody, 5, "暂无变更记录");
return;
}
tbody.replaceChildren();
items.forEach((item) => {
const tr = document.createElement("tr");
const values = [
(item.created_at || "").replace("T", " ").slice(0, 16),
item.actor_username || "—",
item.target || "—",
`${humanizeChangeValue(item.before)}${humanizeChangeValue(item.after)}`,
item.reason || "—",
];
values.forEach((text, index) => {
const td = document.createElement("td");
if (index === 0) td.className = "num";
if (index >= 3) td.className = "wrap";
td.textContent = text;
tr.append(td);
});
tbody.append(tr);
});
}
function appendAuditGapRows(gaps) {
const tbody = $("#auditRows");
if (!tbody) return;
$$('#auditRows tr[data-audit-type="断档"]').forEach((row) => row.remove());
(gaps || []).forEach((gap) => {
const tr = document.createElement("tr");
tr.dataset.auditType = "断档";
tr.dataset.gapId = String(gap.id || "");
tr.dataset.attestationId = gap.pending_attestation_id ? String(gap.pending_attestation_id) : "";
tr.dataset.accountId = String(gap.bank_account_id || "");
tr.dataset.gapStart = gap.gap_start || "";
tr.dataset.gapEnd = gap.gap_end || "";
const risk = document.createElement("td");
risk.innerHTML = '<span class="pill pill-danger">高</span>';
const company = document.createElement("td");
company.className = "cell-main";
company.textContent = `${gap.company_name || "—"} · ${gap.account_number_masked || gap.bank_account_id}`;
const type = document.createElement("td");
type.textContent = "流水断档";
const period = document.createElement("td");
period.className = "num";
period.textContent = `${gap.gap_start || "—"}${gap.gap_end || "—"}`;
const impact = document.createElement("td");
impact.className = "wrap";
impact.textContent = `${gap.gap_kind || "gap"} · ${gap.day_count || "?"}`;
const status = document.createElement("td");
const statusPill = document.createElement("span");
statusPill.className = "pill pill-warn";
statusPill.textContent = gap.pending_attestation_id ? "待审说明" : "待补传";
status.append(statusPill);
const action = document.createElement("td");
if (gap.pending_attestation_id) {
action.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve-attestation">通过说明</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject-attestation">驳回说明</button></div>';
} else {
action.innerHTML = '<button type="button" class="btn btn-sm" data-view-link="reminders">去提醒</button>';
}
tr.append(risk, company, type, period, impact, status, action);
tbody.prepend(tr);
});
}
async function loadAdminCoverageGaps() {
const response = await fetch("/api/admin/coverage-gaps?status=open").catch(() => null);
if (!response?.ok) return;
const data = await response.json().catch(() => null);
state.coverageGaps = data?.items || [];
appendAuditGapRows(state.coverageGaps);
updateAuditCounts();
const badge = $('.side-nav a[data-view="audit"] .nav-badge');
if (badge) {
const n = state.coverageGaps.length || 0;
badge.textContent = String(n || "");
badge.hidden = n <= 0;
}
}
async function loadCompanyCoverageGaps() {
const response = await fetch("/api/company/coverage-gaps").catch(() => null);
if (!response?.ok) return [];
const data = await response.json().catch(() => null);
const items = (data?.items || []).filter((item) => item.status === "open");
state.companyCoverageGaps = items;
const renderNotice = (rootId, bodyId) => {
const root = $(rootId);
const body = $(bodyId);
if (!root || !body) return;
if (!items.length) {
root.style.display = "none";
return;
}
root.style.display = "";
body.textContent = items.slice(0, 3).map((g) => {
const acct = g.account_number_masked || g.bank_account_id;
return `${acct}${g.gap_start}${g.gap_end}${g.day_count || "?"}天)`;
}).join("") + (items.length > 3 ? `${items.length}` : "");
};
renderNotice("#companyCoverageNotice", "#companyCoverageBody");
renderNotice("#flowsCoverageNotice", "#flowsCoverageBody");
return items;
}
function openAttestationDialog(gap) {
if (!gap) {
const gaps = state.companyCoverageGaps || [];
gap = gaps[0];
}
if (!gap) {
showToast("暂无断档", "当前没有可说明的断档区间", "warn");
return;
}
$("#att-account-id").value = gap.bank_account_id || "";
$("#att-gap-start").value = gap.gap_start || "";
$("#att-gap-end").value = gap.gap_end || "";
const label = $("#att-gap-label");
if (label) {
label.textContent = `${gap.account_number_masked || gap.bank_account_id} · ${gap.gap_start}${gap.gap_end}`;
}
$("#att-reason").value = "";
$("#att-evidence").value = "";
openModal("attestationDialog");
}
function formatWan(value) {
const num = Number(value);
if (!Number.isFinite(num)) return "0.00";
@@ -1458,6 +1785,7 @@ async function loadAdminCompanies() {
}
const result = await response.json().catch(() => null);
const companies = result?.companies || [];
state.companies = companies;
renderAdminCompanyTable(companies);
fillCompanySelects(companies.map((company) => company.name));
if (companies.length >= 2) setPair(companies[0].name, companies[1].name);
@@ -1467,6 +1795,10 @@ function initAdmin() {
renderStoredAdminReviews();
updateAuditCounts();
loadAdminCompanies();
loadCalculationSettings();
loadOpeningBalances();
loadCalculationChanges();
loadAdminCoverageGaps();
initDashboard();
let activeAuditType = "all";
@@ -1489,8 +1821,6 @@ function initAdmin() {
}));
$("#auditCompany")?.addEventListener("change", filterAuditRows);
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
@@ -1590,9 +1920,34 @@ function initAdmin() {
);
}
$("#auditRows")?.addEventListener("click", (event) => {
$("#auditRows")?.addEventListener("click", async (event) => {
const button = event.target.closest("[data-audit-action]");
if (!button) return;
if (button.dataset.auditAction === "approve-attestation" || button.dataset.auditAction === "reject-attestation") {
const row = button.closest("tr");
const attestationId = row?.dataset.attestationId;
if (!attestationId) return;
const approve = button.dataset.auditAction === "approve-attestation";
const reason = await askReason({
title: approve ? "通过无业务说明" : "驳回无业务说明",
subtitle: "审核结论将写入留痕;通过后仅关闭断档,不生成银行行。",
confirmLabel: approve ? "通过" : "驳回",
});
if (!reason) return;
const response = await fetch(`/api/admin/no-business-attestations/${attestationId}/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: approve ? "approve" : "reject", review_reason: reason }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("审核失败", result?.message || "请稍后重试", "danger");
return;
}
await loadAdminCoverageGaps();
showToast(approve ? "已通过说明" : "已驳回说明", "断档状态已更新", "success");
return;
}
const row = button.closest("tr");
state.auditRow = row;
const cells = $$("td", row);
@@ -1649,7 +2004,8 @@ function initAdmin() {
$("#openCompanyDialog")?.addEventListener("click", () => openModal("companyDialog"));
$("#companyForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const form = event.currentTarget;
const data = new FormData(form);
const companyName = String(data.get("companyName") || "").trim();
const loginName = String(data.get("loginName") || "").trim();
const createUser = data.get("createUser") !== null;
@@ -1677,7 +2033,7 @@ function initAdmin() {
}
const accountCreated = Boolean(result.username);
closeModal("companyDialog");
event.currentTarget.reset();
form.reset();
await loadAdminCompanies();
showToast(
accountCreated ? "公司与账号已创建" : "公司已创建",
@@ -1783,12 +2139,42 @@ function initAdmin() {
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; }
return;
}
const startInput = $("#cs-start");
const nextStart = startInput?.value || "";
const prevStart = startInput?.dataset.current || "";
const locked = startInput?.dataset.locked === "1";
if (nextStart !== prevStart) {
if (locked) {
showToast("起算日已锁定", "已有结账月份,起算日不可修改", "warn");
return;
}
const reason = await askReason({
title: "修改起算日",
subtitle: "修改起算日必须填写原因,并写入变更留痕。",
confirmLabel: "确认修改",
});
if (!reason) return;
const calcResp = await fetch("/api/admin/settings/calculation-start", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ calculation_start_date: nextStart, reason }),
}).catch(() => null);
if (calcResp?.status === 401) { window.location.href = "index.html"; return; }
const calcResult = await calcResp?.json().catch(() => ({}));
if (!calcResp?.ok) {
showToast("起算日保存失败", calcResult?.message || "请稍后重试", "danger");
return;
}
await loadCalculationSettings();
await loadCalculationChanges();
}
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",
};
// Keep display start_date in settings store in sync when calculation start exists.
if (nextStart) payload.start_date = nextStart;
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1807,7 +2193,8 @@ function initAdmin() {
}
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
applySystemSettings(result.settings || {});
showToast("系统计算口径已保存", "修改前后值与操作人已留痕", "success");
await loadCalculationSettings();
showToast("系统计算口径已保存", "结账日与起算日变更已留痕", "success");
});
$("#runClosingCheck")?.addEventListener("click", () => {
@@ -1859,25 +2246,77 @@ function initAdmin() {
$("#openOpeningDialog")?.addEventListener("click", () => openModal("openingDialog"));
$$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog")));
$("#openingForm")?.addEventListener("submit", (event) => {
$("#openingForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
if (data.get("from") === data.get("to")) {
const form = event.currentTarget;
const data = new FormData(form);
const fromName = data.get("from");
const toName = data.get("to");
if (fromName === toName) {
showToast("本方与对方不能相同", "同公司账户余额不属于公司间期初", "warn");
return;
}
const row = document.createElement("tr");
const self = document.createElement("td"); self.className = "cell-main"; self.textContent = data.get("from"); row.append(self);
const peer = document.createElement("td"); peer.textContent = data.get("to"); row.append(peer);
const subject = document.createElement("td"); subject.textContent = data.get("subject"); row.append(subject);
const direction = document.createElement("td"); direction.textContent = data.get("direction"); row.append(direction);
const amount = document.createElement("td"); amount.className = "num-col"; amount.textContent = Number(data.get("amount")).toLocaleString("zh-CN", { minimumFractionDigits: 2 }); row.append(amount);
const date = document.createElement("td"); date.className = "num"; date.textContent = data.get("effectiveDate"); row.append(date);
const status = document.createElement("td"); status.innerHTML = '<span class="pill pill-warn">待复核</span>'; row.append(status);
$("#openingRows").append(row);
const fromCompany = (state.companies || []).find((c) => c.name === fromName);
const toCompany = (state.companies || []).find((c) => c.name === toName);
if (!fromCompany || !toCompany) {
showToast("公司无效", "请刷新页面后重试", "warn");
return;
}
const amount = Number(data.get("amount"));
if (Number.isNaN(amount)) {
showToast("金额无效", "请输入有效数字", "warn");
return;
}
const response = await fetch("/api/admin/opening-balances", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from_company_id: fromCompany.id,
to_company_id: toCompany.id,
amount: String(amount),
reason: String(data.get("reason") || ""),
}),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("提交失败", result?.message || "请稍后重试", "danger");
return;
}
closeModal("openingDialog");
event.currentTarget.reset();
showToast("期初余额已提交复核", "正式系统将保留录入依据与操作人", "success");
form.reset();
await loadOpeningBalances();
await loadCalculationChanges();
await loadCalculationSettings();
showToast("期初余额已提交", "待确认后才会计入公司端余额", "success");
});
$("#openingRows")?.addEventListener("click", async (event) => {
const confirmBtn = event.target.closest("[data-confirm-opening]");
const voidBtn = event.target.closest("[data-void-opening]");
const id = confirmBtn?.dataset.confirmOpening || voidBtn?.dataset.voidOpening;
if (!id) return;
const reason = await askReason({
title: confirmBtn ? "确认期初余额" : "作废期初余额",
subtitle: "该操作必须填写原因,并写入变更留痕。",
confirmLabel: confirmBtn ? "确认" : "作废",
});
if (!reason) return;
const path = confirmBtn
? `/api/admin/opening-balances/${id}/confirm`
: `/api/admin/opening-balances/${id}/void`;
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("操作失败", result?.message || "请稍后重试", "danger");
return;
}
await loadOpeningBalances();
await loadCalculationChanges();
showToast(confirmBtn ? "期初已确认" : "期初已作废", "变更已留痕", "success");
});
// ── 提醒管理:选公司 → 自动列出待提醒事项 → 一键发送 ──
@@ -3151,6 +3590,20 @@ function renderTransfersOverview(summary) {
const tfPendingAmount = $("#tfPendingAmount");
if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount}`;
if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWan(confirmed.net_change, { signed: true });
const openingCard = $("#tfStatOpeningCard");
const endingCard = $("#tfStatEndingCard");
const openingEl = $("#tfStatOpening");
const endingEl = $("#tfStatEnding");
if (win.has_opening) {
if (openingCard) openingCard.hidden = false;
if (endingCard) endingCard.hidden = false;
if (openingEl) openingEl.innerHTML = formatWan(win.opening, { signed: true });
if (endingEl) endingEl.innerHTML = formatWan(win.ending, { signed: true });
} else {
if (openingCard) openingCard.hidden = true;
if (endingCard) endingCard.hidden = true;
}
if (tfPendingCount) tfPendingCount.textContent = `${pCount}`;
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWan(pending.amount_total || 0);
@@ -3495,12 +3948,37 @@ function initTransfers() {
}
function initCompany() {
$("#openAttestationFromWorkspace")?.addEventListener("click", () => openAttestationDialog());
$("#openAttestationFromFlows")?.addEventListener("click", () => openAttestationDialog());
$$("[data-close-attestation]").forEach((btn) => btn.addEventListener("click", () => closeModal("attestationDialog")));
$("#attestationForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const payload = {
bank_account_id: Number($("#att-account-id")?.value || 0),
gap_start: $("#att-gap-start")?.value || "",
gap_end: $("#att-gap-end")?.value || "",
reason: $("#att-reason")?.value || "",
evidence: $("#att-evidence")?.value || "",
};
const response = await fetch("/api/company/no-business-attestations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("提交失败", result?.message || "请稍后重试", "danger");
return;
}
closeModal("attestationDialog");
await loadCompanyCoverageGaps();
showToast("已提交无业务说明", "等待管理员审核,通过后仅关闭断档提醒", "success");
});
renderCompanyManualRecords();
loadCompanyAccounts();
loadImportBatches();
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
$$("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
$$(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
@@ -3638,6 +4116,7 @@ function initCompany() {
initReconcile();
(async () => {
await loadCompanyWorkspace();
await loadCompanyCoverageGaps();
await renderReconcileMatchStack();
})();
@@ -3656,7 +4135,8 @@ function initCompany() {
});
$("#accountForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const form = event.currentTarget;
const data = new FormData(form);
const response = await fetch("/api/company/accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -3677,7 +4157,7 @@ function initCompany() {
return;
}
closeModal("accountDialog");
event.currentTarget.reset();
form.reset();
await loadCompanyAccounts();
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算", "success");
});
+63 -1
View File
@@ -98,6 +98,17 @@
<div class="grid grid-3-2" style="margin-top: 14px;">
<div class="stack">
<div class="notice danger" id="companyCoverageNotice" style="display: none; margin-bottom: 14px;">
<div>
<div class="n-title" id="companyCoverageTitle">流水断档提醒</div>
<div class="n-body" id="companyCoverageBody"></div>
</div>
<div class="row" style="margin-top: 10px; gap: 8px; flex-wrap: wrap;">
<button type="button" class="btn btn-sm btn-primary" data-view-link="upload">去上传</button>
<button type="button" class="btn btn-sm" id="openAttestationFromWorkspace">提交无业务说明</button>
</div>
</div>
<div class="card" id="workspaceTodos">
<div class="card-head">
<span class="card-title">本月待办<span class="sub" id="workspaceTodoSub">按权威待确认单边流水同步</span></span>
@@ -357,6 +368,16 @@
</section>
<section class="app-view" data-page="flows">
<div class="notice danger" id="flowsCoverageNotice" style="display: none; margin-bottom: 14px;">
<div>
<div class="n-title">流水断档提醒</div>
<div class="n-body" id="flowsCoverageBody"></div>
</div>
<div class="row" style="margin-top: 10px; gap: 8px;">
<button type="button" class="btn btn-sm btn-primary" data-view-link="upload">去上传</button>
<button type="button" class="btn btn-sm" id="openAttestationFromFlows">提交无业务说明</button>
</div>
</div>
<div class="page-head">
<div>
<h1>流水管理</h1>
@@ -472,6 +493,11 @@
<div id="transfersData" hidden>
<div class="grid grid-4" id="transfersStatGrid">
<div class="card stat-card" id="tfStatOpeningCard" hidden>
<div class="stat-label"><span class="stat-dot muted"></span>期初余额</div>
<div class="stat-value" id="tfStatOpening"></div>
<div class="stat-foot">起算日前一日结转</div>
</div>
<div class="card stat-card">
<div class="stat-label"><span class="stat-dot info"></span>往来公司数</div>
<div class="stat-value" id="tfStatCompanies"><span class="unit"></span></div>
@@ -492,6 +518,11 @@
<div class="stat-value" id="tfStatNet"></div>
<div class="stat-foot" id="tfStatNetFoot">正数=应收方向 · 负数=应付方向</div>
</div>
<div class="card stat-card" id="tfStatEndingCard" hidden>
<div class="stat-label"><span class="stat-dot success"></span>期末余额</div>
<div class="stat-value" id="tfStatEnding"></div>
<div class="stat-foot">期初 + 本期变动</div>
</div>
</div>
<div class="xfer-split" id="transfersSplit" style="margin-top: 14px;">
@@ -870,6 +901,37 @@
</div>
<!-- 导入批次详情弹窗 -->
<div class="modal-backdrop" id="attestationDialog">
<div class="modal">
<div class="modal-head">
<span class="modal-title">提交无业务说明</span>
<button type="button" class="modal-close" data-close-attestation aria-label="关闭">×</button>
</div>
<p class="modal-sub">说明经管理员审核通过后仅关闭断档提醒,不会生成银行流水。</p>
<form id="attestationForm" novalidate>
<input type="hidden" id="att-gap-start" name="gap_start" />
<input type="hidden" id="att-gap-end" name="gap_end" />
<input type="hidden" id="att-account-id" name="bank_account_id" />
<div class="field">
<label>断档区间</label>
<div class="meta num" id="att-gap-label"></div>
</div>
<div class="field">
<label for="att-reason">说明(至少 5 个字)</label>
<textarea class="input" id="att-reason" name="reason" rows="3" required></textarea>
</div>
<div class="field">
<label for="att-evidence">佐证(可选)</label>
<input class="input" id="att-evidence" name="evidence" placeholder="如:节假日无业务、账户停用说明编号" />
</div>
<div class="modal-actions">
<button type="button" class="btn" data-close-attestation>取消</button>
<button type="submit" class="btn btn-primary">提交审核</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop" id="modal-batch">
<div class="modal">
<div class="modal-head">
@@ -1046,6 +1108,6 @@
</aside>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js?v=12"></script>
<script src="app.js?v=13"></script>
</body>
</html>