"""Company-portal intercompany transfer summary / detail / export (HEL-175/176). Confirmed totals reuse the authoritative ``eligible_intercompany_events`` view (intercompany + paired or locked). Pending counts/amounts are listed separately and never enter outflow, inflow or net. All money math uses ``Decimal`` on stored TEXT amounts — never float or SQLite SUM. Detail listing (HEL-176) applies counterparty / date / direction / state filters inside SQL before LIMIT, and uses keyset pagination on (effective_at, event_id) descending. Export only ships confirmed rows. """ from __future__ import annotations import base64 import csv import io from datetime import datetime, timedelta, timezone from decimal import Decimal, InvalidOperation import re import sqlite3 from . import calculation, matching, settings _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") _ZERO = Decimal("0.00") _DEFAULT_PAGE = 50 _MAX_PAGE = 200 _MAX_EXPORT_ROWS = 20000 class TransferSummaryInputError(ValueError): """Invalid query parameters (mapped to HTTP 400).""" def today_shanghai() -> str: return datetime.now(timezone(timedelta(hours=8))).date().isoformat() def _validate_date(value: object, label: str) -> str: text = str(value or "").strip() if not text or not _DATE_RE.fullmatch(text): raise TransferSummaryInputError(f"{label}必须是 YYYY-MM-DD 格式。") try: datetime.strptime(text, "%Y-%m-%d") except ValueError as exc: raise TransferSummaryInputError(f"{label}不是有效日期。") from exc return text def _money(value: Decimal) -> str: return format(value.quantize(Decimal("0.01")), "f") def _as_decimal(raw: object) -> Decimal: try: return Decimal(str(raw)) except (InvalidOperation, TypeError) as exc: raise TransferSummaryInputError("金额数据无效,无法汇总。") from exc def _company_row(connection: sqlite3.Connection, company_id: int) -> dict[str, object]: row = connection.execute( "SELECT id, name FROM companies WHERE id = ?", (company_id,) ).fetchone() if row is None: raise TransferSummaryInputError("本公司不存在。") return {"id": int(row["id"]), "name": row["name"]} def _window_bounds( connection: sqlite3.Connection, as_of: str | None ) -> tuple[str, str]: end = _validate_date(as_of, "as_of") if as_of else today_shanghai() calc_start = calculation.get_calculation_start_date(connection) start = calc_start or settings.get_settings(connection).get("start_date") or "2026-01-01" start = _validate_date(start, "start_date") if start > end: # Opening / start-date plumbing may lag; clamp rather than 500. start = end return start, end def _load_confirmed( connection: sqlite3.Connection, company_id: int, start: str, end: str ) -> list[sqlite3.Row]: return connection.execute( """ SELECT e.event_id, e.decision_id, e.effective_at, e.amount, e.currency, e.payer_company_id, e.payee_company_id, e.pairing, cpayer.name AS payer_company_name, cpayee.name AS payee_company_name FROM eligible_intercompany_events e JOIN companies cpayer ON cpayer.id = e.payer_company_id JOIN companies cpayee ON cpayee.id = e.payee_company_id WHERE (e.payer_company_id = ? OR e.payee_company_id = ?) AND e.effective_at >= ? AND e.effective_at <= ? ORDER BY e.event_id """, (company_id, company_id, start, end + "T23:59:59"), ).fetchall() def _load_pending( connection: sqlite3.Connection, company_id: int, start: str, end: str ) -> list[sqlite3.Row]: """Pending = not eligible confirmed, still company-visible for tip only. Includes unresolved / needs_review / internal_single, plus any intercompany decision that is neither paired nor locked. """ return connection.execute( """ SELECT c.event_id, d.id AS decision_id, d.effective_at, d.amount, d.currency, d.classification, d.pairing, d.locked, payer.company_id AS payer_company_id, payee.company_id AS payee_company_id, cpayer.name AS payer_company_name, cpayee.name AS payee_company_name FROM current_transfer_decisions c JOIN transfer_match_decisions d ON d.id = c.decision_id JOIN canonical_transfer_events e ON e.id = c.event_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 e.lifecycle = 'active' AND (payer.company_id = ? OR payee.company_id = ?) AND d.effective_at >= ? AND d.effective_at <= ? AND ( d.classification IN ('unresolved', 'needs_review', 'internal_single') OR ( d.classification = 'intercompany' AND d.pairing != 'paired' AND d.locked = 0 ) ) ORDER BY c.event_id """, (company_id, company_id, start, end + "T23:59:59"), ).fetchall() def _counterparty_of(row: sqlite3.Row, company_id: int) -> tuple[int | None, str | None]: payer = row["payer_company_id"] payee = row["payee_company_id"] if payer is not None and int(payer) == int(company_id): if payee is None: return None, None return int(payee), row["payee_company_name"] if payee is not None and int(payee) == int(company_id): if payer is None: return None, None return int(payer), row["payer_company_name"] return None, None def _is_outflow(row: sqlite3.Row, company_id: int) -> bool: return row["payer_company_id"] is not None and int(row["payer_company_id"]) == int( company_id ) def company_intercompany_summary( connection: sqlite3.Connection, *, company_id: int, as_of: str | None = None, ) -> dict[str, object]: """Build the HEL-169 summary payload for one company session.""" own = _company_row(connection, company_id) start, end = _window_bounds(connection, as_of) confirmed_rows = _load_confirmed(connection, company_id, start, end) pending_rows = _load_pending(connection, company_id, start, end) seen_confirmed: set[int] = set() outflow = _ZERO inflow = _ZERO outflow_count = 0 inflow_count = 0 # counterparty_id -> bucket buckets: dict[int, dict[str, object]] = {} def bucket_for(cp_id: int, cp_name: str | None) -> dict[str, object]: bucket = buckets.get(cp_id) if bucket is None: bucket = { "company_id": cp_id, "company_name": cp_name or "", "confirmed_outflow": _ZERO, "confirmed_inflow": _ZERO, "pending_count": 0, "last_effective_at": None, } buckets[cp_id] = bucket elif cp_name and not bucket["company_name"]: bucket["company_name"] = cp_name return bucket def touch_last(bucket: dict[str, object], effective_at: str | None) -> None: if not effective_at: return previous = bucket["last_effective_at"] if previous is None or str(effective_at) > str(previous): bucket["last_effective_at"] = effective_at for row in confirmed_rows: event_id = int(row["event_id"]) if event_id in seen_confirmed: continue seen_confirmed.add(event_id) amount = _as_decimal(row["amount"]) cp_id, cp_name = _counterparty_of(row, company_id) if _is_outflow(row, company_id): outflow += amount outflow_count += 1 if cp_id is not None: bucket = bucket_for(cp_id, cp_name) bucket["confirmed_outflow"] += amount touch_last(bucket, row["effective_at"]) else: inflow += amount inflow_count += 1 if cp_id is not None: bucket = bucket_for(cp_id, cp_name) bucket["confirmed_inflow"] += amount touch_last(bucket, row["effective_at"]) pending_total = _ZERO seen_pending: set[int] = set() for row in pending_rows: event_id = int(row["event_id"]) if event_id in seen_pending or event_id in seen_confirmed: continue seen_pending.add(event_id) amount = _as_decimal(row["amount"]) pending_total += amount cp_id, cp_name = _counterparty_of(row, company_id) if cp_id is not None: bucket = bucket_for(cp_id, cp_name) bucket["pending_count"] = int(bucket["pending_count"]) + 1 touch_last(bucket, row["effective_at"]) net = outflow - inflow if net > 0: net_direction: str | None = "receivable" elif net < 0: net_direction = "payable" else: net_direction = None counterparties = [] for cp_id in sorted( buckets.keys(), key=lambda i: ( -( buckets[i]["confirmed_outflow"] # type: ignore[operator] + buckets[i]["confirmed_inflow"] ), buckets[i]["company_name"] or "", i, ), ): bucket = buckets[cp_id] conf_out = bucket["confirmed_outflow"] conf_in = bucket["confirmed_inflow"] assert isinstance(conf_out, Decimal) and isinstance(conf_in, Decimal) counterparties.append( { "company_id": cp_id, "company_name": bucket["company_name"], "confirmed_outflow": _money(conf_out), "confirmed_inflow": _money(conf_in), "net": _money(conf_out - conf_in), "pending_count": int(bucket["pending_count"]), "last_effective_at": bucket["last_effective_at"], } ) # Enrich window with calculation-basis opening/ending when configured. balances = calculation.compute_company_balances( connection, company_id, cutoff=end ) has_opening = balances.get("basis") == "full" opening_total = _ZERO ending_total = _ZERO if has_opening: for pair in balances.get("pairs") or []: opening_total += _as_decimal(pair.get("opening") or "0") ending_total += _as_decimal(pair.get("closing") or "0") return { "own_company": own, "window": { "start": start, "end": end, "has_opening": has_opening, "opening": _money(opening_total) if has_opening else None, "ending": _money(ending_total) if has_opening else None, "basis": balances.get("basis"), "calculation_start_date": balances.get("calculation_start_date"), }, "confirmed": { "outflow_total": _money(outflow), "outflow_count": outflow_count, "inflow_total": _money(inflow), "inflow_count": inflow_count, "net_change": _money(net), "net_direction": net_direction, }, "pending": { "count": len(seen_pending), "amount_total": _money(pending_total), }, "counterparties": counterparties, } # --------------------------------------------------------------------------- # Detail list + CSV export (HEL-176) # --------------------------------------------------------------------------- def encode_cursor(effective_at: str, event_id: int) -> str: raw = f"{effective_at}|{event_id}" return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii") def decode_cursor(cursor: str | None) -> tuple[str, int] | None: if not cursor: return None try: raw = base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8") effective_at, event_id_text = raw.split("|", 1) return effective_at, int(event_id_text) except Exception as exc: raise TransferSummaryInputError("分页游标无效。") from exc def _parse_limit(raw: object | None, default: int = _DEFAULT_PAGE) -> int: if raw is None or str(raw).strip() == "": return default try: value = int(str(raw).strip()) except (TypeError, ValueError) as exc: raise TransferSummaryInputError("limit 必须是正整数。") from exc if value < 1: raise TransferSummaryInputError("limit 必须是正整数。") return min(value, _MAX_PAGE) def _parse_optional_date(raw: object | None, label: str) -> str | None: if raw is None or str(raw).strip() == "": return None return _validate_date(raw, label) def _parse_direction(raw: object | None) -> str | None: if raw is None or str(raw).strip() == "": return None value = str(raw).strip().lower() if value not in ("out", "in"): raise TransferSummaryInputError("direction 只能是 out 或 in。") return value def _parse_state(raw: object | None) -> str | None: if raw is None or str(raw).strip() == "": return None value = str(raw).strip().lower() if value not in ("confirmed", "pending"): raise TransferSummaryInputError("state 只能是 confirmed 或 pending。") return value def _parse_counterparty_id(raw: object | None) -> int | None: if raw is None or str(raw).strip() == "": return None try: value = int(str(raw).strip()) except (TypeError, ValueError) as exc: raise TransferSummaryInputError("counterparty_id 参数无效。") from exc if value < 1: raise TransferSummaryInputError("counterparty_id 参数无效。") return value def _event_window( connection: sqlite3.Connection, *, from_: str | None, to: str | None, ) -> tuple[str, str]: end = _parse_optional_date(to, "to") or today_shanghai() start_default = settings.get_settings(connection).get("start_date") or "2026-01-01" start = _parse_optional_date(from_, "from") or _validate_date( start_default, "start_date" ) if start > end: raise TransferSummaryInputError("from 不能晚于 to。") return start, end # Confirmed = eligible_intercompany_events. Pending matches summary tip set. _CONFIRMED_PREDICATE = """ d.classification = 'intercompany' AND (d.pairing = 'paired' OR d.locked = 1) """ _PENDING_PREDICATE = """ ( d.classification IN ('unresolved', 'needs_review', 'internal_single') OR ( d.classification = 'intercompany' AND d.pairing != 'paired' AND d.locked = 0 ) ) """ _BOTH_PREDICATE = f""" ( ({_CONFIRMED_PREDICATE}) OR ({_PENDING_PREDICATE}) ) """ def _state_predicate(state: str | None) -> str: if state == "confirmed": return f"({_CONFIRMED_PREDICATE})" if state == "pending": return f"({_PENDING_PREDICATE})" return _BOTH_PREDICATE def _list_select_sql() -> str: return """ 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, d.reason, 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, (SELECT r.summary FROM transfer_decision_observations o JOIN source_rows r ON r.id = o.source_row_id JOIN sheet_batches s ON s.id = r.sheet_batch_id JOIN import_batches b ON b.id = s.import_batch_id WHERE o.decision_id = d.id AND b.company_id = ? ORDER BY o.id LIMIT 1) AS summary FROM current_transfer_decisions c JOIN transfer_match_decisions d ON d.id = c.decision_id JOIN canonical_transfer_events e ON e.id = c.event_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 """ def _build_event_filters( *, company_id: int, start: str, end: str, counterparty_id: int | None, direction: str | None, state: str | None, cursor: tuple[str, int] | None, ) -> tuple[str, list[object]]: conditions = [ "e.lifecycle = 'active'", "(payer.company_id = ? OR payee.company_id = ?)", "d.effective_at >= ?", "d.effective_at <= ?", _state_predicate(state), ] params: list[object] = [ company_id, company_id, start, end + "T23:59:59", ] if counterparty_id is not None: # Counterparty is the other participant; own company stays forced above. conditions.append( """ ( (payer.company_id = ? AND payee.company_id = ?) OR (payee.company_id = ? AND payer.company_id = ?) ) """ ) params.extend([company_id, counterparty_id, company_id, counterparty_id]) if direction == "out": conditions.append("payer.company_id = ?") params.append(company_id) elif direction == "in": conditions.append("payee.company_id = ?") params.append(company_id) if cursor is not None: cursor_at, cursor_id = cursor conditions.append( """ ( d.effective_at < ? OR (d.effective_at = ? AND c.event_id < ?) ) """ ) params.extend([cursor_at, cursor_at, cursor_id]) where = " WHERE " + " AND ".join(conditions) return where, params def _row_state(row: sqlite3.Row) -> str: classification = row["classification"] pairing = row["pairing"] locked = bool(row["locked"]) if classification == "intercompany" and (pairing == "paired" or locked): return "confirmed" return "pending" def _row_direction(row: sqlite3.Row, company_id: int) -> str | None: if row["payer_company_id"] is not None and int(row["payer_company_id"]) == int( company_id ): return "out" if row["payee_company_id"] is not None and int(row["payee_company_id"]) == int( company_id ): return "in" return None def _event_list_item(row: sqlite3.Row, company_id: int) -> dict[str, object]: direction = _row_direction(row, company_id) state = _row_state(row) if direction == "out": counterparty_company_id = row["payee_company_id"] counterparty_company_name = row["payee_company_name"] else: counterparty_company_id = row["payer_company_id"] counterparty_company_name = row["payer_company_name"] summary = row["summary"] or row["reason"] or "" return { "event_id": int(row["event_id"]), "decision_id": int(row["decision_id"]), "revision": row["revision"], "classification": row["classification"], "pairing": row["pairing"], "status": matching.exposed_status(row), "state": state, "direction": direction, "amount": row["amount"], "currency": row["currency"], "effective_at": row["effective_at"], "mode": row["mode"], "locked": bool(row["locked"]), "rule_version": row["rule_version"], "summary": summary, "own_company_id": company_id, "counterparty_company_id": ( int(counterparty_company_id) if counterparty_company_id is not None else None ), "counterparty_company_name": counterparty_company_name, "evidence_count": row["evidence_count"], } def company_intercompany_events( connection: sqlite3.Connection, *, company_id: int, from_: str | None = None, to: str | None = None, counterparty_id: int | None | object = None, direction: str | None | object = None, state: str | None | object = None, limit: object | None = None, cursor: str | None = None, ) -> dict[str, object]: """Filtered keyset page of company-visible transfer events (HEL-176).""" start, end = _event_window(connection, from_=from_, to=to) cp_id = _parse_counterparty_id(counterparty_id) direction_value = _parse_direction(direction) state_value = _parse_state(state) page_size = _parse_limit(limit) cursor_tuple = decode_cursor(cursor) where, params = _build_event_filters( company_id=company_id, start=start, end=end, counterparty_id=cp_id, direction=direction_value, state=state_value, cursor=cursor_tuple, ) # summary subquery binds own company_id first. sql = ( _list_select_sql() + where + " ORDER BY d.effective_at DESC, c.event_id DESC LIMIT ?" ) rows = connection.execute( sql, (company_id, *params, page_size + 1) ).fetchall() has_more = len(rows) > page_size page = rows[:page_size] items = [_event_list_item(row, company_id) for row in page] next_cursor = None if has_more and page: last = page[-1] next_cursor = encode_cursor(str(last["effective_at"]), int(last["event_id"])) return { "window": {"start": start, "end": end}, "events": items, "next_cursor": next_cursor, "has_more": has_more, } def company_intercompany_export_rows( connection: sqlite3.Connection, *, company_id: int, from_: str | None = None, to: str | None = None, counterparty_id: int | None | object = None, direction: str | None | object = None, ) -> tuple[list[dict[str, object]], dict[str, object]]: """Confirmed-only rows for CSV export; pending never included.""" start, end = _event_window(connection, from_=from_, to=to) cp_id = _parse_counterparty_id(counterparty_id) direction_value = _parse_direction(direction) where, params = _build_event_filters( company_id=company_id, start=start, end=end, counterparty_id=cp_id, direction=direction_value, state="confirmed", cursor=None, ) sql = ( _list_select_sql() + where + " ORDER BY d.effective_at DESC, c.event_id DESC LIMIT ?" ) rows = connection.execute( sql, (company_id, *params, _MAX_EXPORT_ROWS + 1) ).fetchall() if len(rows) > _MAX_EXPORT_ROWS: raise TransferSummaryInputError( f"导出行数超过上限 {_MAX_EXPORT_ROWS},请缩小筛选范围。" ) items = [_event_list_item(row, company_id) for row in rows] meta = { "start": start, "end": end, "counterparty_id": cp_id, "direction": direction_value, "state": "confirmed", "row_count": len(items), } return items, meta def render_intercompany_export_csv(items: list[dict[str, object]]) -> bytes: buffer = io.StringIO() writer = csv.writer(buffer) writer.writerow( [ "日期", "方向", "对方公司", "金额", "币种", "摘要", "状态", "配对", "事件ID", "决策ID", ] ) direction_label = {"out": "转出", "in": "转入"} for item in items: writer.writerow( [ item.get("effective_at") or "", direction_label.get(str(item.get("direction") or ""), ""), item.get("counterparty_company_name") or "", item.get("amount") or "", item.get("currency") or "", item.get("summary") or "", "已确认", item.get("pairing") or "", item.get("event_id") or "", item.get("decision_id") or "", ] ) # UTF-8 BOM so Excel opens the CSV with the right encoding. return (chr(0xFEFF) + buffer.getvalue()).encode("utf-8") def is_transfer_summary_events_query(query: dict[str, list[str]]) -> bool: """Discriminate HEL-176 transfer list from B-44 ledger ``/events``. B-44 uses ``cutoff`` / subject / posting_kind / source_kind / pending_subject. HEL-176 uses ``to`` / direction / counterparty_id / state=pending|confirmed (without ledger-only knobs). """ if (query.get("direction") or [None])[0] is not None: return True if (query.get("counterparty_id") or [None])[0] is not None: return True if (query.get("to") or [None])[0] is not None: return True state = (query.get("state") or [None])[0] if state in ("pending", "confirmed") and (query.get("cutoff") or [None])[0] is None: # Bare state=confirmed without cutoff is the transfer-summary list; # B-44 confirmed always pairs with cutoff in existing callers/tests. if (query.get("subject") or [None])[0] is not None: return False if (query.get("posting_kind") or [None])[0] is not None: return False if (query.get("source_kind") or [None])[0] is not None: return False return True return False