docs(#190): дельты OpenSpec quality-metrics, README, CLAUDE.md, правки design/proposal (I-7, M-4, M-5, M-7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-07 04:01:45 +04:00
parent 8f90d0c22f
commit 3f6bd96388
12 changed files with 638 additions and 8 deletions

View File

@ -124,6 +124,16 @@
экспорта сводной ОСВ в xlsx — тот пишет `EXPORT`; OQ-153). `HeaderPatch.units` — непустая строка после обрезки (пробельная — 422). `GET /users/brief` — `current_user` без права; `documents_count` у ЮЛ — `repositories/directories`; CSV чек-листа —
`?format=csv` у `GET …/checklist`. Контракт для фронта — `../docs/dev/api-contract-slice-3b.md`; решения —
`openspec/changes/archive/2026-10-07-slice-3b-statements-api/design.md`.
- **Факты качества первички (AURORA-1759)** — отдельная схема `dkb_metrics` (`QUALITY_METRICS_SCHEMA`), таблицы `quality_intake_fact` /
`quality_outcome_fact` / `quality_failure_fact` (`models/quality_facts.py`, запись — `repositories/quality_facts.py`, payload —
`domain/quality_facts.py`, атрибуция правок — `domain/quality_attribution.py`, матрица — `domain/quality_metrics.py`, API —
`services/quality_metrics_service.py`, `endpoints/quality_metrics.py`). Факты append-only, без FK, **пишутся хуками в транзакции
события, без своего commit**: приём — `result_mapping._write_intake_facts` (в `_map_candidate` после `map_file`, до `auto_confirm`);
`AUTO_CONFIRMED` — `auto_confirm`; `CONFIRMED` — `DocumentService._write_confirm_outcome_fact` (в `confirm`, правки по живым колонкам,
не по payload приёма); `DELETED(_AFTER_CONFIRM)` — `primary_files.delete`; `SUPERSEDED(_AFTER_CONFIRM)` — автозамена в `result_mapping`;
отказы — `recognition_exchange` (`error`/`timeout`/`dlq`) и `result_mapping._write_mapping_failure_fact`. Новый код, меняющий исход
документа, обязан писать факт тем же способом; DISCLOSURE/OTHER фактов не получают. Метрики — чистая функция от фактов, не хранятся.
Сырой ответ ИС регистрируется в `s3_object` (`bucket_role = raw`); `uploads` и `raw` с фактом приёма не экспирятся при удалении файла.
- **Консолидации (Срез 4)** — `api/endpoints/consolidations/` (periods, versions, locks, status, members, checklist, osv, export,
mapping), сервисы: `consolidations` (создание, список, карточка, периметр `PUT …/members`), `consolidation_guard`
(`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка),

View File

@ -964,6 +964,34 @@ audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `r
делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён
раньше. Откат снимает только NOT NULL.
## Метрики качества первички (AURORA-1759, DBML v5.25)
Ядро пишет неизменяемые **факты качества** по документам ББ/ОФР/ОСВ (приём «как распознано», исход, технический отказ)
в отдельную схему `QUALITY_METRICS_SCHEMA` (`dkb_metrics`) и считает по ним метрики. Правила — `../docs/context/05-domain-rules.md`
§14, контракт `payload` — `api/contracts/metrics/quality-facts.schema.json` (копия `../docs/contracts/metrics/`), решения —
`openspec/changes/quality-metrics/design.md`. Считаются только документы, принятые после выкладки.
API (`/api/v1/quality-metrics`, право `QUALITY_METRICS_VIEW`, выдаётся всем ролям):
| Маршрут | Назначение |
|---|---|
| `GET /matrix` | матрица «источник × причина» за период (период по дате приёма, фильтры ГК, ЮЛ, тип, источник `pipeline`, скан/цифровой), технические отказы по файлам, «в работе», уникальные отчёты |
| `GET /series` | ряд по месяцам даты приёма для причины (`cause`) |
| `GET /cells/{cell}/documents` | постраничный список документов ячейки (`limit`, `offset`) |
| `GET /export` | выгрузка фактов за период, `fmt=csv` (UTF-8 с BOM) или `json`; пишет `audit_event(EXPORT)` |
Экран веб-интерфейса — «Качество первички» (`web/src/widgets/QualityMetrics`).
**Условия выкладки**
- Схема `dkb_metrics` создаётся миграцией; роли миграций нужно право `CREATE` на базу (или схему заранее создаёт платформа;
откат миграции схему не удаляет). Имя схемы — `OPENBAO__APP__QUALITY_METRICS_SCHEMA`, одинаковое для приложения и миграций.
- Superset платформы читает схему отдельной ролью БД **только на чтение** (`USAGE` на схему, `SELECT` на таблицы); роль
создаёт платформа, права ядру не нужны.
- **Обязателен `make seed`** (повторный) после обновления: пункт меню «Качество первички» и доступ к API появляются
только когда право `QUALITY_METRICS_VIEW` и его пары с ролями созданы; без seed меню скрыто у всех, API отвечает 403.
- Порядок merge: PR docs (DBML v5.25, §14, контракт) сливается раньше этого PR.
- Срок хранения сырых ответов ИС (`raw/`) и `uploads/`, на которые ссылаются факты, — вопрос платформы (OQ-179).
## Ошибки API
Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом:
@ -1083,6 +1111,7 @@ email без учёта регистра проверяет только сер
| `ENV_FILE` | `.env` | файл окружения (относительно `api/`) |
| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно |
| `DB_SCHEMA` | `dkb` | схема PostgreSQL модели данных: MetaData ORM, `search_path` движка приложения, `CREATE SCHEMA` и таблица версий Alembic, схема в миграциях. Имя — строчный идентификатор `[a-z_][a-z0-9_]{0,62}`, не `pg_*` (подставляется в DDL без кавычек; иное — ошибка при старте). **Одно значение для приложения и для `alembic upgrade`:** сменить схему у уже мигрированной БД нельзя (таблицы остаются в старой); в контуре — `OPENBAO__APP__DB_SCHEMA`, в миграционном задании тоже. Читается при импорте моделей, поэтому скрипты и тесты видят его через те же `.env`/окружение |
| `QUALITY_METRICS_SCHEMA` | `dkb_metrics` | схема PostgreSQL фактов качества первички (`quality_*_fact`, AURORA-1759): те же правила имени, что у `DB_SCHEMA`; значение должно отличаться от `DB_SCHEMA` (иначе ошибка при старте). Миграция `0012_dbml_v5_25` создаёт схему (`CREATE SCHEMA IF NOT EXISTS`); в контуре — `OPENBAO__APP__QUALITY_METRICS_SCHEMA`, одно значение для приложения и для `alembic upgrade` |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) |
| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны). В обоих режимах фронту открыт заголовок `Content-Disposition` (`Access-Control-Expose-Headers`) — имя файла экспорта (#173) |
| `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) |

View File

@ -41,7 +41,7 @@
- Инварианты `../CLAUDE.md`: первичка не мутируется; единственный писатель в БД — arm-core; производные не хранятся.
Факты — не производные консолидации, а журнал событий (как `audit_event` / `ex_snapshot`); метрики — чистая функция
от фактов, не хранятся.
- Поля — только из DBML: таблицы фактов, роль бакета, признак скана, объект права появляются в DBML v5.24 (change в
- Поля — только из DBML: таблицы фактов, роль бакета, признак скана, объект права появляются в DBML v5.25 (change в
`../docs`) до кода.
- Нормализацию ББ/ОФР делают сервисы; в ядро приходит нормализованный `result.json`. «Сырой текст сервиса» в ядре — это
`financial_statement_value.source_text`, `financial_statement_line.line_code_text`,
@ -201,7 +201,7 @@ Superset читает схему фактов отдельной ролью БД
## 11. Оставлено исполнителю
Имена таблиц и столбцов (в рамках DBML v5.24), формат `payload`, раскладка экрана, способ определения текстового слоя
Имена таблиц и столбцов (в рамках DBML v5.25), формат `payload`, раскладка экрана, способ определения текстового слоя
PDF, точный вид выгрузки, разбиение на задачи.
## Decisions made without discussion
@ -229,7 +229,7 @@ PDF, точный вид выгрузки, разбиение на задачи.
| 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана |
| 06.11.2026 | Task 13: `web/src/api/types.ts` — `'QUALITY_METRICS'` дописан в конец union `PermissionObject` ОДНОЙ строкой (не многострочным форматом), итоговая строка ровно 140 символов — на пределе, но не сверх лимита ширины biome | сохраняет однострочный стиль, которым уже написана эта строка (и соседняя `PermissionAction`); многострочный вариант рассматривался как равноценная по типам альтернатива, выбор между ними непринципиален | нулевая — чисто форматирование, обе формы типобезопасно идентичны |
| 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит |
| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл уже есть в `../docs/contracts/` (сиблинг-репозиторий arm-ka-docs, работа по нему уже слита туда), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда |
| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл есть в `../docs/contracts/metrics/` (сиблинг-репозиторий arm-ka-docs, ветка feat/202, PR docs #203 — НЕ слита; порядок merge: docs PR #203 сливается раньше PR arm-core; позже файл вендорен — R-16), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда |
| 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 |
| 06.11.2026 | Task 8 (`DELETED`/`DELETED_AFTER_CONFIRM`): (1) документ в статусе, отличном от `CONFIRMED`/`RECOGNIZED`/`NEEDS_CHECK` (практически только `ERROR`), при удалении файла НЕ получает факт исхода — у него никогда не было содержательного состояния «как распознано» для фиксации (нет формы/строк, не проходил ни через `AUTO_CONFIRMED`, ни через intake-факт); (2) `issue_count_at_outcome` вычисляется как `counts.error + counts.info` из уже посчитанного агрегата `issue_counts_by_document` вместо отдельного запроса — тот же смысл «все нарушения документа без фильтра», что `len(issues)` в Task 6/7, дешевле на один запрос; (3) `payload` для DELETED*-фактов — единый минимальный вызов `build_outcome_payload(document, fixes=())` без дополнительных запросов к строкам/значениям формы или ОСВ (эти строки всё равно будут физически удалены этим же вызовом `delete`, повторный их снимок здесь экономически не оправдан — а в матрицу §6 эти факты и не попадают, доступны только как «удалён/заменён», drill-down уже видит прежний исход через более ранний intake/outcome факт того же `document_id`) | бриф задачи 8 явно делегировал все три пункта исполнителю («если неоднозначно — зафиксировать выбор, не угадывать»); исполнитель выбрал и задокументировал в докстрингах кода, перенесено сюда контроллером по правилу маркировки находок (решение агента без обсуждения с человеком — в design.md) | (1): при появлении у `ERROR`-документов содержательного состояния до удаления (гипотетически) — нужно будет расширить список статусов; (2)/(3): минимальный риск — агрегаты эквивалентны, а payload не используется для атрибуции (там `primary_cause=None`, вне матрицы) |
| 06.11.2026 | Task 10 (`quality_failure_fact`): `error_code` для отказов маппинга (`_fail`, `_register_failure`) — константа `MAPPING_FAILURE_ERROR_CODE = "MAPPING_FAILED"` (`result_mapping.py`), не свободный текст из `message`/описания исключения | `QualityFailureFact.error_code` не имеет свободнотекстового столбца-соседа для деталей отказа маппинга (в отличие от `recognition_job.error_message`); деталь остаётся в `metrics.mapping_error`/логе, код — единая константа, отличимая от кодов обмена (`ERROR_NO_ACK`/`ERROR_INVALID_REQUEST`), что проверяет отдельный тест | если деталь отказа маппинга понадобится в матрице причин без обращения к логам — нужен отдельный столбец/payload-поле, не замена константы |
@ -240,8 +240,8 @@ PDF, точный вид выгрузки, разбиение на задачи.
| 07.11.2026 | Task 15 (API): последний факт исхода на документ — `DISTINCT ON (document_id) ORDER BY document_id, outcome_at DESC, ранг DESC, id DESC` во всех запросах; ранг (`*_AFTER_CONFIRM` 2, подтверждение 1, остальное 0) совпадает с tie-break домена | SQL и домен должны выбирать один и тот же факт | расхождение выбора даёт разные числа в матрице и в списке ячейки |
| 07.11.2026 | Task 15: выборка идёт от фактов приёма (LEFT JOIN последнего исхода); исход без факта приёма в метрики не входит; границы периода включительные по UTC (`date_to` отсекается в 00:00 UTC следующего дня) | у исхода без приёма нет даты приёма (когорта); UTC — единая зона БД | для пользователей в МСК граница периода сдвинута на 3 часа |
| 07.11.2026 | Task 15: технические отказы — поле `technical_failures {failed_files_count, total_files_count, share}`, период по `failed_at`, `total_files_count` — уникальные `file_id` из приёма и отказов; «в работе» (`in_progress_count`) и `unique_reports_count` (ЮЛ + отчётная дата + тип) — отдельные поля; в ответе отдельно `superseded_after_confirm_count`, `deleted_after_confirm_count` | design §6, решение 27.10 про отказы по файлам | доля отказов считается от другого знаменателя, чем доли причин |
| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` пишется (см. R-12; исходное «нет в `audit_action`» было неверно) | design §7/§8; ячейки шире причин | выгрузка не оставляет следа в аудите; Excel может показать BOM в чужих импортёрах |
| 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY |
| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` пишется (см. R-12; исходное «нет в `audit_action`» было неверно) | design §7/§8; ячейки шире причин | Excel может показать BOM в чужих импортёрах |
| 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3, R-9) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY |
| 07.11.2026 | Task 16 (R-10): на экране нет фильтра по ЮЛ (есть по ГК, периоду, типу, источнику, скану); API параметр `legal_entity_id` поддерживает | нужен отдельный пикер ЮЛ (тысячи записей); владелец: «главное наличие данных» | в UI нельзя посмотреть качество по одному ЮЛ — только через API/выгрузку |
| 07.11.2026 | Task 16 (R-11): матрица «источник × причина» — три параллельных запроса (`pipeline=image`, `pipeline=tabular`, без `pipeline`), колонки «PDF / Excel / Всего»; при выбранном источнике — одна колонка; «Всего» берётся с бэка, не суммой колонок | design §2/§7 требуют сравнения PDF и Excel; бэкенд отдаёт источник только фильтром | три запроса вместо одного на каждую смену фильтров |
| 07.11.2026 | Task 16: «Правки: сервис / наш разбор» в списке документов — суммы полей `*_service` и `*_ours`, `osv_period_account` не входит | в задаче названы только эти два семейства | правки периода и счёта ОСВ в колонке не видны |
@ -258,3 +258,10 @@ PDF, точный вид выгрузки, разбиение на задачи.
| 07.11.2026 | R-19 (I-2): Схема контракта НЕ менялась | все поля контракта заполняются; `raw_line_codes` необязателен и по-прежнему не пишется (R-5b, deferred) | — |
| 07.11.2026 | R-20 (I-2): Тест валидации: хелпер `api/tests/quality_payload_contract.py` (jsonschema Draft 2020-12, `$defs/intake-payload` и `$defs/outcome-payload`); хелперы чтения фактов в интеграционных тестах (`_outcome_fact`, `_intake_fact` в test_api_documents/test_api_files_delete/test_result_mapping) валидируют каждый прочитанный payload, test_auto_confirm — явно. Покрыты: приём ББ/ОФР и ОСВ, AUTO_CONFIRMED (форма и ОСВ), CONFIRMED с правками суммы/кода/шапки/ОСВ, DELETED(_AFTER_CONFIRM), SUPERSEDED(_AFTER_CONFIRM) | payload строится реальными сервисными функциями на PostgreSQL, не ручными литералами; `jsonschema` уже в `requirements-dev.txt` | валидация — в тестах, не в рантайме: выход payload за контракт в проде не отсекается |
| 07.11.2026 | R-21 (I-5): Правка отчётной даты шапки (`report_date`): сырой текст `header_source['report_date'].text` разбирается `parse_date` пакета `arm_statements` (после `fix_lookalikes`, `default_rules()`), даты сравниваются как даты (`attribute_header_date_fix`): равны — «наш разбор», различаются — «сервис», текст не разобрался — «наш разбор». Прочие поля шапки — прежнее сравнение подстрокой | ISO `2025-03-31` как подстрока `31.03.2025` не находился → почти любая правка даты шла в «сервис», вопреки анти-цели §2 («спорное — в наш разбор»); §14.2 и design §5 называют `parse_date` | если `parse_date` ошибётся в формате, дата различится — правка уйдёт в «сервис» (завышение доли ошибок сервиса); правка «Дата колонки» по-прежнему не учитывается (R-5a) |
| 06.11.2026 | R-4 (Task 7): атрибуция правок суммы/кода/шапки читает ЖИВЫЕ колонки (`is_modified`, `original_amount`, `source_text`, `line_code_modified`, `line_code_original_value`, `*_modified`/`*_original_value` шапки), а не JSON `quality_intake_fact.payload`, как буквально предлагал план; в `documents.py` docstring ссылается на «отчёт задачи 7» | `source_text` неизменяем (инвариант «первичка не мутируется»), поэтому живые колонки и снимок приёма равны по содержанию; типизированные колонки проще и надёжнее, чем разбор чужого JSON; функции `attribute_*` принимают строки и источник-агностичны | если понадобится сравнение по payload приёма — сменить источник в одном месте (`_write_confirm_outcome_fact`); docstring ссылается на файл отчёта, который удаляется вместе с SDD-workspace |
| 06.11.2026 | R-5a (Task 7): правка «Дата колонки» (§14.2) НЕ учитывается в счётчиках атрибуции | у `financial_statement_column` нет `*_modified`/`original_*`; правки дат колонок есть лишь в общем механизме аудита `services/statements.py` без чистого запроса «менялась ли дата колонки»; нужен новый столбец (схема) — вне объёма change | правки дат колонок в метриках не видны (занижены правки «наш разбор»/«сервис»); deferred, в Jira при закрытии (M-8) |
| 06.11.2026 | R-5b (Task 7): набор четырёхзначных кодов сырого ответа ИС (`raw_line_codes`, design §5) при приёме не извлекается и в снимок не кладётся; поле контракта необязательно и не пишется | задачи 2/5 извлечение не реализовали; адаптер шага 1 живёт в scan-gateway; извлечение вне объёма change | атрибуция добавленных строк PDF неполна; направление — см. R-6; deferred, в Jira (M-8) |
| 06.11.2026 | R-6 (Task 7, меняет направление R-5b): добавленная строка PDF при отсутствии набора кодов относится к «нашему разбору» (прямая атрибуция без вызова `attribute_added_row` с пустым набором) | пустой набор давал бы «сервис» всегда, вопреки уже принятому решению 27.10 («спорное — в наш разбор», строка про код добавленной строки) и анти-цели §2 | метрика сервиса чуть лучше реальной на добавленных строках (стоимость уже заложена в записи 27.10) |
| 06.11.2026 | R-7 (Task 7, сведён I-2): несоответствие формы `payload.fixes`/`values` контракту `quality-facts.schema.json` было отложено и вынесено в финальное ревью | ни одна задача плана не владела приведением payload к контракту, контракт не был вендорен, тестов валидации не было | закрыто в I-2: R-16…R-21 (вендоринг, приведение payload, тест валидации) |
| 07.11.2026 | R-22 (final review M-7): условие §14.4 «нарушенное соотношение не задето правками» НЕ проверяется: `checks_accepted = meta.checks_accepted_by is not None` (`documents._write_confirm_outcome_fact`), то есть любой акцепт нарушений при ручном подтверждении | на ячейку матрицы не влияет: при любой правке (`service`/`ours`) приоритет §6 п.2-3 выше «документа клиента», а без правок задетое правками соотношение невозможно; влияет только на поле `checks_accepted` в факте и на «документ клиента» у документа без правок — где условие выполняется тривиально. Точная проверка требует пересчёта соотношений до/после правок | если акцепт дан, а часть соотношений исправлена правками, поле `checks_accepted` у такого факта завышено; ячейка остаётся «сервис»/«наш разбор» |
| 07.11.2026 | R-23 (final review I-7): дельты OpenSpec — ADDED `quality-metrics`, `quality-metrics-ui`; MODIFIED `primary-files` (загрузка, удаление), `recognition-job-exchange` (inbox, таймаут), `documents-api` (подтверждение), `osv-result-mapping` (цикл маппинга, автоподтверждение), `authn-authz` (`GET /me`), `reference-data-seed` (seed, 16 → 17 прав) | `openspec validate --strict` требует полного текста изменяемого требования, поэтому MODIFIED копируют текущее требование из `openspec/specs/` с добавлением; авторизационная спека не перечисляет права, поэтому право отражено сценарием `/me` | при архивации дельты заменят требования целиком — правки спек основной ветки, сделанные параллельно, потеряются (проверить конфликт перед `openspec archive`) |

View File

@ -23,18 +23,18 @@
## Затронутые репозитории
- **arm-ka-docs** (отдельный change): DBML v5.24 (таблицы фактов, роль бакета `raw`, признак скана, право), правила
- **arm-ka-docs** (отдельный change): DBML v5.25 (таблицы фактов, роль бакета `raw`, признак скана, право), правила
подсчёта — новый раздел `05-domain-rules.md` §14, контракт фактов `docs/contracts/metrics/quality-facts.schema.json`.
- **arm-core** (этот change): запись фактов, атрибуция правок, расчёт метрик, API, экран, право в seed.
- scan-gateway и tabular-parser не меняются.
Вне срезов `09-delivery-plan.md`: спринт 3, эпик AURORA-1353.
## Затрагиваемые таблицы DBML v5.23
## Затрагиваемые таблицы DBML v5.25
`recognition_job` (ключ сырого ответа), `s3_object` / `s3_bucket_role` (роль `raw`), `stored_file` (признак скана),
`document`, `financial_statement_meta|column|line|value`, `osv_entry`, `document_issue`, `audit_event` (читаются при
записи фактов), `permission_object` (новый объект). Новые таблицы фактов — в DBML v5.24 до кода.
записи фактов), `permission_object` (новый объект). Новые таблицы фактов — в DBML v5.25 до кода.
## Non-goals

View File

@ -0,0 +1,21 @@
# Spec Delta
## Purpose
Дельта change `quality-metrics` (метрики качества первички).
## MODIFIED Requirements
### Requirement: `GET /api/v1/me`
Маршрут SHALL возвращать текущего пользователя: `id`, `email`, `full_name`, `regional_office {id, name, is_head_office}`,
`business_segment`, `roles` (список `code`), `role_details` (те же активные роли списком `{code, name}`, `name` — `role.name`,
порядок как в `roles`), `permissions` (отсортированный список `code`). Это единственный маршрут
Среза 0, доступный любому аутентифицированному пользователю без `require`. Состав `permissions` определяется матрицей ролей (§10), включая `QUALITY_METRICS_VIEW`, который seed выдаёт всем ролям.
#### Scenario: Ответ для dev-пользователя
- **WHEN** `ka_rf@armka.local` (роль `KA_RF`) запрашивает `/api/v1/me`
- **THEN** ответ 200 содержит `roles = ["KA_RF"]`, `role_details` с одним элементом `{code: "KA_RF", name: <непустое название>}` и `permissions`, равные правам `KA_RF` из матрицы §10
#### Scenario: Право метрик в профиле
- **WHEN** любой пользователь запрашивает `/api/v1/me` после `make seed`
- **THEN** `permissions` содержит `QUALITY_METRICS_VIEW`, отсортированно, перед `USERS_ADMIN`

View File

@ -0,0 +1,84 @@
# Spec Delta
## Purpose
Дельта change `quality-metrics` (метрики качества первички).
## MODIFIED Requirements
### Requirement: Подтверждение документа
`POST /api/v1/documents/{document_id}/confirm` (`DOCUMENT_EDIT`, policy) SHALL переводить документ из `RECOGNIZED`
или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`;
из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом
другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Для `BALANCE`/`INCOME` дополнительно: пустые
`period_year` или `period_quarter` — 409 `PERIOD_REQUIRED`; строки `unreadable` / `missing` с непустой суммой или текстом значения — 409 `LINES_UNRESOLVED` (`detail.lines[] {line_id, row_index, line_code_text}`); вторая подтверждённая форма того же типа и периода в файле — 409 `DUPLICATE_FORM` (`detail.document_id`); записи `document_issue` вида `relation` без акцепта — 409
`CHECKS_NOT_PASSED` со списком нарушений в `detail.failures`; при подтверждении SHALL записываться
`financial_statement_meta.reviewed_by/at`. Несовпадение контрагента и уверенность значений подтверждение MUST NOT
блокировать. Если совпали несколько условий, порядок: `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`,
`DOCUMENT_NOT_CONFIRMABLE`, `PERIOD_REQUIRED`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `CHECKS_NOT_PASSED`. Подтверждение берёт блокировки в порядке «файл →
документ → (advisory-блокировка привязок ГК) → версии консолидации (по возрастанию `id`)»: файл — `FOR UPDATE`,
документ — `FOR NO KEY UPDATE` (R-3; совместима со вставкой привязки `FOR KEY SHARE`), версии — `FOR UPDATE`; состояние
перечитывается под этими блокировками; документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`.
В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`,
`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии,
`period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал
версии; false → `period_quarter` ≤ квартала версии). ОСВ за `Q1` равнозначна нарастающей и квартальной (R-14): она
подходит любой версии года при любом `is_cumulative`; ББ/ОФР за `Q1` — отчёт на конец квартала и подходят только версии
за `Q1` (R-14a). Привязанный ранее документ того же ЮЛ с тем же ключом (`document_type`, `osv_account_code`,
`period_quarter`, признак нарастающего: `is_cumulative` true или NULL; для ОСВ за `Q1` признак не различается) в той же
версии SHALL отвязываться (R-1). Каждая версия, получившая
привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` =
подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. В той же транзакции ручное подтверждение документа `BALANCE` / `INCOME` / `OSV` SHALL записывать `quality_outcome_fact` (`outcome = CONFIRMED`) с отнесением правок по живым колонкам документа (`is_modified`, `original_*`, `source_text`), а не по `payload` приёма; `checks_accepted` — признак акцепта проверок шапкой (на ячейку матрицы не влияет: при правках приоритет у «сервиса» и «нашего разбора»). `DISCLOSURE` / `OTHER` факта не получают.
#### Scenario: Причина ошибки маппинга в карточке файла
- **WHEN** файл в `ERROR` после неудачного маппинга (задание `done`, `error_code` пуст)
- **THEN** `job.mapping_error` содержит причину, `job.error_code` пуст
#### Scenario: Документ не подтверждается
- **WHEN** подтверждается документ в статусе `ERROR`
- **THEN** 409 `DOCUMENT_NOT_CONFIRMABLE`, статус не изменился
#### Scenario: Подтверждение
- **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала
- **THEN** `status = CONFIRMED`, `confirmed_by` = пользователь, `audit_event(DOCUMENT_CONFIRM)` с `before_value.status = NEEDS_CHECK`
#### Scenario: Привязка к версии и переход статуса
- **WHEN** версия ГК 2024 Q3 в `INITIATED` с ЮЛ в периметре; подтверждается квартальная ОСВ 60 этого ЮЛ за 2024 Q2
- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории
`INITIATED → DATA_CORRECTION` с `changed_by` = пользователь
#### Scenario: Не подходит по периоду
- **WHEN** подтверждается нарастающая ОСВ 60 за 2024 Q2 при версии за 2024 Q3
- **THEN** привязки нет, статус версии не изменился
#### Scenario: Версия в CANCELLED
- **WHEN** подходящий документ подтверждается при версии `CANCELLED`
- **THEN** привязки нет
#### Scenario: Форма с нарушением проверок
- **WHEN** подтверждается `BALANCE` с непройденным жёстким соотношением без акцепта
- **THEN** 409 `CHECKS_NOT_PASSED`, в `detail.failures` — нарушение; после акцепта подтверждение проходит
#### Scenario: Форма без периода
- **WHEN** подтверждается форма из OCR, у которой нет ни даты отчёта, ни дат колонок
- **THEN** 409 `PERIOD_REQUIRED`; после правки даты отчёта подтверждение проходит
#### Scenario: Форма в чек-листе
- **WHEN** версия ГК 2025 Q3 `INITIATED`, ЮЛ в периметре; подтверждается его `BALANCE` за 2025 Q3
- **THEN** привязка создана, ячейка «Бух. баланс» чек-листа ЮЛ — `CONFIRMED`, `reviewed_by` шапки = пользователь
#### Scenario: Неразобранная строка с суммой
- **WHEN** у формы строка `missing` с суммой и одновременно жёсткое нарушение
- **THEN** 409 `LINES_UNRESOLVED` с этой строкой в `detail.lines`
#### Scenario: Неразобранная строка только с текстом
- **WHEN** у формы строка `unreadable` без сумм, но с мусором `211(1 |` в ячейке значения (`string_value`)
- **THEN** 409 `LINES_UNRESOLVED` — потерянная сумма тоже требует разбора
#### Scenario: Вторая форма того же периода
- **WHEN** в файле два ББ на 30.09.2025, первый подтверждён, аналитик подтверждает второй
- **THEN** 409 `DUPLICATE_FORM` со ссылкой на первый
#### Scenario: Подтверждение с правкой суммы
- **WHEN** аналитик исправил сумму и подтвердил ББ
- **THEN** записан `quality_outcome_fact` `CONFIRMED` со счётчиком правки значения по источнику и `payload.fixes`

View File

@ -0,0 +1,107 @@
# Spec Delta
## Purpose
Дельта change `quality-metrics` (метрики качества первички).
## MODIFIED Requirements
### Requirement: Цикл маппинга отделён от inbox
Фоновая задача ядра SHALL с периодом `MAPPING_POLL_SECONDS` (5) выбирать одно задание со `status ∈ {done,
needs_check}`, чей файл имеет `status = PROCESSING` и `current_recognition_job_id` = это задание (`FOR UPDATE SKIP
LOCKED`), проверять, что `result_s3_object` относится к бакету хранилища и его ключ начинается с
`results/{file_id}/{job_id}/` (OQ-70), читать объект из S3 **вне** транзакции, валидировать `RecognitionResult` v1.x и
в одной транзакции создать документы и строки (листы ОСВ и формы ББ/ОФР), записать `recognition_job.group_schema`,
перевести файл в `PROCESSED` и выполнить автозамену. Результат без документов SHALL давать неповторяемую ошибку
маппинга «в результате нет документов»; результат с формами, но без ОСВ, ошибкой MUST NOT быть. Inbox MUST NOT читать
файл результата и MUST NOT создавать документы. После `map_file` и до автоподтверждения транзакция маппинга SHALL писать факты приёма `quality_intake_fact` по документам `BALANCE` / `INCOME` / `OSV`; неповторяемая ошибка маппинга (файл → `ERROR`) SHALL писать `quality_failure_fact` (`failure_kind = MAPPING`, `error_code = MAPPING_FAILED`).
#### Scenario: Результат смапплен
- **WHEN** в S3 лежит результат парсера для `rb/2024-FY/ОСВ_66 за 2024.xls`, задание `done`, файл `PROCESSING`
- **THEN** не позднее чем через `MAPPING_POLL_SECONDS` + время маппинга файл — `PROCESSED`, создан один `document`
(`OSV`, `osv_account_code = "66"`, `period_year = 2024`, `period_quarter = Q4`, `is_cumulative = true`, `locator = sheet:Лист_1`),
`osv_entry` — все строки результата кроме `TOTAL`, `group_schema` задания содержит `[{"locator": "sheet:Лист_1", "groups": [...]}]`
#### Scenario: Ключ результата вне папки задания
- **WHEN** `result_s3_object.key = results/<другой file_id>/.../result.json`
- **THEN** файл — `ERROR` с ошибкой маппинга, документы не созданы, повтор не выполняется
#### Scenario: Сбой маппинга с повтором
- **WHEN** вставка строк падает по ошибке БД
- **THEN** транзакция откачена, `metrics.mapping_attempts = 1`, файл остаётся `PROCESSING`; на третьей неудаче — `ERROR`
с `metrics.mapping_error`
#### Scenario: Повторный маппинг идемпотентен
- **WHEN** маппинг прерван после частичной вставки (имитация) и выполнен снова
- **THEN** у файла ровно один набор документов и строк, дублей по `(document_id, row_number)` нет
#### Scenario: Файл только с формами
- **WHEN** смаплен golden-снимок `mezheninovskaya/2025-Q3/1_Форма1_бух_09` (одна форма `BALANCE`, ОСВ нет)
- **THEN** файл `PROCESSED`, создан документ `BALANCE`, ошибки «нет документов ОСВ» нет
#### Scenario: Пустой результат
- **WHEN** результат без документов (`documents = []`): контракт v1.x такого результата не допускает — его останавливает проверка схемы, а прямой вызов `map_file` даёт `MappingFatal`
- **THEN** файл `ERROR`, причина в `metrics.mapping_error` (при проверке схемы — ошибка схемы, в `map_file` — «в результате нет документов»), повтор не выполняется
#### Scenario: Факт приёма в транзакции маппинга
- **WHEN** результат смаплен и создан документ ОСВ
- **THEN** в той же транзакции записан `quality_intake_fact` документа до перехода в `CONFIRMED`
#### Scenario: Неповторяемая ошибка маппинга
- **WHEN** маппинг завершился неповторяемой ошибкой
- **THEN** файл `ERROR` и записан `quality_failure_fact` с `failure_kind = MAPPING`
### Requirement: Автоподтверждение чистого документа
В транзакции маппинга после автозамены прежних версий файла и до перехода версий по `NEEDS_CHECK` цикл маппинга SHALL
перевести каждый документ файла, получивший при маппинге статус `RECOGNIZED`, в `CONFIRMED` (ТЗ 03_9 п. 4, 10_10;
решение владельца 08.10.2026, OQ-157): `confirmed_by = NULL`, `confirmed_at = now`; у формы `BALANCE`/`INCOME` —
`financial_statement_meta.reviewed_by = NULL`, `reviewed_at = now`; `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS,
object_type = document)` с `user_id = NULL`, `before_value.status = RECOGNIZED`, `after_value.status = CONFIRMED` и
`description = «автоподтверждение: без замечаний»`. Форма `BALANCE`/`INCOME` с шапкой SHALL подтверждаться
автоматически, только если выполнены условия ручного подтверждения (`period_year` и `period_quarter` определены, нет
непройденных неакцептованных жёстких проверок §7); иначе она MUST оставаться `RECOGNIZED` без ошибки. Документы
`NEEDS_CHECK` MUST NOT подтверждаться автоматически.
Автоподтверждённый документ SHALL привязываться к версиям так же, как при ручном подтверждении (`documents-api`
«Подтверждение документа»: все подходящие версии «в работе», замена привязки того же ключа R-1), и каждая версия,
получившая привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` с `changed_by =
stored_file.uploaded_by` и `comment = «автоподтверждение документа <document_type> <osv_account_code>»` (счёт — только у
ОСВ). Если `uploaded_by` NULL, привязка SHALL создаваться, а переход — пропускаться с записью в лог. Пересчёт статуса
формы после правки или акцепта MUST NOT подтверждать документ автоматически. Для каждого автоподтверждённого документа `BALANCE` / `INCOME` / `OSV` SHALL записываться `quality_outcome_fact` (`AUTO_CONFIRMED`, `primary_cause = WITHOUT_PARTICIPATION`, `confirmed_by` пуст); автозамена прежних версий SHALL писать `SUPERSEDED` / `SUPERSEDED_AFTER_CONFIRM` по вытесненным документам.
#### Scenario: Чистая ОСВ сразу «Загружен»
- **WHEN** смаплен результат `fixtures/osv/rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` для ЮЛ АО «Русский бисквит»
(задание `done`, шапка совпадает с владельцем, нарушений и предупреждений нет)
- **THEN** документ `OSV` 60 — `CONFIRMED`, `confirmed_by` NULL, `confirmed_at` заполнен; есть `audit_event(DOCUMENT_CONFIRM)`
с `user_id` NULL и описанием «автоподтверждение: без замечаний»
#### Scenario: Привязка и переход версии
- **WHEN** версия ГК «Русский Бисквит» 2025 Q1 в `INITIATED`, РБ в периметре; смаплен чистый результат ОСВ 60 РБ за 2025 Q1,
файл загрузил `ka_go`
- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории
`INITIATED → DATA_CORRECTION` с `changed_by` = `ka_go` и комментарием «автоподтверждение документа OSV 60»
#### Scenario: Документ с замечанием ждёт аналитика
- **WHEN** файл ОСВ загружен для ЮЛ «Пекарня Счастливочная», а в шапке `АО "Русский бисквит"`
- **THEN** документ `NEEDS_CHECK`, `confirmed_at` пуст, события `DOCUMENT_CONFIRM` нет, привязки нет
#### Scenario: Чистая форма Excel
- **WHEN** смаплен `statements-xlsx/mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, период определён)
- **THEN** документ `INCOME` `CONFIRMED`, `reviewed_by` NULL, `reviewed_at` заполнен
#### Scenario: Форма OCR не подтверждается сама
- **WHEN** смаплен эталон `ocr/expected_normalize/БО 1 кв 2025` (уверенность значений < 100, дата отчёта не разобрана)
- **THEN** документы `BALANCE` и `INCOME` — `NEEDS_CHECK`, не `CONFIRMED`
#### Scenario: Файл без загрузившего
- **WHEN** версия в `INITIATED`, ЮЛ в периметре; смаплен чистый результат файла с `uploaded_by` NULL
- **THEN** документ `CONFIRMED` и привязан к версии; версия осталась `INITIATED`; в логе — строка о пропуске перехода
#### Scenario: Автозамена чистой версией файла
- **WHEN** документ ОСВ 60 РБ за 2025 Q1 из файла A привязан к версии 2025 Q1; загружен и смаплен файл B того же ключа
(чистый), A заменён автозаменой
- **THEN** привязка документа A снята, документ B `CONFIRMED` и привязан к версии
#### Scenario: Факт автоподтверждения
- **WHEN** документ автоподтверждён
- **THEN** есть `quality_outcome_fact` `AUTO_CONFIRMED` для этого документа

View File

@ -0,0 +1,93 @@
# Spec Delta
## Purpose
Дельта change `quality-metrics` (метрики качества первички).
## MODIFIED Requirements
### Requirement: Загрузка файлов организации
`POST /api/v1/legal-entities/{legal_entity_id}/files` (multipart, одно или несколько полей `files`; право
`DOCUMENT_EDIT`; ЮЛ в области видимости пользователя, иначе 404) SHALL для каждого файла в отдельной транзакции:
определить тип по расширению и сверить с сигнатурой содержимого (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`,
`jpg`); отвергнуть файл больше `UPLOAD_MAX_BYTES` (50 МиБ) или неподдерживаемого типа; вычислить sha256; создать
`s3_object` (`bucket_role = uploads`, ключ `uploads/{file_id}/{job_id}/{безопасное имя}`, `sha256`, `size_bytes`,
`content_type`, `created_by = user:{id}`), положить объект в S3, создать `stored_file` (`status = UPLOADED`,
`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`, `consolidation_id = NULL`) и
`recognition_job` (`status = queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`;
`max_attempts` 3 / 1), установить `stored_file.current_recognition_job_id`, записать
`audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)` с `after_value.consolidation_id = null`. Ответ SHALL
перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`,
`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT
запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`.
Query-параметр `consolidation_version_id` MUST NOT приниматься (удалён в v5.18): загрузка этим маршрутом — без
контекста консолидации и не меняет статусы версий; загрузка в консолидацию — требование «Загрузка файлов в
консолидацию». Для PDF `stored_file.is_scan` SHALL определяться при загрузке по наличию текстового слоя в первых 10 страницах (разбор pypdf — вне event loop, `asyncio.to_thread`): нет текста — скан (`true`), есть — цифровой (`false`); для не-PDF — NULL. PDF со скрытым OCR-слоем считается цифровым.
#### Scenario: Реальная ОСВ принята и ушла в outbox
- **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала
- **THEN** 201, `accepted[0].file_id` существует; `stored_file.status = UPLOADED`, `file_type = XLS`, `content_hash` =
sha256 файла, `consolidation_id IS NULL`; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу
`uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`; публикатор outbox публикует запрос с этим ключом
#### Scenario: Смесь принятых и отклонённых
- **WHEN** в одном запросе xlsx, файл `.docx` и файл 51 МиБ
- **THEN** 201, `accepted` — один, `rejected` — два с кодами `UNSUPPORTED_FILE` и `FILE_TOO_LARGE`
#### Scenario: Расширение не совпадает с содержимым
- **WHEN** загружается файл `osv.xlsx`, байты которого — BIFF8
- **THEN** файл отклонён с кодом `UNSUPPORTED_FILE`
#### Scenario: Чужое ЮЛ
- **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала
- **THEN** 404, объектов и записей нет
#### Scenario: Загрузка в контексте версии
- **WHEN** у ГК ЮЛ есть версия `INITIATED` с этим ЮЛ в периметре, файл загружен маршрутом ЮЛ с query
`consolidation_version_id` (как делал клиент до v5.18)
- **THEN** 201, параметр игнорируется; `stored_file.consolidation_id IS NULL`; версия остаётся `INITIATED`
#### Scenario: ЮЛ вне периметра
- **WHEN** ЮЛ не входит в периметр ни одной версии, файл загружен маршрутом ЮЛ
- **THEN** 201 — маршрут ЮЛ периметр не проверяет; проверка периметра — только у загрузки в консолидацию
#### Scenario: Признак скана
- **WHEN** загружается PDF без текстового слоя
- **THEN** `stored_file.is_scan = true`; у xlsx `is_scan IS NULL`
### Requirement: Удаление файла
`DELETE /api/v1/files/{file_id}` (`DOCUMENT_EDIT`, policy) SHALL удалить строки ОСВ, документы, задания и `stored_file`,
проставить `s3_object.expires_at = now()` (объект физически не удаляется, OQ-22) и записать `audit_event(FILE_DELETE,
before_value = карточка файла, after_value = {unbound_version_ids})`. Удаление MUST быть отклонено 409
`FILE_HAS_CONFIRMED_DOCUMENTS`, если у файла есть документ `CONFIRMED`, подтверждённый пользователем (`confirmed_by` не
NULL), или если автоподтверждённый документ файла (`confirmed_by` NULL) привязан к версии в статусе, отличном от
`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION` (OQ-157, уточняет OQ-110); и 409 `FILE_SUPERSEDES_OTHERS`, если на
файл ссылаются `superseded_by_file_id`. При удалении файла с автоподтверждёнными документами их привязки
`consolidation_document` SHALL сниматься, статус версий MUST NOT меняться, `after_value.unbound_version_ids` — id версий,
где привязки сняты (по возрастанию; пустой список, если привязок не было). Блокировки: задания → файл → документы →
advisory-блокировка привязок ГК → версии по `id`. Файл `SUPERSEDED` удалить можно. Если у файла есть `quality_intake_fact` (по `file_id`), объект `uploads` файла MUST NOT получать `expires_at` — он нужен эталонному прогону качества; сырой ответ ИС (`raw`) при удалении `expires_at` не получает никогда. Для документов с исходом удаление SHALL писать факт исхода `DELETED` / `DELETED_AFTER_CONFIRM` в той же транзакции (спека `quality-metrics`).
#### Scenario: Удаление ошибочного файла
- **WHEN** файл в `ERROR` удаляется владельцем
- **THEN** 204; `stored_file`, `recognition_job` отсутствуют; `s3_object.expires_at` заполнен; `audit_event(FILE_DELETE)`
с `after_value.unbound_version_ids = []`
#### Scenario: Подтверждённый документ
- **WHEN** удаляется файл с документом `CONFIRMED`, у которого `confirmed_by` = пользователь
- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, ничего не изменилось
#### Scenario: Автоподтверждённый документ в корректировке
- **WHEN** версия 2025 Q1 в `DATA_CORRECTION`, к ней привязан автоподтверждённый документ ОСВ 60 РБ; файл удаляется
- **THEN** 204; привязка снята, версия осталась в `DATA_CORRECTION`; `FILE_DELETE.after_value.unbound_version_ids` = [id версии]
#### Scenario: Версия ушла на согласование
- **WHEN** автоподтверждённый документ файла привязан к версии в `CONSOLIDATION_RF`
- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, привязка и файл на месте
#### Scenario: Автоподтверждённый без привязок
- **WHEN** удаляется файл, все документы которого автоподтверждены и ни к одной версии не привязаны
- **THEN** 204; `after_value.unbound_version_ids = []`
#### Scenario: Файл с фактом приёма
- **WHEN** удаляется файл, по документам которого записан `quality_intake_fact`
- **THEN** 204; объект `uploads` и сырой ответ остаются без `expires_at`, результат (`results`) экспирится; записан факт исхода

View File

@ -0,0 +1,43 @@
# Spec Delta
## Purpose
Экран «Качество первички» веб-интерфейса: матрица «источник × причина», динамика по месяцам, список документов ячейки,
выгрузка фактов; пункт меню по праву.
## ADDED Requirements
### Requirement: Экран «Качество первички»
Веб-интерфейс SHALL содержать экран «Качество первички» с фильтрами (период, ГК, тип документа, источник, скан/цифровой),
матрицей «источник × причина» (колонки PDF, Excel, Всего; технические отказы — отдельным столбцом по файлам с пометкой,
что фильтр типа на них не действует), динамикой по месяцам выбранной причины и кнопкой выгрузки фактов (CSV / JSON).
Фильтра по ЮЛ на экране нет (API его поддерживает). Справочник ГК для фильтра SHALL запрашиваться один раз за
жизнь экрана, в том числе при пустом ответе или ошибке.
#### Scenario: Пустой справочник ГК
- **WHEN** справочник ГК пуст или запрос завершился ошибкой
- **THEN** экран не повторяет запрос бесконечно
#### Scenario: Выбран источник
- **WHEN** в фильтре выбран PDF
- **THEN** матрица показывает одну колонку
### Requirement: Переход из ячейки в документ
Клик по ячейке матрицы SHALL открывать список документов ячейки (постранично, со счётчиками правок «сервис» и «наш
разбор» и исходом); из строки списка, если документ ещё существует, SHALL быть переход в экран проверки документа.
#### Scenario: Документ удалён
- **WHEN** документ ячейки уже удалён
- **THEN** строка показана без ссылки на документ
### Requirement: Пункт меню по праву
Боковое меню SHALL показывать пункт «Качество первички» только при наличии `QUALITY_METRICS_VIEW` и снова SHALL
скрывать пункты по полю `permission`: «Пользователи и роли» скрыт без `USERS_ADMIN`. Доступ по-прежнему решает сервер.
#### Scenario: Нет права
- **WHEN** в `/me` нет `QUALITY_METRICS_VIEW`
- **THEN** пункта «Качество первички» в меню нет
#### Scenario: Нет USERS_ADMIN
- **WHEN** в `/me` нет `USERS_ADMIN`
- **THEN** пункт «Пользователи и роли» скрыт

View File

@ -0,0 +1,124 @@
# Spec Delta
## Purpose
Факты качества первички (приём «как распознано», исход, технический отказ), отнесение правок аналитика к источнику
ошибки, матрица причин и API `/quality-metrics` с отдельным правом. Правила подсчёта — `05-domain-rules.md` §14,
модель — DBML v5.25, дизайн — `design.md` этого change.
## ADDED Requirements
### Requirement: Факты качества — отдельная схема, только добавление
Ядро SHALL хранить факты качества тремя плоскими таблицами `quality_intake_fact`, `quality_outcome_fact`,
`quality_failure_fact` в отдельной схеме PostgreSQL (`QUALITY_METRICS_SCHEMA`, по умолчанию `dkb_metrics`; значение MUST
отличаться от `DB_SCHEMA`). Таблицы MUST NOT иметь внешних ключей на рабочие таблицы: идентификаторы документов, файлов,
заданий, ЮЛ и ГК — просто `uuid`, имена ЮЛ и ГК копируются на момент факта. Записи SHALL только добавляться (UPDATE и
DELETE по фактам MUST NOT выполняться); `payload` — `jsonb` с `payload_schema_version`, форма по контракту
`quality-facts.schema.json`. Факты пишутся только для документов, принятых после выкладки.
#### Scenario: Факт переживает удаление файла
- **WHEN** файл с документом, у которого есть факт приёма, удаляется
- **THEN** строки документа и файла удалены, а `quality_intake_fact` остаётся нетронутым
#### Scenario: Схема метрик совпала со схемой модели
- **WHEN** `DB_SCHEMA` и `QUALITY_METRICS_SCHEMA` заданы одним значением
- **THEN** приложение не стартует с ошибкой конфигурации
### Requirement: Факт приёма «как распознано»
В транзакции маппинга результата, после `map_file` и до автоподтверждения, ядро SHALL записать по одному
`quality_intake_fact` на каждый созданный документ `BALANCE`, `INCOME`, `OSV` (`DISCLOSURE` и `OTHER` факта не
получают): измерения (ЮЛ и ГК с именами, тип документа, `source_kind` pdf/excel, `is_scan` для PDF, сервис и версии,
`intake_at`, `intake_status`), счётчики строк, значений, нарушений и предупреждений, ключи S3, и `payload` со снимком
«как распознано». Запись факта MUST NOT менять документы, статусы и результат маппинга.
#### Scenario: Приём ББ
- **WHEN** маппинг создал документ `BALANCE` из PDF
- **THEN** есть ровно один `quality_intake_fact` с `document_id`, `source_kind = pdf`, `intake_status` как у документа после
маппинга и снимком строк и значений в `payload`
#### Scenario: Расшифровка не учитывается
- **WHEN** маппинг создал документ `DISCLOSURE`
- **THEN** факта приёма по нему нет
### Requirement: Факт исхода документа
Ядро SHALL записать `quality_outcome_fact` в транзакции события, которое завершает документ: ручное подтверждение
(`CONFIRMED`), автоподтверждение (`AUTO_CONFIRMED`), удаление файла (`DELETED`), автозамена файла (`SUPERSEDED`). Если
документ на момент удаления или замены уже `CONFIRMED`, исход SHALL быть `DELETED_AFTER_CONFIRM` /
`SUPERSEDED_AFTER_CONFIRM`. Факт содержит `primary_cause`, счётчики правок по источнику (`service` / `ours` / `client`),
`checks_accepted`, число нарушений на момент исхода, подтверждённую отчётную дату. Повторное подтверждение ОСВ после
правки SHALL писать новый факт; метрики берут последний факт документа.
#### Scenario: Автоподтверждение
- **WHEN** документ без замечаний автоподтверждён
- **THEN** факт с `outcome = AUTO_CONFIRMED`, `primary_cause = WITHOUT_PARTICIPATION`, `confirmed_by` пуст
#### Scenario: Удаление после подтверждения
- **WHEN** удаляется файл с автоподтверждённым документом
- **THEN** новый факт `DELETED_AFTER_CONFIRM`; прежний факт `AUTO_CONFIRMED` не изменён
### Requirement: Факт технического отказа
Ядро SHALL записывать `quality_failure_fact` при переходе задания распознавания в `error`, `timeout`, `dlq` и при
неповторяемой ошибке маппинга (файл → `ERROR`). `failure_kind` — `SERVICE_ERROR` / `TIMEOUT` / `NO_ACK` / `MAPPING`;
`error_code` отказа маппинга — константа `MAPPING_FAILED`. Повторная обработка создаёт новое задание: отказ первой
попытки остаётся фактом.
#### Scenario: Таймаут задания
- **WHEN** вахтёр переводит задание в `timeout`
- **THEN** есть факт `failure_kind = TIMEOUT` с `job_id`, `file_id`, `failed_at`
### Requirement: Отнесение правки к источнику ошибки
Правка аналитика (отличие подтверждённого результата от снимка приёма) SHALL относиться к источнику чистой функцией без
БД по правилу «верный ответ есть в сыром тексте сервиса — ошибка наша, иначе сервиса»: сумма, код строки, добавленная
строка, поля шапки (отчётная дата сравнивается как дата после разбора `parse_date`), период и счёт ОСВ. Для Excel любая
ошибка — «наш разбор». Спорные случаи и случаи без сырого текста SHALL относиться к «нашему разбору». Правки колонок дат
и набор кодов сырого ответа для добавленных строк PDF в этом change не учитываются (отложено).
#### Scenario: Сервис прочитал верно
- **WHEN** аналитик исправил сумму, цифры которой совпадают с цифрами сырого текста
- **THEN** правка относится к «нашему разбору»
#### Scenario: Сервис ошибся
- **WHEN** подтверждённый код строки не извлекается из сырого текста кода
- **THEN** правка относится к «ошибке сервиса»
### Requirement: Причина документа и матрица
Каждый документ с исходом SHALL попадать ровно в одну ячейку по первому сработавшему правилу: удалён/заменён до
подтверждения; удалён/заменён после подтверждения; есть правки «сервис» → ошибка сервиса; есть правки «наш разбор» →
наш разбор; ручное подтверждение с акцептом жёстких нарушений → документ клиента; `AUTO_CONFIRMED` → без участия;
иначе → как есть. Документы без исхода — «в работе». Доли считаются от документов с исходом `AUTO_CONFIRMED` /
`CONFIRMED`; удалённые, заменённые и «в работе» в знаменатель не входят. Технические отказы считаются по файлам своей
долей. Расчёт — чистая функция от фактов, в БД не хранится; последний факт документа выбирается по времени исхода, при
равенстве — детерминированно.
#### Scenario: Документ с правкой сервиса и клиента
- **WHEN** у подтверждённого документа есть правка «сервис» и акцепт нарушений
- **THEN** документ в ячейке «ошибка сервиса», счётчики обеих сторон сохранены в факте
#### Scenario: Нулевой знаменатель
- **WHEN** за период нет документов с исходом
- **THEN** доли равны 0, `denominator = 0`
### Requirement: API метрик качества
Система SHALL предоставлять под правом `QUALITY_METRICS_VIEW` маршруты `/api/v1/quality-metrics`: `GET /matrix`
(матрица за период с фильтрами период по дате приёма, ГК, ЮЛ, тип, источник, скан/цифровой, отказы по файлам, счётчик
уникальных отчётов), `GET /series` (ряд по месяцам даты приёма для причины, пропущенные месяцы — нули), `GET
/cells/{cell}/documents` (постраничный список документов ячейки со счётчиками правок, ссылкой на документ, если он
существует, и `confirmed_by`), `GET /export` (факты за период, CSV с BOM и JSON; значения, начинающиеся с символов
формулы, экранируются). Выгрузка SHALL писать `audit_event(EXPORT)` с `object_type = quality_metrics` и фильтрами. Агрегаты
MUST NOT содержать фамилий пользователей.
#### Scenario: Матрица без права
- **WHEN** пользователь без `QUALITY_METRICS_VIEW` запрашивает `/quality-metrics/matrix`
- **THEN** 403
#### Scenario: Выгрузка оставляет след
- **WHEN** пользователь выгружает факты за период
- **THEN** записан `audit_event(EXPORT)` с форматом, фильтрами и числом строк
### Requirement: Право QUALITY_METRICS_VIEW
Объект `QUALITY_METRICS` в `permission_object` и право `QUALITY_METRICS_VIEW` SHALL быть в seed и выдаваться всем
ролям; позже права сужаются данными (`role_permission`) без изменения кода. Видимость по филиалу и ГК не применяется.
#### Scenario: Право у всех ролей
- **WHEN** выполнен `make seed` на чистой БД
- **THEN** `GET /me` любой роли содержит `QUALITY_METRICS_VIEW`

View File

@ -0,0 +1,59 @@
# Spec Delta
## Purpose
Дельта change `quality-metrics` (метрики качества первички).
## MODIFIED Requirements
### Requirement: Inbox принимает результат идемпотентно
Для конверта со `status ∈ {done, needs_check, error}` ядро SHALL валидировать его по
`recognition-result-message.schema.json`, найти задание по `job_id` и: если `result_msg_id` задания уже равен
`Nats-Msg-Id` сообщения — ничего не менять; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`,
`created_by = <service.name>`; только при наличии `result_s3`), обновить задание (`status`, `service_name`,
`service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`,
`result_msg_id`) и зафиксировать транзакцию. При `status = error` файл задания (если `current_recognition_job_id` равен
заданию) SHALL получить `stored_file.status = ERROR`; при `done`/`needs_check` файл остаётся `PROCESSING` и SHALL быть
обработан циклом маппинга (спека `osv-result-mapping`); inbox MUST NOT читать файл результата. Неизвестный `job_id` SHALL
быть залогирован. Результат для задания, уже находящегося в финальном статусе (`done`, `needs_check`, `error`,
`timeout`, `dlq`), SHALL логироваться и не применяться. Если в конверте есть `raw_s3_key`, ядро SHALL независимо от `status` зарегистрировать сырой ответ ИС в `s3_object` (`bucket_role = raw`, ключ `raw/{job_id}.json`) и проставить `recognition_job.raw_s3_object_id`. При `status = error` в той же транзакции SHALL записываться `quality_failure_fact` (`failure_kind = SERVICE_ERROR`, `error_code` из конверта).
#### Scenario: Успешный результат
- **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results/<file_id>/<job_id>/result.json}` и
`Nats-Msg-Id = result:<job_id>:1`
- **THEN** задание получает `status = done`, `result_msg_id = result:<job_id>:1`, `result_s3_object_id` указывает на новую
строку `s3_object` с этим ключом, `finished_at` заполнен; файл остаётся `PROCESSING` до маппинга
#### Scenario: Повторная доставка
- **WHEN** то же сообщение получено второй раз
- **THEN** строка `recognition_job` и `s3_object` не меняются
#### Scenario: Ошибка сервиса
- **WHEN** конверт содержит `status = error`, `error = {UNSUPPORTED_FORMAT, …}` без `result_s3`
- **THEN** задание получает `status = error`, `error_code = UNSUPPORTED_FORMAT`, `error_message` из конверта,
`result_s3_object_id` пуст; файл — `ERROR`
#### Scenario: Результат пришёл после таймаута
- **WHEN** задание уже `timeout`, и приходит конверт `done`
- **THEN** задание не меняется, событие залогировано
#### Scenario: Сырой ответ зарегистрирован
- **WHEN** конверт `done` содержит `raw_s3_key = raw/<job_id>.json`
- **THEN** создан `s3_object` с `bucket_role = raw`, `recognition_job.raw_s3_object_id` указывает на него
#### Scenario: Факт отказа сервиса
- **WHEN** конверт содержит `status = error`
- **THEN** кроме `ERROR` файла записан `quality_failure_fact` с `failure_kind = SERVICE_ERROR`
### Requirement: Таймаут ожидания результата
Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в
`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в
`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Ручной повтор после TIMEOUT/NO_ACK SHALL создавать новое задание по требованию «Новая попытка обработки файла». Каждый перевод в `timeout` и в `dlq` SHALL писать `quality_failure_fact` (`TIMEOUT` / `NO_ACK`) в той же транзакции.
#### Scenario: Сервис взял, но не ответил
- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата
- **THEN** не позднее чем через 30 с задание имеет `status = timeout`, файл — `ERROR` с `error_code = TIMEOUT`
#### Scenario: Факт таймаута
- **WHEN** вахтёр перевёл задание в `timeout`
- **THEN** записан `quality_failure_fact` с `failure_kind = TIMEOUT`

View File

@ -0,0 +1,53 @@
# Spec Delta
## Purpose
Дельта change `quality-metrics` (метрики качества первички).
## MODIFIED Requirements
### Requirement: Идемпотентный seed справочников продукта
Команда `python -m scripts.seed` (из `api/`) SHALL загрузить из `api/seed/core/`: `regional_office` (Головной офис,
`is_head_office = true`), `role` — 7 системных ролей ТЗ 8.1 (`KA_RF`, `KA_GO`, `RKA`, `CLIENT_MANAGER`, `CURATOR_GO`,
`EXPERT`, `ADMIN`, `is_system = true`), `permission` — 17 сочетаний объект × действие из §10 правил с кодами
`<OBJECT>_<ACTION>` (`EXPORT` при совпадении), `role_permission` по матрице §10 (ASSUMPTION(OQ-16)), `chart_of_account`,
`elimination_rule` + `elimination_rule_account` версии 1 для ББ (§4), `required_document_rule` для `BASIC` и `EXTENDED`.
Записи SHALL вставляться или обновляться по натуральному ключу; удаление MUST NOT выполняться.
Исключение — матрица `role_permission` (ей после первичной загрузки владеет администратор, issue #9): в ПУСТУЮ таблицу
матрица файла SHALL загружаться целиком; в заполненную SHALL вставляться только пары, у которых роль или право
вставлены этим же запуском seed (их администратор снять не мог), а остальные пары файла MUST NOT добавляться — право,
снятое администратором, не возвращается, добавленное сверх файла не отзывается. Отчёт команды SHALL сообщать, что
матрица пропущена (или сколько добавлено пар новых ролей и прав). Право `QUALITY_METRICS_VIEW` (объект `QUALITY_METRICS`, действие `VIEW`) SHALL выдаваться всем ролям; на стенде, где матрица уже заполнена, пара добавляется только как пара впервые созданного права — для появления пункта меню нужен повторный `make seed`.
#### Scenario: Первый запуск на пустой схеме
- **WHEN** после `alembic upgrade head` выполняется `python -m scripts.seed`
- **THEN** в `role` 7 строк, в `permission` 17, в `role_permission` — число строк равно сумме прав по матрице §10,
в `elimination_rule` 8 строк с `statement_type = balance`, `version_no = 1`, в `required_document_rule` для `BASIC`
10 строк (BALANCE, INCOME, OSV × 01, 58, 60, 62, 66, 67, 76, 90) и для `EXTENDED` 27 строк
#### Scenario: Повторный запуск
- **WHEN** `python -m scripts.seed` выполняется второй раз без изменения файлов
- **THEN** число строк во всех затронутых таблицах не меняется, команда завершается с кодом 0, в отчёте
`role_permission` помечена как пропущенная
#### Scenario: Право, снятое администратором
- **WHEN** администратор снял с роли `EXPERT` право `EX_VIEW`, после чего seed запущен снова
- **THEN** связь `EXPERT × EX_VIEW` не восстановлена, остальная матрица не изменилась
#### Scenario: Новые роль и право в файлах seed
- **WHEN** матрица заполнена, а в `permissions.yaml` добавлено новое право, в `roles.yaml` — новая роль, и в
`role_permissions.yaml` — их пары (новое право существующей роли, существующее право новой роли)
- **THEN** seed вставляет ровно эти пары; снятые ранее администратором связи существующих ролей и прав не возвращаются
#### Scenario: Администратор снял все связи
- **WHEN** таблица `role_permission` пуста и seed запущен снова
- **THEN** матрица файла загружается целиком, как при первом запуске
#### Scenario: Изменение названия роли в файле
- **WHEN** в `roles.yaml` изменено `name` роли `EXPERT` и seed запущен снова
- **THEN** строка `role` с `code = EXPERT` обновлена, новая не создана
#### Scenario: Право метрик на заполненной матрице
- **WHEN** матрица заполнена, право `QUALITY_METRICS_VIEW` создаётся впервые и seed запущен
- **THEN** добавлены пары права со всеми ролями; ранее снятые администратором связи не возвращены