Files
mostovik-backend/docs/source-integration/source-records-backend-improvements.md
Aleksandr Meshchryakov 18971d33ec
All checks were successful
Mostovik Backend CI/CD / Tests and lint (push) Successful in 3m55s
Mostovik Backend CI/CD / Build linux/amd64 release images (push) Successful in 3m43s
Mostovik Backend CI/CD / Deploy and verify internal main (push) Has been skipped
Mostovik Backend CI/CD / Deploy customer main (push) Has been skipped
Mostovik Backend CI/CD / Deploy dev (push) Successful in 1m45s
feat: complete published registry contracts and gated SRO ingestion
2026-09-14 17:01:02 +02:00

38 KiB
Raw Blame History

Source records — Backend Improvements

Назначение: единый handoff для backend по трём источникам, которые пока работают через frontend-моки:

  • «Реестр участников бюджетного процесса»;
  • «Реестр субъектов МСП — получателей поддержки»;
  • «Проверка членства в СРО».

Документ составлен после генерации клиента командой bun run apigen из актуального openapi.json. Он фиксирует не только наличие URL, но и весь контракт, необходимый, чтобы frontend мог выключить list/detail-моки, использовать сгенерированный Orval-клиент и сохранить таблицы, фильтры, карточки источников, обновление и выгрузку.

Важно:

  • универсальные URL organization-source-records уже существуют, но сами по себе не являются достаточной реализацией источника;
  • все перечисленные изменения должны быть отражены одновременно в runtime backend, openapi.json и contract tests backend;
  • после изменения спецификации frontend запускает bun run apigen; вручную править сгенерированные файлы нельзя;
  • до поставки соответствующего runtime-контракта моки не удаляются.

Граница этого handoff

Это самостоятельное техническое задание на доработку текущего backend и OpenAPI. Детальные исходные правила нормализации, upstream mapping и расширенные fixture-примеры остаются в source-first спецификациях; они обязательны и не могут противоречить этому документу.

Источник Полная source-first спецификация
Бюджетный процесс docs/source-integration/sources/budget-process-registry/backend-api.md
Поддержка МСП docs/source-integration/sources/sme-support-recipients-registry/backend-api.md
Членство в СРО docs/source-integration/sources/sro-membership-check/backend-api.md

Если backend-реализация выбирает между кратким описанием ниже и обязательным полем/инвариантом в source-first спецификации, применяется source-first спецификация. Изменение контракта требует синхронно изменить оба документа, runtime API и openapi.json.

Идентификаторы источников

Источник Source card slug source_group source / parser key record_type Export group
Реестр участников бюджетного процесса budget-process-registry budget_process_registry budget_ubpandnubp budget_registry_organization budget_process_registry
Реестр получателей поддержки МСП sme-support-recipients-registry government_support fns_sme_support_recipients sme_support_measure government_support
Проверка членства в СРО sro-membership-check sro_membership sro_membership_check sro_membership sro_membership

Идентификаторы — канонические. Frontend может понимать aliases маршрута, но никогда не передаёт их в API вместо значений из таблицы.

Доработки существующих endpoint'ов

organization-source-records

Затрагиваемые endpoint'ы:

  • GET /api/v2/organization-source-records/
  • GET /api/v2/organization-source-records/{uid}/
  • POST /api/v2/organization-source-records/export-ticket/
  • POST /api/v2/organization-source-records/export-download/

Проблема:

  • актуальная OpenAPI описывает общий list/read, но source_group содержит только budget_process_registry и government_support; sro_membership отсутствует;
  • list endpoint описывает только базовые query-параметры. Параметры фильтров, которыми обладают экраны и моки трёх источников, не попадают в generated TypeScript client;
  • payload остаётся смешанным универсальным объектом: бюджетные вложенные блоки сужены до Record<string, string>, а для СРО нет полей вообще. Это не позволяет заменить временные DTO сгенерированными безопасными типами;
  • meta list response свободный object, хотя frontend рассчитывает на meta.pagination.total_count для серверной пагинации;
  • export request не принимает sro_membership.

