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,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` клиент инвалидирует провайдеры
|
||||
портфеля/событий: цифры на других экранах изменились.
|
||||
Reference in New Issue
Block a user