38 KiB
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 сгенерированными безопасными типами;metalist 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: listsource_group, exportsources, 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.
Инварианты:
- без
statuslist возвращает все published статусы; records_countкарточки источника равенtotal_countнефильтрованного list на одном snapshot;organizations_count— distinctorganization.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_grouplist/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_reasonnullable;- если
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— distinctorganization.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-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.
Минимальный набор существующих ключей, который нельзя потерять:
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 только после
зелёных интеграционных проверок.