"""Intercompany position aggregation: Decimal math, conservation and drill-down. Every confirmed ledger event produces a signed claim: the payer books a debit (claim ``+amount``) and the payee books a credit (claim ``-amount``). For one company pair and currency the two perspectives must mirror exactly (``C_A == -C_B``) — the module asserts that invariant after every pair aggregation and refuses to render a non-conserving number. Subjects are mirrored from the stored perspective; all money math uses ``Decimal`` on the stored decimal strings, never SQLite ``SUM`` or floating point. """ from __future__ import annotations import base64 from datetime import datetime, timedelta, timezone from decimal import Decimal import json import sqlite3 from .ledger_events import current_revision from .manual_records import list_records from .master_data import mask_account_number from .subjects import MIRROR, SUBJECTS, mirror_subject, subject_label from . import matching class PositionError(ValueError): """Calculation failure: conservation violated or bad parameters.""" class PositionInputError(ValueError): """Invalid query parameters (mapped to HTTP 400).""" SUBJECT_LABEL_MAP = { "receivable": "应收", "payable": "应付", "other_receivable": "其他应收", "other_payable": "其他应付", } def today_shanghai() -> str: return datetime.now(timezone(timedelta(hours=8))).date().isoformat() def validate_window(from_: object, cutoff: object) -> tuple[str, str]: pattern = r"^\d{4}-\d{2}-\d{2}$" import re def check(value, label): text = str(value or "") if not text or not re.fullmatch(pattern, text): raise PositionInputError(f"{label}必须是 YYYY-MM-DD 格式。") try: datetime.strptime(text, "%Y-%m-%d") except ValueError: raise PositionInputError(f"{label}不是有效日期。") from None return text start = check(from_, "from") or "0001-01-01" end = check(cutoff, "cutoff") if start > end: raise PositionInputError("from 不能晚于 cutoff。") return start, end def encode_cursor(parts: tuple[str, ...]) -> str: raw = "|".join(str(part) for part in parts) return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii") def decode_cursor(cursor: str | None, parts: int) -> tuple[str, ...] | None: if not cursor: return None try: raw = base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8") except Exception: raise PositionInputError("分页游标无效。") from None values = tuple(raw.split("|")) if len(values) != parts: raise PositionInputError("分页游标无效。") return values def _row(r) -> dict[str, object]: return {key: r[key] for key in r.keys()} # --------------------------------------------------------------------------- # Event loading # --------------------------------------------------------------------------- _EVENT_SELECT = """ SELECT p.ledger_event_id, p.ledger_revision_id, p.effective_at, p.amount, p.amount_scale, p.currency, p.payer_company_id, p.payee_company_id, p.perspective_company_id, p.subject_code, p.source_kind, p.posting_kind, p.reverses_ledger_event_id, p.adjusts_ledger_event_id, p.source_id, p.evidence_count, cpayer.name AS payer_company_name, cpayee.name AS payee_company_name FROM eligible_position_events p JOIN companies cpayer ON cpayer.id = p.payer_company_id JOIN companies cpayee ON cpayee.id = p.payee_company_id """ def _event_filters( *, from_: str, cutoff: str, currency: str | None = None, company_id: int | None = None, pair: tuple[int, int] | None = None, subject: str | None = None, posting_kind: str | None = None, source_kind: str | None = None, viewer_company_id: int | None = None, state: str | None = None, ) -> tuple[str, list[object]]: conditions = ["p.effective_at >= ?", "p.effective_at <= ?"] params: list[object] = [from_, cutoff + "T23:59:59"] if currency: conditions.append("p.currency = ?") params.append(currency) if company_id is not None or pair is not None: if pair is not None: a, b = int(pair[0]), int(pair[1]) conditions.append( "(p.payer_company_id IN (?, ?) AND p.payee_company_id IN (?, ?))" ) params.extend([a, b, a, b]) else: conditions.append("(p.payer_company_id = ? OR p.payee_company_id = ?)") params.extend([company_id, company_id]) if subject: if subject not in SUBJECTS: raise PositionInputError("科目筛选无效。") # The stored subject lives on one company's perspective; the viewer on # the other side sees its mirror. Match both so mirror events are never # dropped from the filter. conditions.append("(p.subject_code = ? OR p.subject_code = ?)") params.extend([subject, MIRROR[subject]]) if posting_kind: conditions.append("p.posting_kind = ?") params.append(posting_kind) if source_kind: conditions.append("p.source_kind = ?") params.append(source_kind) if viewer_company_id is not None: conditions.append("(p.payer_company_id = ? OR p.payee_company_id = ?)") params.extend([viewer_company_id, viewer_company_id]) if state is not None: if state not in ("confirmed", "pending_subject"): raise PositionInputError("无效的事件状态。") conditions.append("p.state = ?") params.append(state) return "WHERE " + " AND ".join(conditions), params def load_events( connection: sqlite3.Connection, *, from_: str, cutoff: str, currency: str | None = None, company_id: int | None = None, pair: tuple[int, int] | None = None, subject: str | None = None, posting_kind: str | None = None, source_kind: str | None = None, viewer_company_id: int | None = None, ) -> list[sqlite3.Row]: where, params = _event_filters( from_=from_, cutoff=cutoff, currency=currency, company_id=company_id, pair=pair, subject=subject, posting_kind=posting_kind, source_kind=source_kind, viewer_company_id=viewer_company_id, ) return connection.execute( _EVENT_SELECT + where + " ORDER BY p.ledger_event_id", params ).fetchall() def load_all_events( connection: sqlite3.Connection, *, from_: str, cutoff: str, currency: str | None = None, company_id: int | None = None, pair: tuple[int, int] | None = None, subject: str | None = None, state: str | None = None, posting_kind: str | None = None, source_kind: str | None = None, viewer_company_id: int | None = None, ) -> list[sqlite3.Row]: """Load every current ledger revision (confirmed and pending_subject).""" where, params = _event_filters( from_=from_, cutoff=cutoff, currency=currency, company_id=company_id, pair=pair, subject=subject, posting_kind=posting_kind, source_kind=source_kind, viewer_company_id=viewer_company_id, state=state, ) return connection.execute( _DETAIL_SELECT + where + " ORDER BY p.ledger_event_id", params ).fetchall() def signed_amount(event: sqlite3.Row, company_id: int) -> Decimal: """+amount when the company is the payer, -amount when it is the payee.""" value = Decimal(event["amount"]) if int(event["payer_company_id"]) == int(company_id): return value return -value def viewer_direction(event: sqlite3.Row, company_id: int) -> str: return "outgoing" if int(event["payer_company_id"]) == int(company_id) else "incoming" def viewer_subject(event: sqlite3.Row, company_id: int) -> str | None: if event["perspective_company_id"] is None: return None if int(event["perspective_company_id"]) == int(company_id): return event["subject_code"] return mirror_subject(event["subject_code"]) def _subject_side(event: sqlite3.Row, company_id: int) -> tuple[str, str, str]: """``(subject_code, side)`` for ``company_id``: side is 'debit' or 'credit'.""" perspective = int(event["perspective_company_id"]) is_payer = int(event["payer_company_id"]) == int(company_id) if perspective == int(company_id): subject = event["subject_code"] side = "debit" if is_payer else "credit" else: subject = mirror_subject(event["subject_code"]) side = "debit" if is_payer else "credit" return subject, side, perspective def _balance_payload( connection: sqlite3.Connection, events: list[sqlite3.Row], *, company_id: int, from_: str, cutoff: str, currency: str, ) -> dict[str, object]: """One company/currency balance row; zero events still yields the row.""" debit = credit = signed = Decimal("0") event_count = 0 for event in events: if event["currency"] != currency: continue if int(event["payer_company_id"]) == int(company_id): debit += Decimal(event["amount"]) signed += Decimal(event["amount"]) else: credit += Decimal(event["amount"]) signed -= Decimal(event["amount"]) event_count += 1 unresolved = unresolved_for_company( connection, company_id, cutoff, currency=currency ) direction = "receivable" if signed > 0 else ("payable" if signed < 0 else None) return { "company_id": company_id, "company_name": _company_name(connection, company_id), "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, "currency": currency, "opening": {"status": "unavailable", "amount": None}, "period": {"debit": str(debit), "credit": str(credit)}, "result": { "kind": "period_net_change", "signed_amount": str(signed), "direction": direction, "label": "期间净变动", }, "unresolved": unresolved, "trace": { "event_count": event_count, "events_url": ( f"/api/admin/intercompany/events?company_id={company_id}" f"&from={from_}&cutoff={cutoff}¤cy={currency}" ), }, } def _company_name(connection: sqlite3.Connection, company_id: int) -> str: row = connection.execute( "SELECT name FROM companies WHERE id = ?", (company_id,) ).fetchone() return row["name"] if row is not None else "" # --------------------------------------------------------------------------- # Unresolved amounts # --------------------------------------------------------------------------- def _pending_subject_events( connection: sqlite3.Connection, cutoff: str ) -> list[sqlite3.Row]: return connection.execute( """ SELECT r.ledger_event_id, r.effective_at, r.amount, r.currency, r.payer_company_id, r.payee_company_id FROM current_ledger_event_revisions c JOIN ledger_event_revisions r ON r.id = c.revision_id WHERE r.state = 'pending_subject' AND r.effective_at <= ? ORDER BY r.ledger_event_id """, (cutoff + "T23:59:59",), ).fetchall() def _manual_pending( connection: sqlite3.Connection, cutoff: str ) -> list[sqlite3.Row]: return connection.execute( """ SELECT m.id, m.company_id, m.counterparty_company_id, m.occurred_at, m.amount, m.currency FROM manual_records m JOIN current_manual_record_decisions c ON c.record_id = m.id JOIN manual_record_decisions d ON d.id = c.decision_id WHERE d.state = 'pending' AND m.occurred_at <= ? ORDER BY m.id """, (cutoff + "T23:59:59",), ).fetchall() def _unmatched_singles( connection: sqlite3.Connection, company_id: int, cutoff: str ) -> list[sqlite3.Row]: return matching.unresolved_amounts(connection, company_id, cutoff + "T23:59:59") def unresolved_for_company( connection: sqlite3.Connection, company_id: int, cutoff: str, currency: str | None = None, *, include_own_manual_only: bool = True, counterparty_filter: tuple[int, int] | None = None, ) -> dict[str, object]: """Unresolved absolute gross per currency (no plus/minus netting). ``by_reason`` buckets: subject_review (bank events waiting for a subject), unmatched_single (B-43 open observations) and manual_pending (unapproved manual records; only the submitting company / the admin may see them). """ gross: dict[str, Decimal] = {} counts: dict[str, int] = {} reasons: dict[str, dict[str, object]] = {} def add(reason: str, cur: str, amount: Decimal) -> None: if currency and cur != currency: return if reason not in reasons: reasons[reason] = {"gross_amount": Decimal("0"), "count": 0} reasons[reason]["gross_amount"] += amount reasons[reason]["count"] += 1 gross[cur] = gross.get(cur, Decimal("0")) + amount counts[cur] = counts.get(cur, 0) + 1 for event in _pending_subject_events(connection, cutoff): if counterparty_filter is not None: a, b = counterparty_filter if not ({event["payer_company_id"], event["payee_company_id"]} == {a, b}): continue elif company_id not in (event["payer_company_id"], event["payee_company_id"]): continue add("subject_review", event["currency"], Decimal(event["amount"])) for row in _unmatched_singles(connection, company_id, cutoff): if counterparty_filter is not None: participants = _single_participants(connection, row["decision_id"]) a, b = counterparty_filter if participants != {a, b}: continue add("unmatched_single", row["currency"], abs(Decimal(row["amount"]))) for row in _manual_pending(connection, cutoff): if counterparty_filter is not None: a, b = counterparty_filter if not ({row["company_id"], row["counterparty_company_id"]} == {a, b}): continue elif include_own_manual_only and row["company_id"] != int(company_id): continue elif not include_own_manual_only and company_id not in ( row["company_id"], row["counterparty_company_id"], ): continue add("manual_pending", row["currency"], Decimal(row["amount"])) items = sorted(gross.items()) return { "gross_amount": str(sum((gross[k] for k, _ in items), Decimal("0"))), "count": sum(counts.values()), "by_reason": { reason: { "gross_amount": str(payload["gross_amount"]), "count": payload["count"], } for reason, payload in sorted(reasons.items()) }, } def _single_participants(connection: sqlite3.Connection, decision_id: int) -> set[int]: rows = connection.execute( "SELECT company_id FROM transfer_decision_participants WHERE decision_id = ?", (decision_id,), ).fetchall() return {row["company_id"] for row in rows if row["company_id"] is not None} # --------------------------------------------------------------------------- # Directory # --------------------------------------------------------------------------- def company_balances( connection: sqlite3.Connection, *, from_: str, cutoff: str, currency: str | None = None, company_id: int | None = None, limit: int = 50, cursor: str | None = None, ) -> dict[str, object]: from_, cutoff = validate_window(from_, cutoff) events = load_events( connection, from_=from_, cutoff=cutoff, currency=currency, company_id=company_id, ) all_companies = _event_companies(connection) items_by_company: dict[int, list[sqlite3.Row]] = {} for event in events: for company in (event["payer_company_id"], event["payee_company_id"]): items_by_company.setdefault(company, []).append(event) rows: list[tuple[int, str]] = [] for company in all_companies: if company_id is not None and company != company_id: continue if company in items_by_company: rows.append((company, "has_events")) else: # Companies with no confirmed events still appear when they have # unresolved exposure, so the directory never hides risk. unresolved = unresolved_for_company(connection, company, cutoff, currency) if unresolved["count"] > 0: rows.append((company, "unresolved")) if cursor is not None: decoded = decode_cursor(cursor, 2) cursor_company = int(decoded[0]) cursor_currency = decoded[1] else: cursor_company, cursor_currency = None, None flat: list[tuple[int, str]] = [] pending_subject = _pending_subject_events(connection, cutoff) for company, _marker in rows: bucket_events = items_by_company.get(company, []) pending_currencies = { event["currency"] for event in pending_subject if company in (event["payer_company_id"], event["payee_company_id"]) } bucket_currencies = sorted( {event["currency"] for event in bucket_events} | pending_currencies | { item["currency"] for item in _manual_pending(connection, cutoff) if item["company_id"] == company or item["counterparty_company_id"] == company } | { item["currency"] for item in _unmatched_singles(connection, company, cutoff) } ) if currency: bucket_currencies = [currency] if currency in bucket_currencies else [] for cur in bucket_currencies: flat.append((company, cur)) filtered: list[tuple[int, str]] = [] for company, cur in flat: if cursor_company is not None: if company < cursor_company: continue if company == cursor_company and cur <= cursor_currency: continue filtered.append((company, cur)) filtered.sort(key=lambda item: (item[0], item[1])) page = filtered[:limit] has_more = len(filtered) > limit next_cursor = None if has_more: last = page[-1] next_cursor = encode_cursor((str(last[0]), last[1])) items = [] for company, cur in page: items.append( _balance_payload( connection, items_by_company.get(company, []), company_id=company, from_=from_, cutoff=cutoff, currency=cur, ) ) return { "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, "items": items, "next_cursor": next_cursor, "has_more": has_more, } def _event_companies(connection: sqlite3.Connection) -> list[int]: """All master companies (plus any with ledger exposure) for the directory.""" rows = connection.execute( """ SELECT id AS company_id FROM companies UNION SELECT payer_company_id AS company_id FROM eligible_position_events UNION SELECT payee_company_id AS company_id FROM eligible_position_events UNION SELECT payer_company_id AS company_id FROM ledger_event_revisions WHERE state = 'pending_subject' UNION SELECT payee_company_id AS company_id FROM ledger_event_revisions WHERE state = 'pending_subject' ORDER BY company_id """ ).fetchall() return [row["company_id"] for row in rows] # --------------------------------------------------------------------------- # Pair detail # --------------------------------------------------------------------------- def pair_detail( connection: sqlite3.Connection, company_a: int, company_b: int, *, from_: str, cutoff: str, currency: str | None = None, ) -> dict[str, object]: from_, cutoff = validate_window(from_, cutoff) if int(company_a) == int(company_b): raise PositionInputError("公司对的两个公司不能相同。") pair = (int(company_a), int(company_b)) events = load_events( connection, from_=from_, cutoff=cutoff, currency=currency, pair=pair ) pending_currencies = { event["currency"] for event in _pending_subject_events(connection, cutoff) if {event["payer_company_id"], event["payee_company_id"]} == set(pair) } manual_currencies = { row["currency"] for row in _manual_pending(connection, cutoff) if {row["company_id"], row["counterparty_company_id"]} == set(pair) } currencies = sorted( {event["currency"] for event in events} | pending_currencies | manual_currencies ) if currency: currencies = [currency] if currency in currencies else [] outputs = [] for cur in currencies: subset = [event for event in events if event["currency"] == cur] outputs.append( _pair_currency( connection, pair, subset, from_=from_, cutoff=cutoff, currency=cur ) ) if not outputs: outputs.append( _pair_currency( connection, pair, [], from_=from_, cutoff=cutoff, currency=currency or "CNY", ) ) return { "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, "companies": { "a": {"company_id": pair[0], "name": _company_name(connection, pair[0])}, "b": {"company_id": pair[1], "name": _company_name(connection, pair[1])}, }, "items": outputs, } def _pair_currency( connection: sqlite3.Connection, pair: tuple[int, int], events: list[sqlite3.Row], *, from_: str, cutoff: str, currency: str, ) -> dict[str, object]: a, b = pair debit_a = credit_a = debit_b = credit_b = Decimal("0") subject_sides: dict[str, dict[str, object]] = {} for subject in SUBJECTS: subject_sides[subject] = { "subject_code": subject, "label": SUBJECT_LABEL_MAP[subject], "a_debit": Decimal("0"), "a_credit": Decimal("0"), "b_debit": Decimal("0"), "b_credit": Decimal("0"), "count": 0, } for event in events: amount = Decimal(event["amount"]) if int(event["payer_company_id"]) == a: debit_a += amount credit_b += amount else: debit_b += amount credit_a += amount for company in (a, b): subject, side, _perspective = _subject_side(event, company) bucket = subject_sides[subject] bucket[f"{'a' if company == a else 'b'}_{side}"] += amount bucket["count"] += 1 signed_a = debit_a - credit_a signed_b = debit_b - credit_b if signed_a != -signed_b: raise PositionError( "公司对余额不守恒:A 与 B 的净结果未镜像,拒绝展示该数据。" ) def result_of(signed: Decimal) -> dict[str, object]: direction = "receivable" if signed > 0 else ("payable" if signed < 0 else None) return { "kind": "period_net_change", "signed_amount": str(signed), "direction": direction, "label": "期间净变动", } exception_subjects = [ subject for subject, bucket in subject_sides.items() if _subject_abnormal(bucket["a_debit"], bucket["a_credit"], subject) or _subject_abnormal(bucket["b_debit"], bucket["b_credit"], subject) ] return { "currency": currency, "opening": {"status": "unavailable", "amount": None}, "a": { "period": {"debit": str(debit_a), "credit": str(credit_a)}, "result": result_of(signed_a), }, "b": { "period": {"debit": str(debit_b), "credit": str(credit_b)}, "result": result_of(signed_b), }, "conservation": { "abs_equal": abs(signed_a) == abs(signed_b), "opposite": signed_a == -signed_b, }, "subjects": { subject: { "subject_code": bucket["subject_code"], "label": bucket["label"], "a_debit": str(bucket["a_debit"]), "a_credit": str(bucket["a_credit"]), "b_debit": str(bucket["b_debit"]), "b_credit": str(bucket["b_credit"]), "count": bucket["count"], } for subject, bucket in subject_sides.items() if bucket["count"] > 0 }, "normal_balance_exception": exception_subjects, "unresolved": unresolved_for_company( connection, a, cutoff, currency=currency, counterparty_filter=pair, ), "trace": { "event_count": len(events), "events_url": ( f"/api/admin/intercompany/events?company_a={a}&company_b={b}" f"&from={from_}&cutoff={cutoff}¤cy={currency}" ), }, } def _subject_abnormal(debit: Decimal, credit: Decimal, subject: str) -> bool: if subject in ("receivable", "other_receivable"): return debit < credit return credit < debit # --------------------------------------------------------------------------- # Events list and detail # --------------------------------------------------------------------------- def list_events( connection: sqlite3.Connection, *, from_: str, cutoff: str, currency: str | None = None, company_id: int | None = None, company_a: int | None = None, company_b: int | None = None, subject: str | None = None, state: str | None = None, posting_kind: str | None = None, source_kind: str | None = None, viewer_company_id: int | None = None, limit: int = 50, cursor: str | None = None, ) -> dict[str, object]: from_, cutoff = validate_window(from_, cutoff) pair = None if company_a is not None and company_b is not None: pair = (int(company_a), int(company_b)) elif company_a is not None: company_id = int(company_a) events = load_all_events( connection, from_=from_, cutoff=cutoff, currency=currency, company_id=company_id, pair=pair, subject=subject, state=state, posting_kind=posting_kind, source_kind=source_kind, viewer_company_id=viewer_company_id, ) def sort_key(event): return (event["effective_at"], event["ledger_event_id"]) events.sort(key=sort_key, reverse=True) if cursor is not None: decoded = decode_cursor(cursor, 2) cursor_date = decoded[0] cursor_id = int(decoded[1]) else: cursor_date, cursor_id = None, None filtered = [] for event in events: if cursor_date is not None: key = sort_key(event) if key[0] > cursor_date or (key[0] == cursor_date and key[1] >= cursor_id): continue filtered.append(event) page = filtered[:limit] has_more = len(filtered) > limit next_cursor = None if has_more: last = page[-1] next_cursor = encode_cursor((last["effective_at"], str(last["ledger_event_id"]))) items = [] for event in page: item = event_payload( connection, event, viewer_company_id=viewer_company_id ) item["state"] = event["state"] items.append(item) return { "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, "items": items, "next_cursor": next_cursor, "has_more": has_more, } def _current_state(connection: sqlite3.Connection, ledger_event_id: int) -> str: revision = current_revision(connection, ledger_event_id) return revision["state"] if revision is not None else "" def event_payload( connection: sqlite3.Connection, event: sqlite3.Row, *, viewer_company_id: int | None = None ) -> dict[str, object]: item = { "ledger_event_id": event["ledger_event_id"], "ledger_revision_id": event["ledger_revision_id"], "effective_at": event["effective_at"], "amount": event["amount"], "currency": event["currency"], "payer_company_id": event["payer_company_id"], "payer_company_name": event["payer_company_name"], "payee_company_id": event["payee_company_id"], "payee_company_name": event["payee_company_name"], "perspective_company_id": event["perspective_company_id"], "subject_code": event["subject_code"], "subject_label": subject_label(event["subject_code"]), "posting_kind": event["posting_kind"], "source_kind": event["source_kind"], "reverses_ledger_event_id": event["reverses_ledger_event_id"], "adjusts_ledger_event_id": event["adjusts_ledger_event_id"], "source_id": event["source_id"], "evidence_count": event["evidence_count"], } if viewer_company_id is not None: item["direction"] = viewer_direction(event, viewer_company_id) own_subject = viewer_subject(event, viewer_company_id) item["own_subject"] = own_subject item["own_subject_label"] = ( subject_label(own_subject) if own_subject is not None else None ) item["counterparty_company_id"] = ( event["payee_company_id"] if int(event["payer_company_id"]) == int(viewer_company_id) else event["payer_company_id"] ) item["counterparty_company_name"] = ( event["payee_company_name"] if int(event["payer_company_id"]) == int(viewer_company_id) else event["payer_company_name"] ) item.update(_event_line_display(connection, event, viewer_company_id)) return item _REPAY_MARKERS = ("还款", "归还借款", "归还往来款") def bank_short_name(name: str | None) -> str: text = str(name or "").strip() if text.startswith("中国"): text = text[2:] if text.endswith("银行"): text = text[:-2] return text or "银行" def _account_chip(visibility: str, bank_name: str | None, account: str | None) -> dict[str, object]: if visibility == "missing": return {"visibility": "missing", "label": None} if visibility == "masked": return {"visibility": "masked", "label": "按对方授权不可见"} number = str(account or "") tail = number[-4:] if number else "" short = bank_short_name(bank_name) label = f"{short} {tail}".strip() if tail else short return {"visibility": "visible", "label": label} def _event_line_display( connection: sqlite3.Connection, event: sqlite3.Row, viewer_company_id: int | None, ) -> dict[str, object]: """Account chips, summary and repayment flag for the event table.""" missing = _account_chip("missing", None, None) payer_chip, payee_chip = missing, missing summary = None texts: list[str] = [] sides = _event_source_sides(connection, event["ledger_event_id"]) for side in sides: company_id = side["company_id"] own = viewer_company_id is None or int(company_id) == int(viewer_company_id) visibility = "visible" if own else "masked" chip = _account_chip(visibility, side.get("bank_name"), side.get("own_account")) if int(company_id) == int(event["payer_company_id"]): payer_chip = chip elif int(company_id) == int(event["payee_company_id"]): payee_chip = chip if own or viewer_company_id is None: if side.get("summary"): texts.append(str(side["summary"])) if side.get("reason"): texts.append(str(side["reason"])) if texts: summary = texts[0] blob = " ".join(texts) is_repayment = any(marker in blob for marker in _REPAY_MARKERS) return { "payer_account": payer_chip, "payee_account": payee_chip, "summary": summary, "is_repayment": is_repayment, } def _event_source_sides( connection: sqlite3.Connection, ledger_event_id: int ) -> list[dict[str, object]]: rows = connection.execute( """ SELECT b.company_id AS company_id, s.bank_name AS bank_name, r.own_account AS own_account, r.summary AS summary, r.purpose AS purpose, NULL AS reason FROM ledger_event_bank_sources bs JOIN current_transfer_decisions cur ON cur.event_id = bs.bank_event_id JOIN transfer_decision_observations o ON o.decision_id = cur.decision_id 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 bs.ledger_event_id = ? """, (ledger_event_id,), ).fetchall() if rows: return [_row(row) for row in rows] manuals = connection.execute( """ SELECT m.company_id AS company_id, ba.bank_name AS bank_name, ba.account_number AS own_account, m.summary AS summary, NULL AS purpose, m.reason AS reason FROM ledger_event_manual_sources ms JOIN manual_records m ON m.id = ms.manual_record_id LEFT JOIN bank_accounts ba ON ba.id = m.bank_account_id WHERE ms.ledger_event_id = ? """, (ledger_event_id,), ).fetchall() return [_row(row) for row in manuals] _DETAIL_SELECT = """ SELECT p.ledger_event_id, p.id AS ledger_revision_id, p.effective_at, p.amount, p.amount_scale, p.currency, p.payer_company_id, p.payee_company_id, p.perspective_company_id, p.subject_code, p.source_kind, p.posting_kind, p.reverses_ledger_event_id, p.adjusts_ledger_event_id, p.state, p.revision AS revision_number, cpayer.name AS payer_company_name, cpayee.name AS payee_company_name, COALESCE( (SELECT bs.bank_event_id FROM ledger_event_bank_sources bs WHERE bs.ledger_event_id = p.ledger_event_id LIMIT 1), (SELECT ms.manual_record_id FROM ledger_event_manual_sources ms WHERE ms.ledger_event_id = p.ledger_event_id LIMIT 1) ) AS source_id, ((SELECT COUNT(*) FROM ledger_event_bank_sources bs WHERE bs.ledger_event_id = p.ledger_event_id) + (SELECT COUNT(*) FROM ledger_event_manual_sources ms WHERE ms.ledger_event_id = p.ledger_event_id)) AS evidence_count 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 """ def event_detail(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, object] | None: event = connection.execute( _DETAIL_SELECT + " WHERE p.ledger_event_id = ?", (ledger_event_id,) ).fetchone() if event is None: return None history = connection.execute( """ SELECT r.* FROM ledger_event_revisions r WHERE r.ledger_event_id = ? ORDER BY r.revision """, (ledger_event_id,), ).fetchall() suggestions = connection.execute( """ SELECT s.*, c.name AS company_name FROM ledger_subject_suggestions s LEFT JOIN companies c ON c.id = s.suggested_perspective_company_id WHERE s.ledger_event_id = ? ORDER BY s.id """, (ledger_event_id,), ).fetchall() return { "event": event_payload(connection, event), "state": _current_state(connection, ledger_event_id), "history": [ { "revision": rev["revision"], "state": rev["state"], "effective_at": rev["effective_at"], "amount": rev["amount"], "currency": rev["currency"], "perspective_company_id": rev["perspective_company_id"], "subject_code": rev["subject_code"], "posting_kind": rev["posting_kind"], "source_kind": rev["source_kind"], "source_revision_token": rev["source_revision_token"], "actor_username": rev["actor_username"], "reason": rev["reason"], "created_at": rev["created_at"], } for rev in history ], "suggestions": [ { "suggested_perspective_company_id": sug["suggested_perspective_company_id"], "suggested_company_name": sug["company_name"], "suggested_subject_code": sug["suggested_subject_code"], "suggested_subject_label": subject_label(sug["suggested_subject_code"]), "rule_version": sug["rule_version"], "evidence": json.loads(sug["evidence_json"]) if sug["evidence_json"] else {}, "created_at": sug["created_at"], } for sug in suggestions ], } def subject_review_queue( connection: sqlite3.Connection, *, from_: str, cutoff: str, company_id: int | None = None, limit: int = 50, cursor: str | None = None, ) -> dict[str, object]: from_, cutoff = validate_window(from_, cutoff) conditions = ["r.state = 'pending_subject'", "r.effective_at >= ?", "r.effective_at <= ?"] params: list[object] = [from_, cutoff + "T23:59:59"] if company_id is not None: conditions.append("(r.payer_company_id = ? OR r.payee_company_id = ?)") params.extend([company_id, company_id]) rows = connection.execute( f""" SELECT r.ledger_event_id, r.effective_at, r.amount, r.currency, r.payer_company_id, r.payee_company_id, cpayer.name AS payer_company_name, cpayee.name AS payee_company_name, r.id AS revision_id, r.evidence_json AS evidence_json FROM current_ledger_event_revisions cur JOIN ledger_event_revisions r ON r.id = cur.revision_id JOIN companies cpayer ON cpayer.id = r.payer_company_id JOIN companies cpayee ON cpayee.id = r.payee_company_id WHERE {' AND '.join(conditions)} ORDER BY r.ledger_event_id """, params, ).fetchall() rows.sort(key=lambda row: (row["effective_at"], row["ledger_event_id"]), reverse=True) if cursor is not None: decoded = decode_cursor(cursor, 2) cursor_date, cursor_id = decoded[0], int(decoded[1]) else: cursor_date, cursor_id = None, None filtered = [] for row in rows: evidence = json.loads(row["evidence_json"] or "{}") if row["evidence_json"] else {} if evidence.get("admin_disposition") == "exception": continue if cursor_date is not None: if row["effective_at"] > cursor_date or ( row["effective_at"] == cursor_date and row["ledger_event_id"] >= cursor_id ): continue filtered.append(row) page = filtered[:limit] has_more = len(filtered) > limit next_cursor = None if has_more: last = page[-1] next_cursor = encode_cursor((last["effective_at"], str(last["ledger_event_id"]))) items = [] for row in page: suggestions = connection.execute( """ SELECT s.suggested_perspective_company_id, s.suggested_subject_code, c.name AS company_name, s.rule_version, s.evidence_json FROM ledger_subject_suggestions s LEFT JOIN companies c ON c.id = s.suggested_perspective_company_id WHERE s.ledger_event_id = ? ORDER BY s.id """, (row["ledger_event_id"],), ).fetchall() display = _event_line_display(connection, row, None) items.append( { "ledger_event_id": row["ledger_event_id"], "revision_id": row["revision_id"], "effective_at": row["effective_at"], "amount": row["amount"], "currency": row["currency"], "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"], "summary": display.get("summary"), "is_repayment": display.get("is_repayment"), "suggestions": [ { "suggested_perspective_company_id": sug["suggested_perspective_company_id"], "suggested_company_name": sug["company_name"], "suggested_subject_code": sug["suggested_subject_code"], "suggested_subject_label": subject_label(sug["suggested_subject_code"]), "rule_version": sug["rule_version"], "evidence": json.loads(sug["evidence_json"]) if sug["evidence_json"] else {}, } for sug in suggestions ], } ) return { "window": {"from": from_, "cutoff": cutoff, "cutoff_inclusive": True}, "items": items, "next_cursor": next_cursor, "has_more": has_more, } # --------------------------------------------------------------------------- # Evidence drill-down # --------------------------------------------------------------------------- def event_evidence( connection: sqlite3.Connection, ledger_event_id: int, *, viewer_company_id: int | None = None, ) -> dict[str, object] | None: """Evidence blocks behind one ledger event with explicit visibility. ``visibility`` is always present (``visible``/``masked``/``missing``); a company user never guesses from absent fields. Company viewers see their own bank/manual evidence in full, the counterparty side masked, and a ``missing`` block when no source exists. """ event = connection.execute( _EVENT_SELECT + " WHERE p.ledger_event_id = ?", (ledger_event_id,) ).fetchone() if event is None: return None blocks: list[dict[str, object]] = [] bank_claims = connection.execute( "SELECT * FROM ledger_event_bank_sources WHERE ledger_event_id = ? ORDER BY bank_event_id", (ledger_event_id,), ).fetchall() manual_claims = connection.execute( "SELECT * FROM ledger_event_manual_sources WHERE ledger_event_id = ? ORDER BY manual_record_id", (ledger_event_id,), ).fetchall() for claim in bank_claims: blocks.extend( _bank_evidence_blocks(connection, claim["bank_event_id"], viewer_company_id) ) for claim in manual_claims: blocks.append( _manual_evidence_block(connection, claim["manual_record_id"], viewer_company_id) ) if not blocks: blocks.append( { "side": "counterparty", "source_kind": "bank", "visibility": "missing", "fields": {}, } ) return { "ledger_event_id": ledger_event_id, "summary": { "effective_at": event["effective_at"], "amount": event["amount"], "currency": event["currency"], "payer_company_id": event["payer_company_id"], "payer_company_name": event["payer_company_name"], "payee_company_id": event["payee_company_id"], "payee_company_name": event["payee_company_name"], "subject_code": event["subject_code"], "subject_label": subject_label(event["subject_code"]), "posting_kind": event["posting_kind"], }, "blocks": blocks, } def _bank_evidence_blocks( connection: sqlite3.Connection, bank_event_id: int, viewer_company_id: int | None, ) -> list[dict[str, object]]: decision = matching._current_decision_for_event(connection, bank_event_id) if decision is None: return [] observations = matching._decision_observations(connection, decision["id"]) blocks: list[dict[str, object]] = [] for observation in observations: source = 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.purpose, r.reference, r.currency, s.sheet_name, f.original_filename, b.company_id AS batch_company_id, c.name AS company_name 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 LEFT JOIN companies c ON c.id = b.company_id WHERE r.id = ? """, (observation["source_row_id"],), ).fetchone() if source is None: continue own = int(source["batch_company_id"]) == int(viewer_company_id) if viewer_company_id is not None else True side = "own" if own else "counterparty" if viewer_company_id is None or own: visibility = "visible" fields = { "original_filename": source["original_filename"], "sheet_name": source["sheet_name"], "source_row": source["source_row"], "transaction_at": source["transaction_at"], "income": source["income"], "expense": source["expense"], "own_account": source["own_account"] if viewer_company_id is None else mask_account_number(source["own_account"]) if source["own_account"] else None, "own_name": source["own_name"], "counterparty_account_masked": ( mask_account_number(source["counterparty_account"]) if source["counterparty_account"] else None ), "counterparty_name": source["counterparty_name"], "summary": source["summary"], "purpose": source["purpose"], "reference": source["reference"], "currency": source["currency"], "role": observation["role"], } else: visibility = "masked" fields = { "company_id": source["batch_company_id"], "company_name": source["company_name"], "own_account_masked": ( mask_account_number(source["own_account"]) if source["own_account"] else None ), "note": "按对方授权不可见", } blocks.append( { "side": side, "source_kind": "bank", "visibility": visibility, "fields": fields, } ) return blocks def _manual_evidence_block( connection: sqlite3.Connection, manual_record_id: int, viewer_company_id: int | None, ) -> dict[str, object]: rows = list_records(connection, limit=10000) record = next((row for row in rows if row["id"] == manual_record_id), None) if record is None: return { "side": "counterparty", "source_kind": "manual", "visibility": "missing", "fields": {}, } own = ( int(record["company_id"]) == int(viewer_company_id) if viewer_company_id is not None else True ) if viewer_company_id is None or own: visibility = "visible" fields = { "manual_record_id": record["id"], "company_id": record["company_id"], "company_name": record["company_name"], "counterparty_company_id": record["counterparty_company_id"], "counterparty_company_name": record["counterparty_company_name"], "occurred_at": record["occurred_at"], "direction": record["direction"], "amount": record["amount"], "currency": record["currency"], "funding_source": record["funding_source"], "requested_subject": record["requested_subject"], "summary": record["summary"], "reason": record["reason"], "state": record["state"], } else: visibility = "masked" fields = { "company_id": record["company_id"], "company_name": record["company_name"], "state": record["state"], "note": "按对方授权不可见", } return { "side": "own" if own else "counterparty", "source_kind": "manual", "visibility": visibility, "fields": fields, }