HEL-269: 完成月结锁账、撤演示数据与代码收尾

锁账后写保护与闭期补录留痕;重开须审批并按版本链再结;列表/导出改走真实 API。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-08-30 21:42:22 +08:00
co-authored by Cursor multica-agent
parent f5e0915f63
commit e5e326514d
26 changed files with 3455 additions and 487 deletions
+362 -7
View File
@@ -14,9 +14,9 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import (
auth, calculation, company_transfers, dashboard, importing, ledger_events,
manual_records, master_data, matching, multipart, personal_transit, positions,
reminders, settings, subjects,
auth, calculation, company_transfers, dashboard, flows, importing, ledger_events,
manual_records, master_data, matching, multipart, period_close, personal_transit,
positions, reminders, settings, subjects,
)
from bank_importer.db import connect, migrate, utc_now
@@ -62,6 +62,9 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/export.csv":
self._handle_export_csv(query)
return
if path == "/api/flows":
self._handle_flows(query)
return
if path == "/api/admin/companies":
self._handle_admin_companies()
return
@@ -108,6 +111,29 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/admin/audit-log":
self._handle_admin_audit_log(query)
return
if path == "/api/admin/period-closes":
self._handle_admin_period_closes()
return
period_close_one = re.fullmatch(r"/api/admin/period-closes/(\d{4}-\d{2})", path)
if period_close_one:
self._handle_admin_period_close_one(period_close_one.group(1))
return
period_report = re.fullmatch(
r"/api/admin/period-closes/(\d{4}-\d{2})/report.json", path
)
if period_report:
self._handle_admin_period_report(period_report.group(1))
return
if path == "/api/admin/period-reopens":
self._handle_admin_period_reopens(query)
return
period_reopen_one = re.fullmatch(r"/api/admin/period-reopens/(\d+)", path)
if period_reopen_one:
self._handle_admin_period_reopen_one(int(period_reopen_one.group(1)))
return
if path == "/api/admin/period-audit":
self._handle_admin_period_audit(query)
return
if path == "/api/admin/settings":
self._handle_admin_settings()
return
@@ -384,6 +410,22 @@ class AppHandler(SimpleHTTPRequestHandler):
if path == "/api/admin/reminder-settings":
self._handle_admin_reminder_settings_put()
return
period_execute = re.fullmatch(
r"/api/admin/period-closes/(\d{4}-\d{2})/execute", path
)
if period_execute:
self._handle_admin_period_execute(period_execute.group(1))
return
period_reopen = re.fullmatch(
r"/api/admin/period-closes/(\d{4}-\d{2})/reopen", path
)
if period_reopen:
self._handle_admin_period_reopen_request(period_reopen.group(1))
return
period_decide = re.fullmatch(r"/api/admin/period-reopens/(\d+)/decide", path)
if period_decide:
self._handle_admin_period_reopen_decide(int(period_decide.group(1)))
return
company_status_match = re.fullmatch(
r"/api/company/reminders/(\d+)/(acknowledge|resolve)", path
)
@@ -1022,6 +1064,46 @@ class AppHandler(SimpleHTTPRequestHandler):
finally:
connection.close()
def _handle_flows(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_user(connection)
if user is None:
return
raw_company = (query.get("company_id") or [None])[0]
company_id = None
if user["role"] == "company":
if raw_company is not None and raw_company != str(user["company_id"]):
self._send_json(403, {"status": "error", "message": "只能查看本公司的流水。"})
return
company_id = user["company_id"]
elif raw_company is not None:
try:
company_id = int(raw_company)
except ValueError:
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
return
try:
limit = int((query.get("limit") or ["200"])[0])
offset = int((query.get("offset") or ["0"])[0])
except ValueError:
limit, offset = 200, 0
payload = flows.list_flows(
connection,
company_id=company_id,
bank=(query.get("bank") or [None])[0] or None,
account=(query.get("account") or [None])[0] or None,
start=(query.get("start") or [None])[0] or None,
end=(query.get("end") or [None])[0] or None,
keyword=(query.get("keyword") or [None])[0] or None,
limit=limit,
offset=offset,
)
payload["status"] = "ok"
self._send_json(200, payload)
finally:
connection.close()
# ------------------------------------------------------------------
# Admin endpoints
# ------------------------------------------------------------------
@@ -1550,6 +1632,229 @@ class AppHandler(SimpleHTTPRequestHandler):
finally:
connection.close()
def _handle_admin_period_closes(self) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
payload = period_close.overview(connection)
payload["status"] = "ok"
self._send_json(200, payload)
finally:
connection.close()
def _handle_admin_period_close_one(self, year_month: str) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
try:
payload = period_close.close_payload(connection, year_month)
except period_close.PeriodCloseError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
body = dict(payload)
body["period_status"] = body.get("status")
body["status"] = "ok"
self._send_json(200, body)
finally:
connection.close()
def _handle_admin_period_report(self, year_month: str) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
try:
payload = period_close.close_payload(connection, year_month)
except period_close.PeriodCloseError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
if not payload.get("snapshot") or payload.get("status") not in ("closed", "reopened"):
self._send_json(404, {"status": "error", "message": "该账期尚无月报。"})
return
body = json.dumps(
{
"report_no": payload["report_no"],
"snapshot_hash": payload["snapshot_hash"],
"year_month": year_month,
"snapshot": payload["snapshot"],
},
ensure_ascii=False,
indent=2,
).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header(
"Content-Disposition",
f'attachment; filename="{payload["report_no"] or year_month}.json"',
)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
finally:
connection.close()
def _handle_admin_period_reopens(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] or None
items = period_close.list_reopen_requests(connection, status=status)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_admin_period_reopen_one(self, request_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
try:
payload = period_close.reopen_payload(connection, request_id)
except period_close.PeriodCloseError as exc:
self._send_json(404, {"status": "error", "message": str(exc)})
return
payload["status_ok"] = "ok"
payload["ok"] = True
self._send_json(200, {"status": "ok", "item": payload})
finally:
connection.close()
def _handle_admin_period_audit(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
try:
limit = int((query.get("limit") or ["100"])[0])
except ValueError:
limit = 100
items = period_close.list_audit_events(
connection,
action=(query.get("action") or [None])[0] or None,
year_month=(query.get("year_month") or [None])[0] or None,
company_q=(query.get("company") or [None])[0] or None,
since=(query.get("since") or [None])[0] or None,
until=(query.get("until") or [None])[0] or None,
limit=limit,
)
self._send_json(200, {"status": "ok", "items": items})
finally:
connection.close()
def _handle_admin_period_execute(self, year_month: str) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body() or {}
try:
payload = period_close.execute_close(
connection,
year_month,
user,
confirm=bool(data.get("confirm")),
)
except period_close.PeriodConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except period_close.PeriodCloseError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
except Exception as exc:
period_close.mark_close_failed(connection, year_month, user, str(exc))
self._send_json(
500,
{
"status": "error",
"message": f"结账失败,未改动任何数据:{exc}",
},
)
return
auth.audit(
connection, "period_close", actor=user,
target=year_month, detail=payload.get("report_no"), ip=self._client_ip,
)
payload["period_status"] = payload.get("status")
payload["status"] = "ok"
self._send_json(200, payload)
finally:
connection.close()
def _handle_admin_period_reopen_request(self, year_month: str) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body() or {}
try:
payload = period_close.request_reopen(
connection,
year_month,
user,
reason=str(data.get("reason") or ""),
companies_note=str(data.get("companies_note") or ""),
window_days=data.get("window_days") or 3,
)
except period_close.PeriodConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except period_close.PeriodCloseError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
auth.audit(
connection, "period_reopen_request", actor=user,
target=year_month, detail=payload.get("number"), ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "item": payload})
finally:
connection.close()
def _handle_admin_period_reopen_decide(self, request_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body() or {}
approve = bool(data.get("approve"))
try:
payload = period_close.decide_reopen(
connection,
request_id,
user,
approve=approve,
comment=str(data.get("comment") or ""),
)
except period_close.PeriodConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except period_close.PeriodCloseError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
auth.audit(
connection,
"period_reopen_approve" if approve else "period_reopen_reject",
actor=user,
target=payload.get("year_month"),
detail=payload.get("number"),
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "item": payload})
finally:
connection.close()
# ------------------------------------------------------------------
# System settings (admin read/write, persisted + audited)
# ------------------------------------------------------------------
@@ -2292,8 +2597,25 @@ class AppHandler(SimpleHTTPRequestHandler):
if not row_ids:
self._send_json(400, {"status": "error", "message": "必须指定 source_row_ids 或 batch_id。"})
return
writable, locked_rows = period_close.split_writable_row_ids(connection, row_ids)
late = 0
if locked_rows:
late = period_close.record_late_arrivals(connection, locked_rows, user)
if not writable:
self._send_json(
200,
{
"status": "ok",
"matching": {
"created_events": 0, "updated_events": 0, "unchanged": 0,
"skipped_locked": len(locked_rows), "rows": len(row_ids),
},
"late_arrivals": late,
},
)
return
try:
result = matching.reconcile_rows(connection, row_ids, actor=user)
result = matching.reconcile_rows(connection, writable, actor=user)
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"重跑匹配失败:{exc}"})
return
@@ -2302,13 +2624,14 @@ class AppHandler(SimpleHTTPRequestHandler):
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
return
result["late_arrivals"] = late
auth.audit(
connection, "transfer_reconcile", actor=user,
target=f"rows:{len(row_ids)}",
detail=f"created:{result['created_events']};updated:{result['updated_events']}",
target=f"rows:{len(writable)}",
detail=f"created:{result['created_events']};updated:{result['updated_events']};late:{late}",
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "matching": result})
self._send_json(200, {"status": "ok", "matching": result, "late_arrivals": late})
finally:
connection.close()
@@ -2336,6 +2659,7 @@ class AppHandler(SimpleHTTPRequestHandler):
self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"})
return
try:
period_close.assert_event_writable(connection, event_id)
payload = matching.apply_manual_decision(
connection,
event_id,
@@ -2349,6 +2673,9 @@ class AppHandler(SimpleHTTPRequestHandler):
else None,
participant=data.get("participant"),
)
except period_close.PeriodLockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month})
return
except matching.MatchConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
@@ -2648,6 +2975,7 @@ class AppHandler(SimpleHTTPRequestHandler):
return
reason = str(data.get("reason") or "").strip() or "公司端确认单边流水"
try:
period_close.assert_event_writable(connection, event_id)
payload = matching.apply_manual_decision(
connection,
event_id,
@@ -2661,6 +2989,9 @@ class AppHandler(SimpleHTTPRequestHandler):
"company_id": counterparty_company_id,
},
)
except period_close.PeriodLockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month})
return
except matching.MatchConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
@@ -3025,6 +3356,7 @@ class AppHandler(SimpleHTTPRequestHandler):
)
return
try:
period_close.assert_ledger_writable(connection, event_id)
action = str(data.get("action") or "confirm")
if action in ("return", "exception"):
payload = subjects.park_subject(
@@ -3047,6 +3379,9 @@ class AppHandler(SimpleHTTPRequestHandler):
request_key=str(data.get("request_key") or "") or None,
actor=user,
)
except period_close.PeriodLockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month})
return
except subjects.SubjectConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
@@ -3079,6 +3414,9 @@ class AppHandler(SimpleHTTPRequestHandler):
self._send_json(400, {"status": "error", "message": "必须填写操作原因。"})
return
try:
period_close.assert_ledger_writable(connection, event_id)
if action in ("adjust", "reverse") and data.get("effective_at"):
period_close.assert_date_writable(connection, str(data.get("effective_at")))
if action == "reverse":
event_id, _revision_id = ledger_events.create_reversal(
connection, event_id,
@@ -3124,6 +3462,9 @@ class AppHandler(SimpleHTTPRequestHandler):
{"status": "error", "message": "action 必须是 reverse、adjust 或 reopen。"},
)
return
except period_close.PeriodLockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month})
return
except ledger_events.LedgerConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
@@ -3157,6 +3498,13 @@ class AppHandler(SimpleHTTPRequestHandler):
self._send_json(400, {"status": "error", "message": "expected_decision_id 无效。"})
return
try:
existing = connection.execute(
"SELECT occurred_at FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
if existing is not None:
period_close.assert_date_writable(
connection, data.get("effective_at") or existing["occurred_at"]
)
payload = manual_records.decide(
connection,
record_id,
@@ -3169,6 +3517,9 @@ class AppHandler(SimpleHTTPRequestHandler):
target_ledger_event_id=data.get("target_ledger_event_id"),
effective_at=data.get("effective_at"),
)
except period_close.PeriodLockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month})
return
except manual_records.ManualConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
@@ -3617,6 +3968,7 @@ class AppHandler(SimpleHTTPRequestHandler):
if data is None:
return
try:
period_close.assert_date_writable(connection, str(data.get("occurred_at") or ""))
payload = manual_records.submit(
connection,
company_id=company_id,
@@ -3636,6 +3988,9 @@ class AppHandler(SimpleHTTPRequestHandler):
reason=data.get("reason"),
evidence=data.get("evidence"),
)
except period_close.PeriodLockedError as exc:
self._send_json(409, {"status": "error", "message": str(exc), "year_month": exc.year_month})
return
except manual_records.ManualConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return