feat: complete published registry contracts and gated SRO ingestion
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

This commit is contained in:
Aleksandr Meshchryakov
2026-09-14 17:01:02 +02:00
parent 49cbfd265c
commit 18971d33ec
76 changed files with 33156 additions and 311 deletions

View File

@@ -0,0 +1,527 @@
# 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 списка:
```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": "<canonical-source-group>",
"source": "<canonical-source>",
"record_type": "<canonical-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<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;
- поля-идентификаторы остаются строками, включая ведущие нули.
#### Реестр субъектов МСП — получателей поддержки
Текущий запрос таблицы:
```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 только после
зелёных интеграционных проверок.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
# Полный контракт записей источников — 14.09.2026
Основание: `source-records-backend-improvements.md`, присланный пользователем.
Пользователь явно включил весь документ, включая СРО, специальные фильтры МСП
и полный detail бюджета. Запрет на сообщения Глебу сохраняется.
База backend: `49cbfd2`, ветка `codex/source-records-contract`.
14.09 штатный SSH-маршрут оказался недоступен на первом переходе; доступ
восстановлен через существующий российский узел Tailscale с проверенными ключами.
Свежий fetch подтвердил backend `49cbfd2`, frontend origin/dev `c6f2ec7`;
проверки генерации выполняются в отдельном worktree, рабочий frontend не меняется.
## Задачи и владельцы
1. Budget/SME normalization и named list/detail payload schemas — агент Mostovik.
Файлы budget_registry.py, sme_support.py, новые payload-модули и тесты.
2. СРО: нормализация, разрешённый HTTP loader, staging, models/descriptors/tasks,
access gate, миграции и тесты — агент требований.
3. Cards/dashboard/jobs/logs/export typed contracts и counts — агент State Corp.
Реализация находится исключительно в Mostovik backend.
4. Root: organization-source-records validation/search/filter/order/pagination,
serializer integration, discriminated OpenAPI, generated client checks.
5. Независимое ревью, регрессия SQLite/PostgreSQL, migration/OpenAPI, dev release
и доступные живые проверки после восстановления соединения.
## Согласование общих границ
| Участки | Общая поверхность | Решение |
| --- | --- | --- |
| 1/4 | Payload normalizers и response serializer | Агент создаёт отдельные typed payload serializers и нормализатор старого payload; root подключает их в serializers.py. |
| 2/3 | Source cards и parser run | Агент 2 передаёт descriptor/task/gate интерфейсы; source_cards.py и parser views редактирует агент 3. |
| 1/2 | Snapshot infrastructure | Переиспользуется registry_snapshots.py; изменения общего механизма согласуются сообщением. |
| 3/4 | Record export actions | source_record_export.py — агент 3; organizations/views.py — root по переданному интерфейсу. |
| 1 | Известные/неизвестные поля | Не угадывать коды и значения; unknown blocks сохраняются с lineage и метрикой. |
| 2 | Upstream approval | Default disabled; до разрешения владельца upstream оба ручных запуска дают typed409 до enqueue. Задача повторно проверяет gate. |
| 3 | Counts | Для трёх новых источников весь published dataset; ОПК coverage отдельно. Legacy источники сохраняют scope. |
| 4 | Совместимость | Legacy payload/search сохраняются. Новые source-specific поля валидируются и не игнорируются. |
Ruling: канонический ticket flow остаётся ready201 → native form download200,
как в новом handoff и действующем frontend; устаревшее требование preparing202
в SME/SRO source-first docs синхронизируется с этим контрактом. Стоимость ошибки:
если требуется отдельная on-demand генерация, понадобится согласованный frontend polling.
Ruling: публикация СРО без согласованного доступа не запускается. Реализация и
fixture acceptance обязательны; отсутствие разрешения отражается typed409,
а не успешной пустой загрузкой. Стоимость: СРО останется без живого snapshot до разрешения.
## Проверки завершения
- Все требования отмечаются результатами в этом документе, пропуски отдельно.
- Runtime и OpenAPI: required/nullable, source variants, errors, page metadata.
- Фильтры, null-last ordering и ties UID до пагинации на PostgreSQL.
- Fixtures: empty/one/many, Budget partial/large/unknown blocks, SME mixed units,
документы/нарушения, несколько СРО, unsafe URL и идентификационные конфликты.
- Генерация `bun run apigen`, затем `bun run type-check` без ручного изменения generated.
- Совместимость опубликованных данных и сохранение предыдущего snapshot при ошибке.
- Миграции, pytest, scope review, dev release только после сверки remote.

