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
@@ -0,0 +1,200 @@
"""Schemas for `/imports` and `/instruments/pending`, per `docs/ai/import-contract.md`.
Two conventions the Flutter client depends on, both repeated from AGENTS.md because breaking
either one breaks the generated Dart rather than a test:
* money and quantities are `Money` / `MoneyOpt` — `Decimal` in Python, a string on the wire;
* every stable key (`broker`, `parse_status`, `kind`, `status`, `asset_class`) is a plain
`str`, never an enum. `AssetClass.index` cannot exist as a Dart enum member (it collides
with `Enum.index`) and one enum leaking out stops the whole client from compiling.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field
from fintracker.api.schemas.common import Money, MoneyOpt
class AccountSuggestion(BaseModel):
"""A broker account the user may attach an import to when the report's number matched none."""
id: int
name: str
broker: str | None
"""tinvest | sber | vtb | other"""
source_id: str
class ImportCounts(BaseModel):
lines: int
events_total: int
events_new: int
"""No `event` carries this row's `dedupe_key` yet."""
events_duplicate: int
"""The key is already in the ledger: committing upserts, it does not insert."""
events_shadow: int
"""Rows that will land as `shadow` — this feed is not the account's primary source."""
events_pending: int
"""Rows whose instrument nobody recognises yet."""
by_kind: dict[str, int]
class PendingInstrumentOut(BaseModel):
id: int
source: str
source_key: str
isin: str | None = None
ticker: str | None = None
board: str | None = None
name: str | None = None
currency: str | None = None
asset_class_hint: str | None = None
"""share | bond | etf | fund | currency — the report's own wording, never applied alone."""
occurrences: int
sample_quantity: MoneyOpt = None
sample_price: MoneyOpt = None
status: str
"""pending | resolved | ignored"""
instrument_id: int | None = None
first_seen_file_id: int | None = None
created_at: datetime | None = None
class ReconciliationPosition(BaseModel):
instrument_id: int | None
instrument_name: str
ticker: str | None
isin: str | None
qty_report: Money
qty_derived: MoneyOpt
"""Null when the account is unknown or its lots were never rebuilt — never a zero."""
qty_delta: MoneyOpt
matches: bool
class ReconciliationCash(BaseModel):
currency: str
balance_report: Money
balance_derived: MoneyOpt
delta: MoneyOpt
matches: bool
class ReconciliationOut(BaseModel):
as_of: date | None
positions: list[ReconciliationPosition] = Field(default_factory=list)
cash: list[ReconciliationCash] = Field(default_factory=list)
matches: bool = True
class SampleEventOut(BaseModel):
line_no: int
kind: str
"""buy | sell | dividend | coupon | … — the `EventKind` value as a string."""
trade_date: date
settle_date: date | None = None
instrument_key: str | None = None
instrument_name: str | None = None
instrument_id: int | None = None
quantity: MoneyOpt = None
price: MoneyOpt = None
amount: Money
currency: str
fee: MoneyOpt = None
trade_no: str | None = None
dedupe_key: str
is_duplicate: bool
description: str | None = None
class ImportSummary(BaseModel):
"""One import without the heavy parts — what `GET /imports` lists."""
id: int
broker: str
"""sber | vtb | csv"""
filename: str
sha256: str
size_bytes: int
parser_name: str | None = None
parser_version: str | None = None
parse_status: str
"""uploaded | parsed | committed | failed"""
error: str | None = None
duplicate_of_id: int | None = None
"""Not null ⇒ these bytes were already uploaded, and this is that import."""
account_external_id: str | None = None
account_id: int | None = None
"""Null ⇒ the account is unknown and commit is refused."""
account_name: str | None = None
period_from: date | None = None
period_to: date | None = None
uploaded_at: datetime | None = None
committed_at: datetime | None = None
counts: ImportCounts
class ImportPreview(ImportSummary):
"""The full preview: `POST /imports` and `GET /imports/{id}` both return this."""
account_suggestions: list[AccountSuggestion] = Field(default_factory=list)
pending_instruments: list[PendingInstrumentOut] = Field(default_factory=list)
reconciliation: ReconciliationOut
warnings: list[str] = Field(default_factory=list)
sample_events: list[SampleEventOut] = Field(default_factory=list)
class CommitRequest(BaseModel):
account_id: int | None = None
"""Needed only when the preview could not work the account out on its own."""
confirm_duplicates: bool = False
dry_run: bool = False
class ImportResult(BaseModel):
import_id: int
committed: bool
events_created: int
events_updated: int
events_skipped: int
"""Rows still waiting on a `pending_instrument`."""
events_shadow: int
pending_instruments: int
reconciliation: ReconciliationOut
metrics_refreshed: bool
class NewInstrument(BaseModel):
"""The instrument to create from a report's own data (`action = "create"`)."""
asset_class: str
"""share | bond | etf | fund | currency | index | deposit | real_estate | crypto | custom"""
name: str
currency: str = "RUB"
isin: str | None = None
ticker: str | None = None
board: str | None = None
lot: int = 1
class PendingResolveRequest(BaseModel):
action: Literal["link", "create", "ignore"]
instrument_id: int | None = None
"""Required for `link`."""
instrument: NewInstrument | None = None
"""Required for `create`."""
class PendingResolveResult(BaseModel):
id: int
status: str
"""resolved | ignored"""
instrument_id: int | None = None
events_bound: int
"""How many events left `pending` for `confirmed`/`shadow`."""
alias_created: bool
metrics_refreshed: bool