# 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`, а для СРО нет полей вообще. Это не позволяет заменить временные 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 списка: ```json { "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: ```json { "uid": "2e5db996-e17d-4f5e-a898-bb29566927e4", "source_group": "", "source": "", "record_type": "", "payload": {}, "organization": {} } ``` Запрещено возвращать для одной группы plain record, а для другой `{ "data": record }`: generated функция и Zod schema должны иметь одну форму ответа. В list payload допускаются только поля, необходимые таблице; detail дополняет, но не изменяет значения общих/list полей. #### Реестр участников бюджетного процесса Текущий запрос таблицы: ```text 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`: ```text 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` должен включать: ```ts 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` только для непустого неизвестного 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; - поля-идентификаторы остаются строками, включая ведущие нули. #### Реестр субъектов МСП — получателей поддержки Текущий запрос таблицы: ```text 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`: ```text 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 моков невозможно. Целевой запрос: ```text 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`: ```text 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: ```ts 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`: ```json { "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-ticket` → `201` с `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. Минимальный набор существующих ключей, который нельзя потерять: ```text 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 только после зелёных интеграционных проверок.