feat(ledger): импорт отчётов в леджер — приём, дедупликация, pending_instrument

Поток: upload -> raw_report_file (sha256 UNIQUE) -> parse -> raw_report_line,
событий в леджере ещё нет -> preview -> POST /imports/{id}/commit ->
ledger/ingest.py резолвит инструмент, считает dedupe_key, пишет event
confirmed | shadow (plan §1.6 B) — сравнивая account.primary_event_source
с источником отчёта, а не гадая. Нерезолвленный инструмент ждёт в
pending_instrument, никогда не угадывается; POST /instruments/pending/{id}/resolve
привязывает и пересобирает лоты.

ledger/dedupe.py — shadow-матчинг случая B двумя проходами (точная дата, затем
±1 рабочий день, жадно 1:1, |price| ±0,5 %). Шаги shadow_dedupe и
report_reconcile зарегистрированы перед quality: оба говорят через FINDINGS.

/instruments/pending регистрируется в app.py ДО routers/instruments.py:
FastAPI сопоставляет маршруты по порядку, и /instruments/{id} с типом int
отвечает 422 на нечисловой сегмент, а не проваливается дальше.

Контракт — docs/ai/import-contract.md, общий для бэкенда и Flutter.
This commit is contained in:
Dmitry
2026-09-19 10:40:04 +03:00
parent 2e742a093b
commit ff3b76871d
16 changed files with 4484 additions and 0 deletions
@@ -114,8 +114,10 @@ def register_steps() -> None:
valuation,
)
from fintracker.ledger.corporate_actions import rebuild_corporate_actions
from fintracker.ledger.dedupe import rebuild_shadow_matches
from fintracker.ledger.matching import rebuild_flow_links
from fintracker.ledger.rebuild import rebuild_lots
from fintracker.ledger.report_import import reconcile_reports
from fintracker.metrics.refresh import register_step
register_step("fx", _step_fx)
@@ -136,6 +138,12 @@ def register_steps() -> None:
register_step("cashflow", cashflow.rebuild_cash_flow_monthly)
register_step("spending", spending.rebuild_spending_by_category)
register_step("runway", runway.rebuild_runway)
# Both report steps speak through FINDINGS, which `quality` drains — so they have to run
# before it, and inside a refresh at all. `shadow_dedupe` pairs a report's events with
# the API's before `report_reconcile` compares the closing balances, or every shadowed
# position would read as a discrepancy.
register_step("shadow_dedupe", rebuild_shadow_matches)
register_step("report_reconcile", reconcile_reports)
register_step("quality", quality.rebuild_data_quality)