Поток: 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.
13 KiB
Контракт импорта отчётов (/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}):
{
"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
Тело:
{
"account_id": 57, // необязательно, если уже проставлен на превью
"confirm_duplicates": false, // true ⇒ апсертить события с уже существующим dedupe_key
"dry_run": false
}
Ответ 200 ImportResult:
{
"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:
// а) привязать к существующему инструменту
{"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:
{
"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
Два экрана, оба по этому контракту:
/imports— список импортов + кнопка загрузки (file_picker→ multipart). Карточка импорта: брокер, период, счёт, счётчики, блок «дубликаты» (сколько строк уже есть в леджере), блок сверки (позиции и кэш отчёта против derived, расхождения красным), список нераспознанных инструментов со ссылкой на резолв, кнопка «Импортировать» (disabled, покаaccount_id == null)./instruments/pending— список нераспознанного; на строке три действия: выбрать существующий инструмент (поиск черезGET /instruments?q=), создать новый, игнорировать.
После успешного commit и после каждого resolve клиент инвалидирует провайдеры
портфеля/событий: цифры на других экранах изменились.