Нужно:

  • сохранить общий URL, но описать ответ как discriminated union по паре source_group + record_type;
  • вынести в named schemas общую организацию, общую строку, pagination и отдельные list/detail payload каждого из трёх источников;
  • сделать обязательными для published record: uid, source_group, source, record_type, external_id, title, status, created_at, updated_at, organization.uid, organization.name, organization.inn, organization.ogrn, organization.okpo;
  • сохранять nullable именно как JSON null, а не как "", "—" или отсутствующее поле;
  • добавить sro_membership во все применимые enum: list source_group, export sources, source extension, карточка организации и другие фильтры, использующие группу источника;
  • описать meta.pagination отдельной схемой с required полями page, page_size, total_count, total_pages, has_next, has_previous;
  • фильтровать и сортировать до pagination; стабильно разрешать ties по uid, null — последними;
  • поддерживать GET /{uid}/ для каждого uid, возвращённого соответствующим list endpoint;
  • возвращать 400 с машиночитаемым кодом для неизвестного filter/order, 404 для отсутствующей published записи, а не заменять их пустым успешным ответом.

Минимальный общий envelope списка:

{
  "success": true,
  "data": [],
  "errors": null,
  "meta": {
    "pagination": {
      "page": 1,
      "page_size": 50,
      "total_count": 0,
      "total_pages": 0,
      "has_next": false,
      "has_previous": false
    }
  }
}

Форма detail response должна быть единой для всех групп. Рекомендуемый и совместимый с текущим frontend вариант — plain source record без envelope:

{
  "uid": "2e5db996-e17d-4f5e-a898-bb29566927e4",
  "source_group": "<canonical-source-group>",
  "source": "<canonical-source>",
  "record_type": "<canonical-record-type>",
  "payload": {},
  "organization": {}
}

Запрещено возвращать для одной группы plain record, а для другой { "data": record }: generated функция и Zod schema должны иметь одну форму ответа. В list payload допускаются только поля, необходимые таблице; detail дополняет, но не изменяет значения общих/list полей.

Реестр участников бюджетного процесса

Текущий запрос таблицы:

GET /api/v2/organization-source-records/?source_group=budget_process_registry&source=budget_ubpandnubp&record_type=budget_registry_organization&page=1&page_size=50&ordering=extension__organization__name

Нужно добавить в OpenAPI и runtime list endpoint следующие параметры.

Параметр Тип Правило
source_group enum budget_process_registry
source enum/string budget_ubpandnubp
record_type enum/string budget_registry_organization
status enum active, inactive, special, unknown
organization UUID Точный organization.uid
search string, max 200 Наименование, ИНН, КПП, ОГРН, ОКПО, код/реестровый номер
date_from, date_to date Включительно по record_date
region_code string payload.address.region.code
organization_type string Код типа организации
establishment_kind string Код вида учреждения
budget_level string Код уровня бюджета
is_branch boolean Признак обособленного подразделения
has_procurement_permission boolean payload.summary.has_procurement_permission
ordering enum/comma-separated Allowlist ниже; каждый ключ допускает префикс -
page, page_size integer Default 1/50, page_size <= 100

Allowlist ordering:

extension__organization__name, status,
payload__classification__organization_type__name,
payload__classification__establishment_kind__name,
payload__budget__level__name, payload__address__region__name,
updated_at, payload__is_separate_division

BudgetRegistryRecordListPayload должен включать:

interface CodeName {
  code: string
  name: string
}

interface BudgetRegistryRecordListPayload {
  registry: {
    code: string
    registration_number: string | null
    record_number: string | null
  }
  classification: {
    organization_type: CodeName | null
    establishment_kind: CodeName | null
  }
  budget: {
    level: CodeName | null
    code: string | null
    name: string | null
  }
  address: { region: CodeName | null }
  is_separate_division: boolean | null
  summary: {
    activities_count: number
    authorities_count: number
    permissions_count: number
    accounts_count: number
    successions_count: number
    has_procurement_permission: boolean
  }
}

GET /{uid}/ обязан возвращать BudgetRegistryRecordDetailPayload, расширяющий list payload. Это не произвольный object: все блоки и коллекции ниже должны быть named OpenAPI schemas.

Блок detail payload Обязательная структура
registry code, registration_number, record_number, guid, parent_record_number, status_code, status_name, registration_date, inclusion_date, exclusion_date, start_date, end_date, updated_at
legal firm_name, name_in_documents, legal_form, ownership_form, legal_person_kind
classification list-поля, government_body, flags с признаками и contour_type_code
budget list-поля, public_legal_entity, budget_chapter, authorized_organization, treasury_body
address full, postal_code, country, region, district, city, locality, street, house, building, apartment, oktmo, okato, kladr_code
hierarchy founder_kind, founder_place, creator_kind, creator_place, parent_organization, division_parent
reorganization code, name, document, document_number, document_date, start_date, end_date
upstream_audit source_version, load_date, first/last registration GUID, last registration number, control/bid/update number и reason
heads[] full_name, position, is_primary, document_name, document_number, document_date
contacts[] phone, email, website
activities[] code, name, kind
authorities[] code, name, permissions[]: { code, name }
permissions named arrays participant, non_participant, procurement, accepted, transferred, budget_participant, budget_institution
accounts named arrays personal, financial_authority, treasury
successions[], contracts[], attachments[] отдельные named item schemas; fields не сериализуются в одну display-строку
unclassified_blocks Record<string, JsonObject[]> только для непустого неизвестного upstream block, с raw lineage и schema-drift metric