View File

@@ -0,0 +1,103 @@
# Карточки, задачи, история и готовые выгрузки — 14.09.2026
Этот документ уточняет общий runtime-контракт по новому
[source-records-backend-improvements.md](source-integration/source-records-backend-improvements.md).
Для выгрузок он заменяет разделы SME/SRO source-first спецификаций, в которых
описан `preparing` / HTTP 202. Действующий frontend использует готовый ticket
HTTP 201 и native form POST со скачиванием HTTP 200; этот flow сохранён.
| Источник | Card slug | Parser source | Export group |
| --- | --- | --- | --- |
| Бюджет | budget-process-registry | budget_ubpandnubp | budget_process_registry |
| МСП | sme-support-recipients-registry | fns_sme_support_recipients | government_support |
| СРО | sro-membership-check | sro_membership_check | sro_membership |
## Карточки и история
GET `/api/v1/sources/` и `/api/v1/sources/{slug}/` возвращают одинаковую полную
форму карточки, включая `source_items`, `active_tasks`, `latest_load` и
`latest_success_load`. Для трёх источников `records_count` — все опубликованные
записи, `organizations_count` — уникальные организации опубликованного набора.
ОПК остаётся отдельным показателем dashboard `registry_data_coverage` и
`registry_enrichment_analytics`. Scope остальных карточек сохранён.
Последняя неуспешная попытка видна в `latest_load`; она не заменяет
`latest_success_load` и не сдвигает `last_updated_at`. Данные `snapshot`
в карточках, dashboard и логах содержат `artifact_id`, `snapshot_date`, `version`,
`checksum_sha256`, `published_at`. Неизвестная дата/отсутствующая публикация —
null; пустая версия/checksum не подменяется выдуманным значением.
В `/api/v1/system/logs/`, detail и CSV для новых источников `source`
канонический parser source из таблицы, `source_label` — человекочитаемое название.
CSV добавляет код источника, дату/версию/SHA256 снимка. `records_count` отражает
опубликованный итог. У incremental СРО текущий итог и изменённая часть разделены
в artifact metadata (`snapshot_records_count`, `snapshot_organizations_count`,
`batch_published_records_count`, `updated_records_count`).
## Запуск и polling
Оба admin entrypoint — POST `/api/v1/sources/{slug}/refresh/` и
`/api/v1/parsers/run/{source}/` — принимают `{}` или `{"params": {}}`.
Внешние URL, file paths и параметры loader не принимаются для трёх источников.
Только SRO parser-run дополнительно принимает `mode: incremental|full`,
по умолчанию incremental; неверное значение — HTTP 400 `invalid_mode`.
Card refresh возвращает plain HTTP 202 `{task_id, task_ids, status: "queued"}`;
parser-run возвращает эти поля плюс `source` и `task_name` внутри стандартного
`{success, data, errors, meta}`. Активный запуск исключает второй запуск через
любой entrypoint: HTTP 409 `refresh_already_running`. До согласования upstream
доступа СРО возвращает HTTP 409 `upstream_access_not_approved`, без постановки
задачи в очередь. Worker повторно проверяет доступ.
GET `/api/v1/jobs/{task_id}/` для этих трёх источников различает `queued`,
`running`, `retrying`, `success`, `failed`, `cancelled`, `skipped`; `progress`
остаётся целым числом, `message` отражает ход работы. `source` — канонический код.
`result` успешной публикации содержит batch/load/artifact IDs, счётчики и
метаданные снимка (`SnapshotJobResult`). `meta` для новых задач типизирован как `SnapshotRunMetadata`: режим СРО,
число кандидатов/запрошенных организаций/найденных членств, публикация и карантин,
счётчики HTTP/parse ошибок и nullable дата реестра. В публичный meta входят только
разрешённые счётчики, без конфигурации loader или raw responses. Те же счётчики
доступны в log meta; terminal result имеет aliases `records_count` / `organizations_count`.
Legacy jobs сохраняют прежние значения
статуса и прежний result. Общая схема допускает legacy `error`, deprecated raw aliases
`pending|started|retry|failure|revoked` и дополнительные
поля result. Generic parser results list имеет уникальный operationId
`api_v1_parsers_results_list`; detail сохраняет прежний `api_v1_parsers_results_read`.
Dashboard сохраняет operationId `api_v1_parsers_dashboard_list` и tag `api`,
чтобы generated client оставался в прежнем модуле.
## Выгрузка
POST `/api/v2/organization-source-records/export-ticket/` с `sources` и `format`
возвращает HTTP 201 `{ticket, file_name, expires_in}`. Ticket одноразовый,
TTL по умолчанию 300 секунд. POST `/export-download/` с form field `ticket`
возвращает ZIP HTTP 200; истёкший, неизвестный или использованный ticket —
HTTP 410 `{detail, code: "ticket_expired"}`. Capability позволяет native form
без передачи JWT в URL. Для выпуска ticket нужны прежние admin permissions.
Публикация любого из трёх снимков запрашивает существующую фоновую сборку
экспорта после commit. Если подготовленное поколение относится к предыдущей
публикации, новый ticket получает HTTP 503 `source_export_not_ready` до готовности
новой сборки. Ошибка импорта не делает успешное поколение устаревшим.
При занятой блокировке запрос сборки повторяется через 30 секунд; бюджет покрывает
TTL блокировки плюс два повтора. Исчерпание бюджета — ошибка Celery с явной записью
в журнале, а не успешный пропуск. Ночной запуск остаётся восстановительным путём.
Сборка читает записи и идентификаторы публикаций из одного PostgreSQL REPEATABLE
READ snapshot; спулирование завершается до рендеринга CSV/JSON/XLSX. Вложенный
transaction защищён дополнительной проверкой неизменности идентичности публикаций.
Ticket закрепляет immutable generation: новая публикация не меняет его содержимое.
Удаление старых поколений выдерживает TTL после retirement; уже начавшийся stream
держит открытые файлы и не ломается при последующей уборке поколения.
Все три группы экспортируют полную опубликованную историю; ИНН, ОГРН и ОКПО
остаются строками во всех форматах. После обновления схемы/нормализации старого
payload требуется запустить существующую команду `build_source_record_exports`:
появление СРО меняет матрицу с 40 на 43 артефакта, а нормализация payload сама
по себе не создаёт новый artifact ID. Она атомарно увеличивает content revision
затронутых опубликованных артефактов; новая выдача ticket проверяет и эту ревизию.
До пересборки возвращается 503, ранее выданные tickets сохраняют свою generation.
Локальные контрактные тесты не подтверждают живой upstream СРО или принятие
frontend UI. Доступ к upstream по умолчанию выключен; включение требует отдельного
согласованного основания. Настоящие адреса/секреты стендов в эти проверки не входят.

