HEL-202: 基于 b27016d 返工起算日/期初/断档并补齐公司端
以 deploy/hel178 为基线整合 HEL-200 内核:迁移改为 0008 复用 system_settings;修复确认批次 list>int、截止日当天漏算与持久化断言; 公司端余额完整/降级口径与断档说明、管理端审核界面接线。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
b27016d71d
commit
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:
|
||||
|
||||
Reference in New Issue
Block a user