Поток: 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.
201 lines
6.0 KiB
Python
201 lines
6.0 KiB
Python
"""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
|