Коллекции всегда массивы, даже если пусты. Неизвестный непустой upstream block не выбрасывается: он сохраняется в unclassified_blocks, маркируется schema-drift metric и получает named schema после появления репрезентативного sample. Frontend не должен реконструировать коллекции из текста или повторно обращаться к Budget.gov.

Инварианты:

  • без status list возвращает все published статусы;
  • records_count карточки источника равен total_count нефильтрованного list на одном snapshot;
  • organizations_count — distinct organization.uid, а покрытие ОПК считается отдельно;
  • следующий failed import не заменяет последний успешный published snapshot;
  • поля-идентификаторы остаются строками, включая ведущие нули.

Реестр субъектов МСП — получателей поддержки

Текущий запрос таблицы:

GET /api/v2/organization-source-records/?source_group=government_support&source=fns_sme_support_recipients&record_type=sme_support_measure&page=1&page_size=50&ordering=-record_date,-external_id

Нужно добавить в OpenAPI и runtime list endpoint параметры:

Параметр Тип Правило
source_group enum government_support
source enum/string fns_sme_support_recipients
record_type enum/string sme_support_measure
organization UUID Точный organization.uid
search string, max 200 Организация, ИНН/ОГРН/ОКПО, номер поддержки, поставщик, форма/вид
sme_category enum/string Категория МСП
region_code string Код региона обогащённой организации
support_form_code string Точный upstream code формы
support_kind_code string Точный upstream code вида
provider_inn string Ровно 10 цифр
has_violation boolean Наличие нарушения
support_unit enum RUB, square_meter, hour, percent, unit
amount_from, amount_to decimal string Только RUB, включительно
registry_entry_from, registry_entry_to date Дата внесения в XML
support_until_from, support_until_to date Срок поддержки
termination_from, termination_to date Дата прекращения
ordering enum/comma-separated Allowlist ниже, - допускается
page, page_size integer Default 1/50, page_size <= 100

Allowlist ordering:

record_date, external_id, extension__organization__name,
extension__organization__inn, extension__organization__ogrn,
extension__organization__okpo, payload__support_form__name,
payload__support_kind__name, payload__support_until,
payload__provider__name, payload__sme_category__name,
payload__has_violation, amount

Нужны named schemas SmeSupportRecordListPayload и SmeSupportRecordDetailPayload.

List payload должен содержать support_registry_number, recipient_type, sme_category, region, support_form, support_kind, даты решения/срока/прекращения/внесения, support_sizes[], provider, has_violation, количество нарушений и документов, дату snapshot. Ниже — обязательные правила типов, без которых типизированный mapper заменить моки не сможет.

Поле Тип и инвариант
Dictionary { code: string, name: string }; не локализованная одиночная строка
support_sizes[] непустой массив `{ unit_code: string, unit: 'RUB' 'square_meter' 'hour' 'percent' 'unit', value: decimal-string }`
amount сумма только RUB-size либо null; часы, проценты и единицы не конвертируются в деньги
region dictionary с nullable provenance; источник значения сохраняется
provider name, inn, nullable organization_uid, okpo, region, oktmo; enrichment не заменяет upstream name/ИНН
Даты ISO YYYY-MM-DD; termination_date и source_updated_date nullable
Счётчики violation_count и regulatory_documents_count — integer, ноль не заменяется null

Нельзя передавать отформатированную валюту или склеенные documents/violations вместо typed arrays.

Detail payload дополнительно обязан включать source_document_id, полный массив regulatory_documents, полный массив violations и provenance snapshot. В объекте нарушения нужны type, recognized_date, remedy_deadline, remedied_date; пустые значения — null. regulatory_documents и violations всегда массивы. Enrichment поставщика nullable и не подменяет исходные provider.name и provider.inn.

Кроме перечисленных list-параметров backend должен принять временные совместимые aliases support_form и support_kind, если они ещё используются активным frontend callsite. Канонические параметры, отражённые в OpenAPI, — support_form_code и support_kind_code; aliases не должны появляться в новых frontend-вызовах и должны быть удалены только в согласованном breaking change.

