HEL-269: 完成月结锁账、撤演示数据与代码收尾
锁账后写保护与闭期补录留痕;重开须审批并按版本链再结;列表/导出改走真实 API。 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
f5e0915f63
commit
e5e326514d
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user