# Метрики качества первички (arm-core) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** arm-core записывает append-only факты качества распознавания первички (приём/исход/технический отказ), относит правки к источнику (сервис/наш разбор), считает матрицу причин и отдаёт её экраном и API, под новым правом `QUALITY_METRICS_VIEW` (AURORA-1759). **Architecture:** Новая схема `dkb_metrics` (3 таблицы, одна цепочка Alembic с `dkb`), ORM 1:1 с DBML v5.25. Запись факта — тонкие вызовы из уже существующих точек жизненного цикла документа/задания (маппинг, подтверждение, автоподтверждение, удаление, автозамена, таймаут/dlq/ошибка сервиса/маппинга), не новый бизнес-процесс. Атрибуция правки и расчёт матрицы — чистые функции `domain/`, без сессии БД. API — тонкий сервис над чистой функцией, правило `требует-прав → видимость → сервис` упрощено (видимость по филиалу/ГК не применяется — design §8). Экран — новый read-only виджет `web/`. **Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16 (новая схема `dkb_metrics`), `arm_statements` (атрибуция по сырому тексту), React + TypeScript + antd 6. **Spec:** - `openspec/changes/quality-metrics/{proposal,design}.md` (этот репозиторий, arm-core#190) — WHY, все решения владельца 27.10.2026, подтверждено 05.11.2026. - `../docs/model/data-model-v5.25.dbml` — таблицы `quality_intake_fact`/`quality_outcome_fact`/`quality_failure_fact`, enum `quality_outcome`/`quality_cause`/`quality_failure_kind`, `s3_bucket_role.raw`, `stored_file.is_scan`, `recognition_job.raw_s3_object_id`, `permission_object.QUALITY_METRICS` — точные имена и типы столбцов, этот план их не повторяет целиком, только различия/уточнения для ORM. - `../docs/context/05-domain-rules.md` §14 — алгоритм: §14.1 какие документы, §14.2 атрибуция правки (таблица), §14.3 приоритет причины (`quality_cause`), §14.4 «документ клиента», §14.5 технические отказы, §14.6 признак скана. **Ветка:** `feat/190-ocr-metrics`, worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/ocr-metrics/arm-core`, issue RSHB/arm-core#190, Jira AURORA-1759. ## Global Constraints - ORM 1:1 с `../docs/model/data-model-v5.25.dbml`; расхождение решается в пользу DBML (`make dbml-check`). - Факты — append-only, без FK на рабочие таблицы; идентификаторы — простой `uuid`, имена ЮЛ/ГК — копия на момент факта (design §1.4-1.6 докс-репозитория). - Схема БД для фактов — настройка (`QUALITY_METRICS_SCHEMA`, по умолчанию `dkb_metrics`), не хардкод; миграция — той же цепочкой Alembic, что и `dkb`. - `domain/` — чистые функции, без `AsyncSession` и side-effects; §-номер правила — в docstring (`05-domain-rules.md §14.N`). - Сервисы не коммитят сами; факт пишется в ТОЙ ЖЕ транзакции, что и событие, которое его вызвало (маппинг, подтверждение, удаление, автозамена, обработка результата/вахтёр/публикатор) — не отдельным commit. - Факты — отдельный журнал, НЕ через `services/audit.py`/`audit_event` (design §1.4: «как `audit_event` / `ex_snapshot`» по роли, не по механизму записи). - Атрибуция правки — только два источника (`service`/`ours`); «документ клиента» — свойство исхода (`checks_accepted`), не правки (§14.2, §14.4). - Право `QUALITY_METRICS_VIEW` выдаётся в seed ВСЕМ ролям (design §8); видимость по филиалу/ГК не применяется — сервис API без `VisibilityScope`. - Только новые документы (загруженные после выкладки) получают факты — не ретроактивно (design, Non-goals; это следствие того, что факты пишутся только из хуков на новые события, без backfill-скрипта — в плане backfill действительно нет ни одной задачи). ## Review Focus - **Повторная обработка (`FILE_REPROCESS`)**: первая неудачная попытка задания получает `quality_failure_fact` на СВОЁМ `job_id`/`attempt`; новая попытка (новое `recognition_job`, `attempt+1`) получает свои intake/outcome факты независимо. Один `file_id` оказывается и в отказах, и в исходах — это ожидаемо (design «Decisions made without discussion»), а не двойной учёт. Тест на это — в Task 10 (там, где пишется `quality_failure_fact`). - **Удаление уже подтверждённого документа блокируется раньше, чем код дойдёт до записи факта** (`primary_files.delete` бросает `FILE_HAS_CONFIRMED_DOCUMENTS`, если `confirmed_by IS NOT NULL` — ручное подтверждение). Значит `DELETED_AFTER_CONFIRM` пишется ТОЛЬКО для автоподтверждённых документов (`confirmed_by IS NULL`), а вручную подтверждённые до записи факта просто не доходят (409 раньше). Это не баг плана — тест Task 8 должен проверить именно автоподтверждённый случай, а не просить вручную подтверждённый (он невозможен здесь). - **ОСВ не имеет построчного снимка** в `quality_intake_fact.payload` (нет `lines`/`values`, только счётчики и `osv_warnings`) — `domain/quality_facts.py` должен давать РАЗНУЮ форму payload для `document_type=OSV` и для `BALANCE`/`INCOME`, и тест Task 2 должен покрывать оба варианта, не только форму. - **Автозамена (`supersede_previous`) трогает ВСЕ документы старого файла**, не только те, что совпали по ключу с новым файлом (design arm-core: «Замена — файл ЦЕЛИКОМ со всеми документами в нём»). И «выпавшие» документы (`dropped_by_file`), и совпавшие по ключу (`rest`/`replaced_documents`) получают исход `SUPERSEDED`/ `SUPERSEDED_AFTER_CONFIRM` — тест Task 9 должен завести оба случая, не только выпавший. - **Единственный бакет S3**: `message.raw_s3_key` (конверт результата) — это только ключ, БЕЗ имени бакета (в отличие от `message.result_s3`, у которого есть `S3Ref{bucket,key}`). Task 11 должен явно взять имя бакета из уже зарегистрированного `s3_object` файла (`StoredFile.s3_object_id → S3Object.bucket`), а не завести вторую, возможно несовпадающую, настройку имени бакета. - **`quality_outcome_fact.document_id` НЕ unique** (docs `design.md` §1.5: переподтверждение ОСВ и `*_AFTER_CONFIRM` пишут новый факт поверх прежнего, старый не удаляется). Task 9 сама создаёт этот случай (документ уже `CONFIRMED` → автозамена пишет ВТОРОЙ факт `SUPERSEDED_AFTER_CONFIRM` для того же `document_id`). Если Task 15 выберет факты через простой `SELECT * FROM quality_outcome_fact WHERE outcome_at BETWEEN ...`, документ с двумя фактами в периоде посчитается ДВА раза в матрице — ровно сумма перестанет сходиться к 100%, которую design называет анти-целью. Task 15 ОБЯЗАН брать только последний факт на `document_id` (`DISTINCT ON (document_id) ORDER BY document_id, outcome_at DESC` или оконная функция) — и тест Task 15 должен завести документ с двумя фактами и проверить, что в матрице он посчитан один раз, по последнему `primary_cause`/`outcome`. --- ### Task 1: Схема — enum, ORM-модели, миграция 0012 **Files:** - Modify: `api/src/models/enums.py` — добавить `S3BucketRole.raw`, `PermissionObject.QUALITY_METRICS`, `QualityOutcome`, `QualityCause`, `QualityFailureKind` (значения — `../docs/model/data-model-v5.25.dbml` `Enum s3_bucket_role`/`permission_object`/`quality_outcome`/`quality_cause`/`quality_failure_kind`) - Modify: `api/src/models/files.py` — `StoredFile.is_scan: Mapped[bool | None]`, `RecognitionJob.raw_s3_object_id: Mapped[uuid.UUID | None]` (`ForeignKey("s3_object.id")`), рядом с `result_s3_object_id` - Create: `api/src/models/quality_facts.py` — `QualityIntakeFact`, `QualityOutcomeFact`, `QualityFailureFact` (точные столбцы — DBML v5.25 §1.4-1.6 docs-репозитория; `__table_args__ = {"schema": settings.QUALITY_METRICS_SCHEMA}` или аналог существующего паттерна `models.base.SCHEMA` — посмотреть, как `Base`/`UUIDPrimaryMixin` сейчас получают схему `dkb`, и завести такой же механизм для второй схемы, а не хардкодить строку) - Modify: `api/src/core/config.py` (или где объявлены остальные поля `Settings`, напр. `DB_SCHEMA`) — `QUALITY_METRICS_SCHEMA: str = "dkb_metrics"` - Create: `api/alembic/versions/0012_dbml_v5_25.py` (`revision = "0012_dbml_v5_25"`, `down_revision = "0011_dbml_v5_24"`) — создаёт схему `dkb_metrics` (`op.execute("CREATE SCHEMA IF NOT EXISTS dkb_metrics")` или аналог), три таблицы в ней, 4 новых enum, 2 новых столбца и 2 новых значения существующих enum (`s3_bucket_role`, `permission_object`) в схеме `dkb` - Modify: `CLAUDE.md` (этого репозитория) — строка миграций: `... + 0011_dbml_v5_24 + 0012_dbml_v5_25`; ссылка на DBML v5.24 → v5.25 - Test: `api/tests/test_dbml_sync.py` (или как называется тест, гоняющий `check_dbml_vs_orm.py` — найти через codegraph_explore `check_dbml_vs_orm`) **Interfaces:** - Produces: ORM-классы `QualityIntakeFact`, `QualityOutcomeFact`, `QualityFailureFact` (имена столбцов — как в DBML v5.25, snake_case); enum-классы `QualityOutcome`, `QualityCause`, `QualityFailureKind` (Python `StrEnum`, значения UPPER_SNAKE как в DBML); `StoredFile.is_scan`, `RecognitionJob.raw_s3_object_id` — используются в Task 11, 12. - [ ] **Step 1: Добавить enum в `src/models/enums.py`** Четыре новых `class ... (StrEnum)` + два новых значения в существующих `S3BucketRole`/`PermissionObject` — текст значений 1:1 с DBML (см. Files выше). - [ ] **Step 2: `StoredFile.is_scan`, `RecognitionJob.raw_s3_object_id` в `src/models/files.py`** - [ ] **Step 3: `QUALITY_METRICS_SCHEMA` в `Settings`** - [ ] **Step 4: ORM-модели `src/models/quality_facts.py`** Три класса; `QualityOutcomeFact` — без `unique` на `document_id` (Review Focus докс-плана, не повторяется здесь; см. design §1.5 докс-репозитория). - [ ] **Step 5: Ревизия Alembic 0012** Автогенерация черновиком (`alembic revision --autogenerate`), ручная доводка (создание схемы, порядок DDL) — паттерн как в `0011_dbml_v5_24.py`. - [ ] **Step 6: Прогнать цикл миграций** Run: `cd api && alembic upgrade head && alembic downgrade 0011_dbml_v5_24 && alembic upgrade head` Expected: все три шага без ошибок. - [ ] **Step 7: `make dbml-check`** Run: `make dbml-check` (из корня репозитория) Expected: код 0, без расхождений. - [ ] **Step 8: Коммит** ```bash git add api/src/models/enums.py api/src/models/files.py api/src/models/quality_facts.py api/src/core/config.py \ api/alembic/versions/0012_dbml_v5_25.py CLAUDE.md git commit -m "feat(#190): DBML v5.25 — ORM и миграция фактов качества первички" ``` --- ### Task 2: `domain/quality_facts.py` — чистые снимки payload **Files:** - Create: `api/src/domain/quality_facts.py` - Test: `api/tests/test_domain_quality_facts.py` **Interfaces:** - Consumes: `Document`, `FinancialStatementMeta/Column/Line/Value`, `DocumentIssue`, `OsvEntry` (существующие ORM, уже в памяти/результате маппинга — без запроса к сессии внутри этого модуля) - Produces: `build_intake_payload(document: Document, *, lines=None, values=None, issues=(), osv_warnings=()) -> dict[str, Any]` — форма зависит от `document.document_type` (§14.1, Review Focus докс-плана): для `BALANCE`/`INCOME` — `{"header": ..., "lines": [...], "values": [...], "issues": [...]}`; для `OSV` — `{"osv_warnings": [...]}`, без `lines`/`values`. Также `build_outcome_payload(...)`, аналогичная форма + `fixes`. - [ ] **Step 1: Написать тест формы ОБ (BALANCE)** ```python def test_build_intake_payload_statement_has_lines_and_values(): document = make_document(document_type=DocumentType.BALANCE) payload = build_intake_payload(document, lines=[...], values=[...], issues=[]) assert "lines" in payload and "values" in payload assert "osv_warnings" not in payload ``` - [ ] **Step 2: Запустить, убедиться что падает** Run: `cd api && pytest tests/test_domain_quality_facts.py::test_build_intake_payload_statement_has_lines_and_values -v` Expected: FAIL — `build_intake_payload` не существует. - [ ] **Step 3: Написать тест формы ОСВ** ```python def test_build_intake_payload_osv_has_no_lines_or_values(): document = make_document(document_type=DocumentType.OSV) payload = build_intake_payload(document, osv_warnings=["OSV_SOMETHING"]) assert "lines" not in payload and "values" not in payload assert payload["osv_warnings"] == ["OSV_SOMETHING"] ``` - [ ] **Step 4: Реализовать `build_intake_payload`/`build_outcome_payload` в `domain/quality_facts.py`** Докстринг с `§14.1`. Ветвление по `document.document_type is DocumentType.OSV`. - [ ] **Step 5: Прогнать оба теста, убедиться что проходят** Run: `cd api && pytest tests/test_domain_quality_facts.py -v` Expected: PASS. - [ ] **Step 6: Коммит** ```bash git add api/src/domain/quality_facts.py api/tests/test_domain_quality_facts.py git commit -m "feat(#190): чистые снимки payload фактов качества (§14.1)" ``` --- ### Task 3: `domain/quality_attribution.py` — атрибуция правки (§14.2) **Files:** - Create: `api/src/domain/quality_attribution.py` - Test: `api/tests/test_domain_quality_attribution.py` **Interfaces:** - Consumes: `arm_statements` публичный API — подтвердить реальные имена экспортов, запустив `python -c "import arm_statements as m; print([n for n in dir(m) if not n.startswith('_')])"` из `api/` (design называет `fix_lookalikes`, `parse_amount`, `parse_code`, `parse_date`, `default_rules` — если имена/модуль расходятся, использовать реальные и отметить расхождение как находку в ledger, не переименовывать `arm_statements`) - Produces: `attribute_value_fix(confirmed: str, raw_text: str) -> Literal["service", "ours"]`, `attribute_code_fix(confirmed_code: str, raw_text: str) -> Literal["service", "ours"]`, `attribute_added_row(code: str, raw_line_codes: Sequence[str], *, is_excel: bool) -> Literal["service", "ours"]`, `attribute_header_fix(confirmed: str, header_source_text: str) -> Literal["service", "ours"]` — все по правилу единой таблицы §14.2 docs-репозитория (копировать правило оттуда, не изобретать своё). - [ ] **Step 1: Тест — сумма, сервис прочитал верно** ```python def test_attribute_value_fix_ours_when_raw_matches_after_lookalikes(): # сырой текст "1OOO.00" (O вместо 0), подтверждено 1000.00 — цифры совпадают после fix_lookalikes → "ours" assert attribute_value_fix("1000.00", "1OOO.00") == "ours" def test_attribute_value_fix_service_when_raw_differs(): assert attribute_value_fix("1000.00", "999.00") == "service" ``` - [ ] **Step 2: Запустить, убедиться что падает** Run: `cd api && pytest tests/test_domain_quality_attribution.py -v` Expected: FAIL — функции не существуют. - [ ] **Step 3: Реализовать `attribute_value_fix`/`attribute_header_fix` в `domain/quality_attribution.py`** Докстринг `§14.2`. Использует реальную сигнатуру `arm_statements` (подтверждена в Interfaces). - [ ] **Step 4: Тест — код строки и добавленная строка (Excel всегда «ours»)** ```python def test_attribute_code_fix_service_when_code_in_raw_text(): assert attribute_code_fix("1250", "1250\n1260") == "service" def test_attribute_added_row_excel_always_ours(): assert attribute_added_row("1250", raw_line_codes=["1250"], is_excel=True) == "ours" ``` - [ ] **Step 5: Реализовать оставшиеся функции** - [ ] **Step 6: Прогнать все тесты модуля** Run: `cd api && pytest tests/test_domain_quality_attribution.py -v` Expected: PASS, все сценарии §14.2. - [ ] **Step 7: Коммит** ```bash git add api/src/domain/quality_attribution.py api/tests/test_domain_quality_attribution.py git commit -m "feat(#190): атрибуция правки сервис/наш разбор (§14.2)" ``` --- ### Task 4: `repositories/quality_facts.py` — запись фактов **Files:** - Create: `api/src/repositories/quality_facts.py` - Test: `api/tests/test_repositories_quality_facts.py` (маркер `integration` — пишет в реальную БД) **Interfaces:** - Consumes: `QualityIntakeFact`/`QualityOutcomeFact`/`QualityFailureFact` (Task 1); `build_intake_payload`/ `build_outcome_payload` (Task 2) - Produces: `async def insert_intake_fact(session: AsyncSession, **columns) -> None`, `async def insert_outcome_fact(session: AsyncSession, **columns) -> None`, `async def insert_failure_fact(session: AsyncSession, **columns) -> None` — тонкие `session.add(...)`, без `flush`/`commit` (вызывающий решает); сигнатура — **kwargs по столбцам DBML (не позиционные, чтобы вызывающие места в Task 5-10 были читаемы без заглядывания в эту сигнатуру). - [ ] **Step 1: Тест — вставка и чтение intake-факта** ```python @pytest.mark.integration async def test_insert_intake_fact_roundtrip(session): await insert_intake_fact(session, document_id=..., file_id=..., job_id=..., ..., payload={"lines": []}) await session.flush() row = await session.get(QualityIntakeFact, ...) assert row.document_id == ... ``` - [ ] **Step 2: Запустить, убедиться что падает** Run: `cd api && pytest tests/test_repositories_quality_facts.py -v -m integration` Expected: FAIL — `insert_intake_fact` не существует (нужна БД из compose, см. `make test-integration`). - [ ] **Step 3: Реализовать `insert_intake_fact`** - [ ] **Step 4: Тест и реализация `insert_outcome_fact`, `insert_failure_fact`** (тем же циклом RED→GREEN) - [ ] **Step 5: Прогнать все тесты модуля** Run: `cd api && pytest tests/test_repositories_quality_facts.py -v -m integration` Expected: PASS. - [ ] **Step 6: Коммит** ```bash git add api/src/repositories/quality_facts.py api/tests/test_repositories_quality_facts.py git commit -m "feat(#190): репозиторий записи фактов качества" ``` --- ### Task 5: Факт приёма — хук в `_map_candidate` **Files:** - Modify: `api/src/services/result_mapping.py:1097-1142` (`_map_candidate`) — после строки, строящей `documents` (текущая строка 1130: `documents = list((await session.execute(select(Document)...)).scalars())`) и ДО вызова `auto_confirm` (строка 1134), для каждого документа с `document_type in {BALANCE, INCOME, OSV}` собрать снимок (`domain/quality_facts.build_intake_payload`) и записать факт (`repositories/quality_facts.insert_intake_fact`) - Test: `api/tests/test_result_mapping.py` (дополнить существующий файл, не создавать новый — там уже тесты `_map_candidate`/`map_file`) **Interfaces:** - Consumes: `build_intake_payload` (Task 2), `insert_intake_fact` (Task 4) - Produces: ничего нового для других тасков — конечный потребитель цепочки - [ ] **Step 1: Тест — маппинг файла с формой ББ пишет intake-факт** ```python @pytest.mark.integration async def test_map_candidate_writes_intake_fact_for_statement_document(session_factory, ...): # загрузить файл с формой ББ через существующие фикстуры/фабрики (fixtures_helpers.py / factories.py) ... await _map_candidate(session_factory, candidate, result, clock) async with session_factory() as session: fact = (await session.execute(select(QualityIntakeFact))).scalar_one() assert fact.document_type == DocumentType.BALANCE ``` - [ ] **Step 2: Запустить, убедиться что падает** Run: `cd api && pytest tests/test_result_mapping.py::test_map_candidate_writes_intake_fact_for_statement_document -v -m integration` Expected: FAIL — факта нет (хук не вызван). - [ ] **Step 3: Вставить хук в `_map_candidate`** Для каждого `document in documents` с нужным типом — собрать поля (`legal_entity_id`/`group_id` — через `file.legal_entity_id` и `LegalEntity.company_group_id`, уже доступные по `owner`/`index` в этой функции; `pipeline` — по `job.pipeline`; `is_scan` — по `file.is_scan` (Task 12); счётчики — по `financial_statement_*`/ `osv_entry`, дочитанным в этой же транзакции, см. Review Focus докс-плана про форму ОСВ). - [ ] **Step 4: Тест — ОСВ-документ пишет факт без построчного снимка** ```python @pytest.mark.integration async def test_map_candidate_writes_intake_fact_for_osv_without_lines(session_factory, ...): ... assert "lines" not in fact.payload ``` - [ ] **Step 5: Прогнать весь файл тестов** Run: `cd api && pytest tests/test_result_mapping.py -v -m integration` Expected: PASS, включая прежние тесты (регрессия). - [ ] **Step 6: Коммит** ```bash git add api/src/services/result_mapping.py api/tests/test_result_mapping.py git commit -m "feat(#190): факт приёма документа при маппинге (§14.1)" ``` --- ### Task 6: Факт исхода — `AUTO_CONFIRMED` **Files:** - Modify: `api/src/services/auto_confirm.py:34-76` — после `confirmed.append(document)` (строка 75), записать `quality_outcome_fact` с `outcome=QualityOutcome.AUTO_CONFIRMED`, `primary_cause` по §14.3 (сначала нужна функция приоритета из Task 14 — ЕСЛИ Task 14 после этого, атрибут `primary_cause` можно оставить `NULL` здесь и заполнить позже пересчётом; **решение**: вычислять `primary_cause` inline по правилу п. 5 §14.3 (`AUTO_CONFIRMED` без правок ⇒ всегда `WITHOUT_PARTICIPATION`, правок при автоподтверждении не бывает — `D-3` блокирует автоподтверждение форм с нарушениями) — не нужен полный `quality_metrics.py` для этого случая) - Test: `api/tests/test_auto_confirm.py` (дополнить существующий) **Interfaces:** - Consumes: `insert_outcome_fact` (Task 4), `build_outcome_payload` (Task 2) - Produces: — - [ ] **Step 1: Тест — автоподтверждение пишет outcome-факт** ```python async def test_auto_confirm_writes_outcome_fact_without_participation(session, ...): confirmed = await auto_confirm(session, file=file, documents=[document], now=now) fact = (await session.execute(select(QualityOutcomeFact))).scalar_one() assert fact.outcome == QualityOutcome.AUTO_CONFIRMED assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION ``` - [ ] **Step 2: Запустить, убедиться что падает** Run: `cd api && pytest tests/test_auto_confirm.py::test_auto_confirm_writes_outcome_fact_without_participation -v` Expected: FAIL. - [ ] **Step 3: Вставить запись факта в `auto_confirm`** - [ ] **Step 4: Прогнать весь файл** Run: `cd api && pytest tests/test_auto_confirm.py -v` Expected: PASS. - [ ] **Step 5: Коммит** ```bash git add api/src/services/auto_confirm.py api/tests/test_auto_confirm.py git commit -m "feat(#190): факт исхода AUTO_CONFIRMED (§14.3 п.5)" ``` --- ### Task 7: Факт исхода — `CONFIRMED` (ручное подтверждение) **Files:** - Modify: `api/src/services/documents.py` (`DocumentService.confirm`, начинается строка 292; тело после строки 306 не вскрыто этим планом — исполнитель читает его целиком перед правкой) — после успешного перевода в `CONFIRMED` и ДО возврата `DocumentOut`, вычислить `primary_cause` по правкам документа (сравнить подтверждённое с `quality_intake_fact.payload` этого же `document_id` через `attribute_*` из Task 3; если правок нет → `AS_IS` (§14.3 п.6); если есть — `SERVICE_ERROR`/`OUR_ERROR` по приоритету; `checks_accepted` — из уже имеющегося механизма акцепта проверок §7, найти его через codegraph_explore (`checks_accepted_by`/`form_blocker`) — если акцепт присутствует и нарушение не закрыто правкой → `CLIENT_DOCUMENT`, см. §14.4) и записать факт - Test: `api/tests/test_api_documents.py` (дополнить) или `test_services_documents.py`, если сервис тестируется отдельно от HTTP-слоя (проверить, какой файл сейчас покрывает `confirm`, через codegraph_explore `DocumentService confirm test`) **Interfaces:** - Consumes: `attribute_value_fix`/`attribute_code_fix`/`attribute_header_fix` (Task 3), `insert_outcome_fact` (Task 4) - Produces: — - [ ] **Step 1: Тест — подтверждение без правок даёт `AS_IS`** ```python async def test_confirm_writes_outcome_fact_as_is_when_no_fixes(...): await service.confirm(document_id) fact = ... assert fact.outcome == QualityOutcome.CONFIRMED assert fact.primary_cause == QualityCause.AS_IS ``` - [ ] **Step 2: Запустить, убедиться что падает** - [ ] **Step 3: Вставить вычисление `primary_cause` и запись факта в `confirm`** - [ ] **Step 4: Тест — подтверждение с правкой суммы, найденной в сыром тексте, даёт `OUR_ERROR`** ```python async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text(...): ... assert fact.primary_cause == QualityCause.OUR_ERROR ``` - [ ] **Step 5: Прогнать весь файл тестов** Run: `cd api && pytest tests/test_api_documents.py -v` (или найденный тестовый файл сервиса) Expected: PASS. - [ ] **Step 6: Коммит** ```bash git add api/src/services/documents.py <тестовый файл> git commit -m "feat(#190): факт исхода CONFIRMED с атрибуцией правок (§14.2-14.4)" ``` --- ### Task 8: Факт исхода — `DELETED` / `DELETED_AFTER_CONFIRM` **Files:** - Modify: `api/src/services/primary_files.py:307-396` (`PrimaryFilesService.delete`) — после всех 409-проверок, перед физическим удалением строк документов (до того, как `financial_statement_*`/`osv_entry`/`document` удалятся — факт пишется по документам, которые сейчас видны под блокировкой), для каждого документа файла: `outcome = DELETED_AFTER_CONFIRM if document.status is DocumentStatus.CONFIRMED else DELETED` (Review Focus докс-плана: вручную подтверждённые сюда не доходят — 409 раньше; значит `DELETED_AFTER_CONFIRM` здесь — всегда автоподтверждённый случай, `confirmed_by IS NULL`) - Test: `api/tests/test_api_files_delete.py` (дополнить существующий) **Interfaces:** - Consumes: `insert_outcome_fact` (Task 4) - Produces: — - [ ] **Step 1: Тест — удаление неподтверждённого документа пишет `DELETED`** ```python async def test_delete_file_writes_deleted_fact_for_unconfirmed_document(...): await service.delete(file_id) fact = ... assert fact.outcome == QualityOutcome.DELETED ``` - [ ] **Step 2: Запустить, убедиться что падает** - [ ] **Step 3: Вставить запись факта в `delete`** - [ ] **Step 4: Тест — удаление файла с автоподтверждённым документом пишет `DELETED_AFTER_CONFIRM`** ```python async def test_delete_file_writes_deleted_after_confirm_for_auto_confirmed_document(...): # документ уже auto_confirm'нут (confirmed_by is None), версия не в "в работе" (иначе 409 по OQ-157) ... assert fact.outcome == QualityOutcome.DELETED_AFTER_CONFIRM ``` - [ ] **Step 5: Прогнать весь файл тестов** Run: `cd api && pytest tests/test_api_files_delete.py -v` Expected: PASS. - [ ] **Step 6: Коммит** ```bash git add api/src/services/primary_files.py api/tests/test_api_files_delete.py git commit -m "feat(#190): факт исхода DELETED/DELETED_AFTER_CONFIRM" ``` --- ### Task 9: Факт исхода — `SUPERSEDED` / `SUPERSEDED_AFTER_CONFIRM` **Files:** - Modify: `api/src/services/result_mapping.py` (внутри `supersede_previous`/`_supersede_and_warn`, окрестности строк 858-1004 — конкретно там, где уже вычисляется `documents_by_file`/`dropped_by_file`/`old_documents`, **до или после** вызовов `on_documents_superseded`, но в той же транзакции) — для каждого `old` файла из `locked` и КАЖДОГО документа в `documents_by_file[old.id]` (Review Focus докс-плана: и выпавшие, и совпавшие по ключу, весь файл целиком): `outcome = SUPERSEDED_AFTER_CONFIRM if document.status is DocumentStatus.CONFIRMED else SUPERSEDED` - Test: `api/tests/test_result_mapping.py` (дополнить; там же тестируется автозамена, искать `test_supersede`) **Interfaces:** - Consumes: `insert_outcome_fact` (Task 4) - Produces: — - [ ] **Step 1: Тест — автозамена выпавшего документа (нет совпадения по ключу) пишет `SUPERSEDED`** ```python @pytest.mark.integration async def test_supersede_previous_writes_superseded_fact_for_dropped_document(...): ... fact = ... assert fact.outcome == QualityOutcome.SUPERSEDED ``` - [ ] **Step 2: Запустить, убедиться что падает** - [ ] **Step 3: Вставить запись факта в `supersede_previous`** - [ ] **Step 4: Тест — автозамена подтверждённого документа пишет `SUPERSEDED_AFTER_CONFIRM`** ```python @pytest.mark.integration async def test_supersede_previous_writes_superseded_after_confirm_for_confirmed_document(...): ... assert fact.outcome == QualityOutcome.SUPERSEDED_AFTER_CONFIRM ``` - [ ] **Step 5: Прогнать весь файл тестов** Run: `cd api && pytest tests/test_result_mapping.py -v -m integration` Expected: PASS. - [ ] **Step 6: Коммит** ```bash git add api/src/services/result_mapping.py api/tests/test_result_mapping.py git commit -m "feat(#190): факт исхода SUPERSEDED/SUPERSEDED_AFTER_CONFIRM при автозамене" ``` --- ### Task 10: Факт технического отказа — 4 точки **Files:** - Modify: `api/src/services/recognition_exchange.py:372-403` (`expire_deadlines`) — после `update`-запроса, для каждого `job_id` из `job_ids` записать `quality_failure_fact(failure_kind=TIMEOUT)` - Modify: `api/src/services/recognition_exchange.py:229-249` (`_process_one`, ветка `dlq`) — `failure_kind=NO_ACK` - Modify: `api/src/services/recognition_exchange.py:255-273` (`_process_one`, ветка `ERROR_INVALID_REQUEST`) и `688-739` (`_apply_result`, когда `message.status is ERROR`) — оба `failure_kind=SERVICE_ERROR` (Review Focus основного design: любой переход `recognition_job.status → error` — `SERVICE_ERROR`, независимо от того, наша это ошибка валидации запроса или ошибка, о которой сообщил сервис) - Modify: `api/src/services/result_mapping.py:1038-1056` (`_fail`, вызывается при `MappingFatal`) и `1059-1093` (`_register_failure`, ветка `exhausted`) — оба `failure_kind=MAPPING` - Test: `api/tests/test_recognition_exchange.py`, `api/tests/test_result_mapping.py` (дополнить оба) **Interfaces:** - Consumes: `insert_failure_fact` (Task 4) - Produces: — - [ ] **Step 1: Тест — таймаут пишет факт отказа** ```python async def test_expire_deadlines_writes_failure_fact(session_factory, ...): await expire_deadlines(session_factory, now=...) fact = ... assert fact.failure_kind == QualityFailureKind.TIMEOUT ``` - [ ] **Step 2: Запустить, убедиться что падает** - [ ] **Step 3: Вставить запись факта в `expire_deadlines`** - [ ] **Step 4: Тест и реализация — dlq (`NO_ACK`)** (тем же циклом RED→GREEN, в `test_recognition_exchange.py`, рядом с существующими тестами `_process_one`/dlq) - [ ] **Step 5: Тест и реализация — service error (оба места, `SERVICE_ERROR`)** - [ ] **Step 6: Тест и реализация — mapping (оба места, `MAPPING`)** ```python async def test_fail_writes_mapping_failure_fact(...): await _fail(session, candidate, "описание", now) fact = ... assert fact.failure_kind == QualityFailureKind.MAPPING ``` - [ ] **Step 7: Тест — повторная обработка (FILE_REPROCESS) не теряет факт первой неудачной попытки** ```python @pytest.mark.integration async def test_file_reprocess_keeps_failure_fact_of_first_attempt_and_adds_new_intake(...): # первая попытка -> dlq/timeout -> failure_fact(attempt=1); FILE_REPROCESS -> job attempt=2 -> успех -> intake_fact(attempt=2) facts = (await session.execute(select(QualityFailureFact))).scalars().all() assert len(facts) == 1 and facts[0].attempt == 1 intake = (await session.execute(select(QualityIntakeFact))).scalar_one() assert intake.attempt == 2 ``` - [ ] **Step 8: Прогнать оба файла тестов** Run: `cd api && pytest tests/test_recognition_exchange.py tests/test_result_mapping.py -v -m integration` Expected: PASS. - [ ] **Step 9: Коммит** ```bash git add api/src/services/recognition_exchange.py api/src/services/result_mapping.py \ api/tests/test_recognition_exchange.py api/tests/test_result_mapping.py git commit -m "feat(#190): факт технического отказа (TIMEOUT/NO_ACK/SERVICE_ERROR/MAPPING, §14.5)" ``` --- ### Task 11: Регистрация сырого ответа ИС (`s3_bucket_role.raw`) **Files:** - Modify: `api/src/services/recognition_exchange.py:653-685` (рядом с `_register_result_object`) — добавить `_register_raw_object(session, job: RecognitionJob, raw_s3_key: str, now: datetime) -> uuid.UUID`, тот же паттерн `pg_insert(...).on_conflict_do_nothing(...)`, но `bucket_role=S3BucketRole.raw`; имя бакета — ИЗ УЖЕ ЗАРЕГИСТРИРОВАННОГО объекта файла (Review Focus): `select(S3Object.bucket).join(StoredFile, StoredFile.s3_object_id == S3Object.id).where(StoredFile.id == job.file_id)`, НЕ из `message.result_s3` (может отсутствовать при статусе `error`) - Modify: `api/src/services/recognition_exchange.py:712` (`_apply_result`) — после строки `result_s3_object_id = await _register_result_object(...)`, если `message.raw_s3_key is not None`: `job.raw_s3_object_id = await _register_raw_object(session, job, message.raw_s3_key, now)` - Modify: `api/src/contract/message.py:55` — убрать из комментария поля `raw_s3_key` фразу «в БД не регистрируется» (решение отменено v5.25) — только комментарий, тип поля не меняется (контракт не версионируется этим change, поле уже было в схеме) - Test: `api/tests/test_recognition_exchange.py` (дополнить; там же тестируется `_apply_result`/`_register_result_object`) **Interfaces:** - Produces: `_register_raw_object` используется только внутри этого модуля (не экспортируется в другие таски) - [ ] **Step 1: Тест — результат с `raw_s3_key` регистрирует объект и ссылку на задании** ```python @pytest.mark.integration async def test_apply_result_registers_raw_object_and_sets_job_reference(session, ...): message = make_result_message(raw_s3_key="raw/.json", ...) await _apply_message(session, message, None, lambda: now) job = await session.get(RecognitionJob, message.job_id) assert job.raw_s3_object_id is not None raw_object = await session.get(S3Object, job.raw_s3_object_id) assert raw_object.bucket_role == S3BucketRole.raw assert raw_object.bucket == <тот же бакет, что у stored_file файла> ``` - [ ] **Step 2: Запустить, убедиться что падает** Run: `cd api && pytest tests/test_recognition_exchange.py::test_apply_result_registers_raw_object_and_sets_job_reference -v -m integration` Expected: FAIL — `job.raw_s3_object_id` остаётся `None`. - [ ] **Step 3: Реализовать `_register_raw_object` и вызов из `_apply_result`** - [ ] **Step 4: Тест — результат без `raw_s3_key` (Excel) не создаёт raw-объект** ```python @pytest.mark.integration async def test_apply_result_without_raw_s3_key_leaves_raw_object_id_none(...): ... assert job.raw_s3_object_id is None ``` - [ ] **Step 5: Прогнать весь файл** Run: `cd api && pytest tests/test_recognition_exchange.py -v -m integration` Expected: PASS. - [ ] **Step 6: Коммит** ```bash git add api/src/services/recognition_exchange.py api/src/contract/message.py api/tests/test_recognition_exchange.py git commit -m "feat(#190): регистрация сырого ответа ИС Сканирование (s3_bucket_role.raw)" ``` --- ### Task 12: `stored_file.is_scan` при загрузке **Files:** - Modify: `api/requirements.txt` — добавить `pypdf` (пин — реальная версия после установки, см. Step 1) - Modify: `api/src/services/primary_files.py` (внутри `_store`/`upload_one`, строки 397-424 и далее — найти, где создаётся `StoredFile(...)`, через codegraph_explore `_store PrimaryFilesService` если не видно в уже прочитанном диапазоне) — для `detected.file_type is FileType.PDF`: `is_scan = not has_text_layer(content)` (ВНИМАНИЕ — направление: `../docs/model/data-model-v5.25.dbml` `stored_file.is_scan` — **`true` = скан, нет текстового слоя; `false` = цифровой, в т. ч. скрытый OCR-слой** (docs-ревью 05.11.2026 исправило обратную формулировку); для остальных типов файла — `is_scan = None` - Create: `api/src/domain/pdf_text_layer.py` — чистая функция `has_text_layer(content: bytes) -> bool` (битый/ зашифрованный PDF — `False`, не исключение: при загрузке не должно падать на мусорном файле) - Test: `api/tests/test_domain_pdf_text_layer.py`, дополнить `api/tests/test_api_files_upload.py` **Interfaces:** - Produces: `has_text_layer(content: bytes) -> bool` - [ ] **Step 1: Установить `pypdf` и зафиксировать версию** Run: `cd api && uv pip install --python .venv/bin/python pypdf && uv pip freeze --python .venv/bin/python | grep -i pypdf` Expected: строка `pypdf==X.Y.Z` — вписать её в `requirements.txt` в алфавитном месте. - [ ] **Step 2: Тест — PDF с текстовым слоем** ```python def test_has_text_layer_true_for_digital_pdf(): # маленький PDF с текстом, сгенерированный в тесте (напр. reportlab, если уже зависимость — проверить; иначе # зафиксировать как тестовую фикстуру bytes из `fixtures/` или сконструировать минимальный PDF вручную) assert has_text_layer(digital_pdf_bytes) is True ``` - [ ] **Step 3: Запустить, убедиться что падает** - [ ] **Step 4: Тест — скан без текстового слоя и битый файл** ```python def test_has_text_layer_false_for_scanned_pdf(): assert has_text_layer(scanned_pdf_bytes) is False def test_has_text_layer_false_for_corrupt_pdf_without_raising(): assert has_text_layer(b"not a pdf") is False ``` - [ ] **Step 5: Реализовать `has_text_layer`** `pypdf.PdfReader(io.BytesIO(content))`, по страницам `page.extract_text()`; любое исключение библиотеки → `False` (не пропускать наружу — решение по аналогии с «битый PDF» из существующего `detect_file`, docstring с §14.6). - [ ] **Step 6: Прогнать тесты модуля** Run: `cd api && pytest tests/test_domain_pdf_text_layer.py -v` Expected: PASS. - [ ] **Step 7: Тест — загрузка PDF проставляет `is_scan` (направление — true = скан)** ```python async def test_upload_scanned_pdf_sets_is_scan_true(...): file = await service.upload_one(entity, "scan.pdf", scanned_pdf_bytes) # без текстового слоя stored = await session.get(StoredFile, file.id) assert stored.is_scan is True async def test_upload_digital_pdf_sets_is_scan_false(...): file = await service.upload_one(entity, "digital.pdf", digital_pdf_bytes) # с текстовым слоем stored = await session.get(StoredFile, file.id) assert stored.is_scan is False ``` - [ ] **Step 8: Вставить `is_scan = not has_text_layer(content)` в `_store`/`upload_one` (не `has_text_layer(content)` напрямую — направление инвертировано, см. Files выше)** - [ ] **Step 9: Прогнать файл загрузки** Run: `cd api && pytest tests/test_api_files_upload.py -v` Expected: PASS. - [ ] **Step 10: Коммит** ```bash git add api/requirements.txt api/src/domain/pdf_text_layer.py api/src/services/primary_files.py \ api/tests/test_domain_pdf_text_layer.py api/tests/test_api_files_upload.py git commit -m "feat(#190): признак скан/цифровой PDF при загрузке (§14.6)" ``` --- ### Task 13: Право `QUALITY_METRICS_VIEW` **Files:** - Modify: `api/seed/core/permissions.yaml` — новая запись `code: QUALITY_METRICS_VIEW, object: QUALITY_METRICS, action: VIEW, name: ..., description: ...` - Modify: `api/seed/core/role_permissions.yaml` — добавить `QUALITY_METRICS_VIEW` в список КАЖДОЙ роли (design §8: «сейчас всем ролям») - Modify: `web/src/api/types.ts:6` — `PermissionObject` union: добавить `'QUALITY_METRICS'` - Test: `api/tests/test_seed_chart.py` (или тест, валидирующий seed — найти через codegraph_explore `test_seed` permissions), прогон `make seed` - [ ] **Step 1: Тест — seed загружает новое право и даёт его всем ролям** ```python async def test_seed_grants_quality_metrics_view_to_all_roles(session): await run_seed(session) role_count = await session.scalar(select(func.count(Role.id))) granted = await session.scalar( select(func.count(RolePermission.role_id)) .join(Permission, Permission.id == RolePermission.permission_id) .where(Permission.code == "QUALITY_METRICS_VIEW") ) assert granted == role_count ``` - [ ] **Step 2: Запустить, убедиться что падает** Run: `cd api && pytest tests/test_seed_chart.py::test_seed_grants_quality_metrics_view_to_all_roles -v` Expected: FAIL — права нет в seed. - [ ] **Step 3: Добавить запись в `permissions.yaml` и `role_permissions.yaml`** - [ ] **Step 4: Прогнать тест** Run: `cd api && pytest tests/test_seed_chart.py -v` Expected: PASS. - [ ] **Step 5: Обновить `web/src/api/types.ts`** - [ ] **Step 6: Коммит** ```bash git add api/seed/core/permissions.yaml api/seed/core/role_permissions.yaml web/src/api/types.ts \ api/tests/test_seed_chart.py git commit -m "feat(#190): право QUALITY_METRICS_VIEW всем ролям (design §8)" ``` --- ### Task 14: `domain/quality_metrics.py` — матрица причин **Files:** - Create: `api/src/domain/quality_metrics.py` - Test: `api/tests/test_domain_quality_metrics.py` **Interfaces:** - Consumes: списки `QualityIntakeFact`/`QualityOutcomeFact`/`QualityFailureFact` — переданные как простые объекты/dict (функция НЕ читает сессию; вызывающий сервис, Task 15, делает SELECT и передаёт сюда результат) - Produces: `compute_matrix(outcomes: Sequence[QualityOutcomeFactRow]) -> MatrixResult` (имя входного типа и форма `MatrixResult` — на решение исполнителя; обязательно: функция реализует приоритет §14.3 п.1-6 и возвращает знаменатель только по `outcome ∈ {AUTO_CONFIRMED, CONFIRMED}`, пункт «Технические отказы считаются по файлам, отдельной долей» — Task 15 считает их отдельным запросом, не через эту функцию), `compute_monthly_series(...)`. - [ ] **Step 1: Тест — приоритет причины (сервис > наш разбор > документ клиента > без участия > как есть)** ```python def test_compute_matrix_priority_service_over_our_error(): # документ с primary_cause уже SERVICE_ERROR в факте — матрица не пересчитывает атрибуцию, только группирует outcomes = [make_outcome_row(primary_cause=QualityCause.SERVICE_ERROR), make_outcome_row(primary_cause=QualityCause.OUR_ERROR)] matrix = compute_matrix(outcomes) assert matrix.counts[QualityCause.SERVICE_ERROR] == 1 assert matrix.counts[QualityCause.OUR_ERROR] == 1 ``` - [ ] **Step 2: Запустить, убедиться что падает** - [ ] **Step 3: Реализовать `compute_matrix`** Докстринг `§14.3`. Группировка по уже сохранённому `primary_cause` (он вычислен один раз при записи факта, Tasks 6-9 — эта функция НЕ перевычисляет атрибуцию, только агрегирует и считает доли/знаменатель). - [ ] **Step 4: Тест — знаменатель долей только AUTO_CONFIRMED/CONFIRMED, «в работе» не входит** ```python def test_compute_matrix_denominator_excludes_in_progress_documents(): # документ без outcome-факта не передаётся в compute_matrix вовсе (Task 15 его не выбирает) — эта функция # получает только исходы с фактом; тест проверяет, что знаменатель = len(outcomes), не включает отдельно ... ``` - [ ] **Step 5: Прогнать тесты модуля** Run: `cd api && pytest tests/test_domain_quality_metrics.py -v` Expected: PASS. - [ ] **Step 6: Коммит** ```bash git add api/src/domain/quality_metrics.py api/tests/test_domain_quality_metrics.py git commit -m "feat(#190): матрица причин — чистая функция (§14.3)" ``` --- ### Task 15: API `/api/v1/quality-metrics` **Files:** - Create: `api/src/services/quality_metrics_service.py` — `QualityMetricsService` (матрица за период/фильтры, ряд по месяцам, список документов ячейки, выгрузка) — паттерн как `api/src/api/endpoints/consolidations/ corrections.py` + сервис-слой: без `VisibilityScope` (design §8 — видимость по филиалу не применяется) - Create: `api/src/api/dependencies/quality_metrics.py` — `QualityMetricsServiceDep` (паттерн — любой существующий `*ServiceDep` в `src/api/dependencies/`, напр. `consolidations.py`) - Create: `api/src/api/endpoints/quality_metrics.py` — router `/quality-metrics`: - `GET /quality-metrics/matrix` (query: период, ГК, тип документа, скан/цифровой — design §7) - `GET /quality-metrics/series` (ряд по месяцам по выбранной причине) - `GET /quality-metrics/cells/{cause}/documents` (список документов ячейки, с пагинацией) - `GET /quality-metrics/export` (CSV/JSON фактов за период) - все под `Depends(require("QUALITY_METRICS_VIEW"))` - Create: `api/src/schemas/quality_metrics.py` — `QualityMatrixOut`, `QualityMonthlySeriesOut`, `QualityCellDocumentOut` (поля — 1:1 с design §7; деньги здесь не нужны — только счётчики) - Modify: `api/src/app.py` (или где регистрируются роутеры — найти через codegraph_explore `app.include_router`) — подключить новый router - Test: `api/tests/test_api_quality_metrics.py` **Interfaces:** - Consumes: `compute_matrix`/`compute_monthly_series` (Task 14), ORM-модели (Task 1) - [ ] **Step 1: Тест — матрица без права 403** ```python async def test_quality_metrics_matrix_requires_permission(client_without_permission): response = await client_without_permission.get("/api/v1/quality-metrics/matrix") assert response.status_code == 403 ``` - [ ] **Step 2: Запустить, убедиться что падает** Run: `cd api && pytest tests/test_api_quality_metrics.py -v` Expected: FAIL — маршрута нет (404, не 403). - [ ] **Step 3: Реализовать схемы, сервис, роутер, подключить в `app.py`** - [ ] **Step 4: Тест — матрица с данными** ```python async def test_quality_metrics_matrix_returns_counts(client, ...): # создать outcome-факты напрямую через insert_outcome_fact (Task 4), без полного цикла маппинга response = await client.get("/api/v1/quality-metrics/matrix") assert response.status_code == 200 assert response.json()["counts"]["SERVICE_ERROR"] == 1 ``` - [ ] **Step 5: Тест — документ с двумя outcome-фактами считается один раз, по последнему** ```python async def test_quality_metrics_matrix_dedupes_to_latest_outcome_fact(client, session, ...): # один document_id: факт 1 outcome=CONFIRMED primary_cause=AS_IS (outcome_at=t1), # факт 2 outcome=SUPERSEDED_AFTER_CONFIRM primary_cause=NULL (outcome_at=t2 > t1) — как пишет Task 9 await insert_outcome_fact(session, document_id=doc_id, outcome=QualityOutcome.CONFIRMED, primary_cause=QualityCause.AS_IS, outcome_at=t1, ...) await insert_outcome_fact(session, document_id=doc_id, outcome=QualityOutcome.SUPERSEDED_AFTER_CONFIRM, primary_cause=None, outcome_at=t2, ...) await session.commit() response = await client.get("/api/v1/quality-metrics/matrix") body = response.json() assert body["counts"].get("AS_IS", 0) == 0 # устаревший факт не виден assert body["superseded_after_confirm_count"] == 1 # последний факт — в отдельном столбце, не в матрице ``` - [ ] **Step 6: Запустить тест Step 5, убедиться что падает без дедупликации, затем реализовать выборку последнего факта на `document_id` (`DISTINCT ON`/оконная функция, Review Focus) в `quality_metrics_service.py` — во ВСЕХ запросах сервиса (matrix, series, cells/documents, export), не только в одном** Run: `cd api && pytest tests/test_api_quality_metrics.py::test_quality_metrics_matrix_dedupes_to_latest_outcome_fact -v` Expected: сначала FAIL (наивный `SELECT` считает факт 1 тоже), после реализации — PASS. - [ ] **Step 7: Тест — список документов ячейки и выгрузка** - [ ] **Step 8: Прогнать весь файл** Run: `cd api && pytest tests/test_api_quality_metrics.py -v` Expected: PASS. - [ ] **Step 9: Коммит** ```bash git add api/src/services/quality_metrics_service.py api/src/api/dependencies/quality_metrics.py \ api/src/api/endpoints/quality_metrics.py api/src/schemas/quality_metrics.py api/src/app.py \ api/tests/test_api_quality_metrics.py git commit -m "feat(#190): API /quality-metrics — матрица, ряд по месяцам, список, выгрузка" ``` --- ### Task 16: Экран «Качество первички» **Files:** - Create: `web/src/types/qualityMetrics.ts` — 1:1 со схемами Task 15 (комментарий-подсказка как в `web/src/types/corrections.ts:1`: «1:1 с components.schemas» — здесь аналогично с `schemas/quality_metrics.py`) - Create: `web/src/services/api/qualityMetrics.ts` — функции `getMatrix`, `getSeries`, `getCellDocuments`, `getExportUrl`, паттерн — любой существующий `web/src/services/api/*.ts` - Create: `web/src/widgets/QualityMetrics/QualityMetricsPage.tsx` — страница: фильтры + матрица + динамика по месяцам (раскладка — на решение исполнителя, design §7: «при реализации», владелец сказал «главное наличие данных» — не гнаться за точным макетом, его нет) - Create: `web/src/widgets/QualityMetrics/MatrixTable.tsx` — проверить, подходит ли `web/src/components/GenericTable/` для 2D-матрицы с drill-down (строка = причина × источник, ячейка = count + переход в список документов); если не подходит по форме (не табличный список строк, а именно сводная матрица) — обычная antd `Table` вручную, как в `OsvTable.tsx`, не вписывать силой в `GenericTable` - Create: `web/src/widgets/QualityMetrics/MonthlyTrend.tsx` — динамика по месяцам (линейный/столбчатый график — посмотреть, какая либа графиков уже есть в `web/package.json`; если никакой нет — простая таблица «месяц → счётчики» без новой зависимости, design не требует именно графика) - Create: `web/src/widgets/QualityMetrics/DocumentDrillList.tsx` — список документов ячейки, переход в карточку документа (уже существующий экран, найти его маршрут через codegraph_explore `DocumentReview route`) - Modify: роутинг (найти файл регистрации маршрутов `/admin`-подобных страниц через codegraph_explore `react-router routes App.tsx`) — новый маршрут `/quality-metrics`, в пункте меню — видимость по праву `QUALITY_METRICS_VIEW` (см. `web/src/auth/permissions.ts`, паттерн как у существующих пунктов) - Test: `web/src/widgets/QualityMetrics/*.test.ts(x)` — минимум: матрица рендерит счётчики из фикстуры ответа API, пункт меню скрыт без права - [ ] **Step 1: Тест — пункт меню скрыт без права `QUALITY_METRICS_VIEW`** ```typescript test('menu item hidden without QUALITY_METRICS_VIEW permission', () => { // паттерн — существующий тест видимости пункта меню по праву, найти через grep `permissions.ts` тестов в web/ }); ``` - [ ] **Step 2: Запустить, убедиться что падает** Run: `cd web && npm test -- qualityMetrics` Expected: FAIL — пункта меню/права ещё нет. - [ ] **Step 3: Добавить типы, API-клиент, пункт меню с проверкой права** - [ ] **Step 4: Тест — `MatrixTable` рендерит счётчики из фикстуры** ```typescript test('MatrixTable renders counts from fixture response', () => { render(); expect(screen.getByText('12')).toBeInTheDocument(); }); ``` - [ ] **Step 5: Реализовать `MatrixTable`, `MonthlyTrend`, `DocumentDrillList`, `QualityMetricsPage`, маршрут** - [ ] **Step 6: Прогнать тесты и типы/сборку** Run: `cd web && npm test && npx tsc --noEmit && npm run build` Expected: тесты зелёные, `tsc`/`build` без ошибок. - [ ] **Step 7: Проверить в браузере (dev-стенд) golden path** Открыть `/quality-metrics` под пользователем с правом — матрица отображает данные; без права — пункт меню и маршрут недоступны (ручная проверка перед PR, см. `../docs/dev/qa-guide.md`). - [ ] **Step 8: Коммит** ```bash git add web/src/types/qualityMetrics.ts web/src/services/api/qualityMetrics.ts web/src/widgets/QualityMetrics/ \ web/src/auth/permissions.ts <файл роутинга> git commit -m "feat(#190): экран «Качество первички» (web)" ``` --- ## Self-Review (выполнен при написании плана) **Spec coverage:** design §4.1-4.3/§1.4-1.6 docs → Task 1-2; §5/§14.2 → Task 3; §6/§14.3 → Task 14; §14.4 → Task 7; §14.5 → Task 10; §9/raw → Task 11; §9/is_scan, §14.6 → Task 12; §8 → Task 13; §7 → Task 15-16. Non-goals (ретро, время аналитика, счётчик «не разбирается», эталонный прогон, видимость по филиалу) — ни одной задачи, это корректно (они не реализуются). **Type consistency:** `QualityOutcome`/`QualityCause`/`QualityFailureKind` объявлены в Task 1, используются без переименования в Tasks 5-11, 14-15. `insert_intake_fact`/`insert_outcome_fact`/`insert_failure_fact` (Task 4) — везде вызываются по этим именам в Tasks 5-11. **Proportion:** 16 задач на фичу с тремя новыми таблицами, 6 точками записи факта, API и экраном — соразмерно объёму design.md (225 строк) + §14 (75 строк); самые длинные задачи (5, 9, 10, 15, 16) имеют по 4-7 файлов каждая, что отражает реальное количество точек интеграции, а не раздувание. Плана недостаточно для самостоятельной работы без чтения design.md/§14 — он на это и не рассчитан (Spec travels with the plan); экзекьютор каждой задачи должен сначала прочитать соответствующий параграф §14, затем бриф задачи.