Files
caiwuzongzhang/src/bank_importer/positions.py
T

1315 lines
48 KiB
Python

"""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}&currency={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}&currency={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,
}