Files
fin-tracker/docs/ai/import-contract.md
Dmitry ff3b76871d 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.
2026-09-19 10:40:04 +03:00

277 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Контракт импорта отчётов (`/api/v1/imports`, `/api/v1/instruments/pending`)
Фаза 3. Этот файл — единственный источник правды по формам запросов и ответов для двух
сторон: бэкенда (`api/routers/imports.py`, `api/schemas/imports.py`) и Flutter-экранов
(`app/lib/features/imports/`, `app/lib/features/pending/`). Обе стороны пишутся
параллельно, поэтому имена полей и коды ошибок здесь важнее, чем красота.
Общие правила проекта действуют без исключений:
- деньги и количества — `Decimal` в Python и **строки** в JSON (`Money` / `MoneyOpt` из
`api/schemas/common.py`); никаких float;
- даты — ISO-8601 (`2026-09-17`), таймстемпы — с таймзоной;
- ошибки — RFC 7807 (`Problem` из `api/errors.py`), `application/problem+json`;
- `AssetClass` **наружу не выставляется**: `asset_class` везде обычная строка
(`"share" | "bond" | "etf" | "fund" | "currency" | "index" | "deposit" | "real_estate"
| "crypto" | "custom"`). То же для `kind`, `status`, `parse_status`, `broker`;
- `generate_unique_id_function` даёт Dart-методы вида `importsCreate`, поэтому у роутов
обязателен `name=` (`@router.post("", name="create")`).
## Поток
```
POST /imports (multipart) → файл в raw_report_file (sha256 UNIQUE), парсинг,
строки в raw_report_line, СОБЫТИЙ В ЛЕДЖЕРЕ НЕТ
↓ ImportPreview: счётчики, дубли, нераспознанные инструменты, сверка остатков
POST /imports/{id}/commit → ledger/ingest.py пишет event (confirmed | shadow | pending)
↓ ImportResult
GET /instruments/pending → что не распозналось
POST /instruments/pending/{id}/resolve → привязка + пересборка лотов
```
Повторная загрузка того же файла (тот же sha256) **не создаёт новый импорт**: сервер
возвращает существующий с `duplicate_of_id`. Это и есть выполнение требования «тот же файл
дважды → 0 новых событий», причём до парсинга.
## 1. `POST /imports` — загрузка и превью
`multipart/form-data`, поля:
| Поле | Тип | Обяз. | Смысл |
|---|---|---|---|
| `file` | файл | да | сам отчёт |
| `account_id` | int | нет | целевой счёт; если не задан — сервер ищет счёт по `account_external_id` из отчёта |
| `parser` | string | нет | принудительный выбор парсера по имени из `registry.names()`; по умолчанию `sniff` |
Ответ `200 ImportPreview` (тот же объект отдаёт `GET /imports/{id}`):
```jsonc
{
"id": 12,
"broker": "sber", // sber | vtb | csv
"filename": "S930W42_11022026_17092026.html",
"sha256": "9f2c…",
"size_bytes": 87333,
"parser_name": "report_sber",
"parser_version": "1",
"parse_status": "parsed", // uploaded | parsed | committed | failed
"error": null,
"duplicate_of_id": null, // != null ⇒ этот файл уже загружали, это он
"account_external_id": "S930W42",
"account_id": 57, // null ⇒ счёт не найден, commit нельзя
"account_name": "Сбер ИИС",
"account_suggestions": [ // чем закрыть account_id, если он null
{"id": 57, "name": "Сбер ИИС", "broker": "sber", "source_id": "S930W42"}
],
"period_from": "2026-02-11",
"period_to": "2026-09-17",
"uploaded_at": "2026-09-18T12:30:00+03:00",
"committed_at": null,
"counts": {
"lines": 61,
"events_total": 47,
"events_new": 47, // нет такого dedupe_key в event
"events_duplicate": 0, // dedupe_key уже есть — апсерт, не вставка
"events_shadow": 0, // пойдут со status = shadow (не primary source)
"events_pending": 3, // ждут резолва pending_instrument
"by_kind": {"buy": 22, "sell": 2, "deposit": 8, "commission": 15}
},
"pending_instruments": [ // нераспознанное из ЭТОГО файла
{
"id": 4, // строка pending_instrument (уже создана)
"source": "report_sber",
"source_key": "ISIN:RU000A1035S8",
"isin": "RU000A1035S8",
"ticker": "STME",
"board": null,
"name": "Первая-ВечныйПортф БПИФ",
"currency": "RUB",
"asset_class_hint": "fund",
"occurrences": 3,
"sample_quantity": "439",
"sample_price": "4.48",
"status": "pending",
"instrument_id": null
}
],
"reconciliation": { // отчёт против derived — считается на превью и на commit
"as_of": "2026-09-17",
"positions": [
{
"instrument_id": 88, // null, если инструмент ещё pending
"instrument_name": "Аэрофлот",
"ticker": "AFLT",
"isin": "RU0009062285",
"qty_report": "130",
"qty_derived": "130", // null, если счёт ещё не пересчитан
"qty_delta": "0",
"matches": true
}
],
"cash": [
{"currency": "RUB", "balance_report": "3171.34", "balance_derived": "3171.34",
"delta": "0", "matches": true}
],
"matches": true // все позиции и остатки сошлись
},
"warnings": ["Раздел «Купонный доход» отсутствует в файле"],
"sample_events": [ // первые 20 строк для глаз пользователя
{
"line_no": 3,
"kind": "buy",
"trade_date": "2026-02-24",
"settle_date": "2026-02-25",
"instrument_key": "ISIN:RU000A1035S8",
"instrument_name": "Первая-ВечныйПортф БПИФ",
"instrument_id": null,
"quantity": "439",
"price": "4.48",
"amount": "-1966.72",
"currency": "RUB",
"fee": "0.39",
"trade_no": "15678045077",
"dedupe_key": "a1b2…",
"is_duplicate": false,
"description": "Покупка"
}
]
}
```
Коды ошибок `POST /imports`:
| Код | Когда | `title` / `detail` |
|---|---|---|
| 415 | ни один парсер не узнал формат | `Unsupported Media Type` / «Формат файла не распознан; известные парсеры: …» |
| 422 | парсер узнал формат и упал (`ParseError`) | `Unprocessable Entity` + текст ошибки; строка `raw_report_file` создаётся со `parse_status = "failed"`, её id — в `Problem.errors[0].import_id` |
| 413 | файл больше 16 МБ | `Payload Too Large` |
Загрузка **никогда не пишет в `event`**. Даже при `account_id = null` файл сохраняется и
парсится — это диагностика, а не ошибка.
## 2. `GET /imports` — список
Параметры: `limit` (1..200, по умолчанию 50), `offset`, `status` (фильтр по `parse_status`).
Ответ — `list[ImportSummary]`: подмножество `ImportPreview` без `sample_events`,
`pending_instruments` и `reconciliation`.
## 3. `GET /imports/{id}` — превью повторно
Тот же `ImportPreview`. Для незакоммиченного импорта счётчики и сверка пересчитываются
(леджр мог измениться), для закоммиченного — берутся из `raw_report_file.counts`.
## 4. `POST /imports/{id}/commit`
Тело:
```jsonc
{
"account_id": 57, // необязательно, если уже проставлен на превью
"confirm_duplicates": false, // true ⇒ апсертить события с уже существующим dedupe_key
"dry_run": false
}
```
Ответ `200 ImportResult`:
```jsonc
{
"import_id": 12,
"committed": true,
"events_created": 44,
"events_updated": 0,
"events_skipped": 3, // ждут pending_instrument
"events_shadow": 0,
"pending_instruments": 1,
"reconciliation": { /* как в превью, пересчитано после записи */ },
"metrics_refreshed": true
}
```
Ошибки:
| Код | Когда |
|---|---|
| 409 | импорт уже закоммичен (`parse_status == "committed"`) |
| 422 | `account_id` не задан и не выводится из отчёта |
| 422 | `parse_status == "failed"` |
| 404 | нет такого импорта |
Commit идемпотентен по `dedupe_key`: повторный вызов на том же файле даёт
`events_created = 0`.
## 5. `DELETE /imports/{id}`
Удаляет только незакоммиченный импорт (`raw_report_file` + `raw_report_line` каскадом).
`409`, если закоммичен: события уже в леджере, и молчаливое удаление следа запрещено
(`raw_*` append-only).
## 6. `GET /instruments/pending`
Параметры: `status` (`pending` по умолчанию, ещё `resolved`, `ignored`, `all`),
`limit`, `offset`. Ответ — `list[PendingInstrumentOut]` (объект как в `pending_instruments`
выше, плюс `first_seen_file_id`, `created_at`).
## 7. `POST /instruments/pending/{id}/resolve`
Ровно один из трёх режимов, различаемых полем `action`:
```jsonc
// а) привязать к существующему инструменту
{"action": "link", "instrument_id": 88}
// б) создать инструмент из данных отчёта и привязать
{"action": "create", "instrument": {
"asset_class": "fund", // строка, не енум
"isin": "RU000A1035S8",
"ticker": "STME",
"board": "TQTF",
"name": "Первая-ВечныйПортф БПИФ",
"currency": "RUB",
"lot": 1
}}
// в) это не инструмент — больше не спрашивать
{"action": "ignore"}
```
Ответ `200 PendingResolveResult`:
```jsonc
{
"id": 4,
"status": "resolved", // resolved | ignored
"instrument_id": 88,
"events_bound": 3, // событий переведено из pending в confirmed/shadow
"alias_created": true, // добавлена строка instrument_alias(source, source_key)
"metrics_refreshed": true
}
```
Ошибки: `404` — нет строки; `409` — уже `resolved`; `422``action = "create"` и
`asset_class` неизвестен, либо ISIN конфликтует с существующим инструментом (в `detail`
id конфликтующего).
**Инструмент никогда не угадывается.** Сервер не делает fuzzy-match по имени: либо точное
совпадение по ISIN/FIGI/uid/(ticker,board)/alias, либо `pending_instrument`.
## Что видит Flutter
Два экрана, оба по этому контракту:
1. `/imports` — список импортов + кнопка загрузки (`file_picker` → multipart).
Карточка импорта: брокер, период, счёт, счётчики, блок «дубликаты» (сколько строк уже
есть в леджере), блок сверки (позиции и кэш отчёта против derived, расхождения красным),
список нераспознанных инструментов со ссылкой на резолв, кнопка «Импортировать»
(disabled, пока `account_id == null`).
2. `/instruments/pending` — список нераспознанного; на строке три действия:
выбрать существующий инструмент (поиск через `GET /instruments?q=`), создать новый,
игнорировать.
После успешного `commit` и после каждого `resolve` клиент инвалидирует провайдеры
портфеля/событий: цифры на других экранах изменились.