HEL-200: 起算日、期初余额与流水断档检测

新增 0006 迁移与 calculation 模块,实现起算日设置、公司对期初 revision 链、
覆盖断档重算、无业务说明审核,以及完整/降级余额 API;管理端结账与期初页接入真实接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-08-27 15:58:21 +00:00
co-authored by Cursor multica-agent
parent 951b353765
commit 7826458892
7 changed files with 2221 additions and 48 deletions
+424 -1
View File
@@ -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: