arm-core/docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md
Andrey Epifancev 251c30d5ca feat(#190): DBML v5.25 — ORM и миграция фактов качества первички
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-05 15:49:15 +04:00

64 KiB
Raw Blame History

Метрики качества первички (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: Коммит
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)

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: Написать тест формы ОСВ
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: Коммит
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: Тест — сумма, сервис прочитал верно

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»)
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: Коммит
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-факта

@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: Коммит
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-факт

@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: Тест — ОСВ-документ пишет факт без построчного снимка
@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: Коммит
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-факт

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: Коммит
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

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

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: Коммит
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

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

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: Коммит
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

@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

@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: Коммит
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: Тест — таймаут пишет факт отказа

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)

async def test_fail_writes_mapping_failure_fact(...):
    await _fail(session, candidate, "описание", now)
    fact = ...
    assert fact.failure_kind == QualityFailureKind.MAPPING
  • Step 7: Тест — повторная обработка (FILE_REPROCESS) не теряет факт первой неудачной попытки
@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: Коммит
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 регистрирует объект и ссылку на задании

@pytest.mark.integration
async def test_apply_result_registers_raw_object_and_sets_job_reference(session, ...):
    message = make_result_message(raw_s3_key="raw/<job_id>.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-объект

@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: Коммит
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 с текстовым слоем
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: Тест — скан без текстового слоя и битый файл

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 = скан)
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: Коммит
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 загружает новое право и даёт его всем ролям

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: Коммит

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: Тест — приоритет причины (сервис > наш разбор > документ клиента > без участия > как есть)

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, «в работе» не входит
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: Коммит
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

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: Тест — матрица с данными

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-фактами считается один раз, по последнему
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: Коммит
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

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 рендерит счётчики из фикстуры

test('MatrixTable renders counts from fixture response', () => {
	render(<MatrixTable data={matrixFixture} />);
	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: Коммит
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, затем бриф задачи.