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:
@@ -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
|
||||
Reference in New Issue
Block a user