Проверка членства в СРО

Проблема:

  • в текущем openapi.json нет ни одного идентификатора источника СРО: sro_membership, sro_membership_check, sro-membership-check;
  • source_group list/export не принимает sro_membership, а payload list/detail не содержит status членства, СРО, регион или дату допуска;
  • следовательно, общий URL нельзя безопасно вызвать с generated DTO для данного экрана и переключение off моков невозможно.

Целевой запрос:

GET /api/v2/organization-source-records/?source_group=sro_membership&source=sro_membership_check&record_type=sro_membership&page=1&page_size=50&ordering=organization__name,payload__sro_name,payload__admission_date

Нужно добавить параметры:

Параметр Тип Правило
source_group enum sro_membership
source enum/string sro_membership_check
record_type enum/string sro_membership
organization UUID Точный organization.uid
search string, max 200 name/full name, ИНН, ОГРН, ОКПО, название СРО
membership_status enum active, excluded
region string Точный нормализованный регион
sro_id string Точный resolved ID СРО
admission_date_from, admission_date_to date Включительно
ordering enum/comma-separated Allowlist ниже, - допускается
page, page_size integer Default 1/50, page_size <= 100

Allowlist ordering:

organization__name, organization__full_name, organization__inn,
organization__ogrn, organization__okpo, payload__membership_status,
payload__region, payload__sro_name, payload__admission_date, updated_at

Нужны named schemas:

interface SroMembershipListPayload {
  membership_status: 'active' | 'excluded'
  membership_status_raw: string
  region: string
  sro_id: string
  sro_name: string
  sro_url: string
  admission_date: string | null
  source_registry_date: string | null
}

interface SroMembershipDetailPayload extends SroMembershipListPayload {
  source_record_url: string
  retrieved_at: string
  admission_date_missing_reason: 'not_found' | 'sro_page_unresolved' | 'parse_error' | null
  lineage: {
    lookup_key: 'inn' | 'ogrn'
    lookup_value: string
    sro_resolution: 'row_link' | 'same_site_id' | 'same_site_sitemap'
  }
}

Одна строка — одно членство одной организации в одной СРО. Организация с несколькими СРО возвращается несколькими стабильными uid. sro_url и source_record_url должны быть HTTPS URL только в allowlist *.reestr-sro.ru; backend не должен передавать произвольный upstream URL.

Required/nullable инварианты detail:

  • organization.name, organization.full_name, ИНН, ОГРН, ОКПО, membership_status, membership_status_raw, region, sro_id, sro_name, sro_url обязательны;
  • admission_date, source_registry_date и admission_date_missing_reason nullable;
  • если admission_date задана, admission_date_missing_reason строго null;
  • если admission_date = null, причина отсутствия строго non-null;
  • record_date равна дате допуска при её наличии; status согласован с нормализованным membership_status;
  • unsafe/missing URL, конфликт ИНН/ОГРН, неизвестный membership status и отсутствие ОКПО не публикуются как valid record: они попадают в quarantine с наблюдаемым кодом ошибки.

sources

Затрагиваемые endpoint'ы:

  • GET /api/v1/sources/
  • GET /api/v1/sources/{slug}/
  • POST /api/v1/sources/{slug}/refresh/

Нужно зарегистрировать все три source cards и отдавать их из списка и detail endpoint с согласованными slug, counts, source_items, latest_load, latest_success_load и active_tasks. Source item обязан содержать точный parser/source key из таблицы идентификаторов.

Для каждой карточки:

  • records_count — количество published source records текущего snapshot;
  • organizations_count — distinct organization.uid текущего snapshot;
  • last_updated_at и latest load — дата именно последней успешной публикации, а не запуска;
  • detail и list не расходятся по статусу, counts и активной task;
  • 404 на неизвестный slug, а не пустая успешная карточка.

Refresh принимает {} или { "params": {} }, ставит задачу в очередь и отвечает 202:

{
  "status": "queued",
  "task_ids": ["cce43750-3c50-48b7-aade-22cf4eb6cf87"]
}

Повторный активный запуск той же карточки возвращает typed 409 refresh_already_running и не создаёт competing snapshot. Для СРО до получения разрешения владельца upstream допустим 409 upstream_access_not_approved; это отдельная бизнес-ошибка, не пустой успешный refresh.

parsers, jobs и system logs

