Files
caiwuzongzhang/server.py
总工andmultica-agent 85e11a4f72 HEL-222: 整合自动提醒引擎(212b1f9)到现行测试版 d6f39e0
- 迁移链追加 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>
2026-08-28 15:21:27 +00:00

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()