Перейти к содержанию

Жизненный цикл заявки v2 (T19)

workflow.py ведёт заявку по статусам SPEC §13, пишет журнал действий и запускает автоматический расчёт: обогащение → качество данных → стоп-факторы → скоринг → предложения → маршрут → Decision. Заявка v2 хранится в таблице loan_applications, журнал — в application_events, факты — в хранилище фактов (fact_store.py, T13). Код v1 удалён; исторические таблицы сохранены, но в работе конвейера не участвуют (см. MIGRATIONS.md).

Статусы и переходы

submitted -> enriching -> scoring -> offered | declined | in_review
                          scoring -> needs_data  (данные THIN, выписок ещё не просили, T23)
             enriching -> declined   (компания не найдена в ЕГРЮЛ / ЕГРИП)
             enriching -> in_review  (обязательный источник недоступен)
needs_data -> enriching                               (клиент загрузил выписки или продолжил без них, T23, STATEMENTS.md)
in_review -> offered | declined | needs_data          (сотрудник, T20, STAFF_ACTIONS.md)
in_review -> submitted                                (сотрудник перезапускает расчёт, T20)
offered -> accepted -> documents -> signed -> conditions -> disbursed   (T21, DISBURSEMENT.md)
offered -> expired                                    (срок предложения истёк)
до подписания из любого статуса -> withdrawn         (клиент)
enriching | scoring -> submitted                      (прерванный расчёт возвращается в очередь)

Финальные статусы: declined, disbursed, withdrawn, expired. Таблица переходов — TRANSITIONS. Статус меняет только transition(db, app, to, actor=..., message=...): переход вне таблицы — ошибка IllegalTransition, каждый переход пишет событие в журнал. Переходы enriching → declined / in_review, enriching | scoring → submitted и in_review → submitted в схеме SPEC §13 не нарисованы: это ранний выход из конвейера, повтор после сбоя и повторный расчёт по решению сотрудника (например, когда обязательный источник снова доступен).

Журнал действий

application_events — только добавление, одна строка на событие:

Поле Что
at Время (UTC, ISO 8601)
actor system, client:<id пользователя> или staff:<id пользователя>
kind status — смена статуса; source — шаг вызова источника; action — данные собраны; decision — решение; error — сбой расчёта; review, proposal, signature — действия сотрудника и подписи комитета (STAFF_ACTIONS.md). Код подписания, неверный код и проверка отлагательных условий (T21) — события action
from_status, to_status Для status
message Текст для человека
data Подробности: попытка и ошибка источника, маршрут, грейд, PD, версии модели и политики, id предложений, коды стоп-факторов и флагов

События source пишутся сразу, по ходу обогащения (enrich(..., on_event=...)), поэтому экран «Анализируем ваш бизнес» (T27) может опрашивать GET /api/v2/applications/{id} и показывать статус каждого источника (поле sources).

Автоматический расчёт

Worker (worker.py, раз в 0,5 с) вызывает workflow.tick() и затем disbursement.tick() — отлагательные условия и выдачу (T21, DISBURSEMENT.md). workflow.tick():

  1. recover_stale() — заявки, которые зависли в enriching / scoring дольше аренды (120 с), считаются сбоем;
  2. expire_offers() — offered → expired, когда прошёл срок последнего предложения (Offer.valid_until, 10 дней);
  3. run_next() — берёт самую старую заявку submitted. Захват — это сам переход submitted → enriching с проверкой версии строки, поэтому два worker не возьмут одну заявку. Если таких нет — заявку, которая вернулась из needs_data (claim_resumed): она уже в enriching, но без аренды, и захват — это взятие аренды.

После этого worker вызывает preapproval.tick(): раз в сутки, в отдельном потоке, считается предодобренный овердрафт (T22, PREAPPROVAL.md).

run_application:

  1. enrich — все источники параллельно (T07). Заявка, вернувшаяся из needs_data, источники заново не опрашивает, если в прошлый раз ответили все обязательные: берутся сохранённые ответы. Иначе каждая выписка стоила бы клиенту лишнего запроса в БКИ, а запросы БКИ снижают балл.
  2. Одна транзакция: факты в хранилище фактов (с фактами из выписок клиента, statement_upload), ответы источников (без фактов) в loan_applications.enrichment, DataQuality (T09; с выписками — по нашему счёту вместе с ними) в data_quality. Если обогащение полное — переход в scoring.
  3. decide(...) — чистая функция: одинаковые входы дают одинаковое решение.
  4. Вторая транзакция: Decision в loan_applications.decision, событие decision, переход в статус решения.

Если клиент отозвал заявку во время расчёта, результат отбрасывается. Если расчёт упал, заявка возвращается в submitted с задержкой 5 × номер попытки секунд; после третьей попытки уходит в in_review без решения (событие error).

Как принимается решение (decide)

Ситуация Статус Маршрут, причины
Компания не найдена в ЕГРЮЛ / ЕГРИП declined auto; причина company_not_found, дата повторного обращения не задаётся
Обязательный источник недоступен in_review underwriter, route_source_unavailable; скоринга и предложений нет
Стоп-фактор (T14) declined auto, route_stop_factor; скоринга нет
Карта не подходит к признакам (FeatureSetMismatch, T15) in_review underwriter, route_score_unavailable; предложений нет
Данные THIN, клиент ещё не отвечал на запрос данных (statements is None) needs_data маршрут и причины считаются как обычно (route_data_thin), балл есть; предложений нет; requested_documents — выписка из другого банка за 12 месяцев (T23)
Маршрут auto, есть предложения offered route_auto
Маршрут auto, предложений нет declined причины из генератора предложений (T17), повторное обращение через 90 дней
Маршрут андеррайтер или комитет in_review причины T18; посчитанные предложения остаются в Decision, сотрудник одобряет или меняет их (T20, STAFF_ACTIONS.md)

