--- 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= &sro_name= &sro_id= &sro_reg_number= ®ion_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://.reestr-sro.ru//sro-id-/members/?q= ``` В `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 ``` 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 ``` **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 ``` **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 ``` Поддерживаемые 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 ``` **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 {"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 {"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 ``` **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 ``` Фильтры: `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 ``` **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 ``` **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 { "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 {"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 `` вместо `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 |