1315 lines
48 KiB
Python
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}¤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,
|
|
}
|