64 KiB
Метрики качества первички (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, enumquality_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 (docsdesign.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.dbmlEnum 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_explorecheck_dbml_vs_orm)
Interfaces:
-
Produces: ORM-классы
QualityIntakeFact,QualityOutcomeFact,QualityFailureFact(имена столбцов — как в DBML v5.25, snake_case); enum-классыQualityOutcome,QualityCause,QualityFailureKind(PythonStrEnum, значения 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_causeinline по правилу п. 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_exploreDocumentService 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.dbmlstored_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—PermissionObjectunion: добавить'QUALITY_METRICS' -
Test:
api/tests/test_seed_chart.py(или тест, валидирующий seed — найти через codegraph_exploretest_seedpermissions), прогон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_exploreapp.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 + переход в список документов); если не подходит по форме (не табличный список строк, а именно сводная матрица) — обычная antdTableвручную, как вOsvTable.tsx, не вписывать силой вGenericTable -
Create:
web/src/widgets/QualityMetrics/MonthlyTrend.tsx— динамика по месяцам (линейный/столбчатый график — посмотреть, какая либа графиков уже есть вweb/package.json; если никакой нет — простая таблица «месяц → счётчики» без новой зависимости, design не требует именно графика) -
Create:
web/src/widgets/QualityMetrics/DocumentDrillList.tsx— список документов ячейки, переход в карточку документа (уже существующий экран, найти его маршрут через codegraph_exploreDocumentReview route) -
Modify: роутинг (найти файл регистрации маршрутов
/admin-подобных страниц через codegraph_explorereact-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, затем бриф задачи.