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