from __future__ import annotations import csv import io import json import os from pathlib import Path import re from http.cookies import SimpleCookie from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse from bank_importer import ( auth, dashboard, importing, ledger_events, manual_records, master_data, matching, multipart, personal_transit, positions, 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/audit-log": self._handle_admin_audit_log(query) return if path == "/api/admin/settings": self._handle_admin_settings() return if path == "/api/admin/reminders": self._handle_admin_reminders(query) return if path == "/api/admin/reminders/pending": self._handle_admin_reminder_pending(query) 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 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 # 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 # 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": 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/reminders/send": self._handle_admin_send_reminders() 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 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"] 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_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() # ------------------------------------------------------------------ # Reminder management (admin) # ------------------------------------------------------------------ @staticmethod def _reminder_payload(row) -> dict[str, object]: payload = { "id": row["id"], "company_id": row["company_id"], "company_name": row["company_name"], "kind": row["kind"], "content": row["content"], "deadline": row["deadline"], "source": row["source"], "status": row["status"], "actor_username": row["actor_username"], "created_at": row["created_at"], } return payload 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 conditions: list[str] = [] params: list[object] = [] raw_company = (query.get("company_id") or [None])[0] if raw_company: try: params.append(int(raw_company)) except ValueError: self._send_json(400, {"status": "error", "message": "company_id 参数无效。"}) return conditions.append("r.company_id = ?") 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( f""" SELECT r.id, r.company_id, c.name AS company_name, r.kind, r.content, r.deadline, r.source, r.status, r.actor_username, r.created_at FROM reminders r JOIN companies c ON c.id = r.company_id {where} ORDER BY r.id DESC LIMIT ? """, (*params, limit), ).fetchall() self._send_json( 200, {"status": "ok", "reminders": [self._reminder_payload(row) for row in rows]}, ) finally: connection.close() def _handle_admin_reminder_pending(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] try: company_id = int(raw_company) except (TypeError, ValueError): self._send_json(400, {"status": "error", "message": "company_id 参数无效。"}) return company = connection.execute( "SELECT id, name FROM companies WHERE id = ?", (company_id,) ).fetchone() if company is None: self._send_json(404, {"status": "error", "message": "公司不存在。"}) return items = settings.pending_items(connection, company_id) self._send_json( 200, { "status": "ok", "company_id": company_id, "company_name": company["name"], "items": items, }, ) finally: connection.close() def _handle_admin_send_reminders(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("company_id"))) except (TypeError, ValueError): self._send_json(400, {"status": "error", "message": "company_id 参数无效。"}) return company = connection.execute( "SELECT id, name FROM companies WHERE id = ?", (company_id,) ).fetchone() if company is None: self._send_json(404, {"status": "error", "message": "公司不存在。"}) return try: created, deadline = settings.send_reminders( connection, company_id, user ) except ValueError as exc: self._send_json(400, {"status": "error", "message": str(exc)}) return if not created: self._send_json( 400, {"status": "error", "message": "该公司当前没有待提醒事项,无需发送。"}, ) return auth.audit( connection, "reminder_send", actor=user, target=f"company:{company_id}", detail=f"items:{len(created)}", ip=self._client_ip, ) self._send_json( 200, { "status": "ok", "company_id": company_id, "company_name": company["name"], "deadline": deadline, "reminders": created, }, ) 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"], "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_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 params: list[object] = [user["company_id"], user["company_id"]] rows = connection.execute( self._event_base_sql(True) + "AND d.classification IN ('unresolved', 'needs_review') " "ORDER BY d.id DESC LIMIT 500", params, ).fetchall() 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_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.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 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"], "own_account_masked": master_data.mask_account_number(row["own_account"]) if row["own_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_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 @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 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) print(f"Serving on http://{host}:{port}") server.serve_forever() if __name__ == "__main__": main()