Затрагиваемые endpoint'ы:

  • GET /api/v1/parsers/dashboard/
  • POST /api/v1/parsers/run/{source_key}/
  • GET /api/v1/jobs/{task_id}/
  • GET /api/v1/system/logs/, GET /api/v1/system/logs/{id}/, GET /api/v1/system/logs/export/

Нужно:

  • добавить три parser source key в dashboard, parser run и history enum/filter: budget_ubpandnubp, fns_sme_support_recipients, sro_membership_check;
  • для parser run не требовать внешние URL, pagination или upload-параметры: настройки источника принадлежат backend;
  • документировать response parser run (task_id, status, source) и 409 duplicate run;
  • в job detail возвращать typed queued, running, retrying, success, failed, cancelled, skipped, целочисленный progress, сообщение и source-specific result;
  • отображать counts, registry/snapshot date и errors одного published snapshot в dashboard и history;
  • добавить source label и canonical parser source в list/detail/export логов.

export-ticket и export-download

Текущий OpenAPI уже содержит ticket/download URL, но export enum не принимает sro_membership.

Нужно:

  • добавить все значения из колонки Export group в OrganizationSourceRecordExportRequest.sources;
  • сохранить асинхронный ticket flow: POST export-ticket201 с ticket, expires_in, file_name; затем form POST на export-download;
  • сделать ticket одноразовым и короткоживущим; после истечения/повторного применения возвращать typed 410 ticket_expired;
  • выгружать именно тот published snapshot, который соответствует source records, не raw/imported данные;
  • поддержать JSON, CSV и XLSX в соответствии с declared capability источника; значения ИНН, ОГРН, ОКПО, IDs и номера реестра оставлять строками.

Регрессия после bun run apigen: тип ordering

Проблема:

  • генерация актуальной схемы успешно завершается, но bun run type-check завершается ошибкой;
  • из generated endpoints.schemas.ts исчез экспорт V2OrganizationSourceRecordsListOrdering;
  • его импортируют useMassMediaSourceTable.ts и useRopkSanctionsSourceTable.ts.

Причина:

  • параметр ordering у GET /api/v2/organization-source-records/ стал просто string;
  • Orval генерирует отдельный alias V2OrganizationSourceRecordsListOrdering только для OpenAPI enum. Поэтому эта регрессия относится к контракту, а не к настройке Orval.

Нужно:

  • вернуть ordering как named enum/schema в OpenAPI list operation;
  • включить в него все уже поддерживаемые ключи СМИ и санкций, а также ключи трёх источников из этого документа;
  • поддержать на runtime все значения enum, включая обратную сортировку с - и несколько полей через запятую, либо явно заменить frontend-контракт отдельным согласованным string type. Первый вариант предпочтителен: он сохраняет совместимость generated API;
  • после изменения выполнить bun run apigen и bun run type-check в backend/frontend CI.

Минимальный набор существующих ключей, который нельзя потерять:

record_date, external_id, extension__organization__name,
payload__news_source, payload__sentiment,
payload__united_states, payload__united_states_sectoral,
payload__uk_hm_treasury, payload__uk_uksl,
payload__european_union, payload__switzerland, payload__ukraine

Приёмка и последовательность замены моков

Backend готов к передаче frontend только после выполнения всех пунктов ниже.

  • Runtime API и openapi.json содержат одинаковые identifiers трёх источников.
  • List и detail records имеют named/discriminated schemas для budget, SME support и SRO.
  • Все фильтры и ordering allowlist из этого документа доступны в runtime и видны Orval после bun run apigen.
  • sro_membership присутствует во всех требуемых enum, включая records и export.
  • Pagination является typed и возвращает корректный total_count после фильтрации.
  • Source cards, dashboard, list, detail, jobs и logs считают один published snapshot.
  • Refresh/parser run возвращают typed task response, duplicate run — typed 409.
  • Export ticket/download поддерживает три source group и выдаёт published записи.
  • Есть backend contract fixtures: zero, один и many records; nullable dates; multiple SRO на одну организацию; partial и large detail бюджетного реестра; поддержка с несколькими размерами/документами/нарушениями.
  • bun run apigen проходит без ручных изменений generated файлов.
  • bun run type-check проходит, включая СМИ и зарубежные санкции.

После этого frontend выполняет: отключает VITE_BUDGET_PROCESS_REGISTRY_MOCK, VITE_SME_SUPPORT_RECIPIENTS_MOCK, VITE_SRO_MEMBERSHIP_MOCK; заменяет временные DTO на generated schemas/adapters; добавляет runtime contract tests и удаляет mock fixtures только после зелёных интеграционных проверок.