View File

@@ -0,0 +1,139 @@
# Проверка членства в СРО: эксплуатация
Источник `sro_membership_check`, группа/тип `sro_membership`, карточка
`sro-membership-check`. Upstream — сайт `reestr-sro.ru`; внутренний справочник
организаций используется для точной привязки UID и ОКПО. Другие внешние реестры
загрузчик не опрашивает.
## Доступ и пределы запросов
По умолчанию сбор закрыт. Для открытия необходимы одновременно
`SRO_UPSTREAM_ACCESS_APPROVED=true` и непустой
`SRO_UPSTREAM_APPROVAL_REFERENCE` — ссылка/номер документированного разрешения
владельца сайта на автоматизированные query/search запросы. Согласование разработки
внутри проекта не заменяет разрешение владельца upstream. Условия разрешения
должны быть совместимы с настройками загрузчика; более строгие пределы задаются
перед включением. Секреты в approval reference хранить нельзя.
Оба ручных entrypoint проверяют этот gate до enqueue; task и HTTP клиент повторяют
проверку перед внешним IO. Закрытый gate возвращает typed
`409 upstream_access_not_approved`. Если разрешение отозвано после постановки,
job завершается с ошибкой, без успешной пустой публикации.
| Настройка | По умолчанию | Значение |
| --- | --- | --- |
| `SRO_REQUEST_INTERVAL_SECONDS` | `3` | Минимум 3 секунды между запросами, включая redirects/retries; можно увеличить. |
| `SRO_HTTP_MAX_RESPONSE_BYTES` | `2097152` | Предел распакованного тела одного ответа. |
| `SRO_HTTP_TIMEOUT_SECONDS` | `30` | Read timeout; connect timeout 10 секунд. |
| `SRO_MAX_REQUESTS_PER_RUN` | `10000` | Общий предел запросов одного запуска, включая redirects/retries/sitemaps. |
HTTP клиент делает не более трёх попыток при транспортной ошибке и ответах
429/500/502/503/504. `Retry-After` учитывается как секунды или HTTP date, ожидание
свыше 300 секунд завершает запуск ошибкой. Цепочка redirects ограничена пятью
ответами; каждый адрес проверяется до обращения. Разрешён только HTTPS без
credentials/нестандартного порта, на `reestr-sro.ru` и его поддоменах. Переменные
окружения HTTP proxy автоматически не используются. HTML/query с идентификаторами
не выводится в application logs.
## Публикация и обновление
Celery task: `parsers.sro_membership_check.refresh`;
Python entrypoint: `apps.parsers.tasks_registry_snapshots.parse_sro_membership`,
keyword args `requested_by_id=None`, `mode="incremental"|"full"`.
Проверяются только организации текущего канонического справочника. Инкрементальный
запуск выбирает новые организации и те, у которых изменились name/full_name,
ИНН/ОГРН/ОКПО. Изменение определяется fingerprint; у модели Organization нет
универсального `updated_at`. Полный запуск проверяет весь справочник.
Lookup использует точный ОГРН, при его отсутствии/неподходящем формате — ИНН
с проверкой нормализованного имени. Если нет пригодного идентификатора, запуск
отклоняется с `sro_candidate_identifier_missing`, без удаления прежних записей.
Ответ дополнительно сопоставляется по ИНН+ОГРН через внутренний индекс. Отсутствие
ОКПО, конфликт идентификаторов, неоднозначная организация, неизвестный статус,
неполные обязательные поля или missing/unsafe SRO URL отправляют строку в quarantine.
Новые организации из внешнего ответа не создаются.
Одна запись соответствует паре canonical organization UID + SRO ID. Record UID
стабилен между загрузками, включая переход `active``excluded`. SRO ID/URL
берётся из same-site ссылки или точного ID, разрешённого по same-site sitemap;
по одному похожему названию URL не угадывается. Для даты допуска читается same-site
страница members по ИНН. Неизвестная дата остаётся `null` с причиной
`not_found`, `sro_page_unresolved` или `parse_error`. Наблюдаемая дата реестра
не подменяется датой загрузки.
Raw ответы сохраняются в приватном ZIP artifact с manifest URL→файл; отдельные
staged rows содержат source fields, нормализованный payload и причину quarantine.
Для SRO используется отдельный `FileSystemStorage`: `PARSER_PRIVATE_ARTIFACT_ROOT`
по умолчанию равен `<repository-root>/private-parser-artifacts`, вне `MEDIA_ROOT`.
Путь обязан быть абсолютным и не находиться внутри `MEDIA_ROOT`, в том числе после
разрешения symlink. Ошибка конфигурации запрещает файловую операцию. Приватный storage
не выдаёт публичный URL; файлы создаются с правами `0600`, каталоги `0700`.
Остальные источники сохраняют прежний `default_storage`.
В контейнерном окружении задайте `PARSER_PRIVATE_ARTIFACT_ROOT=/app/private-parser-artifacts`
и подключите к web/worker один постоянный volume по этому пути. Каталог должен быть
доступен пользователю приложения для записи и не должен монтироваться в public media,
static или reverse-proxy document root. Существующая retention-команда удаляет private
файлы через тот же `artifact.file.delete()`; удаление/пересоздание контейнера не должно
удалять volume. Миграция `0037` меняет storage в состоянии модели без перемещения данных.
До этой версии живой SRO сбор не запускался. Если оператор ранее создал SRO artifacts
в старом MEDIA_ROOT вручную, их требуется отдельно перенести в private root с сохранением
относительных имён и убрать публичные копии до открытия источника; автоматического
fallback на public raw нет.
Публикация записей, checkpoint организаций, load log и terminal job выполняется
в одной транзакции. Инкрементальная публикация заменяет данные только успешно
проверенного набора организаций; записи остальных организаций сохраняются.
Transport/schema failure или отменённая job откатывает публикацию и checkpoints.
Допустимый пустой ответ удаляет старые членства проверенной организации.
Любая семантически некорректная строка остаётся в quarantine и отклоняет весь
запуск с `sro_incomplete_membership_scan`. Это сохраняет прежние членства и
checkpoints: неизвестный статус или неразрешённая ссылка не доказывают, что
членство исчезло. Организация остаётся кандидатом следующего incremental запуска.
Если incremental не нашёл изменившихся организаций, batch имеет нулевую дельту
и наследует source registry date/version последней успешной публикации; ссылка
на неё сохраняется в `metadata.base_snapshot_artifact_id`. Данные не переписываются.
После commit безопасно ставится существующая задача обновления общих export
artifacts. Ошибка брокера после commit не превращает успешную публикацию в failure;
готовность выгрузки отражает собственный контракт export service.
## Счётчики и диагностика
`artifact.published_count`, `result.published_records_count` и `load_log.records_count`
содержат размер текущего опубликованного набора источника. Для incremental это
общий результат, включая неизменённые организации. `metadata.snapshot_records_count`
и `snapshot_organizations_count` дают такие же общие record/distinct organization
counts; `batch_published_records_count` и `updated_records_count` — число опубликованных
строк текущей дельты. `parsed_count`, quarantine и candidate/queried counts относятся
к текущей проверке; складывать их с общим published count нельзя.
Источник публикует весь собственный справочник; карточка/list/dashboard для трёх
новых источников используют весь published scope. Coverage ОПК показывается
отдельно. МСП и бюджет продолжают полную замену: новый scoped аргумент публикации
по умолчанию `None` сохраняет их поведение.
Для расследования используют job status/error, artifact status,
`metadata.rejection_reason`, `rejection_reasons` и staged disposition/reason.
Они содержат ограниченные коды ошибок, не raw HTML. Partial raw ZIP сохраняется
при ошибке уже начатого сбора; предыдущая публикация остаётся доступна.
Приватные raw artifacts удерживаются минимум 90 дней и минимум 10 последних
успешных SRO batches; неуспешные batches не вытесняют эти десять успешных.
## Расписания и граница проверки
Миграция `parsers.0036_sro_disabled_schedules` создаёт **выключенные** schedules:
daily incremental в 04:00 Europe/Moscow; monthly full 16-го числа в 04:00
Europe/Moscow — после наблюдаемого обновления реестра 15-го. Перед включением
нужно подтвердить фактическую периодичность/условия разрешения. Расписание само
не открывает gate; оба механизма по умолчанию выключены.
На 14.09.2026 query/search и живой SRO snapshot не запускались. Основная страница
без query доступна через read-only web; свежий robots.txt получить не удалось.
Указанные в source-first спецификации `Crawl-delay: 3` и query/search disallow
учтены консервативно. DOM selector/mapping проверены синтетическими fixtures формы
из `docs/source-integration/sources/sro-membership-check/backend-api.md`, а не
полным live scan. После разрешения владельца обязательна ограниченная проверка
фактической разметки перед первой полной загрузкой.