Files
fin-tracker/docs/ai/import-contract.md
Dmitry ff3b76871d 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.
2026-09-19 10:40:04 +03:00

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; 422action = "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 клиент инвалидирует провайдеры портфеля/событий: цифры на других экранах изменились.