Порядок вызовов: data_quality_for (наш счёт плюс выписки клиента) → evaluate_stop_factors → features_from_responses → score (основная модель решает, претендент только записывается) → check_statements (флаги по выпискам) → cash_flow (наш счёт плюс принятые выписки) → generate_offers(..., route_for(grade, flags, data_quality, statements=...), день решения) → route_application. Стоп-факторы, признаки и балл считаются только по нашим данным и госисточникам, выписки на них не влияют (STATEMENTS.md).

Маршрут заявки при нескольких предложениях. Предложения одной заявки могут считаться на разных маршрутах (1 млн ₽ — андеррайтер, 730 тыс. ₽ — автоматически). Заявка идёт по самому строгому из них, а причины маршрута считаются для суммы этого предложения. Так одно рассмотрение сотрудником покрывает все варианты, которые увидит клиент. Без предложений маршрут считается для запрошенной суммы.

Дата расчёта признаков. Признаки и стоп-факторы считаются на дату синтетического мира (WORLD_AS_OF, по умолчанию 2026-10-01), а не на сегодня: мир — снимок, и с каждым днём возраст бизнеса и окна запросов БКИ уезжали бы от данных. Срок действия предложений и дата повторного обращения считаются от реального дня решения (МСК).

Версия политики. Decision.policy_version — одна строка, а политик три. Пишем их все: credit=credit_policy_v0;data_quality=data_quality_v0;features=features_v0. Предложение отдельного поля в контракте — в docs/v2/NOTES.md, Unassigned.

API (/api/v2)

Метод Кто Что
GET /api/v2/companies/{inn} клиент Поиск в мок-ЕГРЮЛ/ЕГРИП для карточки T25. Возвращает сведения компании и ИНН основного собственника; 404 — компания не найдена, 503 — источник недоступен. Нужен вход в кабинет
POST /api/v2/applications клиент {"idempotency_key", "request"}: request — ApplicationRequest без application_id и submitted_at (их задаёт сервер). Повтор с тем же ключом возвращает ту же заявку. Инвестиционный кредит без оборудования — 422
GET /api/v2/applications[?status=] клиент — свои, сотрудник — все Список
GET /api/v2/applications/{id} клиент, сотрудник Заявка
GET /api/v2/applications/{id}/events сотрудник Полный журнал
POST /api/v2/applications/{id}/accept клиент {"offer_id", "revision"}: offered → accepted → documents (документы формируются сразу, T21). Просроченное предложение переводит заявку в expired и отвечает 409
POST .../sign/code, .../sign, GET .../documents/{doc_id} клиент Подписание кодом и документы (T21, DISBURSEMENT.md)
POST /api/v2/applications/{id}/withdraw клиент {"revision"}: → withdrawn
POST /api/v2/applications/{id}/statements клиент multipart/form-data: files (до 5 файлов по 2 МБ) и revision: needs_data → enriching. Нераспознанный файл, чужой ИНН, повтор файла — 422 с текстом для клиента (T23, STATEMENTS.md)
POST /api/v2/applications/{id}/statements/skip клиент {"revision"}: продолжить без выписки, needs_data → enriching
POST /api/v2/applications/{id}/review, .../review/sign сотрудник Действие с кодом причины и комментарием; вторая подпись комитета (T20, STAFF_ACTIONS.md)
GET /api/v2/staff/reason-codes, GET /api/v2/staff/overrides сотрудник Коды причин; как часто сотрудники не соглашаются с системой
GET /api/v2/preapproval?inn=…, POST /api/v2/staff/preapproval/run клиент, сотрудник Предодобренный овердрафт для баннера; запуск расчёта (T22, PREAPPROVAL.md)
POST /api/assistant/messages клиент { "message", "application_id"? }: если передан ID открытой v2-заявки, API проверяет владельца и передаёт помощнику только клиентское представление этой заявки; без ID — общий справочный контекст. Никаких действий над заявкой (T37).

revision — версия строки заявки. Если она устарела, ответ 409: заявка изменилась.

Клиент видит запрос, смены статусов, ход опроса источников (sources: источник, попытка, статус, без текста ошибки), свои выписки (statements: файл, банк, период, время загрузки) и сокращённое решение (client_decision): клиентские тексты причин без повторов, предложения без ожидаемой прибыли, контрфактические подсказки, уровень данных с фразой для клиента, дату повторного обращения. Флаги, баллы, PD, модель-претендент, тексты для сотрудника и журнал с исполнителями клиенту не отдаются: среди них есть признаки ПОД/ФТ (SPEC §1.11). Сотрудник видит всё: Decision, DataQuality, статусы источников, факты, полный журнал, pending_review — решение, которое ждёт второй подписи комитета, — и statements — выписки клиента целиком (null — клиент ещё не отвечал на запрос данных, [] — продолжил без выписки).

Упрощения

  • # MOCK: GET /api/v2/companies/{inn} возвращает запись синтетического мира из мока ЕГРЮЛ/ЕГРИП. Галочки T25 и время согласия моделируют сбор согласий в демо: подтверждение личности, юридически значимое подписание и постоянный реестр согласий не реализованы.
  • # MOCK: признаки считаются на дату синтетического мира, а не на дату ответа источников (см. выше). С реальными источниками дата — snapshot_date(responses).
  • # SIMPLIFICATION: очередь — это колонка status, которую опрашивает worker внутри процесса API. В банке это брокер сообщений или workflow-движок с собственными повторами и очередью ошибок.
  • Срок предложения истекает при очередном проходе worker или при попытке выбрать предложение, а не точно в полночь.