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

1195 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
template_id: source-backend-api
template_version: 1
source_id: 'sro-membership-check'
source_name: 'Проверка членства в СРО'
document_status: review
---
# Backend API источника «Проверка членства в СРО»
> Уточнение 14.09.2026: разделы подготовки выгрузки с `preparing` / HTTP 202
> заменены [общим runtime-контрактом](../../../source-records-public-runtime-contract.md):
> готовый ticket HTTP 201 → native form download HTTP 200; устаревшее поколение —
> HTTP 503 `source_export_not_ready`, истёкший/использованный ticket — HTTP 410 `ticket_expired`.
Назначение: backend-first спецификация контрактов, которые требуются frontend для полного
подключения источника. Документ задаёт DTO, endpoint, фильтры, статусы, ошибки и инварианты
OpenAPI. Он не предписывает внутреннюю ORM, очередь, HTTP-клиент или конкретную реализацию
parser `reestr-sro.ru`.
## Принципы документа
- frontend читает источник только через внутренний backend API;
- backend не проксирует HTML и не отдаёт cookies внешнего сайта;
- source-specific read endpoint не создаются: используются универсальные source records;
- одна запись соответствует членству одной организации в одной СРО;
- одна организация может иметь zero, one или many записей;
- `organization.name`, ИНН, ОГРН и ОКПО обязательны во всех опубликованных записях;
- ОКПО обогащается backend, поскольку `reestr-sro.ru` его не предоставляет;
- значения идентификаторов всегда strings и не преобразуются в number;
- nullable-поля возвращаются как `null`, не как `""`, `"—"` или дата загрузки;
- URL СРО разрешены только внутри `https://reestr-sro.ru` и его поддоменов;
- runtime, `openapi.json`, generated API, generated Zod и contract tests имеют одну форму;
- API возвращает данные, enums и raw source values, а пользовательское форматирование делает
frontend;
- failed/partial refresh не заменяет последний successful published snapshot;
- production refresh запрещён до согласования автоматизированного доступа с владельцем сайта.
## 1. Назначение и область реализации
### Описание источника
| Параметр | Значение |
| ------------------------------ | --------------------------------------------------------------------- |
| Наименование | `Проверка членства в СРО` |
| Назначение данных | Действующее и прекращённое членство юридических лиц в СРО |
| Владелец backend | Команда интеграции источников |
| Внешняя система / URL | `https://www.reestr-sro.ru/proverka_dopuska/` |
| Разрешённые внешние hostnames | `reestr-sro.ru`, `www.reestr-sro.ru`, `*.reestr-sro.ru` |
| Способ получения | Backend-only HTTPS GET, SSR HTML и same-site страницы реестров членов |
| Периодичность | Daily incremental + monthly full sweep после разрешения upstream |
| Ожидаемый объём | Число проверяемых организаций × zero/one/many membership rows |
| Ограничения внешнего API | Bulk API нет; `robots.txt`: `Crawl-delay: 3`, query/search disallow |
| Политика удаления/актуализации | Immutable raw batches; published snapshot заменяется атомарно |
| Raw retention | Не менее 90 дней и не менее 10 последних successful batches |
Интеграция использует только `*.reestr-sro.ru`. Данные НОПРИЗ, ФНС, сайты отдельных СРО и
любые другие внешние сайты не являются upstream этого источника. Канонический внутренний
реестр организаций используется только для связи `organization.uid` и enrichment ОКПО.
### Наблюдаемый upstream-контракт
Основной SSR lookup:
```text
GET https://www.reestr-sro.ru/proverka_dopuska/
?q=<exact INN or OGRN>
&sro_name=
&sro_id=
&sro_reg_number=
&region_id=
&search=Найти
```
Результат находится в `table.sro-members`. Отдельного XHR/fetch с результатами компаний нет.
Наблюдаемая строка содержит:
| HTML / подпись | Нормализованное поле | Обязательность |
| --------------------------------------- | ----------------------------------- | -------------- | -------- |
| `Краткое:` | `organization.name` | Required |
| `Полное:` | `organization.full_name` | Required |
| Ячейка `ИНН, ОГРН`, первая строка | `organization.inn` | Required |
| Ячейка `ИНН, ОГРН`, вторая строка | `organization.ogrn` | Required |
| Ячейка `Статус` | `payload.membership_status_raw` | Required |
| Нормализованный status | `payload.membership_status=active | excluded` | Required |
| Ячейка `Регион регистрации` | `payload.region` | Required |
| Текст ячейки `СРО` | `payload.sro_name` | Required |
| Same-site `a[href]` или resolved SRO ID | `payload.sro_url`, `payload.sro_id` | Required |
| Текст `Реестр обновлен` | `payload.source_registry_date` | Nullable date |
Дата допуска берётся со страницы той же СРО:
```text
https://<regional-host>.reestr-sro.ru/<sro-path>/sro-id-<id>/members/?q=<INN>
```
В `table.sro-members` этой страницы присутствуют `ИНН`, краткое/полное наименование,
`Дата получения допуска` и `Статус`. Дата нормализуется из `DD.MM.YYYY` в `YYYY-MM-DD`.
Наблюдаемые статусы:
| Source value | API enum | Generic record status |
| ----------------- | ---------- | --------------------- |
| `Является членом` | `active` | `active` |
| `Исключен` | `excluded` | `inactive` |
Неизвестный непустой статус не угадывается: raw row сохраняется в quarantine
`unknown_membership_status`. Расширение enum требует обновления OpenAPI и frontend mapper.
### Граница документа
Документ фиксирует входные данные только для обоснования DTO и contract tests. Backend-команда
самостоятельно выбирает реализацию HTTP, DOM parser, sitemap resolver, concurrency, storage и
очереди. Наблюдаемые обязательства реализации с точки зрения frontend:
1. backend возвращает нормализованные записи через универсальные endpoint;
2. ни один frontend endpoint не выдаёт upstream HTML;
3. ОКПО и `organization.uid` уже разрешены;
4. СРО имеет безопасный same-site URL;
5. дата допуска — `date | null` с явной причиной отсутствия в metadata;
6. zero result отличается от ошибки получения/парсинга;
7. source counts и status согласованы между endpoint.
### Покрываемые frontend-сценарии
| Сценарий | Требуется | Страница / элемент | Endpoint |
| ------------------ | --------- | ------------------------------- | --------------------------------------- |
| Каталог источников | Да | `/sources`, карточка источника | `/api/v1/sources/*` |
| Главная аналитика | Да | `/main`, таблица и графики | `/api/v1/parsers/dashboard/` |
| Детальная страница | Да | `/sources/sro-membership-check` | source detail, dashboard, records |
| Таблицы записей | Да | источник и карточка организации | `/api/v2/organization-source-records/*` |
| Ручное обновление | Да | `/settings/scraping` | source refresh, parser run, jobs |
| История обновлений | Да | `/update-history` | `/api/v1/system/logs/*` |
| Выгрузка данных | Да | настройки → выгрузка источников | source-record export |
## 2. Нормативные ссылки и аудит текущих контрактов
| Уровень | Репозиторный путь | Что проверить |
| ------------------------------ | ----------------------------------------------------------- | ----------------------------------------------- |
| Эталон backend-first документа | `docs/info/backend-endpoints-main-page-from-mocks.md` | request/response, ошибки, инварианты |
| Общий шаблон | `docs/source-integration/templates/backend-api.template.md` | обязательная структура |
| Машинная схема | `openapi.json` | paths, named schemas, required, nullable, enums |
| Генерация клиента | `orval.config.ts` | operationId и результат `bun run apigen` |
| Generated TypeScript | `src/shared/api/generated-api/` | DTO и функции запросов |
| Generated Zod | `src/shared/model/generated-zod/` | runtime schemas |
| Runtime model | `src/pages/main/model/` | adapters, query keys, fallbacks |
| Контрактные тесты | `src/pages/main/model/__contract__/` | runtime-ответы тестового backend |
### Точки сверки по каждому активному endpoint
| Endpoint | Текущий reference | Требование источника СРО |
| ------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------ |
| `GET /api/v1/sources/` | `frontend-sources.ts`, `SourceCardListResponse` | Новая карточка и обязательные counts |
| `GET /api/v1/sources/{slug}/` | `frontend-sources.ts`, `SourceCardDetailResponse` | Detail, source item, active task и latest load |
| `GET /api/v1/parsers/dashboard/` | `api.ts`, manual runtime parser | Named schema, source counts, schedule и coverage |
| `GET /api/v2/organization-source-records/` | `organizations.ts`, `sourceRecordsApi.ts` | Новый enum/filter и `SroMembershipListPayload` |
| `GET /api/v2/organization-source-records/{uid}/` | `api.ts`, `useSourceRecordDetail.ts` | `SroMembershipDetailPayload` |
| `POST /api/v1/sources/{slug}/refresh/` | `frontend-sources.ts` | Canonical `task_ids` и typed 409 |
| `POST /api/v1/parsers/run/{source_key}/` | `api.ts`, `sourceCardParserRefresh.ts` | Typed admin run response |
| `GET /api/v1/jobs/{task_id}/` | `background-jobs.ts` | Membership progress metadata |
| `GET /api/v1/system/logs/` | `system.ts` | Новый source enum/filter |
| `GET /api/v1/system/logs/{id}/` | `system.ts` | Counts, registry date и error samples |
| `GET /api/v1/system/logs/export/` | `system.ts` | Source в CSV и единый `batch_id: integer` |
| export ticket/download | `exportReferenceDataSourceRecords.ts` | `sourceGroup=sro_membership` |
Существующие generated endpoint не считаются подходящими только по факту наличия. После
изменения OpenAPI требуется повторный поиск активных callsite.
### Реестр расхождений этого источника
| Endpoint / schema | Runtime сейчас | OpenAPI сейчас | Целевой контракт | Действие backend | Блокирует frontend |
| ---------------------- | ----------------------------------- | ----------------------- | -------------------------------------- | ----------------------------- | ------------------ |
| Source cards | Источника нет | Enum/карточки нет | `sro-membership-check` | Добавить list/detail item | Да |
| Parser dashboard | Ответ разбирается вручную | `200` без schema | Typed `ParserDashboardResponse` | Добавить named schemas | Да |
| Source records | Source/group/payload отсутствуют | Enums отсутствуют | `sro_membership` + typed payload | Расширить enums/discriminator | Да |
| Record detail | Payload неизвестен | Generic/optional fields | Required organization + detail payload | Добавить named detail schema | Да |
| Refresh | Принимаются разные envelopes | Один `task_id` | `status` + canonical `task_ids` | Унифицировать runtime/OpenAPI | Да |
| Parser run | Ручной unknown response | `201` без schema | `ParserRunResponse` | Добавить schema | Для admin UI |
| Records pagination | `meta.pagination` ожидается runtime | `meta` нетипизирован | Полная pagination schema | Типизировать | Да |
| Export ticket/download | Ручные вызовы | Paths отсутствуют | Typed ticket/download schemas | Добавить в OpenAPI | Да |
| Upstream permission | Не представлено в UI/API | Error code отсутствует | `409 upstream_access_not_approved` | Добавить бизнес-ошибку | Да для refresh |
Известные общие расхождения (`GET /api/v1/sources/statuses/`, untyped parser auxiliaries,
разные `source_label/source_display`) не копируются в новый контракт.
## 3. Идентификаторы источника
| Идентификатор | Значение | Назначение |
| ---------------- | -------------------------------------- | ------------------------------------- |
| `sourceId` | `sro-membership-check` | ID спецификации |
| `parentSlug` | `sro-membership-check` | source list/detail/refresh |
| `routeSlug` | `sro-membership-check` | frontend route |
| `parserSource` | `sro_membership_check` | dashboard, parser run, jobs и logs |
| `sourceGroup` | `sro_membership` | records, organization detail и export |
| `recordType` | `sro_membership` | discriminator одной записи членства |
| `sourceItemCode` | `reestr_sro_membership` | единственный source item |
| `refreshKey` | `sro_membership_check` | manual/scheduled refresh |
| `taskName` | `parsers.sro_membership_check.refresh` | metadata фоновой задачи |
| `tableKey` | `sro-memberships` | frontend table/detail route |
Согласованные aliases: отсутствуют.
Правила идентичности:
- record `uid` — стабильный UUID, отличный от `organization.uid`;
- стабильная запись определяется парой organization + resolved SRO membership;
- upstream SRO ID хранится как string или integer без использования в URL внутреннего API;
- повторный batch для того же членства сохраняет record `uid`;
- изменение статуса `active → excluded` обновляет/версионирует ту же предметную запись, а не
создаёт второе членство;
- одновременные разные СРО создают разные record `uid`;
- короткое имя СРО не используется как единственный ключ из-за возможных совпадений.
## 4. Обязательный контракт организации
| Пользовательское поле | API-поле | Тип | Required | Nullable | Источник |
| --------------------- | ------------------------ | -------- | -------: | -------: | ------------------------------------ |
| Организация | `organization.name` | `string` | Да | Нет | Краткое наименование `reestr-sro.ru` |
| Полное наименование | `organization.full_name` | `string` | Да | Нет | Полное наименование `reestr-sro.ru` |
| ИНН | `organization.inn` | `string` | Да | Нет | SSR result |
| ОГРН | `organization.ogrn` | `string` | Да | Нет | SSR result |
| ОКПО | `organization.okpo` | `string` | Да | Нет | Backend enrichment |
| Каноническая связь | `organization.uid` | UUID | Да | Нет | Внутренний реестр организаций |
Минимальный объект:
```json
{
"organization": {
"uid": "0ebfa744-efc0-4e91-afaf-6b784b2bb22f",
"name": "АНО \"ИННОВАЦИОННЫЙ ИНЖИНИРИНГОВЫЙ ЦЕНТР\"",
"full_name": "АВТОНОМНАЯ НЕКОММЕРЧЕСКАЯ ОРГАНИЗАЦИЯ \"ИННОВАЦИОННЫЙ ИНЖИНИРИНГОВЫЙ ЦЕНТР\"",
"inn": "7728424294",
"ogrn": "1187700006273",
"okpo": "28213364"
}
}
```
### Enrichment и публикация
1. Exact lookup по валидному ОГРН.
2. Если ОГРН отсутствует/неоднозначен — exact lookup по ИНН с проверкой названия.
3. ОКПО берётся только из доверенного внутреннего канонического реестра.
4. Fuzzy-only match не публикуется автоматически.
5. Несовпадение ИНН и ОГРН → quarantine `organization_identifier_conflict`.
6. Не найден ОКПО → quarantine `required_okpo_missing`.
7. Нельзя копировать ОГРН/ИНН в ОКПО или возвращать placeholder.
### Payload членства
List schema:
```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
}
```
Detail добавляет:
```ts
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'
}
}
```
`sro_url` и `source_record_url` проходят backend allowlist. API не возвращает URL другого
домена даже если он присутствовал на странице.
## 5. Матрица endpoint и решение по реализации
| № | Endpoint | Решение | Изменение |
| --: | ----------------------------------------------------------- | ---------------------- | ---------------------------------------- |
| 1 | `GET /api/v1/sources/` | Расширить | Карточка источника |
| 2 | `GET /api/v1/sources/{slug}/` | Расширить | Detail, source item, task/load |
| 3 | `GET /api/v1/parsers/dashboard/` | Расширить/типизировать | Source counts, schedule, coverage |
| 4 | `GET /api/v2/organization-source-records/` | Расширить | Enums, filters, ordering, list payload |
| 5 | `GET /api/v2/organization-source-records/{uid}/` | Расширить | Detail discriminator/payload |
| 6 | `POST /api/v1/sources/{slug}/refresh/` | Расширить | Slug, canonical task response, typed 409 |
| 7 | `POST /api/v1/parsers/run/{source_key}/` | Расширить | Admin key и typed response |
| 8 | `GET /api/v1/jobs/{task_id}/` | Использовать/расширить | Progress metadata |
| 9 | `POST /api/v1/parsers/upload/{source_key}/` | Не требуется | HTTP/SSR источник; typed 405 |
| 10 | `GET /api/v1/system/logs/` | Расширить | Source enum/filter |
| 11 | `GET /api/v1/system/logs/{id}/` | Расширить | Counts и errors |
| 12 | `GET /api/v1/system/logs/export/` | Расширить | Source в CSV |
| 13 | `POST /api/v2/organization-source-records/export-ticket/` | Описать в OpenAPI | Группа `sro_membership` |
| 14 | `POST /api/v2/organization-source-records/export-download/` | Описать в OpenAPI | Ticket download |
| 15 | `POST /api/v2/organization-source-records/export/` | Не использовать UI | Legacy sync endpoint |
Не использовать для нового frontend:
- `GET /api/v1/sources/statuses/` — активного read callsite нет;
- `GET /api/v1/parsers/sources/` — регистрация parser является backend concern;
- `GET/POST /api/v1/parsers/schedules/*` — frontend получает schedule через dashboard;
- `GET /api/v1/parsers/load-logs/` — история идёт через system logs;
- `GET /api/v1/parsers/records/` — records идут через v2;
- legacy parser results/download URL;
- `GET /api/v1/jobs/` — frontend poll только конкретный job;
- `POST /api/v1/jobs/{task_id}/control/` и `GET /api/v1/jobs/{task_id}/stream/`;
- прямой frontend proxy к `reestr-sro.ru`.
## 6. Общий паттерн описания endpoint
- internal base path: `/api`;
- auth: Bearer token по общей модели приложения;
- JSON: `application/json; charset=utf-8`;
- datetime: ISO 8601 с timezone;
- date: `YYYY-MM-DD`;
- identifiers: strings, кроме внутреннего numeric log ID;
- pagination: `page >= 1`, `page_size` из allowlist приложения;
- ordering: только перечисленные backend поля;
- empty collection: `[]`;
- missing optional: `null`;
- business error:
```json
{
"success": false,
"errors": [
{
"code": "validation_error",
"message": "Некорректное значение параметра source_group.",
"field": "source_group"
}
]
}
```
Общие коды: `400` validation, `401` unauthenticated, `403` forbidden, `404` missing record,
`409` state conflict, `429` internal API throttle, `500` internal error, `503` source
temporarily unavailable. Upstream HTTP codes не проксируются frontend как есть.
Пагинация:
```json
{
"meta": {
"pagination": {
"page": 1,
"page_size": 50,
"pages": 2,
"total": 74,
"has_next": true,
"has_previous": false
}
}
}
```
## 7. Каталог источников
### 7.1 `GET /api/v1/sources/`
**Request**
```http
GET /api/v1/sources/?page=1&page_size=50&search=СРО
Authorization: Bearer <token>
```
Query: `page`, `page_size`, `search`, общий `status`. Source-specific params отсутствуют.
**Response `200`**
```json
{
"data": [
{
"slug": "sro-membership-check",
"title": "Проверка членства в СРО",
"description": "Сведения о действующем и прекращённом членстве организаций в СРО",
"status": "active",
"status_label": "Обновлено",
"last_updated_at": "2026-08-25T12:00:00Z",
"records_count": 1248,
"organizations_count": 1032,
"source_items_count": 1
}
],
"meta": {
"pagination": {
"page": 1,
"page_size": 50,
"pages": 1,
"total": 1,
"has_next": false,
"has_previous": false
}
}
}
```
Required: все поля карточки, кроме `last_updated_at`, который nullable до первого successful
batch. `records_count` — membership records; `organizations_count` — distinct
`organization.uid`. Counts всегда integers `>= 0`.
Errors: `401`, `403`, `400` для query. Нулевой список — `200`, не `404`.
### 7.2 `GET /api/v1/sources/{slug}/`
**Request**
```http
GET /api/v1/sources/sro-membership-check/
Authorization: Bearer <token>
```
**Response `200`**
```json
{
"data": {
"slug": "sro-membership-check",
"title": "Проверка членства в СРО",
"description": "Сведения о действующем и прекращённом членстве организаций в СРО",
"status": "active",
"status_label": "Обновлено",
"last_updated_at": "2026-08-25T12:00:00Z",
"records_count": 1248,
"organizations_count": 1032,
"source_items": [
{
"code": "reestr_sro_membership",
"title": "Проверка членства в СРО",
"parser_source": "sro_membership_check",
"refresh_key": "sro_membership_check",
"records_count": 1248,
"organizations_count": 1032,
"last_updated_at": "2026-08-25T12:00:00Z"
}
],
"latest_success_load": {
"id": 8712,
"status": "success",
"started_at": "2026-08-25T10:00:00Z",
"finished_at": "2026-08-25T12:00:00Z",
"meta": {
"candidate_organizations_count": 1400,
"queried_organizations_count": 1398,
"not_found_organizations_count": 366,
"raw_memberships_count": 1252,
"published_records_count": 1248,
"quarantined_records_count": 4,
"missing_admission_dates_count": 7,
"upstream_registry_date": "2026-08-15"
}
},
"active_task": null
}
}
```
`active_task` nullable; если есть, содержит `task_id`, `status`, `progress`, `started_at`.
`latest_success_load` nullable до первой публикации. Sum raw/published/quarantine не
подменяется organization count.
Errors: `404 source_not_found`, `401`, `403`.
## 8. Dashboard парсеров
### 8.1 `GET /api/v1/parsers/dashboard/`
**Request**
```http
GET /api/v1/parsers/dashboard/
Authorization: Bearer <token>
```
**Response `200`**
```json
{
"sources": [
{
"source": "sro_membership_check",
"source_display": "Проверка членства в СРО",
"status": "success",
"records_count": 1248,
"organizations_count": 1032,
"last_updated_at": "2026-08-25T12:00:00Z",
"schedule": {
"enabled": true,
"kind": "incremental",
"timezone": "Europe/Moscow",
"next_run_at": "2026-08-26T01:00:00Z"
},
"active_task": null
}
],
"registry_enrichment_analytics": {
"population": {
"active_registry_organizations": 15000
},
"source_coverage": [
{
"source": "sro_membership_check",
"source_display": "Проверка членства в СРО",
"organizations_count": 612,
"coverage_percent": 4.08
}
]
}
}
```
Named schema: `ParserDashboardResponse`. Для аналитики организаций ОПК denominator всегда
`population.active_registry_organizations`, numerator —
`source_coverage[].organizations_count`. Он равен distinct организациям ОПК с опубликованным
членством, а не всем queried candidates.
`/sources[].organizations_count` — все уникальные организации источника; это значение не
обязано совпадать с coverage организаций ОПК. `coverage_percent` backend вычисляет и округляет
по единому правилу проекта.
Errors: `401`, `403`, `500`. Частичное отсутствие одного источника не делает массив `sources`
нетипизированным.
## 9. Записи источника
### 9.1 `GET /api/v2/organization-source-records/`
**Request**
```http
GET /api/v2/organization-source-records/?source_group=sro_membership&source=sro_membership_check&record_type=sro_membership&page=1&page_size=50&search=7728424294&membership_status=active&ordering=organization__name,sro_name,admission_date
Authorization: Bearer <token>
```
Поддерживаемые query params:
| Параметр | Тип | Правило |
| --------------------- | --------------- | ------------------------------------------ | ------------ |
| `source_group` | enum | Для этой таблицы required `sro_membership` |
| `source` | enum | `sro_membership_check` |
| `record_type` | enum | `sro_membership` |
| `organization` | UUID | Фильтр карточки организации |
| `search` | string <= 200 | name/full_name/ИНН/ОГРН/ОКПО/SRO name |
| `membership_status` | `active | excluded` | Exact filter |
| `region` | string | Exact normalized source region |
| `sro_id` | string | Exact resolved ID |
| `admission_date_from` | date | Inclusive |
| `admission_date_to` | date | Inclusive |
| `ordering` | comma-separated | Поля из allowlist, `-` для descending |
| `page`, `page_size` | integer | Общая пагинация |
Ordering allowlist: `organization__name`, `organization__inn`, `organization__ogrn`,
`organization__okpo`, `organization__full_name`, `membership_status`, `region`, `sro_name`,
`admission_date`, `updated_at`. Default:
`organization__name,sro_name,admission_date`.
**Response `200`**
```json
{
"data": [
{
"uid": "b2d9c7d0-8f96-5e83-9df1-9a383f5ccf4d",
"source_group": "sro_membership",
"source": "sro_membership_check",
"record_type": "sro_membership",
"title": "АНО \"ИННОВАЦИОННЫЙ ИНЖИНИРИНГОВЫЙ ЦЕНТР\" — СРО Ассоциация «НЕФТЕГАЗСЕРВИС»",
"status": "active",
"record_date": "2023-12-11",
"organization": {
"uid": "0ebfa744-efc0-4e91-afaf-6b784b2bb22f",
"name": "АНО \"ИННОВАЦИОННЫЙ ИНЖИНИРИНГОВЫЙ ЦЕНТР\"",
"full_name": "АВТОНОМНАЯ НЕКОММЕРЧЕСКАЯ ОРГАНИЗАЦИЯ \"ИННОВАЦИОННЫЙ ИНЖИНИРИНГОВЫЙ ЦЕНТР\"",
"inn": "7728424294",
"ogrn": "1187700006273",
"okpo": "28213364"
},
"payload": {
"membership_status": "active",
"membership_status_raw": "Является членом",
"region": "Московская область",
"sro_id": "517",
"sro_name": "СРО Ассоциация «НЕФТЕГАЗСЕРВИС»",
"sro_url": "https://moskva.reestr-sro.ru/sro-v-proektirovanii/sro-id-517/",
"admission_date": "2023-12-11",
"source_registry_date": "2026-08-15"
},
"updated_at": "2026-08-25T12:00:00Z"
}
],
"meta": {
"pagination": {
"page": 1,
"page_size": 50,
"pages": 1,
"total": 1,
"has_next": false,
"has_previous": false
}
}
}
```
List остаётся лёгким: raw HTML, lineage и error samples в нём отсутствуют. `record_date`
равен `admission_date` или `null`; дата загрузки не используется как fallback.
Validation:
- `400 invalid_source_group`;
- `400 invalid_membership_status`;
- `400 invalid_ordering`;
- `400 invalid_date_range`;
- `200 data=[]` для отсутствия записей;
- `401`, `403` по общей модели.
### 9.2 `GET /api/v2/organization-source-records/{uid}/`
**Request**
```http
GET /api/v2/organization-source-records/b2d9c7d0-8f96-5e83-9df1-9a383f5ccf4d/
Authorization: Bearer <token>
```
**Response `200`**
```json
{
"data": {
"uid": "b2d9c7d0-8f96-5e83-9df1-9a383f5ccf4d",
"source_group": "sro_membership",
"source": "sro_membership_check",
"record_type": "sro_membership",
"title": "АНО \"ИННОВАЦИОННЫЙ ИНЖИНИРИНГОВЫЙ ЦЕНТР\" — СРО Ассоциация «НЕФТЕГАЗСЕРВИС»",
"status": "active",
"record_date": "2023-12-11",
"organization": {
"uid": "0ebfa744-efc0-4e91-afaf-6b784b2bb22f",
"name": "АНО \"ИННОВАЦИОННЫЙ ИНЖИНИРИНГОВЫЙ ЦЕНТР\"",
"full_name": "АВТОНОМНАЯ НЕКОММЕРЧЕСКАЯ ОРГАНИЗАЦИЯ \"ИННОВАЦИОННЫЙ ИНЖИНИРИНГОВЫЙ ЦЕНТР\"",
"inn": "7728424294",
"ogrn": "1187700006273",
"okpo": "28213364"
},
"payload": {
"membership_status": "active",
"membership_status_raw": "Является членом",
"region": "Московская область",
"sro_id": "517",
"sro_name": "СРО Ассоциация «НЕФТЕГАЗСЕРВИС»",
"sro_url": "https://moskva.reestr-sro.ru/sro-v-proektirovanii/sro-id-517/",
"admission_date": "2023-12-11",
"source_registry_date": "2026-08-15",
"source_record_url": "https://www.reestr-sro.ru/proverka_dopuska/?q=7728424294",
"retrieved_at": "2026-08-25T11:58:00Z",
"admission_date_missing_reason": null,
"lineage": {
"lookup_key": "inn",
"lookup_value": "7728424294",
"sro_resolution": "row_link"
}
},
"batch": {
"id": 8712,
"source_version": "registry-date:2026-08-15",
"published_at": "2026-08-25T12:00:00Z"
},
"created_at": "2026-08-25T12:00:00Z",
"updated_at": "2026-08-25T12:00:00Z"
}
}
```
Required/nullable:
- `admission_date`, `source_registry_date`, `admission_date_missing_reason` nullable;
- `sro_url`, `sro_id`, `sro_name`, `region`, organization quartet required;
- если `admission_date` non-null, missing reason обязан быть null;
- если дата null, missing reason обязан быть non-null;
- `source_record_url` и `sro_url` должны пройти same-site allowlist.
Errors: `400 invalid_uid`, `404 source_record_not_found`, `401`, `403`.
## 10. Refresh и фоновые задачи
### 10.1 `POST /api/v1/sources/{slug}/refresh/`
**Request**
```http
POST /api/v1/sources/sro-membership-check/refresh/
Content-Type: application/json
Authorization: Bearer <token>
{"params":{}}
```
Frontend не отправляет upstream URL, cookies, query templates или organization IDs.
**Response `202`**
```json
{
"status": "queued",
"task_ids": ["8f6697a4-9dd1-43a7-b22a-35c91457bd70"]
}
```
`task_ids` всегда non-empty array strings. Один источник сейчас создаёт одну orchestration
task, но envelope остаётся общим.
Errors:
- `403 refresh_forbidden`;
- `404 source_not_found`;
- `409 refresh_already_running`;
- `409 upstream_access_not_approved` до разрешения автоматизированного доступа;
- `503 source_temporarily_unavailable` только если запуск не может быть поставлен в очередь.
### 10.2 `POST /api/v1/parsers/run/{source_key}/`
Административный эквивалент; обычный frontend использует 10.1.
**Request**
```http
POST /api/v1/parsers/run/sro_membership_check/
Content-Type: application/json
Authorization: Bearer <token>
{"mode":"incremental"}
```
`mode`: `incremental|full`; право на `full` может быть только у администратора.
**Response `201`**
```json
{
"source": "sro_membership_check",
"status": "queued",
"task_ids": ["8f6697a4-9dd1-43a7-b22a-35c91457bd70"]
}
```
Errors совпадают с 10.1 плюс `400 invalid_mode` и `403 full_refresh_forbidden`.
### 10.3 `GET /api/v1/jobs/{task_id}/`
**Request**
```http
GET /api/v1/jobs/8f6697a4-9dd1-43a7-b22a-35c91457bd70/
Authorization: Bearer <token>
```
**Response `200`**
```json
{
"task_id": "8f6697a4-9dd1-43a7-b22a-35c91457bd70",
"task_name": "parsers.sro_membership_check.refresh",
"source": "sro_membership_check",
"status": "running",
"progress": 48,
"created_at": "2026-08-25T10:00:00Z",
"started_at": "2026-08-25T10:00:03Z",
"finished_at": null,
"message": "Проверено 672 из 1400 организаций",
"result": null,
"meta": {
"mode": "incremental",
"candidate_organizations_count": 1400,
"queried_organizations_count": 672,
"found_memberships_count": 603,
"published_records_count": 0,
"quarantined_records_count": 2
}
}
```
Terminal `result`:
```json
{
"batch_id": 8712,
"records_count": 1248,
"organizations_count": 1032,
"upstream_registry_date": "2026-08-15"
}
```
Status enum: `queued`, `running`, `retrying`, `success`, `failed`, `cancelled`, `skipped`.
`progress` integer `0..100`. Errors: `404 job_not_found`, `401`, `403`.
### 10.4 `POST /api/v1/parsers/upload/{source_key}/` (условный)
Не требуется: источник не принимает пользовательский файл.
**Request**, который не должен использовать frontend:
```http
POST /api/v1/parsers/upload/sro_membership_check/
Content-Type: multipart/form-data
```
**Response `405`**
```json
{
"success": false,
"errors": [
{
"code": "upload_not_supported",
"message": "Источник обновляется через backend HTTP integration.",
"field": null
}
]
}
```
Backend может не регистрировать source key в generic upload router; OpenAPI должен явно
показывать, что upload для этого source недоступен.
## 11. История обновлений
### 11.1 `GET /api/v1/system/logs/`
**Request**
```http
GET /api/v1/system/logs/?source=sro_membership_check&page=1&page_size=25&ordering=-started_at
Authorization: Bearer <token>
```
Фильтры: `source`, `status`, `batch_id: integer`, dates, search, pagination, ordering.
**Response `200`**
```json
{
"data": [
{
"id": 8712,
"batch_id": 8712,
"source": "sro_membership_check",
"source_display": "Проверка членства в СРО",
"status": "success",
"started_at": "2026-08-25T10:00:00Z",
"finished_at": "2026-08-25T12:00:00Z",
"records_count": 1248,
"organizations_count": 1032,
"error_count": 0
}
],
"meta": {
"pagination": {
"page": 1,
"page_size": 25,
"pages": 1,
"total": 1,
"has_next": false,
"has_previous": false
}
}
}
```
`batch_id` integer во list, detail и export. Empty result — `200`.
### 11.2 `GET /api/v1/system/logs/{id}/`
**Request**
```http
GET /api/v1/system/logs/8712/
Authorization: Bearer <token>
```
**Response `200`**
```json
{
"data": {
"id": 8712,
"batch_id": 8712,
"source": "sro_membership_check",
"source_display": "Проверка членства в СРО",
"status": "success",
"started_at": "2026-08-25T10:00:00Z",
"finished_at": "2026-08-25T12:00:00Z",
"meta": {
"candidate_organizations_count": 1400,
"queried_organizations_count": 1398,
"not_found_organizations_count": 366,
"raw_memberships_count": 1252,
"published_records_count": 1248,
"quarantined_records_count": 4,
"missing_admission_dates_count": 7,
"http_errors_count": 2,
"parse_errors_count": 0,
"upstream_registry_date": "2026-08-15"
},
"errors": []
}
}
```
`errors[]` содержит ограниченные samples: code, stage, safe URL/path, organization UID,
message. Cookies, response body и секреты не включаются.
### 11.3 `GET /api/v1/system/logs/export/`
**Request**
```http
GET /api/v1/system/logs/export/?source=sro_membership_check&status=success
Authorization: Bearer <token>
```
**Response `200`**: `text/csv; charset=utf-8`, `Content-Disposition: attachment`.
CSV включает list-поля и counts detail metadata. Ошибки: `400`, `401`, `403`, `500`.
Response binary schema должен присутствовать в OpenAPI; не использовать JSON DTO.
## 12. Выгрузка данных источника
Canonical frontend flow: ticket → download. Export читает только published normalized rows.
### 12.1 `POST /api/v2/organization-source-records/export-ticket/`
**Request**
```http
POST /api/v2/organization-source-records/export-ticket/
Content-Type: application/json
Authorization: Bearer <token>
{
"source_groups": ["sro_membership"],
"format": "xlsx",
"filters": {
"membership_status": ["active", "excluded"]
}
}
```
**Response `202`**
```json
{
"ticket": "exp_01K38SROMEMBERSHIP",
"status": "queued",
"expires_at": "2026-08-25T13:00:00Z"
}
```
Formats: `json`, `csv`, `xlsx`. Ошибки: invalid group/format/filter, forbidden, too large.
### 12.2 `POST /api/v2/organization-source-records/export-download/`
**Request**
```http
POST /api/v2/organization-source-records/export-download/
Content-Type: application/json
Authorization: Bearer <token>
{"ticket":"exp_01K38SROMEMBERSHIP"}
```
**Response `200`**: binary с точным MIME для выбранного формата и безопасным filename.
`202 export_not_ready` возвращает JSON status/retry_after; `404 export_ticket_not_found`,
`410 export_ticket_expired`, `403` и `500` типизированы.
Колонки export: record UID, Организация, ИНН, ОГРН, ОКПО, Полное наименование, Статус,
исходный статус, Регион, SRO ID, СРО, SRO URL, Дата допуска, дата версии реестра, timestamps.
URL экспортируется как string, не HTML.
### 12.3 `POST /api/v2/organization-source-records/export/`
Legacy sync endpoint не используется новым frontend.
**Request**
```json
{ "source_group": "sro_membership", "format": "xlsx" }
```
**Response**: если endpoint сохраняется для обратной совместимости, schema и binary MIME должны
совпадать с runtime; для нового сценария backend отвечает `400 use_ticket_export` либо
документирует deprecation. Нельзя одновременно считать sync и ticket flow каноническими.
## 13. Статусы и жизненный цикл
| Область | Активные | Terminal |
| -------------- | ------------------------------- | ------------------------------------------- |
| Job | `queued`, `running`, `retrying` | `success`, `failed`, `cancelled`, `skipped` |
| Source card | `refreshing` | `active`, `error`, `unavailable` |
| UI operational | `Выполняется` | `Обновлено` |
| Membership | `active` | `excluded` |
UI label `Обновлено` не означает success конкретной task; история показывает точный terminal
status. Membership status не влияет на parser status.
Publication:
```text
queued → running → collect/normalize/enrich → validate complete batch
→ atomically publish → success
```
При failure последний successful snapshot остаётся читаемым. `not_found` organization lookup
учитывается как coverage result, а не exception. Partial batch может сохраниться для
диагностики, но не становится current.
## 14. Согласованность данных между endpoint
| Инвариант | Требование |
| ---------------------- | --------------------------------------------------- |
| Title | Ровно `Проверка членства в СРО` |
| Slug/source/group | Значения раздела 3 без aliases |
| `records_count` | Published membership records |
| `organizations_count` | Distinct `organization.uid` среди published records |
| Coverage | Distinct active-registry organizations with records |
| `last_updated_at` | Publication time последнего successful batch |
| `source_registry_date` | Дата из upstream HTML, не publication time |
| Organization quartet | Одинаков во list/detail/export |
| Membership UID | Стабилен между batches и endpoint |
| Admission date | Одинакова в payload, `record_date`, detail/export |
| SRO URL | HTTPS + same-site allowlist |
| Pagination total | После всех server filters |
| Status | Generic record status согласован с payload enum |
Одна организация с двумя СРО увеличивает `records_count` на 2 и
`organizations_count` на 1.
## 15. Ошибки, доступ, производительность и ограничения
### Матрица доступа
| Операция | Read user | Operator | Admin |
| ---------------------- | ------------------------- | -------- | ----- |
| Source cards/dashboard | Да | Да | Да |
| Records list/detail | Да | Да | Да |
| Refresh incremental | Нет | Да | Да |
| Full parser run | Нет | Нет | Да |
| History read/export | По общей политике проекта |
| Records export | По общей политике проекта |
### Source-specific ошибки
| Code | HTTP | Когда |
| -------------------------------- | ---: | --------------------------------------------------------- |
| `upstream_access_not_approved` | 409 | Автосбор ещё не разрешён владельцем сайта |
| `refresh_already_running` | 409 | Активная task того же источника |
| `source_temporarily_unavailable` | 503 | Нельзя поставить refresh в очередь |
| `invalid_membership_status` | 400 | Неизвестный filter enum |
| `unsafe_sro_url` | 500 | Нарушен published invariant; запись не отдаётся как valid |
| `source_record_not_found` | 404 | Нет внутренней published записи |
Upstream errors фиксируются в job/log metadata; read endpoint продолжает отдавать последний
successful snapshot.
### Нефункциональные требования
- list p95 <= 800 ms для page size 50 без учёта network клиента;
- detail p95 <= 500 ms;
- индексы: source_group/source/record_type, organization UID, status, region, SRO ID,
admission date, normalized search fields;
- N+1 enrichment в read path запрещён;
- list не содержит lineage/raw HTML;
- export выполняется асинхронно;
- backend external adapter соблюдает минимум `Crawl-delay: 3` и более строгие условия
разрешения владельца сайта;
- hostname allowlist проверяется после каждого redirect;
- response body upstream имеет size limit, timeout и schema-drift detection;
- raw HTML не попадает в application logs;
- URL query с ИНН не должен попадать в telemetry без redaction policy.
`robots.txt` запрещает query/search URLs. До документированного разрешения production task не
должна выполнять внешние запросы; contract refresh остаётся реализуемым и возвращает typed 409.
## 16. Требования к OpenAPI и generated-клиенту
Backend обязан:
1. добавить canonical enums `sro-membership-check`, `sro_membership_check`,
`sro_membership`, `sro_membership` record type;
2. описать required/nullable каждого поля;
3. создать named schemas `SroMembershipListPayload`, `SroMembershipDetailPayload`,
organization summary, dashboard, refresh и errors;
4. типизировать `meta.pagination` полностью;
5. типизировать `task_ids` как non-empty array strings;
6. описать binary MIME history/export responses;
7. добавить ticket/download paths в `openapi.json`;
8. унифицировать `source_display` и `batch_id: integer`;
9. не использовать `additionalProperties: true` вместо payload schema;
10. добавить examples, совпадающие с runtime и contract fixtures.
После публикации schema frontend выполняет:
```bash
bun run apigen
bun run test:contract
```
Diff проверяется в `src/shared/api/generated-api/` и `src/shared/model/generated-zod/`.
Ручные DTO удаляются только после совпадения runtime/OpenAPI.
## 17. Backend-тесты и acceptance criteria
### Минимальные contract fixtures
1. Active membership `7728424294` с кликабельной СРО и датой допуска.
2. Excluded membership.
3. Одна организация с двумя membership rows.
4. Zero result без ошибки.
5. Nullable admission date + обязательная missing reason.
6. Missing/unsafe SRO URL → quarantine, не valid response.
7. Missing OKPO → quarantine.
8. Identifier conflict ИНН/ОГРН.
9. Unknown status/schema drift.
10. Failed batch сохраняет предыдущий snapshot.
### Минимальная матрица backend/contract tests
- [ ] Source list/detail schemas и counts.
- [ ] Dashboard named schema, source coverage и denominator.
- [ ] Records filters, ordering, pagination и empty result.
- [ ] List/detail required/nullable payload rules.
- [ ] Multiple SRO rows для одной organization UID.
- [ ] Stable record UID между batches.
- [ ] Status normalization active/excluded.
- [ ] OKPO enrichment и quarantine.
- [ ] Same-site URL allowlist и redirect validation.
- [ ] Refresh canonical `task_ids`.
- [ ] Typed `upstream_access_not_approved`.
- [ ] Jobs status/progress/result.
- [ ] History list/detail/CSV.
- [ ] Export ticket/download formats и expiry.
- [ ] Auth/permissions по endpoint.
- [ ] OpenAPI examples проходят runtime schema validation.
### Backend acceptance criteria
- [ ] Все выбранные endpoint реализованы и описаны в OpenAPI.
- [ ] Frontend не нуждается в source-specific read endpoint.
- [ ] Каждая published запись содержит Организация/Наименование, ИНН, ОГРН, ОКПО.
- [ ] Полное наименование, статус, регион, СРО и дата допуска соответствуют payload schema.
- [ ] Одна организация может иметь несколько stable membership records.
- [ ] Source card, dashboard, records, history и export согласованы по counts/dates.
- [ ] Runtime, OpenAPI, generated client и contract tests совпадают.
- [ ] Запуск внешнего сбора закрыт typed 409 до upstream approval.
- [ ] `bun run test:contract` проходит на интеграционном стенде.
## 18. Чего backend не должен возвращать
- upstream HTML, DOM selectors или JavaScript;
- cookies, XSRF/session values и response headers внешнего сайта;
- данные НОПРИЗ, ФНС, сайтов СРО или иных доменов как данные этого источника;
- HTML `<a>` вместо `sro_name` + `sro_url`;
- ОКПО, скопированный из другого идентификатора;
- пустые strings/`—` вместо `null`;
- дату загрузки вместо отсутствующей даты допуска;
- небезопасный или не-same-site URL;
- raw traceback/upstream response body в frontend error;
- UI labels, цвета или готовую разметку;
- неполный batch как current snapshot;
- разные aliases идентификаторов в разных endpoint.
## 19. Решения и открытые вопросы
| Вопрос | Ответственный | Решение |
| ---------------------------------- | --------------------- | ---------------------------------------------------------------------------------- |
| Автоматизированный доступ | Product/Legal/Backend | До разрешения refresh возвращает `409 upstream_access_not_approved` |
| Реализация внешнего parser | Backend | Вне этого документа; обязана удовлетворять observable contract и contract fixtures |
| ОКПО | Backend | Exact enrichment из внутреннего канонического реестра; иначе quarantine |
| SRO link отсутствует в строке | Backend | Только same-site ID/sitemap resolution; другие сайты запрещены |
| Дата допуска отсутствует | Product/Backend | `null` + typed missing reason; frontend показывает `—` |
| Full sweep schedule | Product/Backend | Ежемесячно после upstream registry date, incremental ежедневно |
| Raw retention | Backend/ИБ | Не менее 90 дней и 10 successful batches |
| Схема неизвестного upstream status | Backend/Frontend | Quarantine и контрактное расширение enum, без silent fallback |