- 迁移链追加 0009_reminders_engine:旧 manual reminders 表改名 reminders_legacy_manual 保留历史,新建 reminders/reminder_events/ reminder_settings(append-only 触发器) - server.py:新提醒 API(pending/scan/send/manual/resend/settings/ 公司端收件与状态流转)替换旧 manual-send 端点 - web:admin 待提醒清单+扫描+详情抽屉,公司端通知动态化+ 去处理事件委托;设置保存同步提醒扫描参数 - 移除被取代的 settings.pending_items/send_reminders 与旧 UI 逻辑 - 全量测试 346 项通过(5 项浏览器跳过与历史一致) Co-authored-by: multica-agent <github@multica.ai>
4078 lines
170 KiB
Python
4078 lines
170 KiB
Python
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from http.cookies import SimpleCookie
|
|
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,
|
|
)
|
|
from bank_importer.db import connect, migrate, utc_now
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
WEB_ROOT = ROOT / "web"
|
|
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
DB_PATH = Path(os.environ.get("APP_DB_PATH", ROOT / "data" / "app.db"))
|
|
STORAGE_DIR = Path(os.environ.get("APP_STORAGE_DIR", ROOT / "data" / "files"))
|
|
SESSION_COOKIE = "cw_session"
|
|
# Local plain-HTTP deployment: the cookie intentionally carries no Secure
|
|
# flag (see docs/decisions/003-auth.md).
|
|
COOKIE_FLAGS = "HttpOnly; SameSite=Lax; Path=/"
|
|
|
|
|
|
class AppHandler(SimpleHTTPRequestHandler):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, directory=str(WEB_ROOT), **kwargs)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Routing
|
|
# ------------------------------------------------------------------
|
|
|
|
def do_GET(self) -> None:
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path
|
|
query = parse_qs(parsed.query)
|
|
|
|
if path == "/api/me":
|
|
self._handle_me()
|
|
return
|
|
if path == "/api/batches":
|
|
self._handle_batches(query)
|
|
return
|
|
rows_match = re.fullmatch(r"/api/batches/(\d+)/rows", path)
|
|
if rows_match:
|
|
self._handle_batch_rows(int(rows_match.group(1)))
|
|
return
|
|
sheets_match = re.fullmatch(r"/api/batches/(\d+)/sheets", path)
|
|
if sheets_match:
|
|
self._handle_batch_sheets(int(sheets_match.group(1)))
|
|
return
|
|
if path == "/api/export.csv":
|
|
self._handle_export_csv(query)
|
|
return
|
|
if path == "/api/admin/companies":
|
|
self._handle_admin_companies()
|
|
return
|
|
if path == "/api/admin/users":
|
|
self._handle_admin_users()
|
|
return
|
|
if path == "/api/admin/accounts":
|
|
self._handle_admin_accounts(query)
|
|
return
|
|
aliases_match = re.fullmatch(r"/api/admin/accounts/(\d+)/aliases", path)
|
|
if aliases_match:
|
|
self._handle_admin_account_aliases(int(aliases_match.group(1)))
|
|
return
|
|
if path == "/api/admin/master-changes":
|
|
self._handle_admin_master_changes(query)
|
|
return
|
|
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
|
|
if path == "/api/admin/settings":
|
|
self._handle_admin_settings()
|
|
return
|
|
if path == "/api/admin/transfer-events":
|
|
self._handle_admin_transfer_events(query)
|
|
return
|
|
if path == "/api/admin/match-exceptions":
|
|
self._handle_admin_match_exceptions(query)
|
|
return
|
|
if path == "/api/admin/dashboard":
|
|
self._handle_admin_dashboard(query)
|
|
return
|
|
company_dash = re.fullmatch(r"/api/admin/dashboard/companies/(\d+)", path)
|
|
if company_dash:
|
|
self._handle_admin_dashboard_company(int(company_dash.group(1)), query)
|
|
return
|
|
if path == "/api/admin/personal-transit-mappings":
|
|
self._handle_admin_personal_mappings(query)
|
|
return
|
|
event_match = re.fullmatch(r"/api/admin/transfer-events/(\d+)", path)
|
|
if event_match:
|
|
self._handle_admin_transfer_event_detail(int(event_match.group(1)))
|
|
return
|
|
if path == "/api/company/transfer-events":
|
|
self._handle_company_transfer_events(query)
|
|
return
|
|
if path == "/api/company/match-exceptions":
|
|
self._handle_company_match_exceptions(query)
|
|
return
|
|
if path == "/api/company/workspace":
|
|
self._handle_company_workspace()
|
|
return
|
|
company_event_match = re.fullmatch(r"/api/company/transfer-events/(\d+)", path)
|
|
if company_event_match:
|
|
self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
|
|
return
|
|
if path == "/api/admin/reminders/pending":
|
|
self._handle_admin_reminders_pending()
|
|
return
|
|
if path == "/api/admin/reminders":
|
|
self._handle_admin_reminders(query)
|
|
return
|
|
if path == "/api/admin/reminder-settings":
|
|
self._handle_admin_reminder_settings_get()
|
|
return
|
|
admin_reminder_match = re.fullmatch(r"/api/admin/reminders/(\d+)", path)
|
|
if admin_reminder_match:
|
|
self._handle_admin_reminder_detail(int(admin_reminder_match.group(1)))
|
|
return
|
|
if path == "/api/company/reminders":
|
|
self._handle_company_reminders()
|
|
return
|
|
company_reminder_match = re.fullmatch(r"/api/company/reminders/(\d+)", path)
|
|
if company_reminder_match:
|
|
self._handle_company_reminder_detail(int(company_reminder_match.group(1)))
|
|
return
|
|
|
|
# B-44 intercompany positions (admin)
|
|
if path == "/api/admin/intercompany/balances":
|
|
self._handle_admin_intercompany_balances(query)
|
|
return
|
|
admin_pair = re.fullmatch(
|
|
r"/api/admin/intercompany/pairs/(\d+)/(\d+)", path
|
|
)
|
|
if admin_pair:
|
|
self._handle_admin_intercompany_pair(
|
|
int(admin_pair.group(1)), int(admin_pair.group(2)), query
|
|
)
|
|
return
|
|
if path == "/api/admin/intercompany/events":
|
|
self._handle_admin_intercompany_events(query)
|
|
return
|
|
if path == "/api/admin/subject-reviews":
|
|
self._handle_admin_subject_reviews(query)
|
|
return
|
|
if path == "/api/admin/manual-records":
|
|
self._handle_admin_manual_records(query)
|
|
return
|
|
admin_event_match = re.fullmatch(r"/api/admin/intercompany/events/(\d+)", path)
|
|
if admin_event_match:
|
|
self._handle_admin_intercompany_event_detail(int(admin_event_match.group(1)))
|
|
return
|
|
admin_evidence = re.fullmatch(
|
|
r"/api/admin/intercompany/events/(\d+)/evidence", path
|
|
)
|
|
if admin_evidence:
|
|
self._handle_admin_intercompany_evidence(int(admin_evidence.group(1)))
|
|
return
|
|
|
|
# Company transfer-summary (HEL-169/HEL-175/HEL-176)
|
|
if path == "/api/company/intercompany/summary":
|
|
self._handle_company_intercompany_summary(query)
|
|
return
|
|
if path == "/api/company/intercompany/export.csv":
|
|
self._handle_company_intercompany_export_csv(query)
|
|
return
|
|
|
|
# B-44 intercompany positions (company, own-company scope)
|
|
if path == "/api/company/intercompany/balances":
|
|
self._handle_company_intercompany_balances(query)
|
|
return
|
|
company_pair = re.fullmatch(r"/api/company/intercompany/pairs/(\d+)", path)
|
|
if company_pair:
|
|
self._handle_company_intercompany_pair(int(company_pair.group(1)), query)
|
|
return
|
|
if path == "/api/company/intercompany/events":
|
|
# HEL-176 transfer-detail list shares this path with B-44 ledger
|
|
# events; dispatch by distinctive query params (to/direction/…).
|
|
if company_transfers.is_transfer_summary_events_query(query):
|
|
self._handle_company_transfer_summary_events(query)
|
|
else:
|
|
self._handle_company_intercompany_events(query)
|
|
return
|
|
if path == "/api/company/manual-records":
|
|
self._handle_company_manual_records(query)
|
|
return
|
|
if path == "/api/company/companies":
|
|
self._handle_company_companies()
|
|
return
|
|
company_event_match = re.fullmatch(r"/api/company/intercompany/events/(\d+)", path)
|
|
if company_event_match:
|
|
self._handle_company_intercompany_event_detail(
|
|
int(company_event_match.group(1))
|
|
)
|
|
return
|
|
company_evidence = re.fullmatch(
|
|
r"/api/company/intercompany/events/(\d+)/evidence", path
|
|
)
|
|
if company_evidence:
|
|
self._handle_company_intercompany_evidence(int(company_evidence.group(1)))
|
|
return
|
|
|
|
if path == "/admin.html" and not self._guard_page("admin"):
|
|
return
|
|
if path == "/company.html" and not self._guard_page("company"):
|
|
return
|
|
super().do_GET()
|
|
|
|
def do_POST(self) -> None:
|
|
path = urlparse(self.path).path
|
|
|
|
if path == "/api/login":
|
|
self._handle_login()
|
|
return
|
|
if path == "/api/logout":
|
|
self._handle_logout()
|
|
return
|
|
if path == "/api/password/change":
|
|
self._handle_password_change()
|
|
return
|
|
if path == "/api/parse":
|
|
self._handle_parse()
|
|
return
|
|
if path == "/api/admin/companies":
|
|
self._handle_admin_create_company()
|
|
return
|
|
if path == "/api/admin/users":
|
|
self._handle_admin_create_user()
|
|
return
|
|
user_action = re.fullmatch(
|
|
r"/api/admin/users/(\d+)/(disable|enable|reset-password)", path
|
|
)
|
|
if user_action:
|
|
self._handle_admin_user_action(int(user_action.group(1)), user_action.group(2))
|
|
return
|
|
if path == "/api/company/accounts":
|
|
self._handle_company_submit_account()
|
|
return
|
|
account_review = re.fullmatch(r"/api/admin/accounts/(\d+)/review", path)
|
|
if account_review:
|
|
self._handle_admin_review_account(int(account_review.group(1)))
|
|
return
|
|
alias_create = re.fullmatch(r"/api/admin/accounts/(\d+)/aliases", path)
|
|
if alias_create:
|
|
self._handle_admin_add_alias(int(alias_create.group(1)))
|
|
return
|
|
confirm_match = re.fullmatch(r"/api/batches/(\d+)/confirm", path)
|
|
if confirm_match:
|
|
self._handle_batch_review(int(confirm_match.group(1)), "confirm")
|
|
return
|
|
ignore_match = re.fullmatch(r"/api/batches/(\d+)/ignore", path)
|
|
if ignore_match:
|
|
self._handle_batch_review(int(ignore_match.group(1)), "ignore")
|
|
return
|
|
if path == "/api/admin/transfer-events/reconcile":
|
|
self._handle_admin_reconcile()
|
|
return
|
|
if path == "/api/admin/personal-transit-mappings":
|
|
self._handle_admin_create_personal_mapping()
|
|
return
|
|
decision_match = re.fullmatch(r"/api/admin/transfer-events/(\d+)/decisions", path)
|
|
if decision_match:
|
|
self._handle_admin_transfer_decision(int(decision_match.group(1)))
|
|
return
|
|
mapping_review = re.fullmatch(r"/api/admin/personal-transit-mappings/(\d+)/review", path)
|
|
if mapping_review:
|
|
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
|
|
return
|
|
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
|
|
|
|
# B-44 intercompany positions (admin writes)
|
|
subject_decision = re.fullmatch(
|
|
r"/api/admin/intercompany/events/(\d+)/subject-decisions", path
|
|
)
|
|
if subject_decision:
|
|
self._handle_admin_subject_decision(int(subject_decision.group(1)))
|
|
return
|
|
adjustment = re.fullmatch(
|
|
r"/api/admin/intercompany/events/(\d+)/adjustments", path
|
|
)
|
|
if adjustment:
|
|
self._handle_admin_intercompany_adjustment(int(adjustment.group(1)))
|
|
return
|
|
manual_decision = re.fullmatch(r"/api/admin/manual-records/(\d+)/decisions", path)
|
|
if manual_decision:
|
|
self._handle_admin_manual_record_decision(int(manual_decision.group(1)))
|
|
return
|
|
|
|
# B-44 intercompany positions (company writes)
|
|
if path == "/api/company/manual-records":
|
|
self._handle_company_manual_records_submit()
|
|
return
|
|
company_confirm = re.fullmatch(
|
|
r"/api/company/transfer-events/(\d+)/confirm", path
|
|
)
|
|
if company_confirm:
|
|
self._handle_company_transfer_confirm(int(company_confirm.group(1)))
|
|
return
|
|
if path == "/api/admin/reminders/scan":
|
|
self._handle_admin_reminders_scan()
|
|
return
|
|
if path == "/api/admin/reminders/send":
|
|
self._handle_admin_reminders_send()
|
|
return
|
|
if path == "/api/admin/reminders/manual":
|
|
self._handle_admin_reminders_manual()
|
|
return
|
|
admin_resend_match = re.fullmatch(r"/api/admin/reminders/(\d+)/resend", path)
|
|
if admin_resend_match:
|
|
self._handle_admin_reminder_resend(int(admin_resend_match.group(1)))
|
|
return
|
|
if path == "/api/admin/reminder-settings":
|
|
self._handle_admin_reminder_settings_put()
|
|
return
|
|
company_status_match = re.fullmatch(
|
|
r"/api/company/reminders/(\d+)/(acknowledge|resolve)", path
|
|
)
|
|
if company_status_match:
|
|
self._handle_company_reminder_status(
|
|
int(company_status_match.group(1)), company_status_match.group(2)
|
|
)
|
|
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
|
|
# ------------------------------------------------------------------
|
|
|
|
def _session_token(self) -> str | None:
|
|
header = self.headers.get("Cookie")
|
|
if not header:
|
|
return None
|
|
cookie = SimpleCookie()
|
|
try:
|
|
cookie.load(header)
|
|
except Exception:
|
|
return None
|
|
morsel = cookie.get(SESSION_COOKIE)
|
|
return morsel.value if morsel is not None else None
|
|
|
|
def _current_user(self, connection):
|
|
token = self._session_token()
|
|
if not token:
|
|
return None
|
|
return auth.resolve_session(connection, token)
|
|
|
|
def _require_user(self, connection, *, pending_password_ok: bool = False):
|
|
user = self._current_user(connection)
|
|
if user is None:
|
|
self._send_json(401, {"status": "error", "message": "请先登录。"})
|
|
return None
|
|
if user["must_change_password"] and not pending_password_ok:
|
|
self._send_json(
|
|
403,
|
|
{"status": "error", "message": "首次登录须修改密码后才能继续操作。"},
|
|
)
|
|
return None
|
|
return user
|
|
|
|
def _require_admin(self, connection):
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return None
|
|
if user["role"] != "admin":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限管理员。"})
|
|
return None
|
|
return user
|
|
|
|
def _guard_page(self, role: str) -> bool:
|
|
"""UX-layer static page gate; real enforcement is on the APIs."""
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._current_user(connection)
|
|
finally:
|
|
connection.close()
|
|
if user is None or user["role"] != role:
|
|
self.send_response(302)
|
|
self.send_header("Location", "/")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
return False
|
|
return True
|
|
|
|
def _set_session_cookie(self, token: str) -> None:
|
|
self.send_header("Set-Cookie", f"{SESSION_COOKIE}={token}; {COOKIE_FLAGS}")
|
|
|
|
def _clear_session_cookie(self) -> None:
|
|
self.send_header(
|
|
"Set-Cookie", f"{SESSION_COOKIE}=; {COOKIE_FLAGS}; Max-Age=0"
|
|
)
|
|
|
|
@property
|
|
def _client_ip(self) -> str:
|
|
return self.client_address[0]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Auth endpoints
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_login(self) -> None:
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
username = str(data.get("username") or "").strip()
|
|
password = str(data.get("password") or "")
|
|
portal = str(data.get("portal") or "")
|
|
if not username or not password or portal not in {"admin", "company"}:
|
|
self._send_json(400, {"status": "error", "message": "请填写账号、密码并选择工作端口。"})
|
|
return
|
|
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, reason = auth.authenticate(connection, username, password, self._client_ip)
|
|
if reason == "rate_limited":
|
|
self._send_json(
|
|
429,
|
|
{"status": "error", "message": "失败次数过多,请 10 分钟后再试。"},
|
|
)
|
|
return
|
|
if reason == "disabled":
|
|
self._send_json(
|
|
403, {"status": "error", "message": "账号已停用,请联系管理员。"}
|
|
)
|
|
return
|
|
if user is None:
|
|
self._send_json(
|
|
401, {"status": "error", "message": "账号或密码不正确。"}
|
|
)
|
|
return
|
|
if user["role"] != portal:
|
|
self._send_json(
|
|
403, {"status": "error", "message": "账号与该工作端口不匹配。"}
|
|
)
|
|
return
|
|
token = auth.create_session(connection, user["id"])
|
|
self._send_json(
|
|
200, self._user_payload(connection, user), session_token=token
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_logout(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._current_user(connection)
|
|
token = self._session_token()
|
|
if token:
|
|
auth.revoke_session(connection, token)
|
|
if user is not None:
|
|
auth.audit(connection, "logout", actor=user, ip=self._client_ip)
|
|
self._send_json(200, {"status": "ok"}, clear_session=True)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_me(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._current_user(connection)
|
|
if user is None:
|
|
self._send_json(401, {"status": "error", "message": "请先登录。"})
|
|
return
|
|
self._send_json(200, self._user_payload(connection, user))
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_password_change(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection, pending_password_ok=True)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
old_password = str(data.get("old_password") or "")
|
|
new_password = str(data.get("new_password") or "")
|
|
if not auth.verify_password(old_password, user["password_hash"]):
|
|
self._send_json(401, {"status": "error", "message": "原密码不正确。"})
|
|
return
|
|
error = auth.change_password(
|
|
connection, user["id"], old_password, new_password
|
|
)
|
|
if error is not None:
|
|
self._send_json(400, {"status": "error", "message": error})
|
|
return
|
|
self._send_json(200, {"status": "ok"})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _user_payload(self, connection, user) -> dict[str, object]:
|
|
company_name = None
|
|
if user["company_id"] is not None:
|
|
company = connection.execute(
|
|
"SELECT name FROM companies WHERE id = ?", (user["company_id"],)
|
|
).fetchone()
|
|
company_name = company["name"] if company is not None else None
|
|
return {
|
|
"status": "ok",
|
|
"username": user["username"],
|
|
"role": user["role"],
|
|
"must_change_password": bool(user["must_change_password"]),
|
|
"company_id": user["company_id"],
|
|
"company_name": company_name,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Statement import (existing behavior + tenant binding)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_parse(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
upload = None
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
try:
|
|
fields, upload = self._receive_upload()
|
|
filename = os.path.basename(upload.filename)
|
|
suffix = Path(filename).suffix.lower()
|
|
if suffix not in {".xls", ".xlsx"}:
|
|
raise ValueError("仅支持 .xls 或 .xlsx 银行流水文件。")
|
|
if not multipart.valid_file_signature(upload.path, suffix):
|
|
raise ValueError(
|
|
"文件内容与扩展名不符,可能已损坏或不是有效的 Excel 文件。"
|
|
)
|
|
|
|
if user["role"] == "company":
|
|
# Tenant binding comes from the session only; any
|
|
# company_id in the multipart body is ignored.
|
|
company_id = user["company_id"]
|
|
upload_account_id, invalid = self._resolve_upload_account(
|
|
connection, user, fields, company_id
|
|
)
|
|
if invalid:
|
|
return
|
|
else:
|
|
company_id = self._parse_company_field(
|
|
connection, fields.get("company_id")
|
|
)
|
|
if company_id is None:
|
|
return
|
|
upload_account_id, invalid = self._resolve_upload_account(
|
|
connection, user, fields, company_id
|
|
)
|
|
if invalid:
|
|
return
|
|
|
|
result = importing.import_statement_path(
|
|
connection, STORAGE_DIR, filename, upload.path,
|
|
company_id=company_id,
|
|
upload_bank_account_id=upload_account_id,
|
|
)
|
|
auth.audit(
|
|
connection,
|
|
"import_upload",
|
|
actor=user,
|
|
target=f"batch:{result.batch_id}",
|
|
detail=f"{filename} -> {result.status}",
|
|
ip=self._client_ip,
|
|
)
|
|
payload = self._import_payload(connection, result)
|
|
except (ValueError,) as exc:
|
|
self._send_json(422, {"status": "exception", "message": str(exc)})
|
|
return
|
|
except Exception:
|
|
self._send_json(
|
|
500,
|
|
{"status": "error", "message": "文件解析失败,请检查文件是否完整。"},
|
|
)
|
|
return
|
|
|
|
if result.status == "exception":
|
|
self._send_json(422, payload)
|
|
else:
|
|
self._send_json(200, payload)
|
|
finally:
|
|
if upload is not None:
|
|
upload.path.unlink(missing_ok=True)
|
|
connection.close()
|
|
|
|
def _receive_upload(self) -> tuple[dict[str, str], multipart.UploadedFile]:
|
|
"""Stream the multipart body into a validated temp file.
|
|
|
|
The whole request is never buffered in memory; file bytes are written
|
|
to a temp file under the storage directory as they arrive and are
|
|
never trimmed. Only the first file part is accepted.
|
|
"""
|
|
raw_length = self.headers.get("Content-Length")
|
|
if raw_length is None:
|
|
raise ValueError("上传请求缺少 Content-Length。")
|
|
try:
|
|
content_length = int(raw_length)
|
|
except ValueError:
|
|
raise ValueError("上传请求的 Content-Length 无效。")
|
|
if content_length <= 0:
|
|
raise ValueError("文件为空或超过 20 MB 限制。")
|
|
if content_length > MAX_UPLOAD_BYTES + multipart.MAX_FIELD_BYTES:
|
|
raise ValueError("文件超过 20 MB 限制。")
|
|
try:
|
|
fields, upload = multipart.parse_upload(
|
|
self.rfile,
|
|
content_length,
|
|
self.headers.get("Content-Type", ""),
|
|
STORAGE_DIR / ".uploads",
|
|
max_file_bytes=MAX_UPLOAD_BYTES,
|
|
)
|
|
except multipart.MultipartError as exc:
|
|
raise ValueError(str(exc)) from exc
|
|
return fields, upload
|
|
|
|
def _resolve_upload_account(self, connection, user, fields, company_id) -> tuple[int | None, bool]:
|
|
"""Validate and return the approved upload account for a batch.
|
|
|
|
Returns ``(account_id, False)`` when the account is accepted or when
|
|
none was supplied, and ``(None, True)`` after sending the rejection.
|
|
The approved account is persisted on the batch so ownership can be
|
|
resolved later even when source rows lack an own account.
|
|
"""
|
|
raw = fields.get("bank_account_id")
|
|
if not raw:
|
|
return None, False
|
|
try:
|
|
account_id = int(raw)
|
|
except ValueError:
|
|
self._send_json(400, {"status": "error", "message": "bank_account_id 参数无效。"})
|
|
return None, True
|
|
account = master_data.get_account(connection, account_id)
|
|
if account is None or account["company_id"] != company_id:
|
|
self._send_json(404, {"status": "error", "message": "账户不存在。"})
|
|
return None, True
|
|
if not master_data.is_usable(account, master_data.utc_today()):
|
|
self._send_json(
|
|
409,
|
|
{"status": "error", "message": "该账户未启用或已超出有效期,不能上传流水。"},
|
|
)
|
|
return None, True
|
|
return account_id, False
|
|
|
|
def _parse_company_field(self, connection, raw) -> int | None:
|
|
try:
|
|
company_id = int(str(raw))
|
|
except (TypeError, ValueError):
|
|
self._send_json(
|
|
400, {"status": "error", "message": "管理员上传必须指定有效的 company_id。"}
|
|
)
|
|
return None
|
|
company = connection.execute(
|
|
"SELECT id FROM companies WHERE id = ?", (company_id,)
|
|
).fetchone()
|
|
if company is None:
|
|
self._send_json(
|
|
400, {"status": "error", "message": "指定的公司不存在。"}
|
|
)
|
|
return None
|
|
return company_id
|
|
|
|
# ------------------------------------------------------------------
|
|
# Batches, rows and export
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_batches(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
conditions = []
|
|
params: list[object] = []
|
|
if user["role"] == "company":
|
|
conditions.append("b.company_id = ?")
|
|
params.append(user["company_id"])
|
|
else:
|
|
raw = (query.get("company_id") or [None])[0]
|
|
if raw:
|
|
try:
|
|
company_id = int(raw)
|
|
except ValueError:
|
|
self._send_json(
|
|
400, {"status": "error", "message": "company_id 参数无效。"}
|
|
)
|
|
return
|
|
conditions.append("b.company_id = ?")
|
|
params.append(company_id)
|
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT b.id, b.status, b.created_at, b.company_id,
|
|
f.original_filename, c.name AS company_name,
|
|
COALESCE((
|
|
SELECT SUM(s.transaction_count) FROM sheet_batches s
|
|
WHERE s.import_batch_id = b.id
|
|
), 0) AS transactions,
|
|
(SELECT s.bank_name FROM sheet_batches s
|
|
WHERE s.import_batch_id = b.id
|
|
ORDER BY s.id LIMIT 1) AS bank_name,
|
|
(SELECT MIN(s.period_start) FROM sheet_batches s
|
|
WHERE s.import_batch_id = b.id) AS period_start,
|
|
(SELECT MAX(s.period_end) FROM sheet_batches s
|
|
WHERE s.import_batch_id = b.id) AS period_end,
|
|
(SELECT COUNT(*) 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
|
|
WHERE s.import_batch_id = b.id AND rv.review_status = 'confirmed')
|
|
AS confirmed_transactions,
|
|
(SELECT COUNT(*) FROM sheet_reviews r
|
|
WHERE r.import_batch_id = b.id AND r.review_status = 'confirmed')
|
|
AS confirmed_sheets,
|
|
(SELECT COUNT(*) FROM sheet_reviews r
|
|
WHERE r.import_batch_id = b.id
|
|
AND r.outcome = 'parsed' AND r.review_status = 'pending')
|
|
AS pending_sheets,
|
|
(SELECT COUNT(*) FROM sheet_reviews r
|
|
WHERE r.import_batch_id = b.id AND r.outcome = 'exception')
|
|
AS exception_sheets,
|
|
(SELECT COUNT(*) FROM sheet_reviews r
|
|
WHERE r.import_batch_id = b.id
|
|
AND (r.outcome = 'ignored' OR r.review_status = 'ignored'))
|
|
AS ignored_sheets
|
|
FROM import_batches b
|
|
JOIN source_files f ON f.id = b.source_file_id
|
|
LEFT JOIN companies c ON c.id = b.company_id
|
|
{where}
|
|
ORDER BY b.id DESC
|
|
LIMIT 500
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
self._send_json(200, {"status": "ok", "batches": [dict(row) for row in rows]})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_batch_sheets(self, batch_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
company_id = user["company_id"] if user["role"] == "company" else None
|
|
batch = importing.scoped_batch(connection, batch_id, company_id)
|
|
if batch is None:
|
|
self._send_json(404, {"status": "error", "message": "批次不存在。"})
|
|
return
|
|
file_row = connection.execute(
|
|
"""
|
|
SELECT f.original_filename FROM source_files f
|
|
JOIN import_batches b ON b.source_file_id = f.id
|
|
WHERE b.id = ?
|
|
""",
|
|
(batch_id,),
|
|
).fetchone()
|
|
rows = importing.sheet_review_rows(connection, batch_id)
|
|
self._send_json(
|
|
200,
|
|
{
|
|
"status": "ok",
|
|
"batch_id": batch_id,
|
|
"company_id": batch["company_id"],
|
|
"original_filename": file_row["original_filename"]
|
|
if file_row is not None
|
|
else None,
|
|
"sheets": [self._sheet_payload(row) for row in rows],
|
|
},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_batch_review(self, batch_id: int, decision: str) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
sheets = data.get("sheets")
|
|
if not isinstance(sheets, list):
|
|
self._send_json(
|
|
400, {"status": "error", "message": "sheets 必须是工作表名称数组。"}
|
|
)
|
|
return
|
|
company_id = user["company_id"] if user["role"] == "company" else None
|
|
try:
|
|
outcome = importing.review_sheets(
|
|
connection,
|
|
batch_id,
|
|
sheets,
|
|
decision,
|
|
user,
|
|
company_id,
|
|
reason=str(data.get("reason") or "") if decision == "ignore" else None,
|
|
)
|
|
except LookupError as exc:
|
|
self._send_json(404, {"status": "error", "message": str(exc)})
|
|
return
|
|
except importing.SheetReviewError 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
|
|
except Exception:
|
|
# Confirmation and matching share one transaction; any failure
|
|
# rolls both back (the sheet stays pending).
|
|
self._send_json(
|
|
500,
|
|
{"status": "error", "message": "确认失败,本次确认与匹配已整体回滚。"},
|
|
)
|
|
return
|
|
rows = importing.sheet_review_rows(connection, batch_id)
|
|
payload: dict[str, object] = {
|
|
"status": "ok",
|
|
"updated": outcome["updated"],
|
|
"already": outcome["already"],
|
|
"sheets": [self._sheet_payload(row) for row in rows],
|
|
}
|
|
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()
|
|
|
|
def _handle_batch_rows(self, batch_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
batch = connection.execute(
|
|
"SELECT id, company_id FROM import_batches WHERE id = ?", (batch_id,)
|
|
).fetchone()
|
|
# 404 (not 403) when the batch belongs to another company, so a
|
|
# company user cannot probe the existence of other tenants' data.
|
|
if batch is None or (
|
|
user["role"] == "company" and batch["company_id"] != user["company_id"]
|
|
):
|
|
self._send_json(404, {"status": "error", "message": "批次不存在。"})
|
|
return
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT r.id, r.sheet_batch_id, r.source_row, r.transaction_at,
|
|
r.income, r.expense, r.balance, r.own_account, r.own_name,
|
|
r.counterparty_account, r.counterparty_name, r.counterparty_bank,
|
|
r.summary, r.purpose, r.reference, r.currency,
|
|
s.sheet_name, rv.review_status
|
|
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
|
|
WHERE s.import_batch_id = ?
|
|
ORDER BY s.id, r.source_row
|
|
""",
|
|
(batch_id,),
|
|
).fetchall()
|
|
self._send_json(200, {"status": "ok", "rows": [dict(row) for row in rows]})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_export_csv(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
raw = (query.get("company_id") or [None])[0]
|
|
params: list[object] = []
|
|
if user["role"] == "company":
|
|
if raw is not None and raw != str(user["company_id"]):
|
|
self._send_json(
|
|
403,
|
|
{"status": "error", "message": "只能导出本公司的数据。"},
|
|
)
|
|
return
|
|
where = "WHERE b.company_id = ?"
|
|
params.append(user["company_id"])
|
|
scope = f"company:{user['company_id']}"
|
|
elif raw is not None:
|
|
# Admin: company_id optional; absent means all companies.
|
|
try:
|
|
company_id = int(raw)
|
|
except ValueError:
|
|
self._send_json(
|
|
400, {"status": "error", "message": "company_id 参数无效。"}
|
|
)
|
|
return
|
|
where = "WHERE b.company_id = ?"
|
|
params.append(company_id)
|
|
scope = f"company:{company_id}"
|
|
else:
|
|
where = ""
|
|
scope = "all"
|
|
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT b.id AS batch_id, s.sheet_name, r.source_row, r.transaction_at,
|
|
r.income, r.expense, r.balance, r.own_account, r.own_name,
|
|
r.counterparty_account, r.counterparty_name, r.counterparty_bank,
|
|
r.summary, r.purpose, r.reference, r.currency
|
|
FROM source_rows r
|
|
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
|
JOIN import_batches b ON b.id = s.import_batch_id
|
|
-- Only worksheets confirmed by the cashier may leave the
|
|
-- ledger: parse success is not business confirmation.
|
|
JOIN sheet_reviews rv
|
|
ON rv.sheet_batch_id = s.id AND rv.review_status = 'confirmed'
|
|
{where}
|
|
ORDER BY b.id, s.id, r.source_row
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
|
|
buffer = io.StringIO()
|
|
writer = csv.writer(buffer)
|
|
writer.writerow(
|
|
[
|
|
"批次", "工作表", "源行号", "交易时间", "收入", "支出", "余额",
|
|
"本方账号", "本方户名", "对方账号", "对方户名", "对方开户行",
|
|
"摘要", "用途", "流水号", "币种",
|
|
]
|
|
)
|
|
for row in rows:
|
|
writer.writerow([row[key] for key in row.keys()])
|
|
# UTF-8 BOM so Excel opens the CSV with the right encoding.
|
|
content = (chr(0xFEFF) + buffer.getvalue()).encode("utf-8")
|
|
|
|
auth.audit(
|
|
connection,
|
|
"export_csv",
|
|
actor=user,
|
|
target=scope,
|
|
detail=f"rows:{len(rows)}",
|
|
ip=self._client_ip,
|
|
)
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/csv; charset=utf-8")
|
|
self.send_header("Content-Disposition", 'attachment; filename="export.csv"')
|
|
self.send_header("Content-Length", str(len(content)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
finally:
|
|
connection.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Admin endpoints
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_admin_companies(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT c.id, c.name, c.credit_code, c.cashier_name, c.status,
|
|
c.created_at, c.updated_at,
|
|
(SELECT COUNT(*) FROM bank_accounts a WHERE a.company_id = c.id)
|
|
AS account_count,
|
|
(SELECT GROUP_CONCAT(u.username, '、') FROM users u
|
|
WHERE u.company_id = c.id AND u.role = 'company')
|
|
AS usernames
|
|
FROM companies c
|
|
ORDER BY c.id
|
|
"""
|
|
).fetchall()
|
|
self._send_json(200, {"status": "ok", "companies": [dict(row) for row in rows]})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_create_company(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
name = str(data.get("name") or "").strip()
|
|
username = str(data.get("username") or "").strip()
|
|
try:
|
|
company_id = master_data.create_company(
|
|
connection,
|
|
name,
|
|
str(data.get("credit_code") or ""),
|
|
str(data.get("cashier_name") or ""),
|
|
user,
|
|
)
|
|
except master_data.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
|
|
auth.audit(
|
|
connection,
|
|
"company_create",
|
|
actor=user,
|
|
target=f"company:{company_id}",
|
|
detail=name,
|
|
ip=self._client_ip,
|
|
)
|
|
payload: dict[str, object] = {
|
|
"status": "ok", "company_id": company_id, "name": name,
|
|
}
|
|
if username:
|
|
# Optionally create the company login in the same request, so
|
|
# a new company is immediately usable without code changes.
|
|
# The initial password is a random one-time value shown only
|
|
# in this creation response, never stored plaintext or logged;
|
|
# must_change_password forces a change at first login.
|
|
initial_password = auth.generate_initial_password(exclude=username)
|
|
try:
|
|
user_id = auth.create_user(
|
|
connection, username, initial_password, "company",
|
|
company_id=company_id, must_change_password=True,
|
|
)
|
|
except ValueError as exc:
|
|
self._send_json(
|
|
400,
|
|
{"status": "error",
|
|
"message": f"公司已创建,但公司账号创建失败:{exc}"},
|
|
)
|
|
return
|
|
auth.audit(
|
|
connection, "user_create", actor=user,
|
|
target=f"user:{user_id}", detail=f"company:{company_id}",
|
|
ip=self._client_ip,
|
|
)
|
|
payload.update(
|
|
{"user_id": user_id, "username": username,
|
|
"initial_password": initial_password}
|
|
)
|
|
self._send_json(200, payload)
|
|
finally:
|
|
connection.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bank account master data
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _account_payload(row, *, full: bool) -> dict[str, object]:
|
|
"""Serialize an account; company users only ever see the masked form."""
|
|
payload: dict[str, object] = {
|
|
"id": row["id"],
|
|
"company_id": row["company_id"],
|
|
"bank_name": row["bank_name"],
|
|
"account_name": row["account_name"],
|
|
"account_type": row["account_type"],
|
|
"status": row["status"],
|
|
"effective_from": row["effective_from"],
|
|
"effective_to": row["effective_to"],
|
|
"reviewed_at": row["reviewed_at"],
|
|
"review_reason": row["review_reason"],
|
|
"created_at": row["created_at"],
|
|
}
|
|
if "company_name" in row.keys():
|
|
payload["company_name"] = row["company_name"]
|
|
if full:
|
|
payload["account_number"] = row["account_number"]
|
|
else:
|
|
payload["account_number_masked"] = master_data.mask_account_number(
|
|
row["account_number"]
|
|
)
|
|
return payload
|
|
|
|
def _handle_admin_accounts(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
raw_company = (query.get("company_id") or [None])[0]
|
|
raw_status = (query.get("status") or [None])[0]
|
|
try:
|
|
company_id = int(raw_company) if raw_company else None
|
|
except ValueError:
|
|
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
|
return
|
|
try:
|
|
rows = master_data.list_accounts(
|
|
connection, company_id=company_id, status=raw_status or None
|
|
)
|
|
except ValueError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok",
|
|
# The admin audit view is the authorized full-number view.
|
|
"accounts": [self._account_payload(row, full=True) for row in rows]},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_accounts(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
if user["role"] != "company":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
|
return
|
|
rows = master_data.list_accounts(connection, company_id=user["company_id"])
|
|
usable_ids = {
|
|
row["id"]
|
|
for row in master_data.usable_accounts(connection, user["company_id"])
|
|
}
|
|
accounts = []
|
|
for row in rows:
|
|
payload = self._account_payload(row, full=False)
|
|
payload["usable"] = row["id"] in usable_ids
|
|
accounts.append(payload)
|
|
self._send_json(200, {"status": "ok", "accounts": accounts})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_submit_account(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
if user["role"] != "company":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
# The company binding always comes from the session; any
|
|
# company_id in the body is ignored.
|
|
try:
|
|
account = master_data.submit_bank_account(
|
|
connection,
|
|
company_id=user["company_id"],
|
|
bank_name=str(data.get("bank_name") or ""),
|
|
account_type=data.get("account_type"),
|
|
account_number=data.get("account_number"),
|
|
account_name=data.get("account_name"),
|
|
start_date=data.get("start_date"),
|
|
actor=user,
|
|
)
|
|
except master_data.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
|
|
auth.audit(
|
|
connection,
|
|
"account_submit",
|
|
actor=user,
|
|
target=f"bank_account:{account['id']}",
|
|
detail=master_data.mask_account_number(account["account_number"]),
|
|
ip=self._client_ip,
|
|
)
|
|
payload = self._account_payload(account, full=False)
|
|
payload["usable"] = master_data.is_usable(account, master_data.utc_today())
|
|
self._send_json(200, {"status": "ok", "account": payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_review_account(self, account_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
try:
|
|
account = master_data.review_bank_account(
|
|
connection,
|
|
account_id,
|
|
str(data.get("decision") or ""),
|
|
str(data.get("reason") or ""),
|
|
user,
|
|
effective_from=data.get("effective_from"),
|
|
effective_to=data.get("effective_to"),
|
|
)
|
|
except LookupError as exc:
|
|
self._send_json(404, {"status": "error", "message": str(exc)})
|
|
return
|
|
except master_data.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
|
|
auth.audit(
|
|
connection,
|
|
f"account_review_{data.get('decision')}",
|
|
actor=user,
|
|
target=f"bank_account:{account_id}",
|
|
detail=master_data.mask_account_number(account["account_number"]),
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok",
|
|
"account": self._account_payload(account, full=True)},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_account_aliases(self, account_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
if master_data.get_account(connection, account_id) is None:
|
|
self._send_json(404, {"status": "error", "message": "账户不存在。"})
|
|
return
|
|
rows = master_data.list_aliases(connection, account_id)
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok", "aliases": [dict(row) for row in rows]},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_add_alias(self, account_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
try:
|
|
alias_id = master_data.add_alias(
|
|
connection,
|
|
account_id,
|
|
str(data.get("alias_kind") or ""),
|
|
data.get("alias_value"),
|
|
priority=data.get("priority"),
|
|
effective_from=data.get("effective_from"),
|
|
effective_to=data.get("effective_to"),
|
|
actor=user,
|
|
)
|
|
except LookupError as exc:
|
|
self._send_json(404, {"status": "error", "message": str(exc)})
|
|
return
|
|
except master_data.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
|
|
auth.audit(
|
|
connection,
|
|
"account_alias_create",
|
|
actor=user,
|
|
target=f"account_alias:{alias_id}",
|
|
detail=f"bank_account:{account_id}",
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(200, {"status": "ok", "alias_id": alias_id})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_master_changes(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
conditions: list[str] = []
|
|
params: list[object] = []
|
|
entity_type = (query.get("entity_type") or [None])[0]
|
|
entity_id = (query.get("entity_id") or [None])[0]
|
|
if entity_type:
|
|
conditions.append("entity_type = ?")
|
|
params.append(entity_type)
|
|
if entity_id:
|
|
try:
|
|
params.append(int(entity_id))
|
|
except ValueError:
|
|
self._send_json(400, {"status": "error", "message": "entity_id 参数无效。"})
|
|
return
|
|
conditions.append("entity_id = ?")
|
|
raw_limit = (query.get("limit") or ["100"])[0]
|
|
try:
|
|
limit = max(1, min(int(raw_limit), 500))
|
|
except ValueError:
|
|
limit = 100
|
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT id, entity_type, entity_id, action, before_json, after_json,
|
|
reason, actor_user_id, actor_username, created_at
|
|
FROM master_data_changes
|
|
{where}
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
""",
|
|
(*params, limit),
|
|
).fetchall()
|
|
self._send_json(200, {"status": "ok", "changes": [dict(row) for row in rows]})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_users(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT u.id, u.username, u.role, u.company_id, c.name AS company_name,
|
|
u.status, u.must_change_password, u.created_at
|
|
FROM users u
|
|
LEFT JOIN companies c ON c.id = u.company_id
|
|
ORDER BY u.id
|
|
"""
|
|
).fetchall()
|
|
# password_hash is deliberately never selected or serialized.
|
|
self._send_json(200, {"status": "ok", "users": [dict(row) for row in rows]})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_create_user(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
|
|
username = str(data.get("username") or "").strip()
|
|
try:
|
|
company_id = int(str(data.get("company_id")))
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"status": "error", "message": "必须指定有效的 company_id。"})
|
|
return
|
|
# The initial password is a random one-time value shown only in
|
|
# this creation response, never stored plaintext or logged;
|
|
# must_change_password forces a change at first login. Password
|
|
# reset keeps a random one-time password instead.
|
|
initial_password = auth.generate_initial_password(exclude=username)
|
|
try:
|
|
user_id = auth.create_user(
|
|
connection,
|
|
username,
|
|
initial_password,
|
|
"company",
|
|
company_id=company_id,
|
|
must_change_password=True,
|
|
)
|
|
except ValueError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
# The random one-time password is never written to audit detail;
|
|
# only the company binding is recorded.
|
|
auth.audit(
|
|
connection,
|
|
"user_create",
|
|
actor=admin,
|
|
target=f"user:{user_id}",
|
|
detail=f"company:{company_id}",
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(
|
|
200,
|
|
{
|
|
"status": "ok",
|
|
"user_id": user_id,
|
|
"username": username,
|
|
"company_id": company_id,
|
|
"initial_password": initial_password,
|
|
},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_user_action(self, user_id: int, action: str) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
admin = self._require_admin(connection)
|
|
if admin is None:
|
|
return
|
|
target = connection.execute(
|
|
"SELECT * FROM users WHERE id = ?", (user_id,)
|
|
).fetchone()
|
|
if target is None:
|
|
self._send_json(404, {"status": "error", "message": "用户不存在。"})
|
|
return
|
|
|
|
if action == "disable":
|
|
with connection:
|
|
connection.execute(
|
|
"UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?",
|
|
(utc_now(), user_id),
|
|
)
|
|
auth.revoke_user_sessions(connection, user_id)
|
|
auth.audit(
|
|
connection, "user_disable", actor=admin,
|
|
target=f"user:{user_id}", detail=target["username"], ip=self._client_ip,
|
|
)
|
|
self._send_json(200, {"status": "ok", "user_id": user_id, "user_status": "disabled"})
|
|
elif action == "enable":
|
|
with connection:
|
|
connection.execute(
|
|
"UPDATE users SET status = 'active', updated_at = ? WHERE id = ?",
|
|
(utc_now(), user_id),
|
|
)
|
|
auth.audit(
|
|
connection, "user_enable", actor=admin,
|
|
target=f"user:{user_id}", detail=target["username"], ip=self._client_ip,
|
|
)
|
|
self._send_json(200, {"status": "ok", "user_id": user_id, "user_status": "active"})
|
|
else: # reset-password
|
|
new_password = auth.generate_initial_password()
|
|
with connection:
|
|
connection.execute(
|
|
"""
|
|
UPDATE users
|
|
SET password_hash = ?, must_change_password = 1, updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
auth.hash_password(new_password),
|
|
utc_now(),
|
|
user_id,
|
|
),
|
|
)
|
|
auth.revoke_user_sessions(connection, user_id)
|
|
auth.audit(
|
|
connection, "user_reset_password", actor=admin,
|
|
target=f"user:{user_id}", detail=target["username"], ip=self._client_ip,
|
|
)
|
|
# Shown once in this response; never logged or audited.
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok", "user_id": user_id, "initial_password": new_password},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_audit_log(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
raw = (query.get("limit") or ["100"])[0]
|
|
try:
|
|
limit = max(1, min(int(raw), 500))
|
|
except ValueError:
|
|
limit = 100
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT id, actor_user_id, actor_username, action, target, detail, ip, created_at
|
|
FROM audit_log
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
self._send_json(200, {"status": "ok", "entries": [dict(row) for row in rows]})
|
|
finally:
|
|
connection.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
values = settings.get_settings(connection)
|
|
self._send_json(200, {"status": "ok", "settings": values})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_update_settings(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
before = settings.get_settings(connection)
|
|
try:
|
|
updated = settings.update_settings(connection, data, user)
|
|
except ValueError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
changed = {
|
|
key: updated[key]
|
|
for key in updated
|
|
if before.get(key) != updated[key]
|
|
}
|
|
auth.audit(
|
|
connection,
|
|
"settings_update",
|
|
actor=user,
|
|
target="system_settings",
|
|
detail=";".join(
|
|
f"{key}:{before.get(key)}->{updated[key]}" for key in changed
|
|
),
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(200, {"status": "ok", "settings": updated})
|
|
finally:
|
|
connection.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Canonical transfer events (admin)
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _event_base_sql(company_scope: bool) -> str:
|
|
"""Shared list SELECT; ``company_scope`` filters to one company."""
|
|
where = (
|
|
"WHERE payer.company_id = ? OR payee.company_id = ?"
|
|
if company_scope
|
|
else ""
|
|
)
|
|
return f"""
|
|
SELECT c.event_id, d.id AS decision_id, d.revision, d.classification,
|
|
d.pairing, d.amount, d.currency, d.effective_at, d.mode,
|
|
d.locked, d.rule_version, d.created_at,
|
|
payer.company_id AS payer_company_id,
|
|
payee.company_id AS payee_company_id,
|
|
payer.bank_account_id AS payer_account_id,
|
|
payee.bank_account_id AS payee_account_id,
|
|
cpayer.name AS payer_company_name,
|
|
cpayee.name AS payee_company_name,
|
|
(SELECT COUNT(*) FROM transfer_decision_observations o
|
|
WHERE o.decision_id = d.id) AS evidence_count
|
|
FROM current_transfer_decisions c
|
|
JOIN transfer_match_decisions d ON d.id = c.decision_id
|
|
LEFT JOIN transfer_decision_participants payer
|
|
ON payer.decision_id = d.id AND payer.role = 'payer'
|
|
LEFT JOIN transfer_decision_participants payee
|
|
ON payee.decision_id = d.id AND payee.role = 'payee'
|
|
LEFT JOIN companies cpayer ON cpayer.id = payer.company_id
|
|
LEFT JOIN companies cpayee ON cpayee.id = payee.company_id
|
|
{where}
|
|
"""
|
|
|
|
def _handle_admin_transfer_events(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
conditions: list[str] = []
|
|
params: list[object] = []
|
|
raw_company = (query.get("company_id") or [None])[0]
|
|
if raw_company:
|
|
try:
|
|
params.append(int(raw_company))
|
|
params.append(int(raw_company))
|
|
except ValueError:
|
|
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
|
return
|
|
conditions.append("(payer.company_id = ? OR payee.company_id = ?)")
|
|
raw_from = (query.get("from") or [None])[0]
|
|
if raw_from:
|
|
conditions.append("d.effective_at >= ?")
|
|
params.append(raw_from)
|
|
raw_to = (query.get("to") or [None])[0]
|
|
if raw_to:
|
|
conditions.append("d.effective_at <= ?")
|
|
params.append(raw_to)
|
|
raw_limit = (query.get("limit") or ["200"])[0]
|
|
try:
|
|
limit = max(1, min(int(raw_limit), 500))
|
|
except ValueError:
|
|
limit = 200
|
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
rows = connection.execute(
|
|
self._event_base_sql(False) + where + " ORDER BY d.id DESC LIMIT ?",
|
|
(*params, limit),
|
|
).fetchall()
|
|
items = [self._admin_event_list_item(row) for row in rows]
|
|
status = (query.get("status") or [None])[0]
|
|
if status:
|
|
items = [item for item in items if item["status"] == status]
|
|
self._send_json(200, {"status": "ok", "events": items})
|
|
finally:
|
|
connection.close()
|
|
|
|
@staticmethod
|
|
def _admin_event_list_item(row) -> dict[str, object]:
|
|
return {
|
|
"event_id": row["event_id"],
|
|
"decision_id": row["decision_id"],
|
|
"revision": row["revision"],
|
|
"classification": row["classification"],
|
|
"pairing": row["pairing"],
|
|
"status": matching.exposed_status(row),
|
|
"amount": row["amount"],
|
|
"currency": row["currency"],
|
|
"effective_at": row["effective_at"],
|
|
"mode": row["mode"],
|
|
"locked": bool(row["locked"]),
|
|
"rule_version": row["rule_version"],
|
|
"payer_company_id": row["payer_company_id"],
|
|
"payer_company_name": row["payer_company_name"],
|
|
"payee_company_id": row["payee_company_id"],
|
|
"payee_company_name": row["payee_company_name"],
|
|
"evidence_count": row["evidence_count"],
|
|
"created_at": row["created_at"],
|
|
}
|
|
|
|
def _handle_admin_transfer_event_detail(self, event_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
payload = self._admin_event_detail(connection, event_id)
|
|
if payload is None:
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
self._send_json(200, {"status": "ok", "event": payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _admin_event_detail(self, connection, event_id: int) -> dict[str, object] | None:
|
|
current = matching._current_decision_for_event(connection, event_id)
|
|
if current is None:
|
|
return None
|
|
decisions = connection.execute(
|
|
"SELECT * FROM transfer_match_decisions WHERE event_id = ? ORDER BY revision",
|
|
(event_id,),
|
|
).fetchall()
|
|
participants = matching._decision_participants(connection, current["id"])
|
|
observations = matching._decision_observations(connection, current["id"])
|
|
candidates = connection.execute(
|
|
"SELECT * FROM transfer_match_candidates WHERE decision_id = ? ORDER BY id",
|
|
(current["id"],),
|
|
).fetchall()
|
|
observation_rows = []
|
|
for observation in observations:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense,
|
|
r.own_account, r.own_name, r.counterparty_account,
|
|
r.counterparty_name, r.summary, r.reference, r.currency,
|
|
s.sheet_name, f.original_filename,
|
|
b.company_id AS batch_company_id
|
|
FROM source_rows r
|
|
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
|
JOIN import_batches b ON b.id = s.import_batch_id
|
|
JOIN source_files f ON f.id = b.source_file_id
|
|
WHERE r.id = ?
|
|
""",
|
|
(observation["source_row_id"],),
|
|
).fetchone()
|
|
observation_rows.append(
|
|
{
|
|
"source_row_id": observation["source_row_id"],
|
|
"role": observation["role"],
|
|
"source_row": row["source_row"] if row else None,
|
|
"sheet_name": row["sheet_name"] if row else None,
|
|
"original_filename": row["original_filename"] if row else None,
|
|
"company_id": row["batch_company_id"] if row else None,
|
|
"transaction_at": row["transaction_at"] if row else None,
|
|
"income": row["income"] if row else None,
|
|
"expense": row["expense"] if row else None,
|
|
"counterparty_name": row["counterparty_name"] if row else None,
|
|
}
|
|
)
|
|
history = [
|
|
{
|
|
"decision_id": decision["id"],
|
|
"revision": decision["revision"],
|
|
"classification": decision["classification"],
|
|
"pairing": decision["pairing"],
|
|
"status": matching.exposed_status(decision),
|
|
"amount": decision["amount"],
|
|
"currency": decision["currency"],
|
|
"effective_at": decision["effective_at"],
|
|
"mode": decision["mode"],
|
|
"locked": bool(decision["locked"]),
|
|
"rule_version": decision["rule_version"],
|
|
"reason": decision["reason"],
|
|
"actor_username": decision["actor_username"],
|
|
"supersedes_decision_id": decision["supersedes_decision_id"],
|
|
"created_at": decision["created_at"],
|
|
}
|
|
for decision in decisions
|
|
]
|
|
return {
|
|
"event_id": event_id,
|
|
"decision_id": current["id"],
|
|
"revision": current["revision"],
|
|
"classification": current["classification"],
|
|
"pairing": current["pairing"],
|
|
"status": matching.exposed_status(current),
|
|
"amount": current["amount"],
|
|
"currency": current["currency"],
|
|
"effective_at": current["effective_at"],
|
|
"mode": current["mode"],
|
|
"locked": bool(current["locked"]),
|
|
"rule_version": current["rule_version"],
|
|
"participants": [
|
|
{
|
|
"role": participant["role"],
|
|
"company_id": participant["company_id"],
|
|
"bank_account_id": participant["bank_account_id"],
|
|
"resolve_method": participant["resolve_method"],
|
|
"evidence": json.loads(participant["evidence"])
|
|
if participant["evidence"]
|
|
else None,
|
|
}
|
|
for participant in participants
|
|
],
|
|
"observations": observation_rows,
|
|
"candidates": [
|
|
{
|
|
"source_row_id": candidate["source_row_id"],
|
|
"rule_tier": candidate["rule_tier"],
|
|
"date_diff_days": candidate["date_diff_days"],
|
|
"account_mirror": bool(candidate["account_mirror"]),
|
|
"reference_match": candidate["reference_match"],
|
|
"summary_match": bool(candidate["summary_match"]),
|
|
"accepted": candidate["accepted"],
|
|
"reason": candidate["reason"],
|
|
}
|
|
for candidate in candidates
|
|
],
|
|
"history": history,
|
|
}
|
|
|
|
def _handle_admin_match_exceptions(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
conditions = ["d.classification IN ('unresolved', 'needs_review')"]
|
|
params: list[object] = []
|
|
raw_company = (query.get("company_id") or [None])[0]
|
|
if raw_company:
|
|
try:
|
|
company_id = int(raw_company)
|
|
except ValueError:
|
|
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
|
return
|
|
conditions.append("(payer.company_id = ? OR payee.company_id = ?)")
|
|
params.extend([company_id, company_id])
|
|
rows = connection.execute(
|
|
self._event_base_sql(False)
|
|
+ f"WHERE {' AND '.join(conditions)} ORDER BY d.id DESC LIMIT 500",
|
|
params,
|
|
).fetchall()
|
|
items = [self._admin_event_list_item(row) for row in rows]
|
|
self._send_json(200, {"status": "ok", "exceptions": items})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_dashboard(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01"
|
|
cutoff = (query.get("cutoff") or [None])[0] or None
|
|
try:
|
|
payload = dashboard.build_dashboard(
|
|
connection, from_date=from_date, cutoff=cutoff
|
|
)
|
|
except ValueError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_dashboard_company(
|
|
self, company_id: int, query: dict[str, list[str]]
|
|
) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
from_date = (query.get("from") or ["2026-01-01"])[0] or "2026-01-01"
|
|
cutoff = (query.get("cutoff") or [dashboard.today_shanghai()])[0]
|
|
try:
|
|
payload = dashboard.company_peer_groups(
|
|
connection, company_id, from_date=from_date, cutoff=cutoff
|
|
)
|
|
except KeyError:
|
|
self._send_json(404, {"status": "error", "message": "公司不存在。"})
|
|
return
|
|
except ValueError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_reconcile(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
raw_rows = data.get("source_row_ids")
|
|
row_ids: list[int] = []
|
|
if raw_rows is not None:
|
|
if not isinstance(raw_rows, list):
|
|
self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"})
|
|
return
|
|
try:
|
|
row_ids = [int(item) for item in raw_rows]
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"status": "error", "message": "source_row_ids 必须是整数数组。"})
|
|
return
|
|
raw_batch = data.get("batch_id")
|
|
if raw_batch is not None and not row_ids:
|
|
try:
|
|
batch_id = int(raw_batch)
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"status": "error", "message": "batch_id 参数无效。"})
|
|
return
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT r.id 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
|
|
WHERE s.import_batch_id = ? AND rv.review_status = 'confirmed'
|
|
ORDER BY r.id
|
|
""",
|
|
(batch_id,),
|
|
).fetchall()
|
|
row_ids = [item["id"] for item in rows]
|
|
if not row_ids:
|
|
self._send_json(400, {"status": "error", "message": "必须指定 source_row_ids 或 batch_id。"})
|
|
return
|
|
try:
|
|
result = matching.reconcile_rows(connection, row_ids, actor=user)
|
|
except Exception as exc:
|
|
self._send_json(500, {"status": "error", "message": f"重跑匹配失败:{exc}"})
|
|
return
|
|
try:
|
|
ledger_events.reconcile_bank_events(connection, actor=user)
|
|
except Exception as exc:
|
|
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
|
|
return
|
|
auth.audit(
|
|
connection, "transfer_reconcile", actor=user,
|
|
target=f"rows:{len(row_ids)}",
|
|
detail=f"created:{result['created_events']};updated:{result['updated_events']}",
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(200, {"status": "ok", "matching": result})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_transfer_decision(self, event_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
action = str(data.get("action") or "")
|
|
try:
|
|
expected_revision = (
|
|
int(data["expected_revision"])
|
|
if data.get("expected_revision") is not None
|
|
else None
|
|
)
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"status": "error", "message": "expected_revision 参数无效。"})
|
|
return
|
|
source_row_ids = data.get("source_row_ids")
|
|
if source_row_ids is not None and not isinstance(source_row_ids, list):
|
|
self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"})
|
|
return
|
|
try:
|
|
payload = matching.apply_manual_decision(
|
|
connection,
|
|
event_id,
|
|
action,
|
|
reason=str(data.get("reason") or ""),
|
|
expected_revision=expected_revision,
|
|
request_key=str(data.get("request_key") or "") or None,
|
|
actor=user,
|
|
source_row_ids=[int(item) for item in source_row_ids]
|
|
if source_row_ids
|
|
else None,
|
|
participant=data.get("participant"),
|
|
)
|
|
except matching.MatchConflictError as exc:
|
|
self._send_json(409, {"status": "error", "message": str(exc)})
|
|
return
|
|
except matching.MatchInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
try:
|
|
ledger_events.reconcile_bank_events(connection, actor=user)
|
|
except Exception as exc:
|
|
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
|
|
return
|
|
self._send_json(200, {"status": "ok", "decision": payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Personal transit mappings (admin)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_admin_personal_mappings(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
raw_company = (query.get("company_id") or [None])[0]
|
|
raw_status = (query.get("status") or [None])[0]
|
|
try:
|
|
company_id = int(raw_company) if raw_company else None
|
|
except ValueError:
|
|
self._send_json(400, {"status": "error", "message": "company_id 参数无效。"})
|
|
return
|
|
try:
|
|
rows = personal_transit.list_mappings(
|
|
connection, company_id=company_id, status=raw_status or None
|
|
)
|
|
except ValueError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok",
|
|
"mappings": [personal_transit.mapping_payload(row, full=True) for row in rows]},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_create_personal_mapping(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
try:
|
|
company_id = int(str(data.get("represented_company_id")))
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"status": "error", "message": "represented_company_id 参数无效。"})
|
|
return
|
|
try:
|
|
mapping = personal_transit.submit_mapping(
|
|
connection,
|
|
account_number=data.get("account_number"),
|
|
account_name=data.get("account_name"),
|
|
represented_company_id=company_id,
|
|
allowed_direction=str(data.get("allowed_direction") or ""),
|
|
effective_from=data.get("effective_from"),
|
|
actor=user,
|
|
)
|
|
except personal_transit.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
|
|
auth.audit(
|
|
connection, "personal_transit_submit", actor=user,
|
|
target=f"personal_transit:{mapping['id']}",
|
|
detail=f"company:{company_id}",
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok",
|
|
"mapping": personal_transit.mapping_payload(mapping, full=True)},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_review_personal_mapping(self, mapping_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
try:
|
|
mapping = personal_transit.review_mapping(
|
|
connection,
|
|
mapping_id,
|
|
str(data.get("decision") or ""),
|
|
str(data.get("reason") or ""),
|
|
user,
|
|
effective_from=data.get("effective_from"),
|
|
effective_to=data.get("effective_to"),
|
|
)
|
|
except LookupError as exc:
|
|
self._send_json(404, {"status": "error", "message": str(exc)})
|
|
return
|
|
except personal_transit.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
|
|
auth.audit(
|
|
connection, "personal_transit_review", actor=user,
|
|
target=f"personal_transit:{mapping_id}",
|
|
detail=str(data.get("decision")),
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok",
|
|
"mapping": personal_transit.mapping_payload(mapping, full=True)},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Canonical transfer events (company, own-company scope only)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_company_transfer_events(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":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
|
return
|
|
conditions = []
|
|
params: list[object] = [user["company_id"], user["company_id"]]
|
|
status = (query.get("status") or [None])[0]
|
|
raw_limit = (query.get("limit") or ["200"])[0]
|
|
try:
|
|
limit = max(1, min(int(raw_limit), 500))
|
|
except ValueError:
|
|
limit = 200
|
|
rows = connection.execute(
|
|
self._event_base_sql(True) + " ORDER BY d.id DESC LIMIT ?",
|
|
(*params, limit),
|
|
).fetchall()
|
|
items = [
|
|
self._company_event_list_item(row, user["company_id"]) for row in rows
|
|
]
|
|
if status:
|
|
items = [item for item in items if item["status"] == status]
|
|
self._send_json(200, {"status": "ok", "events": items})
|
|
finally:
|
|
connection.close()
|
|
|
|
@staticmethod
|
|
def _company_event_list_item(row, own_company_id: int) -> dict[str, object]:
|
|
counterparty_company_id = (
|
|
row["payee_company_id"]
|
|
if row["payer_company_id"] == own_company_id
|
|
else row["payer_company_id"]
|
|
)
|
|
counterparty_company_name = (
|
|
row["payee_company_name"]
|
|
if row["payer_company_id"] == own_company_id
|
|
else row["payer_company_name"]
|
|
)
|
|
return {
|
|
"event_id": row["event_id"],
|
|
"decision_id": row["decision_id"],
|
|
"revision": row["revision"],
|
|
"classification": row["classification"],
|
|
"pairing": row["pairing"],
|
|
"status": matching.exposed_status(row),
|
|
"amount": row["amount"],
|
|
"currency": row["currency"],
|
|
"effective_at": row["effective_at"],
|
|
"mode": row["mode"],
|
|
"rule_version": row["rule_version"],
|
|
"own_company_id": own_company_id,
|
|
"counterparty_company_id": counterparty_company_id,
|
|
"counterparty_company_name": counterparty_company_name,
|
|
"evidence_count": row["evidence_count"],
|
|
}
|
|
|
|
def _handle_company_workspace(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
if user["role"] != "company":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
|
return
|
|
payload = matching.company_workspace_payload(connection, user["company_id"])
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_match_exceptions(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":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
|
return
|
|
rows = matching.company_pending_unilaterals(connection, user["company_id"])
|
|
items = [
|
|
self._company_event_list_item(row, user["company_id"]) for row in rows
|
|
]
|
|
self._send_json(200, {"status": "ok", "exceptions": items})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_transfer_confirm(self, event_id: int) -> None:
|
|
"""Company confirms a pending unilateral using existing assign_participant rules."""
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
if user["role"] != "company":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
|
return
|
|
company_id = int(user["company_id"])
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
request_key = str(data.get("request_key") or "").strip() or None
|
|
# Idempotent replay: same request_key returns current workspace without re-decrement.
|
|
if request_key:
|
|
existing = connection.execute(
|
|
"""
|
|
SELECT id, actor_username
|
|
FROM transfer_match_decisions
|
|
WHERE idempotency_key = ? AND event_id = ?
|
|
""",
|
|
(request_key, event_id),
|
|
).fetchone()
|
|
if existing is not None:
|
|
if existing["actor_username"] != user["username"]:
|
|
self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
|
|
return
|
|
workspace = matching.company_workspace_payload(connection, company_id)
|
|
payload = matching._decision_payload(connection, event_id, existing["id"])
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok", "decision": payload, "workspace": workspace},
|
|
)
|
|
return
|
|
pending = matching.company_pending_unilaterals(connection, company_id)
|
|
target = next((row for row in pending if int(row["event_id"]) == event_id), None)
|
|
if target is None:
|
|
self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
|
|
return
|
|
try:
|
|
expected_revision = (
|
|
int(data["expected_revision"])
|
|
if data.get("expected_revision") is not None
|
|
else None
|
|
)
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"status": "error", "message": "expected_revision 参数无效。"})
|
|
return
|
|
try:
|
|
counterparty_company_id = int(data["counterparty_company_id"])
|
|
except (KeyError, TypeError, ValueError):
|
|
self._send_json(
|
|
400, {"status": "error", "message": "counterparty_company_id 必须是有效的公司 id。"}
|
|
)
|
|
return
|
|
if counterparty_company_id == company_id:
|
|
self._send_json(400, {"status": "error", "message": "对方公司不能是本公司。"})
|
|
return
|
|
# Own side is already on the decision; assign the opposite role.
|
|
own_is_payer = target["payer_company_id"] == company_id
|
|
role = "payee" if own_is_payer else "payer"
|
|
if target["payer_company_id"] is None and target["payee_company_id"] is None:
|
|
self._send_json(400, {"status": "error", "message": "单边流水缺少本方参与方,无法确认。"})
|
|
return
|
|
if not own_is_payer and target["payee_company_id"] != company_id:
|
|
self._send_json(404, {"status": "error", "message": "待确认单边流水不存在或已处理。"})
|
|
return
|
|
reason = str(data.get("reason") or "").strip() or "公司端确认单边流水"
|
|
try:
|
|
payload = matching.apply_manual_decision(
|
|
connection,
|
|
event_id,
|
|
"assign_participant",
|
|
reason=reason,
|
|
expected_revision=expected_revision,
|
|
request_key=request_key,
|
|
actor=user,
|
|
participant={
|
|
"role": role,
|
|
"company_id": counterparty_company_id,
|
|
},
|
|
)
|
|
except matching.MatchConflictError as exc:
|
|
self._send_json(409, {"status": "error", "message": str(exc)})
|
|
return
|
|
except matching.MatchInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
try:
|
|
ledger_events.reconcile_bank_events(connection, actor=user)
|
|
except Exception as exc:
|
|
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
|
|
return
|
|
workspace = matching.company_workspace_payload(connection, company_id)
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok", "decision": payload, "workspace": workspace},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_transfer_event_detail(self, event_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
if user["role"] != "company":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
|
return
|
|
current = matching._current_decision_for_event(connection, event_id)
|
|
if current is None:
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
participants = matching._decision_participants(connection, current["id"])
|
|
participant_company_ids = {p["company_id"] for p in participants}
|
|
if user["company_id"] not in participant_company_ids:
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
observations = matching._decision_observations(connection, current["id"])
|
|
own_rows = self._company_observation_rows(connection, observations, user["company_id"])
|
|
other_side = next(
|
|
(
|
|
p for p in participants
|
|
if p["company_id"] != user["company_id"]
|
|
),
|
|
None,
|
|
)
|
|
counterparty = None
|
|
if other_side is not None:
|
|
company = connection.execute(
|
|
"SELECT name FROM companies WHERE id = ?", (other_side["company_id"],)
|
|
).fetchone()
|
|
counterparty = {
|
|
"company_id": other_side["company_id"],
|
|
"company_name": company["name"] if company else None,
|
|
}
|
|
if other_side["bank_account_id"] is not None:
|
|
account = master_data.get_account(connection, other_side["bank_account_id"])
|
|
if account is not None:
|
|
counterparty["account_number_masked"] = master_data.mask_account_number(
|
|
account["account_number"]
|
|
)
|
|
self._send_json(
|
|
200,
|
|
{
|
|
"status": "ok",
|
|
"event": {
|
|
"event_id": event_id,
|
|
"classification": current["classification"],
|
|
"pairing": current["pairing"],
|
|
"status": matching.exposed_status(current),
|
|
"amount": current["amount"],
|
|
"currency": current["currency"],
|
|
"effective_at": current["effective_at"],
|
|
"mode": current["mode"],
|
|
"reason": current["reason"],
|
|
"counterparty": counterparty,
|
|
"observations": own_rows,
|
|
},
|
|
},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _company_observation_rows(self, connection, observations, own_company_id: int) -> list[dict]:
|
|
rows = []
|
|
for observation in observations:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense,
|
|
r.own_account, r.own_name, r.counterparty_account,
|
|
r.counterparty_name, r.summary, r.reference,
|
|
s.sheet_name, b.id AS import_batch_id,
|
|
f.original_filename, b.company_id AS batch_company_id
|
|
FROM source_rows r
|
|
JOIN sheet_batches s ON s.id = r.sheet_batch_id
|
|
JOIN import_batches b ON b.id = s.import_batch_id
|
|
JOIN source_files f ON f.id = b.source_file_id
|
|
WHERE r.id = ?
|
|
""",
|
|
(observation["source_row_id"],),
|
|
).fetchone()
|
|
if row is None or row["batch_company_id"] != own_company_id:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"source_row_id": row["id"],
|
|
"role": observation["role"],
|
|
"source_row": row["source_row"],
|
|
"sheet_name": row["sheet_name"],
|
|
"transaction_at": row["transaction_at"],
|
|
"income": row["income"],
|
|
"expense": row["expense"],
|
|
"batch_company_id": row["batch_company_id"],
|
|
"import_batch_id": row["import_batch_id"],
|
|
"original_filename": row["original_filename"],
|
|
"own_account_masked": master_data.mask_account_number(row["own_account"])
|
|
if row["own_account"]
|
|
else None,
|
|
"counterparty_account_masked": master_data.mask_account_number(
|
|
row["counterparty_account"]
|
|
)
|
|
if row["counterparty_account"]
|
|
else None,
|
|
"counterparty_name": row["counterparty_name"],
|
|
"summary": row["summary"],
|
|
"reference": row["reference"],
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# B-44 intercompany positions (admin)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _parse_window(self, query: dict[str, list[str]]) -> tuple[str, str]:
|
|
raw_from = (query.get("from") or [None])[0]
|
|
raw_cutoff = (query.get("cutoff") or [None])[0]
|
|
if raw_cutoff is None:
|
|
raw_cutoff = positions.today_shanghai()
|
|
try:
|
|
return positions.validate_window(raw_from, raw_cutoff)
|
|
except positions.PositionInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
raise
|
|
|
|
def _parse_limit(self, query: dict[str, list[str]], default: int = 50) -> int:
|
|
raw = (query.get("limit") or [str(default)])[0]
|
|
try:
|
|
return max(1, min(int(raw), 200))
|
|
except ValueError:
|
|
return default
|
|
|
|
def _parse_int(self, query: dict[str, list[str]], key: str, label: str) -> int | None:
|
|
raw = (query.get(key) or [None])[0]
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
self._send_json(400, {"status": "error", "message": f"{label}参数无效。"})
|
|
raise
|
|
|
|
def _handle_admin_intercompany_balances(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
try:
|
|
from_, cutoff = self._parse_window(query)
|
|
company_id = self._parse_int(query, "company_id", "company_id")
|
|
currency = (query.get("currency") or [None])[0]
|
|
limit = self._parse_limit(query)
|
|
cursor = (query.get("cursor") or [None])[0]
|
|
except Exception:
|
|
return
|
|
try:
|
|
payload = positions.company_balances(
|
|
connection, from_=from_, cutoff=cutoff, currency=currency,
|
|
company_id=company_id, limit=limit, cursor=cursor,
|
|
)
|
|
except positions.PositionInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_intercompany_pair(
|
|
self, company_a: int, company_b: int, query: dict[str, list[str]]
|
|
) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
if not self._companies_exist(connection, (company_a, company_b)):
|
|
self._send_json(404, {"status": "error", "message": "公司不存在。"})
|
|
return
|
|
try:
|
|
from_, cutoff = self._parse_window(query)
|
|
currency = (query.get("currency") or [None])[0]
|
|
except Exception:
|
|
return
|
|
try:
|
|
payload = positions.pair_detail(
|
|
connection, company_a, company_b,
|
|
from_=from_, cutoff=cutoff, currency=currency,
|
|
)
|
|
except positions.PositionError as exc:
|
|
self._send_json(422, {"status": "error", "message": str(exc)})
|
|
return
|
|
except positions.PositionInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _companies_exist(self, connection, ids: tuple[int, ...]) -> bool:
|
|
for company_id in ids:
|
|
row = connection.execute(
|
|
"SELECT id FROM companies WHERE id = ?", (company_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
return True
|
|
|
|
def _handle_admin_intercompany_events(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
try:
|
|
from_, cutoff = self._parse_window(query)
|
|
currency = (query.get("currency") or [None])[0]
|
|
company_id = self._parse_int(query, "company_id", "company_id")
|
|
company_a = self._parse_int(query, "company_a", "company_a")
|
|
company_b = self._parse_int(query, "company_b", "company_b")
|
|
subject = (query.get("subject") or [None])[0]
|
|
state = (query.get("state") or [None])[0]
|
|
posting_kind = (query.get("posting_kind") or [None])[0]
|
|
source_kind = (query.get("source_kind") or [None])[0]
|
|
limit = self._parse_limit(query)
|
|
cursor = (query.get("cursor") or [None])[0]
|
|
except Exception:
|
|
return
|
|
try:
|
|
payload = positions.list_events(
|
|
connection, from_=from_, cutoff=cutoff, currency=currency,
|
|
company_id=company_id, company_a=company_a, company_b=company_b,
|
|
subject=subject, state=state, posting_kind=posting_kind,
|
|
source_kind=source_kind, limit=limit, cursor=cursor,
|
|
)
|
|
except positions.PositionInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_subject_reviews(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
try:
|
|
from_, cutoff = self._parse_window(query)
|
|
company_id = self._parse_int(query, "company_id", "company_id")
|
|
limit = self._parse_limit(query)
|
|
cursor = (query.get("cursor") or [None])[0]
|
|
except Exception:
|
|
return
|
|
try:
|
|
payload = positions.subject_review_queue(
|
|
connection, from_=from_, cutoff=cutoff,
|
|
company_id=company_id, limit=limit, cursor=cursor,
|
|
)
|
|
except positions.PositionInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_manual_records(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
company_id = self._parse_int(query, "company_id", "company_id")
|
|
if company_id is None and (query.get("company_id") or [None])[0]:
|
|
return
|
|
state = (query.get("state") or [None])[0]
|
|
limit = self._parse_limit(query, 100)
|
|
try:
|
|
rows = manual_records.list_records(
|
|
connection, company_id=company_id, state=state, limit=limit
|
|
)
|
|
except manual_records.ManualInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
items = []
|
|
for row in rows:
|
|
payload = manual_records._row_payload(connection, row)
|
|
items.append(payload)
|
|
self._send_json(200, {"status": "ok", "records": items})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_intercompany_event_detail(self, event_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
payload = positions.event_detail(connection, event_id)
|
|
if payload is None:
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_intercompany_evidence(self, event_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
payload = positions.event_evidence(connection, event_id)
|
|
if payload is None:
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_subject_decision(self, event_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
try:
|
|
perspective_company_id = int(data["perspective_company_id"])
|
|
expected_revision = (
|
|
int(data["expected_revision"])
|
|
if data.get("expected_revision") is not None
|
|
else None
|
|
)
|
|
except (KeyError, TypeError, ValueError):
|
|
self._send_json(
|
|
400, {"status": "error", "message": "perspective_company_id 或 expected_revision 无效。"}
|
|
)
|
|
return
|
|
try:
|
|
action = str(data.get("action") or "confirm")
|
|
if action in ("return", "exception"):
|
|
payload = subjects.park_subject(
|
|
connection,
|
|
event_id,
|
|
disposition=action,
|
|
reason=str(data.get("reason") or ""),
|
|
expected_revision=expected_revision,
|
|
request_key=str(data.get("request_key") or "") or None,
|
|
actor=user,
|
|
)
|
|
else:
|
|
payload = subjects.confirm_subject(
|
|
connection,
|
|
event_id,
|
|
perspective_company_id=perspective_company_id,
|
|
subject_code=str(data.get("subject_code") or ""),
|
|
reason=str(data.get("reason") or ""),
|
|
expected_revision=expected_revision,
|
|
request_key=str(data.get("request_key") or "") or None,
|
|
actor=user,
|
|
)
|
|
except subjects.SubjectConflictError as exc:
|
|
self._send_json(409, {"status": "error", "message": str(exc)})
|
|
return
|
|
except subjects.SubjectInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
auth.audit(
|
|
connection, "subject_confirm", actor=user,
|
|
target=f"ledger_event:{event_id}",
|
|
detail=f"revision:{payload['revision']};subject:{payload['subject_code']}",
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(200, {"status": "ok", "revision": payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_intercompany_adjustment(self, event_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
action = str(data.get("action") or "")
|
|
reason = str(data.get("reason") or "")
|
|
request_key = str(data.get("request_key") or "") or None
|
|
if not reason:
|
|
self._send_json(400, {"status": "error", "message": "必须填写操作原因。"})
|
|
return
|
|
try:
|
|
if action == "reverse":
|
|
event_id, _revision_id = ledger_events.create_reversal(
|
|
connection, event_id,
|
|
source_kind="adjustment",
|
|
source_revision_token=None,
|
|
effective_at=data.get("effective_at") or None,
|
|
reason=reason, actor=user, idempotency_key=request_key,
|
|
)
|
|
outcome: dict[str, object] = {
|
|
"action": "reverse", "ledger_event_id": event_id,
|
|
}
|
|
elif action == "adjust":
|
|
try:
|
|
effective_at = str(data.get("effective_at") or "")
|
|
amount = str(data.get("amount") or "")
|
|
currency = str(data.get("currency") or "")
|
|
payer = int(data["payer_company_id"])
|
|
payee = int(data["payee_company_id"])
|
|
perspective = int(data["perspective_company_id"])
|
|
subject_code = str(data.get("subject_code") or "")
|
|
except (KeyError, TypeError, ValueError):
|
|
self._send_json(
|
|
400, {"status": "error", "message": "adjust 参数不完整或无效。"}
|
|
)
|
|
return
|
|
event_id, _revision_id = ledger_events.create_adjustment(
|
|
connection, event_id,
|
|
effective_at=effective_at, amount=amount, currency=currency,
|
|
payer_company_id=payer, payee_company_id=payee,
|
|
perspective_company_id=perspective, subject_code=subject_code,
|
|
reason=reason, actor=user, idempotency_key=request_key,
|
|
)
|
|
outcome = {"action": "adjust", "ledger_event_id": event_id}
|
|
elif action == "reopen":
|
|
event_id, _revision_id = ledger_events.reopen_subject(
|
|
connection, event_id, reason=reason, actor=user,
|
|
idempotency_key=request_key,
|
|
)
|
|
outcome = {"action": "reopen", "ledger_event_id": event_id}
|
|
else:
|
|
self._send_json(
|
|
400,
|
|
{"status": "error", "message": "action 必须是 reverse、adjust 或 reopen。"},
|
|
)
|
|
return
|
|
except ledger_events.LedgerConflictError as exc:
|
|
self._send_json(409, {"status": "error", "message": str(exc)})
|
|
return
|
|
except ledger_events.LedgerInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
auth.audit(
|
|
connection, f"ledger_{action}", actor=user,
|
|
target=f"ledger_event:{event_id}", detail=reason, ip=self._client_ip,
|
|
)
|
|
self._send_json(200, {"status": "ok", **outcome})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_manual_record_decision(self, record_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
try:
|
|
expected_decision_id = (
|
|
int(data["expected_decision_id"])
|
|
if data.get("expected_decision_id") is not None
|
|
else None
|
|
)
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"status": "error", "message": "expected_decision_id 无效。"})
|
|
return
|
|
try:
|
|
payload = manual_records.decide(
|
|
connection,
|
|
record_id,
|
|
str(data.get("action") or ""),
|
|
reason=str(data.get("reason") or ""),
|
|
expected_decision_id=expected_decision_id,
|
|
request_key=str(data.get("request_key") or "") or None,
|
|
actor=user,
|
|
subject_code=data.get("subject_code"),
|
|
target_ledger_event_id=data.get("target_ledger_event_id"),
|
|
effective_at=data.get("effective_at"),
|
|
)
|
|
except manual_records.ManualConflictError as exc:
|
|
self._send_json(409, {"status": "error", "message": str(exc)})
|
|
return
|
|
except manual_records.ManualInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", "decision": payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# B-44 intercompany positions (company, own-company scope only)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _company_intercompany_scope(self, connection):
|
|
"""Reject company users with 403; return the session company id."""
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return None, None
|
|
if user["role"] != "company":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
|
|
return None, None
|
|
return user, user["company_id"]
|
|
|
|
def _handle_company_intercompany_summary(self, query: dict[str, list[str]]) -> None:
|
|
"""Own-company transfer summary; company_id is session-only (HEL-175)."""
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
# Front-end must never supply company_id; reject even matching values.
|
|
if (query.get("company_id") or [None])[0] is not None:
|
|
self._send_json(
|
|
400,
|
|
{"status": "error", "message": "不允许传入 company_id 参数。"},
|
|
)
|
|
return
|
|
as_of = (query.get("as_of") or [None])[0]
|
|
try:
|
|
payload = company_transfers.company_intercompany_summary(
|
|
connection, company_id=int(company_id), as_of=as_of
|
|
)
|
|
except company_transfers.TransferSummaryInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _reject_forged_company_id(self, query: dict[str, list[str]]) -> bool:
|
|
"""Return True when the handler already sent a 400 for company_id."""
|
|
if (query.get("company_id") or [None])[0] is not None:
|
|
self._send_json(
|
|
400,
|
|
{"status": "error", "message": "不允许传入 company_id 参数。"},
|
|
)
|
|
return True
|
|
return False
|
|
|
|
def _handle_company_transfer_summary_events(
|
|
self, query: dict[str, list[str]]
|
|
) -> None:
|
|
"""HEL-176 filtered transfer-detail list (keyset pagination)."""
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
if self._reject_forged_company_id(query):
|
|
return
|
|
try:
|
|
payload = company_transfers.company_intercompany_events(
|
|
connection,
|
|
company_id=int(company_id),
|
|
from_=(query.get("from") or [None])[0],
|
|
to=(query.get("to") or [None])[0],
|
|
counterparty_id=(query.get("counterparty_id") or [None])[0],
|
|
direction=(query.get("direction") or [None])[0],
|
|
state=(query.get("state") or [None])[0],
|
|
limit=(query.get("limit") or [None])[0],
|
|
cursor=(query.get("cursor") or [None])[0],
|
|
)
|
|
except company_transfers.TransferSummaryInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_intercompany_export_csv(
|
|
self, query: dict[str, list[str]]
|
|
) -> None:
|
|
"""Confirmed-only CSV export; session company scope + audit (HEL-176)."""
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
if self._reject_forged_company_id(query):
|
|
return
|
|
# Pending must never leave via export, even if a client sends state=.
|
|
if (query.get("state") or [None])[0] not in (None, "", "confirmed"):
|
|
self._send_json(
|
|
400,
|
|
{
|
|
"status": "error",
|
|
"message": "导出仅支持已确认明细,不允许导出待确认。",
|
|
},
|
|
)
|
|
return
|
|
try:
|
|
items, meta = company_transfers.company_intercompany_export_rows(
|
|
connection,
|
|
company_id=int(company_id),
|
|
from_=(query.get("from") or [None])[0],
|
|
to=(query.get("to") or [None])[0],
|
|
counterparty_id=(query.get("counterparty_id") or [None])[0],
|
|
direction=(query.get("direction") or [None])[0],
|
|
)
|
|
except company_transfers.TransferSummaryInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
content = company_transfers.render_intercompany_export_csv(items)
|
|
detail_parts = [
|
|
f"rows:{meta['row_count']}",
|
|
f"from:{meta['start']}",
|
|
f"to:{meta['end']}",
|
|
"state:confirmed",
|
|
]
|
|
if meta["counterparty_id"] is not None:
|
|
detail_parts.append(f"counterparty_id:{meta['counterparty_id']}")
|
|
if meta["direction"] is not None:
|
|
detail_parts.append(f"direction:{meta['direction']}")
|
|
auth.audit(
|
|
connection,
|
|
"export_intercompany_csv",
|
|
actor=user,
|
|
target=f"company:{company_id}",
|
|
detail=";".join(detail_parts),
|
|
ip=self._client_ip,
|
|
)
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/csv; charset=utf-8")
|
|
self.send_header(
|
|
"Content-Disposition",
|
|
'attachment; filename="intercompany-export.csv"',
|
|
)
|
|
self.send_header("Content-Length", str(len(content)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_intercompany_balances(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
try:
|
|
from_, cutoff = self._parse_window(query)
|
|
currency = (query.get("currency") or [None])[0]
|
|
limit = self._parse_limit(query)
|
|
cursor = (query.get("cursor") or [None])[0]
|
|
except Exception:
|
|
return
|
|
try:
|
|
payload = positions.company_balances(
|
|
connection, from_=from_, cutoff=cutoff, currency=currency,
|
|
company_id=company_id, limit=limit, cursor=cursor,
|
|
)
|
|
counterparties = self._company_counterparty_summary(
|
|
connection, company_id, from_, cutoff, currency
|
|
)
|
|
except positions.PositionInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload, "counterparties": counterparties})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _company_counterparty_summary(
|
|
self, connection, company_id: int, from_: str, cutoff: str, currency: str | None
|
|
) -> list[dict[str, object]]:
|
|
events = positions.load_events(
|
|
connection, from_=from_, cutoff=cutoff, currency=currency,
|
|
company_id=company_id,
|
|
)
|
|
# Buckets are keyed by ``(counterparty, currency)``: amounts never mix
|
|
# across currencies, so one counterparty with CNY and USD yields two rows.
|
|
buckets: dict[tuple[int, str], dict[str, object]] = {}
|
|
for event in events:
|
|
counterparty = (
|
|
event["payee_company_id"]
|
|
if int(event["payer_company_id"]) == int(company_id)
|
|
else event["payer_company_id"]
|
|
)
|
|
name = (
|
|
event["payee_company_name"]
|
|
if int(event["payer_company_id"]) == int(company_id)
|
|
else event["payer_company_name"]
|
|
)
|
|
key = (int(counterparty), event["currency"])
|
|
bucket = buckets.setdefault(
|
|
key,
|
|
{
|
|
"counterparty_company_id": counterparty,
|
|
"counterparty_company_name": name,
|
|
"currency": event["currency"],
|
|
"signed": 0,
|
|
"event_count": 0,
|
|
},
|
|
)
|
|
bucket["signed"] += positions.signed_amount(event, company_id)
|
|
bucket["event_count"] += 1
|
|
|
|
# Counterparties with pending-subject exposure also show up so the
|
|
# company portal never hides unconfirmed balances.
|
|
pending = connection.execute(
|
|
"""
|
|
SELECT p.payer_company_id, p.payee_company_id, p.amount, p.currency,
|
|
cpayer.name AS payer_company_name, cpayee.name AS payee_company_name
|
|
FROM current_ledger_event_revisions cur
|
|
JOIN ledger_event_revisions p ON p.id = cur.revision_id
|
|
JOIN companies cpayer ON cpayer.id = p.payer_company_id
|
|
JOIN companies cpayee ON cpayee.id = p.payee_company_id
|
|
WHERE p.state = 'pending_subject'
|
|
AND p.effective_at >= ? AND p.effective_at <= ?
|
|
AND (p.payer_company_id = ? OR p.payee_company_id = ?)
|
|
""",
|
|
(from_, cutoff + "T23:59:59", company_id, company_id),
|
|
).fetchall()
|
|
for row in pending:
|
|
if currency and row["currency"] != currency:
|
|
continue
|
|
counterparty = (
|
|
row["payee_company_id"]
|
|
if int(row["payer_company_id"]) == int(company_id)
|
|
else row["payer_company_id"]
|
|
)
|
|
name = (
|
|
row["payee_company_name"]
|
|
if int(row["payer_company_id"]) == int(company_id)
|
|
else row["payer_company_name"]
|
|
)
|
|
buckets.setdefault(
|
|
(int(counterparty), row["currency"]),
|
|
{
|
|
"counterparty_company_id": counterparty,
|
|
"counterparty_company_name": name,
|
|
"currency": row["currency"],
|
|
"signed": 0,
|
|
"event_count": 0,
|
|
},
|
|
)
|
|
items = []
|
|
for (counterparty, cur), bucket in sorted(buckets.items()):
|
|
signed = bucket["signed"]
|
|
unresolved = positions.unresolved_for_company(
|
|
connection, company_id, cutoff, currency=cur,
|
|
counterparty_filter=(company_id, counterparty),
|
|
)
|
|
direction = "receivable" if signed > 0 else ("payable" if signed < 0 else None)
|
|
items.append(
|
|
{
|
|
"counterparty_company_id": counterparty,
|
|
"counterparty_company_name": bucket["counterparty_company_name"],
|
|
"currency": cur,
|
|
"result": {
|
|
"kind": "period_net_change",
|
|
"signed_amount": str(signed),
|
|
"direction": direction,
|
|
"label": "期间净变动",
|
|
},
|
|
"unresolved": unresolved,
|
|
"event_count": bucket["event_count"],
|
|
}
|
|
)
|
|
return items
|
|
|
|
def _handle_company_intercompany_pair(
|
|
self, counterparty_id: int, query: dict[str, list[str]]
|
|
) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
if not self._companies_exist(connection, (counterparty_id,)):
|
|
self._send_json(404, {"status": "error", "message": "公司不存在。"})
|
|
return
|
|
try:
|
|
from_, cutoff = self._parse_window(query)
|
|
currency = (query.get("currency") or [None])[0]
|
|
except Exception:
|
|
return
|
|
try:
|
|
payload = positions.pair_detail(
|
|
connection, company_id, counterparty_id,
|
|
from_=from_, cutoff=cutoff, currency=currency,
|
|
)
|
|
except positions.PositionError as exc:
|
|
self._send_json(422, {"status": "error", "message": str(exc)})
|
|
return
|
|
except positions.PositionInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_intercompany_events(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
try:
|
|
from_, cutoff = self._parse_window(query)
|
|
currency = (query.get("currency") or [None])[0]
|
|
subject = (query.get("subject") or [None])[0]
|
|
state = (query.get("state") or [None])[0]
|
|
posting_kind = (query.get("posting_kind") or [None])[0]
|
|
source_kind = (query.get("source_kind") or [None])[0]
|
|
limit = self._parse_limit(query)
|
|
cursor = (query.get("cursor") or [None])[0]
|
|
except Exception:
|
|
return
|
|
try:
|
|
payload = positions.list_events(
|
|
connection, from_=from_, cutoff=cutoff, currency=currency,
|
|
subject=subject, state=state, posting_kind=posting_kind,
|
|
source_kind=source_kind, viewer_company_id=company_id,
|
|
limit=limit, cursor=cursor,
|
|
)
|
|
except positions.PositionInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_intercompany_event_detail(self, event_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
payload = positions.event_detail(connection, event_id)
|
|
if payload is None:
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
event = payload["event"]
|
|
if company_id not in (event["payer_company_id"], event["payee_company_id"]):
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
event.update(
|
|
positions.event_payload(
|
|
connection,
|
|
connection.execute(
|
|
positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?",
|
|
(event_id,),
|
|
).fetchone(),
|
|
viewer_company_id=company_id,
|
|
)
|
|
)
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_intercompany_evidence(self, event_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
detail = positions.event_detail(connection, event_id)
|
|
if detail is None:
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
event = detail["event"]
|
|
if company_id not in (event["payer_company_id"], event["payee_company_id"]):
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
payload = positions.event_evidence(
|
|
connection, event_id, viewer_company_id=company_id
|
|
)
|
|
if payload is None:
|
|
self._send_json(404, {"status": "error", "message": "事件不存在。"})
|
|
return
|
|
self._send_json(200, {"status": "ok", **payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_companies(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
rows = connection.execute(
|
|
"SELECT id, name FROM companies ORDER BY id"
|
|
).fetchall()
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok", "companies": [dict(row) for row in rows]},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_manual_records(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
state = (query.get("state") or [None])[0]
|
|
limit = self._parse_limit(query, 100)
|
|
try:
|
|
rows = manual_records.list_records(
|
|
connection, company_id=company_id, state=state, limit=limit
|
|
)
|
|
except manual_records.ManualInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
# A company only ever sees its own submissions, never declarations
|
|
# where it is merely the counterparty.
|
|
own = [
|
|
manual_records._row_payload(connection, row)
|
|
for row in rows
|
|
if row["company_id"] == company_id
|
|
]
|
|
self._send_json(200, {"status": "ok", "records": own})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_manual_records_submit(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user, company_id = self._company_intercompany_scope(connection)
|
|
if company_id is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
try:
|
|
payload = manual_records.submit(
|
|
connection,
|
|
company_id=company_id,
|
|
counterparty_company_id=int(data["counterparty_company_id"]),
|
|
occurred_at=str(data.get("occurred_at") or ""),
|
|
direction=str(data.get("direction") or ""),
|
|
amount=str(data.get("amount") or ""),
|
|
currency=str(data.get("currency") or ""),
|
|
funding_source=str(data.get("funding_source") or ""),
|
|
requested_subject=str(data.get("requested_subject") or ""),
|
|
request_key=str(data.get("request_key") or ""),
|
|
actor=user,
|
|
bank_account_id=data.get("bank_account_id"),
|
|
personal_transit_mapping_id=data.get("personal_transit_mapping_id"),
|
|
related_source_row_id=data.get("related_source_row_id"),
|
|
summary=data.get("summary"),
|
|
reason=data.get("reason"),
|
|
evidence=data.get("evidence"),
|
|
)
|
|
except manual_records.ManualConflictError as exc:
|
|
self._send_json(409, {"status": "error", "message": str(exc)})
|
|
return
|
|
except manual_records.ManualInputError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
auth.audit(
|
|
connection, "manual_submit", actor=user,
|
|
target=f"manual_record:{payload['id']}",
|
|
detail=f"amount:{payload['amount']};currency:{payload['currency']}",
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(200, {"status": "ok", "record": payload})
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Request/response plumbing
|
|
# ------------------------------------------------------------------
|
|
|
|
def _read_json_body(self) -> dict[str, object] | None:
|
|
try:
|
|
content_length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
content_length = 0
|
|
if content_length <= 0 or content_length > 1024 * 1024:
|
|
self._send_json(400, {"status": "error", "message": "请求体无效。"})
|
|
return None
|
|
try:
|
|
data = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
self._send_json(400, {"status": "error", "message": "请求体不是有效的 JSON。"})
|
|
return None
|
|
if not isinstance(data, dict):
|
|
self._send_json(400, {"status": "error", "message": "请求体必须是 JSON 对象。"})
|
|
return None
|
|
return data
|
|
|
|
# ------------------------------------------------------------------
|
|
# Reminders
|
|
# ------------------------------------------------------------------
|
|
|
|
def _handle_admin_reminders_pending(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
findings = reminders.scan_findings(connection)
|
|
items = [reminders.finding_to_dict(item) for item in findings]
|
|
companies = len({item["company_id"] for item in items})
|
|
self._send_json(
|
|
200,
|
|
{
|
|
"status": "ok",
|
|
"findings": items,
|
|
"summary": {"companies": companies, "items": len(items)},
|
|
},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_reminders_scan(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
result = reminders.run_scan(connection, actor=user, ip=self._client_ip)
|
|
items = [reminders.finding_to_dict(item) for item in result["findings"]]
|
|
companies = len({item["company_id"] for item in items})
|
|
self._send_json(
|
|
200,
|
|
{
|
|
"status": "ok",
|
|
"findings": items,
|
|
"counts": result["counts"],
|
|
"summary": {"companies": companies, "items": len(items)},
|
|
},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_reminders_send(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
raw_keys = data.get("dedupe_keys")
|
|
if not isinstance(raw_keys, list) or not raw_keys:
|
|
self._send_json(400, {"status": "error", "message": "请指定 dedupe_keys。"})
|
|
return
|
|
dedupe_keys = [str(key) for key in raw_keys]
|
|
sent = reminders.deliver_many(
|
|
connection, dedupe_keys, actor=user, ip=self._client_ip
|
|
)
|
|
self._send_json(200, {"status": "ok", "sent": sent, "count": len(sent)})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_reminders_manual(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
try:
|
|
company_id = int(data.get("company_id"))
|
|
except (TypeError, ValueError):
|
|
self._send_json(400, {"status": "error", "message": "company_id 无效。"})
|
|
return
|
|
display_type = str(data.get("display_type") or data.get("type") or "").strip()
|
|
content = str(data.get("content") or "").strip()
|
|
deadline = str(data.get("deadline") or "").strip() or None
|
|
if not display_type or not content:
|
|
self._send_json(400, {"status": "error", "message": "类型与内容不能为空。"})
|
|
return
|
|
try:
|
|
reminder_id = reminders.send_manual(
|
|
connection,
|
|
company_id=company_id,
|
|
display_type=display_type,
|
|
content=content,
|
|
deadline=deadline,
|
|
actor=user,
|
|
ip=self._client_ip,
|
|
)
|
|
except ValueError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok", "reminder_id": reminder_id})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_reminder_resend(self, reminder_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
try:
|
|
reminders.resend_reminder(
|
|
connection, reminder_id, actor=user, ip=self._client_ip
|
|
)
|
|
except ValueError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
self._send_json(200, {"status": "ok"})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_reminders(self, query: dict[str, list[str]]) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
source = (query.get("source") or [None])[0]
|
|
if source not in {None, "auto", "manual", "system"}:
|
|
self._send_json(400, {"status": "error", "message": "source 参数无效。"})
|
|
return
|
|
filter_source = "auto" if source == "system" else source
|
|
items = reminders.list_admin_reminders(connection, source=filter_source)
|
|
stats = {"unread": 0, "doing": 0, "done": 0}
|
|
for item in items:
|
|
stats[item["status_ui"]] += 1
|
|
self._send_json(200, {"status": "ok", "reminders": items, "stats": stats})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_reminder_detail(self, reminder_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
detail = reminders.get_reminder_detail(connection, reminder_id)
|
|
if detail is None:
|
|
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
|
|
return
|
|
self._send_json(200, {"status": "ok", "reminder": detail})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_reminder_settings_get(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
self._send_json(200, {"status": "ok", "settings": reminders.get_settings(connection)})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_admin_reminder_settings_put(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_admin(connection)
|
|
if user is None:
|
|
return
|
|
data = self._read_json_body()
|
|
if data is None:
|
|
return
|
|
updates = data.get("settings") if isinstance(data.get("settings"), dict) else data
|
|
if not isinstance(updates, dict):
|
|
self._send_json(400, {"status": "error", "message": "settings 格式无效。"})
|
|
return
|
|
try:
|
|
settings = reminders.update_settings(connection, updates)
|
|
except ValueError as exc:
|
|
self._send_json(400, {"status": "error", "message": str(exc)})
|
|
return
|
|
auth.audit(
|
|
connection,
|
|
"reminder_settings_update",
|
|
actor=user,
|
|
detail=json.dumps(settings, ensure_ascii=False),
|
|
ip=self._client_ip,
|
|
)
|
|
self._send_json(200, {"status": "ok", "settings": settings})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_reminders(self) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
if user["role"] != "company":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
|
|
return
|
|
items = reminders.list_company_reminders(connection, int(user["company_id"]))
|
|
unread = reminders.company_unread_count(connection, int(user["company_id"]))
|
|
stats = {"unread": 0, "doing": 0, "done": 0}
|
|
for item in items:
|
|
stats[item["status_ui"]] += 1
|
|
self._send_json(
|
|
200,
|
|
{"status": "ok", "reminders": items, "unread_count": unread, "stats": stats},
|
|
)
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_reminder_detail(self, reminder_id: int) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
if user["role"] != "company":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
|
|
return
|
|
detail = reminders.get_reminder_detail(connection, reminder_id)
|
|
if detail is None or int(detail["company_id"]) != int(user["company_id"]):
|
|
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
|
|
return
|
|
self._send_json(200, {"status": "ok", "reminder": detail})
|
|
finally:
|
|
connection.close()
|
|
|
|
def _handle_company_reminder_status(self, reminder_id: int, action: str) -> None:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
user = self._require_user(connection)
|
|
if user is None:
|
|
return
|
|
if user["role"] != "company":
|
|
self._send_json(403, {"status": "error", "message": "该操作仅限公司端。"})
|
|
return
|
|
new_status = "acknowledged" if action == "acknowledge" else "resolved"
|
|
ok = reminders.update_reminder_status(
|
|
connection,
|
|
reminder_id,
|
|
new_status,
|
|
company_id=int(user["company_id"]),
|
|
actor=user,
|
|
)
|
|
if not ok:
|
|
self._send_json(404, {"status": "error", "message": "提醒不存在。"})
|
|
return
|
|
self._send_json(200, {"status": "ok"})
|
|
finally:
|
|
connection.close()
|
|
|
|
@staticmethod
|
|
def _sheet_payload(row) -> dict[str, object]:
|
|
"""Serialize one persisted per-sheet review record."""
|
|
return {
|
|
"sheet_name": row["sheet_name"],
|
|
"outcome": row["outcome"],
|
|
"review_status": row["review_status"],
|
|
"message": row["message"],
|
|
"scanned_rows": row["scanned_rows"],
|
|
"candidate_headers": json.loads(row["candidate_headers"])
|
|
if row["candidate_headers"]
|
|
else [],
|
|
"review_reason": row["review_reason"],
|
|
"bank": row["bank_name"],
|
|
"template": row["template_id"],
|
|
"header_row": row["header_row"],
|
|
"period_start": row["period_start"],
|
|
"period_end": row["period_end"],
|
|
"transactions": row["transaction_count"] or 0,
|
|
"warnings": json.loads(row["warnings"]) if row["warnings"] else [],
|
|
}
|
|
|
|
@staticmethod
|
|
def _import_payload(connection, result) -> dict[str, object]:
|
|
"""Build the parse response from persisted state, not from memory.
|
|
|
|
All worksheet results are returned (no more ``batches[0]``). A
|
|
cross-company duplicate is opaque: only the generic status, the
|
|
uploader's own batch id and the sha256 are returned, never the other
|
|
company's batch id, summary or diagnostics.
|
|
"""
|
|
payload: dict[str, object] = {
|
|
"status": result.status,
|
|
"batch_id": result.batch_id,
|
|
"file_sha256": result.sha256,
|
|
}
|
|
if result.message:
|
|
payload["message"] = result.message
|
|
if result.status == "duplicate" and not result.duplicate_same_company:
|
|
return payload
|
|
|
|
file_row = connection.execute(
|
|
"SELECT original_filename FROM source_files WHERE id = ?",
|
|
(result.source_file_id,),
|
|
).fetchone()
|
|
if file_row is not None:
|
|
payload["original_filename"] = file_row["original_filename"]
|
|
sheets = importing.sheet_review_rows(connection, result.batch_id)
|
|
payload["sheets"] = [AppHandler._sheet_payload(row) for row in sheets]
|
|
return payload
|
|
|
|
def _send_json(
|
|
self,
|
|
status: int,
|
|
payload: dict[str, object],
|
|
*,
|
|
session_token: str | None = None,
|
|
clear_session: bool = False,
|
|
) -> None:
|
|
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(content)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
if session_token is not None:
|
|
self._set_session_cookie(session_token)
|
|
if clear_session:
|
|
self._clear_session_cookie()
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
|
|
|
|
def _parse_scan_time(value: str) -> tuple[int, int]:
|
|
parts = value.strip().split(":")
|
|
if len(parts) != 2:
|
|
return 8, 0
|
|
try:
|
|
return int(parts[0]), int(parts[1])
|
|
except ValueError:
|
|
return 8, 0
|
|
|
|
|
|
def _seconds_until_scan(scan_time: str) -> float:
|
|
hour, minute = _parse_scan_time(scan_time)
|
|
now = datetime.now(timezone.utc)
|
|
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
if target <= now:
|
|
target += timedelta(days=1)
|
|
return (target - now).total_seconds()
|
|
|
|
|
|
def _scheduled_reminder_scan_loop() -> None:
|
|
"""Daily scan thread; audit-only, does not auto-deliver to companies."""
|
|
while True:
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
settings = reminders.get_settings(connection)
|
|
scan_time = settings.get("scan_time", "08:00")
|
|
finally:
|
|
connection.close()
|
|
time.sleep(max(1.0, _seconds_until_scan(scan_time)))
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
reminders.run_scan(connection, actor=None, ip=None)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def ensure_bootstrap_admin(connection) -> str | None:
|
|
"""Create the first admin when none exists; returns the generated password."""
|
|
existing = connection.execute(
|
|
"SELECT 1 FROM users WHERE role = 'admin' LIMIT 1"
|
|
).fetchone()
|
|
if existing is not None:
|
|
return None
|
|
username = os.environ.get("APP_ADMIN_USERNAME", "group-admin")
|
|
env_password = os.environ.get("APP_BOOTSTRAP_ADMIN_PASSWORD")
|
|
password = env_password or auth.generate_initial_password()
|
|
user_id = auth.create_user(connection, username, password, "admin")
|
|
auth.audit(connection, "bootstrap_admin", target=f"user:{user_id}", detail=username)
|
|
return None if env_password else password
|
|
|
|
|
|
def main() -> None:
|
|
host = os.environ.get("APP_HOST", "0.0.0.0")
|
|
port = int(os.environ.get("APP_PORT", "4173"))
|
|
connection = connect(DB_PATH)
|
|
try:
|
|
migrate(connection)
|
|
initial_password = ensure_bootstrap_admin(connection)
|
|
finally:
|
|
connection.close()
|
|
if initial_password is not None:
|
|
# Printed once to stdout; never written to any log file.
|
|
print(f"Bootstrap admin initial password (shown once): {initial_password}")
|
|
server = ThreadingHTTPServer((host, port), AppHandler)
|
|
scan_thread = threading.Thread(target=_scheduled_reminder_scan_loop, daemon=True)
|
|
scan_thread.start()
|
|
print(f"Serving on http://{host}:{port}")
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|