Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fa32e1571 | ||
|
|
27f2b0b69a |
@@ -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:
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
"""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()
|
||||
total = Decimal("0")
|
||||
for row in rows:
|
||||
amount = _parse_decimal(row["amount"])
|
||||
if row["payee_id"] == viewer_id:
|
||||
total += amount
|
||||
elif row["payer_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,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),
|
||||
|
||||
@@ -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;
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
"""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"
|
||||
)
|
||||
self.assertEqual("-80.00", balance["net_change"])
|
||||
self.assertEqual("-80.00", balance["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,
|
||||
)
|
||||
company_id = master_data.create_company(connection, "甲公司", None, None, None)
|
||||
auth.create_user(
|
||||
connection, "cashier-a", "CashierA123", "company", company_id,
|
||||
must_change_password=False,
|
||||
)
|
||||
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 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()
|
||||
@@ -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,
|
||||
|
||||
+46
-26
@@ -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">
|
||||
|
||||
+505
-25
@@ -1,6 +1,9 @@
|
||||
const $ = (selector, scope = document) => scope.querySelector(selector);
|
||||
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
||||
|
||||
function openModal(id) { $("#" + id)?.classList.add("open"); }
|
||||
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
|
||||
|
||||
const portal = document.body.dataset.portal || "entry";
|
||||
const viewNames = portal === "admin"
|
||||
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" }
|
||||
@@ -18,6 +21,9 @@ const accountStatusLabels = {
|
||||
};
|
||||
|
||||
const state = {
|
||||
coverageGaps: [],
|
||||
companyCoverageGaps: [],
|
||||
companies: [],
|
||||
currentView: portal === "admin" ? "dashboard" : "workspace",
|
||||
selectedFile: null,
|
||||
parseResult: null,
|
||||
@@ -1145,6 +1151,327 @@ 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 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");
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user