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