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:
Dmitry
2026-09-19 10:40:04 +03:00
parent 2e742a093b
commit ff3b76871d
16 changed files with 4484 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
# Контракт импорта отчётов (`/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` клиент инвалидирует провайдеры
портфеля/событий: цифры на других экранах изменились.