diff --git a/server.py b/server.py index bf89568..354c0fc 100644 --- a/server.py +++ b/server.py @@ -10,7 +10,7 @@ from http.cookies import SimpleCookie from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse -from bank_importer import auth, importing, master_data, matching, multipart, personal_transit +from bank_importer import auth, calculation, importing, master_data, matching, multipart, personal_transit from bank_importer.db import connect, migrate, utc_now @@ -71,6 +71,30 @@ class AppHandler(SimpleHTTPRequestHandler): if path == "/api/admin/master-changes": self._handle_admin_master_changes(query) 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/company/accounts": self._handle_company_accounts() return @@ -167,6 +191,43 @@ class AppHandler(SimpleHTTPRequestHandler): if mapping_review: self._handle_admin_review_personal_mapping(int(mapping_review.group(1))) 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 + 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": "接口不存在。"}) # ------------------------------------------------------------------ @@ -664,6 +725,8 @@ class AppHandler(SimpleHTTPRequestHandler): } if outcome.get("matching") is not None: payload["matching"] = outcome["matching"] + if decision == "confirm" and outcome.get("updated", 0) > 0: + calculation.recalculate_coverage_gaps(connection) self._send_json(200, payload) finally: connection.close() @@ -1153,6 +1216,366 @@ class AppHandler(SimpleHTTPRequestHandler): finally: connection.close() + 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_users(self) -> None: connection = connect(DB_PATH) try: diff --git a/src/bank_importer/calculation.py b/src/bank_importer/calculation.py new file mode 100644 index 0000000..4043fbb --- /dev/null +++ b/src/bank_importer/calculation.py @@ -0,0 +1,913 @@ +"""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"]), + ) + 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) + if tail_start <= required_end: + kind = "tail" if last_end >= required_start else "head" + gaps.append((tail_start, 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 + 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, + "status": row["status"], + "first_detected_at": row["first_detected_at"], + } + + +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": + 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) + 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 d.effective_at <= ?" + params.append(cutoff) + start_where = "" + if start_date: + start_where = "AND d.effective_at >= ?" + 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 WHERE status = 'active'" + ).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} diff --git a/src/bank_importer/db.py b/src/bank_importer/db.py index b83a618..9739f13 100644 --- a/src/bank_importer/db.py +++ b/src/bank_importer/db.py @@ -569,6 +569,168 @@ MIGRATIONS: tuple[Migration, ...] = ( ALTER TABLE import_batches DROP COLUMN upload_bank_account_id; """, ), + Migration( + version=6, + name="0006_calculation_window", + up=""" + CREATE TABLE system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by INTEGER REFERENCES users (id) + ); + + 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 d.effective_at >= (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 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; + DROP TABLE IF EXISTS system_settings; + """, + ), ) diff --git a/tests/test_calculation.py b/tests/test_calculation.py new file mode 100644 index 0000000..9b8a8b6 --- /dev/null +++ b/tests/test_calculation.py @@ -0,0 +1,493 @@ +"""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"]) + + +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() diff --git a/tests/test_persistence.py b/tests/test_persistence.py index b23c6d5..918867f 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase): class MigrationTests(PersistenceTestCase): def test_migrate_creates_schema_and_is_idempotent(self) -> None: first = applied_versions(self.connection) - self.assertEqual([1, 2, 3, 4, 5], first) + self.assertEqual([1, 2, 3, 4, 5, 6], first) self.assertEqual([], migrate(self.connection)) self.assertEqual(first, applied_versions(self.connection)) tables = { @@ -73,19 +73,24 @@ class MigrationTests(PersistenceTestCase): "transfer_match_candidates", "current_transfer_decisions", "transfer_observation_claims", + "system_settings", + "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([5, 4, 3, 2, 1], rollback(self.connection, 0)) + self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0)) self.assertEqual([], applied_versions(self.connection)) remaining = self.connection.execute( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'" ).fetchone() self.assertIsNone(remaining) - self.assertEqual([1, 2, 3, 4, 5], migrate(self.connection)) - self.assertEqual([1, 2, 3, 4, 5], applied_versions(self.connection)) + self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection)) + self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection)) def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None: self.import_sample() diff --git a/web/admin.html b/web/admin.html index 1aa72b4..73ab995 100644 --- a/web/admin.html +++ b/web/admin.html @@ -608,8 +608,9 @@
- - 期初余额以此日前一日的期末数为准 + + 期初余额以此日前一日的期末数为准 +
@@ -643,15 +644,30 @@
- 期初余额管理2026-01-01 起算的公司间往来期初数 + 期初余额管理公司间往来期初数
+ +
+ + + + + +
本方公司对方公司方向金额录入人录入时间状态
+
+
+ +
+
+ 变更记录起算日与期初余额改动留痕 +
- + - +
本方公司对方公司科目方向金额(万元)生效日状态
时间操作人对象变更原因
暂无变更记录
@@ -870,31 +886,15 @@
- - -
-
- - -
-
- +
- - + +
-

提交后进入复核,不能直接修改已确认期初。

+

正数表示本方对对方为应收;提交后进入待确认,确认后才计入公司端余额。