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