# Контракт импорта отчётов (`/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` клиент инвалидирует провайдеры портфеля/событий: цифры на других экранах изменились.