From 9d2c44940a88801b442edeac518c3fd1149e891d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 15:06:10 +0400 Subject: [PATCH 01/44] =?UTF-8?q?docs(#190):=20=D0=B4=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B8=D1=87=D0=BA=D0=B8=20(AURORA-1759),=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=20=D0=B4=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D1=86=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/quality-metrics/.openspec.yaml | 2 + openspec/changes/quality-metrics/design.md | 224 ++++++++++++++++++ openspec/changes/quality-metrics/proposal.md | 50 ++++ 3 files changed, 276 insertions(+) create mode 100644 openspec/changes/quality-metrics/.openspec.yaml create mode 100644 openspec/changes/quality-metrics/design.md create mode 100644 openspec/changes/quality-metrics/proposal.md diff --git a/openspec/changes/quality-metrics/.openspec.yaml b/openspec/changes/quality-metrics/.openspec.yaml new file mode 100644 index 0000000..97e3642 --- /dev/null +++ b/openspec/changes/quality-metrics/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-27 diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md new file mode 100644 index 0000000..5aa154f --- /dev/null +++ b/openspec/changes/quality-metrics/design.md @@ -0,0 +1,224 @@ +# Дизайн: метрики качества первички (AURORA-1759) + +## 1. Зачем и для кого + +**Заказчик** (пользователи АРМ в банке) сам открывает экран в веб-интерфейсе и видит, какую работу снял АРМ: доля +документов, прошедших без участия аналитика или подтверждённых без правок, доля правленых, технические отказы — за +период, по ГК, по типу документа и по источнику. Цифры подтверждают эффект инициативы. + +**Владелец проекта** использует те же цифры как аргумент о качестве сервиса разбора PDF (ИС Сканирование): если они +плохие — довод за переход на другой сервис. Поэтому каждая ошибка относится к источнику: **сервис разбора**, **наш +разбор** (нормализация `arm_statements`, маппинг ядра, tabular-parser) или **документ клиента** (контрольные цифры не +сходятся в оригинале). Без такого разделения цифра «30 % правят» против сервиса не работает. + +Позже метрики переедут в отдельное решение на платформе банка (там точно есть Superset, хранилище неизвестно) — перенос +не должен требовать доработки ядра. + +## 2. Цели, не-цели, анти-цели + +**Цели** +- Факты качества по каждому документу ББ / ОФР / ОСВ, загруженному после выкладки, и по каждому техническому отказу. +- Отнесение каждой правки аналитика к источнику ошибки по сырому тексту сервиса. +- Экран: матрица «источник × причина» за период + динамика по месяцам; переход из ячейки в список документов и далее + в документ (правки, комментарии, сам файл). +- Факты пригодны для Superset как есть и для переноса в другое хранилище. +- Сохранить вход для будущего эталонного прогона: сырые ответы ИС и исходные xlsx, подтверждённый результат. + +**Не-цели** — см. `proposal.md`, раздел Non-goals (в т. ч. ретро-метрики, время аналитика, видимость по филиалам, сам +эталонный прогон). + +**Анти-цели** (провал, даже если работает) +- Цифры, которые нельзя проверить: каждая ячейка матрицы раскрывается до конкретных документов. +- Вина сервиса там, где ошиблись мы (или наоборот): правило атрибуции — механическое и записанное (§5), спорные случаи + уходят в «наш разбор», а не в «сервис». +- Лишняя работа аналитика: никаких отметок, анкет и кнопок ради метрик. +- Метрики, которые меняются задним числом: факт записан — не переписывается (append-only), поздние события — новые + факты. +- Связка с рабочими таблицами, из-за которой перенос на платформу требует переписать ядро. + +## 3. Ограничения + +- Инварианты `../CLAUDE.md`: первичка не мутируется; единственный писатель в БД — arm-core; производные не хранятся. + Факты — не производные консолидации, а журнал событий (как `audit_event` / `ex_snapshot`); метрики — чистая функция + от фактов, не хранятся. +- Поля — только из DBML: таблицы фактов, роль бакета, признак скана, объект права появляются в DBML v5.24 (change в + `../docs`) до кода. +- Нормализацию ББ/ОФР делают сервисы; в ядро приходит нормализованный `result.json`. «Сырой текст сервиса» в ядре — это + `financial_statement_value.source_text`, `financial_statement_line.line_code_text`, + `financial_statement_meta.header_source`, `osv_entry.row_value` (§11.5: `text` — всегда сырой текст источника). + Сырой JSON ИС лежит в S3 `raw/{job_id}.json`, в БД не зарегистрирован. +- Пересчёт после правки перезаписывает `needs_review`, `line_status`, статус документа и `document_issue` + (`relation`/`soft`) — поэтому состояние «как распознано» фиксируется фактом в момент приёма, до любых правок. +- Удаление файла физически удаляет строки документа (`primary_files.delete`) — факты не ссылаются на них FK. +- Сейчас у заказчика все работают под одной учётной записью (до РАИСА/SSO, AURORA-1404). + +## 4. Факты + +Отдельная схема PostgreSQL (имя — настройка, по умолчанию `dkb_metrics`), миграции — общей цепочкой Alembic. Таблицы +плоские: измерения — обычные столбцы, подробности — `jsonb` с версией схемы (`schema_version`). Без FK на рабочие +таблицы; идентификаторы документов, файлов, заданий, ЮЛ, ГК — просто `uuid`, имена ЮЛ и ГК копируются на момент факта. +Контракт столбцов и `payload` — `../docs/contracts/quality-facts.schema.json`. Записи только добавляются. + +### 4.1 `quality_intake_fact` — документ «как распознано» + +Пишется в транзакции маппинга (`result_mapping._map_candidate`, после `map_file`, **до** `auto_confirm`), по одному на +созданный документ ББ / ОФР / ОСВ (`DISCLOSURE` / `OTHER` — не пишутся). + +Измерения: `document_id`, `file_id`, `job_id`, `attempt`, `legal_entity_id` + имя, `group_id` + имя (ГК ЮЛ на момент +факта; ЮЛ вне ГК — пусто), `document_type`, `source_kind` (`pdf` / `excel`), `is_scan` (только PDF), `service_name`, +`service_version`, `statements_version` (ББ/ОФР), `result_schema_version`, `report_date` (как распознано), +`intake_at`, `intake_status` (статус документа после маппинга: `RECOGNIZED` / `NEEDS_CHECK`), счётчики: строк, +значений, значений с `needs_review`, строк `unreadable` / `missing`, нарушений `relation` / `soft`, предупреждений +сервиса; `raw_s3_key` (PDF), `result_s3_key`, `upload_s3_key`. + +`payload`: снимок «как распознано» — шапка (`header_source`, распознанные поля), строки (`ref`, `row_index`, +`line_code`, `line_code_text`, `line_status`, `needs_review`), значения (`line ref`, колонка, `amount`, `string_value`, +`source_text`, `confidence`, `is_recognized`, `needs_review`), нарушения и коды предупреждений. Для ОСВ — счётчики +строк и коды предупреждений `OSV_*`, без построчного снимка (правок ОСВ по суммам в ядре нет). + +### 4.2 `quality_outcome_fact` — исход документа + +Один на документ, пишется при первом из событий: +- **подтверждение** — `documents.confirm` (вручную) и `auto_confirm` (автоматически), в той же транзакции; +- **удаление до подтверждения** — `primary_files.delete`; +- **замена** — автозамена `FILE_REPLACE` (документ вытеснен новым файлом). + +Измерения: `document_id` (связь с 4.1), `outcome` (`AUTO_CONFIRMED` / `CONFIRMED` / `DELETED` / `SUPERSEDED`), +`outcome_at`, `primary_cause` (§6), счётчики правок по источнику (§5): значения, коды, добавленные строки, шапка — +`service` / `ours` / `client`; `checks_accepted` (подтверждено с акцептом нарушенных проверок), число нарушений на +момент исхода, `report_date` подтверждённый, `confirmed_by` (пользователь; для агрегатов не используется). + +`payload`: подтверждённый результат (то же строение, что снимок приёма) и список правок с отнесением — каждая правка: +что, было (сырой текст / распознано), стало, источник, правило §5, которое сработало. + +Подтверждённую ОСВ можно править (правка сбрасывает подтверждение) — повторное подтверждение пишет **новый** факт +исхода; метрики берут последний. + +Удаление файла с уже подтверждённым документом (автоподтверждённые при версии «в работе», OQ-157) и автозамена +подтверждённого документа пишут **новый** факт `DELETED_AFTER_CONFIRM` / `SUPERSEDED_AFTER_CONFIRM` с исходом, который +был до него. Метрики берут последний факт: такой документ уходит из своей ячейки в отдельный столбец «удалён / заменён +после подтверждения» (решение владельца 27.10.2026) — в доли не входит, но виден, и из него раскрывается список с +прежним исходом. + +### 4.3 `quality_failure_fact` — технический отказ + +Пишется при переходе задания распознавания в `error` / `timeout` / `dlq` (`recognition_exchange`) и при неповторяемой +ошибке маппинга (`MappingFatal`, файл → `ERROR`). Измерения: `job_id`, `file_id`, `attempt`, `legal_entity_id`, +`group_id`, `source_kind`, `is_scan`, `service_name`, `failure_kind` (`SERVICE_ERROR` / `TIMEOUT` / `NO_ACK` / +`MAPPING`), `error_code`, `failed_at`. Повторная обработка (`FILE_REPROCESS`) создаёт новое задание: отказ первой +попытки остаётся фактом, документы удачной попытки получают свои 4.1 / 4.2. + +## 5. Отнесение правки к источнику + +Правка — отличие подтверждённого результата от снимка приёма (4.1). Правило одно: **верный ответ есть в сыром тексте +сервиса — ошибка наша; нет — ошибка сервиса.** Для Excel сервис разбора — наш tabular-parser, поэтому у Excel любая +ошибка распознавания — «наш разбор». + +| Что правили | «Наш разбор», если | Иначе | +|---|---|---| +| Сумма | цифры подтверждённой суммы (без знака) совпадают с цифрами сырого текста после замены букв-двойников §11.3 — сервис прочитал верно, а мы неверно разобрали (знак, скобки, разряды, мусор по краям) | сервис | +| Код строки | подтверждённый код есть среди кодов, извлекаемых из `line_code_text` (в т. ч. одна из частей склейки `1250\n1260`) | сервис | +| Добавленная строка (`row_index IS NULL`) | её код есть в наборе кодов сырого ответа (для PDF — четырёхзначные токены, стоящие отдельной строкой в тексте ячеек `raw/{job_id}.json`; набор извлекается при приёме, вне транзакции, рядом с чтением `result.json`, и кладётся в снимок 4.1; для Excel — всегда «наш разбор») | сервис | +| Шапка: период, ИНН, единицы, наименование | подтверждённое значение извлекается из `header_source[*].text` | сервис | +| Дата колонки | подтверждённая дата извлекается из подписи колонки (`label`) | сервис | +| Период / счёт ОСВ | всегда «наш разбор» (Excel) | — | + +**Документ клиента** — не правка, а исход: подтверждён **вручную** с акцептом нарушенных **жёстких** проверок +(`checks_accepted`), и нарушенное соотношение не задето правками. Мягкие нарушения (в т. ч. «в пределах округления» и +межформенная 1370↔2400) не учитываются — это в основном шум округления, и подтверждение они не блокируют. +Автоподтверждение жёстких нарушений иметь не может. Если правки чинят часть нарушений, а остальные акцептованы — у +документа и правки, и «документ клиента». + +Сравнение — чистая функция `domain/quality_attribution.py` (§14 `05-domain-rules.md`), без БД. Разбор сырого текста — +публичным API `arm_statements` (`fix_lookalikes`, `parse_amount`, `parse_code`, `parse_date`, `default_rules`), тем же +кодом, что разбирал сервис; сам пакет не меняется (и не зависит от того, слита ли AURORA-1677: имя модуля то же). + +## 6. Причина документа (ячейка матрицы) + +Каждый документ с исходом попадает ровно в одну ячейку — по первому сработавшему правилу: + +1. исход `DELETED` / `SUPERSEDED` → «удалён / заменён до подтверждения»; `DELETED_AFTER_CONFIRM` / + `SUPERSEDED_AFTER_CONFIRM` → «удалён / заменён после подтверждения» (оба столбца в доли не входят); +2. есть правки «сервис» → **ошибка сервиса**; +3. есть правки «наш разбор» → **наш разбор**; +4. подтверждён с акцептом нарушений (§5) → **документ клиента**; +5. `AUTO_CONFIRMED` → **без участия**; +6. `CONFIRMED` без правок → **как есть** (распознано верно, но потребовалась проверка аналитика). + +Документы без исхода — **в работе** (отдельный столбец, в доли не входит). Технические отказы — отдельный столбец из +4.3, считаются по заданиям. Все счётчики правок по источнику остаются в факте, поэтому глубже матрицы видно и +«сервис + наш разбор» в одном документе. + +Знаменатель долей — документы с исходом `AUTO_CONFIRMED` / `CONFIRMED`. Технические отказы считаются **по файлам** +(документов у них нет, тип неизвестен): отдельный столбец со своей долей «отказавших файлов от загруженных файлов того +же источника»; фильтр по типу документа на него не действует (на экране — пометка). Рядом с матрицей — счётчик +«уникальных отчётов» (ЮЛ + подтверждённая отчётная дата + тип), чтобы было видно, сколько повторных загрузок; это +счётчик, а не режим матрицы. + +## 7. Расчёт, API, экран + +- **Расчёт** — чистая функция `domain/quality_metrics.py` от фактов: фильтры (период по дате приёма, ГК, ЮЛ, тип, + источник, скан/цифровой), матрица и ряд по месяцам. В БД не пишется. +- **API** (`/api/v1/quality-metrics`, право §8): матрица за период; ряд по месяцам по выбранной причине; список + документов ячейки (документ, ЮЛ, дата, причина, счётчики правок, ссылка на документ — если он ещё существует); + выгрузка фактов за период (CSV / JSON) для переноса. +- **Экран** «Качество первички» (`web/`, пункт меню по праву): фильтры, матрица «источник × причина», динамика по + месяцам, переход ячейка → список документов → документ (существующий экран проверки распознавания с правками и + комментариями). Точная раскладка — при реализации (владелец: «главное наличие данных»). + +## 8. Доступ + +Новый объект права `QUALITY_METRICS` в `permission_object`, право `QUALITY_METRICS_VIEW`; seed выдаёт его всем ролям. +Позже права сужаются данными (`role_permission`), без кода. Видимость по филиалу / ГК не применяется. Список +документов показывает `confirmed_by`, агрегаты — без фамилий. + +Superset читает схему фактов отдельной ролью БД только на чтение (создаёт платформа; в репозитории — описание в +`03-architecture.md`). + +## 9. Сырые ответы и признак скана + +- **Сырой ответ ИС.** Ядро уже получает `raw_s3_key` в сообщении результата — регистрирует объект в `s3_object` с новой + ролью бакета `raw` и ссылкой из `recognition_job`. Удаление файла не ставит `expires_at` сырому ответу и `uploads/`, + на которые ссылаются факты: они нужны эталону. Срок хранения — вопрос платформы (OQ-111), фиксируется в + `07-open-questions.md`. +- **Скан / цифровой PDF** определяется при загрузке по наличию текстового слоя (шрифтов в PDF) и хранится в + `stored_file`. PDF со скрытым OCR-слоем считается цифровым — допущение, записывается в §14. + +## 10. Что решено владельцем (27.10.2026) + +- Метрики — для заказчика (эффект инициативы) и как аргумент о сервисе разбора PDF. +- Охват — PDF и Excel; разрезы — качество сервиса, нашего разбора, документа клиента, технические отказы. +- Атрибуция по сырому тексту сервиса + сохранить данные для эталонного прогона («оставляем»). +- Экран в веб-интерфейсе, заказчик открывает сам; матрица и динамика; раскладка — при реализации. +- Только данные после выкладки. +- Без счётчика «есть, но не разбирается». +- Время аналитика — собирать в будущем (визиты с привязкой к документу, без нагрузки на аналитика; фамилии — для + разбора, агрегаты — без них); в этом раунде — только технические метрики. +- Отдельное право; сейчас видят все. +- Документ, удалённый или заменённый после подтверждения, — в отдельный столбец «удалён / заменён после + подтверждения» (не остаётся в «без участия» и не считается ошибкой). +- Перенос на платформу без доработки ядра; там есть Superset. + +## 11. Оставлено исполнителю + +Имена таблиц и столбцов (в рамках DBML v5.24), формат `payload`, раскладка экрана, способ определения текстового слоя +PDF, точный вид выгрузки, разбиение на задачи. + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 27.10.2026 | Сам эталонный прогон — не в этом change; здесь только сохранение входа (сырые ответы, xlsx, подтверждённый результат) | прогон требует адаптера шага 1 сервисов (он в scan-gateway / tabular-parser) и отдельного инструмента сравнения; без данных он невозможен, а данные копятся только с выкладки | прогон приедет позже; данные для него уже будут | +| 27.10.2026 | Правки шапки (период, ИНН, единицы, наименование) — ошибка распознавания, атрибуция по `header_source` | шапку тоже распознаёт сервис | неверная классификация части правок | +| 27.10.2026 | Документы без исхода — столбец «в работе», в доли не входят | неподтверждённый документ ещё не оценён | — | +| 27.10.2026 | Повторная обработка: отказ первой попытки — технический отказ; документ — по итоговой попытке | отказ был, но документ в итоге получен | двойной учёт файла в отказах и в исходах — виден по `file_id` | +| 27.10.2026 | Одна ячейка на документ по приоритету §6 (сервис > наш разбор > документ клиента > без участия > как есть) | матрица должна суммироваться в 100 %; приоритет «сервис» — потому что правку сервиса аналитик делает руками всегда | документ с ошибками обеих сторон виден только в «сервисе»; счётчики в факте сохраняют обе | +| 27.10.2026 | Спорное — в «наш разбор» (верный ответ есть в сыром тексте, напр. склейка `1250\n1260`) | анти-цель: не приписывать сервису наши недоработки | метрика сервиса выглядит лучше, чем есть | +| 27.10.2026 | Отдельная схема БД `dkb_metrics` под факты, плоские таблицы + `jsonb` | Superset подключается к схеме ролью только на чтение; перенос — одна схема с контрактом | лишняя сложность миграций со второй схемой | +| 27.10.2026 | Признак скана — по текстовому слою PDF в ядре при загрузке, без изменения scan-gateway | ИС такой признак не отдаёт; ядро видит файл при загрузке | PDF со скрытым OCR-слоем — «цифровой» | +| 27.10.2026 | Период метрик — по дате приёма результата | исход приходит позже, документ остаётся в своей когорте | недавние периоды долго «в работе» | +| 27.10.2026 | Право `QUALITY_METRICS_VIEW` всем ролям в seed | владелец: сейчас видят все, потом — отдельная роль | — | +| 27.10.2026 | «Документ клиента» — только ручное подтверждение с акцептом жёстких нарушений; мягкие и 1370↔2400 не учитываются (builder check) | мягкие — шум округления и не блокируют подтверждение; иначе автоподтверждённые уйдут из «без участия» | реальные дефекты отчётности в пределах мягких соотношений не видны | +| 27.10.2026 | Код добавленной строки ищется среди четырёхзначных токенов сырого ответа ИС, извлечённых при приёме, без переноса адаптера шага 1 в ядро (builder check) | адаптер живёт в scan-gateway; совпадение с суммой даёт «наш разбор» — по правилу «спорное — нам» | метрика сервиса чуть лучше реальной на добавленных строках | +| 27.10.2026 | Технические отказы — по файлам, своей долей; фильтр по типу документа на них не действует; «уникальные отчёты» — счётчик, не режим (builder check) | у отказавшего файла нет документов и типа; смешивать файлы и документы в одном знаменателе нельзя | доля отказов не складывается с долями матрицы в 100 % | +| 27.10.2026 | ОСВ — без построчного снимка, только статусы, предупреждения, отказы, правки периода/счёта | правок сумм ОСВ в ядре нет | при появлении правок ОСВ — доработка снимка | diff --git a/openspec/changes/quality-metrics/proposal.md b/openspec/changes/quality-metrics/proposal.md new file mode 100644 index 0000000..e296000 --- /dev/null +++ b/openspec/changes/quality-metrics/proposal.md @@ -0,0 +1,50 @@ +# Метрики качества первички (AURORA-1759, arm-core #190) + +## Зачем + +Заказчику нужно подтверждать эффект инициативы АРМ КА цифрами, а не прикидками: какая доля ББ/ОФР/ОСВ доходит до +результата без ручного ввода, сколько правят, что ломается. Вторая цель — иметь доказательную базу о качестве сервиса +разбора PDF (ИС Сканирование): если цифры неудовлетворительны, это аргумент за смену сервиса, и он должен отделять +ошибки сервиса от ошибок нашей нормализации и от дефектов самого документа клиента. + +## Что меняется + +- Ядро записывает **неизменяемые факты качества** по каждому документу: на приёме результата («как распознано») и на + исходе (подтверждён / удалён / заменён), а также факты технических отказов заданий распознавания. Факты + самодостаточны (без FK на рабочие таблицы), переживают удаление и перераспознавание файла и лежат плоскими таблицами + в отдельной схеме БД — их читает Superset платформы и переносит отдельное решение без доработки ядра. +- Каждая правка аналитика относится к источнику ошибки сравнением подтверждённого значения с сырым текстом сервиса: + сервис разбора / наш разбор / документ клиента. +- Сырой ответ ИС Сканирование (`raw/{job_id}.json`) регистрируется в реестре S3 и хранится, пока на него ссылаются + факты, — для будущего эталонного прогона новой нормализации. +- Признак «скан / цифровой PDF» определяется при загрузке. +- Экран «Качество первички» в веб-интерфейсе: матрица «источник × причина» за период и динамика по месяцам, переход из + ячейки в список документов и в документ; выгрузка фактов. Отдельное право на просмотр. + +## Затронутые репозитории + +- **arm-ka-docs** (отдельный change): DBML v5.24 (таблицы фактов, роль бакета `raw`, признак скана, право), правила + подсчёта — новый раздел `05-domain-rules.md` §14, контракт фактов `docs/contracts/quality-facts.schema.json`. +- **arm-core** (этот change): запись фактов, атрибуция правок, расчёт метрик, API, экран, право в seed. +- scan-gateway и tabular-parser не меняются. + +Вне срезов `09-delivery-plan.md`: спринт 3, эпик AURORA-1353. + +## Затрагиваемые таблицы DBML v5.23 + +`recognition_job` (ключ сырого ответа), `s3_object` / `s3_bucket_role` (роль `raw`), `stored_file` (признак скана), +`document`, `financial_statement_meta|column|line|value`, `osv_entry`, `document_issue`, `audit_event` (читаются при +записи фактов), `permission_object` (новый объект). Новые таблицы фактов — в DBML v5.24 до кода. + +## Non-goals + +- Метрики по документам, загруженным до выкладки: считаем только новые. +- Время работы аналитика (визиты документа, активное / полное время) и пользовательские метрики интерфейса — в будущий + раунд; факты ключуются по `document_id`, чтобы визиты потом присоединились. +- Отдельный счётчик «документ есть, но сервис его не разбирает» (ОСВ в PDF, расшифровки) — не отделить от технических + отказов ББ/ОФР (решение владельца). +- Ограничение видимости метрик по филиалу / ГК — пока все видят всё. +- Сам эталонный прогон (перенормализация сохранённых сырых ответов) — этот change только сохраняет для него данные. +- Свой BI: Superset платформы читает факты напрямую. + +Подробности и решения — `design.md`. -- 2.47.2 From 1171e173af44aa2ade7b5a9b07321a871e807d77 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 14:08:14 +0400 Subject: [PATCH 02/44] =?UTF-8?q?docs(#190):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20OQ-111=20=E2=86=92?= =?UTF-8?q?=20OQ-179?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OQ-111 уже занят (ретеншн S3 общего назначения) — реальный свободный номер для срока хранения raw/{job_id}.json нашёлся при работе над arm-ka-docs#202. Co-Authored-By: Claude Sonnet 5 --- openspec/changes/quality-metrics/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 5aa154f..f94907a 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -179,7 +179,7 @@ Superset читает схему фактов отдельной ролью БД - **Сырой ответ ИС.** Ядро уже получает `raw_s3_key` в сообщении результата — регистрирует объект в `s3_object` с новой ролью бакета `raw` и ссылкой из `recognition_job`. Удаление файла не ставит `expires_at` сырому ответу и `uploads/`, - на которые ссылаются факты: они нужны эталону. Срок хранения — вопрос платформы (OQ-111), фиксируется в + на которые ссылаются факты: они нужны эталону. Срок хранения — вопрос платформы (OQ-179), фиксируется в `07-open-questions.md`. - **Скан / цифровой PDF** определяется при загрузке по наличию текстового слоя (шрифтов в PDF) и хранится в `stored_file`. PDF со скрытым OCR-слоем считается цифровым — допущение, записывается в §14. -- 2.47.2 From 251c30d5cab077de3a85dcd6d352fdc4ee9350fd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 15:49:15 +0400 Subject: [PATCH 03/44] =?UTF-8?q?feat(#190):=20DBML=20v5.25=20=E2=80=94=20?= =?UTF-8?q?ORM=20=D0=B8=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D0=B2=20=D0=BA=D0=B0=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=87=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- Makefile | 2 +- README.md | 6 +- api/.env.example | 3 + api/.env.test | 1 + api/alembic/env.py | 11 +- api/alembic/versions/0012_dbml_v5_25.py | 282 +++++ api/scripts/check_dbml_vs_orm.py | 6 +- api/scripts/dbml_parity.py | 18 +- api/src/core/config.py | 24 +- api/src/models/__init__.py | 7 +- api/src/models/base.py | 6 + api/src/models/enums.py | 53 +- api/src/models/files.py | 8 +- api/src/models/quality_facts.py | 139 +++ api/tests/test_check_dbml_script.py | 14 +- api/tests/test_config_openbao_app.py | 25 + api/tests/test_db_schema.py | 40 +- api/tests/test_enums.py | 26 +- api/tests/test_migrations.py | 94 +- api/tests/test_models_part_01_03.py | 6 +- api/tests/test_models_part_04_06.py | 5 +- api/tests/test_models_part_07_09.py | 29 +- .../2026-11-05-quality-metrics-arm-core.md | 1097 +++++++++++++++++ openspec/changes/quality-metrics/design.md | 6 + 25 files changed, 1832 insertions(+), 78 deletions(-) create mode 100644 api/alembic/versions/0012_dbml_v5_25.py create mode 100644 api/src/models/quality_facts.py create mode 100644 docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md diff --git a/CLAUDE.md b/CLAUDE.md index d85d904..3c828bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.24.dbml` (текущая версия; v5.24 — назначения ВГО; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23` + `0011_dbml_v5_24`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.25.dbml` (текущая версия; v5.25 — метрики качества первички, отдельная схема `dkb_metrics`; v5.24 — назначения ВГО; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23` + `0011_dbml_v5_24` + `0012_dbml_v5_25`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и diff --git a/Makefile b/Makefile index 9058745..1b12041 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ -DBML ?= ../../docs/model/data-model-v5.24.dbml +DBML ?= ../../docs/model/data-model-v5.25.dbml .PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check statements-check check-full up migrate seed seed-dev diff --git a/README.md b/README.md index e25604d..d9c176a 100644 --- a/README.md +++ b/README.md @@ -1142,7 +1142,7 @@ email без учёта регистра проверяет только сер | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | | `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | -| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.24.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.25.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -1227,7 +1227,7 @@ GET требует `CONSOLIDATION_VIEW` и не меняет данные. Им ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.24.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.25.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -1334,7 +1334,7 @@ Dockerfile только для локального compose - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и - [`../docs/model/data-model-v5.24.dbml`](../docs/model/data-model-v5.24.dbml) — модель данных; + [`../docs/model/data-model-v5.25.dbml`](../docs/model/data-model-v5.25.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; diff --git a/api/.env.example b/api/.env.example index 5a14e50..c759d80 100644 --- a/api/.env.example +++ b/api/.env.example @@ -10,6 +10,9 @@ # Схема PostgreSQL модели данных (по умолчанию dkb). Одно значение для приложения и Alembic; идентификатор # [a-z_][a-z0-9_]{0,62}, не pg_*. В контуре платформы: OPENBAO__APP__DB_SCHEMA DB_SCHEMA=dkb +# Отдельная схема фактов качества распознавания первички (по умолчанию dkb_metrics), не совпадает с DB_SCHEMA. +# В контуре платформы: OPENBAO__APP__QUALITY_METRICS_SCHEMA +QUALITY_METRICS_SCHEMA=dkb_metrics # OPENBAO__APP__AUTH_DEV_ISSUER=true # OPENBAO__APP__FORWARDED_ALLOW_IPS=10.0.0.1,10.0.0.2 # Приоритет: обычный env -> OPENBAO__APP__* -> .env -> значение по умолчанию. Неизвестное имя — ошибка при старте. diff --git a/api/.env.test b/api/.env.test index 522fdf0..43db72d 100644 --- a/api/.env.test +++ b/api/.env.test @@ -6,6 +6,7 @@ DEBUG=true # Схема БД: как в .env.example (тесты используют ту же настройку, что и приложение) DB_SCHEMA=dkb +QUALITY_METRICS_SCHEMA=dkb_metrics # Как в .env.example (тесты сервер не запускают) SERVER__HOST=127.0.0.1 diff --git a/api/alembic/env.py b/api/alembic/env.py index 862e131..907b7bf 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -8,6 +8,10 @@ URL БД (по убыванию приоритета): Схема создаётся до таблицы версий `<схема>.alembic_version` и до миграций — только если её нет: PostgreSQL проверяет право CREATE на базу раньше, чем `IF NOT EXISTS`, а платформа может выдать роли миграций готовую схему без этого права. + +v5.25: вторая схема `settings.QUALITY_METRICS_SCHEMA` (факты качества, `METRICS_SCHEMA`) — та же цепочка миграций и +`alembic_version` (одна схема версий, `version_table_schema=SCHEMA`), но её саму создаёт сама миграция 0012 +(`CREATE SCHEMA IF NOT EXISTS`), а не `env.py`: в отличие от `SCHEMA`, она не нужна до первой миграции. """ import asyncio @@ -19,7 +23,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from alembic import context from src.core.config import settings from src.models import Base # модели регистрируются в src/models/__init__.py -from src.models.base import SCHEMA +from src.models.base import METRICS_SCHEMA, SCHEMA #: Значение `sqlalchemy.url` в шаблонном `alembic.ini` — признак «URL не задан вызывающим кодом». INI_PLACEHOLDER_PREFIX = "driver://" @@ -49,9 +53,10 @@ def database_url() -> str: def include_name(name: str | None, type_: str, parent_names: object) -> bool: - """Autogenerate видит только схему модели (`SCHEMA`) (схема по умолчанию `public` и прочие — не наши).""" + """Autogenerate видит схему модели (`SCHEMA`) и схему фактов качества (`METRICS_SCHEMA`, v5.25) — схема по + умолчанию `public` и прочие не наши.""" if type_ == "schema": - return name == SCHEMA + return name in (SCHEMA, METRICS_SCHEMA) return True diff --git a/api/alembic/versions/0012_dbml_v5_25.py b/api/alembic/versions/0012_dbml_v5_25.py new file mode 100644 index 0000000..1ad55ad --- /dev/null +++ b/api/alembic/versions/0012_dbml_v5_25.py @@ -0,0 +1,282 @@ +"""DBML v5.25: метрики качества первички (AURORA-1759, openspec/changes/quality-metrics). + +Три новых enum-типа (`quality_outcome`, `quality_cause`, `quality_failure_kind`) и два новых значения существующих +(`s3_bucket_role.raw`, `permission_object.QUALITY_METRICS`) — в схеме модели (`SCHEMA`, по умолчанию `dkb`), как все +прочие enum'ы. Две новые колонки там же: `stored_file.is_scan`, `recognition_job.raw_s3_object_id` (FK -> `s3_object`). + +Три новые таблицы фактов (`quality_intake_fact`, `quality_outcome_fact`, `quality_failure_fact`) — в ОТДЕЛЬНОЙ схеме +`METRICS_SCHEMA` (`settings.QUALITY_METRICS_SCHEMA`, по умолчанию `dkb_metrics`): одна цепочка Alembic с `SCHEMA` +(`alembic_version` остаётся только в `SCHEMA`), но данные в своей схеме — append-only, без FK на рабочие таблицы +(design §4). Схему создаёт эта миграция — условно, только если её нет (как `SCHEMA` в `env.py`/0001: платформа может +выдать роли миграций готовую схему без права `CREATE` на базу, а оно проверяется раньше `IF NOT EXISTS`). + +Откат: таблицы и колонки отбрасываются без условий (новые, данных по ним с момента прошлого релиза не было). +Откат двух enum-значений — только если значение не использовано (append-only журнал `s3_object`/`permission` не +трогаем): пересборка типа без значения останавливается `RAISE EXCEPTION`, как в 0002/0009. Схему `METRICS_SCHEMA` +откат не трогает — симметрично тому, что `SCHEMA` никогда не удаляется (та же причина: роль может не иметь права +`DROP SCHEMA`, если схему ей выдала платформа, а не эта миграция). + +Revision ID: 0012_dbml_v5_25 +Revises: 0011_dbml_v5_24 +Create Date: 2026-11-05 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op +from src.models.base import METRICS_SCHEMA, SCHEMA + +revision: str = "0012_dbml_v5_25" +down_revision: str | Sequence[str] | None = "0011_dbml_v5_24" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +#: Три новых enum-типа v5.25, значения — в порядке DBML; живут в SCHEMA, хотя используют их таблицы METRICS_SCHEMA. +NEW_ENUMS: dict[str, tuple[str, ...]] = { + "quality_outcome": ( + "AUTO_CONFIRMED", + "CONFIRMED", + "DELETED", + "SUPERSEDED", + "DELETED_AFTER_CONFIRM", + "SUPERSEDED_AFTER_CONFIRM", + ), + "quality_cause": ("SERVICE_ERROR", "OUR_ERROR", "CLIENT_DOCUMENT", "WITHOUT_PARTICIPATION", "AS_IS"), + "quality_failure_kind": ("SERVICE_ERROR", "TIMEOUT", "NO_ACK", "MAPPING"), +} +RAW_S3_OBJECT_FK = "fk_recognition_job_raw_s3_object_id_s3_object" +#: Как в env.py/0001: `CREATE SCHEMA IF NOT EXISTS` сам по себе проверяет право `CREATE` на базу раньше, чем +#: отрабатывает `IF NOT EXISTS` — платформа может выдать схему роли миграций без этого права. +CREATE_METRICS_SCHEMA_IF_MISSING_SQL = ( + f"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '{METRICS_SCHEMA}') " + f"THEN CREATE SCHEMA {METRICS_SCHEMA}; END IF; END $$" +) + + +def _enum_ref(name: str) -> postgresql.ENUM: + """Ссылка на уже существующий (или только что созданный этой миграцией) PG-enum `SCHEMA.`.""" + return postgresql.ENUM(name=name, schema=SCHEMA, create_type=False) + + +def _create_new_enum_types() -> None: + for name, values in NEW_ENUMS.items(): + literal_values = ", ".join(f"'{value}'" for value in values) + op.execute(f"CREATE TYPE {SCHEMA}.{name} AS ENUM ({literal_values})") + + +def _drop_new_enum_types() -> None: + for name in reversed(NEW_ENUMS): + op.execute(f"DROP TYPE {SCHEMA}.{name}") + + +def _revert_s3_bucket_role() -> None: + """Откат `s3_bucket_role += raw`: только если `raw` ещё не использован хотя бы одним `s3_object`.""" + op.execute(f"""DO $$ DECLARE n bigint; labels text; BEGIN + SELECT count(*) INTO n FROM {SCHEMA}.s3_object WHERE bucket_role::text = 'raw'; + IF n > 0 THEN RAISE EXCEPTION 'откат v5.25 невозможен: s3_object с bucket_role=raw: %', n; END IF; + SELECT string_agg(quote_literal(e.enumlabel), ',' ORDER BY e.enumsortorder) INTO labels + FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid JOIN pg_namespace ns ON ns.oid = t.typnamespace + WHERE ns.nspname = '{SCHEMA}' AND t.typname = 's3_bucket_role' AND e.enumlabel <> 'raw'; + ALTER TYPE {SCHEMA}.s3_bucket_role RENAME TO s3_bucket_role_v5_25; + EXECUTE 'CREATE TYPE {SCHEMA}.s3_bucket_role AS ENUM (' || labels || ')'; + ALTER TABLE {SCHEMA}.s3_object ALTER COLUMN bucket_role TYPE {SCHEMA}.s3_bucket_role + USING bucket_role::text::{SCHEMA}.s3_bucket_role; + DROP TYPE {SCHEMA}.s3_bucket_role_v5_25; + END $$""") + + +def _revert_permission_object() -> None: + """Откат `permission_object += QUALITY_METRICS`: только если право ещё не выдано (нет записи `permission`).""" + op.execute(f"""DO $$ DECLARE n bigint; labels text; BEGIN + SELECT count(*) INTO n FROM {SCHEMA}.permission WHERE object_type::text = 'QUALITY_METRICS'; + IF n > 0 THEN RAISE EXCEPTION 'откат v5.25 невозможен: permission с object_type=QUALITY_METRICS: %', n; END IF; + SELECT string_agg(quote_literal(e.enumlabel), ',' ORDER BY e.enumsortorder) INTO labels + FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid JOIN pg_namespace ns ON ns.oid = t.typnamespace + WHERE ns.nspname = '{SCHEMA}' AND t.typname = 'permission_object' AND e.enumlabel <> 'QUALITY_METRICS'; + ALTER TYPE {SCHEMA}.permission_object RENAME TO permission_object_v5_25; + EXECUTE 'CREATE TYPE {SCHEMA}.permission_object AS ENUM (' || labels || ')'; + ALTER TABLE {SCHEMA}.permission ALTER COLUMN object_type TYPE {SCHEMA}.permission_object + USING object_type::text::{SCHEMA}.permission_object; + DROP TYPE {SCHEMA}.permission_object_v5_25; + END $$""") + + +def _create_quality_intake_fact() -> None: + op.create_table( + "quality_intake_fact", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("document_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("job_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("attempt", sa.SmallInteger(), nullable=False), + sa.Column("legal_entity_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("legal_entity_name", sa.String(), nullable=False), + sa.Column("group_id", postgresql.UUID(as_uuid=True)), + sa.Column("group_name", sa.String()), + sa.Column("document_type", _enum_ref("document_type"), nullable=False), + sa.Column("pipeline", _enum_ref("recognition_pipeline"), nullable=False), + sa.Column("is_scan", sa.Boolean()), + sa.Column("service_name", sa.String()), + sa.Column("service_version", sa.String()), + sa.Column("statements_version", sa.String()), + sa.Column("result_schema_version", sa.String()), + sa.Column("report_date", sa.Date()), + sa.Column("intake_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("intake_status", _enum_ref("document_status"), nullable=False), + sa.Column("line_count", sa.Integer()), + sa.Column("value_count", sa.Integer()), + sa.Column("value_needs_review_count", sa.Integer()), + sa.Column("line_unreadable_count", sa.Integer()), + sa.Column("line_missing_count", sa.Integer()), + sa.Column("issue_relation_count", sa.Integer()), + sa.Column("issue_soft_count", sa.Integer()), + sa.Column("warning_count", sa.Integer()), + sa.Column("raw_s3_key", sa.String()), + sa.Column("result_s3_key", sa.String()), + sa.Column("upload_s3_key", sa.String()), + sa.Column("payload", postgresql.JSONB(), nullable=False), + sa.Column("payload_schema_version", sa.String()), + schema=METRICS_SCHEMA, + ) + op.create_index( + "uq_quality_intake_fact_document_id", + "quality_intake_fact", + ["document_id"], + unique=True, + schema=METRICS_SCHEMA, + ) + op.create_index( + "ix_quality_intake_fact_legal_entity_id_intake_at", + "quality_intake_fact", + ["legal_entity_id", "intake_at"], + schema=METRICS_SCHEMA, + ) + op.create_index( + "ix_quality_intake_fact_group_id_intake_at", + "quality_intake_fact", + ["group_id", "intake_at"], + schema=METRICS_SCHEMA, + ) + + +def _create_quality_outcome_fact() -> None: + op.create_table( + "quality_outcome_fact", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("document_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("outcome", _enum_ref("quality_outcome"), nullable=False), + sa.Column("outcome_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("primary_cause", _enum_ref("quality_cause")), + sa.Column("fixes_value_service", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("fixes_value_ours", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("fixes_code_service", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("fixes_code_ours", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("fixes_added_row_service", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("fixes_added_row_ours", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("fixes_header_service", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("fixes_header_ours", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("fixes_osv_period_account", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("checks_accepted", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("issue_count_at_outcome", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("report_date", sa.Date()), + sa.Column("confirmed_by", postgresql.UUID(as_uuid=True)), + sa.Column("payload", postgresql.JSONB(), nullable=False), + sa.Column("payload_schema_version", sa.String()), + schema=METRICS_SCHEMA, + ) + op.create_index( + "ix_quality_outcome_fact_document_id_outcome_at", + "quality_outcome_fact", + ["document_id", "outcome_at"], + schema=METRICS_SCHEMA, + ) + op.create_index( + "ix_quality_outcome_fact_outcome_outcome_at", + "quality_outcome_fact", + ["outcome", "outcome_at"], + schema=METRICS_SCHEMA, + ) + + +def _create_quality_failure_fact() -> None: + op.create_table( + "quality_failure_fact", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("job_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("attempt", sa.SmallInteger(), nullable=False), + sa.Column("legal_entity_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("legal_entity_name", sa.String(), nullable=False), + sa.Column("group_id", postgresql.UUID(as_uuid=True)), + sa.Column("group_name", sa.String()), + sa.Column("pipeline", _enum_ref("recognition_pipeline"), nullable=False), + sa.Column("is_scan", sa.Boolean()), + sa.Column("service_name", sa.String()), + sa.Column("failure_kind", _enum_ref("quality_failure_kind"), nullable=False), + sa.Column("error_code", sa.String()), + sa.Column("failed_at", sa.DateTime(timezone=True), nullable=False), + schema=METRICS_SCHEMA, + ) + op.create_index( + "uq_quality_failure_fact_job_id", "quality_failure_fact", ["job_id"], unique=True, schema=METRICS_SCHEMA + ) + op.create_index( + "ix_quality_failure_fact_file_id_attempt", + "quality_failure_fact", + ["file_id", "attempt"], + schema=METRICS_SCHEMA, + ) + + +def upgrade() -> None: + _create_new_enum_types() + op.execute(f"ALTER TYPE {SCHEMA}.s3_bucket_role ADD VALUE IF NOT EXISTS 'raw' AFTER 'archive'") + op.execute(f"ALTER TYPE {SCHEMA}.permission_object ADD VALUE IF NOT EXISTS 'QUALITY_METRICS' AFTER 'AUDIT'") + op.add_column("stored_file", sa.Column("is_scan", sa.Boolean()), schema=SCHEMA) + op.add_column("recognition_job", sa.Column("raw_s3_object_id", postgresql.UUID(as_uuid=True)), schema=SCHEMA) + op.create_foreign_key( + RAW_S3_OBJECT_FK, + "recognition_job", + "s3_object", + ["raw_s3_object_id"], + ["id"], + source_schema=SCHEMA, + referent_schema=SCHEMA, + ) + + op.execute(CREATE_METRICS_SCHEMA_IF_MISSING_SQL) + _create_quality_intake_fact() + _create_quality_outcome_fact() + _create_quality_failure_fact() + + +def downgrade() -> None: + op.drop_index("ix_quality_failure_fact_file_id_attempt", table_name="quality_failure_fact", schema=METRICS_SCHEMA) + op.drop_index("uq_quality_failure_fact_job_id", table_name="quality_failure_fact", schema=METRICS_SCHEMA) + op.drop_table("quality_failure_fact", schema=METRICS_SCHEMA) + + op.drop_index( + "ix_quality_outcome_fact_outcome_outcome_at", table_name="quality_outcome_fact", schema=METRICS_SCHEMA + ) + op.drop_index( + "ix_quality_outcome_fact_document_id_outcome_at", table_name="quality_outcome_fact", schema=METRICS_SCHEMA + ) + op.drop_table("quality_outcome_fact", schema=METRICS_SCHEMA) + + op.drop_index("ix_quality_intake_fact_group_id_intake_at", table_name="quality_intake_fact", schema=METRICS_SCHEMA) + op.drop_index( + "ix_quality_intake_fact_legal_entity_id_intake_at", table_name="quality_intake_fact", schema=METRICS_SCHEMA + ) + op.drop_index("uq_quality_intake_fact_document_id", table_name="quality_intake_fact", schema=METRICS_SCHEMA) + op.drop_table("quality_intake_fact", schema=METRICS_SCHEMA) + # METRICS_SCHEMA (как и SCHEMA) остаётся — см. docstring модуля + + op.drop_constraint(RAW_S3_OBJECT_FK, "recognition_job", type_="foreignkey", schema=SCHEMA) + op.drop_column("recognition_job", "raw_s3_object_id", schema=SCHEMA) + op.drop_column("stored_file", "is_scan", schema=SCHEMA) + _revert_permission_object() + _revert_s3_bucket_role() + _drop_new_enum_types() diff --git a/api/scripts/check_dbml_vs_orm.py b/api/scripts/check_dbml_vs_orm.py index 24a88b8..4054477 100644 --- a/api/scripts/check_dbml_vs_orm.py +++ b/api/scripts/check_dbml_vs_orm.py @@ -33,9 +33,11 @@ if str(_API_DIR) not in sys.path: sys.path.insert(0, str(_API_DIR)) if "ENV_FILE" not in os.environ and not Path(".env").is_file(): os.environ["ENV_FILE"] = str(_API_DIR / ".env.example") -# Сверка идёт в эталонной схеме `dkb`: имя схемы в БД конфигурируемо (`DB_SCHEMA`), на совпадение имён, типов и -# ограничений с DBML оно не влияет. Обычная переменная окружения приоритетнее `OPENBAO__APP__DB_SCHEMA` и `.env`. +# Сверка идёт в эталонной схеме `dkb`/`dkb_metrics`: имена схем в БД конфигурируемы (`DB_SCHEMA`, +# `QUALITY_METRICS_SCHEMA`), на совпадение имён, типов и ограничений с DBML они не влияют. Обычная переменная +# окружения приоритетнее `OPENBAO__APP__*` и `.env`. os.environ["DB_SCHEMA"] = "dkb" +os.environ["QUALITY_METRICS_SCHEMA"] = "dkb_metrics" from sqlalchemy import MetaData # noqa: E402 diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index 067a03a..7840908 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -57,12 +57,23 @@ from sqlalchemy.sql.functions import FunctionElement #: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением. SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.24.dbml" +#: v5.25: три таблицы фактов качества живут в отдельной схеме (`QUALITY_METRICS_SCHEMA`, по умолчанию `dkb_metrics`) +#: — единственное исключение из «всё в SCHEMA». DBML не кодирует схему Postgres, поэтому список таблиц, для которых +#: ожидается не SCHEMA, явный (аналог `_check_problems`/`vgo_assignment` ниже). +METRICS_SCHEMA = "dkb_metrics" +METRICS_TABLES = frozenset({"quality_intake_fact", "quality_outcome_fact", "quality_failure_fact"}) +DBML_FILE_NAME = "data-model-v5.25.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" NO_ACTION = "NO ACTION" + +def expected_schema(table_name: str) -> str: + """Схема, в которой таблица `table_name` обязана жить по DBML: `SCHEMA`, кроме `METRICS_TABLES` (v5.25).""" + return METRICS_SCHEMA if table_name in METRICS_TABLES else SCHEMA + + _NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)") _VERSION_RE = re.compile(r"v(\d+\.\d+)") _PARTIAL_RE = re.compile(r"partial\s+UNIQUE\s+WHERE\s+(.+?)(?:\s+—.*)?$", re.IGNORECASE | re.DOTALL) @@ -424,8 +435,9 @@ def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML) -> elif name not in orm_tables: problems.append(missing(name, ORM)) else: - if orm_tables[name].schema != SCHEMA: - problems.append(mismatch(f"{name}: схема", SCHEMA, orm_tables[name].schema)) + schema = expected_schema(name) + if orm_tables[name].schema != schema: + problems.append(mismatch(f"{name}: схема", schema, orm_tables[name].schema)) problems += _column_problems(orm_tables[name], dbml_tables[name]) problems += _index_problems(orm_tables[name], dbml_tables[name]) problems += _check_problems(orm_tables[name]) diff --git a/api/src/core/config.py b/api/src/core/config.py index 63feae4..5c8c543 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -127,8 +127,12 @@ class Settings(BaseSettings): server: ServerSettings = ServerSettings() ROOT_PATH: str = "/" # Схема PostgreSQL модели данных (MetaData ORM, search_path движка, Alembic). В контуре — OPENBAO__APP__DB_SCHEMA. - # Имя подставляется в DDL, поэтому валидируется как простой идентификатор (см. `_check_db_schema`) + # Имя подставляется в DDL, поэтому валидируется как простой идентификатор (см. `_check_schema_identifier`) DB_SCHEMA: str = "dkb" + # v5.25: отдельная схема PostgreSQL фактов качества распознавания первички (quality_intake_fact/outcome_fact/ + # failure_fact) — одна цепочка Alembic с DB_SCHEMA, но другая схема (Superset подключается сюда read-only ролью, + # openspec/changes/quality-metrics §4, §8). В контуре — OPENBAO__APP__QUALITY_METRICS_SCHEMA + QUALITY_METRICS_SCHEMA: str = "dkb_metrics" VERSION: str = "1.0.0" # CORS при DEBUG=false: разрешённые источники (JSON-список или строка через запятую); пусто — кросс-доменные # запросы запрещены. При DEBUG=true — всегда `*` (OQ-65) @@ -189,17 +193,25 @@ class Settings(BaseSettings): file_secret_settings, ) - @field_validator("DB_SCHEMA") + @field_validator("DB_SCHEMA", "QUALITY_METRICS_SCHEMA") @classmethod - def _check_db_schema(cls, value: str) -> str: - """Имя схемы — строчный SQL-идентификатор до 63 символов, не `pg_*` (зарезервировано PostgreSQL): безопасно - для подстановки в DDL и `search_path` без кавычек.""" + def _check_schema_identifier(cls, value: str, info: ValidationInfo) -> str: + """Имя схемы (основной модели или метрик качества) — строчный SQL-идентификатор до 63 символов, не `pg_*` + (зарезервировано PostgreSQL): безопасно для подстановки в DDL и `search_path` без кавычек.""" if not DB_SCHEMA_RE.fullmatch(value) or value.startswith("pg_"): raise ValueError( - f"DB_SCHEMA={value!r}: нужен идентификатор [a-z_][a-z0-9_]{{0,62}}, не начинающийся с 'pg_'" + f"{info.field_name}={value!r}: нужен идентификатор [a-z_][a-z0-9_]{{0,62}}, не начинающийся с 'pg_'" ) return value + @model_validator(mode="after") + def _check_schemas_distinct(self) -> "Settings": + """`DB_SCHEMA` и `QUALITY_METRICS_SCHEMA` обязаны различаться: иначе таблицы модели и факты качества + оказались бы в одной схеме и имена конфликтовали бы.""" + if self.DB_SCHEMA == self.QUALITY_METRICS_SCHEMA: + raise ValueError(f"DB_SCHEMA и QUALITY_METRICS_SCHEMA совпадают ({self.DB_SCHEMA!r}): нужны разные схемы") + return self + @field_validator("CORS_ORIGINS", mode="before") @classmethod def _split_origins(cls, value: Any) -> Any: diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 9ef474f..f06b892 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,13 +1,13 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.24.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.25.dbml; заполняется в Срезе 0. Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ -from . import access, accounting, audit_ex, consolidation, directories, files, kfo, osv, statements +from . import access, accounting, audit_ex, consolidation, directories, files, kfo, osv, quality_facts, statements from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). -DBML_VERSION = "5.24" +DBML_VERSION = "5.25" __all__ = [ "DBML_VERSION", @@ -21,5 +21,6 @@ __all__ = [ "files", "kfo", "osv", + "quality_facts", "statements", ] diff --git a/api/src/models/base.py b/api/src/models/base.py index 75a8b49..2de1e91 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -12,6 +12,12 @@ from src.core.config import settings #: Читается при импорте: MetaData, enum и sequence привязываются к схеме сразу (см. R-1 PR #38). SCHEMA = settings.DB_SCHEMA +#: Отдельная схема PostgreSQL фактов качества распознавания первички (DBML v5.25, `quality_*_fact`) — единственный +#: источник `settings.QUALITY_METRICS_SCHEMA`. Таблицы фактов (`src/models/quality_facts.py`) задают её явно через +#: `__table_args__ = {"schema": METRICS_SCHEMA}` — `Base.metadata` при этом остаётся одной на обе схемы (как и +#: `ENUMS`/enum-типы, которые по-прежнему живут в `SCHEMA`, см. `.enums`). +METRICS_SCHEMA = settings.QUALITY_METRICS_SCHEMA + #: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же #: имена ограничений и `downgrade` был детерминирован. Для составных/partial-индексов, где конвенция даёт #: неоднозначное или слишком длинное имя (лимит PostgreSQL — 63 символа), имена задаются явно в `__table_args__`. diff --git a/api/src/models/enums.py b/api/src/models/enums.py index e303ec2..a6daef2 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -1,9 +1,13 @@ -"""Enum'ы модели данных DBML v5.19 (33 штуки) и фабрика `pg_enum` для PostgreSQL-колонок. +"""Enum'ы модели данных DBML v5.25 (36 штук) и фабрика `pg_enum` для PostgreSQL-колонок. -Источник истины — docs/model/data-model-v5.19.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке +Источник истины — docs/model/data-model-v5.25.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. + +v5.25: `quality_outcome`/`quality_cause`/`quality_failure_kind` — новые enum'ы метрик качества первички +(openspec/changes/quality-metrics §4, §6). Их значения живут в той же схеме `SCHEMA`, что все прочие enum'ы, хотя +таблицы, которые их используют (`quality_*_fact`), — в отдельной схеме `settings.QUALITY_METRICS_SCHEMA` (см. `.base`). """ import enum @@ -114,6 +118,7 @@ class S3BucketRole(enum.StrEnum): results = "results" exports = "exports" archive = "archive" + raw = "raw" # v5.25: сырой ответ ИС Сканирование, raw/{job_id}.json — не экспирится с файлом (OQ-179) class RecognitionStatus(enum.StrEnum): @@ -283,6 +288,7 @@ class PermissionObject(enum.StrEnum): EXPORT = "EXPORT" USERS = "USERS" AUDIT = "AUDIT" + QUALITY_METRICS = "QUALITY_METRICS" # v5.25: метрики качества распознавания первички (право QUALITY_METRICS_VIEW) class PermissionAction(enum.StrEnum): @@ -347,6 +353,46 @@ class AuditAction(enum.StrEnum): ROLE_PERMISSION_CHANGE = "ROLE_PERMISSION_CHANGE" +class QualityOutcome(enum.StrEnum): + """DBML `quality_outcome` (v5.25): исход документа — `quality_outcome_fact.outcome` (design §4.2).""" + + AUTO_CONFIRMED = "AUTO_CONFIRMED" + CONFIRMED = "CONFIRMED" + DELETED = "DELETED" + SUPERSEDED = "SUPERSEDED" + DELETED_AFTER_CONFIRM = "DELETED_AFTER_CONFIRM" + SUPERSEDED_AFTER_CONFIRM = "SUPERSEDED_AFTER_CONFIRM" + + +class QualityCause(enum.StrEnum): + """DBML `quality_cause` (v5.25): причина документа — ячейка матрицы «источник × причина» (design §5, §6). + + `SERVICE_ERROR` — есть правка, не найденная в сыром тексте сервиса; `OUR_ERROR` — есть правка, найденная в сыром + тексте (наш разбор ошибся); `CLIENT_DOCUMENT` — подтверждён с акцептом нарушенных жёстких проверок, без + несписанных правок на это нарушение; `WITHOUT_PARTICIPATION` — `AUTO_CONFIRMED`; `AS_IS` — `CONFIRMED` без + правок и без акцепта. + """ + + SERVICE_ERROR = "SERVICE_ERROR" + OUR_ERROR = "OUR_ERROR" + CLIENT_DOCUMENT = "CLIENT_DOCUMENT" + WITHOUT_PARTICIPATION = "WITHOUT_PARTICIPATION" + AS_IS = "AS_IS" + + +class QualityFailureKind(enum.StrEnum): + """DBML `quality_failure_kind` (v5.25): вид технического отказа — `quality_failure_fact.failure_kind`. + + `SERVICE_ERROR` — `recognition_job.status = error`; `NO_ACK` — `dlq` (redelivery после `max_deliver`); + `MAPPING` — `MappingFatal`, файл → `ERROR`. + """ + + SERVICE_ERROR = "SERVICE_ERROR" + TIMEOUT = "TIMEOUT" + NO_ACK = "NO_ACK" + MAPPING = "MAPPING" + + #: Реестр: имя enum в DBML (= имя типа в PostgreSQL) → класс. ENUMS: dict[str, type[enum.Enum]] = { "entity_type": EntityType, @@ -382,6 +428,9 @@ ENUMS: dict[str, type[enum.Enum]] = { "permission_action": PermissionAction, "audit_section": AuditSection, "audit_action": AuditAction, + "quality_outcome": QualityOutcome, + "quality_cause": QualityCause, + "quality_failure_kind": QualityFailureKind, } diff --git a/api/src/models/files.py b/api/src/models/files.py index e4d7495..328df9e 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -1,4 +1,4 @@ -"""Часть 04 DBML v5.19 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. +"""Часть 04 DBML v5.25 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file` разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт @@ -70,6 +70,9 @@ class StoredFile(UUIDPrimaryMixin, Base): status: Mapped[FileStatus] = mapped_column(pg_enum("file_status"), nullable=False) version_no: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1")) superseded_by_file_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id")) + # v5.25: только PDF — признак скана по отсутствию текстового слоя при загрузке; true = скан, false = цифровой + # (в т.ч. скрытый OCR-слой — допущение §14); NULL для не-PDF (05-domain-rules.md §14.6) + is_scan: Mapped[bool | None] = mapped_column(Boolean) current_recognition_job_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey( @@ -169,6 +172,9 @@ class RecognitionJob(UUIDPrimaryMixin, Base): error_code: Mapped[str | None] = mapped_column(String) error_message: Mapped[str | None] = mapped_column(Text) result_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) + # v5.25: сырой ответ ИС Сканирование (роль raw), заполняется при приёме результата image pipeline — отменяет + # решение v5.13 «в БД не регистрируется» (openspec/changes/quality-metrics §9) + raw_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) result_schema_version: Mapped[str | None] = mapped_column(String) group_schema: Mapped[Any | None] = mapped_column(nullable_jsonb()) metrics: Mapped[Any | None] = mapped_column(nullable_jsonb()) diff --git a/api/src/models/quality_facts.py b/api/src/models/quality_facts.py new file mode 100644 index 0000000..889b39c --- /dev/null +++ b/api/src/models/quality_facts.py @@ -0,0 +1,139 @@ +"""DBML v5.25 «Метрики качества»: факты качества распознавания первички (AURORA-1759, openspec/changes/quality-metrics). + +Три append-only таблицы в отдельной схеме PostgreSQL `settings.QUALITY_METRICS_SCHEMA` (по умолчанию `dkb_metrics`, +`.base.METRICS_SCHEMA`) — не в `SCHEMA` (`dkb`), где живёт остальная модель. Задаётся явно через `__table_args__ = +{"schema": METRICS_SCHEMA}`: `Base`/`Base.metadata` общие для обеих схем (как и enum-типы `pg_enum`, которые +остаются в `SCHEMA`) — второй `DeclarativeBase` не нужен. + +Без FK на рабочие таблицы (`document`, `stored_file`, `recognition_job`, `legal_entity`, `company_group`): факт +обязан переживать удаление/перераспознавание документа и физическое удаление первички (design §4). Идентификаторы — +простой `uuid`, имена ЮЛ/ГК — копия на момент факта. +""" + +import datetime +import uuid +from typing import Any + +from sqlalchemy import Boolean, Date, DateTime, Index, Integer, SmallInteger, String, text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import METRICS_SCHEMA, Base, UUIDPrimaryMixin +from .enums import ( + DocumentStatus, + DocumentType, + QualityCause, + QualityFailureKind, + QualityOutcome, + RecognitionPipeline, + pg_enum, +) + + +class QualityIntakeFact(UUIDPrimaryMixin, Base): + """Документ «как распознано» (design §4.1): один факт на созданный документ BALANCE/INCOME/OSV (DISCLOSURE/OTHER + факт не пишут). Пишется в транзакции маппинга, после `map_file`, до `auto_confirm`.""" + + __tablename__ = "quality_intake_fact" + __table_args__ = ( + Index("uq_quality_intake_fact_document_id", "document_id", unique=True), + Index("ix_quality_intake_fact_legal_entity_id_intake_at", "legal_entity_id", "intake_at"), + Index("ix_quality_intake_fact_group_id_intake_at", "group_id", "intake_at"), + {"schema": METRICS_SCHEMA}, + ) + + document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + attempt: Mapped[int] = mapped_column(SmallInteger, nullable=False) + legal_entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + legal_entity_name: Mapped[str] = mapped_column(String, nullable=False) + group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + group_name: Mapped[str | None] = mapped_column(String) + document_type: Mapped[DocumentType] = mapped_column(pg_enum("document_type"), nullable=False) + pipeline: Mapped[RecognitionPipeline] = mapped_column(pg_enum("recognition_pipeline"), nullable=False) + is_scan: Mapped[bool | None] = mapped_column(Boolean) + service_name: Mapped[str | None] = mapped_column(String) + service_version: Mapped[str | None] = mapped_column(String) + statements_version: Mapped[str | None] = mapped_column(String) + result_schema_version: Mapped[str | None] = mapped_column(String) + report_date: Mapped[datetime.date | None] = mapped_column(Date) + intake_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + intake_status: Mapped[DocumentStatus] = mapped_column(pg_enum("document_status"), nullable=False) + line_count: Mapped[int | None] = mapped_column(Integer) + value_count: Mapped[int | None] = mapped_column(Integer) + value_needs_review_count: Mapped[int | None] = mapped_column(Integer) + line_unreadable_count: Mapped[int | None] = mapped_column(Integer) + line_missing_count: Mapped[int | None] = mapped_column(Integer) + issue_relation_count: Mapped[int | None] = mapped_column(Integer) + issue_soft_count: Mapped[int | None] = mapped_column(Integer) + warning_count: Mapped[int | None] = mapped_column(Integer) + raw_s3_key: Mapped[str | None] = mapped_column(String) + result_s3_key: Mapped[str | None] = mapped_column(String) + upload_s3_key: Mapped[str | None] = mapped_column(String) + payload: Mapped[Any] = mapped_column(JSONB, nullable=False) + payload_schema_version: Mapped[str | None] = mapped_column(String) + + +class QualityOutcomeFact(UUIDPrimaryMixin, Base): + """Исход документа (design §4.2): подтверждение (ручное/`auto_confirm`), удаление до подтверждения или + автозамена. НЕ unique на `document_id` — повторное подтверждение ОСВ и `DELETED_AFTER_CONFIRM`/ + `SUPERSEDED_AFTER_CONFIRM` пишут новый факт поверх прежнего; метрики берут последний по `outcome_at`.""" + + __tablename__ = "quality_outcome_fact" + __table_args__ = ( + Index("ix_quality_outcome_fact_document_id_outcome_at", "document_id", "outcome_at"), + Index("ix_quality_outcome_fact_outcome_outcome_at", "outcome", "outcome_at"), + {"schema": METRICS_SCHEMA}, + ) + + document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + outcome: Mapped[QualityOutcome] = mapped_column(pg_enum("quality_outcome"), nullable=False) + outcome_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) + # NULL для DELETED*/SUPERSEDED* — они не входят в матрицу причин (design §6) + primary_cause: Mapped[QualityCause | None] = mapped_column(pg_enum("quality_cause")) + fixes_value_service: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + fixes_value_ours: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + fixes_code_service: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + fixes_code_ours: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + fixes_added_row_service: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + fixes_added_row_ours: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + fixes_header_service: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + fixes_header_ours: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + # всегда «наш разбор» (Excel) — не делится по источнику (design §5, последняя строка таблицы) + fixes_osv_period_account: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + # подтверждён с акцептом жёстких нарушений §7 — условие «документ клиента» (design §5, §14 05-domain-rules.md) + checks_accepted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + issue_count_at_outcome: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + report_date: Mapped[datetime.date | None] = mapped_column(Date) + confirmed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) # для агрегатов не используется + payload: Mapped[Any] = mapped_column(JSONB, nullable=False) + payload_schema_version: Mapped[str | None] = mapped_column(String) + + +class QualityFailureFact(UUIDPrimaryMixin, Base): + """Технический отказ задания распознавания (`recognition_job.status` error/timeout/dlq) или неповторяемая + ошибка маппинга (`MappingFatal`, файл -> `ERROR`; design §4.3). Считается по файлам, не по документам: у + отказавшего файла нет документов и типа. `FILE_REPROCESS` создаёт новое задание — отказ первой попытки остаётся + фактом, документы удачной попытки получают свои `QualityIntakeFact`/`QualityOutcomeFact`.""" + + __tablename__ = "quality_failure_fact" + __table_args__ = ( + Index("uq_quality_failure_fact_job_id", "job_id", unique=True), + Index("ix_quality_failure_fact_file_id_attempt", "file_id", "attempt"), + {"schema": METRICS_SCHEMA}, + ) + + job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + attempt: Mapped[int] = mapped_column(SmallInteger, nullable=False) + legal_entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + legal_entity_name: Mapped[str] = mapped_column(String, nullable=False) + group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + group_name: Mapped[str | None] = mapped_column(String) + pipeline: Mapped[RecognitionPipeline] = mapped_column(pg_enum("recognition_pipeline"), nullable=False) + is_scan: Mapped[bool | None] = mapped_column(Boolean) + service_name: Mapped[str | None] = mapped_column(String) + failure_kind: Mapped[QualityFailureKind] = mapped_column(pg_enum("quality_failure_kind"), nullable=False) + error_code: Mapped[str | None] = mapped_column(String) + failed_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index acfc2cb..471a6bb 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: lines = dbml_file.read_text(encoding="utf-8").splitlines() - newer = tmp_path / "data-model-v5.25.dbml" - # шапка 5.24 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.24", "v5.25") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.25 не совпадает с версией ORM 5.24: обновите модели"]) + newer = tmp_path / "data-model-v5.26.dbml" + # шапка 5.25 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.25", "v5.26") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.26 не совпадает с версией ORM 5.25: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.25.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.24", "v5.25", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.26.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.25", "v5.26", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.25 не совпадает с версией ORM 5.24: обновите модели\n" + assert result.stdout == "версия DBML 5.26 не совпадает с версией ORM 5.25: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_config_openbao_app.py b/api/tests/test_config_openbao_app.py index fa879f0..3c951fe 100644 --- a/api/tests/test_config_openbao_app.py +++ b/api/tests/test_config_openbao_app.py @@ -30,6 +30,7 @@ FLAT_KEYS = { "CORS_ORIGINS", "OUTBOX_POLL_SECONDS", "DB_SCHEMA", + "QUALITY_METRICS_SCHEMA", } @@ -73,6 +74,30 @@ def test_db_schema_rejects_unsafe_identifier(env: pytest.MonkeyPatch, bad: str) _settings() +def test_quality_metrics_schema_default_and_app(env: pytest.MonkeyPatch) -> None: + """v5.25: `QUALITY_METRICS_SCHEMA` — по умолчанию `dkb_metrics`; в контуре — + `OPENBAO__APP__QUALITY_METRICS_SCHEMA`.""" + assert _settings().QUALITY_METRICS_SCHEMA == "dkb_metrics" + env.setenv("OPENBAO__APP__QUALITY_METRICS_SCHEMA", "dkb_metrics_alt") + assert _settings().QUALITY_METRICS_SCHEMA == "dkb_metrics_alt" + + +@pytest.mark.parametrize("bad", ["", "Dkb", "1dkb", "dkb-alt", 'dkb"; DROP SCHEMA x; --', "pg_dkb", "a" * 64, "схема"]) +def test_quality_metrics_schema_rejects_unsafe_identifier(env: pytest.MonkeyPatch, bad: str) -> None: + """Та же проверка идентификатора, что у `DB_SCHEMA` (общий валидатор `_check_schema_identifier`).""" + env.setenv("QUALITY_METRICS_SCHEMA", bad) + with pytest.raises(ValidationError): + _settings() + + +def test_db_schema_and_quality_metrics_schema_must_differ(env: pytest.MonkeyPatch) -> None: + """v5.25: совпадающие схемы — ошибка при старте (иначе таблицы модели и факты качества столкнутся по имени).""" + env.setenv("DB_SCHEMA", "dkb") + env.setenv("QUALITY_METRICS_SCHEMA", "dkb") + with pytest.raises(ValidationError, match="DB_SCHEMA и QUALITY_METRICS_SCHEMA совпадают"): + _settings() + + def test_default_without_app(env: pytest.MonkeyPatch) -> None: assert _settings().NATS_ENABLED is False diff --git a/api/tests/test_db_schema.py b/api/tests/test_db_schema.py index 709d36d..c3d7dcb6 100644 --- a/api/tests/test_db_schema.py +++ b/api/tests/test_db_schema.py @@ -1,7 +1,9 @@ -"""Схема БД конфигурируема (`DB_SCHEMA`, issue #38): миграции и приложение работают в схеме с нестандартным именем. +"""Схема БД конфигурируема (`DB_SCHEMA`, issue #38; `QUALITY_METRICS_SCHEMA`, v5.25): миграции и приложение работают +в схемах с нестандартными именами. -`SCHEMA` читается при импорте моделей, поэтому и Alembic, и «приложение» запускаются отдельными процессами с -`DB_SCHEMA=dkb_alt` в окружении (обычная переменная приоритетнее `.env.test`). +`SCHEMA`/`METRICS_SCHEMA` читаются при импорте моделей, поэтому и Alembic, и «приложение» запускаются отдельными +процессами с `DB_SCHEMA=dkb_alt`/`QUALITY_METRICS_SCHEMA=dkb_metrics_alt` в окружении (обычная переменная +приоритетнее `.env.test`). """ import os @@ -14,10 +16,11 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine from tests.pg_helpers import API_DIR, alembic_async -from tests.test_models_part_07_09 import ALL_TABLES, EXPECTED_ENUM_COUNT +from tests.test_models_part_07_09 import ALL_TABLES, EXPECTED_ENUM_COUNT, METRICS_TABLES ALT = "dkb_alt" -ALT_ENV = {"DB_SCHEMA": ALT} +METRICS_ALT = "dkb_metrics_alt" +ALT_ENV = {"DB_SCHEMA": ALT, "QUALITY_METRICS_SCHEMA": METRICS_ALT} #: «Приложение» в схеме ALT: движок и сессия из `src.db.db` (search_path из настроек), запрос ORM без указания схемы. APP_PROBE = """ @@ -26,12 +29,14 @@ from sqlalchemy import func, select, text from src.db.db import session_transaction from src.models import Base from src.models.access import AppUser +from src.models.base import METRICS_SCHEMA async def main() -> None: async with session_transaction() as session: assert (await session.execute(text("SHOW search_path"))).scalar() == "dkb_alt" assert (await session.execute(select(func.count()).select_from(AppUser))).scalar() == 0 assert Base.metadata.schema == "dkb_alt" + assert METRICS_SCHEMA == "dkb_metrics_alt" print("PROBE_OK") asyncio.run(main()) @@ -44,7 +49,8 @@ async def _scalar(conn: AsyncConnection, sql: str, **params: str) -> Any: @pytest.mark.integration async def test_migrations_and_app_work_in_custom_schema(scratch_db_url: str) -> None: - """upgrade head -> приложение читает таблицу через сессию -> downgrade base; схема `dkb` не создаётся.""" + """upgrade head -> приложение читает таблицу через сессию -> downgrade base; схемы `dkb`/`dkb_metrics` не создаются + (обе настройки — DB_SCHEMA и QUALITY_METRICS_SCHEMA, v5.25 — переопределены именами ALT/METRICS_ALT).""" engine = create_async_engine(scratch_db_url) try: await alembic_async(scratch_db_url, "upgrade", "head", env=ALT_ENV) @@ -56,16 +62,27 @@ async def test_migrations_and_app_work_in_custom_schema(scratch_db_url: str) -> ) ).scalars() ) - assert tables == ALL_TABLES | {"alembic_version"} + assert tables == (ALL_TABLES - METRICS_TABLES) | {"alembic_version"} + metrics_tables = set( + ( + await conn.execute( + text("SELECT table_name FROM information_schema.tables WHERE table_schema = :s"), + {"s": METRICS_ALT}, + ) + ).scalars() + ) + assert metrics_tables == METRICS_TABLES namespaces = set((await conn.execute(text("SELECT nspname FROM pg_namespace"))).scalars()) assert "dkb" not in namespaces + assert "dkb_metrics" not in namespaces # имя по умолчанию не прошито — переопределено METRICS_ALT enums = await _scalar( conn, "SELECT count(*) FROM pg_type WHERE typnamespace = CAST(:s AS regnamespace) AND typtype = 'e'", s=ALT, ) - assert enums == EXPECTED_ENUM_COUNT - # все внешние ключи ведут в таблицы схемы ALT (строковые FK миграций тоже берут схему из настроек) + assert enums == EXPECTED_ENUM_COUNT # все enum'ы (вкл. v5.25) — в SCHEMA/ALT, не в METRICS_SCHEMA + # все внешние ключи ведут в таблицы схемы ALT (строковые FK миграций тоже берут схему из настроек); + # у таблиц METRICS_ALT FK нет вовсе (design §4), поэтому это не сужает проверку foreign_elsewhere = await _scalar( conn, "SELECT count(*) FROM pg_constraint WHERE contype = 'f' AND confrelid::regclass::text NOT LIKE :p", @@ -92,5 +109,10 @@ async def test_migrations_and_app_work_in_custom_schema(scratch_db_url: str) -> s=ALT, ) assert left == 0 + # METRICS_ALT остаётся (пустой) после отката — симметрично тому, что ALT сам не удаляется (0012/0001) + metrics_left = await _scalar( + conn, "SELECT count(*) FROM pg_class WHERE relnamespace = CAST(:s AS regnamespace)", s=METRICS_ALT + ) + assert metrics_left == 0 finally: await engine.dispose() diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index 679c50e..a94f831 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.24: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.25: имена, значения и их порядок (без БД).""" import enum import re @@ -10,12 +10,12 @@ from src.models import DBML_VERSION from src.models.enums import ENUMS, pg_enum from tests.dbml_helpers import dbml_path, load_dbml -EXPECTED_ENUM_COUNT = 33 +EXPECTED_ENUM_COUNT = 36 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.24" + assert DBML_VERSION == "5.25" assert f"v{DBML_VERSION}" in dbml_path().name @@ -29,6 +29,26 @@ def test_counterparty_match_kind_values() -> None: assert [m.value for m in ENUMS["counterparty_match_kind"]] == ["INN", "NAME", "ALIAS", "MANUAL", "PROPAGATED"] +def test_quality_enums_values() -> None: + """v5.25: три новых enum'а метрик качества — значения и порядок как в DBML.""" + assert [m.value for m in ENUMS["quality_outcome"]] == [ + "AUTO_CONFIRMED", + "CONFIRMED", + "DELETED", + "SUPERSEDED", + "DELETED_AFTER_CONFIRM", + "SUPERSEDED_AFTER_CONFIRM", + ] + assert [m.value for m in ENUMS["quality_cause"]] == [ + "SERVICE_ERROR", + "OUR_ERROR", + "CLIENT_DOCUMENT", + "WITHOUT_PARTICIPATION", + "AS_IS", + ] + assert [m.value for m in ENUMS["quality_failure_kind"]] == ["SERVICE_ERROR", "TIMEOUT", "NO_ACK", "MAPPING"] + + def test_enums_count_and_names_match_dbml() -> None: dbml_enums = {e.name for e in load_dbml().enums} assert len(dbml_enums) == EXPECTED_ENUM_COUNT diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 202dda4..8cdd8b4 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -20,6 +20,7 @@ from sqlalchemy.exc import IntegrityError, ProgrammingError from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine from src.models import Base +from src.models.base import METRICS_SCHEMA from src.models.enums import ENUMS, OsvDataType from tests.pg_helpers import alembic_async as alembic from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db, run_alembic, service_url @@ -34,6 +35,7 @@ from tests.test_models_part_07_09 import ( ALL_TABLES, EXPECTED_ENUM_COUNT, EXPECTED_TABLE_COUNT, + METRICS_TABLES, _check_active_lock_is_unique, ) @@ -41,7 +43,8 @@ V5_19 = "0007_dbml_v5_19" V5_20 = "0008_dbml_v5_20" V5_21 = "0009_dbml_v5_21" V5_23 = "0010_dbml_v5_23" -HEAD = "0011_dbml_v5_24" +V5_24 = "0011_dbml_v5_24" +HEAD = "0012_dbml_v5_25" V5_18 = "0006_dbml_v5_18" V5_17 = "0005_dbml_v5_17" V5_16 = "0004_dbml_v5_16" @@ -62,15 +65,19 @@ async def _scalars(conn: AsyncConnection, sql: str) -> list[Any]: return list((await conn.execute(text(sql))).scalars()) -async def tables_in_dkb(conn: AsyncConnection) -> set[str]: +async def tables_in_schema(conn: AsyncConnection, schema: str) -> set[str]: return set( await _scalars( conn, - "SELECT table_name FROM information_schema.tables WHERE table_schema = 'dkb'", + f"SELECT table_name FROM information_schema.tables WHERE table_schema = '{schema}'", ) ) +async def tables_in_dkb(conn: AsyncConnection) -> set[str]: + return await tables_in_schema(conn, "dkb") + + async def enums_in_dkb(conn: AsyncConnection) -> set[str]: return set( await _scalars( @@ -81,31 +88,35 @@ async def enums_in_dkb(conn: AsyncConnection) -> set[str]: ) -async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]: - """Каталог схемы dkb без служебной таблицы alembic: колонки, ограничения, индексы, enum'ы (с порядком значений).""" +async def schema_snapshot(conn: AsyncConnection, schema: str = "dkb") -> dict[str, Any]: + """Каталог схемы `schema` без служебной таблицы alembic: колонки, ограничения, индексы, enum'ы (порядок значений). + + v5.25: параметризовано схемой — вызывается и для `dkb`, и для `METRICS_SCHEMA` (своих enum'ов там нет, см. вызов + в `test_migrated_schema_equals_orm_create_all`).""" columns = ( await conn.execute( text( "SELECT table_name, ordinal_position, column_name, data_type, udt_schema, udt_name, is_nullable, " "column_default, numeric_precision, numeric_scale FROM information_schema.columns " - "WHERE table_schema = 'dkb' AND table_name <> :vt ORDER BY table_name, ordinal_position" + "WHERE table_schema = :schema AND table_name <> :vt ORDER BY table_name, ordinal_position" ), - {"vt": VERSION_TABLE}, + {"vt": VERSION_TABLE, "schema": schema}, ) ).all() constraints = ( await conn.execute( text( "SELECT c.conname, c.conrelid::regclass::text, pg_get_constraintdef(c.oid) FROM pg_constraint c " - "WHERE c.connamespace = 'dkb'::regnamespace AND c.conrelid::regclass::text <> 'dkb.alembic_version' " - "AND c.contype <> 'n'" - ) + "WHERE c.connamespace = CAST(:schema AS regnamespace) " + "AND c.conrelid::regclass::text <> :version_table AND c.contype <> 'n'" + ), + {"schema": schema, "version_table": f"{schema}.alembic_version"}, ) ).all() indexes = ( await conn.execute( - text("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' AND tablename <> :vt"), - {"vt": VERSION_TABLE}, + text("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = :schema AND tablename <> :vt"), + {"vt": VERSION_TABLE, "schema": schema}, ) ).all() enums = ( @@ -113,8 +124,9 @@ async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]: text( "SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_type t " "JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_enum e ON e.enumtypid = t.oid " - "WHERE n.nspname = 'dkb' GROUP BY t.typname" - ) + "WHERE n.nspname = :schema GROUP BY t.typname" + ), + {"schema": schema}, ) ).all() return { @@ -127,10 +139,12 @@ async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]: async def _assert_full_model(conn: AsyncConnection) -> None: tables = await tables_in_dkb(conn) - assert tables - {VERSION_TABLE} == ALL_TABLES - assert len(tables - {VERSION_TABLE}) == EXPECTED_TABLE_COUNT + assert tables - {VERSION_TABLE} == ALL_TABLES - METRICS_TABLES # v5.25: METRICS_TABLES живут в METRICS_SCHEMA + assert len(tables - {VERSION_TABLE}) == EXPECTED_TABLE_COUNT - len(METRICS_TABLES) + metrics_tables = await tables_in_schema(conn, METRICS_SCHEMA) + assert metrics_tables == METRICS_TABLES enums = await enums_in_dkb(conn) - assert enums == set(ENUMS) + assert enums == set(ENUMS) # v5.25: enum'ы фактов качества тоже в dkb (METRICS_SCHEMA своих типов не имеет) assert len(enums) == EXPECTED_ENUM_COUNT assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [HEAD] @@ -152,8 +166,11 @@ async def _insert_office(conn: AsyncConnection) -> uuid.UUID: async def _truncate_model_tables(engine: AsyncEngine) -> None: + # v5.25: METRICS_TABLES исключены — они в METRICS_SCHEMA (не dkb), и в этом файле их никто не заполняет async with engine.begin() as conn: - await conn.execute(text("TRUNCATE " + ", ".join(f"dkb.{table}" for table in sorted(ALL_TABLES)) + " CASCADE")) + await conn.execute( + text("TRUNCATE " + ", ".join(f"dkb.{table}" for table in sorted(ALL_TABLES - METRICS_TABLES)) + " CASCADE") + ) @pytest.mark.integration @@ -1149,7 +1166,9 @@ async def test_autogenerate_finds_nothing_after_upgrade(scratch_db_url: str) -> "compare_type": True, "compare_server_default": True, "include_schemas": True, - "include_name": lambda name, type_, _parents: name == "dkb" if type_ == "schema" else True, + "include_name": lambda name, type_, _parents: ( + name in ("dkb", METRICS_SCHEMA) if type_ == "schema" else True + ), "version_table_schema": "dkb", }, ) @@ -1168,7 +1187,10 @@ async def test_autogenerate_finds_nothing_after_upgrade(scratch_db_url: str) -> @pytest.mark.integration async def test_migrated_schema_equals_orm_create_all(scratch_db_url: str) -> None: - """Каталог после миграции = каталог `create_all` ORM: колонки, default, ограничения, индексы, enum (имена и DDL).""" + """Каталог после миграции = каталог `create_all` ORM: колонки, default, ограничения, индексы, enum (имена и DDL). + + v5.25: `create_all` сам схему METRICS_SCHEMA не создаёт (в отличие от CREATE SCHEMA-условного кода миграции) — + в референсной БД её нужно завести явно, иначе create_all падает на первой таблице фактов.""" await alembic(scratch_db_url, "upgrade", "head") reference_name = f"armcore_scratch_{uuid.uuid4().hex[:12]}" reference_url = await create_scratch_db(reference_name) @@ -1177,15 +1199,21 @@ async def test_migrated_schema_equals_orm_create_all(scratch_db_url: str) -> Non try: async with reference.begin() as conn: await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb")) + await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {METRICS_SCHEMA}")) await conn.run_sync(Base.metadata.create_all) async with migrated.connect() as conn: got = await schema_snapshot(conn) + got_metrics = await schema_snapshot(conn, METRICS_SCHEMA) async with reference.connect() as conn: expected = await schema_snapshot(conn) + expected_metrics = await schema_snapshot(conn, METRICS_SCHEMA) assert got["enums"] == expected["enums"] assert got["columns"] == expected["columns"] assert got["constraints"] == expected["constraints"] assert got["indexes"] == expected["indexes"] + assert got_metrics["columns"] == expected_metrics["columns"] + assert got_metrics["constraints"] == expected_metrics["constraints"] + assert got_metrics["indexes"] == expected_metrics["indexes"] finally: await migrated.dispose() await reference.dispose() @@ -1242,7 +1270,9 @@ async def test_migrated_schema_behaviour(scratch_db_url: str) -> None: @pytest.mark.integration async def test_upgrade_into_preprovisioned_schema_without_database_create(scratch_db_url: str) -> None: - """Схему dkb выдала платформа во владение роли миграций, права CREATE на БД у роли нет: цикл миграций проходит. + """Схемы dkb/dkb_metrics выдала платформа во владение роли миграций, права CREATE на БД у роли нет: цикл миграций + проходит (v5.25: METRICS_SCHEMA нужна той же роли, которая создаёт её таблицы в 0012 — платформа должна выдать + обе схемы, если своего права CREATE у роли нет). PostgreSQL проверяет право CREATE на БД раньше, чем IF NOT EXISTS, поэтому схема создаётся только при отсутствии. """ @@ -1260,6 +1290,7 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc async with scratch.connect() as conn: await conn.execute(text(f'REVOKE CREATE ON DATABASE "{scratch_url.database}" FROM PUBLIC')) await conn.execute(text(f'CREATE SCHEMA dkb AUTHORIZATION "{role}"')) + await conn.execute(text(f'CREATE SCHEMA {METRICS_SCHEMA} AUTHORIZATION "{role}"')) can_create = await conn.scalar( text("SELECT has_database_privilege(:role, :db, 'CREATE')"), {"role": role, "db": scratch_url.database}, @@ -1274,6 +1305,10 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc await _assert_full_model(conn) owner = await conn.scalar(text("SELECT nspowner::regrole::text FROM pg_namespace WHERE nspname = 'dkb'")) assert owner == role + metrics_owner = await conn.scalar( + text("SELECT nspowner::regrole::text FROM pg_namespace WHERE nspname = :s"), {"s": METRICS_SCHEMA} + ) + assert metrics_owner == role finally: if role_created: async with scratch.connect() as conn: @@ -1285,7 +1320,11 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc def test_offline_sql_creates_schema_before_version_table() -> None: - """Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов.""" + """Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов. + + v5.25: 0012 тем же условным способом создаёт METRICS_SCHEMA — до своих таблиц фактов, но после dkb/alembic_version + (METRICS_SCHEMA не нужна для бутстрапа версий, см. docstring 0012/alembic/env.py). + """ output = run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql") schema_at = output.index( "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'dkb') " @@ -1295,6 +1334,14 @@ def test_offline_sql_creates_schema_before_version_table() -> None: assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output + + metrics_schema_at = output.index( + f"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '{METRICS_SCHEMA}') " + f"THEN CREATE SCHEMA {METRICS_SCHEMA}; END IF; END $$" + ) + assert metrics_schema_at > schema_at # METRICS_SCHEMA — в 0012, последней; dkb создаётся до первой миграции + assert metrics_schema_at < output.index(f"CREATE TABLE {METRICS_SCHEMA}.quality_intake_fact") + # Версия head пишется UPDATE'ами по полной цепочке миграций. for new_version, old_version in ( (V5_14, PREVIOUS), @@ -1306,7 +1353,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None: (V5_20, V5_19), (V5_21, V5_20), (V5_23, V5_21), - (HEAD, V5_23), + (V5_24, V5_23), + (HEAD, V5_24), ): assert ( f"UPDATE dkb.alembic_version SET version_num='{new_version}' " diff --git a/api/tests/test_models_part_01_03.py b/api/tests/test_models_part_01_03.py index 01467d4..b73457e 100644 --- a/api/tests/test_models_part_01_03.py +++ b/api/tests/test_models_part_01_03.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.schema import CreateIndex, CreateTable from src.models import Base +from src.models.base import METRICS_SCHEMA, SCHEMA from tests.dbml_parity import find_problems PART_01_03_TABLES = { @@ -43,7 +44,9 @@ def orm_tables() -> dict[str, Table]: def test_part_01_03_tables_registered_in_metadata() -> None: assert PART_01_03_TABLES <= set(orm_tables()) # общий состав metadata — в тестах последней по номеру части - assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables) + # v5.25: кроме таблиц фактов качества (своя схема METRICS_SCHEMA, не SCHEMA) — см. per-таблицу проверку схемы в + # find_problems/test_all_48_tables_registered_and_match_dbml_names + assert all(t.schema in (SCHEMA, METRICS_SCHEMA) for t in Base.metadata.sorted_tables) def test_every_table_and_index_compiles_for_postgresql() -> None: @@ -82,6 +85,7 @@ async def test_create_all_on_scratch_db(scratch_db_url: str) -> None: try: async with engine.begin() as conn: await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb")) + await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {METRICS_SCHEMA}")) # v5.25: quality_*_fact await conn.run_sync(Base.metadata.create_all) async with engine.connect() as conn: diff --git a/api/tests/test_models_part_04_06.py b/api/tests/test_models_part_04_06.py index 8688c4e..f919341 100644 --- a/api/tests/test_models_part_04_06.py +++ b/api/tests/test_models_part_04_06.py @@ -13,6 +13,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from src.models import Base +from src.models.base import METRICS_SCHEMA, SCHEMA from src.models.directories import LegalEntity from src.models.enums import ( DocumentStatus, @@ -52,7 +53,8 @@ def orm_tables() -> dict[str, Table]: def test_part_04_06_tables_registered_in_metadata() -> None: assert PART_01_03_TABLES | PART_04_06_TABLES <= set(orm_tables()) # полный состав и счёт — в тесте части 07–09 - assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables) + # v5.25: кроме таблиц фактов качества (своя схема METRICS_SCHEMA) — см. test_models_part_01_03 + assert all(t.schema in (SCHEMA, METRICS_SCHEMA) for t in Base.metadata.sorted_tables) def test_orm_matches_dbml() -> None: @@ -99,6 +101,7 @@ async def test_create_all_and_behavior_on_scratch_db(scratch_db_url: str) -> Non try: async with engine.begin() as conn: await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb")) + await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {METRICS_SCHEMA}")) # v5.25: quality_*_fact await conn.run_sync(Base.metadata.create_all) async with engine.connect() as conn: diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index 958e0e3..af84858 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from src.models import Base from src.models.access import AppUser +from src.models.base import METRICS_SCHEMA, SCHEMA from src.models.consolidation import Consolidation, ConsolidationLock, ConsolidationVersion from src.models.directories import CompanyGroup, RegionalOffice from src.models.enums import ( @@ -47,10 +48,16 @@ PART_07_09_TABLES = { "ex_snapshot_member", "ex_snapshot_item", "required_document_rule", + # quality_facts (v5.25, схема METRICS_SCHEMA — не SCHEMA, см. test_all_48_tables_registered_and_match_dbml_names) + "quality_intake_fact", + "quality_outcome_fact", + "quality_failure_fact", } ALL_TABLES = PART_01_03_TABLES | PART_04_06_TABLES | PART_07_09_TABLES -EXPECTED_TABLE_COUNT = 45 -EXPECTED_ENUM_COUNT = 33 +EXPECTED_TABLE_COUNT = 48 +EXPECTED_ENUM_COUNT = 36 +#: Таблицы в METRICS_SCHEMA — единственное исключение из «все таблицы в SCHEMA» (v5.25). +METRICS_TABLES = {"quality_intake_fact", "quality_outcome_fact", "quality_failure_fact"} def test_part_07_09_tables_are_new() -> None: @@ -112,18 +119,18 @@ def test_ex_snapshot_has_no_fk_to_consolidation() -> None: # ---------------------------------------------------------------- итоговая полнота моделей (43 таблицы) -def test_all_43_tables_registered_and_match_dbml_names() -> None: - """Состав metadata == таблицы DBML v5.17 (имена буквально), всего 43, все в схеме dkb.""" +def test_all_48_tables_registered_and_match_dbml_names() -> None: + """Состав metadata == таблицы DBML v5.25 (имена буквально), всего 48; все в SCHEMA, кроме METRICS_TABLES (v5.25).""" tables = list(Base.metadata.sorted_tables) assert len(tables) == EXPECTED_TABLE_COUNT - assert all(t.schema == "dkb" for t in tables) + assert all(t.schema == (METRICS_SCHEMA if t.name in METRICS_TABLES else SCHEMA) for t in tables) dbml_names = {t.name for t in load_dbml().tables} assert len(dbml_names) == EXPECTED_TABLE_COUNT assert {t.name for t in tables} == dbml_names assert ALL_TABLES == dbml_names # разбиение по частям покрывает модель без пропусков и дублей -def test_all_28_enums_defined() -> None: +def test_all_36_enums_defined() -> None: assert len(ENUMS) == EXPECTED_ENUM_COUNT assert set(ENUMS) == {e.name for e in load_dbml().enums} @@ -138,17 +145,21 @@ def test_orm_matches_dbml_for_all_43_tables() -> None: @pytest.mark.integration async def test_create_all_43_tables_on_scratch_db(scratch_db_url: str) -> None: - """`create_all` всех 43 таблиц; 28 enum-типов в dkb; partial unique `consolidation_lock` работает поведением.""" + """`create_all` всех 48 таблиц (45 в dkb + 3 в METRICS_SCHEMA); 36 enum-типов в dkb; partial unique + `consolidation_lock` работает поведением.""" engine = create_async_engine(scratch_db_url) try: async with engine.begin() as conn: await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb")) + await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {METRICS_SCHEMA}")) # v5.25: quality_*_fact await conn.run_sync(Base.metadata.create_all) async with engine.connect() as conn: tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema="dkb"))) - assert len(tables) == EXPECTED_TABLE_COUNT - assert tables == ALL_TABLES + assert len(tables) == EXPECTED_TABLE_COUNT - len(METRICS_TABLES) + assert tables == ALL_TABLES - METRICS_TABLES + metrics_tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema=METRICS_SCHEMA))) + assert metrics_tables == METRICS_TABLES enums = { row[0] for row in await conn.execute( diff --git a/docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md b/docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md new file mode 100644 index 0000000..8c32a4f --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md @@ -0,0 +1,1097 @@ +# Метрики качества первички (arm-core) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** arm-core записывает append-only факты качества распознавания первички (приём/исход/технический отказ), +относит правки к источнику (сервис/наш разбор), считает матрицу причин и отдаёт её экраном и API, под новым правом +`QUALITY_METRICS_VIEW` (AURORA-1759). + +**Architecture:** Новая схема `dkb_metrics` (3 таблицы, одна цепочка Alembic с `dkb`), ORM 1:1 с DBML v5.25. Запись +факта — тонкие вызовы из уже существующих точек жизненного цикла документа/задания (маппинг, подтверждение, +автоподтверждение, удаление, автозамена, таймаут/dlq/ошибка сервиса/маппинга), не новый бизнес-процесс. Атрибуция +правки и расчёт матрицы — чистые функции `domain/`, без сессии БД. API — тонкий сервис над чистой функцией, правило +`требует-прав → видимость → сервис` упрощено (видимость по филиалу/ГК не применяется — design §8). Экран — новый +read-only виджет `web/`. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16 (новая схема `dkb_metrics`), +`arm_statements` (атрибуция по сырому тексту), React + TypeScript + antd 6. + +**Spec:** +- `openspec/changes/quality-metrics/{proposal,design}.md` (этот репозиторий, arm-core#190) — WHY, все решения + владельца 27.10.2026, подтверждено 05.11.2026. +- `../docs/model/data-model-v5.25.dbml` — таблицы `quality_intake_fact`/`quality_outcome_fact`/`quality_failure_fact`, + enum `quality_outcome`/`quality_cause`/`quality_failure_kind`, `s3_bucket_role.raw`, `stored_file.is_scan`, + `recognition_job.raw_s3_object_id`, `permission_object.QUALITY_METRICS` — точные имена и типы столбцов, этот план + их не повторяет целиком, только различия/уточнения для ORM. +- `../docs/context/05-domain-rules.md` §14 — алгоритм: §14.1 какие документы, §14.2 атрибуция правки (таблица), + §14.3 приоритет причины (`quality_cause`), §14.4 «документ клиента», §14.5 технические отказы, §14.6 признак скана. + +**Ветка:** `feat/190-ocr-metrics`, worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/ocr-metrics/arm-core`, issue +RSHB/arm-core#190, Jira AURORA-1759. + +## Global Constraints + +- ORM 1:1 с `../docs/model/data-model-v5.25.dbml`; расхождение решается в пользу DBML (`make dbml-check`). +- Факты — append-only, без FK на рабочие таблицы; идентификаторы — простой `uuid`, имена ЮЛ/ГК — копия на момент + факта (design §1.4-1.6 докс-репозитория). +- Схема БД для фактов — настройка (`QUALITY_METRICS_SCHEMA`, по умолчанию `dkb_metrics`), не хардкод; миграция — + той же цепочкой Alembic, что и `dkb`. +- `domain/` — чистые функции, без `AsyncSession` и side-effects; §-номер правила — в docstring (`05-domain-rules.md + §14.N`). +- Сервисы не коммитят сами; факт пишется в ТОЙ ЖЕ транзакции, что и событие, которое его вызвало (маппинг, + подтверждение, удаление, автозамена, обработка результата/вахтёр/публикатор) — не отдельным commit. +- Факты — отдельный журнал, НЕ через `services/audit.py`/`audit_event` (design §1.4: «как `audit_event` / `ex_snapshot`» + по роли, не по механизму записи). +- Атрибуция правки — только два источника (`service`/`ours`); «документ клиента» — свойство исхода + (`checks_accepted`), не правки (§14.2, §14.4). +- Право `QUALITY_METRICS_VIEW` выдаётся в seed ВСЕМ ролям (design §8); видимость по филиалу/ГК не применяется — + сервис API без `VisibilityScope`. +- Только новые документы (загруженные после выкладки) получают факты — не ретроактивно (design, Non-goals; это + следствие того, что факты пишутся только из хуков на новые события, без backfill-скрипта — в плане backfill + действительно нет ни одной задачи). + +## Review Focus + +- **Повторная обработка (`FILE_REPROCESS`)**: первая неудачная попытка задания получает `quality_failure_fact` на + СВОЁМ `job_id`/`attempt`; новая попытка (новое `recognition_job`, `attempt+1`) получает свои intake/outcome факты + независимо. Один `file_id` оказывается и в отказах, и в исходах — это ожидаемо (design «Decisions made without + discussion»), а не двойной учёт. Тест на это — в Task 10 (там, где пишется `quality_failure_fact`). +- **Удаление уже подтверждённого документа блокируется раньше, чем код дойдёт до записи факта** (`primary_files.delete` + бросает `FILE_HAS_CONFIRMED_DOCUMENTS`, если `confirmed_by IS NOT NULL` — ручное подтверждение). Значит + `DELETED_AFTER_CONFIRM` пишется ТОЛЬКО для автоподтверждённых документов (`confirmed_by IS NULL`), а + вручную подтверждённые до записи факта просто не доходят (409 раньше). Это не баг плана — тест Task 8 должен + проверить именно автоподтверждённый случай, а не просить вручную подтверждённый (он невозможен здесь). +- **ОСВ не имеет построчного снимка** в `quality_intake_fact.payload` (нет `lines`/`values`, только счётчики и + `osv_warnings`) — `domain/quality_facts.py` должен давать РАЗНУЮ форму payload для `document_type=OSV` и для + `BALANCE`/`INCOME`, и тест Task 2 должен покрывать оба варианта, не только форму. +- **Автозамена (`supersede_previous`) трогает ВСЕ документы старого файла**, не только те, что совпали по ключу с + новым файлом (design arm-core: «Замена — файл ЦЕЛИКОМ со всеми документами в нём»). И «выпавшие» документы + (`dropped_by_file`), и совпавшие по ключу (`rest`/`replaced_documents`) получают исход `SUPERSEDED`/ + `SUPERSEDED_AFTER_CONFIRM` — тест Task 9 должен завести оба случая, не только выпавший. +- **Единственный бакет S3**: `message.raw_s3_key` (конверт результата) — это только ключ, БЕЗ имени бакета (в отличие + от `message.result_s3`, у которого есть `S3Ref{bucket,key}`). Task 11 должен явно взять имя бакета из уже + зарегистрированного `s3_object` файла (`StoredFile.s3_object_id → S3Object.bucket`), а не завести вторую, + возможно несовпадающую, настройку имени бакета. +- **`quality_outcome_fact.document_id` НЕ unique** (docs `design.md` §1.5: переподтверждение ОСВ и + `*_AFTER_CONFIRM` пишут новый факт поверх прежнего, старый не удаляется). Task 9 сама создаёт этот случай + (документ уже `CONFIRMED` → автозамена пишет ВТОРОЙ факт `SUPERSEDED_AFTER_CONFIRM` для того же `document_id`). + Если Task 15 выберет факты через простой `SELECT * FROM quality_outcome_fact WHERE outcome_at BETWEEN ...`, документ + с двумя фактами в периоде посчитается ДВА раза в матрице — ровно сумма перестанет сходиться к 100%, которую + design называет анти-целью. Task 15 ОБЯЗАН брать только последний факт на `document_id` (`DISTINCT ON (document_id) + ORDER BY document_id, outcome_at DESC` или оконная функция) — и тест Task 15 должен завести документ с двумя + фактами и проверить, что в матрице он посчитан один раз, по последнему `primary_cause`/`outcome`. + +--- + +### Task 1: Схема — enum, ORM-модели, миграция 0012 + +**Files:** +- Modify: `api/src/models/enums.py` — добавить `S3BucketRole.raw`, `PermissionObject.QUALITY_METRICS`, + `QualityOutcome`, `QualityCause`, `QualityFailureKind` (значения — `../docs/model/data-model-v5.25.dbml` + `Enum s3_bucket_role`/`permission_object`/`quality_outcome`/`quality_cause`/`quality_failure_kind`) +- Modify: `api/src/models/files.py` — `StoredFile.is_scan: Mapped[bool | None]`, + `RecognitionJob.raw_s3_object_id: Mapped[uuid.UUID | None]` (`ForeignKey("s3_object.id")`), рядом с + `result_s3_object_id` +- Create: `api/src/models/quality_facts.py` — `QualityIntakeFact`, `QualityOutcomeFact`, `QualityFailureFact` + (точные столбцы — DBML v5.25 §1.4-1.6 docs-репозитория; `__table_args__ = {"schema": settings.QUALITY_METRICS_SCHEMA}` + или аналог существующего паттерна `models.base.SCHEMA` — посмотреть, как `Base`/`UUIDPrimaryMixin` сейчас получают + схему `dkb`, и завести такой же механизм для второй схемы, а не хардкодить строку) +- Modify: `api/src/core/config.py` (или где объявлены остальные поля `Settings`, напр. `DB_SCHEMA`) — + `QUALITY_METRICS_SCHEMA: str = "dkb_metrics"` +- Create: `api/alembic/versions/0012_dbml_v5_25.py` (`revision = "0012_dbml_v5_25"`, + `down_revision = "0011_dbml_v5_24"`) — создаёт схему `dkb_metrics` (`op.execute("CREATE SCHEMA IF NOT EXISTS + dkb_metrics")` или аналог), три таблицы в ней, 4 новых enum, 2 новых столбца и 2 новых значения существующих enum + (`s3_bucket_role`, `permission_object`) в схеме `dkb` +- Modify: `CLAUDE.md` (этого репозитория) — строка миграций: `... + 0011_dbml_v5_24 + 0012_dbml_v5_25`; ссылка на + DBML v5.24 → v5.25 +- Test: `api/tests/test_dbml_sync.py` (или как называется тест, гоняющий `check_dbml_vs_orm.py` — найти через + codegraph_explore `check_dbml_vs_orm`) + +**Interfaces:** +- Produces: ORM-классы `QualityIntakeFact`, `QualityOutcomeFact`, `QualityFailureFact` (имена столбцов — как в + DBML v5.25, snake_case); enum-классы `QualityOutcome`, `QualityCause`, `QualityFailureKind` (Python `StrEnum`, + значения UPPER_SNAKE как в DBML); `StoredFile.is_scan`, `RecognitionJob.raw_s3_object_id` — используются в Task 11, + 12. + +- [ ] **Step 1: Добавить enum в `src/models/enums.py`** + +Четыре новых `class ... (StrEnum)` + два новых значения в существующих `S3BucketRole`/`PermissionObject` — текст +значений 1:1 с DBML (см. Files выше). + +- [ ] **Step 2: `StoredFile.is_scan`, `RecognitionJob.raw_s3_object_id` в `src/models/files.py`** + +- [ ] **Step 3: `QUALITY_METRICS_SCHEMA` в `Settings`** + +- [ ] **Step 4: ORM-модели `src/models/quality_facts.py`** + +Три класса; `QualityOutcomeFact` — без `unique` на `document_id` (Review Focus докс-плана, не повторяется здесь; +см. design §1.5 докс-репозитория). + +- [ ] **Step 5: Ревизия Alembic 0012** + +Автогенерация черновиком (`alembic revision --autogenerate`), ручная доводка (создание схемы, порядок DDL) — +паттерн как в `0011_dbml_v5_24.py`. + +- [ ] **Step 6: Прогнать цикл миграций** + +Run: `cd api && alembic upgrade head && alembic downgrade 0011_dbml_v5_24 && alembic upgrade head` +Expected: все три шага без ошибок. + +- [ ] **Step 7: `make dbml-check`** + +Run: `make dbml-check` (из корня репозитория) +Expected: код 0, без расхождений. + +- [ ] **Step 8: Коммит** + +```bash +git add api/src/models/enums.py api/src/models/files.py api/src/models/quality_facts.py api/src/core/config.py \ + api/alembic/versions/0012_dbml_v5_25.py CLAUDE.md +git commit -m "feat(#190): DBML v5.25 — ORM и миграция фактов качества первички" +``` + +--- + +### Task 2: `domain/quality_facts.py` — чистые снимки payload + +**Files:** +- Create: `api/src/domain/quality_facts.py` +- Test: `api/tests/test_domain_quality_facts.py` + +**Interfaces:** +- Consumes: `Document`, `FinancialStatementMeta/Column/Line/Value`, `DocumentIssue`, `OsvEntry` (существующие ORM, + уже в памяти/результате маппинга — без запроса к сессии внутри этого модуля) +- Produces: `build_intake_payload(document: Document, *, lines=None, values=None, issues=(), osv_warnings=()) + -> dict[str, Any]` — форма зависит от `document.document_type` (§14.1, Review Focus докс-плана): для + `BALANCE`/`INCOME` — `{"header": ..., "lines": [...], "values": [...], "issues": [...]}`; для `OSV` — + `{"osv_warnings": [...]}`, без `lines`/`values`. Также `build_outcome_payload(...)`, аналогичная форма + `fixes`. + +- [ ] **Step 1: Написать тест формы ОБ (BALANCE)** + +```python +def test_build_intake_payload_statement_has_lines_and_values(): + document = make_document(document_type=DocumentType.BALANCE) + payload = build_intake_payload(document, lines=[...], values=[...], issues=[]) + assert "lines" in payload and "values" in payload + assert "osv_warnings" not in payload +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +Run: `cd api && pytest tests/test_domain_quality_facts.py::test_build_intake_payload_statement_has_lines_and_values -v` +Expected: FAIL — `build_intake_payload` не существует. + +- [ ] **Step 3: Написать тест формы ОСВ** + +```python +def test_build_intake_payload_osv_has_no_lines_or_values(): + document = make_document(document_type=DocumentType.OSV) + payload = build_intake_payload(document, osv_warnings=["OSV_SOMETHING"]) + assert "lines" not in payload and "values" not in payload + assert payload["osv_warnings"] == ["OSV_SOMETHING"] +``` + +- [ ] **Step 4: Реализовать `build_intake_payload`/`build_outcome_payload` в `domain/quality_facts.py`** + +Докстринг с `§14.1`. Ветвление по `document.document_type is DocumentType.OSV`. + +- [ ] **Step 5: Прогнать оба теста, убедиться что проходят** + +Run: `cd api && pytest tests/test_domain_quality_facts.py -v` +Expected: PASS. + +- [ ] **Step 6: Коммит** + +```bash +git add api/src/domain/quality_facts.py api/tests/test_domain_quality_facts.py +git commit -m "feat(#190): чистые снимки payload фактов качества (§14.1)" +``` + +--- + +### Task 3: `domain/quality_attribution.py` — атрибуция правки (§14.2) + +**Files:** +- Create: `api/src/domain/quality_attribution.py` +- Test: `api/tests/test_domain_quality_attribution.py` + +**Interfaces:** +- Consumes: `arm_statements` публичный API — подтвердить реальные имена экспортов, запустив + `python -c "import arm_statements as m; print([n for n in dir(m) if not n.startswith('_')])"` из `api/` (design + называет `fix_lookalikes`, `parse_amount`, `parse_code`, `parse_date`, `default_rules` — если имена/модуль + расходятся, использовать реальные и отметить расхождение как находку в ledger, не переименовывать `arm_statements`) +- Produces: `attribute_value_fix(confirmed: str, raw_text: str) -> Literal["service", "ours"]`, + `attribute_code_fix(confirmed_code: str, raw_text: str) -> Literal["service", "ours"]`, + `attribute_added_row(code: str, raw_line_codes: Sequence[str], *, is_excel: bool) -> Literal["service", "ours"]`, + `attribute_header_fix(confirmed: str, header_source_text: str) -> Literal["service", "ours"]` — все по правилу + единой таблицы §14.2 docs-репозитория (копировать правило оттуда, не изобретать своё). + +- [ ] **Step 1: Тест — сумма, сервис прочитал верно** + +```python +def test_attribute_value_fix_ours_when_raw_matches_after_lookalikes(): + # сырой текст "1OOO.00" (O вместо 0), подтверждено 1000.00 — цифры совпадают после fix_lookalikes → "ours" + assert attribute_value_fix("1000.00", "1OOO.00") == "ours" + + +def test_attribute_value_fix_service_when_raw_differs(): + assert attribute_value_fix("1000.00", "999.00") == "service" +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +Run: `cd api && pytest tests/test_domain_quality_attribution.py -v` +Expected: FAIL — функции не существуют. + +- [ ] **Step 3: Реализовать `attribute_value_fix`/`attribute_header_fix` в `domain/quality_attribution.py`** + +Докстринг `§14.2`. Использует реальную сигнатуру `arm_statements` (подтверждена в Interfaces). + +- [ ] **Step 4: Тест — код строки и добавленная строка (Excel всегда «ours»)** + +```python +def test_attribute_code_fix_service_when_code_in_raw_text(): + assert attribute_code_fix("1250", "1250\n1260") == "service" + + +def test_attribute_added_row_excel_always_ours(): + assert attribute_added_row("1250", raw_line_codes=["1250"], is_excel=True) == "ours" +``` + +- [ ] **Step 5: Реализовать оставшиеся функции** + +- [ ] **Step 6: Прогнать все тесты модуля** + +Run: `cd api && pytest tests/test_domain_quality_attribution.py -v` +Expected: PASS, все сценарии §14.2. + +- [ ] **Step 7: Коммит** + +```bash +git add api/src/domain/quality_attribution.py api/tests/test_domain_quality_attribution.py +git commit -m "feat(#190): атрибуция правки сервис/наш разбор (§14.2)" +``` + +--- + +### Task 4: `repositories/quality_facts.py` — запись фактов + +**Files:** +- Create: `api/src/repositories/quality_facts.py` +- Test: `api/tests/test_repositories_quality_facts.py` (маркер `integration` — пишет в реальную БД) + +**Interfaces:** +- Consumes: `QualityIntakeFact`/`QualityOutcomeFact`/`QualityFailureFact` (Task 1); `build_intake_payload`/ + `build_outcome_payload` (Task 2) +- Produces: `async def insert_intake_fact(session: AsyncSession, **columns) -> None`, + `async def insert_outcome_fact(session: AsyncSession, **columns) -> None`, + `async def insert_failure_fact(session: AsyncSession, **columns) -> None` — тонкие `session.add(...)`, без + `flush`/`commit` (вызывающий решает); сигнатура — **kwargs по столбцам DBML (не позиционные, чтобы вызывающие + места в Task 5-10 были читаемы без заглядывания в эту сигнатуру). + +- [ ] **Step 1: Тест — вставка и чтение intake-факта** + +```python +@pytest.mark.integration +async def test_insert_intake_fact_roundtrip(session): + await insert_intake_fact(session, document_id=..., file_id=..., job_id=..., ..., payload={"lines": []}) + await session.flush() + row = await session.get(QualityIntakeFact, ...) + assert row.document_id == ... +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +Run: `cd api && pytest tests/test_repositories_quality_facts.py -v -m integration` +Expected: FAIL — `insert_intake_fact` не существует (нужна БД из compose, см. `make test-integration`). + +- [ ] **Step 3: Реализовать `insert_intake_fact`** + +- [ ] **Step 4: Тест и реализация `insert_outcome_fact`, `insert_failure_fact`** (тем же циклом RED→GREEN) + +- [ ] **Step 5: Прогнать все тесты модуля** + +Run: `cd api && pytest tests/test_repositories_quality_facts.py -v -m integration` +Expected: PASS. + +- [ ] **Step 6: Коммит** + +```bash +git add api/src/repositories/quality_facts.py api/tests/test_repositories_quality_facts.py +git commit -m "feat(#190): репозиторий записи фактов качества" +``` + +--- + +### Task 5: Факт приёма — хук в `_map_candidate` + +**Files:** +- Modify: `api/src/services/result_mapping.py:1097-1142` (`_map_candidate`) — после строки, строящей `documents` + (текущая строка 1130: `documents = list((await session.execute(select(Document)...)).scalars())`) и ДО вызова + `auto_confirm` (строка 1134), для каждого документа с `document_type in {BALANCE, INCOME, OSV}` собрать снимок + (`domain/quality_facts.build_intake_payload`) и записать факт (`repositories/quality_facts.insert_intake_fact`) +- Test: `api/tests/test_result_mapping.py` (дополнить существующий файл, не создавать новый — там уже тесты + `_map_candidate`/`map_file`) + +**Interfaces:** +- Consumes: `build_intake_payload` (Task 2), `insert_intake_fact` (Task 4) +- Produces: ничего нового для других тасков — конечный потребитель цепочки + +- [ ] **Step 1: Тест — маппинг файла с формой ББ пишет intake-факт** + +```python +@pytest.mark.integration +async def test_map_candidate_writes_intake_fact_for_statement_document(session_factory, ...): + # загрузить файл с формой ББ через существующие фикстуры/фабрики (fixtures_helpers.py / factories.py) + ... + await _map_candidate(session_factory, candidate, result, clock) + async with session_factory() as session: + fact = (await session.execute(select(QualityIntakeFact))).scalar_one() + assert fact.document_type == DocumentType.BALANCE +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +Run: `cd api && pytest tests/test_result_mapping.py::test_map_candidate_writes_intake_fact_for_statement_document -v -m integration` +Expected: FAIL — факта нет (хук не вызван). + +- [ ] **Step 3: Вставить хук в `_map_candidate`** + +Для каждого `document in documents` с нужным типом — собрать поля (`legal_entity_id`/`group_id` — через +`file.legal_entity_id` и `LegalEntity.company_group_id`, уже доступные по `owner`/`index` в этой функции; +`pipeline` — по `job.pipeline`; `is_scan` — по `file.is_scan` (Task 12); счётчики — по `financial_statement_*`/ +`osv_entry`, дочитанным в этой же транзакции, см. Review Focus докс-плана про форму ОСВ). + +- [ ] **Step 4: Тест — ОСВ-документ пишет факт без построчного снимка** + +```python +@pytest.mark.integration +async def test_map_candidate_writes_intake_fact_for_osv_without_lines(session_factory, ...): + ... + assert "lines" not in fact.payload +``` + +- [ ] **Step 5: Прогнать весь файл тестов** + +Run: `cd api && pytest tests/test_result_mapping.py -v -m integration` +Expected: PASS, включая прежние тесты (регрессия). + +- [ ] **Step 6: Коммит** + +```bash +git add api/src/services/result_mapping.py api/tests/test_result_mapping.py +git commit -m "feat(#190): факт приёма документа при маппинге (§14.1)" +``` + +--- + +### Task 6: Факт исхода — `AUTO_CONFIRMED` + +**Files:** +- Modify: `api/src/services/auto_confirm.py:34-76` — после `confirmed.append(document)` (строка 75), записать + `quality_outcome_fact` с `outcome=QualityOutcome.AUTO_CONFIRMED`, `primary_cause` по §14.3 (сначала нужна + функция приоритета из Task 14 — ЕСЛИ Task 14 после этого, атрибут `primary_cause` можно оставить `NULL` здесь и + заполнить позже пересчётом; **решение**: вычислять `primary_cause` inline по правилу п. 5 §14.3 + (`AUTO_CONFIRMED` без правок ⇒ всегда `WITHOUT_PARTICIPATION`, правок при автоподтверждении не бывает — `D-3` + блокирует автоподтверждение форм с нарушениями) — не нужен полный `quality_metrics.py` для этого случая) +- Test: `api/tests/test_auto_confirm.py` (дополнить существующий) + +**Interfaces:** +- Consumes: `insert_outcome_fact` (Task 4), `build_outcome_payload` (Task 2) +- Produces: — + +- [ ] **Step 1: Тест — автоподтверждение пишет outcome-факт** + +```python +async def test_auto_confirm_writes_outcome_fact_without_participation(session, ...): + confirmed = await auto_confirm(session, file=file, documents=[document], now=now) + fact = (await session.execute(select(QualityOutcomeFact))).scalar_one() + assert fact.outcome == QualityOutcome.AUTO_CONFIRMED + assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +Run: `cd api && pytest tests/test_auto_confirm.py::test_auto_confirm_writes_outcome_fact_without_participation -v` +Expected: FAIL. + +- [ ] **Step 3: Вставить запись факта в `auto_confirm`** + +- [ ] **Step 4: Прогнать весь файл** + +Run: `cd api && pytest tests/test_auto_confirm.py -v` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +```bash +git add api/src/services/auto_confirm.py api/tests/test_auto_confirm.py +git commit -m "feat(#190): факт исхода AUTO_CONFIRMED (§14.3 п.5)" +``` + +--- + +### Task 7: Факт исхода — `CONFIRMED` (ручное подтверждение) + +**Files:** +- Modify: `api/src/services/documents.py` (`DocumentService.confirm`, начинается строка 292; тело после строки 306 + не вскрыто этим планом — исполнитель читает его целиком перед правкой) — после успешного перевода в `CONFIRMED` и + ДО возврата `DocumentOut`, вычислить `primary_cause` по правкам документа (сравнить подтверждённое с + `quality_intake_fact.payload` этого же `document_id` через `attribute_*` из Task 3; если правок нет → + `AS_IS` (§14.3 п.6); если есть — `SERVICE_ERROR`/`OUR_ERROR` по приоритету; `checks_accepted` — из уже имеющегося + механизма акцепта проверок §7, найти его через codegraph_explore (`checks_accepted_by`/`form_blocker`) — + если акцепт присутствует и нарушение не закрыто правкой → `CLIENT_DOCUMENT`, см. §14.4) и записать факт +- Test: `api/tests/test_api_documents.py` (дополнить) или `test_services_documents.py`, если сервис тестируется + отдельно от HTTP-слоя (проверить, какой файл сейчас покрывает `confirm`, через codegraph_explore `DocumentService + confirm test`) + +**Interfaces:** +- Consumes: `attribute_value_fix`/`attribute_code_fix`/`attribute_header_fix` (Task 3), `insert_outcome_fact` + (Task 4) +- Produces: — + +- [ ] **Step 1: Тест — подтверждение без правок даёт `AS_IS`** + +```python +async def test_confirm_writes_outcome_fact_as_is_when_no_fixes(...): + await service.confirm(document_id) + fact = ... + assert fact.outcome == QualityOutcome.CONFIRMED + assert fact.primary_cause == QualityCause.AS_IS +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +- [ ] **Step 3: Вставить вычисление `primary_cause` и запись факта в `confirm`** + +- [ ] **Step 4: Тест — подтверждение с правкой суммы, найденной в сыром тексте, даёт `OUR_ERROR`** + +```python +async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text(...): + ... + assert fact.primary_cause == QualityCause.OUR_ERROR +``` + +- [ ] **Step 5: Прогнать весь файл тестов** + +Run: `cd api && pytest tests/test_api_documents.py -v` (или найденный тестовый файл сервиса) +Expected: PASS. + +- [ ] **Step 6: Коммит** + +```bash +git add api/src/services/documents.py <тестовый файл> +git commit -m "feat(#190): факт исхода CONFIRMED с атрибуцией правок (§14.2-14.4)" +``` + +--- + +### Task 8: Факт исхода — `DELETED` / `DELETED_AFTER_CONFIRM` + +**Files:** +- Modify: `api/src/services/primary_files.py:307-396` (`PrimaryFilesService.delete`) — после всех 409-проверок, + перед физическим удалением строк документов (до того, как `financial_statement_*`/`osv_entry`/`document` + удалятся — факт пишется по документам, которые сейчас видны под блокировкой), для каждого документа файла: + `outcome = DELETED_AFTER_CONFIRM if document.status is DocumentStatus.CONFIRMED else DELETED` (Review Focus + докс-плана: вручную подтверждённые сюда не доходят — 409 раньше; значит `DELETED_AFTER_CONFIRM` здесь — всегда + автоподтверждённый случай, `confirmed_by IS NULL`) +- Test: `api/tests/test_api_files_delete.py` (дополнить существующий) + +**Interfaces:** +- Consumes: `insert_outcome_fact` (Task 4) +- Produces: — + +- [ ] **Step 1: Тест — удаление неподтверждённого документа пишет `DELETED`** + +```python +async def test_delete_file_writes_deleted_fact_for_unconfirmed_document(...): + await service.delete(file_id) + fact = ... + assert fact.outcome == QualityOutcome.DELETED +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +- [ ] **Step 3: Вставить запись факта в `delete`** + +- [ ] **Step 4: Тест — удаление файла с автоподтверждённым документом пишет `DELETED_AFTER_CONFIRM`** + +```python +async def test_delete_file_writes_deleted_after_confirm_for_auto_confirmed_document(...): + # документ уже auto_confirm'нут (confirmed_by is None), версия не в "в работе" (иначе 409 по OQ-157) + ... + assert fact.outcome == QualityOutcome.DELETED_AFTER_CONFIRM +``` + +- [ ] **Step 5: Прогнать весь файл тестов** + +Run: `cd api && pytest tests/test_api_files_delete.py -v` +Expected: PASS. + +- [ ] **Step 6: Коммит** + +```bash +git add api/src/services/primary_files.py api/tests/test_api_files_delete.py +git commit -m "feat(#190): факт исхода DELETED/DELETED_AFTER_CONFIRM" +``` + +--- + +### Task 9: Факт исхода — `SUPERSEDED` / `SUPERSEDED_AFTER_CONFIRM` + +**Files:** +- Modify: `api/src/services/result_mapping.py` (внутри `supersede_previous`/`_supersede_and_warn`, окрестности + строк 858-1004 — конкретно там, где уже вычисляется `documents_by_file`/`dropped_by_file`/`old_documents`, + **до или после** вызовов `on_documents_superseded`, но в той же транзакции) — для каждого `old` файла из `locked` + и КАЖДОГО документа в `documents_by_file[old.id]` (Review Focus докс-плана: и выпавшие, и совпавшие по ключу, + весь файл целиком): `outcome = SUPERSEDED_AFTER_CONFIRM if document.status is DocumentStatus.CONFIRMED else + SUPERSEDED` +- Test: `api/tests/test_result_mapping.py` (дополнить; там же тестируется автозамена, искать `test_supersede`) + +**Interfaces:** +- Consumes: `insert_outcome_fact` (Task 4) +- Produces: — + +- [ ] **Step 1: Тест — автозамена выпавшего документа (нет совпадения по ключу) пишет `SUPERSEDED`** + +```python +@pytest.mark.integration +async def test_supersede_previous_writes_superseded_fact_for_dropped_document(...): + ... + fact = ... + assert fact.outcome == QualityOutcome.SUPERSEDED +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +- [ ] **Step 3: Вставить запись факта в `supersede_previous`** + +- [ ] **Step 4: Тест — автозамена подтверждённого документа пишет `SUPERSEDED_AFTER_CONFIRM`** + +```python +@pytest.mark.integration +async def test_supersede_previous_writes_superseded_after_confirm_for_confirmed_document(...): + ... + assert fact.outcome == QualityOutcome.SUPERSEDED_AFTER_CONFIRM +``` + +- [ ] **Step 5: Прогнать весь файл тестов** + +Run: `cd api && pytest tests/test_result_mapping.py -v -m integration` +Expected: PASS. + +- [ ] **Step 6: Коммит** + +```bash +git add api/src/services/result_mapping.py api/tests/test_result_mapping.py +git commit -m "feat(#190): факт исхода SUPERSEDED/SUPERSEDED_AFTER_CONFIRM при автозамене" +``` + +--- + +### Task 10: Факт технического отказа — 4 точки + +**Files:** +- Modify: `api/src/services/recognition_exchange.py:372-403` (`expire_deadlines`) — после `update`-запроса, + для каждого `job_id` из `job_ids` записать `quality_failure_fact(failure_kind=TIMEOUT)` +- Modify: `api/src/services/recognition_exchange.py:229-249` (`_process_one`, ветка `dlq`) — + `failure_kind=NO_ACK` +- Modify: `api/src/services/recognition_exchange.py:255-273` (`_process_one`, ветка `ERROR_INVALID_REQUEST`) и + `688-739` (`_apply_result`, когда `message.status is ERROR`) — оба `failure_kind=SERVICE_ERROR` (Review Focus + основного design: любой переход `recognition_job.status → error` — `SERVICE_ERROR`, независимо от того, наша + это ошибка валидации запроса или ошибка, о которой сообщил сервис) +- Modify: `api/src/services/result_mapping.py:1038-1056` (`_fail`, вызывается при `MappingFatal`) и `1059-1093` + (`_register_failure`, ветка `exhausted`) — оба `failure_kind=MAPPING` +- Test: `api/tests/test_recognition_exchange.py`, `api/tests/test_result_mapping.py` (дополнить оба) + +**Interfaces:** +- Consumes: `insert_failure_fact` (Task 4) +- Produces: — + +- [ ] **Step 1: Тест — таймаут пишет факт отказа** + +```python +async def test_expire_deadlines_writes_failure_fact(session_factory, ...): + await expire_deadlines(session_factory, now=...) + fact = ... + assert fact.failure_kind == QualityFailureKind.TIMEOUT +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +- [ ] **Step 3: Вставить запись факта в `expire_deadlines`** + +- [ ] **Step 4: Тест и реализация — dlq (`NO_ACK`)** (тем же циклом RED→GREEN, в `test_recognition_exchange.py`, + рядом с существующими тестами `_process_one`/dlq) + +- [ ] **Step 5: Тест и реализация — service error (оба места, `SERVICE_ERROR`)** + +- [ ] **Step 6: Тест и реализация — mapping (оба места, `MAPPING`)** + +```python +async def test_fail_writes_mapping_failure_fact(...): + await _fail(session, candidate, "описание", now) + fact = ... + assert fact.failure_kind == QualityFailureKind.MAPPING +``` + +- [ ] **Step 7: Тест — повторная обработка (FILE_REPROCESS) не теряет факт первой неудачной попытки** + +```python +@pytest.mark.integration +async def test_file_reprocess_keeps_failure_fact_of_first_attempt_and_adds_new_intake(...): + # первая попытка -> dlq/timeout -> failure_fact(attempt=1); FILE_REPROCESS -> job attempt=2 -> успех -> intake_fact(attempt=2) + facts = (await session.execute(select(QualityFailureFact))).scalars().all() + assert len(facts) == 1 and facts[0].attempt == 1 + intake = (await session.execute(select(QualityIntakeFact))).scalar_one() + assert intake.attempt == 2 +``` + +- [ ] **Step 8: Прогнать оба файла тестов** + +Run: `cd api && pytest tests/test_recognition_exchange.py tests/test_result_mapping.py -v -m integration` +Expected: PASS. + +- [ ] **Step 9: Коммит** + +```bash +git add api/src/services/recognition_exchange.py api/src/services/result_mapping.py \ + api/tests/test_recognition_exchange.py api/tests/test_result_mapping.py +git commit -m "feat(#190): факт технического отказа (TIMEOUT/NO_ACK/SERVICE_ERROR/MAPPING, §14.5)" +``` + +--- + +### Task 11: Регистрация сырого ответа ИС (`s3_bucket_role.raw`) + +**Files:** +- Modify: `api/src/services/recognition_exchange.py:653-685` (рядом с `_register_result_object`) — добавить + `_register_raw_object(session, job: RecognitionJob, raw_s3_key: str, now: datetime) -> uuid.UUID`, тот же паттерн + `pg_insert(...).on_conflict_do_nothing(...)`, но `bucket_role=S3BucketRole.raw`; имя бакета — ИЗ УЖЕ + ЗАРЕГИСТРИРОВАННОГО объекта файла (Review Focus): `select(S3Object.bucket).join(StoredFile, + StoredFile.s3_object_id == S3Object.id).where(StoredFile.id == job.file_id)`, НЕ из `message.result_s3` (может + отсутствовать при статусе `error`) +- Modify: `api/src/services/recognition_exchange.py:712` (`_apply_result`) — после строки + `result_s3_object_id = await _register_result_object(...)`, если `message.raw_s3_key is not None`: + `job.raw_s3_object_id = await _register_raw_object(session, job, message.raw_s3_key, now)` +- Modify: `api/src/contract/message.py:55` — убрать из комментария поля `raw_s3_key` фразу «в БД не регистрируется» + (решение отменено v5.25) — только комментарий, тип поля не меняется (контракт не версионируется этим change, + поле уже было в схеме) +- Test: `api/tests/test_recognition_exchange.py` (дополнить; там же тестируется `_apply_result`/`_register_result_object`) + +**Interfaces:** +- Produces: `_register_raw_object` используется только внутри этого модуля (не экспортируется в другие таски) + +- [ ] **Step 1: Тест — результат с `raw_s3_key` регистрирует объект и ссылку на задании** + +```python +@pytest.mark.integration +async def test_apply_result_registers_raw_object_and_sets_job_reference(session, ...): + message = make_result_message(raw_s3_key="raw/.json", ...) + await _apply_message(session, message, None, lambda: now) + job = await session.get(RecognitionJob, message.job_id) + assert job.raw_s3_object_id is not None + raw_object = await session.get(S3Object, job.raw_s3_object_id) + assert raw_object.bucket_role == S3BucketRole.raw + assert raw_object.bucket == <тот же бакет, что у stored_file файла> +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +Run: `cd api && pytest tests/test_recognition_exchange.py::test_apply_result_registers_raw_object_and_sets_job_reference -v -m integration` +Expected: FAIL — `job.raw_s3_object_id` остаётся `None`. + +- [ ] **Step 3: Реализовать `_register_raw_object` и вызов из `_apply_result`** + +- [ ] **Step 4: Тест — результат без `raw_s3_key` (Excel) не создаёт raw-объект** + +```python +@pytest.mark.integration +async def test_apply_result_without_raw_s3_key_leaves_raw_object_id_none(...): + ... + assert job.raw_s3_object_id is None +``` + +- [ ] **Step 5: Прогнать весь файл** + +Run: `cd api && pytest tests/test_recognition_exchange.py -v -m integration` +Expected: PASS. + +- [ ] **Step 6: Коммит** + +```bash +git add api/src/services/recognition_exchange.py api/src/contract/message.py api/tests/test_recognition_exchange.py +git commit -m "feat(#190): регистрация сырого ответа ИС Сканирование (s3_bucket_role.raw)" +``` + +--- + +### Task 12: `stored_file.is_scan` при загрузке + +**Files:** +- Modify: `api/requirements.txt` — добавить `pypdf` (пин — реальная версия после установки, см. Step 1) +- Modify: `api/src/services/primary_files.py` (внутри `_store`/`upload_one`, строки 397-424 и далее — найти, где + создаётся `StoredFile(...)`, через codegraph_explore `_store PrimaryFilesService` если не видно в уже + прочитанном диапазоне) — для `detected.file_type is FileType.PDF`: `is_scan = not has_text_layer(content)` + (ВНИМАНИЕ — направление: `../docs/model/data-model-v5.25.dbml` `stored_file.is_scan` — **`true` = скан, нет + текстового слоя; `false` = цифровой, в т. ч. скрытый OCR-слой** (docs-ревью 05.11.2026 исправило обратную + формулировку); для остальных типов файла — `is_scan = None` +- Create: `api/src/domain/pdf_text_layer.py` — чистая функция `has_text_layer(content: bytes) -> bool` (битый/ + зашифрованный PDF — `False`, не исключение: при загрузке не должно падать на мусорном файле) +- Test: `api/tests/test_domain_pdf_text_layer.py`, дополнить `api/tests/test_api_files_upload.py` + +**Interfaces:** +- Produces: `has_text_layer(content: bytes) -> bool` + +- [ ] **Step 1: Установить `pypdf` и зафиксировать версию** + +Run: `cd api && uv pip install --python .venv/bin/python pypdf && uv pip freeze --python .venv/bin/python | grep -i pypdf` +Expected: строка `pypdf==X.Y.Z` — вписать её в `requirements.txt` в алфавитном месте. + +- [ ] **Step 2: Тест — PDF с текстовым слоем** + +```python +def test_has_text_layer_true_for_digital_pdf(): + # маленький PDF с текстом, сгенерированный в тесте (напр. reportlab, если уже зависимость — проверить; иначе + # зафиксировать как тестовую фикстуру bytes из `fixtures/` или сконструировать минимальный PDF вручную) + assert has_text_layer(digital_pdf_bytes) is True +``` + +- [ ] **Step 3: Запустить, убедиться что падает** + +- [ ] **Step 4: Тест — скан без текстового слоя и битый файл** + +```python +def test_has_text_layer_false_for_scanned_pdf(): + assert has_text_layer(scanned_pdf_bytes) is False + + +def test_has_text_layer_false_for_corrupt_pdf_without_raising(): + assert has_text_layer(b"not a pdf") is False +``` + +- [ ] **Step 5: Реализовать `has_text_layer`** + +`pypdf.PdfReader(io.BytesIO(content))`, по страницам `page.extract_text()`; любое исключение библиотеки → `False` +(не пропускать наружу — решение по аналогии с «битый PDF» из существующего `detect_file`, docstring с §14.6). + +- [ ] **Step 6: Прогнать тесты модуля** + +Run: `cd api && pytest tests/test_domain_pdf_text_layer.py -v` +Expected: PASS. + +- [ ] **Step 7: Тест — загрузка PDF проставляет `is_scan` (направление — true = скан)** + +```python +async def test_upload_scanned_pdf_sets_is_scan_true(...): + file = await service.upload_one(entity, "scan.pdf", scanned_pdf_bytes) # без текстового слоя + stored = await session.get(StoredFile, file.id) + assert stored.is_scan is True + + +async def test_upload_digital_pdf_sets_is_scan_false(...): + file = await service.upload_one(entity, "digital.pdf", digital_pdf_bytes) # с текстовым слоем + stored = await session.get(StoredFile, file.id) + assert stored.is_scan is False +``` + +- [ ] **Step 8: Вставить `is_scan = not has_text_layer(content)` в `_store`/`upload_one` (не `has_text_layer(content)` + напрямую — направление инвертировано, см. Files выше)** + +- [ ] **Step 9: Прогнать файл загрузки** + +Run: `cd api && pytest tests/test_api_files_upload.py -v` +Expected: PASS. + +- [ ] **Step 10: Коммит** + +```bash +git add api/requirements.txt api/src/domain/pdf_text_layer.py api/src/services/primary_files.py \ + api/tests/test_domain_pdf_text_layer.py api/tests/test_api_files_upload.py +git commit -m "feat(#190): признак скан/цифровой PDF при загрузке (§14.6)" +``` + +--- + +### Task 13: Право `QUALITY_METRICS_VIEW` + +**Files:** +- Modify: `api/seed/core/permissions.yaml` — новая запись `code: QUALITY_METRICS_VIEW, object: QUALITY_METRICS, + action: VIEW, name: ..., description: ...` +- Modify: `api/seed/core/role_permissions.yaml` — добавить `QUALITY_METRICS_VIEW` в список КАЖДОЙ роли (design §8: + «сейчас всем ролям») +- Modify: `web/src/api/types.ts:6` — `PermissionObject` union: добавить `'QUALITY_METRICS'` +- Test: `api/tests/test_seed_chart.py` (или тест, валидирующий seed — найти через codegraph_explore + `test_seed` permissions), прогон `make seed` + +- [ ] **Step 1: Тест — seed загружает новое право и даёт его всем ролям** + +```python +async def test_seed_grants_quality_metrics_view_to_all_roles(session): + await run_seed(session) + role_count = await session.scalar(select(func.count(Role.id))) + granted = await session.scalar( + select(func.count(RolePermission.role_id)) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(Permission.code == "QUALITY_METRICS_VIEW") + ) + assert granted == role_count +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +Run: `cd api && pytest tests/test_seed_chart.py::test_seed_grants_quality_metrics_view_to_all_roles -v` +Expected: FAIL — права нет в seed. + +- [ ] **Step 3: Добавить запись в `permissions.yaml` и `role_permissions.yaml`** + +- [ ] **Step 4: Прогнать тест** + +Run: `cd api && pytest tests/test_seed_chart.py -v` +Expected: PASS. + +- [ ] **Step 5: Обновить `web/src/api/types.ts`** + +- [ ] **Step 6: Коммит** + +```bash +git add api/seed/core/permissions.yaml api/seed/core/role_permissions.yaml web/src/api/types.ts \ + api/tests/test_seed_chart.py +git commit -m "feat(#190): право QUALITY_METRICS_VIEW всем ролям (design §8)" +``` + +--- + +### Task 14: `domain/quality_metrics.py` — матрица причин + +**Files:** +- Create: `api/src/domain/quality_metrics.py` +- Test: `api/tests/test_domain_quality_metrics.py` + +**Interfaces:** +- Consumes: списки `QualityIntakeFact`/`QualityOutcomeFact`/`QualityFailureFact` — переданные как простые + объекты/dict (функция НЕ читает сессию; вызывающий сервис, Task 15, делает SELECT и передаёт сюда результат) +- Produces: `compute_matrix(outcomes: Sequence[QualityOutcomeFactRow]) -> MatrixResult` (имя входного типа и форма + `MatrixResult` — на решение исполнителя; обязательно: функция реализует приоритет §14.3 п.1-6 и возвращает + знаменатель только по `outcome ∈ {AUTO_CONFIRMED, CONFIRMED}`, пункт «Технические отказы считаются по файлам, + отдельной долей» — Task 15 считает их отдельным запросом, не через эту функцию), `compute_monthly_series(...)`. + +- [ ] **Step 1: Тест — приоритет причины (сервис > наш разбор > документ клиента > без участия > как есть)** + +```python +def test_compute_matrix_priority_service_over_our_error(): + # документ с primary_cause уже SERVICE_ERROR в факте — матрица не пересчитывает атрибуцию, только группирует + outcomes = [make_outcome_row(primary_cause=QualityCause.SERVICE_ERROR), make_outcome_row(primary_cause=QualityCause.OUR_ERROR)] + matrix = compute_matrix(outcomes) + assert matrix.counts[QualityCause.SERVICE_ERROR] == 1 + assert matrix.counts[QualityCause.OUR_ERROR] == 1 +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +- [ ] **Step 3: Реализовать `compute_matrix`** + +Докстринг `§14.3`. Группировка по уже сохранённому `primary_cause` (он вычислен один раз при записи факта, Tasks +6-9 — эта функция НЕ перевычисляет атрибуцию, только агрегирует и считает доли/знаменатель). + +- [ ] **Step 4: Тест — знаменатель долей только AUTO_CONFIRMED/CONFIRMED, «в работе» не входит** + +```python +def test_compute_matrix_denominator_excludes_in_progress_documents(): + # документ без outcome-факта не передаётся в compute_matrix вовсе (Task 15 его не выбирает) — эта функция + # получает только исходы с фактом; тест проверяет, что знаменатель = len(outcomes), не включает отдельно + ... +``` + +- [ ] **Step 5: Прогнать тесты модуля** + +Run: `cd api && pytest tests/test_domain_quality_metrics.py -v` +Expected: PASS. + +- [ ] **Step 6: Коммит** + +```bash +git add api/src/domain/quality_metrics.py api/tests/test_domain_quality_metrics.py +git commit -m "feat(#190): матрица причин — чистая функция (§14.3)" +``` + +--- + +### Task 15: API `/api/v1/quality-metrics` + +**Files:** +- Create: `api/src/services/quality_metrics_service.py` — `QualityMetricsService` (матрица за период/фильтры, + ряд по месяцам, список документов ячейки, выгрузка) — паттерн как `api/src/api/endpoints/consolidations/ + corrections.py` + сервис-слой: без `VisibilityScope` (design §8 — видимость по филиалу не применяется) +- Create: `api/src/api/dependencies/quality_metrics.py` — `QualityMetricsServiceDep` (паттерн — любой существующий + `*ServiceDep` в `src/api/dependencies/`, напр. `consolidations.py`) +- Create: `api/src/api/endpoints/quality_metrics.py` — router `/quality-metrics`: + - `GET /quality-metrics/matrix` (query: период, ГК, тип документа, скан/цифровой — design §7) + - `GET /quality-metrics/series` (ряд по месяцам по выбранной причине) + - `GET /quality-metrics/cells/{cause}/documents` (список документов ячейки, с пагинацией) + - `GET /quality-metrics/export` (CSV/JSON фактов за период) + - все под `Depends(require("QUALITY_METRICS_VIEW"))` +- Create: `api/src/schemas/quality_metrics.py` — `QualityMatrixOut`, `QualityMonthlySeriesOut`, + `QualityCellDocumentOut` (поля — 1:1 с design §7; деньги здесь не нужны — только счётчики) +- Modify: `api/src/app.py` (или где регистрируются роутеры — найти через codegraph_explore `app.include_router`) + — подключить новый router +- Test: `api/tests/test_api_quality_metrics.py` + +**Interfaces:** +- Consumes: `compute_matrix`/`compute_monthly_series` (Task 14), ORM-модели (Task 1) + +- [ ] **Step 1: Тест — матрица без права 403** + +```python +async def test_quality_metrics_matrix_requires_permission(client_without_permission): + response = await client_without_permission.get("/api/v1/quality-metrics/matrix") + assert response.status_code == 403 +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +Run: `cd api && pytest tests/test_api_quality_metrics.py -v` +Expected: FAIL — маршрута нет (404, не 403). + +- [ ] **Step 3: Реализовать схемы, сервис, роутер, подключить в `app.py`** + +- [ ] **Step 4: Тест — матрица с данными** + +```python +async def test_quality_metrics_matrix_returns_counts(client, ...): + # создать outcome-факты напрямую через insert_outcome_fact (Task 4), без полного цикла маппинга + response = await client.get("/api/v1/quality-metrics/matrix") + assert response.status_code == 200 + assert response.json()["counts"]["SERVICE_ERROR"] == 1 +``` + +- [ ] **Step 5: Тест — документ с двумя outcome-фактами считается один раз, по последнему** + +```python +async def test_quality_metrics_matrix_dedupes_to_latest_outcome_fact(client, session, ...): + # один document_id: факт 1 outcome=CONFIRMED primary_cause=AS_IS (outcome_at=t1), + # факт 2 outcome=SUPERSEDED_AFTER_CONFIRM primary_cause=NULL (outcome_at=t2 > t1) — как пишет Task 9 + await insert_outcome_fact(session, document_id=doc_id, outcome=QualityOutcome.CONFIRMED, + primary_cause=QualityCause.AS_IS, outcome_at=t1, ...) + await insert_outcome_fact(session, document_id=doc_id, outcome=QualityOutcome.SUPERSEDED_AFTER_CONFIRM, + primary_cause=None, outcome_at=t2, ...) + await session.commit() + response = await client.get("/api/v1/quality-metrics/matrix") + body = response.json() + assert body["counts"].get("AS_IS", 0) == 0 # устаревший факт не виден + assert body["superseded_after_confirm_count"] == 1 # последний факт — в отдельном столбце, не в матрице +``` + +- [ ] **Step 6: Запустить тест Step 5, убедиться что падает без дедупликации, затем реализовать выборку + последнего факта на `document_id` (`DISTINCT ON`/оконная функция, Review Focus) в `quality_metrics_service.py` + — во ВСЕХ запросах сервиса (matrix, series, cells/documents, export), не только в одном** + +Run: `cd api && pytest tests/test_api_quality_metrics.py::test_quality_metrics_matrix_dedupes_to_latest_outcome_fact -v` +Expected: сначала FAIL (наивный `SELECT` считает факт 1 тоже), после реализации — PASS. + +- [ ] **Step 7: Тест — список документов ячейки и выгрузка** + +- [ ] **Step 8: Прогнать весь файл** + +Run: `cd api && pytest tests/test_api_quality_metrics.py -v` +Expected: PASS. + +- [ ] **Step 9: Коммит** + +```bash +git add api/src/services/quality_metrics_service.py api/src/api/dependencies/quality_metrics.py \ + api/src/api/endpoints/quality_metrics.py api/src/schemas/quality_metrics.py api/src/app.py \ + api/tests/test_api_quality_metrics.py +git commit -m "feat(#190): API /quality-metrics — матрица, ряд по месяцам, список, выгрузка" +``` + +--- + +### Task 16: Экран «Качество первички» + +**Files:** +- Create: `web/src/types/qualityMetrics.ts` — 1:1 со схемами Task 15 (комментарий-подсказка как в + `web/src/types/corrections.ts:1`: «1:1 с components.schemas» — здесь аналогично с `schemas/quality_metrics.py`) +- Create: `web/src/services/api/qualityMetrics.ts` — функции `getMatrix`, `getSeries`, `getCellDocuments`, + `getExportUrl`, паттерн — любой существующий `web/src/services/api/*.ts` +- Create: `web/src/widgets/QualityMetrics/QualityMetricsPage.tsx` — страница: фильтры + матрица + динамика по + месяцам (раскладка — на решение исполнителя, design §7: «при реализации», владелец сказал «главное наличие + данных» — не гнаться за точным макетом, его нет) +- Create: `web/src/widgets/QualityMetrics/MatrixTable.tsx` — проверить, подходит ли + `web/src/components/GenericTable/` для 2D-матрицы с drill-down (строка = причина × источник, ячейка = count + + переход в список документов); если не подходит по форме (не табличный список строк, а именно сводная матрица) — + обычная antd `Table` вручную, как в `OsvTable.tsx`, не вписывать силой в `GenericTable` +- Create: `web/src/widgets/QualityMetrics/MonthlyTrend.tsx` — динамика по месяцам (линейный/столбчатый график — + посмотреть, какая либа графиков уже есть в `web/package.json`; если никакой нет — простая таблица «месяц → + счётчики» без новой зависимости, design не требует именно графика) +- Create: `web/src/widgets/QualityMetrics/DocumentDrillList.tsx` — список документов ячейки, переход в карточку + документа (уже существующий экран, найти его маршрут через codegraph_explore `DocumentReview route`) +- Modify: роутинг (найти файл регистрации маршрутов `/admin`-подобных страниц через codegraph_explore + `react-router routes App.tsx`) — новый маршрут `/quality-metrics`, в пункте меню — видимость по праву + `QUALITY_METRICS_VIEW` (см. `web/src/auth/permissions.ts`, паттерн как у существующих пунктов) +- Test: `web/src/widgets/QualityMetrics/*.test.ts(x)` — минимум: матрица рендерит счётчики из фикстуры ответа API, + пункт меню скрыт без права + +- [ ] **Step 1: Тест — пункт меню скрыт без права `QUALITY_METRICS_VIEW`** + +```typescript +test('menu item hidden without QUALITY_METRICS_VIEW permission', () => { + // паттерн — существующий тест видимости пункта меню по праву, найти через grep `permissions.ts` тестов в web/ +}); +``` + +- [ ] **Step 2: Запустить, убедиться что падает** + +Run: `cd web && npm test -- qualityMetrics` +Expected: FAIL — пункта меню/права ещё нет. + +- [ ] **Step 3: Добавить типы, API-клиент, пункт меню с проверкой права** + +- [ ] **Step 4: Тест — `MatrixTable` рендерит счётчики из фикстуры** + +```typescript +test('MatrixTable renders counts from fixture response', () => { + render(); + expect(screen.getByText('12')).toBeInTheDocument(); +}); +``` + +- [ ] **Step 5: Реализовать `MatrixTable`, `MonthlyTrend`, `DocumentDrillList`, `QualityMetricsPage`, маршрут** + +- [ ] **Step 6: Прогнать тесты и типы/сборку** + +Run: `cd web && npm test && npx tsc --noEmit && npm run build` +Expected: тесты зелёные, `tsc`/`build` без ошибок. + +- [ ] **Step 7: Проверить в браузере (dev-стенд) golden path** + +Открыть `/quality-metrics` под пользователем с правом — матрица отображает данные; без права — пункт меню и +маршрут недоступны (ручная проверка перед PR, см. `../docs/dev/qa-guide.md`). + +- [ ] **Step 8: Коммит** + +```bash +git add web/src/types/qualityMetrics.ts web/src/services/api/qualityMetrics.ts web/src/widgets/QualityMetrics/ \ + web/src/auth/permissions.ts <файл роутинга> +git commit -m "feat(#190): экран «Качество первички» (web)" +``` + +--- + +## Self-Review (выполнен при написании плана) + +**Spec coverage:** design §4.1-4.3/§1.4-1.6 docs → Task 1-2; §5/§14.2 → Task 3; §6/§14.3 → Task 14; §14.4 → Task 7; +§14.5 → Task 10; §9/raw → Task 11; §9/is_scan, §14.6 → Task 12; §8 → Task 13; §7 → Task 15-16. Non-goals (ретро, +время аналитика, счётчик «не разбирается», эталонный прогон, видимость по филиалу) — ни одной задачи, это +корректно (они не реализуются). + +**Type consistency:** `QualityOutcome`/`QualityCause`/`QualityFailureKind` объявлены в Task 1, используются без +переименования в Tasks 5-11, 14-15. `insert_intake_fact`/`insert_outcome_fact`/`insert_failure_fact` (Task 4) — +везде вызываются по этим именам в Tasks 5-11. + +**Proportion:** 16 задач на фичу с тремя новыми таблицами, 6 точками записи факта, API и экраном — соразмерно +объёму design.md (225 строк) + §14 (75 строк); самые длинные задачи (5, 9, 10, 15, 16) имеют по 4-7 файлов +каждая, что отражает реальное количество точек интеграции, а не раздувание. + +Плана недостаточно для самостоятельной работы без чтения design.md/§14 — он на это и не рассчитан (Spec travels +with the plan); экзекьютор каждой задачи должен сначала прочитать соответствующий параграф §14, затем бриф задачи. diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index f94907a..07d13e9 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -222,3 +222,9 @@ PDF, точный вид выгрузки, разбиение на задачи. | 27.10.2026 | Код добавленной строки ищется среди четырёхзначных токенов сырого ответа ИС, извлечённых при приёме, без переноса адаптера шага 1 в ядро (builder check) | адаптер живёт в scan-gateway; совпадение с суммой даёт «наш разбор» — по правилу «спорное — нам» | метрика сервиса чуть лучше реальной на добавленных строках | | 27.10.2026 | Технические отказы — по файлам, своей долей; фильтр по типу документа на них не действует; «уникальные отчёты» — счётчик, не режим (builder check) | у отказавшего файла нет документов и типа; смешивать файлы и документы в одном знаменателе нельзя | доля отказов не складывается с долями матрицы в 100 % | | 27.10.2026 | ОСВ — без построчного снимка, только статусы, предупреждения, отказы, правки периода/счёта | правок сумм ОСВ в ядре нет | при появлении правок ОСВ — доработка снимка | +| 05.11.2026 | Task 1: `api/scripts/dbml_parity.py` (схема таблицы — через явный allowlist `METRICS_TABLES`, не единственная константа `SCHEMA`), `api/scripts/check_dbml_vs_orm.py` (фиксирует `QUALITY_METRICS_SCHEMA=dkb_metrics` в окружении проверки, как уже делает для `DB_SCHEMA`) и `api/alembic/env.py` (`include_name` признаёт обе схемы) — доработаны под вторую схему; план задачи 1 предполагал, что `check_dbml_vs_orm.py` «скорее всего» не потребует правок | без этого `make dbml-check` безусловно падал бы на `quality_intake_fact: схема: ожидалось dkb / фактически dkb_metrics» для всех трёх таблиц фактов; это ровно тот структурный сюрприз, о котором бриф просил не обходить тихо, а чинить или сообщать — чинить оказалось дешевле и находится в рамках задачи (`make dbml-check` — приёмный критерий шага 7) | без правки вторая схема логически невозможна — любой PR с `dkb_metrics` падает на CI/проверке | +| 05.11.2026 | Три новых ORM-класса (`QualityIntakeFact/OutcomeFact/FailureFact`) — на ОБЩЕМ `Base`/`Base.metadata` (как и всё остальное), схема переопределена точечно через `__table_args__ = {"schema": METRICS_SCHEMA}`; второй `DeclarativeBase` НЕ заводился | ровно второй вариант, предложенный самим бри­фом («…или аналог существующего паттерна»); так `Base.metadata` остаётся единой точкой для `check_dbml_vs_orm.py`/`alembic/env.py`/`create_all` — отдельная metadata потребовала бы протащить её через все три места отдельно | при введении третьей схемы в будущем придётся повторить тот же приём, а не скопировать общий механизм | +| 05.11.2026 | Три новых enum-типа (`quality_outcome`, `quality_cause`, `quality_failure_kind`) — значения живут в схеме `SCHEMA` (`dkb`), НЕ в `METRICS_SCHEMA`, хотя используют их только таблицы `dkb_metrics` | `pg_enum()` (src/models/enums.py) без правок — единственный источник схемы enum'а; разводить enum'ы по двум схемам потребовало бы параметра схемы в фабрике без текущей пользы (Superset читает только таблицы, не типы) | таблица `dkb_metrics` зависит от типов в `dkb`; перенос схемы метрик на другой сервер БД без `dkb` невозможен без правки (сейчас design §10 этого не требует) | +| 05.11.2026 | Откат миграции 0012 НЕ удаляет схему `dkb_metrics` (оставляет её пустой), симметрично тому, что `SCHEMA` (`dkb`) никогда не удаляется в `env.py`/0001 | тест `test_upgrade_into_preprovisioned_schema_without_database_create` (api/tests/test_migrations.py) показал конкретный отказ: роль без `CREATE` на базу не могла бы создать `dkb_metrics` заново при повторном `upgrade head`, если бы платформа выдавала схему готовой, а откат её удалял; симметрия с `dkb` — тот же риск, то же решение | если отладочный откат до base оставляет пустую схему `dkb_metrics` — незначительный мусор, не ошибка | +| 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана | +| 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит | -- 2.47.2 From 6d1a322e87662e84a3e2eedeb865925b7dad2702 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 16:00:12 +0400 Subject: [PATCH 04/44] =?UTF-8?q?docs(#190):=20=D0=B4=D0=BE=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=B1=20=D0=BE=D1=82=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B5=20quality-facts.schema.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- openspec/changes/quality-metrics/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 07d13e9..cb19363 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -228,3 +228,4 @@ PDF, точный вид выгрузки, разбиение на задачи. | 05.11.2026 | Откат миграции 0012 НЕ удаляет схему `dkb_metrics` (оставляет её пустой), симметрично тому, что `SCHEMA` (`dkb`) никогда не удаляется в `env.py`/0001 | тест `test_upgrade_into_preprovisioned_schema_without_database_create` (api/tests/test_migrations.py) показал конкретный отказ: роль без `CREATE` на базу не могла бы создать `dkb_metrics` заново при повторном `upgrade head`, если бы платформа выдавала схему готовой, а откат её удалял; симметрия с `dkb` — тот же риск, то же решение | если отладочный откат до base оставляет пустую схему `dkb_metrics` — незначительный мусор, не ошибка | | 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана | | 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит | +| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл уже есть в `../docs/contracts/` (сиблинг-репозиторий arm-ka-docs, работа по нему уже слита туда), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | -- 2.47.2 From 26529b3bef0165f015437ac1109f191bd50ede3e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 16:04:25 +0400 Subject: [PATCH 05/44] =?UTF-8?q?feat(#190):=20=D1=87=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BA=D0=B8=20payload?= =?UTF-8?q?=20=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D0=B2=20=D0=BA=D0=B0=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20(=C2=A714.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 5.5 --- api/src/domain/quality_facts.py | 107 +++++++++++++++++++++ api/tests/test_domain_quality_facts.py | 127 +++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 api/src/domain/quality_facts.py create mode 100644 api/tests/test_domain_quality_facts.py diff --git a/api/src/domain/quality_facts.py b/api/src/domain/quality_facts.py new file mode 100644 index 0000000..a013204 --- /dev/null +++ b/api/src/domain/quality_facts.py @@ -0,0 +1,107 @@ +"""Чистые снимки `payload` фактов качества первички (§14.1; design `quality-metrics` §4.1, §4.2). + +Чистые функции без БД и сессии: строят JSON-блоб `payload` для `quality_intake_fact` (снимок «как распознано», при +приёме) и `quality_outcome_fact` (подтверждённый результат + список правок, при исходе). Запись в таблицы — в +репозитории и хуках сервисов, не здесь. + +Форма зависит от типа документа (§14.1): `BALANCE` / `INCOME` — шапка, строки, значения и нарушения; `OSV` — +только предупреждения `OSV_*` (построчного снимка и правок сумм в ядре нет). Снимок и исход различаются только +ключом `fixes` у исхода. +Объекты строк, значений и нарушений читаются атрибутами без привязки к сессии: отсутствующий атрибут даёт `None`. +""" + +from collections.abc import Iterable, Sequence +from datetime import date, datetime +from decimal import Decimal +from enum import Enum +from typing import Any +from uuid import UUID + +from src.models.enums import DocumentType +from src.models.files import Document + +__all__ = ["build_intake_payload", "build_outcome_payload"] + +_HEADER_FIELDS = ("document_type", "period_year", "period_quarter", "is_cumulative", "reporting_date") +_LINE_FIELDS = ("row_index", "line_code", "line_code_text", "line_status", "needs_review") +_VALUE_FIELDS = ( + "line_id", + "column_id", + "amount", + "string_value", + "source_text", + "confidence", + "is_recognized", + "needs_review", +) +_ISSUE_FIELDS = ("kind", "code", "severity", "source", "field", "relation", "codes", "line_ids") + + +def build_intake_payload( + document: Document, + *, + lines: Sequence[Any] | None = None, + values: Sequence[Any] | None = None, + issues: Iterable[Any] = (), + osv_warnings: Iterable[str] = (), +) -> dict[str, Any]: + """Снимок «как распознано» для `quality_intake_fact.payload` (§14.1, design §4.1). + + `BALANCE` / `INCOME`: `{"header", "lines", "values", "issues"}`. `OSV`: `{"osv_warnings"}` без `lines` и `values`. + """ + if document.document_type is DocumentType.OSV: + return {"osv_warnings": list(osv_warnings)} + return { + "header": _pick(document, _HEADER_FIELDS), + "lines": [_line(line) for line in lines or ()], + "values": [_value(value) for value in values or ()], + "issues": [_pick(issue, _ISSUE_FIELDS) for issue in issues], + } + + +def build_outcome_payload( # noqa: PLR0913 - сигнатура снимка фиксирована (design §4.2) + document: Document, + *, + lines: Sequence[Any] | None = None, + values: Sequence[Any] | None = None, + issues: Iterable[Any] = (), + osv_warnings: Iterable[str] = (), + fixes: Iterable[Any] = (), +) -> dict[str, Any]: + """Подтверждённый результат и правки для `quality_outcome_fact.payload` (§14.1, design §4.2). + + Строение — как у снимка приёма (`build_intake_payload`), плюс `fixes`: список правок с отнесением, каждая запись + строится вызывающим (состав записи — задача отнесения правок, не этой функции). + """ + payload = build_intake_payload(document, lines=lines, values=values, issues=issues, osv_warnings=osv_warnings) + payload["fixes"] = list(fixes) + return payload + + +def _line(line: Any) -> dict[str, Any]: + return { + "id": _plain(getattr(line, "id", None)), + "ref": _plain(getattr(line, "source_ref", None)), + **_pick(line, _LINE_FIELDS), + } + + +def _value(value: Any) -> dict[str, Any]: + return _pick(value, _VALUE_FIELDS) + + +def _pick(obj: Any, fields: Sequence[str]) -> dict[str, Any]: + return {field: _plain(getattr(obj, field, None)) for field in fields} + + +def _plain(value: Any) -> Any: + """Приводит значение к JSON-совместимому виду: Decimal, UUID и даты — строкой, enum — своим значением.""" + if isinstance(value, Enum): + return value.value + if isinstance(value, Decimal): + return str(value) + if isinstance(value, UUID): + return str(value) + if isinstance(value, datetime | date): + return value.isoformat() + return value diff --git a/api/tests/test_domain_quality_facts.py b/api/tests/test_domain_quality_facts.py new file mode 100644 index 0000000..6bae63a --- /dev/null +++ b/api/tests/test_domain_quality_facts.py @@ -0,0 +1,127 @@ +"""Чистые снимки payload фактов качества (§14.1): форма для ОБ/ОФР и для ОСВ.""" + +import uuid +from decimal import Decimal + +from src.domain.quality_facts import build_intake_payload, build_outcome_payload +from src.models.enums import ( + DocumentIssueKind, + DocumentStatus, + DocumentType, + IssueSeverity, + IssueSource, + StatementLineStatus, +) +from src.models.files import Document +from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue + + +def make_document(document_type: DocumentType = DocumentType.BALANCE) -> Document: + return Document( + id=uuid.uuid4(), + document_type=document_type, + status=DocumentStatus.RECOGNIZED, + ) + + +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 + + +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"] + + +def test_build_intake_payload_statement_serializes_line_value_and_issue_fields(): + document = make_document(document_type=DocumentType.INCOME) + line_id = uuid.uuid4() + column_id = uuid.uuid4() + line = FinancialStatementLine( + id=line_id, + source_ref="row:3", + row_index=3, + line_code="2110", + line_code_text="2110", + line_status=StatementLineStatus.ok, + needs_review=True, + ) + value = FinancialStatementValue( + line_id=line_id, + column_id=column_id, + amount=Decimal("1500.00"), + string_value=None, + source_text="1 500", + confidence=Decimal("95.00"), + is_recognized=True, + needs_review=False, + ) + issue = DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.relation, + code="RELATION_BROKEN", + severity=IssueSeverity.error, + message="нарушено", + source=IssueSource.core, + relation="2110 = 2120", + codes=["2110"], + ) + payload = build_intake_payload(document, lines=[line], values=[value], issues=[issue]) + assert payload["lines"] == [ + { + "id": str(line_id), + "ref": "row:3", + "row_index": 3, + "line_code": "2110", + "line_code_text": "2110", + "line_status": "ok", + "needs_review": True, + } + ] + assert payload["values"] == [ + { + "line_id": str(line_id), + "column_id": str(column_id), + "amount": "1500.00", + "string_value": None, + "source_text": "1 500", + "confidence": "95.00", + "is_recognized": True, + "needs_review": False, + } + ] + assert payload["issues"] == [ + { + "kind": "relation", + "code": "RELATION_BROKEN", + "severity": "error", + "source": "core", + "field": None, + "relation": "2110 = 2120", + "codes": ["2110"], + "line_ids": None, + } + ] + + +def test_build_outcome_payload_statement_has_fixes_and_no_osv_warnings(): + document = make_document(document_type=DocumentType.BALANCE) + fix = {"what": "line_code", "was": "2110", "became": "2120", "source": "service", "rule": "§5"} + payload = build_outcome_payload(document, lines=[], values=[], issues=[], fixes=[fix]) + assert payload["fixes"] == [fix] + assert "lines" in payload and "values" in payload + assert "osv_warnings" not in payload + + +def test_build_outcome_payload_osv_has_fixes_and_no_lines(): + document = make_document(document_type=DocumentType.OSV) + fix = {"what": "period", "was": "2024 Q1", "became": "2024 Q2", "source": "ours", "rule": "§5"} + payload = build_outcome_payload(document, osv_warnings=["OSV_SOMETHING"], fixes=[fix]) + assert payload["osv_warnings"] == ["OSV_SOMETHING"] + assert payload["fixes"] == [fix] + assert "lines" not in payload and "values" not in payload -- 2.47.2 From 5f1001c808ba0e42c954e0cd1212fe01ef8d7dc2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 16:07:45 +0400 Subject: [PATCH 06/44] =?UTF-8?q?fix(#190):=20header=5Fsource=20=D0=B8=20?= =?UTF-8?q?=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=9E=D0=A1=D0=92=20=D0=B2=20payload=20=D1=84?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D0=B2=20=D0=BA=D0=B0=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B0=20(=C2=A714.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снимки приёма и исхода получают необязательные kwargs: header_source (сырой текст шапки из financial_statement_meta.header_source) кладётся в header для ББ/ОФР, osv_row_count — в payload ОСВ как row_count. Без kwargs ключи не эмитятся. Co-Authored-By: Claude Sonnet 5 --- api/src/domain/quality_facts.py | 45 +++++++++++++++++++++----- api/tests/test_domain_quality_facts.py | 38 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/api/src/domain/quality_facts.py b/api/src/domain/quality_facts.py index a013204..a702689 100644 --- a/api/src/domain/quality_facts.py +++ b/api/src/domain/quality_facts.py @@ -10,7 +10,7 @@ Объекты строк, значений и нарушений читаются атрибутами без привязки к сессии: отсутствующий атрибут даёт `None`. """ -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence from datetime import date, datetime from decimal import Decimal from enum import Enum @@ -37,22 +37,33 @@ _VALUE_FIELDS = ( _ISSUE_FIELDS = ("kind", "code", "severity", "source", "field", "relation", "codes", "line_ids") -def build_intake_payload( +def build_intake_payload( # noqa: PLR0913 - сигнатура снимка фиксирована (design §4.1) document: Document, *, lines: Sequence[Any] | None = None, values: Sequence[Any] | None = None, issues: Iterable[Any] = (), osv_warnings: Iterable[str] = (), + header_source: Mapping[str, Any] | None = None, + osv_row_count: int | None = None, ) -> dict[str, Any]: """Снимок «как распознано» для `quality_intake_fact.payload` (§14.1, design §4.1). - `BALANCE` / `INCOME`: `{"header", "lines", "values", "issues"}`. `OSV`: `{"osv_warnings"}` без `lines` и `values`. + `BALANCE` / `INCOME`: `{"header", "lines", "values", "issues"}`; при переданном `header_source` (сырой текст и + уверенность по полям шапки, `financial_statement_meta.header_source`) он кладётся в `header["header_source"]`. + `OSV`: `{"osv_warnings"}` без `lines` и `values`; при переданном `osv_row_count` добавляется `row_count`. + Без необязательных аргументов ключей `header_source` и `row_count` в payload нет. """ if document.document_type is DocumentType.OSV: - return {"osv_warnings": list(osv_warnings)} + payload: dict[str, Any] = {"osv_warnings": list(osv_warnings)} + if osv_row_count is not None: + payload["row_count"] = osv_row_count + return payload + header = _pick(document, _HEADER_FIELDS) + if header_source is not None: + header["header_source"] = {key: _plain_mapping(entry) for key, entry in header_source.items()} return { - "header": _pick(document, _HEADER_FIELDS), + "header": header, "lines": [_line(line) for line in lines or ()], "values": [_value(value) for value in values or ()], "issues": [_pick(issue, _ISSUE_FIELDS) for issue in issues], @@ -67,13 +78,24 @@ def build_outcome_payload( # noqa: PLR0913 - сигнатура снимка ф issues: Iterable[Any] = (), osv_warnings: Iterable[str] = (), fixes: Iterable[Any] = (), + header_source: Mapping[str, Any] | None = None, + osv_row_count: int | None = None, ) -> dict[str, Any]: """Подтверждённый результат и правки для `quality_outcome_fact.payload` (§14.1, design §4.2). - Строение — как у снимка приёма (`build_intake_payload`), плюс `fixes`: список правок с отнесением, каждая запись - строится вызывающим (состав записи — задача отнесения правок, не этой функции). + Строение — как у снимка приёма (`build_intake_payload`, с теми же `header_source` и `osv_row_count`), плюс `fixes`: + список правок с отнесением, каждая запись строится вызывающим (состав записи — задача отнесения правок, не этой + функции). """ - payload = build_intake_payload(document, lines=lines, values=values, issues=issues, osv_warnings=osv_warnings) + payload = build_intake_payload( + document, + lines=lines, + values=values, + issues=issues, + osv_warnings=osv_warnings, + header_source=header_source, + osv_row_count=osv_row_count, + ) payload["fixes"] = list(fixes) return payload @@ -86,6 +108,13 @@ def _line(line: Any) -> dict[str, Any]: } +def _plain_mapping(entry: Any) -> Any: + """Приводит запись `header_source` (`{"text", "confidence"}`) к JSON-совместимому виду; Decimal → строка.""" + if isinstance(entry, Mapping): + return {key: _plain(item) for key, item in entry.items()} + return _plain(entry) + + def _value(value: Any) -> dict[str, Any]: return _pick(value, _VALUE_FIELDS) diff --git a/api/tests/test_domain_quality_facts.py b/api/tests/test_domain_quality_facts.py index 6bae63a..796cd9f 100644 --- a/api/tests/test_domain_quality_facts.py +++ b/api/tests/test_domain_quality_facts.py @@ -15,6 +15,8 @@ from src.models.enums import ( from src.models.files import Document from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue +ROW_COUNT = 4_200 + def make_document(document_type: DocumentType = DocumentType.BALANCE) -> Document: return Document( @@ -125,3 +127,39 @@ def test_build_outcome_payload_osv_has_fixes_and_no_lines(): assert payload["osv_warnings"] == ["OSV_SOMETHING"] assert payload["fixes"] == [fix] assert "lines" not in payload and "values" not in payload + + +def test_build_intake_payload_includes_header_source_for_statement(): + document = make_document(document_type=DocumentType.BALANCE) + header_source = {"inn": {"text": "7701234567", "confidence": Decimal("100.00")}} + payload = build_intake_payload(document, lines=[], values=[], header_source=header_source) + assert payload["header"]["header_source"] == {"inn": {"text": "7701234567", "confidence": "100.00"}} + + +def test_build_outcome_payload_includes_header_source_for_statement(): + document = make_document(document_type=DocumentType.INCOME) + header_source = {"okpo": {"text": "12345678", "confidence": None}} + payload = build_outcome_payload(document, lines=[], values=[], fixes=[], header_source=header_source) + assert payload["header"]["header_source"] == {"okpo": {"text": "12345678", "confidence": None}} + + +def test_build_intake_payload_includes_osv_row_count(): + document = make_document(document_type=DocumentType.OSV) + payload = build_intake_payload(document, osv_warnings=["OSV_SOMETHING"], osv_row_count=ROW_COUNT) + assert payload["row_count"] == ROW_COUNT + assert payload["osv_warnings"] == ["OSV_SOMETHING"] + + +def test_build_outcome_payload_includes_osv_row_count(): + document = make_document(document_type=DocumentType.OSV) + payload = build_outcome_payload(document, osv_warnings=[], fixes=[], osv_row_count=0) + assert payload["row_count"] == 0 + + +def test_header_source_and_row_count_absent_when_kwargs_omitted(): + balance = build_intake_payload(make_document(document_type=DocumentType.BALANCE), lines=[], values=[]) + assert "header_source" not in balance["header"] + osv = build_intake_payload(make_document(document_type=DocumentType.OSV), osv_warnings=[]) + assert "row_count" not in osv + outcome_osv = build_outcome_payload(make_document(document_type=DocumentType.OSV), osv_warnings=[], fixes=[]) + assert "row_count" not in outcome_osv -- 2.47.2 From 5603dd1898b7c1b1fd2c81efd071b2663502c420 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 18:37:57 +0400 Subject: [PATCH 07/44] =?UTF-8?q?feat(#190):=20=D0=B0=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=B1=D1=83=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81/=D0=BD=D0=B0?= =?UTF-8?q?=D1=88=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20(=C2=A714.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- api/src/domain/quality_attribution.py | 86 ++++++++++++++++++++ api/tests/test_domain_quality_attribution.py | 62 ++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 api/src/domain/quality_attribution.py create mode 100644 api/tests/test_domain_quality_attribution.py diff --git a/api/src/domain/quality_attribution.py b/api/src/domain/quality_attribution.py new file mode 100644 index 0000000..7fe1412 --- /dev/null +++ b/api/src/domain/quality_attribution.py @@ -0,0 +1,86 @@ +"""Атрибуция правки «сервис / наш разбор» (§14.2 `05-domain-rules.md`; design `quality-metrics` §5). + +Правка — отличие подтверждённого аналитиком значения от того, что пришло на вход (снимок `quality_intake_fact`). +Правило одно: верный ответ есть в сыром тексте, который распознал сервис (ИС Сканирование / `tabular-parser`), — +ошибка наша (мы неверно разобрали то, что сервис прочитал правильно); верного ответа в сыром тексте нет — ошибка +сервиса. Для Excel сервис разбора — наш же `tabular-parser`, поэтому там любая ошибка распознавания — «наш разбор» +(design §5); это решает вызывающий, не передавая Excel-документы сюда, а не отдельная ветка в этих функциях. + +Разбор сырого текста — тем же кодом, что разбирал сервис: публичным API `arm_statements` +(`fix_lookalikes`, `parse_amount`, `parse_code`, `default_rules`). Правила читаются один раз на вызов +(`default_rules()` — чистая и дешёвая фабрика пакета, копия правил per-call не кэшируется: это чистый домен без +состояния на процесс, кэш — не его забота). +""" + +from collections.abc import Sequence +from typing import Literal + +from arm_statements import Source, default_rules, fix_lookalikes, parse_amount, parse_code + +__all__ = [ + "attribute_added_row", + "attribute_code_fix", + "attribute_header_fix", + "attribute_value_fix", +] + +Attribution = Literal["service", "ours"] + + +def _digits_only(text: str) -> str: + return "".join(char for char in text if char.isdigit()) + + +def attribute_value_fix(confirmed: str, raw_text: str) -> Attribution: + """§14.2 «Сумма»: «наш разбор», если цифры подтверждённой суммы (без знака) совпадают с цифрами, которые + извлекает из сырого текста `parse_amount` после замены двойников §11.3 — сервис прочитал верно, а мы + неверно разобрали (знак, скобки, разряды, мусор по краям); иначе — «сервис» (сервис сам прочитал не те цифры). + + `parse_amount` требует `Source`; здесь он не определяет исход («О»→«0» и выделение числового ядра в тексте + работают одинаково для PDF и Excel), поэтому берём `Source.OCR` — распознавание различий между источниками + в `parse_amount` касается только неоднозначных разделителей тысяч, а их чтение в эту цифровую проверку не + входит (решение задачи 3, см. отчёт). + """ + rules = default_rules() + parsed = parse_amount(raw_text, Source.OCR, rules) + if parsed.amount is None: + return "service" + return "ours" if _digits_only(str(abs(parsed.amount))) == _digits_only(confirmed) else "service" + + +def attribute_code_fix(confirmed_code: str, raw_text: str) -> Attribution: + """§14.2 «Код строки»: «наш разбор», если подтверждённый код есть среди кодов, которые `parse_code` извлекает + из строк сырого `line_code_text` (в т. ч. из одной из частей склейки `1250\\n1260` — каждая строка разбирается + отдельно, как это делает сам пакет для многострочных ячеек); иначе — «сервис». + """ + rules = default_rules() + codes: set[str] = set() + for line in raw_text.splitlines(): + if not line.strip(): + continue + parsed = parse_code(line, rules) + if parsed.code is not None: + codes.add(parsed.code) + return "ours" if confirmed_code in codes else "service" + + +def attribute_added_row(code: str, raw_line_codes: Sequence[str], *, is_excel: bool) -> Attribution: + """§14.2 «Добавленная строка» (`row_index IS NULL`): для Excel всегда «наш разбор» (сервис разбора Excel — наш + `tabular-parser`, `raw_line_codes` не смотрим вовсе); для PDF — «наш разбор», если код добавленной строки есть + среди четырёхзначных токенов сырого ответа ИС Сканирование (`raw_line_codes` извлекаются раньше, при приёме, — + не задача этого модуля); иначе — «сервис» (сервис вообще не увидел такой код). + """ + if is_excel: + return "ours" + return "ours" if code in raw_line_codes else "service" + + +def attribute_header_fix(confirmed: str, header_source_text: str) -> Attribution: + """§14.2 «Шапка» (период, ИНН, единицы, наименование) и «Дата колонки»: «наш разбор», если подтверждённое + значение можно найти в сыром тексте источника после замены двойников §11.3 — `header_source[*].text` для полей + шапки или подпись колонки (`label`) для даты колонки; какой именно текст передать — решает вызывающий, функция + одна и та же для обоих случаев (design §5). Иначе — «сервис». + """ + rules = default_rules() + normalized_source = fix_lookalikes(header_source_text, rules.lookalikes) + return "ours" if confirmed in normalized_source else "service" diff --git a/api/tests/test_domain_quality_attribution.py b/api/tests/test_domain_quality_attribution.py new file mode 100644 index 0000000..a5897cf --- /dev/null +++ b/api/tests/test_domain_quality_attribution.py @@ -0,0 +1,62 @@ +"""Атрибуция правки «сервис / наш разбор» (§14.2).""" + +from src.domain.quality_attribution import ( + attribute_added_row, + attribute_code_fix, + attribute_header_fix, + attribute_value_fix, +) + + +def test_attribute_value_fix_ours_when_raw_matches_after_lookalikes(): + # сырой текст "1OOO.00" (O вместо 0), подтверждено 1000.00 — цифры совпадают после замены двойников → "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" + + +def test_attribute_value_fix_ignores_sign_difference(): + # знак — наша зона ответственности (скобки/минус разобраны неверно), цифры сошлись → "ours" + assert attribute_value_fix("-1000.00", "(1000.00)") == "ours" + + +def test_attribute_code_fix_ours_when_code_in_raw_text(): + # код найден среди кодов склейки "1250\n1260" (две строки сырого line_code_text) → наш разбор, не сервис + # (имя теста в брифе и его ассерт противоречили таблице §14.2 — исправлено по дизайну, см. отчёт задачи) + assert attribute_code_fix("1250", "1250\n1260") == "ours" + + +def test_attribute_code_fix_service_when_code_not_in_raw_text(): + assert attribute_code_fix("1250", "9999\n9998") == "service" + + +def test_attribute_added_row_excel_always_ours(): + assert attribute_added_row("1250", raw_line_codes=["1250"], is_excel=True) == "ours" + + +def test_attribute_added_row_excel_always_ours_even_if_code_absent(): + # для Excel правило не смотрит на raw_line_codes вовсе — ошибка всегда наша (наш tabular-parser) + assert attribute_added_row("1250", raw_line_codes=[], is_excel=True) == "ours" + + +def test_attribute_added_row_pdf_ours_when_code_in_raw_tokens(): + assert attribute_added_row("1250", raw_line_codes=["1100", "1250", "1600"], is_excel=False) == "ours" + + +def test_attribute_added_row_pdf_service_when_code_absent(): + assert attribute_added_row("1250", raw_line_codes=["1100", "1600"], is_excel=False) == "service" + + +def test_attribute_header_fix_ours_when_confirmed_in_source_text(): + assert attribute_header_fix("Рога и копыта", "ООО «Рога и копыта»") == "ours" + + +def test_attribute_header_fix_service_when_confirmed_not_in_source_text(): + assert attribute_header_fix("Рога и копыта", "ООО «Другая компания»") == "service" + + +def test_attribute_header_fix_ours_after_lookalikes_for_inn(): + # ИНН — цифровое поле шапки: двойник буквы рядом с цифрой (О вместо 0) исправляется §11.3 перед сравнением + assert attribute_header_fix("7000112233", "7OOO112233") == "ours" -- 2.47.2 From a2d24fe80272fe71a1f267e42399210c6d665613 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 18:48:11 +0400 Subject: [PATCH 08/44] =?UTF-8?q?feat(#190):=20=D1=80=D0=B5=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B9=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=20=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тонкие функции insert_intake_fact / insert_outcome_fact / insert_failure_fact: session.add без flush и commit, факт попадает в транзакцию вызывающего (design §4). Co-Authored-By: Claude Haiku 5.5 --- api/src/repositories/quality_facts.py | 25 ++++ api/tests/test_repositories_quality_facts.py | 122 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 api/src/repositories/quality_facts.py create mode 100644 api/tests/test_repositories_quality_facts.py diff --git a/api/src/repositories/quality_facts.py b/api/src/repositories/quality_facts.py new file mode 100644 index 0000000..8c84c32 --- /dev/null +++ b/api/src/repositories/quality_facts.py @@ -0,0 +1,25 @@ +"""Запись фактов качества первички (design §4; AURORA-1759). + +Тонкий слой без бизнес-логики: факт добавляется в сессию вызывающего и попадает в ТУ ЖЕ транзакцию, что и событие, +которое его породило. Функции не делают `flush` и `commit` — решает вызывающий. Аргументы — kwargs по столбцам +DBML/ORM; колонки, не переданные вызывающим, берут значения по умолчанию БД (`server_default`). +""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact + + +async def insert_intake_fact(session: AsyncSession, **columns) -> None: + """Добавляет факт «документ как распознано» (design §4.1).""" + session.add(QualityIntakeFact(**columns)) + + +async def insert_outcome_fact(session: AsyncSession, **columns) -> None: + """Добавляет факт исхода документа (design §4.2).""" + session.add(QualityOutcomeFact(**columns)) + + +async def insert_failure_fact(session: AsyncSession, **columns) -> None: + """Добавляет факт технического отказа задания или ошибки маппинга (design §4.3).""" + session.add(QualityFailureFact(**columns)) diff --git a/api/tests/test_repositories_quality_facts.py b/api/tests/test_repositories_quality_facts.py new file mode 100644 index 0000000..d9f15b5 --- /dev/null +++ b/api/tests/test_repositories_quality_facts.py @@ -0,0 +1,122 @@ +"""Запись фактов качества (§4 design): roundtrip через реальную БД (маркер `integration`).""" + +import datetime +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import ( + DocumentStatus, + DocumentType, + QualityFailureKind, + QualityOutcome, + RecognitionPipeline, +) +from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact +from src.repositories.quality_facts import insert_failure_fact, insert_intake_fact, insert_outcome_fact + +INTAKE_AT = datetime.datetime(2026, 11, 5, 10, 0, tzinfo=datetime.UTC) +LINE_COUNT = 42 +FIXES_VALUE_SERVICE = 3 +FAILURE_ATTEMPT = 2 + + +@pytest.mark.integration +async def test_insert_intake_fact_roundtrip(db_session: AsyncSession) -> None: + document_id = uuid.uuid4() + file_id = uuid.uuid4() + job_id = uuid.uuid4() + legal_entity_id = uuid.uuid4() + + await insert_intake_fact( + db_session, + document_id=document_id, + file_id=file_id, + job_id=job_id, + attempt=1, + legal_entity_id=legal_entity_id, + legal_entity_name='ООО "Русский бисквит"', + group_id=None, + group_name=None, + document_type=DocumentType.BALANCE, + pipeline=RecognitionPipeline.image, + is_scan=True, + service_name="scan-gateway", + intake_at=INTAKE_AT, + intake_status=DocumentStatus.RECOGNIZED, + line_count=LINE_COUNT, + payload={"lines": []}, + ) + await db_session.flush() + + row = ( + await db_session.execute(select(QualityIntakeFact).where(QualityIntakeFact.document_id == document_id)) + ).scalar_one() + assert row.file_id == file_id + assert row.job_id == job_id + assert row.legal_entity_name == 'ООО "Русский бисквит"' + assert row.document_type == DocumentType.BALANCE + assert row.pipeline == RecognitionPipeline.image + assert row.intake_status == DocumentStatus.RECOGNIZED + assert row.line_count == LINE_COUNT + assert row.payload == {"lines": []} + + +@pytest.mark.integration +async def test_insert_outcome_fact_roundtrip(db_session: AsyncSession) -> None: + document_id = uuid.uuid4() + + await insert_outcome_fact( + db_session, + document_id=document_id, + outcome=QualityOutcome.CONFIRMED, + outcome_at=INTAKE_AT, + fixes_value_service=FIXES_VALUE_SERVICE, + checks_accepted=True, + payload={"checks": []}, + ) + await db_session.flush() + + row = ( + await db_session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) + ).scalar_one() + assert row.outcome == QualityOutcome.CONFIRMED + assert row.outcome_at == INTAKE_AT + assert row.fixes_value_service == FIXES_VALUE_SERVICE + # не переданные счётчики берутся из server_default БД + assert row.fixes_value_ours == 0 + assert row.checks_accepted is True + assert row.payload == {"checks": []} + + +@pytest.mark.integration +async def test_insert_failure_fact_roundtrip(db_session: AsyncSession) -> None: + job_id = uuid.uuid4() + file_id = uuid.uuid4() + + await insert_failure_fact( + db_session, + job_id=job_id, + file_id=file_id, + attempt=FAILURE_ATTEMPT, + legal_entity_id=uuid.uuid4(), + legal_entity_name='ООО "Русский бисквит"', + group_id=None, + group_name=None, + pipeline=RecognitionPipeline.tabular, + is_scan=False, + service_name="tabular-parser", + failure_kind=QualityFailureKind.TIMEOUT, + error_code="TIMEOUT", + failed_at=INTAKE_AT, + ) + await db_session.flush() + + row = (await db_session.execute(select(QualityFailureFact).where(QualityFailureFact.job_id == job_id))).scalar_one() + assert row.file_id == file_id + assert row.attempt == FAILURE_ATTEMPT + assert row.failure_kind == QualityFailureKind.TIMEOUT + assert row.error_code == "TIMEOUT" + assert row.failed_at == INTAKE_AT -- 2.47.2 From 12ea8acd423bb8699cf9549c86445a4bcc244a02 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 19:07:02 +0400 Subject: [PATCH 09/44] =?UTF-8?q?feat(#190):=20=D1=84=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B5=20(=C2=A714.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хук в _map_candidate (result_mapping.py): после map_file, до auto_confirm — для каждого документа BALANCE/INCOME/OSV строит снимок build_intake_payload и пишет quality_intake_fact через insert_intake_fact. Чисто добавочный шаг: не меняет документы, статусы и прочий результат маппинга; DISCLOSURE/OTHER факта не получают. Счётчики строк/значений — из financial_statement_* (BALANCE/INCOME) и document_issue; warning_count/osv_warnings — предупреждения источника service (document_issue.source = service): для BALANCE/ОФР 1.x таких записей нет, для ОСВ это коды OSV_*. Для ОСВ построчные счётчики не считаются (их в ядре нет), только row_count в payload. Тесты в test_result_mapping.py (маркер integration, не запускались — нет PostgreSQL в песочнице, подтверждена только коллекция) + один DB-free тест на множество типов документов хука. Co-Authored-By: Claude Sonnet 5 --- api/src/services/result_mapping.py | 128 +++++++++++++++++++++++++++++ api/tests/test_result_mapping.py | 86 +++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index d1630fb..95d0bca 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -53,6 +53,7 @@ from src.domain.osv_mapping import ( document_status, opening_matches, ) +from src.domain.quality_facts import build_intake_payload from src.domain.statement_issues import IssueDraft, mapping_warning from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok from src.domain.statement_status import duplicate_period_forms, has_error_warning, osv_status_v2 @@ -73,11 +74,14 @@ from src.models.enums import ( OsvGroupType, PeriodQuarter, RecognitionStatus, + StatementLineStatus, ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.models.statements import DocumentIssue from src.repositories import statements as statements_repo from src.repositories.counterparties import CounterpartyIndex +from src.repositories.quality_facts import insert_intake_fact from src.services import consolidation_documents from src.services.audit import AuditService from src.services.file_status import set_file_status_for_job @@ -119,6 +123,8 @@ MAPPABLE_JOB_STATUSES = (RecognitionStatus.done, RecognitionStatus.needs_check) CUMULATIVE_CHECK_QUARTERS = frozenset({PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4}) RESOLVED_DATA_TYPES = frozenset({OsvDataType.BU, OsvDataType.SUM}) # резолв контрагента — §3 п. 4, R-3 ROOT_LEVEL = 0 +# §14.1 (design quality-metrics §4.1): факт приёма пишется только для этих типов; DISCLOSURE/OTHER — без факта +INTAKE_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV}) STORED_FILE_OBJECT_TYPE = "stored_file" # `audit_event.object_type` события FILE_REPLACE SUPERSEDE_LOCK_PREFIX = "supersede" # пространство ключей advisory-блокировки автозамены в пределах ЮЛ _FORBIDDEN_KEY_SEGMENTS = frozenset({"", ".", ".."}) @@ -1094,6 +1100,123 @@ async def _register_failure( return exhausted +# --- факт приёма (quality_intake_fact, §14.1, design quality-metrics §4.1, AURORA-1759) ------------------------------ + + +async def _s3_key(session: AsyncSession, object_id: uuid.UUID | None) -> str | None: + """Ключ `s3_object` по id для факта качества (хранится строкой, без FK — design §4); нет id или записи — `None`.""" + if object_id is None: + return None + return (await session.execute(select(S3Object.key).where(S3Object.id == object_id))).scalar_one_or_none() + + +async def _statement_intake_fields( + session: AsyncSession, document: Document, issues: Sequence[DocumentIssue] +) -> dict[str, Any]: + """Счётчики и `payload` факта приёма формы ББ/ОФР: строки и значения — `financial_statement_*` формы документа, + нарушения/предупреждения — уже прочитанные `document_issue`. `warning_count` — предупреждения СЕРВИСА + (`document_issue.source = service`; у `1.x` таких записей нет, формирует только маппер `2.0`). + + Формы без шапки (`meta is None`) из текущего `map_file` не возникают — каждая форма получает `financial_statement_ + meta` в той же транзакции (`_insert_statements`/`insert_forms_v2`); на случай, если это всё же не так, снимок без + построчных данных дешевле, чем прерванная транзакция маппинга.""" + meta = await statements_repo.get_meta(session, document.id) + if meta is None: + return {"payload": build_intake_payload(document, issues=issues)} + _, pairs = await statements_repo.load_form(session, meta.id) + lines = [line for line, _ in pairs] + values = [value for _, line_values in pairs for value in line_values] + return { + "line_count": len(lines), + "value_count": len(values), + "value_needs_review_count": sum(1 for value in values if value.needs_review), + "line_unreadable_count": sum(1 for line in lines if line.line_status is StatementLineStatus.unreadable), + "line_missing_count": sum(1 for line in lines if line.line_status is StatementLineStatus.missing), + "issue_relation_count": sum(1 for issue in issues if issue.kind is DocumentIssueKind.relation), + "issue_soft_count": sum(1 for issue in issues if issue.kind is DocumentIssueKind.soft), + "warning_count": sum( + 1 for issue in issues if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service + ), + "payload": build_intake_payload( + document, lines=lines, values=values, issues=issues, header_source=meta.header_source + ), + } + + +async def _osv_intake_fields( + session: AsyncSession, document: Document, issues: Sequence[DocumentIssue] +) -> dict[str, Any]: + """`payload` факта приёма ОСВ: без построчного снимка (строк/значений §7 правок у ОСВ в ядре нет) — только число + строк `osv_entry` и коды предупреждений сервиса `OSV_*` (`document_issue` вида `warning`, `source = service`, + см. `_osv_warning_issue`; предупреждения маппинга ядра — `MAPPING_WARNING`, `source = core` — в этот список не + входят, design §4.1 «коды предупреждений OSV_*»). Счётчики строк/значений формы для ОСВ не считаются (`None`).""" + row_count = ( + await session.execute(select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id)) + ).scalar_one() + osv_warnings = [ + issue.code + for issue in issues + if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service + ] + return {"payload": build_intake_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count)} + + +async def _write_intake_facts( # noqa: PLR0913 - все входы факта приёма именованные + session: AsyncSession, + *, + job: RecognitionJob, + file: StoredFile, + owner: LegalEntity, + documents: Sequence[Document], + now: datetime, +) -> None: + """Факт приёма «как распознано» (§14.1, design §4.1): один `quality_intake_fact` на каждый документ + BALANCE/INCOME/OSV файла (`DISCLOSURE`/`OTHER` — без факта); пишется в транзакции маппинга, после `map_file` + (документы, строки форм и `document_issue` уже вставлены), до `auto_confirm`. Чисто добавочный шаг: не меняет + `documents`, статусы и прочий результат маппинга.""" + group_name: str | None = None + if owner.company_group_id is not None: + group = await session.get(CompanyGroup, owner.company_group_id) + group_name = group.name if group is not None else None + raw_s3_key = await _s3_key(session, job.raw_s3_object_id) + result_s3_key = await _s3_key(session, job.result_s3_object_id) + upload_s3_key = await _s3_key(session, file.s3_object_id) + for document in documents: + if document.document_type not in INTAKE_FACT_DOCUMENT_TYPES: + continue + issues = await statements_repo.issues(session, document.id) + fields = ( + await _osv_intake_fields(session, document, issues) + if document.document_type is DocumentType.OSV + else await _statement_intake_fields(session, document, issues) + ) + await insert_intake_fact( + session, + document_id=document.id, + file_id=file.id, + job_id=job.id, + attempt=job.attempt, + legal_entity_id=file.legal_entity_id, + legal_entity_name=owner.name, + group_id=owner.company_group_id, + group_name=group_name, + document_type=document.document_type, + pipeline=job.pipeline, + is_scan=file.is_scan, + service_name=job.service_name, + service_version=job.service_version, + statements_version=job.statements_version, + result_schema_version=job.result_schema_version, + report_date=document.reporting_date, + intake_at=now, + intake_status=document.status, + raw_s3_key=raw_s3_key, + result_s3_key=result_s3_key, + upload_s3_key=upload_s3_key, + **fields, + ) + + async def _map_candidate( session_factory: async_sessionmaker[AsyncSession], candidate: Candidate, @@ -1128,6 +1251,11 @@ async def _map_candidate( # автоподтверждение чистых документов (привязка и переход версии, OQ-157, design D-1), затем переход по # `NEEDS_CHECK` (D-6) — он уже видит версию после автоподтверждения и второго перехода не делает. documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars()) + # §14.1: факт приёма — ПОСЛЕ map_file (документы и формы уже вставлены), ДО auto_confirm (снимок «как + # распознано», не «как подтверждено»); owner уже есть в БД — map_file выше не дал бы MappingFatal иначе + owner = await session.get(LegalEntity, file.legal_entity_id) + if owner is not None: + await _write_intake_facts(session, job=job, file=file, owner=owner, documents=documents, now=now) # отложенный импорт: цикл documents → result_mapping → auto_confirm → statements → documents from src.services.auto_confirm import auto_confirm # noqa: PLC0415 diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 45d4148..b4e99af 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -54,6 +54,7 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry +from src.models.quality_facts import QualityIntakeFact from src.models.statements import ( DocumentIssue, FinancialStatementLine, @@ -2104,3 +2105,88 @@ async def test_unreadable_result_json_is_fatal( assert "Expecting" not in job.metrics[MAPPING_ERROR_KEY] assert MAPPING_ATTEMPTS_KEY not in job.metrics assert await process_once(maker, storage, _settings()) is False + + +# --- факт приёма (quality_intake_fact, §14.1, design quality-metrics §4.1, AURORA-1759/#190) ------------------------ + +STATEMENT_LINE_COUNT = 2 # result-statement-minimal.json: строки 1100, 1600 +STATEMENT_VALUE_COUNT = 4 # 2 строки × 2 колонки + + +async def _intake_fact(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> QualityIntakeFact: + async with maker() as session: + return ( + await session.execute(select(QualityIntakeFact).where(QualityIntakeFact.document_id == document_id)) + ).scalar_one() + + +async def test_map_candidate_writes_intake_fact_for_statement_document( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """Т5 плана quality-metrics (§14.1): маппинг формы ББ пишет один `quality_intake_fact` со снимком «как + распознано» — построчный снимок, счётчики и ключи S3 загрузки/результата.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, entity_id = await _prepare(maker, storage, _statement_result("BALANCE"), owner_inn="7700000000") + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.document_type is DocumentType.BALANCE + fact = await _intake_fact(maker, document.id) + assert fact.document_type is DocumentType.BALANCE + assert (fact.file_id, fact.job_id, fact.legal_entity_id) == (file_id, job_id, entity_id) + assert fact.legal_entity_name == OWNER_NAME + assert (fact.group_id, fact.group_name) == (None, None) # ЮЛ без ГК + assert fact.pipeline is (await _job(maker, job_id)).pipeline + assert fact.attempt == (await _job(maker, job_id)).attempt + assert fact.report_date == document.reporting_date + # «как распознано» — ДО auto_confirm (design §4.1): CONFIRMED сюда попасть не может, даже если документ чистый + # и `auto_confirm` подтвердит его позже в той же транзакции (`document` выше прочитан уже ПОСЛЕ auto_confirm) + assert fact.intake_status in (DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK) + assert fact.line_count == STATEMENT_LINE_COUNT + assert fact.value_count == STATEMENT_VALUE_COUNT + assert fact.raw_s3_key is None # `raw_s3_object_id` не заполняется до приёма image pipeline (Task 11/12) + assert fact.result_s3_key is not None + assert fact.upload_s3_key is not None + assert fact.payload["header"]["document_type"] == "BALANCE" + assert len(fact.payload["lines"]) == STATEMENT_LINE_COUNT + assert len(fact.payload["values"]) == STATEMENT_VALUE_COUNT + assert "osv_warnings" not in fact.payload + + +async def test_map_candidate_writes_intake_fact_for_osv_without_lines( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """ОСВ не имеет построчного снимка правок (их в ядре нет): `payload` без `lines`/`values`, только число строк + `osv_entry` (`row_count`) и коды предупреждений сервиса; `line_count` и прочие построчные счётчики — `None` + (счётчики формы ББ/ОФР к ОСВ не относятся).""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, entity_id = await _prepare(maker, storage, _minimal()) + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.document_type is DocumentType.OSV + fact = await _intake_fact(maker, document.id) + assert fact.document_type is DocumentType.OSV + assert (fact.file_id, fact.job_id, fact.legal_entity_id) == (file_id, job_id, entity_id) + assert "lines" not in fact.payload + assert "values" not in fact.payload + assert fact.payload["row_count"] == len(await _entries(maker, document.id)) + assert fact.payload["osv_warnings"] == [] # результат 1.x: предупреждений сервиса `OSV_*` нет + assert fact.line_count is None + assert fact.value_count is None + assert fact.value_needs_review_count is None + assert fact.line_unreadable_count is None + assert fact.line_missing_count is None + assert fact.issue_relation_count is None + assert fact.issue_soft_count is None + assert fact.warning_count is None + + +def test_intake_fact_document_types_excludes_disclosure_and_other() -> None: + """§14.1 (design §4.1): факт приёма пишется только для BALANCE/INCOME/OSV; `DISCLOSURE`/`OTHER` исключены — + чистая проверка множества типов хука, без БД (в отличие от прочих тестов `_map_candidate` в этом файле).""" + assert rm.INTAKE_FACT_DOCUMENT_TYPES == {DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV} + assert DocumentType.DISCLOSURE not in rm.INTAKE_FACT_DOCUMENT_TYPES + assert DocumentType.OTHER not in rm.INTAKE_FACT_DOCUMENT_TYPES -- 2.47.2 From 2a9ed10a298d6223fb03acae2f2cc4d5366fcd1f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 19:25:03 +0400 Subject: [PATCH 10/44] =?UTF-8?q?feat(#190):=20=D1=84=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=B0=20AUTO=5FCONFIRMED=20(?= =?UTF-8?q?=C2=A714.3=20=D0=BF.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хук в auto_confirm: на каждый документ, подтверждённый системой, пишется quality_outcome_fact (outcome=AUTO_CONFIRMED, primary_cause=WITHOUT_PARTICIPATION всегда — правок при автоподтверждении не бывает, D-3 блокирует формы с нарушениями). payload строится build_outcome_payload по тому же шаблону, что факт приёма (Task 5): ОСВ — osv_warnings/row_count, формы — lines/values/header_source, issue_count_at_outcome — все нарушения документа без фильтра. Запись в транзакции маппинга, без flush/commit (Task 4 insert_outcome_fact). Co-Authored-By: Claude Sonnet 5 --- api/src/services/auto_confirm.py | 70 +++++++++++++++++++++- api/tests/test_auto_confirm.py | 100 +++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/api/src/services/auto_confirm.py b/api/src/services/auto_confirm.py index 84f943c..f639b89 100644 --- a/api/src/services/auto_confirm.py +++ b/api/src/services/auto_confirm.py @@ -5,16 +5,36 @@ `CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором `stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5). + +§14.3 п. 5 (design `quality-metrics` §4.2, AURORA-1759): на каждый подтверждённый документ пишется факт исхода +`quality_outcome_fact` с `outcome = AUTO_CONFIRMED`. Правок при автоподтверждении не бывает (D-3 блокирует формы +с нарушениями) — `primary_cause` всегда `WITHOUT_PARTICIPATION`, без вычисления приоритета §14.3 (он нужен только +общему случаю `CONFIRMED`, Task 14). Запись — в той же транзакции, без `flush`/`commit`. """ import datetime from collections.abc import Sequence +from typing import Any +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from src.models.enums import AuditAction, AuditSection, DocumentStatus +from src.domain.quality_facts import build_outcome_payload +from src.models.enums import ( + AuditAction, + AuditSection, + DocumentIssueKind, + DocumentStatus, + DocumentType, + IssueSource, + QualityCause, + QualityOutcome, +) from src.models.files import Document, StoredFile +from src.models.osv import OsvEntry +from src.models.statements import FinancialStatementMeta from src.repositories import statements as statements_repo +from src.repositories.quality_facts import insert_outcome_fact from src.services import consolidation_documents from src.services.audit import AuditService from src.services.statements import FORM_TYPES, form_blocker @@ -31,6 +51,43 @@ def transition_comment(document: Document) -> str: return " ".join(parts) +async def _outcome_fact_fields( + session: AsyncSession, document: Document, *, meta: FinancialStatementMeta | None +) -> dict[str, Any]: + """`payload` и `issue_count_at_outcome` факта исхода `AUTO_CONFIRMED` (§14.3 п. 5, design §4.2). + + Строение `payload` повторяет факт приёма (Task 5, `services/result_mapping.py`: + `_osv_intake_fields`/`_statement_intake_fields`), но как снимок ИСХОДА (`build_outcome_payload`, `fixes=()` — + правок при автоподтверждении не бывает, D-3). `issue_count_at_outcome` — ВСЕ нарушения документа на этот момент, + без фильтра по `kind`/`source` (в отличие от `warning_count` факта приёма, который фильтрует только + предупреждения сервиса).""" + issues = await statements_repo.issues(session, document.id) + if document.document_type is DocumentType.OSV: + row_count = ( + await session.execute( + select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id) + ) + ).scalar_one() + osv_warnings = [ + issue.code + for issue in issues + if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service + ] + payload = build_outcome_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count, fixes=()) + elif meta is not None: + _, pairs = await statements_repo.load_form(session, meta.id) + lines = [line for line, _ in pairs] + values = [value for _, line_values in pairs for value in line_values] + payload = build_outcome_payload( + document, lines=lines, values=values, issues=issues, header_source=meta.header_source, fixes=() + ) + else: + # DISCLOSURE/OTHER и форма без шапки (в текущем `map_file` не возникает — см. Task 5): снимок без построчных + # данных дешевле, чем прерванная транзакция маппинга + payload = build_outcome_payload(document, issues=issues, fixes=()) + return {"issue_count_at_outcome": len(issues), "payload": payload} + + async def auto_confirm( session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime ) -> list[Document]: @@ -73,4 +130,15 @@ async def auto_confirm( after_value={"status": DocumentStatus.CONFIRMED.value}, ) confirmed.append(document) + fields = await _outcome_fact_fields(session, document, meta=meta) + await insert_outcome_fact( + session, + document_id=document.id, + outcome=QualityOutcome.AUTO_CONFIRMED, + outcome_at=now, + primary_cause=QualityCause.WITHOUT_PARTICIPATION, + report_date=document.reporting_date, + confirmed_by=None, + **fields, + ) return confirmed diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py index 45c5e07..56e0f22 100644 --- a/api/tests/test_auto_confirm.py +++ b/api/tests/test_auto_confirm.py @@ -18,15 +18,22 @@ from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice from src.models.enums import ( AuditAction, ConsolidationStatus, + DocumentIssueKind, DocumentStatus, DocumentType, FileStatus, + IssueSeverity, + IssueSource, PeriodQuarter, + QualityCause, + QualityOutcome, RecognitionStatus, StatementLineStatus, ) from src.models.files import Document, StoredFile +from src.models.quality_facts import QualityOutcomeFact from src.models.statements import ( + DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, @@ -177,6 +184,99 @@ async def test_mixed_documents_confirm_only_clean(seeded: AsyncSession) -> None: assert [d.status for d in documents] == [DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK] +async def test_auto_confirm_writes_outcome_fact_without_participation(seeded: AsyncSession) -> None: + """§14.3 п. 5 (design `quality-metrics` §4.2): `AUTO_CONFIRMED` без правок ⇒ всегда `WITHOUT_PARTICIPATION`.""" + group, entities = await _group(seeded) + user = await _user(seeded) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id) + now = utc_now() + + confirmed = await auto_confirm(seeded, file=file, documents=[document], now=now) + + assert confirmed == [document] + fact = ( + await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document.id)) + ).scalar_one() + assert fact.outcome == QualityOutcome.AUTO_CONFIRMED + assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION + assert fact.outcome_at == now + assert fact.confirmed_by is None + assert fact.report_date == document.reporting_date + + +async def test_auto_confirm_outcome_fact_osv_payload_shape(seeded: AsyncSession) -> None: + """Payload исхода ОСВ — как у факта приёма (Task 5 `_osv_intake_fields`): без `lines`/`values`, предупреждения + сервиса `OSV_*` в `osv_warnings` (ядровые `MAPPING_WARNING` — нет), число строк `osv_entry` — `row_count`; + `issue_count_at_outcome` — ВСЕ нарушения документа, без фильтра по `source`/`kind`.""" + group, entities = await _group(seeded) + user = await _user(seeded) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id) + now = utc_now() + seeded.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.warning, + code="OSV_ROUNDING", + severity=IssueSeverity.info, + message="тестовое предупреждение сервиса", + source=IssueSource.service, + created_at=now, + ) + ) + seeded.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.warning, + code="MAPPING_WARNING", + severity=IssueSeverity.info, + message="тестовое предупреждение ядра", + source=IssueSource.core, + created_at=now, + ) + ) + await seeded.flush() + + await auto_confirm(seeded, file=file, documents=[document], now=now) + + fact = ( + await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document.id)) + ).scalar_one() + assert "lines" not in fact.payload + assert "values" not in fact.payload + assert fact.payload["osv_warnings"] == ["OSV_ROUNDING"] + assert fact.payload["row_count"] == 0 + assert fact.payload["fixes"] == [] + assert fact.issue_count_at_outcome == 2 # noqa: PLR2004 - оба созданных нарушения, без фильтра + + +@pytest.mark.fixtures +async def test_auto_confirm_outcome_fact_for_form_has_lines(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Payload исхода формы ББ/ОФР — строки и значения формы, как у факта приёма (Task 5 `_statement_intake_fields`), + `header_source` из `meta`; правок при автоподтверждении нет (`fixes=[]`).""" + path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" + import json # noqa: PLC0415 - нужен только здесь + + data = json.loads(path.read_text("utf-8")) + inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn")) + owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + file = await seeded.get(StoredFile, income.stored_file_id) + assert file is not None + now = utc_now() + + assert await auto_confirm(seeded, file=file, documents=[income], now=now) == [income] + + fact = ( + await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == income.id)) + ).scalar_one() + assert fact.outcome == QualityOutcome.AUTO_CONFIRMED + assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION + assert fact.report_date == income.reporting_date + assert fact.payload["fixes"] == [] + assert fact.payload["lines"] + assert "osv_warnings" not in fact.payload + + @pytest.mark.fixtures async def test_clean_form_sets_reviewed_at(seeded: AsyncSession, fixtures_dir: Path) -> None: path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" -- 2.47.2 From d26804a85dea9cb4e4774f358c28caf3f09909dc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 19:36:11 +0400 Subject: [PATCH 11/44] =?UTF-8?q?fix(#190):=20=D1=84=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=B0=20=D1=82=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=BE=20=D0=B4=D0=BB=D1=8F=20BALANCE/INCOME/OSV=20(?= =?UTF-8?q?=C2=A714.3=20=D0=BF.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью нашло: хук писал quality_outcome_fact для ЛЮБОГО автоподтверждённого документа, в том числе DISCLOSURE/OTHER — у них нет факта приёма (INTAKE_FACT_DOCUMENT_TYPES, Task 5), и факт исхода без пары превратился бы в сироту при соединении по document_id. Добавлен гейт OUTCOME_FACT_DOCUMENT_TYPES = {BALANCE, INCOME, OSV} (не импортируется из result_mapping.py — там уже отложенный импорт auto_confirm во избежание цикла); auto_confirm для DISCLOSURE/OTHER не меняется — только пропускает запись факта. Поправлен вводивший в заблуждение комментарий в _outcome_fact_fields. Тест на DISCLOSURE: автоподтверждение проходит, quality_outcome_fact не появляется. Co-Authored-By: Claude Sonnet 5 --- api/src/services/auto_confirm.py | 53 +++++++++++++++++++------------- api/tests/test_auto_confirm.py | 21 +++++++++++++ 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/api/src/services/auto_confirm.py b/api/src/services/auto_confirm.py index f639b89..ab1187e 100644 --- a/api/src/services/auto_confirm.py +++ b/api/src/services/auto_confirm.py @@ -6,10 +6,13 @@ пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором `stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5). -§14.3 п. 5 (design `quality-metrics` §4.2, AURORA-1759): на каждый подтверждённый документ пишется факт исхода -`quality_outcome_fact` с `outcome = AUTO_CONFIRMED`. Правок при автоподтверждении не бывает (D-3 блокирует формы -с нарушениями) — `primary_cause` всегда `WITHOUT_PARTICIPATION`, без вычисления приоритета §14.3 (он нужен только -общему случаю `CONFIRMED`, Task 14). Запись — в той же транзакции, без `flush`/`commit`. +§14.3 п. 5 (design `quality-metrics` §4.2, AURORA-1759): на каждый подтверждённый документ типа `BALANCE`/ +`INCOME`/`OSV` (`OUTCOME_FACT_DOCUMENT_TYPES`, тот же набор, что у факта приёма — `INTAKE_FACT_DOCUMENT_TYPES`, +Task 5) пишется факт исхода `quality_outcome_fact` с `outcome = AUTO_CONFIRMED`. `DISCLOSURE`/`OTHER` +автоподтверждаются как прежде (без факта приёма — факта исхода у них тоже нет, иначе `document_id` факта исхода +не нашёл бы пары при соединении с фактом приёма в матрице §14). Правок при автоподтверждении не бывает (D-3 +блокирует формы с нарушениями) — `primary_cause` всегда `WITHOUT_PARTICIPATION`, без вычисления приоритета §14.3 +(он нужен только общему случаю `CONFIRMED`, Task 14). Запись — в той же транзакции, без `flush`/`commit`. """ import datetime @@ -41,6 +44,10 @@ from src.services.statements import FORM_TYPES, form_blocker AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний" OBJECT_TYPE = "document" +# §14.1/§14.3 (design quality-metrics §4.1/§4.2): факт качества (приёма и исхода) пишется только для этих типов; +# тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES` в services/result_mapping.py (Task 5) — не импортируется оттуда +# во избежание цикла result_mapping → auto_confirm (там этот импорт уже отложенный) +OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV}) def transition_comment(document: Document) -> str: @@ -56,11 +63,12 @@ async def _outcome_fact_fields( ) -> dict[str, Any]: """`payload` и `issue_count_at_outcome` факта исхода `AUTO_CONFIRMED` (§14.3 п. 5, design §4.2). - Строение `payload` повторяет факт приёма (Task 5, `services/result_mapping.py`: - `_osv_intake_fields`/`_statement_intake_fields`), но как снимок ИСХОДА (`build_outcome_payload`, `fixes=()` — - правок при автоподтверждении не бывает, D-3). `issue_count_at_outcome` — ВСЕ нарушения документа на этот момент, - без фильтра по `kind`/`source` (в отличие от `warning_count` факта приёма, который фильтрует только - предупреждения сервиса).""" + Вызывается только для `document.document_type ∈ OUTCOME_FACT_DOCUMENT_TYPES` (гейт — в `auto_confirm`, + `DISCLOSURE`/`OTHER` сюда не попадают). Строение `payload` повторяет факт приёма (Task 5, + `services/result_mapping.py`: `_osv_intake_fields`/`_statement_intake_fields`), но как снимок ИСХОДА + (`build_outcome_payload`, `fixes=()` — правок при автоподтверждении не бывает, D-3). `issue_count_at_outcome` — + ВСЕ нарушения документа на этот момент, без фильтра по `kind`/`source` (в отличие от `warning_count` факта + приёма, который фильтрует только предупреждения сервиса).""" issues = await statements_repo.issues(session, document.id) if document.document_type is DocumentType.OSV: row_count = ( @@ -82,8 +90,8 @@ async def _outcome_fact_fields( document, lines=lines, values=values, issues=issues, header_source=meta.header_source, fixes=() ) else: - # DISCLOSURE/OTHER и форма без шапки (в текущем `map_file` не возникает — см. Task 5): снимок без построчных - # данных дешевле, чем прерванная транзакция маппинга + # BALANCE/INCOME без шапки (в текущем `map_file` не возникает — см. Task 5): снимок без построчных данных + # дешевле, чем прерванная транзакция маппинга payload = build_outcome_payload(document, issues=issues, fixes=()) return {"issue_count_at_outcome": len(issues), "payload": payload} @@ -130,15 +138,16 @@ async def auto_confirm( after_value={"status": DocumentStatus.CONFIRMED.value}, ) confirmed.append(document) - fields = await _outcome_fact_fields(session, document, meta=meta) - await insert_outcome_fact( - session, - document_id=document.id, - outcome=QualityOutcome.AUTO_CONFIRMED, - outcome_at=now, - primary_cause=QualityCause.WITHOUT_PARTICIPATION, - report_date=document.reporting_date, - confirmed_by=None, - **fields, - ) + if document.document_type in OUTCOME_FACT_DOCUMENT_TYPES: + fields = await _outcome_fact_fields(session, document, meta=meta) + await insert_outcome_fact( + session, + document_id=document.id, + outcome=QualityOutcome.AUTO_CONFIRMED, + outcome_at=now, + primary_cause=QualityCause.WITHOUT_PARTICIPATION, + report_date=document.reporting_date, + confirmed_by=None, + **fields, + ) return confirmed diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py index 56e0f22..d9635c9 100644 --- a/api/tests/test_auto_confirm.py +++ b/api/tests/test_auto_confirm.py @@ -386,6 +386,27 @@ async def test_recognized_disclosure_is_auto_confirmed(seeded: AsyncSession, fix assert disclosure.status is DocumentStatus.CONFIRMED +@pytest.mark.fixtures +async def test_recognized_disclosure_is_auto_confirmed_without_outcome_fact( + seeded: AsyncSession, fixtures_dir: Path +) -> None: + """`DISCLOSURE`/`OTHER` вне матрицы §14 (как и у факта приёма, `INTAKE_FACT_DOCUMENT_TYPES`, Task 5): + автоподтверждение документа не меняется, но `quality_outcome_fact` не пишется — иначе `document_id` факта + исхода не нашёл бы пары с фактом приёма.""" + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office) + file = await seeded.get(StoredFile, disclosure.stored_file_id) + assert file is not None + + assert await auto_confirm(seeded, file=file, documents=[disclosure], now=utc_now()) == [disclosure] + assert disclosure.status is DocumentStatus.CONFIRMED + + facts = ( + await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id)) + ).scalars().all() + assert facts == [] + + @pytest.mark.fixtures async def test_recognized_disclosure_is_manually_confirmable( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory -- 2.47.2 From bb1714b0969abd4595e5c9e2d640ba49cf15aaf5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 19:58:03 +0400 Subject: [PATCH 12/44] =?UTF-8?q?feat(#190):=20=D1=84=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=B0=20CONFIRMED=20=D1=81=20?= =?UTF-8?q?=D0=B0=D1=82=D1=80=D0=B8=D0=B1=D1=83=D1=86=D0=B8=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=BA=20(=C2=A714.2-14.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentService._write_confirm_outcome_fact — третий хук quality_outcome_fact (outcome=CONFIRMED). В отличие от AUTO_CONFIRMED правки возможны: суммы и коды строк сравниваются с живыми *_modified/original_*/source_text (не с JSON факта приёма), добавленные строки и правки шапки — через attribute_* (Task 3), период/ счёт ОСВ — всегда "наш разбор". primary_cause — по приоритету сервис > наш разбор > документ клиента > как есть. Гейт типа документа (BALANCE/INCOME/OSV) — OUTCOME_FACT_DOCUMENT_TYPES, DISCLOSURE/OTHER без факта. Co-Authored-By: Claude Sonnet 5 --- api/src/services/documents.py | 222 +++++++++++++++++++++++++++- api/tests/test_api_documents.py | 251 +++++++++++++++++++++++++++++++- 2 files changed, 470 insertions(+), 3 deletions(-) diff --git a/api/src/services/documents.py b/api/src/services/documents.py index d2b58fb..5b865bb 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -10,24 +10,48 @@ перечитываются под блокировкой: файл, заменённый параллельной автозаменой, даёт 409 `FILE_SUPERSEDED` (Review Focus 5). """ +import datetime import uuid from collections.abc import Mapping, Sequence from typing import Any +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, NotFoundError +from src.domain.file_types import PIPELINES from src.domain.policy import VisibilityScope +from src.domain.quality_attribution import ( + Attribution, + attribute_added_row, + attribute_code_fix, + attribute_header_fix, + attribute_value_fix, +) +from src.domain.quality_facts import build_outcome_payload from src.models.access import AppUser from src.models.audit_ex import Comment -from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, IssueSeverity +from src.models.enums import ( + AuditAction, + AuditSection, + DocumentIssueKind, + DocumentStatus, + DocumentType, + FileStatus, + IssueSeverity, + IssueSource, + QualityCause, + QualityOutcome, + RecognitionPipeline, +) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry -from src.models.statements import FinancialStatementMeta +from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue from src.repositories import files as files_repo from src.repositories import statements as statements_repo +from src.repositories.quality_facts import insert_outcome_fact from src.schemas.comments import CommentAuthorOut, CommentIn, CommentOut from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut from src.schemas.files import DocumentBrief, FileOut, FileWarningOut, JobBrief @@ -45,6 +69,124 @@ DOCUMENT_NOT_CONFIRMABLE = "DOCUMENT_NOT_CONFIRMABLE" PERIOD_REQUIRED = "PERIOD_REQUIRED" CHECKS_NOT_PASSED = "CHECKS_NOT_PASSED" CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}) +# §14.2-§14.4 (design quality-metrics §4.2, §5, §6; AURORA-1759): факт исхода `CONFIRMED` пишется только для этих +# типов — тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES` в `services/result_mapping.py` (Task 5) и +# `OUTCOME_FACT_DOCUMENT_TYPES` в `services/auto_confirm.py` (Task 6); не импортируется оттуда во избежание цикла +# `documents → auto_confirm → statements → documents` (то же решение, что в Task 6) +OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV}) +# поле шапки → (атрибут «правилось», имя поля в `financial_statement_meta`); порядок — как в §14.2 «Шапка» +_HEADER_FIX_FIELDS: tuple[tuple[str, str], ...] = ( + ("inn_modified", "inn"), + ("report_date_modified", "report_date"), + ("units_modified", "units"), + ("organization_name_modified", "organization_name"), +) +_FIX_COUNTER_KEYS: tuple[str, ...] = ( + "fixes_value_service", + "fixes_value_ours", + "fixes_code_service", + "fixes_code_ours", + "fixes_added_row_service", + "fixes_added_row_ours", + "fixes_header_service", + "fixes_header_ours", + "fixes_osv_period_account", +) +_SERVICE_COUNTER_KEYS: tuple[str, ...] = ( + "fixes_value_service", + "fixes_code_service", + "fixes_added_row_service", + "fixes_header_service", +) +_OURS_COUNTER_KEYS: tuple[str, ...] = ( + "fixes_value_ours", + "fixes_code_ours", + "fixes_added_row_ours", + "fixes_header_ours", + "fixes_osv_period_account", +) + + +def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None: + """Правка суммы (§14.2 «Сумма»): `None` — значение не правилось (`is_modified` не `True`). + + Нет сырого текста для сравнения (`source_text is None`) — классифицировать нельзя; по решению design §5 + «спорное — в наш разбор» (decision log 27.10.2026: анти-цель — не приписывать сервису наши недоработки) + считаем правку нашей, не пропускаем (решение задачи 7, см. отчёт).""" + if not value.is_modified: + return None + confirmed = str(value.amount) if value.amount is not None else "" + attribution: Attribution = ( + "ours" if value.source_text is None else attribute_value_fix(confirmed=confirmed, raw_text=value.source_text) + ) + return { + "kind": "value", + "line_id": str(value.line_id), + "attribution": attribution, + "before": str(value.original_amount) if value.original_amount is not None else None, + "after": confirmed, + } + + +def _code_fix(line: FinancialStatementLine) -> dict[str, Any] | None: + """Правка кода строки (§14.2 «Код строки»): `None` — код не правился. Нет сырого текста — «наш разбор» + (то же решение, что у правки суммы).""" + if not line.line_code_modified: + return None + confirmed_code = line.line_code or "" + attribution: Attribution = ( + "ours" + if line.line_code_text is None + else attribute_code_fix(confirmed_code=confirmed_code, raw_text=line.line_code_text) + ) + return { + "kind": "code", + "line_id": str(line.id), + "attribution": attribution, + "before": line.line_code_original_value, + "after": confirmed_code, + } + + +def _added_row_fix(line: FinancialStatementLine, *, is_excel: bool) -> dict[str, Any] | None: + """Добавленная строка (§14.2 «Добавленная строка», `row_index IS NULL` — v5.19). Для PDF — известный пробел: + сырые четырёхзначные токены ответа ИС Сканирование Task 2/5 не извлекали, поэтому `raw_line_codes` всегда + пуст и результат для PDF всегда «сервис» (design §11 «оставлено исполнителю»; задача 7 это не чинит — вне + рамок, см. отчёт). Для Excel — всегда «наш разбор» (`attribute_added_row` сам это решает по `is_excel`).""" + if line.row_index is not None: + return None + attribution = attribute_added_row(line.line_code or "", (), is_excel=is_excel) + return {"kind": "added_row", "line_id": str(line.id), "attribution": attribution, "after": line.line_code} + + +def _header_confirmed_value(meta: FinancialStatementMeta, field: str) -> str: + value = getattr(meta, field) + if isinstance(value, datetime.date): + return value.isoformat() + return str(value) if value is not None else "" + + +def _header_fix(field: str, *, meta: FinancialStatementMeta, header_source: Mapping[str, Any] | None) -> dict[str, Any]: + """Правка поля шапки (§14.2 «Шапка»); вызывается только когда `meta._modified` уже `True`. Нет сырого + текста поля в `header_source` — «наш разбор» (то же решение, что у правки суммы/кода).""" + confirmed = _header_confirmed_value(meta, field) + source_entry = header_source.get(field) if isinstance(header_source, Mapping) else None + source_text = source_entry.get("text") if isinstance(source_entry, Mapping) else None + attribution: Attribution = ( + "ours" if source_text is None else attribute_header_fix(confirmed=confirmed, header_source_text=source_text) + ) + return {"kind": "header", "field": field, "attribution": attribution, "after": confirmed} + + +def _primary_cause(counters: Mapping[str, int], *, checks_accepted: bool) -> QualityCause: + """Приоритет §14.3 для `CONFIRMED` (design §5, §6): сервис > наш разбор > документ клиента > как есть.""" + if any(counters[key] > 0 for key in _SERVICE_COUNTER_KEYS): + return QualityCause.SERVICE_ERROR + if any(counters[key] > 0 for key in _OURS_COUNTER_KEYS): + return QualityCause.OUR_ERROR + if checks_accepted: + return QualityCause.CLIENT_DOCUMENT + return QualityCause.AS_IS def _metrics(job: RecognitionJob | None) -> dict[str, Any]: @@ -289,6 +431,81 @@ class DocumentService: raise ConflictError(blocker.code, blocker.message, **blocker.extra) return meta + async def _write_confirm_outcome_fact( + self, document: Document, file: StoredFile, meta: FinancialStatementMeta | None, now: datetime.datetime + ) -> None: + """Факт исхода `CONFIRMED` с атрибуцией правок (§14.2-§14.4; design quality-metrics §4.2, §5, §6; + AURORA-1759). Гейт — `OUTCOME_FACT_DOCUMENT_TYPES`: DISCLOSURE/OTHER без факта (как у факта приёма/ + `AUTO_CONFIRMED`, Task 5/6). Правки ищутся по ЖИВЫМ колонкам `*_modified`/`original_*`/`source_text` + (первичка не мутируется) — не по JSON `quality_intake_fact.payload` (решение задачи 7, см. отчёт: тот + payload собран для другой цели и его форма — не для этого сравнения). Пишется в транзакции вызывающего + (`confirm`), без `flush`/`commit`. + """ + if document.document_type not in OUTCOME_FACT_DOCUMENT_TYPES: + return + is_excel = PIPELINES[file.file_type] is RecognitionPipeline.tabular + issues = await statements_repo.issues(self._session, document.id) + fixes: list[dict[str, Any]] = [] + counters: dict[str, int] = dict.fromkeys(_FIX_COUNTER_KEYS, 0) + + def _record(fix: dict[str, Any] | None, prefix: str) -> None: + if fix is None: + return + fixes.append(fix) + suffix = "service" if fix["attribution"] == "service" else "ours" + counters[f"fixes_{prefix}_{suffix}"] += 1 + + if document.document_type is DocumentType.OSV: + # ОСВ: без построчного снимка сумм (правок сумм ОСВ в ядре нет, design §5) — только предупреждения + # сервиса и число строк; правка периода/счёта — document.is_modified, всегда «наш разбор» (Excel) + row_count = ( + await self._session.execute( + select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id) + ) + ).scalar_one() + osv_warnings = [ + issue.code + for issue in issues + if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service + ] + if document.is_modified: + counters["fixes_osv_period_account"] = 1 + fixes.append({"kind": "osv_period_account", "attribution": "ours"}) + payload = build_outcome_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count, fixes=fixes) + elif meta is not None: + _, pairs = await statements_repo.load_form(self._session, meta.id) + lines = [line for line, _ in pairs] + values = [value for _, line_values in pairs for value in line_values] + for value in values: + _record(_value_fix(value), "value") + for line in lines: + _record(_code_fix(line), "code") + _record(_added_row_fix(line, is_excel=is_excel), "added_row") + for modified_attr, field_name in _HEADER_FIX_FIELDS: + if getattr(meta, modified_attr): + _record(_header_fix(field_name, meta=meta, header_source=meta.header_source), "header") + payload = build_outcome_payload( + document, lines=lines, values=values, issues=issues, header_source=meta.header_source, fixes=fixes + ) + else: + # BALANCE/INCOME без шапки (в текущем маппинге не возникает, см. Task 5/6) — снимок без построчных данных + payload = build_outcome_payload(document, issues=issues, fixes=fixes) + + checks_accepted = meta is not None and meta.checks_accepted_by is not None + await insert_outcome_fact( + self._session, + document_id=document.id, + outcome=QualityOutcome.CONFIRMED, + outcome_at=now, + primary_cause=_primary_cause(counters, checks_accepted=checks_accepted), + report_date=document.reporting_date, + confirmed_by=self._audit.user_id, + issue_count_at_outcome=len(issues), + checks_accepted=checks_accepted, + payload=payload, + **counters, + ) + async def confirm(self, document_id: uuid.UUID) -> DocumentOut: """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. @@ -338,4 +555,5 @@ class DocumentService: before_value={"status": before.value}, after_value={"status": DocumentStatus.CONFIRMED.value}, ) + await self._write_confirm_outcome_fact(document, file, meta, now) return await self._card(document, file) diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 06c2734..1c700bc 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -10,6 +10,7 @@ import asyncio import datetime import json import uuid +from decimal import Decimal from pathlib import Path from typing import Any @@ -25,12 +26,19 @@ from src.models.enums import ( AuditSection, CounterpartyMatchKind, DocumentStatus, + DocumentType, FileStatus, PeriodQuarter, + QualityCause, + QualityOutcome, RecognitionStatus, ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry +from src.models.quality_facts import QualityOutcomeFact +from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue +from src.services.statement_recompute import recompute_form +from src.services.statements_engine import default_engine from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, @@ -39,6 +47,7 @@ from tests.factories import ( make_legal_entity, make_stored_file, ) +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot HTTP_OK = 200 HTTP_FORBIDDEN = 403 @@ -61,8 +70,12 @@ LOCK_WAIT_SECONDS = 10.0 pytestmark = pytest.mark.integration +async def _rostov_office(session: AsyncSession) -> RegionalOffice: + return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + + async def _rostov_entity(session: AsyncSession, name: str = "ООО Документы") -> LegalEntity: - office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + office = await _rostov_office(session) group = await make_company_group(session, name=f"ГК {name}", office=office) return await make_legal_entity(session, name=name, group=group) @@ -512,3 +525,239 @@ async def test_osv_v2_warnings_in_card_issues(seeded: AsyncSession, auth_client: assert {issue["code"] for issue in service} == {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"} assert all(issue["kind"] == "warning" and issue["severity"] == "info" for issue in service) assert body["status"] == "NEEDS_CHECK" + + +# --- факт исхода CONFIRMED (§14.2-14.4, design quality-metrics §4.2/§5/§6, AURORA-1759) ------------------------------- + +INCOME_FORM = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" +SYNTHETIC_DISCLOSURE = GOLDEN_STATEMENTS / "synthetic" / "st06_header_variants.json" + + +async def _golden_owner(session: AsyncSession, path: Path, *, office: RegionalOffice, name: str) -> LegalEntity: + """Владелец с ИНН шапки golden-снимка `path` (совпадает с шапкой — `counterparty_check = MATCH`).""" + data = json.loads(path.read_text("utf-8")) + inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) + return await make_legal_entity(session, name=name, inn=inn, office=office) + + +async def _income_form( + session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice +) -> tuple[Document, FinancialStatementMeta]: + """Чистая (confirmable) форма ОФР из golden-снимка — база для тестов факта исхода `CONFIRMED`.""" + path = fixtures_dir / INCOME_FORM + owner = await _golden_owner(session, path, office=office, name="ООО Владелец Исход") + (income,) = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None) + assert income.document_type is DocumentType.INCOME + meta = ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + return income, meta + + +async def _synthetic_disclosure(session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice) -> Document: + """RECOGNIZED `DISCLOSURE` из синтетического снимка st06 (та же фикстура, что в test_auto_confirm.py).""" + path = fixtures_dir / SYNTHETIC_DISCLOSURE + owner = await _golden_owner(session, path, office=office, name="ООО Синтетика Документы") + documents = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None) + disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) + assert disclosure.status is DocumentStatus.RECOGNIZED + return disclosure + + +async def _set_value_fix( + session: AsyncSession, + income: Document, + *, + source_text: str, + original_amount: Decimal, + confirmed_amount: Decimal, +) -> FinancialStatementValue: + """Правит одно значение формы напрямую (ORM, минуя `PATCH .../statement`) — `is_modified=True` и `source_text` + для проверки отнесения правки (§14.2 «Сумма»).""" + meta = ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + value = ( + await session.execute( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta.id) + .limit(1) + ) + ).scalar_one() + value.is_modified = True + value.source_text = source_text + value.original_amount = original_amount + value.amount = confirmed_amount + await session.flush() + return value + + +async def _break_2200_and_accept( + session: AsyncSession, + document: Document, + meta: FinancialStatementMeta, + *, + user_id: uuid.UUID, + now: datetime.datetime, +) -> None: + """Ломает итог 2200 (как `test_form_confirmable.py::_break_2200`) и сразу акцептует нарушение — документ + остаётся подтверждаемым (`form_blocker` не видит нарушения при акцепте), но `checks_accepted_by` задан.""" + values = ( + await session.execute( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200") + ) + ).scalars() + for value in values: + value.amount = (value.amount or 0) + 1000 + await session.flush() + job = await session.get(RecognitionJob, meta.recognition_job_id) + await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job) + meta.checks_accepted_by = user_id + meta.checks_accepted_at = now + meta.checks_accepted_comment = "акцептовано для теста факта исхода" + await session.flush() + + +async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact: + return ( + await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) + ).scalar_one() + + +@pytest.mark.fixtures +async def test_confirm_writes_outcome_fact_as_is_when_no_fixes( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """§14.3 п. 6: ручное подтверждение чистой формы без правок и без акцепта — `AS_IS`.""" + office = await _rostov_office(seeded) + income, _meta = await _income_form(seeded, fixtures_dir, office=office) + user_id = await _user_id(seeded, KA_RF) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert fact.outcome == QualityOutcome.CONFIRMED + assert fact.primary_cause == QualityCause.AS_IS + assert fact.checks_accepted is False + assert fact.confirmed_by == user_id + assert all( + getattr(fact, key) == 0 + for key in ( + "fixes_value_service", + "fixes_value_ours", + "fixes_code_service", + "fixes_code_ours", + "fixes_added_row_service", + "fixes_added_row_ours", + "fixes_header_service", + "fixes_header_ours", + "fixes_osv_period_account", + ) + ) + assert fact.payload["fixes"] == [] + + +@pytest.mark.fixtures +async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """§14.2 «Сумма»: верный ответ есть в сыром тексте сервиса (разбор ошибся мы) — `OUR_ERROR`.""" + office = await _rostov_office(seeded) + income, _meta = await _income_form(seeded, fixtures_dir, office=office) + fixed = await _set_value_fix( + seeded, + income, + source_text="1 234", # сервис прочитал верно (тысячи через пробел) + original_amount=Decimal("1234000.00"), # наш разбор тысячного разделителя ошибся + confirmed_amount=Decimal("1234.00"), # аналитик подтвердил то, что действительно в сыром тексте + ) + fixed_line_id = fixed.line_id # до HTTP-запроса: после него объекты сессии теста могут устареть + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert fact.primary_cause == QualityCause.OUR_ERROR + assert (fact.fixes_value_ours, fact.fixes_value_service) == (1, 0) + assert fact.payload["fixes"] == [ + { + "kind": "value", + "line_id": str(fixed_line_id), + "attribution": "ours", + "before": "1234000.00", + "after": "1234.00", + } + ] + + +@pytest.mark.fixtures +async def test_confirm_writes_outcome_fact_service_error_when_fix_not_in_raw_text( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """§14.2 «Сумма»: верного ответа в сыром тексте нет — сервис прочитал не те цифры — `SERVICE_ERROR`.""" + office = await _rostov_office(seeded) + income, _meta = await _income_form(seeded, fixtures_dir, office=office) + await _set_value_fix( + seeded, + income, + source_text="99", # сервис прочитал совсем другие цифры + original_amount=Decimal("99.00"), + confirmed_amount=Decimal("1234.00"), + ) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert fact.primary_cause == QualityCause.SERVICE_ERROR + assert (fact.fixes_value_service, fact.fixes_value_ours) == (1, 0) + + +@pytest.mark.fixtures +async def test_confirm_writes_outcome_fact_client_document_when_checks_accepted( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """§14.3/§14.4: подтверждение с акцептом нарушенного жёсткого соотношения §7, без правок — `CLIENT_DOCUMENT`.""" + office = await _rostov_office(seeded) + income, meta = await _income_form(seeded, fixtures_dir, office=office) + user_id = await _user_id(seeded, KA_RF) + await _break_2200_and_accept(seeded, income, meta, user_id=user_id, now=datetime.datetime.now(datetime.UTC)) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert fact.checks_accepted is True + assert fact.primary_cause == QualityCause.CLIENT_DOCUMENT + assert fact.payload["fixes"] == [] + + +@pytest.mark.fixtures +async def test_disclosure_confirm_has_no_outcome_fact( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Гейт типа документа (тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES`/`OUTCOME_FACT_DOCUMENT_TYPES`, Task 5/6): + `DISCLOSURE` подтверждается (симметрично `test_recognized_disclosure_is_manually_confirmable`, + test_auto_confirm.py), но факт исхода `CONFIRMED` не пишется.""" + office = await _rostov_office(seeded) + disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{disclosure.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert response.json()["status"] == "CONFIRMED" + facts = ( + (await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id))) + .scalars() + .all() + ) + assert facts == [] -- 2.47.2 From 9bb9fba9017e19c6574121a757242daead7b930c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 10:53:19 +0400 Subject: [PATCH 13/44] =?UTF-8?q?fix(#190):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=207=20?= =?UTF-8?q?=E2=80=94=20PDF-=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=C2=AB=D0=BD=D0=B0=D1=88=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=C2=BB,=20before=20=D1=83=20=5Fheader=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью задачи 7, раунд 1: (1) добавлена строка design.md 06.11.2026 — решение «нет сырого текста → наш разбор» для value/code/header фиксов задокументировано отдельно (не то же, что 1250\n1260); тем же рядом исправлено направление для PDF-добавленной строки — "наш разбор", не "сервис" (пустой raw_line_codes не должен давать service через attribute_added_row, это противоречило уже принятому 27.10.2026 решению). (2) _added_row_fix для PDF больше не вызывает attribute_added_row с заведомо пустым raw_line_codes — атрибуция "ours" прямо. (3) _header_fix теперь возвращает "before" (meta._original_value), как у _value_fix/_code_fix. Co-Authored-By: Claude Sonnet 5 --- api/src/services/documents.py | 28 +++++++++++++--------- openspec/changes/quality-metrics/design.md | 1 + 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 5b865bb..ab604d3 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -110,9 +110,9 @@ _OURS_COUNTER_KEYS: tuple[str, ...] = ( def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None: """Правка суммы (§14.2 «Сумма»): `None` — значение не правилось (`is_modified` не `True`). - Нет сырого текста для сравнения (`source_text is None`) — классифицировать нельзя; по решению design §5 - «спорное — в наш разбор» (decision log 27.10.2026: анти-цель — не приписывать сервису наши недоработки) - считаем правку нашей, не пропускаем (решение задачи 7, см. отчёт).""" + Нет сырого текста для сравнения (`source_text is None`) — классифицировать нельзя; считаем правку нашей, не + пропускаем (design.md 06.11.2026 — уточнение решения 27.10.2026 «спорное — в наш разбор» на случай «данных + нет вовсе», а не «есть, но двусмысленно»; анти-цель §2/§5 — не приписывать сервису наши недоработки).""" if not value.is_modified: return None confirmed = str(value.amount) if value.amount is not None else "" @@ -130,7 +130,7 @@ def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None: def _code_fix(line: FinancialStatementLine) -> dict[str, Any] | None: """Правка кода строки (§14.2 «Код строки»): `None` — код не правился. Нет сырого текста — «наш разбор» - (то же решение, что у правки суммы).""" + (то же решение design.md 06.11.2026, что у правки суммы).""" if not line.line_code_modified: return None confirmed_code = line.line_code or "" @@ -149,13 +149,18 @@ def _code_fix(line: FinancialStatementLine) -> dict[str, Any] | None: def _added_row_fix(line: FinancialStatementLine, *, is_excel: bool) -> dict[str, Any] | None: - """Добавленная строка (§14.2 «Добавленная строка», `row_index IS NULL` — v5.19). Для PDF — известный пробел: - сырые четырёхзначные токены ответа ИС Сканирование Task 2/5 не извлекали, поэтому `raw_line_codes` всегда - пуст и результат для PDF всегда «сервис» (design §11 «оставлено исполнителю»; задача 7 это не чинит — вне - рамок, см. отчёт). Для Excel — всегда «наш разбор» (`attribute_added_row` сам это решает по `is_excel`).""" + """Добавленная строка (§14.2 «Добавленная строка», `row_index IS NULL` — v5.19). + + Excel — `attribute_added_row` (всегда «наш разбор» при `is_excel=True`, без зависимости от токенов). PDF — + сырые четырёхзначные токены ответа ИС Сканирование Task 2/5 не извлекали, `raw_line_codes` для PDF нет совсем + (не просто пуст): `attribute_added_row` здесь НЕ вызывается — передача пустой последовательности ошибочно + вернула бы «сервис», что противоречит уже принятому 27.10.2026 направлению design.md («совпадение с суммой + даёт наш разбор — по правилу „спорное — нам“»). Атрибуция для PDF — «наш разбор» прямо (design.md, 06.11.2026, + поправка к задаче 7: деферренный пробел — когда токены появятся, здесь будет настоящий вызов + `attribute_added_row` с реальным `raw_line_codes`, а не прямая атрибуция).""" if line.row_index is not None: return None - attribution = attribute_added_row(line.line_code or "", (), is_excel=is_excel) + attribution: Attribution = attribute_added_row(line.line_code or "", (), is_excel=True) if is_excel else "ours" return {"kind": "added_row", "line_id": str(line.id), "attribution": attribution, "after": line.line_code} @@ -168,14 +173,15 @@ def _header_confirmed_value(meta: FinancialStatementMeta, field: str) -> str: def _header_fix(field: str, *, meta: FinancialStatementMeta, header_source: Mapping[str, Any] | None) -> dict[str, Any]: """Правка поля шапки (§14.2 «Шапка»); вызывается только когда `meta._modified` уже `True`. Нет сырого - текста поля в `header_source` — «наш разбор» (то же решение, что у правки суммы/кода).""" + текста поля в `header_source` — «наш разбор» (то же решение, что у правки суммы/кода; design.md 06.11.2026).""" confirmed = _header_confirmed_value(meta, field) source_entry = header_source.get(field) if isinstance(header_source, Mapping) else None source_text = source_entry.get("text") if isinstance(source_entry, Mapping) else None attribution: Attribution = ( "ours" if source_text is None else attribute_header_fix(confirmed=confirmed, header_source_text=source_text) ) - return {"kind": "header", "field": field, "attribution": attribution, "after": confirmed} + before = getattr(meta, f"{field}_original_value", None) + return {"kind": "header", "field": field, "attribution": attribution, "before": before, "after": confirmed} def _primary_cause(counters: Mapping[str, int], *, checks_accepted: bool) -> QualityCause: diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index cb19363..553d0b4 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -229,3 +229,4 @@ PDF, точный вид выгрузки, разбиение на задачи. | 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана | | 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит | | 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл уже есть в `../docs/contracts/` (сиблинг-репозиторий arm-ka-docs, работа по нему уже слита туда), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | +| 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 | -- 2.47.2 From 6390b3618827152af8a3c412ad441c0a246d4ee7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 11:06:35 +0400 Subject: [PATCH 14/44] =?UTF-8?q?feat(#190):=20=D1=84=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=B0=20DELETED/DELETED=5FAFTER?= =?UTF-8?q?=5FCONFIRM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удаление файла (PrimaryFilesService.delete) пишет факт качества quality_outcome_fact по каждому документу типа BALANCE/INCOME/OSV до физического удаления строк: DELETED — документ не подтверждался (RECOGNIZED/NEEDS_CHECK), DELETED_AFTER_CONFIRM — автоподтверждённый CONFIRMED (вручную подтверждённые сюда не доходят, дают 409 раньше в том же методе). primary_cause=None явно (§14.3 правило 1, вне матрицы причин), payload — минимальный снимок без построчных данных (build_outcome_payload(document, fixes=())). Co-Authored-By: Claude Sonnet 5 --- api/src/services/primary_files.py | 68 ++++++++++++++++++++- api/tests/test_api_files_delete.py | 94 ++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 5268450..32929ae 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -16,9 +16,10 @@ audit-trail «События файлов и документов»; design D-4) уборка таких объектов — ретеншн бакета (OQ-22). """ +import datetime import hashlib import uuid -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping, Sequence from dataclasses import dataclass from typing import Literal @@ -37,6 +38,7 @@ from src.core.config import Settings from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file from src.domain.policy import IN_WORK_STATUSES, VisibilityScope +from src.domain.quality_facts import build_outcome_payload from src.models.consolidation import Consolidation from src.models.directories import LegalEntity from src.models.enums import ( @@ -44,15 +46,18 @@ from src.models.enums import ( AuditSection, ConsolidationStatus, DocumentStatus, + DocumentType, FileStatus, + QualityOutcome, RecognitionStatus, S3BucketRole, ) -from src.models.files import RecognitionJob, S3Object, StoredFile +from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.repositories import consolidations as consolidations_repo from src.repositories import directories as directories_repo from src.repositories import files as files_repo from src.repositories import statements as statements_repo +from src.repositories.quality_facts import insert_outcome_fact from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services import consolidation_documents @@ -73,6 +78,12 @@ FILE_NOT_FOUND = "файл не найден" # одинаково для чу FILE_CONTENT_NOT_FOUND = "FILE_CONTENT_NOT_FOUND" FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" +# §14.3 (design quality-metrics §4.2, AURORA-1759): факт исхода `DELETED`/`DELETED_AFTER_CONFIRM` пишется только +# для этих типов — тот же набор, что `OUTCOME_FACT_DOCUMENT_TYPES` в services/auto_confirm.py (Task 6) и +# services/documents.py (Task 7); не импортируется оттуда во избежание цикла (то же решение, что в Task 6/7) +OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV}) +# статусы документа, у которого исход подтверждения ещё не было: удаление пишет `DELETED`, а не `DELETED_AFTER_CONFIRM` +_NEVER_CONFIRMED_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}) class StorageUnavailableError(DomainError): @@ -304,6 +315,50 @@ class PrimaryFilesService: media_type = CONTENT_TYPES.get(file.file_type.value.lower(), "application/octet-stream") return _chain(first, iterator), media_type, header + async def _write_delete_outcome_facts( + self, + documents: Sequence[Document], + issue_counts: Mapping[uuid.UUID, statements_repo.IssueCounts], + now: datetime.datetime, + ) -> None: + """Факт исхода `DELETED`/`DELETED_AFTER_CONFIRM` на документ удаляемого файла (§14.3; design quality-metrics + §4.2, §6; AURORA-1759). Гейт — `OUTCOME_FACT_DOCUMENT_TYPES`: DISCLOSURE/OTHER без факта (как у `CONFIRMED`/ + `AUTO_CONFIRMED`, Task 6/7). + + Документ `CONFIRMED`, дошедший до этой точки, — ВСЕГДА автоподтверждённый: вручную подтверждённый + (`confirmed_by IS NOT NULL`) даёт 409 `FILE_HAS_CONFIRMED_DOCUMENTS` раньше в `delete` (см. docstring метода), + поэтому здесь — `DELETED_AFTER_CONFIRM`. `RECOGNIZED`/`NEEDS_CHECK` — документ без исхода подтверждения — + `DELETED`. Иной статус (`ERROR`) факта не получает: у него не было осмысленного состояния «как распознано», + которое стоило бы фиксировать как удалённый исход (решение задачи 8, design.md). + + `primary_cause=None` явно — §14.3 правило 1: `DELETED*` вне матрицы причин. `payload` — минимальный снимок + без построчных данных (`build_outcome_payload(document, fixes=())`): это событие удаления, не разбор правок, + и строки формы/ОСВ документа всё равно исчезнут за этим же вызовом (решение задачи 8). Пишется в транзакции + вызывающего (`delete`), без `flush`/`commit`. + """ + for document in documents: + if document.document_type not in OUTCOME_FACT_DOCUMENT_TYPES: + continue + if document.status is DocumentStatus.CONFIRMED: + outcome = QualityOutcome.DELETED_AFTER_CONFIRM + elif document.status in _NEVER_CONFIRMED_STATUSES: + outcome = QualityOutcome.DELETED + else: + continue + counts = issue_counts.get(document.id) + issue_count_at_outcome = (counts.error + counts.info) if counts is not None else 0 + await insert_outcome_fact( + self._session, + document_id=document.id, + outcome=outcome, + outcome_at=now, + primary_cause=None, + report_date=document.reporting_date, + confirmed_by=document.confirmed_by, + issue_count_at_outcome=issue_count_at_outcome, + payload=build_outcome_payload(document, fixes=()), + ) + async def delete(self, file_id: uuid.UUID) -> None: """Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10). @@ -319,7 +374,13 @@ class PrimaryFilesService: и `stored_file`; `s3_object` файла и результатов заданий остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и `results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает - ретеншн бакета. `before_value` аудита — карточка файла.""" + ретеншн бакета. `before_value` аудита — карточка файла. + + §14.3 (design quality-metrics §4.2, AURORA-1759): на каждый документ файла типа BALANCE/INCOME/OSV ДО + физического удаления строк пишется факт исхода `DELETED` (документ не подтверждался) или + `DELETED_AFTER_CONFIRM` (автоподтверждённый — вручную подтверждённый сюда не доходит, см. 409 выше); + `_write_delete_outcome_facts`. + """ visible = await files_repo.get_file(self._session, self._scope, file_id) if visible is None: raise NotFoundError(FILE_NOT_FOUND) @@ -356,6 +417,7 @@ class PrimaryFilesService: ) before = file_out(file, current, documents, counts, issue_counts).model_dump(mode="json") object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] + await self._write_delete_outcome_facts(documents, issue_counts, utc_now()) await files_repo.delete_file_rows(self._session, file) # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла owned_prefixes = [ diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 6af74d1..3190e1b 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -27,12 +27,15 @@ from src.models.enums import ( AuditSection, ConsolidationStatus, DocumentStatus, + DocumentType, FileStatus, PeriodQuarter, + QualityOutcome, RecognitionStatus, ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.models.quality_facts import QualityOutcomeFact from src.models.statements import ( DocumentIssue, FinancialStatementColumn, @@ -88,6 +91,17 @@ async def _delete_events(session: AsyncSession) -> list[AuditEvent]: return list(rows.scalars()) +async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact: + return ( + await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) + ).scalar_one() + + +async def _outcome_facts(session: AsyncSession, document_id: uuid.UUID) -> list[QualityOutcomeFact]: + rows = await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) + return list(rows.scalars()) + + # --- успешное удаление ---------------------------------------------------------------------------------------------- @@ -570,3 +584,83 @@ async def test_delete_auto_confirmed_without_bindings(seeded: AsyncSession, auth assert response.status_code == HTTP_NO_CONTENT, response.text [event] = await _delete_events(seeded) assert event.after_value == {"unbound_version_ids": []} + + +# --- факты исхода DELETED/DELETED_AFTER_CONFIRM (задача 8, §14.3, AURORA-1759) ---------------------------------------- + + +async def test_delete_file_writes_deleted_fact_for_unconfirmed_document( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Документ `NEEDS_CHECK` — подтверждения не было вовсе: удаление файла пишет факт исхода `DELETED`, + `primary_cause` не входит в матрицу причин (§14.3 правило 1) и остаётся `NULL`.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + document = await make_document(seeded, file=file, job=job, status=DocumentStatus.NEEDS_CHECK) + file_id, document_id = file.id, document.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + fact = await _outcome_fact(seeded, document_id) + assert fact.outcome == QualityOutcome.DELETED + assert fact.primary_cause is None + assert fact.confirmed_by is None + assert fact.report_date == document.reporting_date + assert fact.payload["fixes"] == [] + + +async def test_delete_file_writes_deleted_after_confirm_for_auto_confirmed_document( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Документ уже auto_confirm'нут (`confirmed_by IS NULL`), без привязок к версиям консолидации: ручного + подтверждения (OQ-157/409 `FILE_HAS_CONFIRMED_DOCUMENTS`) здесь нет, файл удаляется — факт исхода + `DELETED_AFTER_CONFIRM`.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + document = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) # confirmed_by NULL + file_id, document_id = file.id, document.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + fact = await _outcome_fact(seeded, document_id) + assert fact.outcome == QualityOutcome.DELETED_AFTER_CONFIRM + assert fact.primary_cause is None + assert fact.confirmed_by is None + + +async def test_delete_other_document_writes_no_outcome_fact( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Гейт типа документа (тот же набор `OUTCOME_FACT_DOCUMENT_TYPES`, что у `CONFIRMED`/`AUTO_CONFIRMED`, + Task 6/7): `OTHER` удаляется, как прежде, но факта исхода не получает.""" + entity = await _rostov_entity(seeded) + file, _job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + now = utc_now() + document = Document( + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=DocumentType.OTHER, + file_ordinal=0, + status=DocumentStatus.NEEDS_CHECK, + created_at=now, + updated_at=now, + ) + seeded.add(document) + await seeded.flush() + file_id, document_id = file.id, document.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await _outcome_facts(seeded, document_id) == [] -- 2.47.2 From 3a325e886c9acf3f15aa563544de44644279f709 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 11:08:55 +0400 Subject: [PATCH 15/44] =?UTF-8?q?docs(#190):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=208=20(DELETED/DELETED=5FAFTER=5FCONFIRM)=20=D0=B2?= =?UTF-8?q?=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенос трёх решений, делегированных брифом исполнителю и задокументированных в докстринге PrimaryFilesService._write_delete_outcome_facts, в раздел «Decisions made without discussion» по правилу CLAUDE.md о маркировке находок. Co-Authored-By: Claude Sonnet 5 --- openspec/changes/quality-metrics/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 553d0b4..590c9c7 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -230,3 +230,4 @@ PDF, точный вид выгрузки, разбиение на задачи. | 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит | | 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл уже есть в `../docs/contracts/` (сиблинг-репозиторий arm-ka-docs, работа по нему уже слита туда), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | | 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 | +| 06.11.2026 | Task 8 (`DELETED`/`DELETED_AFTER_CONFIRM`): (1) документ в статусе, отличном от `CONFIRMED`/`RECOGNIZED`/`NEEDS_CHECK` (практически только `ERROR`), при удалении файла НЕ получает факт исхода — у него никогда не было содержательного состояния «как распознано» для фиксации (нет формы/строк, не проходил ни через `AUTO_CONFIRMED`, ни через intake-факт); (2) `issue_count_at_outcome` вычисляется как `counts.error + counts.info` из уже посчитанного агрегата `issue_counts_by_document` вместо отдельного запроса — тот же смысл «все нарушения документа без фильтра», что `len(issues)` в Task 6/7, дешевле на один запрос; (3) `payload` для DELETED*-фактов — единый минимальный вызов `build_outcome_payload(document, fixes=())` без дополнительных запросов к строкам/значениям формы или ОСВ (эти строки всё равно будут физически удалены этим же вызовом `delete`, повторный их снимок здесь экономически не оправдан — а в матрицу §6 эти факты и не попадают, доступны только как «удалён/заменён», drill-down уже видит прежний исход через более ранний intake/outcome факт того же `document_id`) | бриф задачи 8 явно делегировал все три пункта исполнителю («если неоднозначно — зафиксировать выбор, не угадывать»); исполнитель выбрал и задокументировал в докстрингах кода, перенесено сюда контроллером по правилу маркировки находок (решение агента без обсуждения с человеком — в design.md) | (1): при появлении у `ERROR`-документов содержательного состояния до удаления (гипотетически) — нужно будет расширить список статусов; (2)/(3): минимальный риск — агрегаты эквивалентны, а payload не используется для атрибуции (там `primary_cause=None`, вне матрицы) | -- 2.47.2 From 5edcad6e9ccc3aca9a97177995cfaf38ad38fe75 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 11:22:09 +0400 Subject: [PATCH 16/44] =?UTF-8?q?feat(#190):=20=D1=84=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=B0=20SUPERSEDED/SUPERSEDED=5F?= =?UTF-8?q?AFTER=5FCONFIRM=20=D0=BF=D1=80=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §14.3 (design quality-metrics §4.2, AURORA-1759): supersede_previous пишет один факт исхода на каждый документ BALANCE/INCOME/OSV заменённого файла — и выпавший (без ключа среди документов нового файла, OQ-154), и совпавший по ключу («rest»): замена идёт файлом целиком (design №33), поэтому факт не ограничивается только выпавшими документами. CONFIRMED на момент замены (в т. ч. вручную — здесь, в отличие от удаления файла, нет 409-проверки) → SUPERSEDED_AFTER_CONFIRM, иначе SUPERSEDED; primary_cause всегда NULL (вне матрицы причин). Счётчик нарушений — одним запросом на все документы всех заменяемых файлов. Co-Authored-By: Claude Sonnet 5 --- api/src/services/result_mapping.py | 39 +++++++++- api/tests/test_result_mapping.py | 116 ++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 95d0bca..d882aeb 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -53,7 +53,7 @@ from src.domain.osv_mapping import ( document_status, opening_matches, ) -from src.domain.quality_facts import build_intake_payload +from src.domain.quality_facts import build_intake_payload, build_outcome_payload from src.domain.statement_issues import IssueDraft, mapping_warning from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok from src.domain.statement_status import duplicate_period_forms, has_error_warning, osv_status_v2 @@ -73,6 +73,7 @@ from src.models.enums import ( OsvDataType, OsvGroupType, PeriodQuarter, + QualityOutcome, RecognitionStatus, StatementLineStatus, ) @@ -81,7 +82,7 @@ from src.models.osv import OsvEntry from src.models.statements import DocumentIssue from src.repositories import statements as statements_repo from src.repositories.counterparties import CounterpartyIndex -from src.repositories.quality_facts import insert_intake_fact +from src.repositories.quality_facts import insert_intake_fact, insert_outcome_fact from src.services import consolidation_documents from src.services.audit import AuditService from src.services.file_status import set_file_status_for_job @@ -861,7 +862,7 @@ async def _skip_warnings( return [_skip_warning(old, skipped[old.id]) for old in kept.scalars()] -async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена одной транзакцией: входы и шаги именованные +async def supersede_previous( # noqa: PLR0912, PLR0913, PLR0915 - автозамена одной транзакцией: входы и шаги именованные session: AsyncSession, *, new_file: StoredFile, @@ -892,6 +893,11 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена (`_replaceable`) — документы другой организации, загруженные в карточку, не вытесняют друг друга. Файл, оставленный только из-за этого, — предупреждение в `warnings` (`_skip_warning`); `UNKNOWN` заменяется как прежде. + §14.3 (design quality-metrics §4.2, AURORA-1759): на каждый документ BALANCE/INCOME/OSV заменённого файла — + факт исхода `SUPERSEDED` (не подтверждался) или `SUPERSEDED_AFTER_CONFIRM` (был `CONFIRMED` на момент замены, в + т. ч. вручную — здесь, в отличие от удаления файла (задача 8), нет 409-проверки, исключающей такие документы). + На КАЖДЫЙ документ заменённого файла, а не только на выпавшие (OQ-154 выше): замена — файл целиком (design №33). + Блокировки: advisory-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`); затем @@ -969,11 +975,38 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена documents_by_file: dict[uuid.UUID, list[Document]] = {} for document in file_documents.scalars(): documents_by_file.setdefault(document.stored_file_id, []).append(document) + # §14.3 (design quality-metrics §4.2, AURORA-1759): факт исхода на КАЖДЫЙ документ заменённого файла — и выпавший, + # и совпавший по ключу («rest»): замена — файл целиком (design №33, тот же принцип, что у снятия привязок выше). + # Счётчик нарушений — одним запросом на все документы всех заменяемых файлов (без N+1 на документ/файл). + outcome_issue_counts = await statements_repo.issue_counts_by_document( + session, [document.id for group in documents_by_file.values() for document in group] + ) unbound: dict[uuid.UUID, list[uuid.UUID]] = {} dropped_by_file: dict[uuid.UUID, list[Document]] = {} # документы без ключа среди новых (OQ-154) dropped_versions: dict[uuid.UUID, list[uuid.UUID]] = {} # версии, где сняты привязки именно выпавших документов for old in locked: # по возрастанию id; advisory ГК и версии берутся первым вызовом (порядок D-9) old_documents = documents_by_file.get(old.id, []) + for document in old_documents: + if document.document_type not in INTAKE_FACT_DOCUMENT_TYPES: # тот же гейт, что у §14.1 (задачи 5-8) + continue + outcome = ( + QualityOutcome.SUPERSEDED_AFTER_CONFIRM + if document.status is DocumentStatus.CONFIRMED + else QualityOutcome.SUPERSEDED + ) + counts = outcome_issue_counts.get(document.id) + issue_count_at_outcome = (counts.error + counts.info) if counts is not None else 0 + await insert_outcome_fact( + session, + document_id=document.id, + outcome=outcome, + outcome_at=now, + primary_cause=None, # SUPERSEDED*/DELETED* вне матрицы причин (§14.3 правило 1) + report_date=document.reporting_date, + confirmed_by=document.confirmed_by, # может быть не NULL: здесь нет 409-проверки задачи 8 + issue_count_at_outcome=issue_count_at_outcome, + payload=build_outcome_payload(document, fixes=()), + ) dropped = [document for document in old_documents if _document_key(document) not in keys] dropped_by_file[old.id] = dropped # выпавшие снимаются отдельным первым вызовом: он возвращает версии именно их привязок (после блокировки diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index b4e99af..9708bdb 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -50,11 +50,12 @@ from src.models.enums import ( OsvDataType, OsvGroupType, PeriodQuarter, + QualityOutcome, RecognitionStatus, ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry -from src.models.quality_facts import QualityIntakeFact +from src.models.quality_facts import QualityIntakeFact, QualityOutcomeFact from src.models.statements import ( DocumentIssue, FinancialStatementLine, @@ -1539,6 +1540,119 @@ async def test_supersede_previous_reports_dropped_documents_without_changing_rep assert event.after_value is not None and event.after_value["unbound_version_ids"] == [str(in_work_id)] +# --- факт исхода SUPERSEDED/SUPERSEDED_AFTER_CONFIRM (quality_outcome_fact, §14.3, design quality-metrics §4.2, +# AURORA-1759/#190) --------------------------------------------------------------------------------------------- + + +async def _outcome_fact(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> QualityOutcomeFact: + async with maker() as session: + return ( + await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) + ).scalar_one() + + +async def test_supersede_previous_writes_superseded_fact_for_dropped_document( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """Задача 9 (Review Focus докс-плана): документ, выпавший из автозамены (ключа нет среди документов нового + файла — OQ-154), не подтверждавшийся (`NEEDS_CHECK`) — факт исхода `SUPERSEDED`, без причины (вне матрицы §6).""" + maker = committed_sessionmaker + async with maker() as session, session.begin(): + entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000001") + old_file, old_job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + old_62 = await make_document( + session, file=old_file, job=old_job, account="62", status=DocumentStatus.NEEDS_CHECK + ) + new_file, new_job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + new_60 = await make_document(session, file=new_file, job=new_job, account="60") + old_62_id, new_file_id = old_62.id, new_file.id + old_62_report_date = old_62.reporting_date + + async with maker() as session, session.begin(): + new = await session.get(StoredFile, new_file_id) + assert new is not None + await supersede_previous(session, new_file=new, new_documents=[new_60], uploader_id=None, now=_now()) + + fact = await _outcome_fact(maker, old_62_id) + assert fact.outcome == QualityOutcome.SUPERSEDED + assert fact.primary_cause is None + assert fact.confirmed_by is None + assert fact.report_date == old_62_report_date + assert fact.payload["fixes"] == [] + + +async def test_supersede_previous_writes_superseded_after_confirm_for_confirmed_document( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """Задача 9: автозамена подтверждённого документа — в отличие от удаления (задача 8), здесь нет 409-проверки, + блокирующей вручную подтверждённые документы, замена их не исключает. `confirmed_by` факта — из документа + (может быть не NULL, читается как есть, не считается всегда системным).""" + maker = committed_sessionmaker + async with maker() as session, session.begin(): + confirmer = (await session.execute(select(AppUser).order_by(AppUser.email).limit(1))).scalar_one() + entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000002") + old_file, old_job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + old_62 = await make_document(session, file=old_file, job=old_job, account="62", status=DocumentStatus.CONFIRMED) + old_62.confirmed_by = confirmer.id + new_file, new_job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + new_60 = await make_document(session, file=new_file, job=new_job, account="60") + old_62_id, new_file_id, confirmer_id = old_62.id, new_file.id, confirmer.id + + async with maker() as session, session.begin(): + new = await session.get(StoredFile, new_file_id) + assert new is not None + await supersede_previous(session, new_file=new, new_documents=[new_60], uploader_id=None, now=_now()) + + fact = await _outcome_fact(maker, old_62_id) + assert fact.outcome == QualityOutcome.SUPERSEDED_AFTER_CONFIRM + assert fact.primary_cause is None + assert fact.confirmed_by == confirmer_id + + +async def test_supersede_previous_writes_fact_for_matched_document_too( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """Review Focus докс-плана (обязательный тест, не опциональный): замена — «файл ЦЕЛИКОМ со всеми документами в + нём» — факт исхода пишется не только для выпавших документов, но и для совпавшего по ключу с новым файлом + (`replaced_documents`/«rest»). Реализация, пишущая факты только по `dropped`, прошла бы два теста выше, но не + этот: здесь у совпавшего документа (account 60, CONFIRMED) тоже должен появиться факт.""" + maker = committed_sessionmaker + async with maker() as session, session.begin(): + entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000003") + old_file, old_job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + old_60 = await make_document( + session, file=old_file, job=old_job, account="60", status=DocumentStatus.CONFIRMED + ) # совпадёт по ключу с документом нового файла — «rest», не выпавший + old_62 = await make_document( + session, file=old_file, job=old_job, account="62", status=DocumentStatus.NEEDS_CHECK + ) # ключа среди документов нового файла нет — выпавший (OQ-154) + new_file, new_job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + new_60 = await make_document(session, file=new_file, job=new_job, account="60") + old_60_id, old_62_id, new_file_id = old_60.id, old_62.id, new_file.id + + async with maker() as session, session.begin(): + new = await session.get(StoredFile, new_file_id) + assert new is not None + await supersede_previous(session, new_file=new, new_documents=[new_60], uploader_id=None, now=_now()) + + matched_fact = await _outcome_fact(maker, old_60_id) + assert matched_fact.outcome == QualityOutcome.SUPERSEDED_AFTER_CONFIRM # совпал по ключу, но факт тоже пишется + dropped_fact = await _outcome_fact(maker, old_62_id) + assert dropped_fact.outcome == QualityOutcome.SUPERSEDED + + # --- osv-result-mapping «Документ NEEDS_CHECK переводит версию в корректировку» (D-6) ------------------------------ -- 2.47.2 From 9a1584281dfb09350304ca1a12a0c9c59bf69be5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 11:34:58 +0400 Subject: [PATCH 17/44] =?UTF-8?q?fix(#190):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=209=20=E2=80=94=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BE=D0=B2=D0=BF=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=8E=D1=89=D0=B8=D0=B9=20=D0=BF=D0=BE=20=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D1=83=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=201/2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью фикс-раунда 1: у old_file в обоих тестах был только один документ (account="62"), ключ которого не совпадал ни с одним документом нового файла (account="60") — _replaceable не находила совпадений, file_ids оставался пустым, и supersede_previous возвращала [] на раннем `if not file_ids: return []`, до locked/хука. Тесты не проверяли SUPERSEDED/SUPERSEDED_AFTER_CONFIRM, а падали бы с NoResultFound при реальном прогоне. Добавлен документ account="60" в old_file каждого теста — триггер замены файла; account="62" остаётся выпавшим документом, на котором проверяется факт исхода. Co-Authored-By: Claude Sonnet 5 --- api/tests/test_result_mapping.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 9708bdb..ea00a6b 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -1555,13 +1555,21 @@ async def test_supersede_previous_writes_superseded_fact_for_dropped_document( committed_sessionmaker: async_sessionmaker[AsyncSession], ): """Задача 9 (Review Focus докс-плана): документ, выпавший из автозамены (ключа нет среди документов нового - файла — OQ-154), не подтверждавшийся (`NEEDS_CHECK`) — факт исхода `SUPERSEDED`, без причины (вне матрицы §6).""" + файла — OQ-154), не подтверждавшийся (`NEEDS_CHECK`) — факт исхода `SUPERSEDED`, без причины (вне матрицы §6). + + Фикс-раунд 1 ревью задачи 9: у `old_file` ОБЯЗАТЕЛЬНО должен быть документ, совпадающий по ключу с документом нового + файла (здесь — `old_60`/`new_60`, оба account="60") — иначе `_replaceable` не находит ни одного совпадения, + `file_ids` остаётся пустым, и `supersede_previous` возвращает `[]` на раннем `if not file_ids: return []` + ДО того, как доходит до `locked`/хука: файл не заменяется вовсе, и `_outcome_fact` на `old_62` упал бы с + `NoResultFound`. `old_60` сам не проверяется в этом тесте — он лишь триггер замены файла; выпавший документ + для assert — `old_62` (account="62", ключа которого нет среди документов нового файла).""" maker = committed_sessionmaker async with maker() as session, session.begin(): entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000001") old_file, old_job = await make_stored_file( session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) + await make_document(session, file=old_file, job=old_job, account="60") # триггер замены (совпадает по ключу) old_62 = await make_document( session, file=old_file, job=old_job, account="62", status=DocumentStatus.NEEDS_CHECK ) @@ -1590,7 +1598,12 @@ async def test_supersede_previous_writes_superseded_after_confirm_for_confirmed_ ): """Задача 9: автозамена подтверждённого документа — в отличие от удаления (задача 8), здесь нет 409-проверки, блокирующей вручную подтверждённые документы, замена их не исключает. `confirmed_by` факта — из документа - (может быть не NULL, читается как есть, не считается всегда системным).""" + (может быть не NULL, читается как есть, не считается всегда системным). + + Фикс-раунд 1 ревью задачи 9: как и в тесте выше, `old_file` должен иметь документ, совпадающий по ключу с документом + нового файла (`old_60`/`new_60`, account="60") — иначе файл не заменяется вовсе (`_replaceable` не находит + совпадений, `file_ids` пуст, ранний `return []`), и `_outcome_fact` на `old_62` упал бы с `NoResultFound`. + `old_62` (account="62", CONFIRMED) — выпавший документ, на котором проверяется `SUPERSEDED_AFTER_CONFIRM`.""" maker = committed_sessionmaker async with maker() as session, session.begin(): confirmer = (await session.execute(select(AppUser).order_by(AppUser.email).limit(1))).scalar_one() @@ -1598,6 +1611,7 @@ async def test_supersede_previous_writes_superseded_after_confirm_for_confirmed_ old_file, old_job = await make_stored_file( session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) + await make_document(session, file=old_file, job=old_job, account="60") # триггер замены (совпадает по ключу) old_62 = await make_document(session, file=old_file, job=old_job, account="62", status=DocumentStatus.CONFIRMED) old_62.confirmed_by = confirmer.id new_file, new_job = await make_stored_file( -- 2.47.2 From 68dc5118f64656e2d8e4bead6cfe6d6efe7a78dc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 11:39:29 +0400 Subject: [PATCH 18/44] =?UTF-8?q?docs(#190):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=209=20=D0=BE=D0=B1=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D0=B5=20ERROR=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Документирует находку ревью задачи 9 (minor, deferred): почему supersede_previous не исключает ERROR-статус из факта исхода, в отличие от primary_files.delete. Co-Authored-By: Claude Sonnet 5 --- openspec/changes/quality-metrics/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 590c9c7..f41fb50 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -231,3 +231,4 @@ PDF, точный вид выгрузки, разбиение на задачи. | 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл уже есть в `../docs/contracts/` (сиблинг-репозиторий arm-ka-docs, работа по нему уже слита туда), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | | 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 | | 06.11.2026 | Task 8 (`DELETED`/`DELETED_AFTER_CONFIRM`): (1) документ в статусе, отличном от `CONFIRMED`/`RECOGNIZED`/`NEEDS_CHECK` (практически только `ERROR`), при удалении файла НЕ получает факт исхода — у него никогда не было содержательного состояния «как распознано» для фиксации (нет формы/строк, не проходил ни через `AUTO_CONFIRMED`, ни через intake-факт); (2) `issue_count_at_outcome` вычисляется как `counts.error + counts.info` из уже посчитанного агрегата `issue_counts_by_document` вместо отдельного запроса — тот же смысл «все нарушения документа без фильтра», что `len(issues)` в Task 6/7, дешевле на один запрос; (3) `payload` для DELETED*-фактов — единый минимальный вызов `build_outcome_payload(document, fixes=())` без дополнительных запросов к строкам/значениям формы или ОСВ (эти строки всё равно будут физически удалены этим же вызовом `delete`, повторный их снимок здесь экономически не оправдан — а в матрицу §6 эти факты и не попадают, доступны только как «удалён/заменён», drill-down уже видит прежний исход через более ранний intake/outcome факт того же `document_id`) | бриф задачи 8 явно делегировал все три пункта исполнителю («если неоднозначно — зафиксировать выбор, не угадывать»); исполнитель выбрал и задокументировал в докстрингах кода, перенесено сюда контроллером по правилу маркировки находок (решение агента без обсуждения с человеком — в design.md) | (1): при появлении у `ERROR`-документов содержательного состояния до удаления (гипотетически) — нужно будет расширить список статусов; (2)/(3): минимальный риск — агрегаты эквивалентны, а payload не используется для атрибуции (там `primary_cause=None`, вне матрицы) | +| 06.11.2026 | Task 9 (`SUPERSEDED`/`SUPERSEDED_AFTER_CONFIRM`): документ в статусе `ERROR` НЕ исключён из факта-исхода при автозамене — `outcome = SUPERSEDED_AFTER_CONFIRM if status is CONFIRMED else SUPERSEDED`, без третьей ветки, в отличие от Task 8 (`delete`), где `ERROR`/прочие статусы факт не получают вовсе. Выбор противоположен Task 8-й строке выше, но осознанно: подтверждён построчной проверкой всех мест `Document.status = ...` в `src/` (`documents.py`, `osv_header.py`, `auto_confirm.py`, `domain/osv_mapping.py`) — `DocumentStatus.ERROR` НИГДЕ не присваивается документу в продакшн-коде сегодня, различие с Task 8 необнаружимо на практике | бриф задачи 9 дал буквальную формулу (`CONFIRMED` → `..._AFTER_CONFIRM`, иначе → без суффикса) без явного исключения `ERROR`; поскольку путь недостижим, более простой код (без третьей ветки) предпочтён усложнению ради симметрии с Task 8 | если в будущем появится код, присваивающий `Document.status = ERROR`, `delete()` и `supersede_previous()` разойдутся в поведении (один пропустит факт, другой запишет `SUPERSEDED`) без того, чтобы это было осознанным решением на тот момент — стоит перепроверить эту строку, если `ERROR`-статус когда-нибудь станет достижим | -- 2.47.2 From 7f184d895fc2eed0505c08c9202c0b54987d032e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 13:00:12 +0400 Subject: [PATCH 19/44] =?UTF-8?q?feat(#190):=20=D1=84=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D1=82=D0=B5=D1=85=D0=BD=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0=20(TIMEOUT/?= =?UTF-8?q?NO=5FACK/SERVICE=5FERROR/MAPPING,=20=C2=A714.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Задача 10: quality_failure_fact на 6 точках отказа — вахтёр сроков (expire_deadlines, TIMEOUT, батчем на все просроченные задания), dlq (_process_one, NO_ACK), невалидный запрос и результат сервиса со status=error (_process_one/_apply_result, SERVICE_ERROR), неповторяемая и исчерпанная ошибки маппинга (_fail/_register_failure, MAPPING). Измерения ЮЛ/ГК/is_scan — отдельным запросом по file_id (батчем у вахтёра), отсутствие строки ЮЛ не прерывает обработку, только лог. Восстановление после обрыва сессии: имплементер задачи 10 (agent a7ad86adedbac1d8a) закончил работу, но коммит и отчёт не состоялись — диф остался в рабочем дереве незакоммиченным. Коммит и прогон проверок — контроллером (ruff/mypy/pytest -m "not integration" чисты; один magic-value в новом тесте поправлен тут же), не ревью находка. Co-Authored-By: Claude Sonnet 5 --- api/src/services/recognition_exchange.py | 194 ++++++++++++++++++++++- api/src/services/result_mapping.py | 84 +++++++++- api/tests/test_recognition_exchange.py | 163 ++++++++++++++++++- api/tests/test_recognition_inbox.py | 75 ++++++++- api/tests/test_result_mapping.py | 140 +++++++++++++++- 5 files changed, 643 insertions(+), 13 deletions(-) diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index fc69e77..dbf4a24 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -79,8 +79,10 @@ from src.contract.versions import parse_message from src.core.config import Settings from src.core.nats import NatsPublishError from src.core.storage import ObjectStorage -from src.models.enums import FileStatus, RecognitionPipeline, RecognitionStatus, S3BucketRole +from src.models.directories import CompanyGroup, LegalEntity +from src.models.enums import FileStatus, QualityFailureKind, RecognitionPipeline, RecognitionStatus, S3BucketRole from src.models.files import RecognitionJob, S3Object, StoredFile +from src.repositories.quality_facts import insert_failure_fact from src.services.file_status import set_file_status_for_job, set_file_status_for_jobs from src.services.result_mapping import run_mapping_loop @@ -208,6 +210,121 @@ def _short(text: str) -> str: return text if len(text) <= ERROR_MESSAGE_MAX_LENGTH else text[: ERROR_MESSAGE_MAX_LENGTH - 1] + "…" +# --- факт отказа (quality_failure_fact, §14.5, design quality-metrics §4.3, AURORA-1759) ----------------------------- + + +@dataclass(frozen=True) +class _FailureDimensions: + """ЮЛ/ГК и признак скана файла на момент отказа — измерения `quality_failure_fact`, не хранимые в `recognition_job` + самом (design §4: факт без FK, имена и id ЮЛ/ГК — копия на момент отказа).""" + + legal_entity_id: uuid.UUID + legal_entity_name: str + group_id: uuid.UUID | None + group_name: str | None + is_scan: bool | None + + +async def _failure_dimensions_batch( + session: AsyncSession, file_ids: Sequence[uuid.UUID] +) -> dict[uuid.UUID, _FailureDimensions]: + """Измерения ЮЛ/ГК для набора `file_id` одним запросом (вахтёр переводит в `timeout` пачку заданий за проход, + §14.5) — без N+1 на задание. Файл без строки ЮЛ (не должно быть при штатной работе — ЮЛ не удаляются) просто + не попадает в результат.""" + if not file_ids: + return {} + stmt = ( + select( + StoredFile.id, + StoredFile.is_scan, + LegalEntity.id, + LegalEntity.name, + LegalEntity.company_group_id, + CompanyGroup.name, + ) + .join(LegalEntity, LegalEntity.id == StoredFile.legal_entity_id) + .outerjoin(CompanyGroup, CompanyGroup.id == LegalEntity.company_group_id) + .where(StoredFile.id.in_(list(file_ids))) + ) + rows = await session.execute(stmt) + return { + file_id: _FailureDimensions( + legal_entity_id=legal_entity_id, legal_entity_name=legal_entity_name, group_id=group_id, + group_name=group_name, is_scan=is_scan, + ) + for file_id, is_scan, legal_entity_id, legal_entity_name, group_id, group_name in rows + } + + +async def _failure_dimensions(session: AsyncSession, file_id: uuid.UUID) -> _FailureDimensions | None: + """Измерения ЮЛ/ГК одного файла (dlq / ошибка валидации запроса / ошибка сервиса — задание обрабатывается по + одному); тот же запрос, что у `_failure_dimensions_batch`, набор из одного элемента.""" + return (await _failure_dimensions_batch(session, [file_id])).get(file_id) + + +async def _record_failure_fact( # noqa: PLR0913 - все измерения факта отказа именованные + session: AsyncSession, + *, + job_id: uuid.UUID, + file_id: uuid.UUID, + attempt: int, + pipeline: RecognitionPipeline, + dims: _FailureDimensions, + failure_kind: QualityFailureKind, + error_code: str | None, + service_name: str | None, + failed_at: datetime, +) -> None: + """Тонкая обёртка `insert_failure_fact` (Task 4): измерения ЮЛ/ГК — из уже найденного `dims` (без повторного + запроса на каждый вызов у вызывающих с батчем).""" + await insert_failure_fact( + session, + job_id=job_id, + file_id=file_id, + attempt=attempt, + legal_entity_id=dims.legal_entity_id, + legal_entity_name=dims.legal_entity_name, + group_id=dims.group_id, + group_name=dims.group_name, + pipeline=pipeline, + is_scan=dims.is_scan, + service_name=service_name, + failure_kind=failure_kind, + error_code=error_code, + failed_at=failed_at, + ) + + +async def _write_timeout_failure_facts(session: AsyncSession, rows: Sequence[Any], *, failed_at: datetime) -> None: + """Факт отказа `TIMEOUT` на каждое задание, переведённое в `timeout` этим проходом вахтёра (§14.5): задание ещё + не получало ответа сервиса — `service_name = NULL`. Пишется для ВСЕХ строк `rows`, включая задания не текущие + для своего файла (у файла уже новое задание) — отказ конкретной попытки, а не статус файла.""" + if not rows: + return + dimensions = await _failure_dimensions_batch(session, sorted({row.file_id for row in rows})) + for row in rows: + dims = dimensions.get(row.file_id) + if dims is None: + logger.warning( + "Факт отказа TIMEOUT не записан: ЮЛ файла не найдено. job_id={job_id}, file_id={file_id}", + job_id=row.id, + file_id=row.file_id, + ) + continue + await _record_failure_fact( + session, + job_id=row.id, + file_id=row.file_id, + attempt=row.attempt, + pipeline=row.pipeline, + dims=dims, + failure_kind=QualityFailureKind.TIMEOUT, + error_code=ERROR_TIMEOUT, + service_name=None, + failed_at=failed_at, + ) + + async def _process_one( # noqa: PLR0913 - один шаг прохода: все параметры прохода session: AsyncSession, nats: RequestPublisher, @@ -239,6 +356,27 @@ async def _process_one( # noqa: PLR0913 - один шаг прохода: вс ) ) await set_file_status_for_job(session, row, FileStatus.ERROR, now=now) + # §14.5: факт отказа NO_ACK — задание так и не получило ответа сервиса, service_name неизвестен + dims = await _failure_dimensions(session, row.file_id) + if dims is None: + logger.warning( + "Факт отказа NO_ACK не записан: ЮЛ файла не найдено. job_id={job_id}, file_id={file_id}", + job_id=row.id, + file_id=row.file_id, + ) + else: + await _record_failure_fact( + session, + job_id=row.id, + file_id=row.file_id, + attempt=row.attempt, + pipeline=row.pipeline, + dims=dims, + failure_kind=QualityFailureKind.NO_ACK, + error_code=ERROR_NO_ACK, + service_name=None, + failed_at=now, + ) report.dlq += 1 logger.warning( "Запрос распознавания без ack: задание переведено в dlq. error_category=no_ack, job_id={job_id}, " @@ -263,6 +401,28 @@ async def _process_one( # noqa: PLR0913 - один шаг прохода: вс ) # финальная ошибка задания — файл текущего задания ERROR (spec primary-files «Жизненный цикл статуса файла») await set_file_status_for_job(session, row, FileStatus.ERROR, now=now) + # §14.5: любой переход recognition_job.status -> error — SERVICE_ERROR, независимо от причины (наша + # валидация запроса или ошибка, о которой сообщил сервис, design Review Focus основного плана) + dims = await _failure_dimensions(session, row.file_id) + if dims is None: + logger.warning( + "Факт отказа SERVICE_ERROR не записан: ЮЛ файла не найдено. job_id={job_id}, file_id={file_id}", + job_id=row.id, + file_id=row.file_id, + ) + else: + await _record_failure_fact( + session, + job_id=row.id, + file_id=row.file_id, + attempt=row.attempt, + pipeline=row.pipeline, + dims=dims, + failure_kind=QualityFailureKind.SERVICE_ERROR, + error_code=ERROR_INVALID_REQUEST, + service_name=None, + failed_at=now, + ) report.invalid += 1 logger.error( "Запрос распознавания не собран по контракту, задание переведено в error. " @@ -373,7 +533,8 @@ async def expire_deadlines(session_factory: async_sessionmaker[AsyncSession], *, """Вахтёр сроков (spec «Таймаут ожидания результата»): `processing`/`published` с `reply_deadline < now` → `timeout`, `error_code = TIMEOUT`, `finished_at = now`. Один атомарный `UPDATE … RETURNING id` с условием по статусу: результат, успевший перевести задание в финальный статус, не перетирается. В той же транзакции файлы этих - заданий (только где задание — текущее, `current_recognition_job_id`) → `ERROR`. Возвращает число заданий.""" + заданий (только где задание — текущее, `current_recognition_job_id`) → `ERROR`, и для КАЖДОГО (а не только + текущего) — факт отказа `TIMEOUT` (§14.5). Возвращает число заданий.""" moment = (now or _utcnow)() stmt = ( update(RecognitionJob) @@ -387,12 +548,14 @@ async def expire_deadlines(session_factory: async_sessionmaker[AsyncSession], *, error_message="Сервис распознавания не прислал результат до срока ответа (reply_deadline)", finished_at=moment, ) - .returning(RecognitionJob.id) + .returning(RecognitionJob.id, RecognitionJob.file_id, RecognitionJob.pipeline, RecognitionJob.attempt) .execution_options(synchronize_session=False) ) async with session_factory() as session, session.begin(): - job_ids = list((await session.execute(stmt)).scalars()) + rows = (await session.execute(stmt)).all() + job_ids = [row.id for row in rows] files = await set_file_status_for_jobs(session, job_ids, FileStatus.ERROR, now=moment) + await _write_timeout_failure_facts(session, rows, failed_at=moment) count = len(job_ids) if count: logger.warning( @@ -726,6 +889,29 @@ async def _apply_result( job.result_s3_object_id = result_s3_object_id job.result_msg_id = msg_id file_changed = await _apply_result_file_status(session, job, now) + if job.status is RecognitionStatus.error: + # §14.5: любой переход recognition_job.status -> error — SERVICE_ERROR; service_name/error_code уже + # заполнены выше из сообщения сервиса — здесь единственная точка отказа, где они известны + dims = await _failure_dimensions(session, job.file_id) + if dims is None: + logger.warning( + "Факт отказа SERVICE_ERROR не записан: ЮЛ файла не найдено. job_id={job_id}, file_id={file_id}", + job_id=job.id, + file_id=job.file_id, + ) + else: + await _record_failure_fact( + session, + job_id=job.id, + file_id=job.file_id, + attempt=job.attempt, + pipeline=job.pipeline, + dims=dims, + failure_kind=QualityFailureKind.SERVICE_ERROR, + error_code=job.error_code, + service_name=job.service_name, + failed_at=now, + ) logger.info( "Результат распознавания принят. job_id={job_id}, status={status}, service={service}, msg_id={msg_id}, " "error_code={error_code}, file_changed={file_changed}", diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index d882aeb..ca6cca9 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -73,6 +73,7 @@ from src.models.enums import ( OsvDataType, OsvGroupType, PeriodQuarter, + QualityFailureKind, QualityOutcome, RecognitionStatus, StatementLineStatus, @@ -82,7 +83,7 @@ from src.models.osv import OsvEntry from src.models.statements import DocumentIssue from src.repositories import statements as statements_repo from src.repositories.counterparties import CounterpartyIndex -from src.repositories.quality_facts import insert_intake_fact, insert_outcome_fact +from src.repositories.quality_facts import insert_failure_fact, insert_intake_fact, insert_outcome_fact from src.services import consolidation_documents from src.services.audit import AuditService from src.services.file_status import set_file_status_for_job @@ -129,6 +130,10 @@ INTAKE_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOM STORED_FILE_OBJECT_TYPE = "stored_file" # `audit_event.object_type` события FILE_REPLACE SUPERSEDE_LOCK_PREFIX = "supersede" # пространство ключей advisory-блокировки автозамены в пределах ЮЛ _FORBIDDEN_KEY_SEGMENTS = frozenset({"", ".", ".."}) +# §14.5 (design quality-metrics §4.3): единый короткий error_code факта отказа MAPPING — что именно не так (схема, +# S3, объект не найден, БД) остаётся в `metrics.mapping_error`/логе (M-1), у `quality_failure_fact` нет текстовой +# колонки для детали; свой код, не пересекается с ERROR_TIMEOUT/ERROR_NO_ACK/ERROR_INVALID_REQUEST обмена +MAPPING_FAILURE_ERROR_CODE = "MAPPING_FAILED" Clock = Callable[[], datetime] SleepFn = Callable[[float], Awaitable[None]] @@ -1074,17 +1079,86 @@ async def _lock_file(session: AsyncSession, file_id: uuid.UUID) -> StoredFile | return (await session.execute(stmt)).scalar_one_or_none() +# --- факт отказа (quality_failure_fact, §14.5, design quality-metrics §4.3, AURORA-1759) ----------------------------- + + +@dataclass(frozen=True) +class _FailureDimensions: + """ЮЛ/ГК и признак скана файла на момент отказа маппинга — измерения `quality_failure_fact` (design §4: факт без + FK, имена и id ЮЛ/ГК — копия на момент отказа).""" + + legal_entity_id: uuid.UUID + legal_entity_name: str + group_id: uuid.UUID | None + group_name: str | None + is_scan: bool | None + + +async def _failure_dimensions(session: AsyncSession, file_id: uuid.UUID) -> _FailureDimensions | None: + """ЮЛ/ГК и признак скана файла по `file_id` (`session.get`, тот же приём, что у `_write_intake_facts`): файл или + ЮЛ не найдены (не должно быть при штатной работе) — `None`, факт отказа не пишется, остальная обработка не + прерывается.""" + file = await session.get(StoredFile, file_id) + if file is None: + return None + owner = await session.get(LegalEntity, file.legal_entity_id) + if owner is None: + return None + group_name: str | None = None + if owner.company_group_id is not None: + group = await session.get(CompanyGroup, owner.company_group_id) + group_name = group.name if group is not None else None + return _FailureDimensions( + legal_entity_id=owner.id, + legal_entity_name=owner.name, + group_id=owner.company_group_id, + group_name=group_name, + is_scan=file.is_scan, + ) + + +async def _write_mapping_failure_fact(session: AsyncSession, job: RecognitionJob, *, failed_at: datetime) -> None: + """Факт отказа `MAPPING` (§14.5) — `job` уже дошёл до `done`/`needs_check` (`service_name`/`attempt` сервиса + известны, в отличие от отказов обмена задачи 10 sites 1-4), а маппинг результата не удался.""" + dims = await _failure_dimensions(session, job.file_id) + if dims is None: + logger.warning( + "Факт отказа MAPPING не записан: ЮЛ файла не найдено. job_id={job_id}, file_id={file_id}", + job_id=job.id, + file_id=job.file_id, + ) + return + await insert_failure_fact( + session, + job_id=job.id, + file_id=job.file_id, + attempt=job.attempt, + legal_entity_id=dims.legal_entity_id, + legal_entity_name=dims.legal_entity_name, + group_id=dims.group_id, + group_name=dims.group_name, + pipeline=job.pipeline, + is_scan=dims.is_scan, + service_name=job.service_name, + failure_kind=QualityFailureKind.MAPPING, + error_code=MAPPING_FAILURE_ERROR_CODE, + failed_at=failed_at, + ) + + async def _fail( session: AsyncSession, candidate: Candidate, message: str, now: datetime, *, cause: BaseException | None = None ) -> None: """Неповторяемая ошибка: `metrics.mapping_error` и файл текущего задания → `ERROR` (транзакция вызывающего). - `cause` — исходное исключение (например, `ValidationError`): его полный текст пишется только в лог (M-1).""" + `cause` — исходное исключение (например, `ValidationError`): его полный текст пишется только в лог (M-1). + §14.5: факт отказа `MAPPING`.""" job = await _lock_job(session, candidate.job_id) if job is None: return job.metrics = {**_metrics(job), MAPPING_ERROR_KEY: _short(message)} await session.flush() changed = await set_file_status_for_job(session, job, FileStatus.ERROR, now=now) + await _write_mapping_failure_fact(session, job, failed_at=now) logger.opt(exception=cause).error( "Маппинг результата невозможен: файл переведён в ERROR. error_category=mapping_fatal, job_id={job_id}, " "file_id={file_id}, file_changed={changed}, error={error}", @@ -1103,8 +1177,9 @@ async def _register_failure( max_attempts: int, now: datetime, ) -> bool: - """D-2: после отката — `metrics.mapping_attempts + 1` отдельной транзакцией; на `max_attempts` — файл `ERROR`. - Возвращает `True`, если попытки исчерпаны (файл переведён в `ERROR` и покинул очередь).""" + """D-2: после отката — `metrics.mapping_attempts + 1` отдельной транзакцией; на `max_attempts` — файл `ERROR` и + факт отказа `MAPPING` (§14.5). Возвращает `True`, если попытки исчерпаны (файл переведён в `ERROR` и покинул + очередь).""" async with session_factory() as session, session.begin(): job = await _lock_job(session, candidate.job_id) if job is None: @@ -1122,6 +1197,7 @@ async def _register_failure( await session.flush() if exhausted: await set_file_status_for_job(session, job, FileStatus.ERROR, now=now) + await _write_mapping_failure_fact(session, job, failed_at=now) logger.opt(exception=exc).error( "Ошибка маппинга результата, транзакция откачена. error_category=mapping_error, job_id={job_id}, " "file_id={file_id}, attempts={attempts}, file_error={exhausted}", diff --git a/api/tests/test_recognition_exchange.py b/api/tests/test_recognition_exchange.py index 9892882..ca76048 100644 --- a/api/tests/test_recognition_exchange.py +++ b/api/tests/test_recognition_exchange.py @@ -23,17 +23,22 @@ import pytest_asyncio from jsonschema import Draft202012Validator, FormatChecker from nats.aio.client import Client as NATS from nats.aio.msg import Msg -from sqlalchemy import JSON, update +from sqlalchemy import JSON, select, update from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from src.contract import NATS_MSG_ID_HEADER, RecognitionRequest from src.contract.request import FileType as ContractFileType from src.core.config import get_settings from src.core.nats import NatsConnection, NatsPublishError -from src.models.enums import FileStatus, FileType, RecognitionPipeline, RecognitionStatus -from src.models.files import RecognitionJob +from src.models.directories import LegalEntity +from src.models.enums import FileStatus, FileType, QualityFailureKind, RecognitionPipeline, RecognitionStatus +from src.models.files import RecognitionJob, StoredFile +from src.models.quality_facts import QualityFailureFact from src.services import recognition_exchange as rx from src.services.recognition_exchange import ( + ERROR_INVALID_REQUEST, + ERROR_NO_ACK, + ERROR_TIMEOUT, WATCHDOG_PERIOD_SECONDS, ExchangeTasks, expire_deadlines, @@ -41,6 +46,7 @@ from src.services.recognition_exchange import ( run_publisher_loop, run_watchdog_loop, ) +from src.services.result_mapping import MAPPING_FAILURE_ERROR_CODE from tests.factories import CommittedJobs CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts" @@ -187,6 +193,25 @@ def _publish( return publish_pending(session_factory, nats, ack_timeout=ACK, max_republish=MAX_REPUBLISH, now=now) +async def _legal_entity(session_factory: async_sessionmaker[AsyncSession], file_id: uuid.UUID) -> LegalEntity: + """ЮЛ-владелец файла (для сравнения с измерениями `quality_failure_fact`, §14.5).""" + async with session_factory() as session: + file = await session.get(StoredFile, file_id) + assert file is not None + entity = await session.get(LegalEntity, file.legal_entity_id) + assert entity is not None + return entity + + +async def _failure_fact( + session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID +) -> QualityFailureFact | None: + """Факт отказа задания `job_id`, если записан (`job_id` — unique, не больше одного).""" + async with session_factory() as session: + stmt = select(QualityFailureFact).where(QualityFailureFact.job_id == job_id) + return (await session.execute(stmt)).scalar_one_or_none() + + # --- юнит: соответствие enum, константы ------------------------------------------------------------------------ @@ -200,6 +225,13 @@ def test_orm_pipeline_matches_request_subjects(): assert {m.value for m in RecognitionPipeline} == {"tabular", "image"} +def test_mapping_failure_error_code_is_distinct_from_exchange_codes(): + """§14.5: `error_code` отказа MAPPING (`result_mapping.py`, задача 10) — свой короткий код, не пересекается с + кодами обмена (ERROR_TIMEOUT/ERROR_NO_ACK/ERROR_INVALID_REQUEST) — иначе по `error_code` без `failure_kind` + нельзя было бы отличить отказ обмена от отказа маппинга. Чисто логическая проверка, без БД.""" + assert MAPPING_FAILURE_ERROR_CODE not in {ERROR_TIMEOUT, ERROR_NO_ACK, ERROR_INVALID_REQUEST} + + def test_watchdog_period_is_30_seconds(): assert WATCHDOG_PERIOD_SECONDS == 30 # noqa: PLR2004 - значение из spec @@ -395,6 +427,41 @@ async def test_invalid_request_marks_job_error_and_pass_continues( assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None) +@pytest.mark.integration +@pytest.mark.parametrize("current", [True, False], ids=["current", "non_current"]) +async def test_invalid_request_writes_service_error_failure_fact( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, current: bool +): + """§14.5: запрос, не собранный по контракту, — тоже `SERVICE_ERROR` (любой переход `recognition_job.status -> + error` — SERVICE_ERROR, design Review Focus); факт пишется независимо от того, текущее это задание файла + (как и у TIMEOUT вахтёра) — отказ конкретной попытки. `nats.publish` до этой ветки не доходит — фейковый NATS + без реального соединения достаточно.""" + clock = Clock() + file_id, broken = await jobs.make_file_job(original_name=None) + if not current: + await jobs.add_job(file_id, status=RecognitionStatus.processing, current=True) + job = await jobs.get(broken) + entity = await _legal_entity(session_factory, file_id) + + report = await _publish(session_factory, FakeNats(), clock) + + assert report.invalid == 1 + fact = await _failure_fact(session_factory, broken) + assert fact is not None + assert fact.file_id == file_id + assert fact.attempt == job.attempt + assert fact.legal_entity_id == entity.id + assert fact.legal_entity_name == entity.name + assert fact.group_id is None + assert fact.group_name is None + assert fact.pipeline is RecognitionPipeline.tabular + assert fact.is_scan is None + assert fact.service_name is None + assert fact.failure_kind is QualityFailureKind.SERVICE_ERROR + assert fact.error_code == ERROR_INVALID_REQUEST + assert fact.failed_at == clock() + + # --- повторы без ack и dlq --------------------------------------------------------------------------------------- @@ -689,6 +756,62 @@ async def test_expire_deadlines_moves_current_file_to_error( assert (file.status, file.updated_at) == (FileStatus.ERROR, clock()) +@pytest.mark.integration +async def test_expire_deadlines_writes_timeout_failure_fact( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs +): + """§14.5: вахтёр переводит задание в `timeout` — факт отказа TIMEOUT; сервис так и не ответил — `service_name` + пуст. `.returning()` `expire_deadlines` отдаёт `file_id`/`pipeline`/`attempt` тем же `UPDATE`, без второго + запроса на задание.""" + clock = Clock() + file_id, job_id = await jobs.make_file_job( + job_status=RecognitionStatus.processing, reply_deadline=clock() - timedelta(minutes=1) + ) + job = await jobs.get(job_id) + entity = await _legal_entity(session_factory, file_id) + + assert await expire_deadlines(session_factory, now=clock) == 1 + + fact = await _failure_fact(session_factory, job_id) + assert fact is not None + assert fact.file_id == file_id + assert fact.attempt == job.attempt + assert fact.legal_entity_id == entity.id + assert fact.legal_entity_name == entity.name + assert fact.group_id is None + assert fact.group_name is None + assert fact.pipeline is RecognitionPipeline.tabular + assert fact.is_scan is None + assert fact.service_name is None + assert fact.failure_kind is QualityFailureKind.TIMEOUT + assert fact.error_code == ERROR_TIMEOUT + assert fact.failed_at == clock() + + +@pytest.mark.integration +async def test_expire_deadlines_writes_failure_fact_for_non_current_job( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs +): + """§14.5: факт отказа пишется на КАЖДОЕ просроченное задание, а не только на текущее задание файла (design + §14.5: «один файл может быть и в отказах, и в исходах»; Review Focus 1 — файл старого задания не трогается, но + задание всё равно получает свой факт — отказ попытки, а не статус файла).""" + clock = Clock() + file_id, old_job = await jobs.make_file_job( + file_status=FileStatus.UPLOADED, + job_status=RecognitionStatus.processing, + reply_deadline=clock() - timedelta(minutes=1), + ) + await jobs.add_job(file_id, status=RecognitionStatus.queued, current=True) + + assert await expire_deadlines(session_factory, now=clock) == 1 + + fact = await _failure_fact(session_factory, old_job) + assert fact is not None + assert fact.failure_kind is QualityFailureKind.TIMEOUT + file = await jobs.file(file_id) + assert file.status is FileStatus.UPLOADED # файл не трогается (текущее задание — другое) + + @pytest.mark.integration @pytest.mark.parametrize("file_status", [FileStatus.PROCESSED, FileStatus.SUPERSEDED]) async def test_expire_deadlines_keeps_processed_and_superseded_file( @@ -754,6 +877,40 @@ async def test_dlq_moves_current_file_to_error( assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None) +@pytest.mark.integration +async def test_dlq_writes_no_ack_failure_fact(session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs): + """§14.5: повторы без ack исчерпаны (`dlq`) — факт отказа NO_ACK; задание не получило ответа сервиса — + `service_name` пуст.""" + clock = Clock() + file_id, job_id = await jobs.make_file_job( + job_status=RecognitionStatus.published, + published_at=clock() - ACK * 2, + request_msg_id="dlq-fact-test:1", + reply_deadline=clock() + timedelta(hours=1), + metrics={"republish_count": MAX_REPUBLISH}, + ) + job = await jobs.get(job_id) + entity = await _legal_entity(session_factory, file_id) + + report = await _publish(session_factory, FakeNats(), clock) + + assert report.dlq == 1 + fact = await _failure_fact(session_factory, job_id) + assert fact is not None + assert fact.file_id == file_id + assert fact.attempt == job.attempt + assert fact.legal_entity_id == entity.id + assert fact.legal_entity_name == entity.name + assert fact.group_id is None + assert fact.group_name is None + assert fact.pipeline is RecognitionPipeline.tabular + assert fact.is_scan is None + assert fact.service_name is None + assert fact.failure_kind is QualityFailureKind.NO_ACK + assert fact.error_code == ERROR_NO_ACK + assert fact.failed_at == clock() + + # --- фоновые циклы ----------------------------------------------------------------------------------------------- diff --git a/api/tests/test_recognition_inbox.py b/api/tests/test_recognition_inbox.py index 289587a..65037bb 100644 --- a/api/tests/test_recognition_inbox.py +++ b/api/tests/test_recognition_inbox.py @@ -35,8 +35,10 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from src.contract import NATS_MSG_ID_HEADER, ack_msg_id, result_msg_id from src.core.config import get_settings from src.core.nats import NatsConnection -from src.models.enums import FileStatus, RecognitionStatus, S3BucketRole +from src.models.directories import LegalEntity +from src.models.enums import FileStatus, QualityFailureKind, RecognitionStatus, S3BucketRole from src.models.files import RecognitionJob, S3Object, StoredFile +from src.models.quality_facts import QualityFailureFact from src.services import recognition_exchange as rx from src.services.recognition_exchange import ( FINAL_STATUSES, @@ -176,6 +178,25 @@ async def _file_id(jobs: CommittedJobs, job_id: uuid.UUID) -> uuid.UUID: return (await jobs.get(job_id)).file_id +async def _legal_entity(session_factory: async_sessionmaker[AsyncSession], file_id: uuid.UUID) -> LegalEntity: + """ЮЛ-владелец файла (для сравнения с измерениями `quality_failure_fact`, §14.5).""" + async with session_factory() as session: + file = await session.get(StoredFile, file_id) + assert file is not None + entity = await session.get(LegalEntity, file.legal_entity_id) + assert entity is not None + return entity + + +async def _failure_fact( + session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID +) -> QualityFailureFact | None: + """Факт отказа задания `job_id`, если записан (`job_id` — unique, не больше одного).""" + async with session_factory() as session: + stmt = select(QualityFailureFact).where(QualityFailureFact.job_id == job_id) + return (await session.execute(stmt)).scalar_one_or_none() + + async def _handle(session_factory: async_sessionmaker[AsyncSession], msg: Any, **kwargs: Any) -> None: await handle_message(msg, session_factory=session_factory, **kwargs) @@ -535,6 +556,58 @@ async def test_service_error_without_result_s3(session_factory, jobs: CommittedJ assert await _s3_total(session_factory) == total_before +@pytest.mark.integration +async def test_service_error_result_writes_failure_fact(session_factory, jobs: CommittedJobs): + """§14.5 (задача 10, site 4): результат сервиса со `status=error` — факт отказа SERVICE_ERROR; в отличие от + отказов обмена без ответа (TIMEOUT/NO_ACK/INVALID_REQUEST — задачи 10 sites 1-3), здесь сервис уже ответил — + `service_name`/`error_code` берутся из уже заполненных полей задания, а не оставляются пустыми.""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + entity = await _legal_entity(session_factory, file_id) + body = envelope(job_id, file_id, "error", error={"code": "NOT_IMPLEMENTED", "message": "разбор ОСВ — Срез 1"}) + + await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1))) + + job = await _row(session_factory, job_id) + fact = await _failure_fact(session_factory, job_id) + assert fact is not None + assert fact.file_id == file_id + assert fact.attempt == job["attempt"] + assert fact.legal_entity_id == entity.id + assert fact.legal_entity_name == entity.name + assert fact.group_id is None + assert fact.group_name is None + assert fact.pipeline == job["pipeline"] + assert fact.is_scan is None + assert fact.service_name == "tabular-parser" + assert fact.failure_kind is QualityFailureKind.SERVICE_ERROR + assert fact.error_code == "NOT_IMPLEMENTED" + assert fact.failed_at == job["finished_at"] + + +@pytest.mark.integration +async def test_result_for_final_status_before_error_does_not_write_second_failure_fact( + session_factory, jobs: CommittedJobs +): + """Дубликат/запоздавший результат для задания, уже `error` (R-22 — без изменений): повторного факта нет — + `job_id` в `quality_failure_fact` unique, да и `_apply_result` для финального статуса возвращает `False` раньше + любых изменений (Review Focus).""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + body = envelope(job_id, file_id, "error", error={"code": "NOT_IMPLEMENTED", "message": "разбор ОСВ — Срез 1"}) + await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1))) + first = await _failure_fact(session_factory, job_id) + assert first is not None + + # повтор того же результата (дубликат по result_msg_id) — задание уже в финальном статусе, без изменений + await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1))) + + async with session_factory() as session: + stmt = select(func.count()).select_from(QualityFailureFact).where(QualityFailureFact.job_id == job_id) + count = (await session.execute(stmt)).scalar_one() + assert count == 1 + + @pytest.mark.integration @pytest.mark.parametrize( "status", diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index ea00a6b..e781248 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -50,12 +50,13 @@ from src.models.enums import ( OsvDataType, OsvGroupType, PeriodQuarter, + QualityFailureKind, QualityOutcome, RecognitionStatus, ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry -from src.models.quality_facts import QualityIntakeFact, QualityOutcomeFact +from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact from src.models.statements import ( DocumentIssue, FinancialStatementLine, @@ -2317,4 +2318,141 @@ def test_intake_fact_document_types_excludes_disclosure_and_other() -> None: чистая проверка множества типов хука, без БД (в отличие от прочих тестов `_map_candidate` в этом файле).""" assert rm.INTAKE_FACT_DOCUMENT_TYPES == {DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV} assert DocumentType.DISCLOSURE not in rm.INTAKE_FACT_DOCUMENT_TYPES + + +# --- факт отказа маппинга (quality_failure_fact, §14.5, design quality-metrics §4.3, задача 10 sites 5-6) ---------- + + +async def _failure_fact(maker: async_sessionmaker[AsyncSession], job_id: uuid.UUID) -> QualityFailureFact | None: + async with maker() as session: + return ( + await session.execute(select(QualityFailureFact).where(QualityFailureFact.job_id == job_id)) + ).scalar_one_or_none() + + +async def test_mapping_fatal_writes_mapping_failure_fact(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Задача 10, site 5 (§14.5): неповторяемая ошибка маппинга (`MappingFatal` — здесь ключ результата вне папки + задания, как в `test_result_key_outside_job_folder_is_error`) — факт отказа `MAPPING`. `job` в `_fail` уже + `done`/`needs_check` — `service_name` берётся из задания, а не оставляется пустым (в отличие от отказов обмена + задачи 10 sites 1-3, где сервис ещё не ответил).""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, entity_id = await _prepare( + maker, storage, _minimal(), key=f"results/{uuid.uuid4()}/{uuid.uuid4()}/result.json" + ) + job_before = await _job(maker, job_id) + + assert await process_once(maker, storage, _settings()) is True + + fact = await _failure_fact(maker, job_id) + assert fact is not None + assert fact.file_id == file_id + assert fact.attempt == job_before.attempt + assert fact.legal_entity_id == entity_id + assert fact.legal_entity_name == OWNER_NAME + assert fact.group_id is None + assert fact.group_name is None + assert fact.pipeline == job_before.pipeline + assert fact.is_scan is None + assert fact.service_name == job_before.service_name # `_prepare` не выставляет service_name — оба None + assert fact.failure_kind is QualityFailureKind.MAPPING + assert fact.error_code == rm.MAPPING_FAILURE_ERROR_CODE + assert fact.failed_at is not None + + +async def test_register_failure_writes_mapping_failure_fact_only_when_exhausted( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +): + """Задача 10, site 6 (§14.5): повторяемая ошибка маппинга (как в `test_mapping_failure_retries_then_error`) — + факт отказа `MAPPING` пишется только когда попытки исчерпаны (`exhausted`), не на каждом откате транзакции.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, entity_id = await _prepare(maker, storage, _minimal()) + + async def failing_map_file(session: AsyncSession, **kwargs: Any) -> list[str]: + raise RuntimeError("подставная ошибка БД при вставке строк") + + monkeypatch.setattr(rm, "map_file", failing_map_file) + + for _attempt in (1, 2): + assert await process_once(maker, storage, _settings()) is False + assert await _failure_fact(maker, job_id) is None # попытки не исчерпаны — факта ещё нет + + job_before = await _job(maker, job_id) + assert await process_once(maker, storage, _settings()) is True # третья попытка исчерпана — файл ERROR + + fact = await _failure_fact(maker, job_id) + assert fact is not None + assert fact.file_id == file_id + assert fact.attempt == job_before.attempt + assert fact.legal_entity_id == entity_id + assert fact.legal_entity_name == OWNER_NAME + assert fact.group_id is None + assert fact.group_name is None + assert fact.pipeline == job_before.pipeline + assert fact.is_scan is None + assert fact.service_name is None + assert fact.failure_kind is QualityFailureKind.MAPPING + assert fact.error_code == rm.MAPPING_FAILURE_ERROR_CODE + assert fact.failed_at is not None + + +async def test_file_reprocess_keeps_failure_fact_of_first_attempt_and_adds_new_intake( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """Шаг 7 плана (§14.5): `FILE_REPROCESS` (новое `recognition_job` с `attempt + 1` на тот же файл — + `src/services/file_retry.py` `FileRetryService._new`, здесь воспроизведено напрямую вставкой задания — тем же + приёмом, что `test_result_of_non_current_job_is_ignored`/`test_job_replaced_while_reading_result` выше) не + теряет факт отказа первой попытки: он остаётся на `job_id` первого задания (`attempt=1`), а успешная вторая + попытка пишет свой `quality_intake_fact` (`attempt=2`, задача 5) — оба факта сосуществуют на одном `file_id`, + это не двойной учёт одного события (design §14.5: «один файл может быть и в отказах, и в исходах»).""" + maker, storage = committed_sessionmaker, FakeStorage() + async with maker() as session, session.begin(): + entity = await make_legal_entity(session, name=OWNER_NAME) + file, job1 = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing + ) + job1.reply_deadline = _now() - timedelta(minutes=1) + file_id, job1_id = file.id, job1.id + + # первая попытка: вахтёр переводит задание в timeout -> файл ERROR -> факт отказа TIMEOUT (attempt=1, задача 10 + # sites 1-4, уже на этой ветке) + assert await expire_deadlines(maker) == 1 + assert (await _file(maker, file_id)).status is FileStatus.ERROR + + # FILE_REPROCESS: новое задание attempt=2, файл снова ждёт результата (как `FileRetryService._new`) + async with maker() as session, session.begin(): + new_file = await session.get(StoredFile, file_id) + job1_fresh = await session.get(RecognitionJob, job1_id) + assert new_file is not None and job1_fresh is not None + job2 = RecognitionJob( + file_id=file_id, + pipeline=job1_fresh.pipeline, + request_subject=job1_fresh.request_subject, + schema_version="1.0", + status=RecognitionStatus.done, + attempt=2, + finished_at=_now(), + ) + session.add(job2) + await session.flush() + new_file.current_recognition_job_id = job2.id + new_file.status = FileStatus.PROCESSING + job2_id = job2.id + payload = _minimal() | {"job_id": str(job2.id), "file_id": str(file_id)} + await make_result_object(session, job=job2, storage=storage, result=payload) + + # вторая попытка — успех: маппинг создаёт документ и пишет свой факт приёма (attempt=2) + assert await process_once(maker, storage, _settings()) is True + + async with maker() as session: + facts = (await session.execute(select(QualityFailureFact))).scalars().all() + assert len(facts) == 1 + assert facts[0].job_id == job1_id + assert facts[0].attempt == 1 + assert facts[0].file_id == file_id + + [document] = await _documents(maker, file_id) + intake = await _intake_fact(maker, document.id) + assert intake.job_id == job2_id + assert intake.attempt == 2 # noqa: PLR2004 - вторая попытка (job2) этого файла + assert intake.file_id == file_id assert DocumentType.OTHER not in rm.INTAKE_FACT_DOCUMENT_TYPES -- 2.47.2 From 494e789fbf2bc9301d05377f1fe471469f83417f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 13:13:27 +0400 Subject: [PATCH 20/44] =?UTF-8?q?docs(#190):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=2010=20=D0=BF=D1=80=D0=BE=20error=5Fcode=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2=20=D0=BC=D0=B0=D0=BF=D0=BF?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- openspec/changes/quality-metrics/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index f41fb50..3d35fca 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -231,4 +231,5 @@ PDF, точный вид выгрузки, разбиение на задачи. | 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл уже есть в `../docs/contracts/` (сиблинг-репозиторий arm-ka-docs, работа по нему уже слита туда), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | | 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 | | 06.11.2026 | Task 8 (`DELETED`/`DELETED_AFTER_CONFIRM`): (1) документ в статусе, отличном от `CONFIRMED`/`RECOGNIZED`/`NEEDS_CHECK` (практически только `ERROR`), при удалении файла НЕ получает факт исхода — у него никогда не было содержательного состояния «как распознано» для фиксации (нет формы/строк, не проходил ни через `AUTO_CONFIRMED`, ни через intake-факт); (2) `issue_count_at_outcome` вычисляется как `counts.error + counts.info` из уже посчитанного агрегата `issue_counts_by_document` вместо отдельного запроса — тот же смысл «все нарушения документа без фильтра», что `len(issues)` в Task 6/7, дешевле на один запрос; (3) `payload` для DELETED*-фактов — единый минимальный вызов `build_outcome_payload(document, fixes=())` без дополнительных запросов к строкам/значениям формы или ОСВ (эти строки всё равно будут физически удалены этим же вызовом `delete`, повторный их снимок здесь экономически не оправдан — а в матрицу §6 эти факты и не попадают, доступны только как «удалён/заменён», drill-down уже видит прежний исход через более ранний intake/outcome факт того же `document_id`) | бриф задачи 8 явно делегировал все три пункта исполнителю («если неоднозначно — зафиксировать выбор, не угадывать»); исполнитель выбрал и задокументировал в докстрингах кода, перенесено сюда контроллером по правилу маркировки находок (решение агента без обсуждения с человеком — в design.md) | (1): при появлении у `ERROR`-документов содержательного состояния до удаления (гипотетически) — нужно будет расширить список статусов; (2)/(3): минимальный риск — агрегаты эквивалентны, а payload не используется для атрибуции (там `primary_cause=None`, вне матрицы) | +| 06.11.2026 | Task 10 (`quality_failure_fact`): `error_code` для отказов маппинга (`_fail`, `_register_failure`) — константа `MAPPING_FAILURE_ERROR_CODE = "MAPPING_FAILED"` (`result_mapping.py`), не свободный текст из `message`/описания исключения | `QualityFailureFact.error_code` не имеет свободнотекстового столбца-соседа для деталей отказа маппинга (в отличие от `recognition_job.error_message`); деталь остаётся в `metrics.mapping_error`/логе, код — единая константа, отличимая от кодов обмена (`ERROR_NO_ACK`/`ERROR_INVALID_REQUEST`), что проверяет отдельный тест | если деталь отказа маппинга понадобится в матрице причин без обращения к логам — нужен отдельный столбец/payload-поле, не замена константы | | 06.11.2026 | Task 9 (`SUPERSEDED`/`SUPERSEDED_AFTER_CONFIRM`): документ в статусе `ERROR` НЕ исключён из факта-исхода при автозамене — `outcome = SUPERSEDED_AFTER_CONFIRM if status is CONFIRMED else SUPERSEDED`, без третьей ветки, в отличие от Task 8 (`delete`), где `ERROR`/прочие статусы факт не получают вовсе. Выбор противоположен Task 8-й строке выше, но осознанно: подтверждён построчной проверкой всех мест `Document.status = ...` в `src/` (`documents.py`, `osv_header.py`, `auto_confirm.py`, `domain/osv_mapping.py`) — `DocumentStatus.ERROR` НИГДЕ не присваивается документу в продакшн-коде сегодня, различие с Task 8 необнаружимо на практике | бриф задачи 9 дал буквальную формулу (`CONFIRMED` → `..._AFTER_CONFIRM`, иначе → без суффикса) без явного исключения `ERROR`; поскольку путь недостижим, более простой код (без третьей ветки) предпочтён усложнению ради симметрии с Task 8 | если в будущем появится код, присваивающий `Document.status = ERROR`, `delete()` и `supersede_previous()` разойдутся в поведении (один пропустит факт, другой запишет `SUPERSEDED`) без того, чтобы это было осознанным решением на тот момент — стоит перепроверить эту строку, если `ERROR`-статус когда-нибудь станет достижим | -- 2.47.2 From fa36602487c0d51d42a7db223c29b66378fa45fd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 13:16:02 +0400 Subject: [PATCH 21/44] =?UTF-8?q?fix(#190):=20=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D1=8C=20=D0=B0=D1=81=D1=81=D0=B5=D1=80=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20OTHER=20=D0=B2=20=D0=B5=D1=91=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82,=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=B8=D0=B7?= =?UTF-8?q?=20FILE=5FREPROCESS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью задачи 10 нашло сплайс: при реконструкции после обрыва сессии `assert DocumentType.OTHER not in rm.INTAKE_FACT_DOCUMENT_TYPES` приклеилась к концу test_file_reprocess_keeps_failure_fact_of_first_attempt_and_adds_new_intake (FILE_REPROCESS, §14.5) вместо своего теста test_intake_fact_document_types_excludes_disclosure_and_other, чья докстрока обещала проверку обоих исключённых типов (DISCLOSURE/OTHER), но проверяла только DISCLOSURE. Строка перемещена на своё место — ровно 1 строка туда, 1 строка оттуда, больше ничего в файле не менялось. Co-Authored-By: Claude Sonnet 5 --- api/tests/test_result_mapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index e781248..6de2490 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -2318,6 +2318,7 @@ def test_intake_fact_document_types_excludes_disclosure_and_other() -> None: чистая проверка множества типов хука, без БД (в отличие от прочих тестов `_map_candidate` в этом файле).""" assert rm.INTAKE_FACT_DOCUMENT_TYPES == {DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV} assert DocumentType.DISCLOSURE not in rm.INTAKE_FACT_DOCUMENT_TYPES + assert DocumentType.OTHER not in rm.INTAKE_FACT_DOCUMENT_TYPES # --- факт отказа маппинга (quality_failure_fact, §14.5, design quality-metrics §4.3, задача 10 sites 5-6) ---------- @@ -2455,4 +2456,3 @@ async def test_file_reprocess_keeps_failure_fact_of_first_attempt_and_adds_new_i assert intake.job_id == job2_id assert intake.attempt == 2 # noqa: PLR2004 - вторая попытка (job2) этого файла assert intake.file_id == file_id - assert DocumentType.OTHER not in rm.INTAKE_FACT_DOCUMENT_TYPES -- 2.47.2 From 110b2ce61d482f8a3e8ec026058278f382bb0196 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 13:27:02 +0400 Subject: [PATCH 22/44] =?UTF-8?q?feat(#190):=20=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D1=8B=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20?= =?UTF-8?q?=D0=98=D0=A1=20=D0=A1=D0=BA=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20(s3=5Fbucket=5Frole.raw)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _register_raw_object (по образцу _register_result_object) регистрирует raw_s3_key в s3_object с bucket_role=raw; бакет берётся у файла задания (stored_file -> s3_object), не у message.result_s3 (отсутствует при status=error) — именно error чаще всего и даёт сырой ответ без результата. Регистрация не зависит от message.status (R-2). Поле RecognitionJob.raw_s3_object_id уже существовало (Task 1) — решение v5.13 «не регистрируется» отменено v5.25 (OQ-179), комментарий contract/message.py обновлён. Тесты — tests/test_recognition_inbox.py (там же тестируются _apply_result и _register_result_object через handle_message, а не в test_recognition_exchange.py, который покрывает только outbox/вахтёр). Co-Authored-By: Claude Sonnet 5 --- api/src/contract/message.py | 2 +- api/src/services/recognition_exchange.py | 65 ++++++++++++- api/tests/test_recognition_inbox.py | 112 +++++++++++++++++++++++ 3 files changed, 177 insertions(+), 2 deletions(-) diff --git a/api/src/contract/message.py b/api/src/contract/message.py index a371cab..aaef408 100644 --- a/api/src/contract/message.py +++ b/api/src/contract/message.py @@ -52,7 +52,7 @@ class _MessageBase(ContractModel): result_s3: OptionalNotNull[S3Ref] = None # results/{file_id}/{job_id}/result.json result_schema_version: OptionalNotNull[ResultSchemaVersion] = None # schema_version файла результата error: OptionalNotNull[MessageError] = None - raw_s3_key: str | None = None # scan-gateway: raw/{job_id}.json; в БД не регистрируется + raw_s3_key: str | None = None # scan-gateway: raw/{job_id}.json @model_validator(mode="after") def _check_status_requirements(self) -> Self: diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index dbf4a24..325b1cb 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -848,11 +848,72 @@ async def _register_result_object(session: AsyncSession, message: ResultMessage, return existing +async def _register_raw_object( + session: AsyncSession, job: RecognitionJob, message: ResultMessage, now: datetime +) -> uuid.UUID | None: + """Сырой ответ ИС Сканирование в реестре `s3_object` (`bucket_role = raw`, `created_by = service.name`) — + v5.25: решение «не регистрировать» (OQ-179) отменено, сырой ответ нужен для эталонного прогона переразметки. + + Регистрируется независимо от `message.status` (R-2): сырой ответ полезен и у `error` — именно там + `message.result_s3` обычно отсутствует. Бакет — у ТОГО ЖЕ файла, что у задания (`stored_file` → `s3_object`), + не `message.result_s3` (может быть `None` при `status = error`). Если объект файла задания почему-то не + найден в реестре (не должно случаться в штатной работе), регистрация сырого ответа тихо пропускается — не + прерывает приём результата, только warning (тот же защитный стиль, что у `_failure_dimensions`). + + Уникальность `(bucket, key)` — как у `_register_result_object`: конфликт — переиспользуем существующую строку. + """ + assert message.raw_s3_key is not None + bucket = ( + await session.execute( + select(S3Object.bucket) + .join(StoredFile, StoredFile.s3_object_id == S3Object.id) + .where(StoredFile.id == job.file_id) + ) + ).scalar_one_or_none() + if bucket is None: + logger.warning( + "Сырой ответ ИС Сканирование не зарегистрирован: объект файла задания не найден в s3_object. " + "job_id={job_id}, file_id={file_id}, raw_s3_key={raw_s3_key}", + job_id=job.id, + file_id=job.file_id, + raw_s3_key=message.raw_s3_key, + ) + return None + key = message.raw_s3_key + stmt = ( + pg_insert(S3Object) + .values( + bucket_role=S3BucketRole.raw, + bucket=bucket, + key=key, + created_by=message.service.name.value, + created_at=now, + ) + .on_conflict_do_nothing(index_elements=[S3Object.bucket, S3Object.key]) + .returning(S3Object.id) + ) + inserted = (await session.execute(stmt)).scalar_one_or_none() + if inserted is not None: + return inserted + existing = ( + await session.execute(select(S3Object.id).where(S3Object.bucket == bucket, S3Object.key == key)) + ).scalar_one() + logger.warning( + "Сырой ответ ИС Сканирование уже зарегистрирован в s3_object, строка переиспользована. job_id={job_id}, " + "bucket={bucket}, key={key}", + job_id=job.id, + bucket=bucket, + key=key, + ) + return existing + + async def _apply_result( session: AsyncSession, job: RecognitionJob, message: ResultMessage, msg_id: str, now: datetime ) -> bool: """Результат (`done | needs_check | error`): дубликат и финальный статус — без изменений (`False`); иначе — - регистрация файла результата, обновление задания и статуса файла (`True`, дальше — commit).""" + регистрация файла результата и сырого ответа (если он есть, независимо от статуса — R-2), обновление задания + и статуса файла (`True`, дальше — commit).""" if job.result_msg_id == msg_id: logger.debug( "Повторная доставка результата (дубликат по result_msg_id), изменений нет. job_id={job_id}, " @@ -873,6 +934,8 @@ async def _apply_result( return False result_s3_object_id = await _register_result_object(session, message, now) if message.result_s3 else None + if message.raw_s3_key is not None: + job.raw_s3_object_id = await _register_raw_object(session, job, message, now) job.status = RESULT_STATUSES[message.status] job.service_name = message.service.name.value job.service_version = message.service.version diff --git a/api/tests/test_recognition_inbox.py b/api/tests/test_recognition_inbox.py index 65037bb..cfa321d 100644 --- a/api/tests/test_recognition_inbox.py +++ b/api/tests/test_recognition_inbox.py @@ -91,6 +91,10 @@ def _result_key(file_id: uuid.UUID, job_id: uuid.UUID) -> str: return f"results/{file_id}/{job_id}/result.json" +def _raw_key(job_id: uuid.UUID) -> str: + return f"raw/{job_id}.json" + + def envelope( job_id: uuid.UUID, file_id: uuid.UUID, status: str, *, version: str = "1.0", **extra: Any ) -> dict[str, Any]: @@ -731,6 +735,114 @@ async def test_existing_s3_object_is_reused(session_factory, jobs: CommittedJobs assert len(await _s3_rows(session_factory, _result_key(file_id, job_id))) == 1 +# --- сырой ответ ИС Сканирование (s3_bucket_role.raw, v5.25, задача 11, R-2) -------------------------------------- + + +@pytest.mark.integration +async def test_done_result_with_raw_s3_key_registers_raw_object(session_factory, jobs: CommittedJobs): + """Сценарий «Успешный результат» с `raw_s3_key`: сырой ответ регистрируется отдельной строкой `s3_object` + (`bucket_role = raw`), на ТОМ ЖЕ бакете, что у загрузки файла, `recognition_job.raw_s3_object_id` — на неё.""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + body = envelope(job_id, file_id, "done", raw_s3_key=_raw_key(job_id)) + + await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1))) + + job = await _row(session_factory, job_id) + assert job["raw_s3_object_id"] is not None + assert job["raw_s3_object_id"] != job["result_s3_object_id"] + (raw,) = await _s3_rows(session_factory, _raw_key(job_id)) + assert raw["id"] == job["raw_s3_object_id"] + assert raw["bucket_role"] is S3BucketRole.raw + assert raw["bucket"] == BUCKET # тот же бакет, что у stored_file файла — не из message.result_s3 + assert raw["created_by"] == "tabular-parser" + + +@pytest.mark.integration +async def test_result_without_raw_s3_key_leaves_raw_object_id_none(session_factory, jobs: CommittedJobs): + """Результат без `raw_s3_key` (например, Excel через tabular-parser) не создаёт raw-объект и не трогает + `raw_s3_object_id`.""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + total_before = await _s3_total(session_factory) + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))) + + job = await _row(session_factory, job_id) + assert job["raw_s3_object_id"] is None + assert await _s3_total(session_factory) == total_before + 1 # только результат, без raw + + +@pytest.mark.integration +async def test_service_error_result_with_raw_s3_key_registers_raw_object(session_factory, jobs: CommittedJobs): + """Ruling R-2: регистрация сырого ответа не зависит от `message.status` — при `status=error` `result_s3` + обычно отсутствует (sites §14.5), но сырой ответ сервиса всё равно регистрируется; бакет берётся у файла + задания (`stored_file` → `s3_object`), не у `message.result_s3` (его и нет).""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + body = envelope( + job_id, file_id, "error", error={"code": "NOT_IMPLEMENTED", "message": "x"}, raw_s3_key=_raw_key(job_id) + ) + + await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1))) + + job = await _row(session_factory, job_id) + assert job["result_s3_object_id"] is None # error: message.result_s3 отсутствует + assert job["raw_s3_object_id"] is not None + (raw,) = await _s3_rows(session_factory, _raw_key(job_id)) + assert raw["id"] == job["raw_s3_object_id"] + assert raw["bucket_role"] is S3BucketRole.raw + assert raw["bucket"] == BUCKET + + +@pytest.mark.integration +async def test_existing_raw_object_is_reused(session_factory, jobs: CommittedJobs): + """Ключ сырого ответа уже зарегистрирован — строка переиспользуется, дубль (bucket, key) не вставляется (тот + же паттерн идемпотентности, что у `_register_result_object`/`test_existing_s3_object_is_reused`).""" + job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) + file_id = await _file_id(jobs, job_id) + async with session_factory() as session, session.begin(): + existing = S3Object( + bucket_role=S3BucketRole.raw, + bucket=BUCKET, + key=_raw_key(job_id), + created_by="tabular-parser", + created_at=datetime.now(UTC), + ) + session.add(existing) + await session.flush() + existing_id = existing.id + jobs.track_s3_object(existing_id) + + body = envelope(job_id, file_id, "done", raw_s3_key=_raw_key(job_id)) + await _handle(session_factory, fake_msg(body, result_msg_id(job_id, 1))) + + assert (await _row(session_factory, job_id))["raw_s3_object_id"] == existing_id + assert len(await _s3_rows(session_factory, _raw_key(job_id))) == 1 + + +@pytest.mark.integration +async def test_register_raw_object_without_stored_file_row_is_skipped(session_factory, logs: list[Any]): + """Защитное поведение `_register_raw_object` (не должно случаться в штатной работе: `stored_file.s3_object_id` + NOT NULL FK — у файла задания всегда есть строка в `s3_object`): если бакет всё же не находится (здесь — + `job.file_id` не ссылается ни на одну строку `stored_file`), регистрация тихо пропускается — `None` и warning, + без исключения внутрь `_apply_result` (транзакция результата не должна падать из-за этого).""" + job_id = uuid.uuid4() + file_id = uuid.uuid4() # не существует в stored_file + body = envelope( + job_id, file_id, "error", error={"code": "NOT_IMPLEMENTED", "message": "x"}, raw_s3_key=_raw_key(job_id) + ) + message = rx._parse_message(fake_msg(body, result_msg_id(job_id, 1))) + assert message is not None + job = RecognitionJob(id=job_id, file_id=file_id) + + async with session_factory() as session: + result = await rx._register_raw_object(session, job, message, datetime.now(UTC)) + + assert result is None + assert len(_logged(logs, "не зарегистрирован", "WARNING")) == 1 + + # --- статусы stored_file (Срез 2c: spec primary-files «Жизненный цикл статуса файла», D-5, R-4, R-5) ------------- -- 2.47.2 From 362d20c1c1a35afbe648d61404b54f30944a19c2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 13:41:25 +0400 Subject: [PATCH 23/44] =?UTF-8?q?feat(#190):=20=D0=BF=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=BA=20=D1=81=D0=BA=D0=B0=D0=BD/=D1=86=D0=B8?= =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=B2=D0=BE=D0=B9=20PDF=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20(=C2=A714?= =?UTF-8?q?.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- api/requirements.txt | 1 + api/src/domain/pdf_text_layer.py | 24 ++++++++ api/src/services/primary_files.py | 6 ++ api/tests/test_api_files_upload.py | 80 +++++++++++++++++++++++++ api/tests/test_domain_pdf_text_layer.py | 52 ++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 api/src/domain/pdf_text_layer.py create mode 100644 api/tests/test_domain_pdf_text_layer.py diff --git a/api/requirements.txt b/api/requirements.txt index 990521c..8ace6fc 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -49,6 +49,7 @@ pydantic_core==2.46.4 Pygments==2.20.0 PyJWT[crypto]==2.8.0 PyNaCl==1.5.0 +pypdf==6.21.4 python-dotenv==1.2.2 python-multipart==0.0.32 PyYAML==6.0.3 diff --git a/api/src/domain/pdf_text_layer.py b/api/src/domain/pdf_text_layer.py new file mode 100644 index 0000000..8762c9e --- /dev/null +++ b/api/src/domain/pdf_text_layer.py @@ -0,0 +1,24 @@ +"""Признак текстового слоя PDF при загрузке (`stored_file.is_scan`, `05-domain-rules.md` §14.6). + +Цифровой PDF несёт извлекаемый текст (шрифты, операторы показа текста в потоке содержимого страницы); чистый скан — +только растровое изображение страницы без текста. ASSUMPTION §14.6: PDF со скрытым OCR-слоем (пост-обработка) +считается цифровым — различить его от исходно цифрового документа без дополнительных эвристик не планируется. + +Файл на этом шаге уже прошёл проверку сигнатуры `detect_file` (`domain/file_types.py`), но содержимое после сигнатуры +может быть повреждено или зашифровано — `has_text_layer` не должна падать на нём: любое исключение `pypdf` ловится и +даёт `False` (как для чистого скана), иначе загрузка отказала бы на мусорном, но формально PDF-файле. +""" + +import io + +from pypdf import PdfReader + + +def has_text_layer(content: bytes) -> bool: + """Есть ли хотя бы на одной странице `content` извлекаемый текст; битый/зашифрованный PDF и любая другая + ошибка разбора — `False`, без исключения.""" + try: + reader = PdfReader(io.BytesIO(content)) + return any(page.extract_text().strip() for page in reader.pages) + except Exception: + return False diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 32929ae..effb898 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -37,6 +37,7 @@ from src.contract import CONTRACT_VERSION, request_subject from src.core.config import Settings from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file +from src.domain.pdf_text_layer import has_text_layer from src.domain.policy import IN_WORK_STATUSES, VisibilityScope from src.domain.quality_facts import build_outcome_payload from src.models.consolidation import Consolidation @@ -48,6 +49,7 @@ from src.models.enums import ( DocumentStatus, DocumentType, FileStatus, + FileType, QualityOutcome, RecognitionStatus, S3BucketRole, @@ -528,6 +530,9 @@ class PrimaryFilesService: ) raise StorageUnavailableError(f"{STORAGE_UNAVAILABLE_MESSAGE}: файл «{name}» не сохранён") from error + # §14.6: признак скана — только для PDF, по отсутствию текстового слоя (true = скан, false = цифровой, в + # т.ч. со скрытым OCR-слоем); для остальных типов файла — NULL (pipeline = tabular) + is_scan = not has_text_layer(content) if detected.file_type is FileType.PDF else None stored_file = StoredFile( id=file_id, legal_entity_id=entity.id, @@ -541,6 +546,7 @@ class PrimaryFilesService: version_no=1, uploaded_by=user_id, uploaded_at=now, + is_scan=is_scan, ) job = RecognitionJob( id=job_id, diff --git a/api/tests/test_api_files_upload.py b/api/tests/test_api_files_upload.py index 3eff62f..dd0a374 100644 --- a/api/tests/test_api_files_upload.py +++ b/api/tests/test_api_files_upload.py @@ -307,6 +307,86 @@ async def test_image_file_goes_to_image_pipeline_with_one_attempt( assert len(storage.objects) == 1 +# --- «Признак скана» (§14.6, `stored_file.is_scan`) ------------------------------------------------------------- + + +def _digital_pdf(tag: str) -> bytes: + """Минимальный валидный одностраничный PDF с текстовым слоем (`BT ... Tj ET`): в репозитории нет зависимости + для генерации PDF (design #190, задача 12), поэтому байты строятся вручную, с уникальным хвостом текста (разный + sha256 — не дубль).""" + content_stream = b"BT /F1 12 Tf 10 100 Td (" + tag.encode() + b") Tj ET" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> " + b"/MediaBox [0 0 200 200] /Contents 5 0 R >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Length " + str(len(content_stream)).encode() + b" >>\nstream\n" + content_stream + b"\nendstream", + ] + buf = bytearray(b"%PDF-1.4\n") + offsets: list[int] = [] + for i, obj in enumerate(objects, start=1): + offsets.append(len(buf)) + buf += f"{i} 0 obj\n".encode() + obj + b"\nendobj\n" + xref_offset = len(buf) + n = len(objects) + 1 + buf += f"xref\n0 {n}\n".encode() + buf += b"0000000000 65535 f \n" + for off in offsets: + buf += f"{off:010d} 00000 n \n".encode() + buf += b"trailer\n<< /Size " + str(n).encode() + b" /Root 1 0 R >>\nstartxref\n" + buf += str(xref_offset).encode() + b"\n%%EOF" + return bytes(buf) + + +@pytest.mark.integration +async def test_upload_scanned_pdf_sets_is_scan_true( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """PDF без текстового слоя (в т.ч. формально битый, как здесь — сигнатура есть, структуры нет) — скан (`True`).""" + entity = await _rostov_entity(seeded) + client = await auth_client(KA_RF) + + response = await _post(client, entity.id, ("ББ скан.pdf", PDF + b"no-text-layer")) + + assert response.status_code == HTTP_CREATED, response.text + file = await seeded.get(StoredFile, uuid.UUID(response.json()["accepted"][0]["file_id"])) + assert file is not None + assert file.is_scan is True + + +@pytest.mark.integration +async def test_upload_digital_pdf_sets_is_scan_false( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """ВНИМАНИЕ — направление: PDF с текстовым слоем — цифровой, `is_scan = False` (не `True`).""" + entity = await _rostov_entity(seeded) + client = await auth_client(KA_RF) + + response = await _post(client, entity.id, ("ББ цифровой.pdf", _digital_pdf("digital"))) + + assert response.status_code == HTTP_CREATED, response.text + file = await seeded.get(StoredFile, uuid.UUID(response.json()["accepted"][0]["file_id"])) + assert file is not None + assert file.is_scan is False + + +@pytest.mark.integration +async def test_upload_non_pdf_leaves_is_scan_null( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """Не-PDF (конвейер `tabular`) — признак скана не определяется, `NULL`.""" + entity = await _rostov_entity(seeded) + client = await auth_client(KA_RF) + + response = await _post(client, entity.id, ("osv.xls", _xls("not-pdf-is-scan"))) + + assert response.status_code == HTTP_CREATED, response.text + file = await seeded.get(StoredFile, uuid.UUID(response.json()["accepted"][0]["file_id"])) + assert file is not None + assert file.is_scan is None + + @pytest.mark.integration async def test_two_files_write_two_upload_events( auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage diff --git a/api/tests/test_domain_pdf_text_layer.py b/api/tests/test_domain_pdf_text_layer.py new file mode 100644 index 0000000..99c22d7 --- /dev/null +++ b/api/tests/test_domain_pdf_text_layer.py @@ -0,0 +1,52 @@ +"""`has_text_layer` — признак текстового слоя PDF (`05-domain-rules.md` §14.6, `stored_file.is_scan`).""" + +from pathlib import Path + +import pytest + +from src.domain.pdf_text_layer import has_text_layer + +SCANNED_PDF = Path("ocr") / "aep_9.pdf" # реальный скан без текстового слоя (2 страницы, extract_text == "") + + +def _digital_pdf_bytes(text: bytes = b"Hello World") -> bytes: + """Минимальный валидный одностраничный PDF с текстовым слоем: `BT ... Tj ET` в потоке содержимого страницы, + без внешних зависимостей (в репозитории нет библиотеки генерации PDF, см. design #190).""" + content_stream = b"BT /F1 12 Tf 10 100 Td (" + text + b") Tj ET" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> " + b"/MediaBox [0 0 200 200] /Contents 5 0 R >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Length " + str(len(content_stream)).encode() + b" >>\nstream\n" + content_stream + b"\nendstream", + ] + buf = bytearray(b"%PDF-1.4\n") + offsets: list[int] = [] + for i, obj in enumerate(objects, start=1): + offsets.append(len(buf)) + buf += f"{i} 0 obj\n".encode() + obj + b"\nendobj\n" + xref_offset = len(buf) + n = len(objects) + 1 + buf += f"xref\n0 {n}\n".encode() + buf += b"0000000000 65535 f \n" + for off in offsets: + buf += f"{off:010d} 00000 n \n".encode() + buf += b"trailer\n<< /Size " + str(n).encode() + b" /Root 1 0 R >>\nstartxref\n" + buf += str(xref_offset).encode() + b"\n%%EOF" + return bytes(buf) + + +def test_has_text_layer_true_for_digital_pdf() -> None: + assert has_text_layer(_digital_pdf_bytes()) is True + + +@pytest.mark.fixtures +def test_has_text_layer_false_for_scanned_pdf(fixtures_dir: Path) -> None: + scanned_pdf_bytes = fixtures_dir.joinpath(SCANNED_PDF).read_bytes() + + assert has_text_layer(scanned_pdf_bytes) is False + + +def test_has_text_layer_false_for_corrupt_pdf_without_raising() -> None: + assert has_text_layer(b"not a pdf") is False -- 2.47.2 From c161643ac11f6bdc7c1e943910557a8536237604 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 22:46:48 +0400 Subject: [PATCH 24/44] =?UTF-8?q?feat(#190):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BE=20QUALITY=5FMETRICS=5FVIEW=20=D0=B2=D1=81=D0=B5=D0=BC=20?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D1=8F=D0=BC=20(design=20=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- api/seed/core/permissions.yaml | 4 ++++ api/seed/core/role_permissions.yaml | 7 +++++++ api/tests/test_seed_roles.py | 11 +++++++++-- web/src/api/types.ts | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/api/seed/core/permissions.yaml b/api/seed/core/permissions.yaml index 34b518a..6e5dd5b 100644 --- a/api/seed/core/permissions.yaml +++ b/api/seed/core/permissions.yaml @@ -65,3 +65,7 @@ object: AUDIT action: VIEW name: "Журнал действий: просмотр" +- code: QUALITY_METRICS_VIEW + object: QUALITY_METRICS + action: VIEW + name: "Метрики качества: просмотр" diff --git a/api/seed/core/role_permissions.yaml b/api/seed/core/role_permissions.yaml index 8c5559f..485bef2 100644 --- a/api/seed/core/role_permissions.yaml +++ b/api/seed/core/role_permissions.yaml @@ -13,12 +13,14 @@ ADMIN: - EX_VIEW - USERS_ADMIN - AUDIT_VIEW + - QUALITY_METRICS_VIEW CURATOR_GO: - DIRECTORY_VIEW - DIRECTORY_EDIT - CONSOLIDATION_VIEW - KFO_VIEW - EX_VIEW + - QUALITY_METRICS_VIEW KA_RF: - DIRECTORY_VIEW - DIRECTORY_EDIT @@ -30,6 +32,7 @@ KA_RF: - KFO_EXPORT - EX_VIEW - EXPORT + - QUALITY_METRICS_VIEW RKA: - DIRECTORY_VIEW - DIRECTORY_EDIT @@ -40,6 +43,7 @@ RKA: - KFO_APPROVE - EX_VIEW - EXPORT + - QUALITY_METRICS_VIEW KA_GO: - DIRECTORY_VIEW - DIRECTORY_EDIT @@ -52,15 +56,18 @@ KA_GO: - KFO_EXPORT - EX_VIEW - EXPORT + - QUALITY_METRICS_VIEW EXPERT: - DIRECTORY_VIEW - DOCUMENT_VIEW - CONSOLIDATION_VIEW - KFO_VIEW - EX_VIEW + - QUALITY_METRICS_VIEW CLIENT_MANAGER: - DIRECTORY_VIEW - DOCUMENT_VIEW - DOCUMENT_EDIT - CONSOLIDATION_VIEW - KFO_VIEW + - QUALITY_METRICS_VIEW diff --git a/api/tests/test_seed_roles.py b/api/tests/test_seed_roles.py index 64fd184..8ff5b36 100644 --- a/api/tests/test_seed_roles.py +++ b/api/tests/test_seed_roles.py @@ -30,6 +30,7 @@ MATRIX_S10: dict[str, set[tuple[str, str]]] = { ("EX", "VIEW"), ("USERS", "ADMIN"), ("AUDIT", "VIEW"), + ("QUALITY_METRICS", "VIEW"), }, "CURATOR_GO": { ("DIRECTORY", "VIEW"), @@ -37,6 +38,7 @@ MATRIX_S10: dict[str, set[tuple[str, str]]] = { ("CONSOLIDATION", "VIEW"), ("KFO", "VIEW"), ("EX", "VIEW"), + ("QUALITY_METRICS", "VIEW"), }, "KA_RF": { ("DIRECTORY", "VIEW"), @@ -49,6 +51,7 @@ MATRIX_S10: dict[str, set[tuple[str, str]]] = { ("KFO", "EXPORT"), ("EX", "VIEW"), ("EXPORT", "EXPORT"), + ("QUALITY_METRICS", "VIEW"), }, "RKA": { ("DIRECTORY", "VIEW"), @@ -60,6 +63,7 @@ MATRIX_S10: dict[str, set[tuple[str, str]]] = { ("KFO", "APPROVE"), ("EX", "VIEW"), ("EXPORT", "EXPORT"), + ("QUALITY_METRICS", "VIEW"), }, "KA_GO": { ("DIRECTORY", "VIEW"), @@ -73,6 +77,7 @@ MATRIX_S10: dict[str, set[tuple[str, str]]] = { ("KFO", "EXPORT"), ("EX", "VIEW"), ("EXPORT", "EXPORT"), + ("QUALITY_METRICS", "VIEW"), }, "EXPERT": { ("DIRECTORY", "VIEW"), @@ -80,6 +85,7 @@ MATRIX_S10: dict[str, set[tuple[str, str]]] = { ("CONSOLIDATION", "VIEW"), ("KFO", "VIEW"), ("EX", "VIEW"), + ("QUALITY_METRICS", "VIEW"), }, "CLIENT_MANAGER": { ("DIRECTORY", "VIEW"), @@ -87,11 +93,12 @@ MATRIX_S10: dict[str, set[tuple[str, str]]] = { ("DOCUMENT", "EDIT"), ("CONSOLIDATION", "VIEW"), ("KFO", "VIEW"), + ("QUALITY_METRICS", "VIEW"), }, } ROLE_COUNT = 7 -PERMISSION_COUNT = 16 -GRANT_COUNT = sum(len(pairs) for pairs in MATRIX_S10.values()) # 52 +PERMISSION_COUNT = 17 +GRANT_COUNT = sum(len(pairs) for pairs in MATRIX_S10.values()) # 59 ROLE_CODES = {"KA_RF", "KA_GO", "RKA", "CLIENT_MANAGER", "CURATOR_GO", "EXPERT", "ADMIN"} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d6166a0..4cfae5b 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -3,7 +3,7 @@ export type BusinessSegment = 'DKB' | 'DMSB' | 'DM'; /** Как `PermissionObject` / `PermissionAction` в api/src/models/enums.py. */ -export type PermissionObject = 'DIRECTORY' | 'DOCUMENT' | 'CONSOLIDATION' | 'KFO' | 'EX' | 'EXPORT' | 'USERS' | 'AUDIT'; +export type PermissionObject = 'DIRECTORY' | 'DOCUMENT' | 'CONSOLIDATION' | 'KFO' | 'EX' | 'EXPORT' | 'USERS' | 'AUDIT' | 'QUALITY_METRICS'; export type PermissionAction = 'VIEW' | 'EDIT' | 'APPROVE' | 'VERIFY' | 'DELETE' | 'EXPORT' | 'ADMIN'; /** Страница списка верхнего уровня (`schemas/common.py:Page`). */ -- 2.47.2 From b20f3fa60798e3c77baf38a875a3818d6869ddb7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 22:58:50 +0400 Subject: [PATCH 25/44] =?UTF-8?q?docs(#190):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=2013=20(=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20uni?= =?UTF-8?q?on=20types.ts)=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- openspec/changes/quality-metrics/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 3d35fca..c935de0 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -227,6 +227,7 @@ PDF, точный вид выгрузки, разбиение на задачи. | 05.11.2026 | Три новых enum-типа (`quality_outcome`, `quality_cause`, `quality_failure_kind`) — значения живут в схеме `SCHEMA` (`dkb`), НЕ в `METRICS_SCHEMA`, хотя используют их только таблицы `dkb_metrics` | `pg_enum()` (src/models/enums.py) без правок — единственный источник схемы enum'а; разводить enum'ы по двум схемам потребовало бы параметра схемы в фабрике без текущей пользы (Superset читает только таблицы, не типы) | таблица `dkb_metrics` зависит от типов в `dkb`; перенос схемы метрик на другой сервер БД без `dkb` невозможен без правки (сейчас design §10 этого не требует) | | 05.11.2026 | Откат миграции 0012 НЕ удаляет схему `dkb_metrics` (оставляет её пустой), симметрично тому, что `SCHEMA` (`dkb`) никогда не удаляется в `env.py`/0001 | тест `test_upgrade_into_preprovisioned_schema_without_database_create` (api/tests/test_migrations.py) показал конкретный отказ: роль без `CREATE` на базу не могла бы создать `dkb_metrics` заново при повторном `upgrade head`, если бы платформа выдавала схему готовой, а откат её удалял; симметрия с `dkb` — тот же риск, то же решение | если отладочный откат до base оставляет пустую схему `dkb_metrics` — незначительный мусор, не ошибка | | 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана | +| 06.11.2026 | Task 13: `web/src/api/types.ts` — `'QUALITY_METRICS'` дописан в конец union `PermissionObject` ОДНОЙ строкой (не многострочным форматом), итоговая строка ровно 140 символов — на пределе, но не сверх лимита ширины biome | сохраняет однострочный стиль, которым уже написана эта строка (и соседняя `PermissionAction`); многострочный вариант рассматривался как равноценная по типам альтернатива, выбор между ними непринципиален | нулевая — чисто форматирование, обе формы типобезопасно идентичны | | 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит | | 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл уже есть в `../docs/contracts/` (сиблинг-репозиторий arm-ka-docs, работа по нему уже слита туда), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | | 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 | -- 2.47.2 From cd7b687049a5c7d7c62444ae81deb7959327474e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 02:35:25 +0400 Subject: [PATCH 26/44] =?UTF-8?q?feat(#190):=20=D0=BC=D0=B0=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D0=B8=D1=81=D1=82=D0=B0=D1=8F=20=D1=84=D1=83?= =?UTF-8?q?=D0=BD=D0=BA=D1=86=D0=B8=D1=8F=20(=C2=A714.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- api/src/domain/quality_metrics.py | 139 +++++++++++++++++++++++ api/tests/test_domain_quality_metrics.py | 111 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 api/src/domain/quality_metrics.py create mode 100644 api/tests/test_domain_quality_metrics.py diff --git a/api/src/domain/quality_metrics.py b/api/src/domain/quality_metrics.py new file mode 100644 index 0000000..0f72174 --- /dev/null +++ b/api/src/domain/quality_metrics.py @@ -0,0 +1,139 @@ +"""Матрица причин и ряд по месяцам (§14.3 `05-domain-rules.md`; design `quality-metrics` §6, §7). + +Чистая функция от фактов исхода: сессию не читает и в БД не пишет. Причина документа (`primary_cause`) вычислена один +раз при записи факта (приоритет §14.3 п.1-6: сервис > наш разбор > документ клиента > без участия > как есть), здесь +она только группируется — атрибуция не пересчитывается. + +Метрики берут по документу последний факт исхода (`outcome_at`): повторное подтверждение ОСВ и +`*_AFTER_CONFIRM` пишут новый факт поверх прежнего. Исходы `DELETED` / `SUPERSEDED` («до подтверждения») и +`DELETED_AFTER_CONFIRM` / `SUPERSEDED_AFTER_CONFIRM` («после подтверждения») — отдельные столбцы, в доли не входят. +Знаменатель долей — документы с исходом `AUTO_CONFIRMED` / `CONFIRMED`. «В работе» (документ без исхода) и технические +отказы (считаются по файлам) сюда не передаются — их считает вызывающий сервис отдельными запросами. +""" + +import datetime +import uuid +from collections.abc import Sequence +from dataclasses import dataclass + +from src.models.enums import QualityCause, QualityOutcome + +__all__ = [ + "MatrixResult", + "MonthPoint", + "QualityOutcomeFactRow", + "compute_matrix", + "compute_monthly_series", +] + +_IN_DENOMINATOR = frozenset({QualityOutcome.AUTO_CONFIRMED, QualityOutcome.CONFIRMED}) +_BEFORE_CONFIRM = frozenset({QualityOutcome.DELETED, QualityOutcome.SUPERSEDED}) +_AFTER_CONFIRM = frozenset({QualityOutcome.DELETED_AFTER_CONFIRM, QualityOutcome.SUPERSEDED_AFTER_CONFIRM}) + + +@dataclass(frozen=True, slots=True) +class QualityOutcomeFactRow: + """Минимум полей `quality_outcome_fact`, нужных агрегации (вызывающий сервис строит его из SELECT).""" + + document_id: uuid.UUID + outcome: QualityOutcome + outcome_at: datetime.datetime + primary_cause: QualityCause | None + + +@dataclass(frozen=True, slots=True) +class MatrixResult: + """Столбцы матрицы причин (§14.3). + + `counts` — документы по причинам, всегда со всеми ключами `QualityCause` (нулевые тоже); `denominator` — сумма + `counts` (документы с исходом `AUTO_CONFIRMED` / `CONFIRMED`); `shares` — `counts / denominator`, при нулевом + знаменателе все доли 0.0. `removed_before_confirm` / `removed_after_confirm` — отдельные столбцы, в доли не входят. + """ + + counts: dict[QualityCause, int] + shares: dict[QualityCause, float] + denominator: int + removed_before_confirm: int + removed_after_confirm: int + + +@dataclass(frozen=True, slots=True) +class MonthPoint: + """Точка ряда: `month` — первое число месяца `outcome_at`; `share` — доля причины от знаменателя месяца.""" + + month: datetime.date + count: int + denominator: int + share: float + + +def _latest_per_document(outcomes: Sequence[QualityOutcomeFactRow]) -> list[QualityOutcomeFactRow]: + """Последний по `outcome_at` факт каждого документа (design §4.2); при равенстве времени — поздний во входе.""" + latest: dict[uuid.UUID, QualityOutcomeFactRow] = {} + for row in outcomes: + current = latest.get(row.document_id) + if current is None or row.outcome_at >= current.outcome_at: + latest[row.document_id] = row + return list(latest.values()) + + +def _cause(row: QualityOutcomeFactRow) -> QualityCause: + if row.primary_cause is None: + raise ValueError(f"у исхода {row.outcome} документа {row.document_id} нет primary_cause (инвариант факта)") + return row.primary_cause + + +def _share(count: int, denominator: int) -> float: + return count / denominator if denominator else 0.0 + + +def compute_matrix(outcomes: Sequence[QualityOutcomeFactRow]) -> MatrixResult: + """§14.3: матрица причин по фактам исхода (группировка по сохранённой `primary_cause`, без пересчёта).""" + counts = dict.fromkeys(QualityCause, 0) + before = after = 0 + for row in _latest_per_document(outcomes): + if row.outcome in _IN_DENOMINATOR: + counts[_cause(row)] += 1 + elif row.outcome in _BEFORE_CONFIRM: + before += 1 + elif row.outcome in _AFTER_CONFIRM: + after += 1 + denominator = sum(counts.values()) + return MatrixResult( + counts=counts, + shares={cause: _share(count, denominator) for cause, count in counts.items()}, + denominator=denominator, + removed_before_confirm=before, + removed_after_confirm=after, + ) + + +def _next_month(month: datetime.date) -> datetime.date: + return datetime.date(month.year + month.month // 12, month.month % 12 + 1, 1) + + +def compute_monthly_series(outcomes: Sequence[QualityOutcomeFactRow], cause: QualityCause) -> list[MonthPoint]: + """§14.3 / design §7: ряд по месяцам `outcome_at` для выбранной причины. + + В ряд входят только документы со знаменателем (`AUTO_CONFIRMED` / `CONFIRMED`, последний факт документа). + Месяцы между первым и последним месяцем данных без документов заполняются нулями (непрерывная ось). + """ + count_by_month: dict[datetime.date, int] = {} + total_by_month: dict[datetime.date, int] = {} + for row in _latest_per_document(outcomes): + if row.outcome not in _IN_DENOMINATOR: + continue + month = datetime.date(row.outcome_at.year, row.outcome_at.month, 1) + total_by_month[month] = total_by_month.get(month, 0) + 1 + if _cause(row) is cause: + count_by_month[month] = count_by_month.get(month, 0) + 1 + if not total_by_month: + return [] + points: list[MonthPoint] = [] + month, last = min(total_by_month), max(total_by_month) + while month <= last: + total = total_by_month.get(month, 0) + count = count_by_month.get(month, 0) + points.append(MonthPoint(month=month, count=count, denominator=total, share=_share(count, total))) + month = _next_month(month) + return points diff --git a/api/tests/test_domain_quality_metrics.py b/api/tests/test_domain_quality_metrics.py new file mode 100644 index 0000000..d475bcb --- /dev/null +++ b/api/tests/test_domain_quality_metrics.py @@ -0,0 +1,111 @@ +"""Матрица причин и ряд по месяцам (§14.3; design quality-metrics §6, §7) — чистая функция, без БД.""" + +import datetime +import uuid + +import pytest + +from src.domain.quality_metrics import QualityOutcomeFactRow, compute_matrix, compute_monthly_series +from src.models.enums import QualityCause, QualityOutcome + +TWO = 2 +HALF = 0.5 + + +def make_outcome_row( + *, + primary_cause: QualityCause | None = QualityCause.AS_IS, + outcome: QualityOutcome = QualityOutcome.CONFIRMED, + outcome_at: datetime.datetime | None = None, + document_id: uuid.UUID | None = None, +) -> QualityOutcomeFactRow: + return QualityOutcomeFactRow( + document_id=document_id or uuid.uuid4(), + outcome=outcome, + outcome_at=outcome_at or datetime.datetime(2026, 10, 15, tzinfo=datetime.UTC), + primary_cause=primary_cause, + ) + + +def test_compute_matrix_priority_service_over_our_error(): + # атрибуция уже в факте — матрица только группирует + matrix = compute_matrix( + [ + make_outcome_row(primary_cause=QualityCause.SERVICE_ERROR), + make_outcome_row(primary_cause=QualityCause.OUR_ERROR), + ] + ) + assert matrix.counts[QualityCause.SERVICE_ERROR] == 1 + assert matrix.counts[QualityCause.OUR_ERROR] == 1 + assert matrix.counts[QualityCause.AS_IS] == 0 + + +def test_compute_matrix_denominator_excludes_removed_documents(): + outcomes = [ + make_outcome_row(primary_cause=QualityCause.WITHOUT_PARTICIPATION, outcome=QualityOutcome.AUTO_CONFIRMED), + make_outcome_row(primary_cause=QualityCause.AS_IS), + make_outcome_row(primary_cause=None, outcome=QualityOutcome.DELETED), + make_outcome_row(primary_cause=None, outcome=QualityOutcome.SUPERSEDED), + make_outcome_row(primary_cause=None, outcome=QualityOutcome.SUPERSEDED_AFTER_CONFIRM), + ] + matrix = compute_matrix(outcomes) + assert matrix.denominator == TWO + assert matrix.removed_before_confirm == TWO + assert matrix.removed_after_confirm == 1 + assert matrix.shares[QualityCause.AS_IS] == HALF + assert sum(matrix.shares.values()) == pytest.approx(1.0) + + +def test_compute_matrix_empty_has_no_division_by_zero(): + matrix = compute_matrix([]) + assert matrix.denominator == 0 + assert set(matrix.counts) == set(QualityCause) + assert all(share == 0.0 for share in matrix.shares.values()) + + +def test_compute_matrix_takes_latest_fact_per_document(): + doc = uuid.uuid4() + first = make_outcome_row(document_id=doc, primary_cause=QualityCause.OUR_ERROR) + later = make_outcome_row( + document_id=doc, + primary_cause=None, + outcome=QualityOutcome.DELETED_AFTER_CONFIRM, + outcome_at=datetime.datetime(2026, 10, 20, tzinfo=datetime.UTC), + ) + for ordered in ([first, later], [later, first]): + matrix = compute_matrix(ordered) + assert matrix.denominator == 0 + assert matrix.removed_after_confirm == 1 + + +def test_compute_matrix_confirmed_without_cause_is_invariant_violation(): + with pytest.raises(ValueError): + compute_matrix([make_outcome_row(primary_cause=None)]) + + +def test_monthly_series_fills_gaps_and_ignores_removed(): + def at(month: int) -> datetime.datetime: + return datetime.datetime(2026, month, 5, tzinfo=datetime.UTC) + + outcomes = [ + make_outcome_row(primary_cause=QualityCause.OUR_ERROR, outcome_at=at(8)), + make_outcome_row(primary_cause=QualityCause.AS_IS, outcome_at=at(8)), + make_outcome_row(primary_cause=QualityCause.OUR_ERROR, outcome_at=at(10)), + make_outcome_row(primary_cause=None, outcome=QualityOutcome.DELETED, outcome_at=at(12)), + ] + series = compute_monthly_series(outcomes, QualityCause.OUR_ERROR) + assert [(p.month.month, p.count, p.denominator, p.share) for p in series] == [ + (8, 1, 2, 0.5), + (9, 0, 0, 0.0), + (10, 1, 1, 1.0), + ] + + +def test_monthly_series_empty_and_year_rollover(): + assert compute_monthly_series([], QualityCause.AS_IS) == [] + outcomes = [ + make_outcome_row(outcome_at=datetime.datetime(2026, 12, 31, tzinfo=datetime.UTC)), + make_outcome_row(outcome_at=datetime.datetime(2027, 1, 1, tzinfo=datetime.UTC)), + ] + months = [p.month for p in compute_monthly_series(outcomes, QualityCause.AS_IS)] + assert months == [datetime.date(2026, 12, 1), datetime.date(2027, 1, 1)] -- 2.47.2 From d191fd3039668e38671928c542c594a6c3ec4dcb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 02:37:38 +0400 Subject: [PATCH 27/44] =?UTF-8?q?fix(#190):=20=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=BC=D0=B0=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Task=2014?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- api/src/domain/quality_metrics.py | 36 ++++++++++++++---- api/tests/test_domain_quality_metrics.py | 48 ++++++++++++++++++++---- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/api/src/domain/quality_metrics.py b/api/src/domain/quality_metrics.py index 0f72174..b7bf258 100644 --- a/api/src/domain/quality_metrics.py +++ b/api/src/domain/quality_metrics.py @@ -33,11 +33,16 @@ _AFTER_CONFIRM = frozenset({QualityOutcome.DELETED_AFTER_CONFIRM, QualityOutcome @dataclass(frozen=True, slots=True) class QualityOutcomeFactRow: - """Минимум полей `quality_outcome_fact`, нужных агрегации (вызывающий сервис строит его из SELECT).""" + """Минимум полей `quality_outcome_fact`, нужных агрегации (вызывающий сервис строит его из SELECT). + + `received_at` — дата приёма результата (`quality_intake_fact.intake_at`, design §7: период по дате приёма, когорта). + Поля нет в `quality_outcome_fact`: Task 15 подставляет его join'ом intake <-> outcome по `document_id`. + """ document_id: uuid.UUID outcome: QualityOutcome outcome_at: datetime.datetime + received_at: datetime.datetime primary_cause: QualityCause | None @@ -59,7 +64,7 @@ class MatrixResult: @dataclass(frozen=True, slots=True) class MonthPoint: - """Точка ряда: `month` — первое число месяца `outcome_at`; `share` — доля причины от знаменателя месяца.""" + """Точка ряда: `month` — первое число месяца `received_at`; `share` — доля причины от знаменателя месяца.""" month: datetime.date count: int @@ -67,12 +72,26 @@ class MonthPoint: share: float +def _tie_rank(outcome: QualityOutcome) -> int: + """Приоритет при равном `outcome_at`: `*_AFTER_CONFIRM` (2) > подтверждение (1) > удаление/замена до него (0).""" + if outcome in _AFTER_CONFIRM: + return 2 + return 1 if outcome in _IN_DENOMINATOR else 0 + + def _latest_per_document(outcomes: Sequence[QualityOutcomeFactRow]) -> list[QualityOutcomeFactRow]: - """Последний по `outcome_at` факт каждого документа (design §4.2); при равенстве времени — поздний во входе.""" + """Последний по `outcome_at` факт каждого документа (design §4.2). + + При равном `outcome_at` побеждает исход с большим `_tie_rank` (результат не зависит от порядка входа: «после + подтверждения» — позднее по смыслу); при равенстве и ранга — поздний во входе. + """ latest: dict[uuid.UUID, QualityOutcomeFactRow] = {} for row in outcomes: current = latest.get(row.document_id) - if current is None or row.outcome_at >= current.outcome_at: + if current is None or (row.outcome_at, _tie_rank(row.outcome)) >= ( + current.outcome_at, + _tie_rank(current.outcome), + ): latest[row.document_id] = row return list(latest.values()) @@ -98,6 +117,8 @@ def compute_matrix(outcomes: Sequence[QualityOutcomeFactRow]) -> MatrixResult: before += 1 elif row.outcome in _AFTER_CONFIRM: after += 1 + else: + raise ValueError(f"неизвестный исход {row.outcome!r} документа {row.document_id}") denominator = sum(counts.values()) return MatrixResult( counts=counts, @@ -113,9 +134,10 @@ def _next_month(month: datetime.date) -> datetime.date: def compute_monthly_series(outcomes: Sequence[QualityOutcomeFactRow], cause: QualityCause) -> list[MonthPoint]: - """§14.3 / design §7: ряд по месяцам `outcome_at` для выбранной причины. + """§14.3 / design §7: ряд по месяцам даты приёма (`received_at`, когорта) для выбранной причины. - В ряд входят только документы со знаменателем (`AUTO_CONFIRMED` / `CONFIRMED`, последний факт документа). + В ряд входят только документы со знаменателем (`AUTO_CONFIRMED` / `CONFIRMED`, последний по `outcome_at` факт + документа); месяц берётся по `received_at`, не по `outcome_at`. Месяцы между первым и последним месяцем данных без документов заполняются нулями (непрерывная ось). """ count_by_month: dict[datetime.date, int] = {} @@ -123,7 +145,7 @@ def compute_monthly_series(outcomes: Sequence[QualityOutcomeFactRow], cause: Qua for row in _latest_per_document(outcomes): if row.outcome not in _IN_DENOMINATOR: continue - month = datetime.date(row.outcome_at.year, row.outcome_at.month, 1) + month = datetime.date(row.received_at.year, row.received_at.month, 1) total_by_month[month] = total_by_month.get(month, 0) + 1 if _cause(row) is cause: count_by_month[month] = count_by_month.get(month, 0) + 1 diff --git a/api/tests/test_domain_quality_metrics.py b/api/tests/test_domain_quality_metrics.py index d475bcb..8fba158 100644 --- a/api/tests/test_domain_quality_metrics.py +++ b/api/tests/test_domain_quality_metrics.py @@ -17,17 +17,20 @@ def make_outcome_row( primary_cause: QualityCause | None = QualityCause.AS_IS, outcome: QualityOutcome = QualityOutcome.CONFIRMED, outcome_at: datetime.datetime | None = None, + received_at: datetime.datetime | None = None, document_id: uuid.UUID | None = None, ) -> QualityOutcomeFactRow: + resolved_outcome_at = outcome_at or datetime.datetime(2026, 10, 15, tzinfo=datetime.UTC) return QualityOutcomeFactRow( document_id=document_id or uuid.uuid4(), outcome=outcome, - outcome_at=outcome_at or datetime.datetime(2026, 10, 15, tzinfo=datetime.UTC), + outcome_at=resolved_outcome_at, + received_at=received_at or resolved_outcome_at, primary_cause=primary_cause, ) -def test_compute_matrix_priority_service_over_our_error(): +def test_compute_matrix_groups_by_stored_primary_cause(): # атрибуция уже в факте — матрица только группирует matrix = compute_matrix( [ @@ -88,10 +91,10 @@ def test_monthly_series_fills_gaps_and_ignores_removed(): return datetime.datetime(2026, month, 5, tzinfo=datetime.UTC) outcomes = [ - make_outcome_row(primary_cause=QualityCause.OUR_ERROR, outcome_at=at(8)), - make_outcome_row(primary_cause=QualityCause.AS_IS, outcome_at=at(8)), - make_outcome_row(primary_cause=QualityCause.OUR_ERROR, outcome_at=at(10)), - make_outcome_row(primary_cause=None, outcome=QualityOutcome.DELETED, outcome_at=at(12)), + make_outcome_row(primary_cause=QualityCause.OUR_ERROR, received_at=at(8), outcome_at=at(11)), + make_outcome_row(primary_cause=QualityCause.AS_IS, received_at=at(8), outcome_at=at(9)), + make_outcome_row(primary_cause=QualityCause.OUR_ERROR, received_at=at(10), outcome_at=at(10)), + make_outcome_row(primary_cause=None, outcome=QualityOutcome.DELETED, received_at=at(12), outcome_at=at(12)), ] series = compute_monthly_series(outcomes, QualityCause.OUR_ERROR) assert [(p.month.month, p.count, p.denominator, p.share) for p in series] == [ @@ -104,8 +107,37 @@ def test_monthly_series_fills_gaps_and_ignores_removed(): def test_monthly_series_empty_and_year_rollover(): assert compute_monthly_series([], QualityCause.AS_IS) == [] outcomes = [ - make_outcome_row(outcome_at=datetime.datetime(2026, 12, 31, tzinfo=datetime.UTC)), - make_outcome_row(outcome_at=datetime.datetime(2027, 1, 1, tzinfo=datetime.UTC)), + make_outcome_row(received_at=datetime.datetime(2026, 12, 31, tzinfo=datetime.UTC)), + make_outcome_row(received_at=datetime.datetime(2027, 1, 1, tzinfo=datetime.UTC)), ] months = [p.month for p in compute_monthly_series(outcomes, QualityCause.AS_IS)] assert months == [datetime.date(2026, 12, 1), datetime.date(2027, 1, 1)] + + +def test_compute_matrix_equal_outcome_at_after_confirm_wins_regardless_of_order(): + doc = uuid.uuid4() + same_time = datetime.datetime(2026, 10, 20, tzinfo=datetime.UTC) + confirmed = make_outcome_row(document_id=doc, outcome_at=same_time) + deleted = make_outcome_row( + document_id=doc, primary_cause=None, outcome=QualityOutcome.DELETED_AFTER_CONFIRM, outcome_at=same_time + ) + for ordered in ([confirmed, deleted], [deleted, confirmed]): + matrix = compute_matrix(ordered) + assert matrix.denominator == 0 + assert matrix.removed_after_confirm == 1 + + +def test_compute_matrix_unknown_outcome_raises(): + row = make_outcome_row() + object.__setattr__(row, "outcome", "UNKNOWN") + with pytest.raises(ValueError, match="неизвестный исход"): + compute_matrix([row]) + + +def test_monthly_series_uses_received_month_not_outcome_month(): + row = make_outcome_row( + received_at=datetime.datetime(2026, 8, 20, tzinfo=datetime.UTC), + outcome_at=datetime.datetime(2026, 10, 2, tzinfo=datetime.UTC), + ) + series = compute_monthly_series([row], QualityCause.AS_IS) + assert [(p.month, p.count, p.denominator) for p in series] == [(datetime.date(2026, 8, 1), 1, 1)] -- 2.47.2 From 779e13fca86c44a0a70776adf8acec1b23e09cb6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 02:38:24 +0400 Subject: [PATCH 28/44] =?UTF-8?q?docs(#190):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=2014=20(=D0=BC=D0=B0=D1=82=D1=80=D0=B8=D1=86=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD)=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- openspec/changes/quality-metrics/design.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index c935de0..25a5b53 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -234,3 +234,6 @@ PDF, точный вид выгрузки, разбиение на задачи. | 06.11.2026 | Task 8 (`DELETED`/`DELETED_AFTER_CONFIRM`): (1) документ в статусе, отличном от `CONFIRMED`/`RECOGNIZED`/`NEEDS_CHECK` (практически только `ERROR`), при удалении файла НЕ получает факт исхода — у него никогда не было содержательного состояния «как распознано» для фиксации (нет формы/строк, не проходил ни через `AUTO_CONFIRMED`, ни через intake-факт); (2) `issue_count_at_outcome` вычисляется как `counts.error + counts.info` из уже посчитанного агрегата `issue_counts_by_document` вместо отдельного запроса — тот же смысл «все нарушения документа без фильтра», что `len(issues)` в Task 6/7, дешевле на один запрос; (3) `payload` для DELETED*-фактов — единый минимальный вызов `build_outcome_payload(document, fixes=())` без дополнительных запросов к строкам/значениям формы или ОСВ (эти строки всё равно будут физически удалены этим же вызовом `delete`, повторный их снимок здесь экономически не оправдан — а в матрицу §6 эти факты и не попадают, доступны только как «удалён/заменён», drill-down уже видит прежний исход через более ранний intake/outcome факт того же `document_id`) | бриф задачи 8 явно делегировал все три пункта исполнителю («если неоднозначно — зафиксировать выбор, не угадывать»); исполнитель выбрал и задокументировал в докстрингах кода, перенесено сюда контроллером по правилу маркировки находок (решение агента без обсуждения с человеком — в design.md) | (1): при появлении у `ERROR`-документов содержательного состояния до удаления (гипотетически) — нужно будет расширить список статусов; (2)/(3): минимальный риск — агрегаты эквивалентны, а payload не используется для атрибуции (там `primary_cause=None`, вне матрицы) | | 06.11.2026 | Task 10 (`quality_failure_fact`): `error_code` для отказов маппинга (`_fail`, `_register_failure`) — константа `MAPPING_FAILURE_ERROR_CODE = "MAPPING_FAILED"` (`result_mapping.py`), не свободный текст из `message`/описания исключения | `QualityFailureFact.error_code` не имеет свободнотекстового столбца-соседа для деталей отказа маппинга (в отличие от `recognition_job.error_message`); деталь остаётся в `metrics.mapping_error`/логе, код — единая константа, отличимая от кодов обмена (`ERROR_NO_ACK`/`ERROR_INVALID_REQUEST`), что проверяет отдельный тест | если деталь отказа маппинга понадобится в матрице причин без обращения к логам — нужен отдельный столбец/payload-поле, не замена константы | | 06.11.2026 | Task 9 (`SUPERSEDED`/`SUPERSEDED_AFTER_CONFIRM`): документ в статусе `ERROR` НЕ исключён из факта-исхода при автозамене — `outcome = SUPERSEDED_AFTER_CONFIRM if status is CONFIRMED else SUPERSEDED`, без третьей ветки, в отличие от Task 8 (`delete`), где `ERROR`/прочие статусы факт не получают вовсе. Выбор противоположен Task 8-й строке выше, но осознанно: подтверждён построчной проверкой всех мест `Document.status = ...` в `src/` (`documents.py`, `osv_header.py`, `auto_confirm.py`, `domain/osv_mapping.py`) — `DocumentStatus.ERROR` НИГДЕ не присваивается документу в продакшн-коде сегодня, различие с Task 8 необнаружимо на практике | бриф задачи 9 дал буквальную формулу (`CONFIRMED` → `..._AFTER_CONFIRM`, иначе → без суффикса) без явного исключения `ERROR`; поскольку путь недостижим, более простой код (без третьей ветки) предпочтён усложнению ради симметрии с Task 8 | если в будущем появится код, присваивающий `Document.status = ERROR`, `delete()` и `supersede_previous()` разойдутся в поведении (один пропустит факт, другой запишет `SUPERSEDED`) без того, чтобы это было осознанным решением на тот момент — стоит перепроверить эту строку, если `ERROR`-статус когда-нибудь станет достижим | +| 07.11.2026 | Task 14 (`compute_matrix`): по `document_id` берётся последний факт по `outcome_at`; при равенстве `outcome_at` — `*_AFTER_CONFIRM` > `CONFIRMED`/`AUTO_CONFIRMED` > `DELETED`/`SUPERSEDED`, затем поздний во входе | результат не должен зависеть от порядка строк SELECT; design §4.2 — метрики берут последний факт | при редком совпадении времени документ посчитается подтверждённым вместо удалённого или наоборот | +| 07.11.2026 | Task 14: два столбца вне долей — `removed_before_confirm` (DELETED, SUPERSEDED) и `removed_after_confirm` (оба `*_AFTER_CONFIRM`), без разбивки на удалён/заменён; доли — float, при нулевом знаменателе 0.0 (не None); исход из знаменателя с `primary_cause=None` и неизвестный `outcome` — `ValueError`, без молчаливого пропуска | design §6 п.1 называет два столбца; битый факт или новый enum не должен тихо искажать матрицу | разбивку придётся добавлять позже; битый факт даёт 500 на экране вместо искажённой матрицы; фронт отличает 0% от «нет данных» только по `denominator` | +| 07.11.2026 | Task 14 (R-8): ряд по месяцам идёт по месяцу даты приёма (`received_at` = `quality_intake_fact.intake_at`), а не по `outcome_at`; поле добавлено во входную строку `QualityOutcomeFactRow`, Task 15 подставляет его join'ом intake↔outcome по `document_id`; пропущенные месяцы заполняются нулями | design §7 и решение 27.10: период по дате приёма (когорта) | документ попадёт в месяц исхода, а не приёма, метрики недавних периодов исказятся | -- 2.47.2 From bebc678a968e5046c09a0c6df398362ea38d0d40 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 02:43:36 +0400 Subject: [PATCH 29/44] =?UTF-8?q?feat(#190):=20API=20/quality-metrics=20?= =?UTF-8?q?=E2=80=94=20=D0=BC=D0=B0=D1=82=D1=80=D0=B8=D1=86=D0=B0,=20?= =?UTF-8?q?=D1=80=D1=8F=D0=B4=20=D0=BF=D0=BE=20=D0=BC=D0=B5=D1=81=D1=8F?= =?UTF-8?q?=D1=86=D0=B0=D0=BC,=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA,=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- api/src/api/dependencies/quality_metrics.py | 16 + api/src/api/endpoints/quality_metrics.py | 65 ++++ api/src/api/router.py | 3 +- api/src/schemas/quality_metrics.py | 134 +++++++ api/src/services/quality_metrics_service.py | 365 ++++++++++++++++++++ api/tests/test_api_quality_metrics.py | 324 +++++++++++++++++ 6 files changed, 906 insertions(+), 1 deletion(-) create mode 100644 api/src/api/dependencies/quality_metrics.py create mode 100644 api/src/api/endpoints/quality_metrics.py create mode 100644 api/src/schemas/quality_metrics.py create mode 100644 api/src/services/quality_metrics_service.py create mode 100644 api/tests/test_api_quality_metrics.py diff --git a/api/src/api/dependencies/quality_metrics.py b/api/src/api/dependencies/quality_metrics.py new file mode 100644 index 0000000..06d5184 --- /dev/null +++ b/api/src/api/dependencies/quality_metrics.py @@ -0,0 +1,16 @@ +"""Зависимости API «Метрики качества» (R-6: сессия — только `SessionDep`). Видимость по филиалу не применяется (§8).""" + +from typing import Annotated + +from fastapi import Depends + +from src.api.dependencies.db import SessionDep +from src.services.quality_metrics_service import QualityMetricsService + + +def get_quality_metrics_service(session: SessionDep) -> QualityMetricsService: + """Сервис метрик качества: только чтение фактов, без аудита и `VisibilityScope`.""" + return QualityMetricsService(session) + + +QualityMetricsServiceDep = Annotated[QualityMetricsService, Depends(get_quality_metrics_service)] diff --git a/api/src/api/endpoints/quality_metrics.py b/api/src/api/endpoints/quality_metrics.py new file mode 100644 index 0000000..b3a5fd9 --- /dev/null +++ b/api/src/api/endpoints/quality_metrics.py @@ -0,0 +1,65 @@ +"""`/quality-metrics` — метрики качества распознавания первички (design `quality-metrics` §7, §8). + +Все маршруты под правом `QUALITY_METRICS_VIEW`; видимость по филиалу/ГК не применяется. Период — по дате приёма.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, Response + +from src.api.dependencies.auth import require +from src.api.dependencies.quality_metrics import QualityMetricsServiceDep +from src.common.filenames import content_disposition +from src.models.enums import QualityCause +from src.schemas.common import Page, PageParams +from src.schemas.quality_metrics import ( + ExportFormat, + QualityCell, + QualityCellDocumentOut, + QualityFilters, + QualityMatrixOut, + QualityMonthlySeriesOut, +) + +router = APIRouter( + prefix="/quality-metrics", tags=["quality-metrics"], dependencies=[Depends(require("QUALITY_METRICS_VIEW"))] +) + + +@router.get("/matrix", summary="Матрица причин за период") +async def get_matrix( + service: QualityMetricsServiceDep, filters: Annotated[QualityFilters, Query()] +) -> QualityMatrixOut: + """Документы по причинам (доли — от подтверждённых), удалённые/заменённые до и после подтверждения, «в работе», + уникальные отчёты и технические отказы по файлам (на них фильтр по типу документа не действует).""" + return await service.matrix(filters) + + +@router.get("/series", summary="Ряд по месяцам даты приёма для причины") +async def get_series( + service: QualityMetricsServiceDep, + filters: Annotated[QualityFilters, Query()], + cause: Annotated[QualityCause, Query()], +) -> QualityMonthlySeriesOut: + return await service.series(filters, cause) + + +@router.get("/cells/{cell}/documents", summary="Документы ячейки матрицы") +async def get_cell_documents( + cell: QualityCell, + service: QualityMetricsServiceDep, + filters: Annotated[QualityFilters, Query()], + page: Annotated[PageParams, Query()], +) -> Page[QualityCellDocumentOut]: + """Ячейка — причина (`QualityCause`), `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM` или `IN_PROGRESS`.""" + return await service.cell_documents(filters, cell, limit=page.limit, offset=page.offset) + + +@router.get("/export", response_class=Response, summary="Выгрузка фактов за период (CSV / JSON)") +async def export_facts( + service: QualityMetricsServiceDep, + filters: Annotated[QualityFilters, Query()], + fmt: Annotated[ExportFormat, Query(alias="format")] = "csv", +) -> Response: + content, media_type, ext = await service.export(filters, fmt) + header = content_disposition(f"quality-facts.{ext}", ascii_fallback=f"quality-facts.{ext}") + return Response(content=content, media_type=media_type, headers={"Content-Disposition": header}) diff --git a/api/src/api/router.py b/api/src/api/router.py index acc2f81..3731296 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -8,7 +8,7 @@ dev-эмитент токенов — только при `AUTH_DEV_ISSUER and D from fastapi import APIRouter -from src.api.endpoints import admin, consolidations, directories, files, me, users +from src.api.endpoints import admin, consolidations, directories, files, me, quality_metrics, users from src.api.endpoints.dev_auth import build_dev_router from src.core.config import Settings, get_settings @@ -24,6 +24,7 @@ def build_api_router(settings: Settings) -> APIRouter: api_router.include_router(admin.router) api_router.include_router(files.router) api_router.include_router(consolidations.router) + api_router.include_router(quality_metrics.router) if settings.AUTH_DEV_ISSUER and settings.DEBUG: api_router.include_router(build_dev_router(settings)) return api_router diff --git a/api/src/schemas/quality_metrics.py b/api/src/schemas/quality_metrics.py new file mode 100644 index 0000000..57a9f41 --- /dev/null +++ b/api/src/schemas/quality_metrics.py @@ -0,0 +1,134 @@ +"""Схемы API «Метрики качества» (design `quality-metrics` §6-§8): матрица причин, ряд по месяцам, список документов +ячейки. Деньги не нужны — только счётчики и доли.""" + +import datetime +import enum +import uuid +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + +from src.models.enums import DocumentType, QualityCause, QualityOutcome, RecognitionPipeline + +__all__ = [ + "ExportFormat", + "MonthPointOut", + "QualityCell", + "QualityCellDocumentOut", + "QualityFilters", + "QualityFixesOut", + "QualityMatrixOut", + "QualityMonthlySeriesOut", + "TechnicalFailuresOut", +] + +ExportFormat = Literal["csv", "json"] + + +class QualityCell(enum.StrEnum): + """Ячейка матрицы, из которой раскрывается список документов: пять причин (`QualityCause`), два столбца «удалён / + заменён» (в доли не входят, design §6 п.1) и «в работе» (документы без исхода).""" + + SERVICE_ERROR = QualityCause.SERVICE_ERROR.value + OUR_ERROR = QualityCause.OUR_ERROR.value + CLIENT_DOCUMENT = QualityCause.CLIENT_DOCUMENT.value + WITHOUT_PARTICIPATION = QualityCause.WITHOUT_PARTICIPATION.value + AS_IS = QualityCause.AS_IS.value + REMOVED_BEFORE_CONFIRM = "REMOVED_BEFORE_CONFIRM" + REMOVED_AFTER_CONFIRM = "REMOVED_AFTER_CONFIRM" + IN_PROGRESS = "IN_PROGRESS" + + +class QualityFilters(BaseModel): + """Фильтры из query (`Annotated[QualityFilters, Query()]`). Период — по дате приёма результата (`intake_at`, + когорта, design §7), обе границы включительно; `date_to` без времени — до конца этого дня (UTC).""" + + date_from: datetime.date | None = None + date_to: datetime.date | None = None + group_id: uuid.UUID | None = None + legal_entity_id: uuid.UUID | None = None + document_type: DocumentType | None = None + pipeline: RecognitionPipeline | None = Field(default=None, description="источник: image = PDF, tabular = Excel") + is_scan: bool | None = Field(default=None, description="скан (true) / цифровой PDF (false); у Excel признака нет") + + @model_validator(mode="after") + def _period_order(self) -> "QualityFilters": + if self.date_from and self.date_to and self.date_from > self.date_to: + raise ValueError("date_from позже date_to") + return self + + +class TechnicalFailuresOut(BaseModel): + """Технические отказы считаются по файлам (design §6); фильтр по типу документа на них не действует. + `total_files_count` — уникальные файлы с фактом приёма или отказа в выбранных период/ГК/ЮЛ/источнике/скане.""" + + failed_files_count: int + total_files_count: int + share: float + + +class QualityMatrixOut(BaseModel): + """Матрица причин за период. `counts`/`shares` — по всем значениям `QualityCause`; доли — от `denominator` + (документы с исходом AUTO_CONFIRMED / CONFIRMED). Столбцы «удалён / заменён» и «в работе» в доли не входят. + `removed_after_confirm` = `deleted_after_confirm_count` + `superseded_after_confirm_count`.""" + + counts: dict[QualityCause, int] + shares: dict[QualityCause, float] + denominator: int + removed_before_confirm: int + removed_after_confirm: int + deleted_after_confirm_count: int + superseded_after_confirm_count: int + in_progress_count: int + unique_reports_count: int = Field(description="уникальные отчёты: ЮЛ + подтверждённая отчётная дата + тип") + technical_failures: TechnicalFailuresOut + + +class MonthPointOut(BaseModel): + month: datetime.date + count: int + denominator: int + share: float + + +class QualityMonthlySeriesOut(BaseModel): + cause: QualityCause + points: list[MonthPointOut] + + +class QualityFixesOut(BaseModel): + """Счётчики правок по источнику (design §5): `service` / `ours`; правка периода/счёта ОСВ — всегда «наш разбор».""" + + value_service: int + value_ours: int + code_service: int + code_ours: int + added_row_service: int + added_row_ours: int + header_service: int + header_ours: int + osv_period_account: int + + +class QualityCellDocumentOut(BaseModel): + """Документ ячейки (design §7, §8): `confirmed_by_*` — только здесь, агрегаты фамилий не содержат. + `document_link` — путь API карточки документа, `None`, если документ уже удалён. `outcome` — текущий (последний) + исход; `previous_outcome` — исход до `*_AFTER_CONFIRM` (только для ячейки «после подтверждения»).""" + + document_id: uuid.UUID + document_type: DocumentType + legal_entity_id: uuid.UUID + legal_entity_name: str + group_name: str | None + received_at: datetime.datetime + outcome: QualityOutcome | None + outcome_at: datetime.datetime | None + previous_outcome: QualityOutcome | None + primary_cause: QualityCause | None + report_date: datetime.date | None + fixes: QualityFixesOut | None + checks_accepted: bool | None + issue_count_at_outcome: int | None + confirmed_by_id: uuid.UUID | None + confirmed_by_name: str | None + document_link: str | None diff --git a/api/src/services/quality_metrics_service.py b/api/src/services/quality_metrics_service.py new file mode 100644 index 0000000..c6e4926 --- /dev/null +++ b/api/src/services/quality_metrics_service.py @@ -0,0 +1,365 @@ +"""Сервис «Метрики качества» (design `quality-metrics` §6-§8; AURORA-1759): чтение фактов качества, расчёт — в +`domain.quality_metrics`. Только чтение, без `VisibilityScope` (видимость по филиалу/ГК не применяется, §8), без аудита. + +Выборка единая для всех методов (`_base`): факты приёма (`quality_intake_fact`, по одному на документ) LEFT JOIN +последний факт исхода документа. «Последний» выбирает SQL — `DISTINCT ON (document_id) ORDER BY document_id, +outcome_at DESC, ранг исхода DESC, id DESC`; ранг (`*_AFTER_CONFIRM` > подтверждение > удаление/замена до него) +повторяет tie-break домена, поэтому SQL и домен выбирают один и тот же факт. Период — по `intake_at` (когорта). +Исход без факта приёма (факты пишутся одной транзакцией, но на всякий случай) в метрики не попадает — у него нет +даты приёма. +""" + +import csv +import enum +import io +import json +import uuid +from collections.abc import Iterable, Sequence +from datetime import UTC, date, datetime, time, timedelta +from typing import Any + +from fastapi.encoders import jsonable_encoder +from sqlalchemy import Select, case, distinct, func, select, union +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.quality_metrics import ( + QualityOutcomeFactRow, + compute_matrix, + compute_monthly_series, +) +from src.models.access import AppUser +from src.models.enums import QualityCause, QualityOutcome +from src.models.files import Document +from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact +from src.schemas.common import Page +from src.schemas.quality_metrics import ( + ExportFormat, + MonthPointOut, + QualityCell, + QualityCellDocumentOut, + QualityFilters, + QualityFixesOut, + QualityMatrixOut, + QualityMonthlySeriesOut, + TechnicalFailuresOut, +) + +__all__ = ["EXPORT_COLUMNS", "QualityMetricsService"] + +_IN_DENOMINATOR = (QualityOutcome.AUTO_CONFIRMED, QualityOutcome.CONFIRMED) +_BEFORE_CONFIRM = (QualityOutcome.DELETED, QualityOutcome.SUPERSEDED) +_AFTER_CONFIRM = (QualityOutcome.DELETED_AFTER_CONFIRM, QualityOutcome.SUPERSEDED_AFTER_CONFIRM) + +EXPORT_COLUMNS = ( + "document_id", "file_id", "legal_entity_id", "legal_entity_name", "group_id", "group_name", "document_type", + "pipeline", "is_scan", "service_name", "service_version", "intake_at", "intake_status", "report_date_recognized", + "outcome", "outcome_at", "primary_cause", "fixes_value_service", "fixes_value_ours", "fixes_code_service", + "fixes_code_ours", "fixes_added_row_service", "fixes_added_row_ours", "fixes_header_service", "fixes_header_ours", + "fixes_osv_period_account", "checks_accepted", "issue_count_at_outcome", "report_date_confirmed", +) # fmt: skip + + +def _start(day: date) -> datetime: + return datetime.combine(day, time.min, tzinfo=UTC) + + +def _latest_outcomes(): # noqa: ANN202 - Subquery + """Последний факт исхода на `document_id` (см. модульную строку).""" + outcome = QualityOutcomeFact.outcome + rank = case((outcome.in_(_AFTER_CONFIRM), 2), (outcome.in_(_IN_DENOMINATOR), 1), else_=0) + return ( + select(QualityOutcomeFact) + .distinct(QualityOutcomeFact.document_id) + .order_by( + QualityOutcomeFact.document_id, + QualityOutcomeFact.outcome_at.desc(), + rank.desc(), + QualityOutcomeFact.id.desc(), + ) + .subquery("latest_outcome") + ) + + +def _intake_conditions(f: QualityFilters, *, with_document_type: bool = True) -> list[Any]: + cond: list[Any] = [] + if f.date_from: + cond.append(QualityIntakeFact.intake_at >= _start(f.date_from)) + if f.date_to: + cond.append(QualityIntakeFact.intake_at < _start(f.date_to + timedelta(days=1))) + if f.group_id: + cond.append(QualityIntakeFact.group_id == f.group_id) + if f.legal_entity_id: + cond.append(QualityIntakeFact.legal_entity_id == f.legal_entity_id) + if f.pipeline: + cond.append(QualityIntakeFact.pipeline == f.pipeline) + if f.is_scan is not None: + cond.append(QualityIntakeFact.is_scan == f.is_scan) + if with_document_type and f.document_type: + cond.append(QualityIntakeFact.document_type == f.document_type) + return cond + + +def _failure_conditions(f: QualityFilters) -> list[Any]: + """Те же фильтры по отказам; тип документа не действует (у отказа документов нет).""" + cond: list[Any] = [] + if f.date_from: + cond.append(QualityFailureFact.failed_at >= _start(f.date_from)) + if f.date_to: + cond.append(QualityFailureFact.failed_at < _start(f.date_to + timedelta(days=1))) + if f.group_id: + cond.append(QualityFailureFact.group_id == f.group_id) + if f.legal_entity_id: + cond.append(QualityFailureFact.legal_entity_id == f.legal_entity_id) + if f.pipeline: + cond.append(QualityFailureFact.pipeline == f.pipeline) + if f.is_scan is not None: + cond.append(QualityFailureFact.is_scan == f.is_scan) + return cond + + +def _fixes(row: Any) -> QualityFixesOut: + return QualityFixesOut( + value_service=row.fixes_value_service, + value_ours=row.fixes_value_ours, + code_service=row.fixes_code_service, + code_ours=row.fixes_code_ours, + added_row_service=row.fixes_added_row_service, + added_row_ours=row.fixes_added_row_ours, + header_service=row.fixes_header_service, + header_ours=row.fixes_header_ours, + osv_period_account=row.fixes_osv_period_account, + ) + + +class QualityMetricsService: + """Матрица, ряд по месяцам, список документов ячейки и выгрузка фактов.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @staticmethod + def _base(f: QualityFilters, latest: Any, *columns: Any) -> Select[Any]: + """`SELECT columns FROM intake LEFT JOIN latest_outcome WHERE фильтры`.""" + return ( + select(*columns) + .select_from(QualityIntakeFact) + .outerjoin(latest, latest.c.document_id == QualityIntakeFact.document_id) + .where(*_intake_conditions(f)) + ) + + async def _outcome_rows(self, f: QualityFilters) -> list[Any]: + """Документы периода с последним исходом (у документов «в работе» все поля исхода `None`).""" + latest = _latest_outcomes() + stmt = self._base( + f, + latest, + QualityIntakeFact.document_id, + QualityIntakeFact.legal_entity_id, + QualityIntakeFact.document_type, + QualityIntakeFact.intake_at, + latest.c.outcome, + latest.c.outcome_at, + latest.c.primary_cause, + latest.c.report_date, + ).order_by(QualityIntakeFact.document_id) + return list((await self._session.execute(stmt)).all()) + + @staticmethod + def _fact_rows(rows: Iterable[Any]) -> list[QualityOutcomeFactRow]: + return [ + QualityOutcomeFactRow( + document_id=r.document_id, + outcome=r.outcome, + outcome_at=r.outcome_at, + primary_cause=r.primary_cause, + received_at=r.intake_at, + ) + for r in rows + if r.outcome is not None + ] + + async def _technical_failures(self, f: QualityFilters) -> TechnicalFailuresOut: + failed_ids = select(QualityFailureFact.file_id).where(*_failure_conditions(f)) + intake_ids = select(QualityIntakeFact.file_id).where(*_intake_conditions(f, with_document_type=False)) + failed_stmt = select(func.count(distinct(QualityFailureFact.file_id))).where(*_failure_conditions(f)) + failed = (await self._session.execute(failed_stmt)).scalar_one() + files = union(failed_ids, intake_ids).subquery() + total = (await self._session.execute(select(func.count()).select_from(files))).scalar_one() + return TechnicalFailuresOut( + failed_files_count=failed, total_files_count=total, share=failed / total if total else 0.0 + ) + + async def matrix(self, f: QualityFilters) -> QualityMatrixOut: + rows = await self._outcome_rows(f) + result = compute_matrix(self._fact_rows(rows)) + reports = { + (r.legal_entity_id, r.report_date, r.document_type) + for r in rows + if r.outcome in _IN_DENOMINATOR and r.report_date is not None + } + return QualityMatrixOut( + counts=result.counts, + shares=result.shares, + denominator=result.denominator, + removed_before_confirm=result.removed_before_confirm, + removed_after_confirm=result.removed_after_confirm, + deleted_after_confirm_count=sum(r.outcome is QualityOutcome.DELETED_AFTER_CONFIRM for r in rows), + superseded_after_confirm_count=sum(r.outcome is QualityOutcome.SUPERSEDED_AFTER_CONFIRM for r in rows), + in_progress_count=sum(r.outcome is None for r in rows), + unique_reports_count=len(reports), + technical_failures=await self._technical_failures(f), + ) + + async def series(self, f: QualityFilters, cause: QualityCause) -> QualityMonthlySeriesOut: + points = compute_monthly_series(self._fact_rows(await self._outcome_rows(f)), cause) + return QualityMonthlySeriesOut( + cause=cause, + points=[ + MonthPointOut(month=p.month, count=p.count, denominator=p.denominator, share=p.share) for p in points + ], + ) + + @staticmethod + def _cell_condition(cell: QualityCell, latest: Any) -> Any: + if cell is QualityCell.IN_PROGRESS: + return latest.c.id.is_(None) + if cell is QualityCell.REMOVED_BEFORE_CONFIRM: + return latest.c.outcome.in_(_BEFORE_CONFIRM) + if cell is QualityCell.REMOVED_AFTER_CONFIRM: + return latest.c.outcome.in_(_AFTER_CONFIRM) + return (latest.c.outcome.in_(_IN_DENOMINATOR)) & (latest.c.primary_cause == QualityCause(cell.value)) + + async def cell_documents( + self, f: QualityFilters, cell: QualityCell, *, limit: int, offset: int + ) -> Page[QualityCellDocumentOut]: + latest = _latest_outcomes() + condition = self._cell_condition(cell, latest) + total = (await self._session.execute(self._base(f, latest, func.count()).where(condition))).scalar_one() + stmt = ( + self._base( + f, + latest, + QualityIntakeFact.document_id, + QualityIntakeFact.document_type, + QualityIntakeFact.legal_entity_id, + QualityIntakeFact.legal_entity_name, + QualityIntakeFact.group_name, + QualityIntakeFact.intake_at, + latest.c.outcome, + latest.c.outcome_at, + latest.c.primary_cause, + latest.c.fixes_value_service, + latest.c.fixes_value_ours, + latest.c.fixes_code_service, + latest.c.fixes_code_ours, + latest.c.fixes_added_row_service, + latest.c.fixes_added_row_ours, + latest.c.fixes_header_service, + latest.c.fixes_header_ours, + latest.c.fixes_osv_period_account, + latest.c.checks_accepted, + latest.c.issue_count_at_outcome, + latest.c.report_date, + latest.c.confirmed_by, + ) + .where(condition) + .order_by(QualityIntakeFact.intake_at.desc(), QualityIntakeFact.document_id) + .limit(limit) + .offset(offset) + ) + rows = list((await self._session.execute(stmt)).all()) + ids = [r.document_id for r in rows] + existing = ( + set((await self._session.execute(select(Document.id).where(Document.id.in_(ids)))).scalars()) + if ids + else set() + ) + user_ids = {r.confirmed_by for r in rows if r.confirmed_by is not None} + names: dict[uuid.UUID, str] = {} + if user_ids: + found = await self._session.execute(select(AppUser.id, AppUser.full_name).where(AppUser.id.in_(user_ids))) + names = {uid: name for uid, name in found.all()} + previous = await self._previous_outcomes(ids) if cell is QualityCell.REMOVED_AFTER_CONFIRM else {} + items = [ + QualityCellDocumentOut( + document_id=r.document_id, + document_type=r.document_type, + legal_entity_id=r.legal_entity_id, + legal_entity_name=r.legal_entity_name, + group_name=r.group_name, + received_at=r.intake_at, + outcome=r.outcome, + outcome_at=r.outcome_at, + previous_outcome=previous.get(r.document_id), + primary_cause=r.primary_cause, + report_date=r.report_date, + fixes=_fixes(r) if r.outcome is not None else None, + checks_accepted=r.checks_accepted, + issue_count_at_outcome=r.issue_count_at_outcome, + confirmed_by_id=r.confirmed_by, + confirmed_by_name=names.get(r.confirmed_by) if r.confirmed_by else None, + document_link=f"/api/v1/documents/{r.document_id}" if r.document_id in existing else None, + ) + for r in rows + ] + return Page(items=items, total=total, limit=limit, offset=offset) + + async def _previous_outcomes(self, document_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, QualityOutcome]: + """Исход до `*_AFTER_CONFIRM`: самый поздний факт подтверждения документа.""" + if not document_ids: + return {} + stmt = ( + select(QualityOutcomeFact.document_id, QualityOutcomeFact.outcome) + .where(QualityOutcomeFact.document_id.in_(document_ids), QualityOutcomeFact.outcome.in_(_IN_DENOMINATOR)) + .order_by( + QualityOutcomeFact.document_id, QualityOutcomeFact.outcome_at.desc(), QualityOutcomeFact.id.desc() + ) + .distinct(QualityOutcomeFact.document_id) + ) + return {doc: outcome for doc, outcome in (await self._session.execute(stmt)).all()} + + async def export_records(self, f: QualityFilters) -> list[dict[str, Any]]: + """Факты периода одной таблицей: приём + последний исход (поля исхода пусты у документов «в работе»).""" + latest = _latest_outcomes() + i = QualityIntakeFact + stmt = self._base( + f, + latest, + i.document_id, i.file_id, i.legal_entity_id, i.legal_entity_name, i.group_id, i.group_name, + i.document_type, i.pipeline, i.is_scan, i.service_name, i.service_version, i.intake_at, i.intake_status, + i.report_date.label("report_date_recognized"), + latest.c.outcome, latest.c.outcome_at, latest.c.primary_cause, + latest.c.fixes_value_service, latest.c.fixes_value_ours, latest.c.fixes_code_service, + latest.c.fixes_code_ours, latest.c.fixes_added_row_service, latest.c.fixes_added_row_ours, + latest.c.fixes_header_service, latest.c.fixes_header_ours, latest.c.fixes_osv_period_account, + latest.c.checks_accepted, latest.c.issue_count_at_outcome, + latest.c.report_date.label("report_date_confirmed"), + ).order_by(i.intake_at, i.document_id) # fmt: skip + return [dict(r._mapping) for r in (await self._session.execute(stmt)).all()] + + async def export(self, f: QualityFilters, fmt: ExportFormat) -> tuple[bytes, str, str]: + """Файл выгрузки: (содержимое, media type, расширение). CSV — UTF-8 с BOM (для Excel), разделитель — запятая.""" + records = await self.export_records(f) + if fmt == "json": + return ( + json.dumps(jsonable_encoder(records), ensure_ascii=False).encode(), + "application/json", + "json", + ) + buffer = io.StringIO() + writer = csv.DictWriter(buffer, fieldnames=EXPORT_COLUMNS, extrasaction="ignore") + writer.writeheader() + for record in records: + writer.writerow({k: "" if v is None else _cell(v) for k, v in record.items()}) + return buffer.getvalue().encode("utf-8-sig"), "text/csv; charset=utf-8", "csv" + + +def _cell(value: Any) -> Any: + """Значение ячейки CSV: перечисления — их значение, даты — ISO, булевы — true/false.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, datetime | date): + return value.isoformat() + return value diff --git a/api/tests/test_api_quality_metrics.py b/api/tests/test_api_quality_metrics.py new file mode 100644 index 0000000..e37547d --- /dev/null +++ b/api/tests/test_api_quality_metrics.py @@ -0,0 +1,324 @@ +"""API `/quality-metrics` (design `quality-metrics` §6-§8; AURORA-1759, Task 15). + +Быстрые тесты (без БД): право `QUALITY_METRICS_VIEW`, валидация query, маршруты в OpenAPI, CSV-ячейки. Интеграционные +(`integration`, PostgreSQL): факты пишутся напрямую через `insert_*_fact`, без полного цикла маппинга.""" + +import csv +import datetime +import io +import uuid +from collections.abc import AsyncIterator +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.auth import current_user +from src.api.router import router +from src.core.exception_handlers import register_exception_handlers +from src.db.db import get_async_session +from src.models.enums import ( + DocumentStatus, + DocumentType, + QualityCause, + QualityFailureKind, + QualityOutcome, + RecognitionPipeline, +) +from src.repositories.quality_facts import insert_failure_fact, insert_intake_fact, insert_outcome_fact +from src.repositories.users import UserWithAccess +from src.services.quality_metrics_service import EXPORT_COLUMNS +from tests.conftest import AuthClientFactory + +HTTP_OK, HTTP_FORBIDDEN, HTTP_UNPROCESSABLE = 200, 403, 422 +KA_RF = "ka_rf@armka.local" +BASE = "/quality-metrics" +UTC = datetime.UTC +T0 = datetime.datetime(2026, 11, 1, 10, 0, tzinfo=UTC) +T1 = T0 + datetime.timedelta(hours=1) +T2 = T0 + datetime.timedelta(hours=2) +VIEW = "QUALITY_METRICS_VIEW" +THREE, FOUR = 3, 4 + + +# --- быстрые тесты -------------------------------------------------------------------------------------------------- + + +async def _forbidden_client(permissions: frozenset[str]) -> AsyncIterator[httpx.AsyncClient]: + app = FastAPI() + register_exception_handlers(app) + app.include_router(router) + + async def no_session() -> AsyncIterator[None]: + yield None + + async def principal() -> UserWithAccess: + return UserWithAccess( + user=SimpleNamespace(), + regional_office=SimpleNamespace(), + roles=(), + permissions=permissions, # type: ignore[arg-type] + ) + + app.dependency_overrides[get_async_session] = no_session + app.dependency_overrides[current_user] = principal + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test/api/v1") as client: + yield client + + +@pytest.fixture +async def client_without_permission() -> AsyncIterator[httpx.AsyncClient]: + async for client in _forbidden_client(frozenset({"DIRECTORY_VIEW"})): + yield client + + +@pytest.mark.parametrize( + "path", + [ + "/matrix", + "/series?cause=AS_IS", + f"/cells/{QualityCause.AS_IS}/documents", + "/export", + ], +) +async def test_quality_metrics_requires_permission(client_without_permission: httpx.AsyncClient, path: str) -> None: + response = await client_without_permission.get(BASE + path) + assert response.status_code == HTTP_FORBIDDEN + assert VIEW in str(response.json()) + + +async def test_quality_metrics_matrix_requires_permission(client_without_permission: httpx.AsyncClient) -> None: + response = await client_without_permission.get("/quality-metrics/matrix") + assert response.status_code == HTTP_FORBIDDEN + + +@pytest.mark.parametrize( + "path", + [ + "/matrix?date_from=2026-02-01&date_to=2026-01-01", + "/matrix?document_type=NOPE", + "/series", # cause обязателен + "/series?cause=NOPE", + "/cells/NOPE/documents", + "/cells/AS_IS/documents?limit=0", + "/export?format=xml", + ], +) +async def test_quality_metrics_validates_query(path: str) -> None: + async for client in _forbidden_client(frozenset({VIEW})): + response = await client.get(BASE + path) + assert response.status_code == HTTP_UNPROCESSABLE, response.text + + +def test_quality_metrics_routes_in_openapi() -> None: + app = FastAPI() + app.include_router(router) + paths = app.openapi()["paths"] + for path in ("/matrix", "/series", "/cells/{cell}/documents", "/export"): + assert f"/api/v1{BASE}{path}" in paths + + +# --- интеграционные тесты ------------------------------------------------------------------------------------------- + + +def _intake_columns(document_id: uuid.UUID, **over: Any) -> dict[str, Any]: + columns: dict[str, Any] = { + "document_id": document_id, + "file_id": uuid.uuid4(), + "job_id": uuid.uuid4(), + "attempt": 1, + "legal_entity_id": uuid.uuid4(), + "legal_entity_name": "ООО Тест", + "group_id": None, + "group_name": None, + "document_type": DocumentType.OSV, + "pipeline": RecognitionPipeline.image, + "is_scan": True, + "intake_at": T0, + "intake_status": DocumentStatus.RECOGNIZED, + "payload": {}, + } + columns.update(over) + return columns + + +async def _doc( + session: AsyncSession, + outcome: QualityOutcome | None, + cause: QualityCause | None, + *, + at: datetime.datetime = T1, + **intake: Any, +) -> uuid.UUID: + """Документ с фактом приёма и (если `outcome`) одним фактом исхода.""" + document_id = uuid.uuid4() + await insert_intake_fact(session, **_intake_columns(document_id, **intake)) + if outcome is not None: + await insert_outcome_fact( + session, document_id=document_id, outcome=outcome, outcome_at=at, primary_cause=cause, payload={} + ) + await session.flush() + return document_id + + +@pytest.mark.integration +async def test_quality_metrics_matrix_returns_counts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.SERVICE_ERROR) + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) + await _doc(seeded, QualityOutcome.AUTO_CONFIRMED, QualityCause.WITHOUT_PARTICIPATION) + await _doc(seeded, QualityOutcome.DELETED, None) + await _doc(seeded, None, None) # в работе + client = await auth_client(KA_RF) + response = await client.get(f"{BASE}/matrix") + assert response.status_code == HTTP_OK + body = response.json() + assert body["counts"]["SERVICE_ERROR"] == 1 + assert body["counts"]["AS_IS"] == 1 + assert body["counts"]["OUR_ERROR"] == 0 + assert body["denominator"] == THREE + assert body["shares"]["SERVICE_ERROR"] == pytest.approx(1 / 3) + assert body["removed_before_confirm"] == 1 + assert body["in_progress_count"] == 1 + + +@pytest.mark.integration +async def test_quality_metrics_matrix_dedupes_to_latest_outcome_fact( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + document_id = await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, at=T1) + await insert_outcome_fact( + seeded, + document_id=document_id, + outcome=QualityOutcome.SUPERSEDED_AFTER_CONFIRM, + outcome_at=T2, + primary_cause=None, + payload={}, + ) + await seeded.flush() + client = await auth_client(KA_RF) + body = (await client.get(f"{BASE}/matrix")).json() + assert body["counts"].get("AS_IS", 0) == 0 # устаревший факт не виден + assert body["superseded_after_confirm_count"] == 1 + assert body["removed_after_confirm"] == 1 + assert body["denominator"] == 0 + # series и список ячейки видят тот же последний факт + series = (await client.get(f"{BASE}/series", params={"cause": "AS_IS"})).json() + assert series["points"] == [] + cell = (await client.get(f"{BASE}/cells/AS_IS/documents")).json() + assert cell["total"] == 0 + removed = (await client.get(f"{BASE}/cells/REMOVED_AFTER_CONFIRM/documents")).json() + assert removed["total"] == 1 + assert removed["items"][0]["previous_outcome"] == "CONFIRMED" + export = (await client.get(f"{BASE}/export", params={"format": "json"})).json() + assert [r["outcome"] for r in export] == ["SUPERSEDED_AFTER_CONFIRM"] + + +@pytest.mark.integration +async def test_quality_metrics_equal_outcome_at_prefers_after_confirm( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + document_id = await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, at=T1) + await insert_outcome_fact( + seeded, + document_id=document_id, + outcome=QualityOutcome.DELETED_AFTER_CONFIRM, + outcome_at=T1, + primary_cause=None, + payload={}, + ) + await seeded.flush() + body = (await (await auth_client(KA_RF)).get(f"{BASE}/matrix")).json() + assert body["deleted_after_confirm_count"] == 1 + assert body["counts"]["AS_IS"] == 0 + + +@pytest.mark.integration +async def test_quality_metrics_filters_and_series(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = uuid.uuid4() + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.OUR_ERROR, group_id=group, group_name="ГК") + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, group_id=group, group_name="ГК", is_scan=False) + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, group_id=group, document_type=DocumentType.BALANCE) + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) # другая ГК + await _doc( # другой месяц приёма + seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, group_id=group, intake_at=T0 + datetime.timedelta(days=40) + ) + client = await auth_client(KA_RF) + + async def denominator(**params: Any) -> int: + return (await client.get(f"{BASE}/matrix", params={"group_id": str(group), **params})).json()["denominator"] + + assert await denominator() == FOUR + assert await denominator(document_type="BALANCE") == 1 + assert await denominator(is_scan="false") == 1 + assert await denominator(pipeline="tabular") == 0 + assert await denominator(date_from="2026-11-01", date_to="2026-11-01") == THREE # границы включительно + assert await denominator(date_from="2026-11-02") == 1 + series = (await client.get(f"{BASE}/series", params={"group_id": str(group), "cause": "AS_IS"})).json() + assert [(p["month"], p["count"], p["denominator"]) for p in series["points"]][0] == ("2026-11-01", 2, 3) + assert len(series["points"]) == 2 # noqa: PLR2004 - ноябрь и декабрь + + +@pytest.mark.integration +async def test_quality_metrics_technical_failures_by_files( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) + for kind in (QualityFailureKind.SERVICE_ERROR, QualityFailureKind.TIMEOUT): + await insert_failure_fact( + seeded, + job_id=uuid.uuid4(), + file_id=uuid.uuid4(), + attempt=1, + legal_entity_id=uuid.uuid4(), + legal_entity_name="ООО Тест", + pipeline=RecognitionPipeline.image, + is_scan=True, + failure_kind=kind, + failed_at=T1, + ) + await seeded.flush() + client = await auth_client(KA_RF) + failures = (await client.get(f"{BASE}/matrix", params={"document_type": "BALANCE"})).json()["technical_failures"] + assert failures == {"failed_files_count": 2, "total_files_count": 3, "share": pytest.approx(2 / 3)} + + +@pytest.mark.integration +async def test_quality_metrics_cell_documents(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + for _ in range(3): + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.SERVICE_ERROR, legal_entity_name="ООО Альфа") + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) + in_progress = await _doc(seeded, None, None) + client = await auth_client(KA_RF) + page = (await client.get(f"{BASE}/cells/SERVICE_ERROR/documents", params={"limit": 2})).json() + assert (page["total"], page["limit"], page["offset"], len(page["items"])) == (3, 2, 0, 2) + item = page["items"][0] + assert item["primary_cause"] == "SERVICE_ERROR" + assert item["legal_entity_name"] == "ООО Альфа" + assert item["document_link"] is None # рабочего документа нет + assert set(item["fixes"]) >= {"value_service", "value_ours", "osv_period_account"} + second = (await client.get(f"{BASE}/cells/SERVICE_ERROR/documents", params={"limit": 2, "offset": 2})).json() + assert len(second["items"]) == 1 + progress = (await client.get(f"{BASE}/cells/IN_PROGRESS/documents")).json() + assert [i["document_id"] for i in progress["items"]] == [str(in_progress)] + assert progress["items"][0]["outcome"] is None + + +@pytest.mark.integration +async def test_quality_metrics_export(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, legal_entity_name="ООО Бета") + await _doc(seeded, None, None) + client = await auth_client(KA_RF) + response = await client.get(f"{BASE}/export") + assert response.status_code == HTTP_OK + assert response.headers["content-type"].startswith("text/csv") + assert "attachment" in response.headers["content-disposition"] + rows = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig")))) + assert list(rows[0]) == list(EXPORT_COLUMNS) + assert len(rows) == 2 # noqa: PLR2004 + assert {r["outcome"] for r in rows} == {"CONFIRMED", ""} + assert rows[0]["is_scan"] == "true" + as_json = await client.get(f"{BASE}/export", params={"format": "json", "date_from": "2026-11-01"}) + assert len(as_json.json()) == 2 # noqa: PLR2004 -- 2.47.2 From 1d99ea265e9a82bc5601adf2667b0639a4247fc1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 02:46:07 +0400 Subject: [PATCH 30/44] =?UTF-8?q?docs(#190):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=2015=20(API=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0)=20=D0=B2?= =?UTF-8?q?=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- openspec/changes/quality-metrics/design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 25a5b53..1123f5b 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -237,3 +237,8 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | Task 14 (`compute_matrix`): по `document_id` берётся последний факт по `outcome_at`; при равенстве `outcome_at` — `*_AFTER_CONFIRM` > `CONFIRMED`/`AUTO_CONFIRMED` > `DELETED`/`SUPERSEDED`, затем поздний во входе | результат не должен зависеть от порядка строк SELECT; design §4.2 — метрики берут последний факт | при редком совпадении времени документ посчитается подтверждённым вместо удалённого или наоборот | | 07.11.2026 | Task 14: два столбца вне долей — `removed_before_confirm` (DELETED, SUPERSEDED) и `removed_after_confirm` (оба `*_AFTER_CONFIRM`), без разбивки на удалён/заменён; доли — float, при нулевом знаменателе 0.0 (не None); исход из знаменателя с `primary_cause=None` и неизвестный `outcome` — `ValueError`, без молчаливого пропуска | design §6 п.1 называет два столбца; битый факт или новый enum не должен тихо искажать матрицу | разбивку придётся добавлять позже; битый факт даёт 500 на экране вместо искажённой матрицы; фронт отличает 0% от «нет данных» только по `denominator` | | 07.11.2026 | Task 14 (R-8): ряд по месяцам идёт по месяцу даты приёма (`received_at` = `quality_intake_fact.intake_at`), а не по `outcome_at`; поле добавлено во входную строку `QualityOutcomeFactRow`, Task 15 подставляет его join'ом intake↔outcome по `document_id`; пропущенные месяцы заполняются нулями | design §7 и решение 27.10: период по дате приёма (когорта) | документ попадёт в месяц исхода, а не приёма, метрики недавних периодов исказятся | +| 07.11.2026 | Task 15 (API): последний факт исхода на документ — `DISTINCT ON (document_id) ORDER BY document_id, outcome_at DESC, ранг DESC, id DESC` во всех запросах; ранг (`*_AFTER_CONFIRM` 2, подтверждение 1, остальное 0) совпадает с tie-break домена | SQL и домен должны выбирать один и тот же факт | расхождение выбора даёт разные числа в матрице и в списке ячейки | +| 07.11.2026 | Task 15: выборка идёт от фактов приёма (LEFT JOIN последнего исхода); исход без факта приёма в метрики не входит; границы периода включительные по UTC (`date_to` отсекается в 00:00 UTC следующего дня) | у исхода без приёма нет даты приёма (когорта); UTC — единая зона БД | для пользователей в МСК граница периода сдвинута на 3 часа | +| 07.11.2026 | Task 15: технические отказы — поле `technical_failures {failed_files_count, total_files_count, share}`, период по `failed_at`, `total_files_count` — уникальные `file_id` из приёма и отказов; «в работе» (`in_progress_count`) и `unique_reports_count` (ЮЛ + отчётная дата + тип) — отдельные поля; в ответе отдельно `superseded_after_confirm_count`, `deleted_after_confirm_count` | design §6, решение 27.10 про отказы по файлам | доля отказов считается от другого знаменателя, чем доли причин | +| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` не пишется (нет в `audit_action`) | design §7/§8; ячейки шире причин | выгрузка не оставляет следа в аудите; Excel может показать BOM в чужих импортёрах | +| 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY | -- 2.47.2 From 3589f53c3c4ab8c68cb087c48a30d269f2610110 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 02:46:25 +0400 Subject: [PATCH 31/44] =?UTF-8?q?fix(#190):=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=83=D0=BB=D1=8B=20=D0=B2=20CSV-=D0=B2=D1=8B?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20=D0=BA=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B0=20(T15.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- api/src/services/quality_metrics_service.py | 5 +++++ api/tests/test_api_quality_metrics.py | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/api/src/services/quality_metrics_service.py b/api/src/services/quality_metrics_service.py index c6e4926..34a3db9 100644 --- a/api/src/services/quality_metrics_service.py +++ b/api/src/services/quality_metrics_service.py @@ -354,6 +354,9 @@ class QualityMetricsService: return buffer.getvalue().encode("utf-8-sig"), "text/csv; charset=utf-8", "csv" +_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r") + + def _cell(value: Any) -> Any: """Значение ячейки CSV: перечисления — их значение, даты — ISO, булевы — true/false.""" if isinstance(value, bool): @@ -362,4 +365,6 @@ def _cell(value: Any) -> Any: return value.value if isinstance(value, datetime | date): return value.isoformat() + if isinstance(value, str) and value.startswith(_FORMULA_PREFIXES): + return "'" + value # защита от CSV-инъекции формул в Excel return value diff --git a/api/tests/test_api_quality_metrics.py b/api/tests/test_api_quality_metrics.py index e37547d..7d3f3a2 100644 --- a/api/tests/test_api_quality_metrics.py +++ b/api/tests/test_api_quality_metrics.py @@ -30,7 +30,7 @@ from src.models.enums import ( ) from src.repositories.quality_facts import insert_failure_fact, insert_intake_fact, insert_outcome_fact from src.repositories.users import UserWithAccess -from src.services.quality_metrics_service import EXPORT_COLUMNS +from src.services.quality_metrics_service import EXPORT_COLUMNS, _cell from tests.conftest import AuthClientFactory HTTP_OK, HTTP_FORBIDDEN, HTTP_UNPROCESSABLE = 200, 403, 422 @@ -322,3 +322,15 @@ async def test_quality_metrics_export(auth_client: AuthClientFactory, seeded: As assert rows[0]["is_scan"] == "true" as_json = await client.get(f"{BASE}/export", params={"format": "json", "date_from": "2026-11-01"}) assert len(as_json.json()) == 2 # noqa: PLR2004 + + +@pytest.mark.parametrize("prefix", ["=", "+", "-", "@", "\t", "\r"]) +def test_csv_cell_escapes_formula_strings(prefix: str) -> None: + assert _cell(f"{prefix}SUM(A1)") == f"'{prefix}SUM(A1)" + + +def test_csv_cell_keeps_non_strings_and_plain_strings() -> None: + assert _cell(-5) == -5 # noqa: PLR2004 + assert _cell("ООО Альфа") == "ООО Альфа" + assert _cell(datetime.date(2026, 11, 1)) == "2026-11-01" + assert _cell(True) == "true" -- 2.47.2 From 717dec69cf5ae44325347118e722df3a7b2d07bd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 02:49:24 +0400 Subject: [PATCH 32/44] =?UTF-8?q?feat(#190):=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20=C2=AB=D0=9A=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B2=D0=B8=D1=87=D0=BA=D0=B8=C2=BB=20(we?= =?UTF-8?q?b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- web/src/App.tsx | 2 + web/src/auth/permissions.ts | 2 + web/src/layouts/QualityMetrics.tsx | 13 ++ web/src/services/api/qualityMetrics.test.ts | 13 ++ web/src/services/api/qualityMetrics.ts | 45 ++++++ web/src/types/qualityMetrics.ts | 98 ++++++++++++ .../QualityMetrics/DocumentDrillList.tsx | 72 +++++++++ .../widgets/QualityMetrics/MatrixTable.tsx | 48 ++++++ .../widgets/QualityMetrics/MonthlyTrend.tsx | 58 +++++++ .../QualityMetrics/QualityMetricsPage.tsx | 146 ++++++++++++++++++ .../QualityMetrics/qualityView.test.ts | 53 +++++++ web/src/widgets/QualityMetrics/qualityView.ts | 59 +++++++ web/src/widgets/Sidebar/Sidebar.tsx | 24 ++- web/src/widgets/Sidebar/visibility.ts | 10 ++ 14 files changed, 637 insertions(+), 6 deletions(-) create mode 100644 web/src/layouts/QualityMetrics.tsx create mode 100644 web/src/services/api/qualityMetrics.test.ts create mode 100644 web/src/services/api/qualityMetrics.ts create mode 100644 web/src/types/qualityMetrics.ts create mode 100644 web/src/widgets/QualityMetrics/DocumentDrillList.tsx create mode 100644 web/src/widgets/QualityMetrics/MatrixTable.tsx create mode 100644 web/src/widgets/QualityMetrics/MonthlyTrend.tsx create mode 100644 web/src/widgets/QualityMetrics/QualityMetricsPage.tsx create mode 100644 web/src/widgets/QualityMetrics/qualityView.test.ts create mode 100644 web/src/widgets/QualityMetrics/qualityView.ts create mode 100644 web/src/widgets/Sidebar/visibility.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 871c730..abe63b2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,7 @@ import { Mono, } from './layouts/'; import { ConsolidationReportPage } from './layouts/ConsolidationReportPage'; +import { QualityMetrics } from './layouts/QualityMetrics'; import { store } from './store/store'; import { Sidebar } from './widgets/Sidebar/Sidebar'; @@ -44,6 +45,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/web/src/auth/permissions.ts b/web/src/auth/permissions.ts index f6a49b8..65eabfe 100644 --- a/web/src/auth/permissions.ts +++ b/web/src/auth/permissions.ts @@ -1,3 +1,5 @@ // Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер: // проверки на клиенте только прячут то, что пользователю всё равно не разрешат. export const USERS_ADMIN = 'USERS_ADMIN'; +// Экран «Качество первички» (метрики качества распознавания). +export const QUALITY_METRICS_VIEW = 'QUALITY_METRICS_VIEW'; diff --git a/web/src/layouts/QualityMetrics.tsx b/web/src/layouts/QualityMetrics.tsx new file mode 100644 index 0000000..679ad3b --- /dev/null +++ b/web/src/layouts/QualityMetrics.tsx @@ -0,0 +1,13 @@ +import { Result } from 'antd'; +import { useCurrentUser } from '../auth/MeContext'; +import { QUALITY_METRICS_VIEW } from '../auth/permissions'; +import { QualityMetricsPage } from '../widgets/QualityMetrics/QualityMetricsPage'; + +/** Маршрут `/quality-metrics`: без права `QUALITY_METRICS_VIEW` страница не создаётся, запросов к API нет. */ +export function QualityMetrics() { + const me = useCurrentUser(); + if (!me.permissions.includes(QUALITY_METRICS_VIEW)) { + return ; + } + return ; +} diff --git a/web/src/services/api/qualityMetrics.test.ts b/web/src/services/api/qualityMetrics.test.ts new file mode 100644 index 0000000..361f439 --- /dev/null +++ b/web/src/services/api/qualityMetrics.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { queryString } from './qualityMetrics'; + +describe('queryString', () => { + it('пропускает пустые поля, булево пишет как true/false', () => { + expect(queryString({ date_from: '2026-01-01', pipeline: undefined, is_scan: false, format: 'csv' })).toBe( + '?date_from=2026-01-01&is_scan=false&format=csv', + ); + }); + it('без параметров — пустая строка', () => { + expect(queryString({})).toBe(''); + }); +}); diff --git a/web/src/services/api/qualityMetrics.ts b/web/src/services/api/qualityMetrics.ts new file mode 100644 index 0000000..f9d2f37 --- /dev/null +++ b/web/src/services/api/qualityMetrics.ts @@ -0,0 +1,45 @@ +import { api, apiFetchBlob } from '../../api/client'; +import type { Page } from '../../api/types'; +import type { + ExportFormat, + QualityCause, + QualityCell, + QualityCellDocumentOut, + QualityFilters, + QualityMatrixOut, + QualityMonthlySeriesOut, +} from '../../types/qualityMetrics'; + +const BASE = '/quality-metrics'; + +export function getMatrix(filters: QualityFilters): Promise { + return api.get(`${BASE}/matrix`, filters); +} + +export function getSeries(filters: QualityFilters, cause: QualityCause): Promise { + return api.get(`${BASE}/series`, { ...filters, cause }); +} + +export function getCellDocuments( + filters: QualityFilters, + cell: QualityCell, + paging: { limit: number; offset: number }, +): Promise> { + return api.get>(`${BASE}/cells/${cell}/documents`, { ...filters, ...paging }); +} + +/** Выгрузка фактов за период; скачивание идёт через fetch, потому что нужен заголовок Authorization. */ +export function exportFacts(filters: QualityFilters, format: ExportFormat): Promise<{ blob: Blob; filename: string | null }> { + return apiFetchBlob(`${BASE}/export${queryString({ ...filters, format })}`); +} + +/** Строка запроса для выгрузки (пустые поля пропускаются) — вынесено отдельно ради теста. */ +export function queryString(params: object): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + search.append(key, String(value)); + } + const qs = search.toString(); + return qs ? `?${qs}` : ''; +} diff --git a/web/src/types/qualityMetrics.ts b/web/src/types/qualityMetrics.ts new file mode 100644 index 0000000..c3f3332 --- /dev/null +++ b/web/src/types/qualityMetrics.ts @@ -0,0 +1,98 @@ +// Типы экрана «Качество первички» 1:1 со схемами API (api/src/schemas/quality_metrics.py). +// Доли — числа 0..1; даты — строки ISO (`2026-11-01`), момент времени — ISO с часовым поясом. + +import type { DocumentType } from './api'; + +/** Исход документа (`QualityOutcome`). */ +export type QualityOutcome = + | 'AUTO_CONFIRMED' + | 'CONFIRMED' + | 'DELETED' + | 'SUPERSEDED' + | 'DELETED_AFTER_CONFIRM' + | 'SUPERSEDED_AFTER_CONFIRM'; + +/** Причина документа (`QualityCause`). */ +export type QualityCause = 'SERVICE_ERROR' | 'OUR_ERROR' | 'CLIENT_DOCUMENT' | 'WITHOUT_PARTICIPATION' | 'AS_IS'; + +/** Ячейка матрицы, из которой раскрывается список документов (`QualityCell`). */ +export type QualityCell = QualityCause | 'REMOVED_BEFORE_CONFIRM' | 'REMOVED_AFTER_CONFIRM' | 'IN_PROGRESS'; + +/** Источник: image = PDF/скан, tabular = Excel (`RecognitionPipeline`). */ +export type RecognitionPipeline = 'image' | 'tabular'; + +export type ExportFormat = 'csv' | 'json'; + +/** Фильтры (`QualityFilters`); пустые поля в запрос не попадают. */ +export interface QualityFilters { + date_from?: string; + date_to?: string; + group_id?: string; + legal_entity_id?: string; + document_type?: DocumentType; + pipeline?: RecognitionPipeline; + is_scan?: boolean; +} + +export interface TechnicalFailuresOut { + failed_files_count: number; + total_files_count: number; + share: number; +} + +export interface QualityMatrixOut { + counts: Record; + shares: Record; + denominator: number; + removed_before_confirm: number; + removed_after_confirm: number; + deleted_after_confirm_count: number; + superseded_after_confirm_count: number; + in_progress_count: number; + unique_reports_count: number; + technical_failures: TechnicalFailuresOut; +} + +export interface MonthPointOut { + month: string; + count: number; + denominator: number; + share: number; +} + +export interface QualityMonthlySeriesOut { + cause: QualityCause; + points: MonthPointOut[]; +} + +export interface QualityFixesOut { + value_service: number; + value_ours: number; + code_service: number; + code_ours: number; + added_row_service: number; + added_row_ours: number; + header_service: number; + header_ours: number; + osv_period_account: number; +} + +export interface QualityCellDocumentOut { + document_id: string; + document_type: DocumentType; + legal_entity_id: string; + legal_entity_name: string; + group_name: string | null; + received_at: string; + outcome: QualityOutcome | null; + outcome_at: string | null; + previous_outcome: QualityOutcome | null; + primary_cause: QualityCause | null; + report_date: string | null; + fixes: QualityFixesOut | null; + checks_accepted: boolean | null; + issue_count_at_outcome: number | null; + confirmed_by_id: string | null; + confirmed_by_name: string | null; + document_link: string | null; +} diff --git a/web/src/widgets/QualityMetrics/DocumentDrillList.tsx b/web/src/widgets/QualityMetrics/DocumentDrillList.tsx new file mode 100644 index 0000000..dc7676e --- /dev/null +++ b/web/src/widgets/QualityMetrics/DocumentDrillList.tsx @@ -0,0 +1,72 @@ +import { Alert, Table } from 'antd'; +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { getCellDocuments } from '../../services/api/qualityMetrics'; +import type { QualityCell, QualityCellDocumentOut, QualityFilters } from '../../types/qualityMetrics'; +import { documentTypeLabel } from '../../utils/labels'; + +const PAGE_SIZE = 20; + +/** Документы одной ячейки матрицы; постранично, имя документа ведёт на карточку документа. */ +export function DocumentDrillList({ filters, cell }: { filters: QualityFilters; cell: QualityCell }) { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // смена фильтров или ячейки — снова с первой страницы + // biome-ignore lint/correctness/useExhaustiveDependencies: сброс страницы по смене входных данных + useEffect(() => setPage(1), [filters, cell]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + getCellDocuments(filters, cell, { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }) + .then((r) => { + if (cancelled) return; + setItems(r.items); + setTotal(r.total); + }) + .catch((e: unknown) => { + if (!cancelled) setError(e instanceof Error ? e.message : 'Не удалось загрузить документы'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [filters, cell, page]); + + return ( + <> + {error && } + + rowKey='document_id' + size='small' + loading={loading} + dataSource={items} + locale={{ emptyText: 'Нет документов' }} + pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, onChange: setPage }} + columns={[ + { + title: 'Документ', + render: (_: unknown, d) => + d.document_link ? ( + {documentTypeLabel(d.document_type, null)} + ) : ( + {documentTypeLabel(d.document_type, null)} (удалён) + ), + }, + { title: 'Юрлицо', dataIndex: 'legal_entity_name' }, + { title: 'ГК', dataIndex: 'group_name', render: (g: string | null) => g ?? '—' }, + { title: 'Отчётная дата', dataIndex: 'report_date', render: (v: string | null) => v ?? '—' }, + { title: 'Принят', dataIndex: 'received_at', render: (v: string) => v.slice(0, 10) }, + { title: 'Подтвердил', dataIndex: 'confirmed_by_name', render: (v: string | null) => v ?? '—' }, + ]} + /> + + ); +} diff --git a/web/src/widgets/QualityMetrics/MatrixTable.tsx b/web/src/widgets/QualityMetrics/MatrixTable.tsx new file mode 100644 index 0000000..9ab35c3 --- /dev/null +++ b/web/src/widgets/QualityMetrics/MatrixTable.tsx @@ -0,0 +1,48 @@ +import { Button, Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { QualityCell, QualityMatrixOut } from '../../types/qualityMetrics'; +import { type MatrixRow, buildMatrixRows } from './qualityView'; + +interface Props { + data: QualityMatrixOut; + /** Клик по счётчику ячейки — открыть список документов. */ + onOpenCell?: (cell: QualityCell, label: string) => void; +} + +/** Матрица причин: строка — причина или служебная группа, счётчик раскрывается в список документов. */ +export function MatrixTable({ data, onOpenCell }: Props) { + const columns: ColumnsType = [ + { + title: 'Причина', + dataIndex: 'label', + render: (label: string, row) => ( +
+
{label}
+ {row.note &&
{row.note}
} +
+ ), + }, + { + title: 'Документов', + dataIndex: 'count', + align: 'right', + width: 140, + render: (count: number, row) => + onOpenCell && count > 0 ? ( + + ) : ( + count + ), + }, + { + title: 'Доля от подтверждённых', + dataIndex: 'share', + align: 'right', + width: 200, + render: (share: string | null) => share ?? '—', + }, + ]; + return rowKey='key' columns={columns} dataSource={buildMatrixRows(data)} pagination={false} size='small' />; +} diff --git a/web/src/widgets/QualityMetrics/MonthlyTrend.tsx b/web/src/widgets/QualityMetrics/MonthlyTrend.tsx new file mode 100644 index 0000000..3492d22 --- /dev/null +++ b/web/src/widgets/QualityMetrics/MonthlyTrend.tsx @@ -0,0 +1,58 @@ +import { Alert, Select, Space, Table } from 'antd'; +import { useEffect, useState } from 'react'; +import { getSeries } from '../../services/api/qualityMetrics'; +import type { MonthPointOut, QualityCause, QualityFilters } from '../../types/qualityMetrics'; +import { CAUSE_ORDER, causeLabel, formatShare, monthLabel } from './qualityView'; + +/** Динамика по месяцам даты приёма для выбранной причины (таблицей: библиотеки графиков в проекте нет). */ +export function MonthlyTrend({ filters }: { filters: QualityFilters }) { + const [cause, setCause] = useState('WITHOUT_PARTICIPATION'); + const [points, setPoints] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + getSeries(filters, cause) + .then((r) => { + if (!cancelled) setPoints(r.points); + }) + .catch((e: unknown) => { + if (!cancelled) setError(e instanceof Error ? e.message : 'Не удалось загрузить динамику'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [filters, cause]); + + return ( + + + value={cause} + onChange={setCause} + style={{ width: 360 }} + options={CAUSE_ORDER.map((c) => ({ value: c, label: causeLabel(c) }))} + /> + {error && } + + rowKey='month' + size='small' + loading={loading} + pagination={false} + dataSource={points} + locale={{ emptyText: 'Нет данных за период' }} + columns={[ + { title: 'Месяц приёма', dataIndex: 'month', render: monthLabel }, + { title: 'Документов', dataIndex: 'count', align: 'right' }, + { title: 'Подтверждено всего', dataIndex: 'denominator', align: 'right' }, + { title: 'Доля', dataIndex: 'share', align: 'right', render: (_: number, p) => formatShare(p.share, p.denominator) }, + ]} + /> + + ); +} diff --git a/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx new file mode 100644 index 0000000..72619b2 --- /dev/null +++ b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx @@ -0,0 +1,146 @@ +import { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd'; +import type { Dayjs } from 'dayjs'; +import { useEffect, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { exportFacts, getMatrix } from '../../services/api/qualityMetrics'; +import type { DocumentType } from '../../types/api'; +import type { ExportFormat, QualityCell, QualityFilters, QualityMatrixOut, RecognitionPipeline } from '../../types/qualityMetrics'; +import { saveBlob } from '../../utils/download'; +import { DocumentDrillList } from './DocumentDrillList'; +import { MatrixTable } from './MatrixTable'; +import { MonthlyTrend } from './MonthlyTrend'; +import { formatShare } from './qualityView'; + +const DOC_TYPES: { value: DocumentType; label: string }[] = [ + { value: 'BALANCE', label: 'Бухгалтерский баланс' }, + { value: 'INCOME', label: 'ОФР' }, + { value: 'OSV', label: 'ОСВ' }, +]; + +const PIPELINES: { value: RecognitionPipeline; label: string }[] = [ + { value: 'image', label: 'PDF (распознавание)' }, + { value: 'tabular', label: 'Excel' }, +]; + +const SCAN_OPTIONS = [ + { value: 'true', label: 'Скан' }, + { value: 'false', label: 'Цифровой PDF' }, +]; + +const iso = (d: Dayjs): string => d.format('YYYY-MM-DD'); + +/** Экран «Качество первички»: фильтры, матрица причин, технические отказы, динамика по месяцам, выгрузка. */ +export function QualityMetricsPage() { + const [filters, setFilters] = useState({}); + const [matrix, setMatrix] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [drill, setDrill] = useState<{ cell: QualityCell; label: string } | null>(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + getMatrix(filters) + .then((r) => { + if (!cancelled) setMatrix(r); + }) + .catch((e: unknown) => { + if (cancelled) return; + setMatrix(null); + setError(e instanceof Error ? e : new Error('Не удалось загрузить метрики')); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [filters]); + + const patch = (changes: Partial) => setFilters((f) => ({ ...f, ...changes })); + + const download = async (format: ExportFormat) => { + try { + const { blob, filename } = await exportFacts(filters, format); + saveBlob(blob, filename ?? `quality-facts.${format}`); + } catch (e: unknown) { + message.error(e instanceof Error ? e.message : 'Не удалось выгрузить данные'); + } + }; + + if (error instanceof ApiError && error.status === 403) { + return ; + } + + return ( +
+

Качество первички

+ + + patch({ date_from: range?.[0] ? iso(range[0]) : undefined, date_to: range?.[1] ? iso(range[1]) : undefined }) + } + /> + + allowClear + placeholder='Тип документа' + style={{ width: 200 }} + options={DOC_TYPES} + onChange={(v) => patch({ document_type: v })} + /> + + allowClear + placeholder='Источник' + style={{ width: 200 }} + options={PIPELINES} + onChange={(v) => patch({ pipeline: v })} + /> + + allowClear + placeholder='Скан / цифровой' + style={{ width: 170 }} + options={SCAN_OPTIONS} + onChange={(v) => patch({ is_scan: v === undefined ? undefined : v === 'true' })} + /> + + + + + {error && } + + {matrix && matrix.denominator === 0 && matrix.in_progress_count === 0 && ( + + )} + {matrix && ( + <> + + {matrix.denominator} + {matrix.unique_reports_count} + + {matrix.technical_failures.failed_files_count} из {matrix.technical_failures.total_files_count} ( + {formatShare(matrix.technical_failures.share, matrix.technical_failures.total_files_count)}) + + + setDrill({ cell, label })} />, + }, + { key: 'trend', label: 'Динамика по месяцам', children: }, + ]} + /> + + )} + + + setDrill(null)} destroyOnClose> + {drill && } + +
+ ); +} diff --git a/web/src/widgets/QualityMetrics/qualityView.test.ts b/web/src/widgets/QualityMetrics/qualityView.test.ts new file mode 100644 index 0000000..c7f8cc4 --- /dev/null +++ b/web/src/widgets/QualityMetrics/qualityView.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import type { QualityMatrixOut } from '../../types/qualityMetrics'; +import { filterByPermission } from '../Sidebar/visibility'; +import { buildMatrixRows, formatShare, monthLabel } from './qualityView'; + +const matrixFixture: QualityMatrixOut = { + counts: { SERVICE_ERROR: 3, OUR_ERROR: 2, CLIENT_DOCUMENT: 1, WITHOUT_PARTICIPATION: 12, AS_IS: 5 }, + shares: { SERVICE_ERROR: 0.13, OUR_ERROR: 0.087, CLIENT_DOCUMENT: 0.043, WITHOUT_PARTICIPATION: 0.522, AS_IS: 0.217 }, + denominator: 23, + removed_before_confirm: 4, + removed_after_confirm: 3, + deleted_after_confirm_count: 1, + superseded_after_confirm_count: 2, + in_progress_count: 6, + unique_reports_count: 20, + technical_failures: { failed_files_count: 1, total_files_count: 30, share: 0.033 }, +}; + +describe('пункт меню по праву', () => { + const items = [{ link: '/a' }, { link: '/q', permission: 'QUALITY_METRICS_VIEW' }]; + it('скрыт без права', () => { + expect(filterByPermission(items, ['USERS_ADMIN']).map((i) => i.link)).toEqual(['/a']); + }); + it('виден с правом', () => { + expect(filterByPermission(items, ['QUALITY_METRICS_VIEW']).map((i) => i.link)).toEqual(['/a', '/q']); + }); +}); + +describe('buildMatrixRows', () => { + const rows = buildMatrixRows(matrixFixture); + it('счётчики из ответа API', () => { + expect(rows.find((r) => r.key === 'WITHOUT_PARTICIPATION')?.count).toBe(12); + expect(rows.find((r) => r.key === 'REMOVED_BEFORE_CONFIRM')?.count).toBe(4); + expect(rows.find((r) => r.key === 'IN_PROGRESS')?.count).toBe(6); + }); + it('доли только у причин; разбивка удалённых после подтверждения', () => { + expect(rows.find((r) => r.key === 'WITHOUT_PARTICIPATION')?.share).toBe('52,2%'); + expect(rows.find((r) => r.key === 'IN_PROGRESS')?.share).toBeNull(); + expect(rows.find((r) => r.key === 'REMOVED_AFTER_CONFIRM')?.note).toBe('удалено: 1, заменено: 2'); + }); +}); + +describe('formatShare', () => { + it('нет знаменателя — «нет данных», не 0%', () => { + expect(formatShare(0, 0)).toBe('нет данных'); + }); +}); + +describe('monthLabel', () => { + it('месяц.год', () => { + expect(monthLabel('2026-03-01')).toBe('03.2026'); + }); +}); diff --git a/web/src/widgets/QualityMetrics/qualityView.ts b/web/src/widgets/QualityMetrics/qualityView.ts new file mode 100644 index 0000000..73feae8 --- /dev/null +++ b/web/src/widgets/QualityMetrics/qualityView.ts @@ -0,0 +1,59 @@ +// Чистые функции экрана «Качество первички»: подписи и строки матрицы. Отдельно от React — чтобы тестировать. + +import type { QualityCause, QualityCell, QualityMatrixOut } from '../../types/qualityMetrics'; + +export const CAUSE_ORDER: readonly QualityCause[] = ['WITHOUT_PARTICIPATION', 'AS_IS', 'CLIENT_DOCUMENT', 'OUR_ERROR', 'SERVICE_ERROR']; + +const CAUSE_LABEL: Record = { + WITHOUT_PARTICIPATION: 'Без участия аналитика (автоподтверждён)', + AS_IS: 'Подтверждён как есть', + CLIENT_DOCUMENT: 'Ошибка в документе клиента', + OUR_ERROR: 'Наша ошибка разбора', + SERVICE_ERROR: 'Ошибка сервиса распознавания', +}; + +export const causeLabel = (cause: QualityCause): string => CAUSE_LABEL[cause]; + +/** Доля 0..1 → «12,3%»; нет знаменателя — «нет данных», а не «0%». */ +export function formatShare(share: number, denominator: number): string { + if (denominator <= 0) return 'нет данных'; + return `${(share * 100).toFixed(1).replace('.', ',')}%`; +} + +export interface MatrixRow { + key: QualityCell; + label: string; + count: number; + /** Подпись доли; null — строка в доли не входит (удалённые, заменённые, «в работе»). */ + share: string | null; + /** Пояснение под названием (разбивка «удалён / заменён»). */ + note?: string; +} + +/** Строки матрицы: пять причин (с долями от подтверждённых) и три служебные строки без долей. */ +export function buildMatrixRows(data: QualityMatrixOut): MatrixRow[] { + const causeRows: MatrixRow[] = CAUSE_ORDER.map((cause) => ({ + key: cause, + label: causeLabel(cause), + count: data.counts[cause] ?? 0, + share: formatShare(data.shares[cause] ?? 0, data.denominator), + })); + return [ + ...causeRows, + { key: 'REMOVED_BEFORE_CONFIRM', label: 'Удалены или заменены до подтверждения', count: data.removed_before_confirm, share: null }, + { + key: 'REMOVED_AFTER_CONFIRM', + label: 'Удалены или заменены после подтверждения', + count: data.removed_after_confirm, + share: null, + note: `удалено: ${data.deleted_after_confirm_count}, заменено: ${data.superseded_after_confirm_count}`, + }, + { key: 'IN_PROGRESS', label: 'В работе (исхода ещё нет)', count: data.in_progress_count, share: null }, + ]; +} + +/** Месяц `2026-03-01` → «03.2026». */ +export function monthLabel(iso: string): string { + const [year, month] = iso.split('-'); + return month && year ? `${month}.${year}` : iso; +} diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index db12ff7..85f21cf 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -1,9 +1,10 @@ import { LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'; import { Button, Divider, Layout, Tooltip, message } from 'antd'; -import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react'; +import { Archive, Building2, ClipboardCheck, ScrollText, UserCog2, Users2 } from 'lucide-react'; import { type ReactNode, memo, useState } from 'react'; import { NavLink, useNavigate } from 'react-router-dom'; -import { USERS_ADMIN } from '../../auth/permissions'; +import { useCurrentUser } from '../../auth/MeContext'; +import { QUALITY_METRICS_VIEW, USERS_ADMIN } from '../../auth/permissions'; import { createConsolidation } from '../../services/api/consolidations'; import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks'; import { useAppDispatch } from '../../store/store'; @@ -11,6 +12,7 @@ import { quarterLabel } from '../../utils/labels'; import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal'; import { UserMenu } from '../UserMenu/UserMenu'; import styles from './styles.module.css'; +import { filterByPermission } from './visibility'; type CollapsedIconProps = { icon: ReactNode; @@ -104,6 +106,12 @@ const config: Group[] = [ icon: , label: 'Журнал действий', }, + { + link: '/quality-metrics', + icon: , + label: 'Качество первички', + permission: QUALITY_METRICS_VIEW, + }, { link: '/admin', icon: , @@ -127,11 +135,14 @@ function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) { ); } -const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) { +const Section = memo(function Section({ isCollapsed, group, permissions }: { isCollapsed: boolean; group: Group; permissions: string[] }) { + // пункты без нужного права не показываем; пустую секцию не рисуем совсем + const children = filterByPermission(group.children, permissions); + if (children.length === 0) return null; return !isCollapsed ? (
{group.title}
- {group.children.map((i) => ( + {children.map((i) => ( {i.icon} {i.label} @@ -141,7 +152,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo
) : ( <> - {group.children.map((i) => ( + {children.map((i) => ( ))} @@ -149,6 +160,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo }); export function Sidebar() { + const me = useCurrentUser(); const navigate = useNavigate(); const dispatch = useAppDispatch(); const [modalOpen, setModalOpen] = useState(false); @@ -194,7 +206,7 @@ export function Sidebar() { )} {config.map((g) => ( -
+
))} (items: readonly T[], permissions: readonly string[]): T[] { + return items.filter((item) => !item.permission || permissions.includes(item.permission)); +} -- 2.47.2 From 7ded34fec518eb745ae8438c6efb0367b2bd6228 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 02:56:27 +0400 Subject: [PATCH 33/44] =?UTF-8?q?fix(#190):=20=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Task=2016?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- web/CLAUDE.md | 2 +- web/src/api/client.ts | 2 +- web/src/services/api/qualityMetrics.test.ts | 10 +- web/src/services/api/qualityMetrics.ts | 15 +-- .../QualityMetrics/DocumentDrillList.tsx | 32 ++++++- .../widgets/QualityMetrics/MatrixTable.tsx | 96 +++++++++++-------- .../widgets/QualityMetrics/MonthlyTrend.tsx | 1 + .../QualityMetrics/QualityMetricsPage.tsx | 74 +++++++++++--- .../QualityMetrics/qualityView.test.ts | 54 ++++++++++- web/src/widgets/QualityMetrics/qualityView.ts | 82 +++++++++++++++- 10 files changed, 288 insertions(+), 80 deletions(-) diff --git a/web/CLAUDE.md b/web/CLAUDE.md index 8309ab3..217c090 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -17,7 +17,7 @@ arm-web перенесены в arm-core #150–#157 (arm-web#7 → #151), сп повторяют схемы бэка (`../api/src/schemas/`), поля не придумываем. 2. Пользовательский путь — `../../docs/context/10-user-journey.md`; макет — `../../docs/mockup/` (по одному экрану через `../../docs/mockup/INDEX.md`). -3. Права — данные: доступ решает сервер (403). Меню пока не скрывается по правам (`Sidebar` не применяет permission); +3. Права — данные: доступ решает сервер (403). Пункты меню с `permission` скрываются без права (`widgets/Sidebar/visibility.ts`); коды прав из `/me` лежат в `src/auth/permissions.ts`. - Требования к экранам: действующие — `../openspec/specs/*-ui/spec.md` (перенесены из arm-web@de4e477), новые и изменённые — `../openspec/changes/` этого репозитория. diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f4d6c15..c13ec2c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -28,7 +28,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function buildQuery(params?: object): string { +export function buildQuery(params?: object): string { if (!params) return ''; const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { diff --git a/web/src/services/api/qualityMetrics.test.ts b/web/src/services/api/qualityMetrics.test.ts index 361f439..034a8ca 100644 --- a/web/src/services/api/qualityMetrics.test.ts +++ b/web/src/services/api/qualityMetrics.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { queryString } from './qualityMetrics'; +import { buildQuery } from '../../api/client'; -describe('queryString', () => { - it('пропускает пустые поля, булево пишет как true/false', () => { - expect(queryString({ date_from: '2026-01-01', pipeline: undefined, is_scan: false, format: 'csv' })).toBe( +describe('buildQuery', () => { + it('пропускает неуказанные поля, булево пишет как true/false', () => { + expect(buildQuery({ date_from: '2026-01-01', pipeline: undefined, is_scan: false, format: 'csv' })).toBe( '?date_from=2026-01-01&is_scan=false&format=csv', ); }); it('без параметров — пустая строка', () => { - expect(queryString({})).toBe(''); + expect(buildQuery({})).toBe(''); }); }); diff --git a/web/src/services/api/qualityMetrics.ts b/web/src/services/api/qualityMetrics.ts index f9d2f37..033b456 100644 --- a/web/src/services/api/qualityMetrics.ts +++ b/web/src/services/api/qualityMetrics.ts @@ -1,4 +1,4 @@ -import { api, apiFetchBlob } from '../../api/client'; +import { api, apiFetchBlob, buildQuery } from '../../api/client'; import type { Page } from '../../api/types'; import type { ExportFormat, @@ -30,16 +30,5 @@ export function getCellDocuments( /** Выгрузка фактов за период; скачивание идёт через fetch, потому что нужен заголовок Authorization. */ export function exportFacts(filters: QualityFilters, format: ExportFormat): Promise<{ blob: Blob; filename: string | null }> { - return apiFetchBlob(`${BASE}/export${queryString({ ...filters, format })}`); -} - -/** Строка запроса для выгрузки (пустые поля пропускаются) — вынесено отдельно ради теста. */ -export function queryString(params: object): string { - const search = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value === undefined || value === null || value === '') continue; - search.append(key, String(value)); - } - const qs = search.toString(); - return qs ? `?${qs}` : ''; + return apiFetchBlob(`${BASE}/export${buildQuery({ ...filters, format })}`); } diff --git a/web/src/widgets/QualityMetrics/DocumentDrillList.tsx b/web/src/widgets/QualityMetrics/DocumentDrillList.tsx index dc7676e..ac13de1 100644 --- a/web/src/widgets/QualityMetrics/DocumentDrillList.tsx +++ b/web/src/widgets/QualityMetrics/DocumentDrillList.tsx @@ -2,8 +2,17 @@ import { Alert, Table } from 'antd'; import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { getCellDocuments } from '../../services/api/qualityMetrics'; -import type { QualityCell, QualityCellDocumentOut, QualityFilters } from '../../types/qualityMetrics'; +import type { + QualityCause, + QualityCell, + QualityCellDocumentOut, + QualityFilters, + QualityFixesOut, + QualityOutcome, +} from '../../types/qualityMetrics'; +import { formatMoscowDate } from '../../utils/dates'; import { documentTypeLabel } from '../../utils/labels'; +import { causeLabel, formatReportDate, outcomeLabel, sumFixes } from './qualityView'; const PAGE_SIZE = 20; @@ -44,6 +53,7 @@ export function DocumentDrillList({ filters, cell }: { filters: QualityFilters; <> {error && } + scroll={{ x: 'max-content' }} rowKey='document_id' size='small' loading={loading} @@ -62,8 +72,24 @@ export function DocumentDrillList({ filters, cell }: { filters: QualityFilters; }, { title: 'Юрлицо', dataIndex: 'legal_entity_name' }, { title: 'ГК', dataIndex: 'group_name', render: (g: string | null) => g ?? '—' }, - { title: 'Отчётная дата', dataIndex: 'report_date', render: (v: string | null) => v ?? '—' }, - { title: 'Принят', dataIndex: 'received_at', render: (v: string) => v.slice(0, 10) }, + { title: 'Отчётная дата', dataIndex: 'report_date', render: (v: string | null) => formatReportDate(v) }, + { title: 'Принят', dataIndex: 'received_at', render: (v: string) => formatMoscowDate(v) ?? '—' }, + { title: 'Исход', dataIndex: 'outcome', render: (v: QualityOutcome | null) => outcomeLabel(v) }, + { + title: 'Прежний исход', + dataIndex: 'previous_outcome', + // прежний исход осмыслен только для ячейки «после подтверждения» + render: (v: QualityOutcome | null) => (cell === 'REMOVED_AFTER_CONFIRM' ? outcomeLabel(v) : '—'), + }, + { title: 'Причина', dataIndex: 'primary_cause', render: (v: QualityCause | null) => (v ? causeLabel(v) : '—') }, + { + title: 'Правки: сервис / наш разбор', + dataIndex: 'fixes', + render: (f: QualityFixesOut | null) => { + const sum = sumFixes(f); + return sum ? `${sum.service} / ${sum.ours}` : '—'; + }, + }, { title: 'Подтвердил', dataIndex: 'confirmed_by_name', render: (v: string | null) => v ?? '—' }, ]} /> diff --git a/web/src/widgets/QualityMetrics/MatrixTable.tsx b/web/src/widgets/QualityMetrics/MatrixTable.tsx index 9ab35c3..d8dcbfb 100644 --- a/web/src/widgets/QualityMetrics/MatrixTable.tsx +++ b/web/src/widgets/QualityMetrics/MatrixTable.tsx @@ -1,48 +1,64 @@ import { Button, Table } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import type { QualityCell, QualityMatrixOut } from '../../types/qualityMetrics'; -import { type MatrixRow, buildMatrixRows } from './qualityView'; +import type { QualityCell, QualityMatrixOut, RecognitionPipeline } from '../../types/qualityMetrics'; +import { type SourceColumn, type SourceRow, buildSourceRows } from './qualityView'; + +export interface MatrixColumnData { + column: SourceColumn; + data: QualityMatrixOut; +} interface Props { - data: QualityMatrixOut; - /** Клик по счётчику ячейки — открыть список документов. */ - onOpenCell?: (cell: QualityCell, label: string) => void; + /** По одной матрице на колонку источника («PDF», «Excel», «Всего») или одна, если источник выбран в фильтре. */ + columns: MatrixColumnData[]; + /** Клик по счётчику ячейки — открыть список документов (с источником колонки). */ + onOpenCell?: (cell: QualityCell, label: string, pipeline: RecognitionPipeline | undefined) => void; } -/** Матрица причин: строка — причина или служебная группа, счётчик раскрывается в список документов. */ -export function MatrixTable({ data, onOpenCell }: Props) { - const columns: ColumnsType = [ - { - title: 'Причина', - dataIndex: 'label', - render: (label: string, row) => ( -
-
{label}
- {row.note &&
{row.note}
} -
- ), - }, - { - title: 'Документов', - dataIndex: 'count', - align: 'right', - width: 140, - render: (count: number, row) => - onOpenCell && count > 0 ? ( - - ) : ( - count - ), - }, - { - title: 'Доля от подтверждённых', - dataIndex: 'share', - align: 'right', - width: 200, - render: (share: string | null) => share ?? '—', - }, +/** Матрица «причина × источник»: в каждой колонке счётчик (раскрывается в список документов) и доля. */ +export function MatrixTable({ columns, onOpenCell }: Props) { + const rows = buildSourceRows(columns.map((c) => ({ key: c.column.key, data: c.data }))); + + const tableColumns: ColumnsType = [ + { title: 'Причина', dataIndex: 'label' }, + ...columns.map(({ column }) => ({ + title: column.label, + align: 'right' as const, + width: 220, + children: [ + { + title: 'Документов', + key: `${column.key}-count`, + align: 'right' as const, + render: (_: unknown, row: SourceRow) => { + const cell = row.cells[column.key]; + if (!cell) return '—'; + return ( +
+ {onOpenCell && cell.count > 0 ? ( + + ) : ( + cell.count + )} + {cell.note &&
{cell.note}
} +
+ ); + }, + }, + { + title: 'Доля', + key: `${column.key}-share`, + align: 'right' as const, + render: (_: unknown, row: SourceRow) => row.cells[column.key]?.share ?? '—', + }, + ], + })), ]; - return rowKey='key' columns={columns} dataSource={buildMatrixRows(data)} pagination={false} size='small' />; + return rowKey='key' columns={tableColumns} dataSource={rows} pagination={false} size='small' bordered />; } diff --git a/web/src/widgets/QualityMetrics/MonthlyTrend.tsx b/web/src/widgets/QualityMetrics/MonthlyTrend.tsx index 3492d22..16e0325 100644 --- a/web/src/widgets/QualityMetrics/MonthlyTrend.tsx +++ b/web/src/widgets/QualityMetrics/MonthlyTrend.tsx @@ -33,6 +33,7 @@ export function MonthlyTrend({ filters }: { filters: QualityFilters }) { return ( + aria-label='Причина' value={cause} onChange={setCause} style={{ width: 360 }} diff --git a/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx index 72619b2..e8b6e05 100644 --- a/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx +++ b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx @@ -1,15 +1,17 @@ import { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd'; import type { Dayjs } from 'dayjs'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ApiError } from '../../api/client'; import { exportFacts, getMatrix } from '../../services/api/qualityMetrics'; +import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks'; +import { useAppDispatch, useAppSelector } from '../../store/store'; import type { DocumentType } from '../../types/api'; -import type { ExportFormat, QualityCell, QualityFilters, QualityMatrixOut, RecognitionPipeline } from '../../types/qualityMetrics'; +import type { ExportFormat, QualityCell, QualityFilters, RecognitionPipeline } from '../../types/qualityMetrics'; import { saveBlob } from '../../utils/download'; import { DocumentDrillList } from './DocumentDrillList'; -import { MatrixTable } from './MatrixTable'; +import { type MatrixColumnData, MatrixTable } from './MatrixTable'; import { MonthlyTrend } from './MonthlyTrend'; -import { formatShare } from './qualityView'; +import { formatShare, sourceColumns } from './qualityView'; const DOC_TYPES: { value: DocumentType; label: string }[] = [ { value: 'BALANCE', label: 'Бухгалтерский баланс' }, @@ -32,22 +34,42 @@ const iso = (d: Dayjs): string => d.format('YYYY-MM-DD'); /** Экран «Качество первички»: фильтры, матрица причин, технические отказы, динамика по месяцам, выгрузка. */ export function QualityMetricsPage() { const [filters, setFilters] = useState({}); - const [matrix, setMatrix] = useState(null); + // матрицы по колонкам источника; «Всего» (или единственная) — последняя, из неё берём итоги над таблицей + const [matrices, setMatrices] = useState([]); + const [exporting, setExporting] = useState(null); + const dispatch = useAppDispatch(); + const groups = useAppSelector((state) => state.company_groups.raw_rows); + const groupsLoading = useAppSelector((state) => state.company_groups.loading); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [drill, setDrill] = useState<{ cell: QualityCell; label: string } | null>(null); + const [drill, setDrill] = useState<{ cell: QualityCell; label: string; pipeline: RecognitionPipeline | undefined } | null>(null); + // фильтры списка документов: общие + источник колонки, по которой кликнули + const drillFilters = useMemo(() => ({ ...filters, pipeline: drill?.pipeline ?? filters.pipeline }), [filters, drill]); + // biome-ignore lint/nursery/useAtIndex: в lib проекта (tsconfig) нет Array.prototype.at + const matrix = matrices[matrices.length - 1]?.data ?? null; + + // список ГК для фильтра: загружаем, если ещё не загружен + useEffect(() => { + if (groups.length === 0 && !groupsLoading) dispatch(getCompanyGroupsThunk()); + }, [groups.length, groupsLoading, dispatch]); useEffect(() => { let cancelled = false; setLoading(true); setError(null); - getMatrix(filters) + // источник не выбран — три запроса: PDF, Excel и без фильтра («Всего»); выбран — один + Promise.all( + sourceColumns(filters.pipeline).map(async (column) => ({ + column, + data: await getMatrix(column.pipeline ? { ...filters, pipeline: column.pipeline } : { ...filters, pipeline: undefined }), + })), + ) .then((r) => { - if (!cancelled) setMatrix(r); + if (!cancelled) setMatrices(r); }) .catch((e: unknown) => { if (cancelled) return; - setMatrix(null); + setMatrices([]); setError(e instanceof Error ? e : new Error('Не удалось загрузить метрики')); }) .finally(() => { @@ -61,11 +83,15 @@ export function QualityMetricsPage() { const patch = (changes: Partial) => setFilters((f) => ({ ...f, ...changes })); const download = async (format: ExportFormat) => { + if (exporting) return; + setExporting(format); try { const { blob, filename } = await exportFacts(filters, format); saveBlob(blob, filename ?? `quality-facts.${format}`); } catch (e: unknown) { message.error(e instanceof Error ? e.message : 'Не удалось выгрузить данные'); + } finally { + setExporting(null); } }; @@ -84,8 +110,20 @@ export function QualityMetricsPage() { patch({ date_from: range?.[0] ? iso(range[0]) : undefined, date_to: range?.[1] ? iso(range[1]) : undefined }) } /> + + allowClear + showSearch + aria-label='Группа компаний' + placeholder='Группа компаний' + style={{ width: 240 }} + loading={groupsLoading} + optionFilterProp='label' + options={groups.map((g) => ({ value: g.id, label: g.name }))} + onChange={(v) => patch({ group_id: v })} + /> allowClear + aria-label='Тип документа' placeholder='Тип документа' style={{ width: 200 }} options={DOC_TYPES} @@ -93,6 +131,7 @@ export function QualityMetricsPage() { /> allowClear + aria-label='Источник' placeholder='Источник' style={{ width: 200 }} options={PIPELINES} @@ -100,13 +139,18 @@ export function QualityMetricsPage() { /> allowClear + aria-label='Скан или цифровой PDF' placeholder='Скан / цифровой' style={{ width: 170 }} options={SCAN_OPTIONS} onChange={(v) => patch({ is_scan: v === undefined ? undefined : v === 'true' })} /> - - + + {error && } @@ -119,7 +163,7 @@ export function QualityMetricsPage() { {matrix.denominator} {matrix.unique_reports_count} - + {matrix.technical_failures.failed_files_count} из {matrix.technical_failures.total_files_count} ( {formatShare(matrix.technical_failures.share, matrix.technical_failures.total_files_count)}) @@ -129,7 +173,7 @@ export function QualityMetricsPage() { { key: 'matrix', label: 'Причины', - children: setDrill({ cell, label })} />, + children: setDrill({ cell, label, pipeline })} />, }, { key: 'trend', label: 'Динамика по месяцам', children: }, ]} @@ -138,8 +182,8 @@ export function QualityMetricsPage() { )} - setDrill(null)} destroyOnClose> - {drill && } + setDrill(null)} destroyOnHidden> + {drill && } ); diff --git a/web/src/widgets/QualityMetrics/qualityView.test.ts b/web/src/widgets/QualityMetrics/qualityView.test.ts index c7f8cc4..7c9c2d3 100644 --- a/web/src/widgets/QualityMetrics/qualityView.test.ts +++ b/web/src/widgets/QualityMetrics/qualityView.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { QualityMatrixOut } from '../../types/qualityMetrics'; import { filterByPermission } from '../Sidebar/visibility'; -import { buildMatrixRows, formatShare, monthLabel } from './qualityView'; +import { buildMatrixRows, buildSourceRows, formatReportDate, formatShare, monthLabel, sourceColumns, sumFixes } from './qualityView'; const matrixFixture: QualityMatrixOut = { counts: { SERVICE_ERROR: 3, OUR_ERROR: 2, CLIENT_DOCUMENT: 1, WITHOUT_PARTICIPATION: 12, AS_IS: 5 }, @@ -51,3 +51,55 @@ describe('monthLabel', () => { expect(monthLabel('2026-03-01')).toBe('03.2026'); }); }); + +describe('sourceColumns', () => { + it('источник не выбран — PDF, Excel, Всего', () => { + expect(sourceColumns(undefined).map((c) => c.key)).toEqual(['image', 'tabular', 'all']); + }); + it('выбран источник — одна колонка', () => { + expect(sourceColumns('tabular').map((c) => c.label)).toEqual(['Excel']); + }); +}); + +describe('buildSourceRows', () => { + it('счётчики по колонкам источника', () => { + const excel = { ...matrixFixture, counts: { ...matrixFixture.counts, AS_IS: 1 } }; + const rows = buildSourceRows([ + { key: 'image', data: matrixFixture }, + { key: 'tabular', data: excel }, + ]); + const asIs = rows.find((r) => r.key === 'AS_IS'); + expect(asIs?.cells.image?.count).toBe(5); + expect(asIs?.cells.tabular?.count).toBe(1); + }); + it('пустой список колонок — нет строк', () => { + expect(buildSourceRows([])).toEqual([]); + }); +}); + +describe('sumFixes', () => { + it('суммирует по сервису и по нашему разбору, правку ОСВ не считает', () => { + const fixes = { + value_service: 1, + value_ours: 2, + code_service: 3, + code_ours: 4, + added_row_service: 5, + added_row_ours: 6, + header_service: 7, + header_ours: 8, + osv_period_account: 9, + }; + expect(sumFixes(fixes)).toEqual({ service: 16, ours: 20 }); + }); + it('нет правок — null', () => { + expect(sumFixes(null)).toBeNull(); + }); +}); + +describe('formatReportDate', () => { + it('ДД.ММ.ГГГГ', () => { + expect(formatReportDate('2026-03-31')).toBe('31.03.2026'); + expect(formatReportDate(null)).toBe('—'); + }); +}); diff --git a/web/src/widgets/QualityMetrics/qualityView.ts b/web/src/widgets/QualityMetrics/qualityView.ts index 73feae8..d480d30 100644 --- a/web/src/widgets/QualityMetrics/qualityView.ts +++ b/web/src/widgets/QualityMetrics/qualityView.ts @@ -1,6 +1,13 @@ // Чистые функции экрана «Качество первички»: подписи и строки матрицы. Отдельно от React — чтобы тестировать. -import type { QualityCause, QualityCell, QualityMatrixOut } from '../../types/qualityMetrics'; +import type { + QualityCause, + QualityCell, + QualityFixesOut, + QualityMatrixOut, + QualityOutcome, + RecognitionPipeline, +} from '../../types/qualityMetrics'; export const CAUSE_ORDER: readonly QualityCause[] = ['WITHOUT_PARTICIPATION', 'AS_IS', 'CLIENT_DOCUMENT', 'OUR_ERROR', 'SERVICE_ERROR']; @@ -57,3 +64,76 @@ export function monthLabel(iso: string): string { const [year, month] = iso.split('-'); return month && year ? `${month}.${year}` : iso; } + +const OUTCOME_LABEL: Record = { + AUTO_CONFIRMED: 'Автоподтверждён', + CONFIRMED: 'Подтверждён аналитиком', + DELETED: 'Удалён до подтверждения', + SUPERSEDED: 'Заменён до подтверждения', + DELETED_AFTER_CONFIRM: 'Удалён после подтверждения', + SUPERSEDED_AFTER_CONFIRM: 'Заменён после подтверждения', +}; + +export const outcomeLabel = (outcome: QualityOutcome | null): string => (outcome ? OUTCOME_LABEL[outcome] : '—'); + +/** Сумма правок по источнику: сколько исправил сервис распознавания и сколько — наш разбор (без правки периода/счёта ОСВ). */ +export function sumFixes(fixes: QualityFixesOut | null): { service: number; ours: number } | null { + if (!fixes) return null; + return { + service: fixes.value_service + fixes.code_service + fixes.added_row_service + fixes.header_service, + ours: fixes.value_ours + fixes.code_ours + fixes.added_row_ours + fixes.header_ours, + }; +} + +/** Отчётная дата `2026-03-31` → «31.03.2026» (без часовых поясов: это календарная дата, не момент времени). */ +export function formatReportDate(iso: string | null): string { + if (!iso) return '—'; + const [year, month, day] = iso.split('-'); + return year && month && day ? `${day}.${month}.${year}` : iso; +} + +/** Колонка матрицы: источник. `pipeline` не задан — «Всего» (запрос без фильтра по источнику). */ +export interface SourceColumn { + key: 'image' | 'tabular' | 'all'; + label: string; + pipeline?: RecognitionPipeline; +} + +const COL_IMAGE: SourceColumn = { key: 'image', label: 'PDF', pipeline: 'image' }; +const COL_TABULAR: SourceColumn = { key: 'tabular', label: 'Excel', pipeline: 'tabular' }; +const COL_ALL: SourceColumn = { key: 'all', label: 'Всего' }; + +/** Колонки матрицы: источник не выбран — «PDF | Excel | Всего», выбран — одна колонка этого источника. */ +export function sourceColumns(selected: RecognitionPipeline | undefined): SourceColumn[] { + if (selected === 'image') return [COL_IMAGE]; + if (selected === 'tabular') return [COL_TABULAR]; + return [COL_IMAGE, COL_TABULAR, COL_ALL]; +} + +export interface SourceCell { + count: number; + share: string | null; + note?: string; +} + +export interface SourceRow { + key: QualityCell; + label: string; + /** Ячейки по ключам колонок (`SourceColumn.key`). */ + cells: Record; +} + +/** Строки матрицы «причина × источник»: подписи берём из первой матрицы, счётчики — по каждой колонке. */ +export function buildSourceRows(columns: { key: string; data: QualityMatrixOut }[]): SourceRow[] { + const first = columns[0]; + if (!first) return []; + const perColumn = columns.map((c) => ({ key: c.key, rows: buildMatrixRows(c.data) })); + return buildMatrixRows(first.data).map((row, i) => { + const cells: Record = {}; + for (const c of perColumn) { + const r = c.rows[i]; + if (r) cells[c.key] = { count: r.count, share: r.share, note: r.note }; + } + return { key: row.key, label: row.label, cells }; + }); +} -- 2.47.2 From 25c568b5beb651a1aff150d3f984b88e5c0448d9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 02:57:03 +0400 Subject: [PATCH 34/44] =?UTF-8?q?docs(#190):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=2016=20(=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0)=20=D0=B2=20design.m?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- openspec/changes/quality-metrics/design.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 1123f5b..93eea5c 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -242,3 +242,9 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | Task 15: технические отказы — поле `technical_failures {failed_files_count, total_files_count, share}`, период по `failed_at`, `total_files_count` — уникальные `file_id` из приёма и отказов; «в работе» (`in_progress_count`) и `unique_reports_count` (ЮЛ + отчётная дата + тип) — отдельные поля; в ответе отдельно `superseded_after_confirm_count`, `deleted_after_confirm_count` | design §6, решение 27.10 про отказы по файлам | доля отказов считается от другого знаменателя, чем доли причин | | 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` не пишется (нет в `audit_action`) | design §7/§8; ячейки шире причин | выгрузка не оставляет следа в аудите; Excel может показать BOM в чужих импортёрах | | 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY | +| 07.11.2026 | Task 16 (R-10): на экране нет фильтра по ЮЛ (есть по ГК, периоду, типу, источнику, скану); API параметр `legal_entity_id` поддерживает | нужен отдельный пикер ЮЛ (тысячи записей); владелец: «главное наличие данных» | в UI нельзя посмотреть качество по одному ЮЛ — только через API/выгрузку | +| 07.11.2026 | Task 16 (R-11): матрица «источник × причина» — три параллельных запроса (`pipeline=image`, `pipeline=tabular`, без `pipeline`), колонки «PDF / Excel / Всего»; при выбранном источнике — одна колонка; «Всего» берётся с бэка, не суммой колонок | design §2/§7 требуют сравнения PDF и Excel; бэкенд отдаёт источник только фильтром | три запроса вместо одного на каждую смену фильтров | +| 07.11.2026 | Task 16: «Правки: сервис / наш разбор» в списке документов — суммы полей `*_service` и `*_ours`, `osv_period_account` не входит | в задаче названы только эти два семейства | правки периода и счёта ОСВ в колонке не видны | +| 07.11.2026 | Task 16: `Sidebar` снова фильтрует пункты по `permission` (поле было в типе, но не применялось после слияния репозиториев); пункт «Пользователи и роли» скрыт без `USERS_ADMIN`; `web/CLAUDE.md` исправлен | это восстановление прежнего поведения (коммит d3c2f8a); сервер и раньше отдавал 403 на эти страницы | у ролей без `USERS_ADMIN` пункт меню пропадает (раньше вёл на экран 403) | +| 07.11.2026 | Task 16: тесты фронта — на чистые функции (`qualityView.ts`), не на рендер: в проекте нет jsdom/testing-library; экран в браузере не проверялся (нет стенда); экспорт — через `apiFetchBlob` (токен), `getExportUrl` из брифа не создан | нет инфраструктуры рендер-тестов; обычная ссылка не передаёт токен | раскладка, Drawer и скачивание проверены только чтением кода — обязательна ручная проверка на стенде до merge | +| 07.11.2026 | Выкладка: пункт меню «Качество первички» виден только после `make seed` на стенде (seed добавляет пары только для впервые созданного права) | поведение seed | без seed меню скрыто у всех, API отвечает 403 | -- 2.47.2 From 85c5ab85960fafc87c7e37bea81ad05dc123ab16 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 03:40:43 +0400 Subject: [PATCH 35/44] =?UTF-8?q?fix(#190):=20C-1=20query-=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B8=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA?= =?UTF-8?q?,=20M-1=20=D0=B1=D0=B5=D0=B7=20payload=20=D0=B2=20DISTINCT=20ON?= =?UTF-8?q?,=20M-2=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20EXPORT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - series/cells/export: одна query-модель на маршрут (QualitySeriesParams, QualityCellParams, QualityExportParams), иначе FastAPI требовал параметр `filters` и отвечал 422 - _latest_outcomes не тянет payload jsonb - выгрузка пишет audit_event(EXPORT), тест; design.md: R-12 и исправлено обоснование - docstring задачи 10: sites 1-3 Co-Authored-By: Claude Opus 5.5 --- api/src/api/endpoints/quality_metrics.py | 25 ++++++++-------- api/src/schemas/quality_metrics.py | 28 ++++++++++++++++- api/src/services/quality_metrics_service.py | 25 +++++++++++++--- api/src/services/result_mapping.py | 2 +- api/tests/test_api_quality_metrics.py | 28 ++++++++++++++++- api/tests/test_result_mapping.py | 2 +- openspec/changes/quality-metrics/design.md | 3 +- .../QualityMetrics/QualityMetricsPage.tsx | 11 +++---- .../QualityMetrics/qualityView.test.ts | 30 +++++++++++++++++-- web/src/widgets/QualityMetrics/qualityView.ts | 7 +++++ 10 files changed, 132 insertions(+), 29 deletions(-) diff --git a/api/src/api/endpoints/quality_metrics.py b/api/src/api/endpoints/quality_metrics.py index b3a5fd9..fe3fd50 100644 --- a/api/src/api/endpoints/quality_metrics.py +++ b/api/src/api/endpoints/quality_metrics.py @@ -6,18 +6,20 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query, Response +from src.api.dependencies.audit import RequestAudit, get_request_audit from src.api.dependencies.auth import require from src.api.dependencies.quality_metrics import QualityMetricsServiceDep from src.common.filenames import content_disposition -from src.models.enums import QualityCause -from src.schemas.common import Page, PageParams +from src.schemas.common import Page from src.schemas.quality_metrics import ( - ExportFormat, QualityCell, QualityCellDocumentOut, + QualityCellParams, + QualityExportParams, QualityFilters, QualityMatrixOut, QualityMonthlySeriesOut, + QualitySeriesParams, ) router = APIRouter( @@ -36,30 +38,27 @@ async def get_matrix( @router.get("/series", summary="Ряд по месяцам даты приёма для причины") async def get_series( - service: QualityMetricsServiceDep, - filters: Annotated[QualityFilters, Query()], - cause: Annotated[QualityCause, Query()], + service: QualityMetricsServiceDep, params: Annotated[QualitySeriesParams, Query()] ) -> QualityMonthlySeriesOut: - return await service.series(filters, cause) + return await service.series(params, params.cause) @router.get("/cells/{cell}/documents", summary="Документы ячейки матрицы") async def get_cell_documents( cell: QualityCell, service: QualityMetricsServiceDep, - filters: Annotated[QualityFilters, Query()], - page: Annotated[PageParams, Query()], + params: Annotated[QualityCellParams, Query()], ) -> Page[QualityCellDocumentOut]: """Ячейка — причина (`QualityCause`), `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM` или `IN_PROGRESS`.""" - return await service.cell_documents(filters, cell, limit=page.limit, offset=page.offset) + return await service.cell_documents(params, cell, limit=params.limit, offset=params.offset) @router.get("/export", response_class=Response, summary="Выгрузка фактов за период (CSV / JSON)") async def export_facts( service: QualityMetricsServiceDep, - filters: Annotated[QualityFilters, Query()], - fmt: Annotated[ExportFormat, Query(alias="format")] = "csv", + params: Annotated[QualityExportParams, Query()], + audit: Annotated[RequestAudit, Depends(get_request_audit)], ) -> Response: - content, media_type, ext = await service.export(filters, fmt) + content, media_type, ext = await service.export(params, params.fmt, audit) header = content_disposition(f"quality-facts.{ext}", ascii_fallback=f"quality-facts.{ext}") return Response(content=content, media_type=media_type, headers={"Content-Disposition": header}) diff --git a/api/src/schemas/quality_metrics.py b/api/src/schemas/quality_metrics.py index 57a9f41..80fee88 100644 --- a/api/src/schemas/quality_metrics.py +++ b/api/src/schemas/quality_metrics.py @@ -6,19 +6,23 @@ import enum import uuid from typing import Literal -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from src.models.enums import DocumentType, QualityCause, QualityOutcome, RecognitionPipeline +from src.schemas.common import DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT __all__ = [ "ExportFormat", "MonthPointOut", "QualityCell", "QualityCellDocumentOut", + "QualityCellParams", + "QualityExportParams", "QualityFilters", "QualityFixesOut", "QualityMatrixOut", "QualityMonthlySeriesOut", + "QualitySeriesParams", "TechnicalFailuresOut", ] @@ -58,6 +62,28 @@ class QualityFilters(BaseModel): return self +class QualitySeriesParams(QualityFilters): + """Query `/series`: фильтры + причина. FastAPI разворачивает `Annotated[Model, Query()]` в параметры, только если + это единственный query-параметр маршрута, поэтому дополнительные поля — в наследнике, а не рядом.""" + + cause: QualityCause + + +class QualityCellParams(QualityFilters): + """Query `/cells/{cell}/documents`: фильтры + пагинация (ограничения как у `PageParams`).""" + + limit: int = Field(DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT) + offset: int = Field(0, ge=0) + + +class QualityExportParams(QualityFilters): + """Query `/export`: фильтры + формат (`?format=csv|json`).""" + + model_config = ConfigDict(populate_by_name=True) + + fmt: ExportFormat = Field("csv", alias="format") + + class TechnicalFailuresOut(BaseModel): """Технические отказы считаются по файлам (design §6); фильтр по типу документа на них не действует. `total_files_count` — уникальные файлы с фактом приёма или отказа в выбранных период/ГК/ЮЛ/источнике/скане.""" diff --git a/api/src/services/quality_metrics_service.py b/api/src/services/quality_metrics_service.py index 34a3db9..1d35bbc 100644 --- a/api/src/services/quality_metrics_service.py +++ b/api/src/services/quality_metrics_service.py @@ -22,13 +22,14 @@ from fastapi.encoders import jsonable_encoder from sqlalchemy import Select, case, distinct, func, select, union from sqlalchemy.ext.asyncio import AsyncSession +from src.api.dependencies.audit import RequestAudit from src.domain.quality_metrics import ( QualityOutcomeFactRow, compute_matrix, compute_monthly_series, ) from src.models.access import AppUser -from src.models.enums import QualityCause, QualityOutcome +from src.models.enums import AuditAction, AuditSection, QualityCause, QualityOutcome from src.models.files import Document from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact from src.schemas.common import Page @@ -50,6 +51,8 @@ _IN_DENOMINATOR = (QualityOutcome.AUTO_CONFIRMED, QualityOutcome.CONFIRMED) _BEFORE_CONFIRM = (QualityOutcome.DELETED, QualityOutcome.SUPERSEDED) _AFTER_CONFIRM = (QualityOutcome.DELETED_AFTER_CONFIRM, QualityOutcome.SUPERSEDED_AFTER_CONFIRM) +_HEAVY_OUTCOME_COLUMNS = ("payload", "payload_schema_version") # в DISTINCT ON не тянем: метрикам не нужны + EXPORT_COLUMNS = ( "document_id", "file_id", "legal_entity_id", "legal_entity_name", "group_id", "group_name", "document_type", "pipeline", "is_scan", "service_name", "service_version", "intake_at", "intake_status", "report_date_recognized", @@ -67,8 +70,9 @@ def _latest_outcomes(): # noqa: ANN202 - Subquery """Последний факт исхода на `document_id` (см. модульную строку).""" outcome = QualityOutcomeFact.outcome rank = case((outcome.in_(_AFTER_CONFIRM), 2), (outcome.in_(_IN_DENOMINATOR), 1), else_=0) + columns = [c for c in QualityOutcomeFact.__table__.c if c.name not in _HEAVY_OUTCOME_COLUMNS] return ( - select(QualityOutcomeFact) + select(*columns) .distinct(QualityOutcomeFact.document_id) .order_by( QualityOutcomeFact.document_id, @@ -337,9 +341,22 @@ class QualityMetricsService: ).order_by(i.intake_at, i.document_id) # fmt: skip return [dict(r._mapping) for r in (await self._session.execute(stmt)).all()] - async def export(self, f: QualityFilters, fmt: ExportFormat) -> tuple[bytes, str, str]: - """Файл выгрузки: (содержимое, media type, расширение). CSV — UTF-8 с BOM (для Excel), разделитель — запятая.""" + async def export(self, f: QualityFilters, fmt: ExportFormat, audit: RequestAudit) -> tuple[bytes, str, str]: + """Файл выгрузки: (содержимое, media type, расширение). CSV — UTF-8 с BOM (для Excel), разделитель — запятая. + Массовая выгрузка с названиями ЮЛ оставляет `audit_event(EXPORT)` (R-12): отчёт, фильтры, формат, число + строк; запись — в транзакции запроса, как у экспорта сводной ОСВ.""" records = await self.export_records(f) + await audit.record( + action=AuditAction.EXPORT, + section=AuditSection.EXPORT, + object_type="quality_metrics", + after_value={ + "report": "quality_metrics", + "format": fmt, + "filters": f.model_dump(mode="json", exclude_none=True, include=set(QualityFilters.model_fields)), + "rows": len(records), + }, + ) if fmt == "json": return ( json.dumps(jsonable_encoder(records), ensure_ascii=False).encode(), diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index ca6cca9..9b3e629 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -1119,7 +1119,7 @@ async def _failure_dimensions(session: AsyncSession, file_id: uuid.UUID) -> _Fai async def _write_mapping_failure_fact(session: AsyncSession, job: RecognitionJob, *, failed_at: datetime) -> None: """Факт отказа `MAPPING` (§14.5) — `job` уже дошёл до `done`/`needs_check` (`service_name`/`attempt` сервиса - известны, в отличие от отказов обмена задачи 10 sites 1-4), а маппинг результата не удался.""" + известны, в отличие от отказов обмена задачи 10 sites 1-3), а маппинг результата не удался.""" dims = await _failure_dimensions(session, job.file_id) if dims is None: logger.warning( diff --git a/api/tests/test_api_quality_metrics.py b/api/tests/test_api_quality_metrics.py index 7d3f3a2..471a8b2 100644 --- a/api/tests/test_api_quality_metrics.py +++ b/api/tests/test_api_quality_metrics.py @@ -14,13 +14,17 @@ from typing import Any import httpx import pytest from fastapi import FastAPI +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.auth import current_user from src.api.router import router from src.core.exception_handlers import register_exception_handlers from src.db.db import get_async_session +from src.models.audit_ex import AuditEvent from src.models.enums import ( + AuditAction, + AuditSection, DocumentStatus, DocumentType, QualityCause, @@ -57,7 +61,7 @@ async def _forbidden_client(permissions: frozenset[str]) -> AsyncIterator[httpx. async def principal() -> UserWithAccess: return UserWithAccess( - user=SimpleNamespace(), + user=SimpleNamespace(id=uuid.uuid4()), regional_office=SimpleNamespace(), roles=(), permissions=permissions, # type: ignore[arg-type] @@ -216,6 +220,28 @@ async def test_quality_metrics_matrix_dedupes_to_latest_outcome_fact( assert [r["outcome"] for r in export] == ["SUPERSEDED_AFTER_CONFIRM"] +@pytest.mark.integration +async def test_quality_metrics_export_writes_audit_event(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = uuid.uuid4() + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, group_id=group, group_name="ГК") + await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS) + client = await auth_client(KA_RF) + response = await client.get(f"{BASE}/export", params={"format": "json", "group_id": str(group)}) + assert response.status_code == HTTP_OK + events = (await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).scalars().all() + assert len(events) == 1 + event = events[0] + assert event.section is AuditSection.EXPORT + assert event.object_type == "quality_metrics" + assert event.user_id is not None + assert event.after_value == { + "report": "quality_metrics", + "format": "json", + "filters": {"group_id": str(group)}, + "rows": 1, + } + + @pytest.mark.integration async def test_quality_metrics_equal_outcome_at_prefers_after_confirm( auth_client: AuthClientFactory, seeded: AsyncSession diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 6de2490..7f8bcfe 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -2415,7 +2415,7 @@ async def test_file_reprocess_keeps_failure_fact_of_first_attempt_and_adds_new_i file_id, job1_id = file.id, job1.id # первая попытка: вахтёр переводит задание в timeout -> файл ERROR -> факт отказа TIMEOUT (attempt=1, задача 10 - # sites 1-4, уже на этой ветке) + # sites 1-3, уже на этой ветке) assert await expire_deadlines(maker) == 1 assert (await _file(maker, file_id)).status is FileStatus.ERROR diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 93eea5c..b212fec 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -240,7 +240,7 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | Task 15 (API): последний факт исхода на документ — `DISTINCT ON (document_id) ORDER BY document_id, outcome_at DESC, ранг DESC, id DESC` во всех запросах; ранг (`*_AFTER_CONFIRM` 2, подтверждение 1, остальное 0) совпадает с tie-break домена | SQL и домен должны выбирать один и тот же факт | расхождение выбора даёт разные числа в матрице и в списке ячейки | | 07.11.2026 | Task 15: выборка идёт от фактов приёма (LEFT JOIN последнего исхода); исход без факта приёма в метрики не входит; границы периода включительные по UTC (`date_to` отсекается в 00:00 UTC следующего дня) | у исхода без приёма нет даты приёма (когорта); UTC — единая зона БД | для пользователей в МСК граница периода сдвинута на 3 часа | | 07.11.2026 | Task 15: технические отказы — поле `technical_failures {failed_files_count, total_files_count, share}`, период по `failed_at`, `total_files_count` — уникальные `file_id` из приёма и отказов; «в работе» (`in_progress_count`) и `unique_reports_count` (ЮЛ + отчётная дата + тип) — отдельные поля; в ответе отдельно `superseded_after_confirm_count`, `deleted_after_confirm_count` | design §6, решение 27.10 про отказы по файлам | доля отказов считается от другого знаменателя, чем доли причин | -| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` не пишется (нет в `audit_action`) | design §7/§8; ячейки шире причин | выгрузка не оставляет следа в аудите; Excel может показать BOM в чужих импортёрах | +| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` пишется (см. R-12; исходное «нет в `audit_action`» было неверно) | design §7/§8; ячейки шире причин | выгрузка не оставляет следа в аудите; Excel может показать BOM в чужих импортёрах | | 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY | | 07.11.2026 | Task 16 (R-10): на экране нет фильтра по ЮЛ (есть по ГК, периоду, типу, источнику, скану); API параметр `legal_entity_id` поддерживает | нужен отдельный пикер ЮЛ (тысячи записей); владелец: «главное наличие данных» | в UI нельзя посмотреть качество по одному ЮЛ — только через API/выгрузку | | 07.11.2026 | Task 16 (R-11): матрица «источник × причина» — три параллельных запроса (`pipeline=image`, `pipeline=tabular`, без `pipeline`), колонки «PDF / Excel / Всего»; при выбранном источнике — одна колонка; «Всего» берётся с бэка, не суммой колонок | design §2/§7 требуют сравнения PDF и Excel; бэкенд отдаёт источник только фильтром | три запроса вместо одного на каждую смену фильтров | @@ -248,3 +248,4 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | Task 16: `Sidebar` снова фильтрует пункты по `permission` (поле было в типе, но не применялось после слияния репозиториев); пункт «Пользователи и роли» скрыт без `USERS_ADMIN`; `web/CLAUDE.md` исправлен | это восстановление прежнего поведения (коммит d3c2f8a); сервер и раньше отдавал 403 на эти страницы | у ролей без `USERS_ADMIN` пункт меню пропадает (раньше вёл на экран 403) | | 07.11.2026 | Task 16: тесты фронта — на чистые функции (`qualityView.ts`), не на рендер: в проекте нет jsdom/testing-library; экран в браузере не проверялся (нет стенда); экспорт — через `apiFetchBlob` (токен), `getExportUrl` из брифа не создан | нет инфраструктуры рендер-тестов; обычная ссылка не передаёт токен | раскладка, Drawer и скачивание проверены только чтением кода — обязательна ручная проверка на стенде до merge | | 07.11.2026 | Выкладка: пункт меню «Качество первички» виден только после `make seed` на стенде (seed добавляет пары только для впервые созданного права) | поведение seed | без seed меню скрыто у всех, API отвечает 403 | +| 07.11.2026 | R-12 (final review M-2): `GET /quality-metrics/export` пишет `audit_event` (`action=EXPORT`, `section=EXPORT`, `object_type=quality_metrics`, `object_id=null`, `after_value={report, format, filters, rows}`) в транзакции запроса; `AuditAction.EXPORT` существует (как у экспорта сводной ОСВ, `consolidated_osv.py`); права — прежние `QUALITY_METRICS_VIEW` | выгрузка содержит названия ЮЛ и массовая; обоснование «нет в `audit_action`» в Task 15 было ошибочным | без записи выгрузка не оставляла следа; при записи в аудит каждый экспорт добавляет строку журнала | diff --git a/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx index e8b6e05..1bc233d 100644 --- a/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx +++ b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx @@ -1,6 +1,6 @@ import { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd'; import type { Dayjs } from 'dayjs'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; import { exportFacts, getMatrix } from '../../services/api/qualityMetrics'; import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks'; @@ -11,7 +11,7 @@ import { saveBlob } from '../../utils/download'; import { DocumentDrillList } from './DocumentDrillList'; import { type MatrixColumnData, MatrixTable } from './MatrixTable'; import { MonthlyTrend } from './MonthlyTrend'; -import { formatShare, sourceColumns } from './qualityView'; +import { formatShare, requestOnce, sourceColumns } from './qualityView'; const DOC_TYPES: { value: DocumentType; label: string }[] = [ { value: 'BALANCE', label: 'Бухгалтерский баланс' }, @@ -48,10 +48,11 @@ export function QualityMetricsPage() { // biome-ignore lint/nursery/useAtIndex: в lib проекта (tsconfig) нет Array.prototype.at const matrix = matrices[matrices.length - 1]?.data ?? null; - // список ГК для фильтра: загружаем, если ещё не загружен + // список ГК для фильтра: запрашиваем один раз за жизнь экрана (пустой ответ и ошибка не должны вызывать повтор) + const groupsRequested = useRef(false); useEffect(() => { - if (groups.length === 0 && !groupsLoading) dispatch(getCompanyGroupsThunk()); - }, [groups.length, groupsLoading, dispatch]); + requestOnce(groupsRequested, groups.length === 0 && !groupsLoading, () => dispatch(getCompanyGroupsThunk())); + }, [groups.length, groupsLoading, dispatch]); // повторные срабатывания гасит groupsRequested useEffect(() => { let cancelled = false; diff --git a/web/src/widgets/QualityMetrics/qualityView.test.ts b/web/src/widgets/QualityMetrics/qualityView.test.ts index 7c9c2d3..3e23303 100644 --- a/web/src/widgets/QualityMetrics/qualityView.test.ts +++ b/web/src/widgets/QualityMetrics/qualityView.test.ts @@ -1,7 +1,16 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { QualityMatrixOut } from '../../types/qualityMetrics'; import { filterByPermission } from '../Sidebar/visibility'; -import { buildMatrixRows, buildSourceRows, formatReportDate, formatShare, monthLabel, sourceColumns, sumFixes } from './qualityView'; +import { + buildMatrixRows, + buildSourceRows, + formatReportDate, + formatShare, + monthLabel, + requestOnce, + sourceColumns, + sumFixes, +} from './qualityView'; const matrixFixture: QualityMatrixOut = { counts: { SERVICE_ERROR: 3, OUR_ERROR: 2, CLIENT_DOCUMENT: 1, WITHOUT_PARTICIPATION: 12, AS_IS: 5 }, @@ -103,3 +112,20 @@ describe('formatReportDate', () => { expect(formatReportDate(null)).toBe('—'); }); }); + +describe('requestOnce', () => { + it('вызывает загрузку ровно один раз, сколько бы раз ни перерисовывали экран (пустой ответ, ошибка)', () => { + const flag = { current: false }; + const run = vi.fn(); + for (let render = 0; render < 5; render++) requestOnce(flag, true, run); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('не вызывает загрузку, если справочник уже есть, и не возвращается к ней позже', () => { + const flag = { current: false }; + const run = vi.fn(); + requestOnce(flag, false, run); + requestOnce(flag, true, run); + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/widgets/QualityMetrics/qualityView.ts b/web/src/widgets/QualityMetrics/qualityView.ts index d480d30..d850f8b 100644 --- a/web/src/widgets/QualityMetrics/qualityView.ts +++ b/web/src/widgets/QualityMetrics/qualityView.ts @@ -137,3 +137,10 @@ export function buildSourceRows(columns: { key: string; data: QualityMatrixOut } return { key: row.key, label: row.label, cells }; }); } + +/** Выполняет `run` не более одного раза за жизнь `flag` (и только если `needed`): защита от цикла повторных запросов. */ +export function requestOnce(flag: { current: boolean }, needed: boolean, run: () => void): void { + if (flag.current) return; + flag.current = true; + if (needed) run(); +} -- 2.47.2 From 7a6c3641c19fd096577b5c91e03195d8e3c33aa6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 03:47:44 +0400 Subject: [PATCH 36/44] =?UTF-8?q?fix(#190):=20I-3=20is=5Fscan=20=D0=B8=20r?= =?UTF-8?q?aw=5Fs3=5Fobject=5Fid=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B8=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8,=20I-4=20uploads=20=D1=81=20=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=BC=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=8D=D0=BA=D1=81=D0=BF=D0=B8=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D1=81=D1=8F,=20M-3=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20PDF=20=D0=B2=D0=BD=D0=B5=20event=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/domain/pdf_text_layer.py | 10 ++-- api/src/models/files.py | 18 ++++--- api/src/repositories/quality_facts.py | 9 ++++ api/src/services/primary_files.py | 15 ++++-- api/tests/test_api_files_delete.py | 59 ++++++++++++++++++++++ api/tests/test_domain_pdf_text_layer.py | 7 +++ api/tests/test_migrations.py | 3 +- openspec/changes/quality-metrics/design.md | 3 ++ 8 files changed, 108 insertions(+), 16 deletions(-) diff --git a/api/src/domain/pdf_text_layer.py b/api/src/domain/pdf_text_layer.py index 8762c9e..dec6224 100644 --- a/api/src/domain/pdf_text_layer.py +++ b/api/src/domain/pdf_text_layer.py @@ -13,12 +13,16 @@ import io from pypdf import PdfReader +# R-15: текстовый слой скана/цифрового PDF виден уже на первых страницах; разбор всех страниц большого файла под +# advisory-lock загрузки не нужен +MAX_PAGES_CHECKED = 10 + def has_text_layer(content: bytes) -> bool: - """Есть ли хотя бы на одной странице `content` извлекаемый текст; битый/зашифрованный PDF и любая другая - ошибка разбора — `False`, без исключения.""" + """Есть ли хотя бы на одной из первых `MAX_PAGES_CHECKED` страниц `content` извлекаемый текст; битый/зашифрованный + PDF и любая другая ошибка разбора — `False`, без исключения.""" try: reader = PdfReader(io.BytesIO(content)) - return any(page.extract_text().strip() for page in reader.pages) + return any(page.extract_text().strip() for page in reader.pages[:MAX_PAGES_CHECKED]) except Exception: return False diff --git a/api/src/models/files.py b/api/src/models/files.py index 328df9e..217693c 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -70,9 +70,6 @@ class StoredFile(UUIDPrimaryMixin, Base): status: Mapped[FileStatus] = mapped_column(pg_enum("file_status"), nullable=False) version_no: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1")) superseded_by_file_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id")) - # v5.25: только PDF — признак скана по отсутствию текстового слоя при загрузке; true = скан, false = цифровой - # (в т.ч. скрытый OCR-слой — допущение §14); NULL для не-PDF (05-domain-rules.md §14.6) - is_scan: Mapped[bool | None] = mapped_column(Boolean) current_recognition_job_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey( @@ -84,9 +81,13 @@ class StoredFile(UUIDPrimaryMixin, Base): uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) uploaded_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) - # v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). Последняя - # колонка: миграция 0006 добавляет её ALTER TABLE, а check_dbml сверяет порядок + # v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). + # Колонка добавлена миграцией 0006 (ALTER TABLE — в конец); check_dbml сверяет порядок consolidation_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("consolidation.id")) + # v5.25: только PDF — признак скана по отсутствию текстового слоя при загрузке; true = скан, false = цифровой + # (в т.ч. скрытый OCR-слой — допущение §14); NULL для не-PDF (05-domain-rules.md §14.6). Последняя колонка: + # миграция 0012 добавляет её ALTER TABLE (в конец), а check_dbml сверяет порядок + is_scan: Mapped[bool | None] = mapped_column(Boolean) class Document(UUIDPrimaryMixin, Base): @@ -172,9 +173,6 @@ class RecognitionJob(UUIDPrimaryMixin, Base): error_code: Mapped[str | None] = mapped_column(String) error_message: Mapped[str | None] = mapped_column(Text) result_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) - # v5.25: сырой ответ ИС Сканирование (роль raw), заполняется при приёме результата image pipeline — отменяет - # решение v5.13 «в БД не регистрируется» (openspec/changes/quality-metrics §9) - raw_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) result_schema_version: Mapped[str | None] = mapped_column(String) group_schema: Mapped[Any | None] = mapped_column(nullable_jsonb()) metrics: Mapped[Any | None] = mapped_column(nullable_jsonb()) @@ -183,3 +181,7 @@ class RecognitionJob(UUIDPrimaryMixin, Base): fields_unrecognized: Mapped[int | None] = mapped_column(Integer) # v5.19: service.statements_version результата — коммит копии общего кода arm_statements statements_version: Mapped[str | None] = mapped_column(String) + # v5.25: сырой ответ ИС Сканирование (роль raw), заполняется при приёме результата image pipeline — отменяет + # решение v5.13 «в БД не регистрируется» (openspec/changes/quality-metrics §9). + # Последняя колонка: миграция 0012 добавляет её ALTER TABLE (в конец), а check_dbml сверяет порядок + raw_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) diff --git a/api/src/repositories/quality_facts.py b/api/src/repositories/quality_facts.py index 8c84c32..f6eb24e 100644 --- a/api/src/repositories/quality_facts.py +++ b/api/src/repositories/quality_facts.py @@ -5,6 +5,9 @@ DBML/ORM; колонки, не переданные вызывающим, берут значения по умолчанию БД (`server_default`). """ +import uuid + +from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact @@ -23,3 +26,9 @@ async def insert_outcome_fact(session: AsyncSession, **columns) -> None: async def insert_failure_fact(session: AsyncSession, **columns) -> None: """Добавляет факт технического отказа задания или ошибки маппинга (design §4.3).""" session.add(QualityFailureFact(**columns)) + + +async def has_intake_fact_for_file(session: AsyncSession, file_id: uuid.UUID) -> bool: + """Есть ли у файла факт приёма: тогда объект `uploads` файла нужен эталонному прогону (`upload_s3_key`) и не + экспирится при удалении файла (spec quality-metrics-model, OQ-179).""" + return bool(await session.scalar(select(exists().where(QualityIntakeFact.file_id == file_id)))) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index effb898..b69c8bc 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -16,6 +16,7 @@ audit-trail «События файлов и документов»; design D-4) уборка таких объектов — ретеншн бакета (OQ-22). """ +import asyncio import datetime import hashlib import uuid @@ -59,7 +60,7 @@ from src.repositories import consolidations as consolidations_repo from src.repositories import directories as directories_repo from src.repositories import files as files_repo from src.repositories import statements as statements_repo -from src.repositories.quality_facts import insert_outcome_fact +from src.repositories.quality_facts import has_intake_fact_for_file, insert_outcome_fact from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services import consolidation_documents @@ -418,12 +419,18 @@ class PrimaryFilesService: self._session, [document.id for document in documents] ) before = file_out(file, current, documents, counts, issue_counts).model_dump(mode="json") - object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] + # R-14: объект uploads, на который ссылается факт приёма (`upload_s3_key`), нужен эталонному прогону — не + # экспирится (spec quality-metrics-model); объект raw сюда не попадает и так + keep_upload = await has_intake_fact_for_file(self._session, file_id) + object_ids = [ + *([] if keep_upload else [file.s3_object_id]), + *(job.result_s3_object_id for job in jobs if job.result_s3_object_id), + ] await self._write_delete_outcome_facts(documents, issue_counts, utc_now()) await files_repo.delete_file_rows(self._session, file) # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла owned_prefixes = [ - (S3BucketRole.uploads, f"{UPLOADS_PREFIX}/{file_id}/"), + *([] if keep_upload else [(S3BucketRole.uploads, f"{UPLOADS_PREFIX}/{file_id}/")]), (S3BucketRole.results, f"{RESULTS_PREFIX}/{file_id}/"), ] await files_repo.expire_s3_objects(self._session, object_ids, owned_prefixes=owned_prefixes, now=utc_now()) @@ -532,7 +539,7 @@ class PrimaryFilesService: # §14.6: признак скана — только для PDF, по отсутствию текстового слоя (true = скан, false = цифровой, в # т.ч. со скрытым OCR-слоем); для остальных типов файла — NULL (pipeline = tabular) - is_scan = not has_text_layer(content) if detected.file_type is FileType.PDF else None + is_scan = not await asyncio.to_thread(has_text_layer, content) if detected.file_type is FileType.PDF else None stored_file = StoredFile( id=file_id, legal_entity_id=entity.id, diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 3190e1b..500007b 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -31,7 +31,9 @@ from src.models.enums import ( FileStatus, PeriodQuarter, QualityOutcome, + RecognitionPipeline, RecognitionStatus, + S3BucketRole, ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry @@ -43,6 +45,7 @@ from src.models.statements import ( FinancialStatementMeta, FinancialStatementValue, ) +from src.repositories.quality_facts import insert_intake_fact from src.services.result_mapping import process_once from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import ( @@ -219,6 +222,62 @@ async def test_delete_expires_only_own_objects(seeded: AsyncSession, auth_client assert foreign is not None and foreign.expires_at is None +async def test_delete_keeps_uploads_and_raw_objects_referenced_by_intake_fact( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Spec quality-metrics-model «Удаление файла с зарегистрированным сырым ответом»: `expires_at` остаётся NULL у + объекта raw и у объекта uploads, на который ссылается факт приёма; у результата — проставляется (I-4).""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.error + ) + result_object = await make_result_object(seeded, job=job, storage=FakeStorage(), result={}) + raw = S3Object( + bucket_role=S3BucketRole.raw, + bucket="raw", + key=f"raw/{job.id}.json", + created_at=utc_now(), + ) + seeded.add(raw) + await seeded.flush() + job.raw_s3_object_id = raw.id + upload = await seeded.get(S3Object, file.s3_object_id) + assert upload is not None + await insert_intake_fact( + seeded, + document_id=uuid.uuid4(), + file_id=file.id, + job_id=job.id, + attempt=1, + legal_entity_id=entity.id, + legal_entity_name=entity.name, + document_type=DocumentType.BALANCE, + pipeline=RecognitionPipeline.image, + is_scan=True, + intake_at=utc_now(), + intake_status=DocumentStatus.RECOGNIZED, + upload_s3_key=upload.key, + raw_s3_key=raw.key, + payload={}, + ) + await seeded.flush() + file_id, upload_id, raw_id, result_id = file.id, upload.id, raw.id, result_object.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + expires = {} + for name, object_id in (("uploads", upload_id), ("raw", raw_id), ("results", result_id)): + s3 = await seeded.get(S3Object, object_id, populate_existing=True) + assert s3 is not None + expires[name] = s3.expires_at + assert expires["uploads"] is None + assert expires["raw"] is None + assert expires["results"] is not None + + async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл.""" entity = await _rostov_entity(seeded) diff --git a/api/tests/test_domain_pdf_text_layer.py b/api/tests/test_domain_pdf_text_layer.py index 99c22d7..9688f16 100644 --- a/api/tests/test_domain_pdf_text_layer.py +++ b/api/tests/test_domain_pdf_text_layer.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from src.domain import pdf_text_layer from src.domain.pdf_text_layer import has_text_layer SCANNED_PDF = Path("ocr") / "aep_9.pdf" # реальный скан без текстового слоя (2 страницы, extract_text == "") @@ -50,3 +51,9 @@ def test_has_text_layer_false_for_scanned_pdf(fixtures_dir: Path) -> None: def test_has_text_layer_false_for_corrupt_pdf_without_raising() -> None: assert has_text_layer(b"not a pdf") is False + + +def test_has_text_layer_looks_only_at_first_pages(monkeypatch: pytest.MonkeyPatch) -> None: + """R-15: страницы за `MAX_PAGES_CHECKED` не разбираются (при лимите 0 текст не виден).""" + monkeypatch.setattr(pdf_text_layer, "MAX_PAGES_CHECKED", 0) + assert has_text_layer(_digital_pdf_bytes()) is False diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 8cdd8b4..5550581 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -609,7 +609,8 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N conn, version_no=1, valid_from=datetime.date(2020, 1, 1) ) # v5.23: версиям нужна версия правил - await alembic(scratch_db_url, "upgrade", "head") + await alembic(scratch_db_url, "upgrade", V5_18) + # на ревизии 0006, не на head: поздние миграции (0012 is_scan) добавляют свои колонки в конец async with engine.begin() as conn: assert await _consolidation_id_column(conn) == [("YES", "uuid")] last = await _scalars( diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index b212fec..58a4643 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -249,3 +249,6 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | Task 16: тесты фронта — на чистые функции (`qualityView.ts`), не на рендер: в проекте нет jsdom/testing-library; экран в браузере не проверялся (нет стенда); экспорт — через `apiFetchBlob` (токен), `getExportUrl` из брифа не создан | нет инфраструктуры рендер-тестов; обычная ссылка не передаёт токен | раскладка, Drawer и скачивание проверены только чтением кода — обязательна ручная проверка на стенде до merge | | 07.11.2026 | Выкладка: пункт меню «Качество первички» виден только после `make seed` на стенде (seed добавляет пары только для впервые созданного права) | поведение seed | без seed меню скрыто у всех, API отвечает 403 | | 07.11.2026 | R-12 (final review M-2): `GET /quality-metrics/export` пишет `audit_event` (`action=EXPORT`, `section=EXPORT`, `object_type=quality_metrics`, `object_id=null`, `after_value={report, format, filters, rows}`) в транзакции запроса; `AuditAction.EXPORT` существует (как у экспорта сводной ОСВ, `consolidated_osv.py`); права — прежние `QUALITY_METRICS_VIEW` | выгрузка содержит названия ЮЛ и массовая; обоснование «нет в `audit_action`» в Task 15 было ошибочным | без записи выгрузка не оставляла следа; при записи в аудит каждый экспорт добавляет строку журнала | +| 07.11.2026 | R-13 (final review I-3, рулинг владельца): `stored_file.is_scan` и `recognition_job.raw_s3_object_id` — последние колонки таблиц, и в ORM, и в DBML v5.25 (docs); версия DBML не меняется. Тест миграции 0006 проверяет «последняя колонка» на ревизии `0006_dbml_v5_18`, а не на head | миграция 0012 добавляет колонки `ADD COLUMN` (в конец), `test_migrated_schema_equals_orm_create_all` и 0006-тест требуют порядок ORM = порядок миграции; прецедент v5.18 `consolidation_id` | порядок колонок в DBML v5.25 не совпадает с черновиками аналитики; смена порядка в ORM требует правки docs PR #203 (merge вместе) | +| 07.11.2026 | R-14 (final review I-4): при удалении файла, у которого есть `quality_intake_fact` с `file_id`, объект `uploads` файла не получает `expires_at` (исключён из `object_ids` и из префикса `uploads/{file_id}/`); определение — один запрос `has_intake_fact_for_file` по `file_id`, а не сравнение `upload_s3_key` | spec quality-metrics-model: объект нужен эталонному прогону (OQ-179); сравнение по `file_id` дешевле и покрывает все факты файла; объект `raw` в `object_ids` не входит и раньше | если у файла несколько документов и факт есть не у всех, объект сохраняется целиком (бакет растёт на размер файла); если `file_id` в факте ошибочен — объект экспирится и эталон потеряется | +| 07.11.2026 | R-15 (final review M-3): `has_text_layer` вызывается через `asyncio.to_thread` и разбирает не больше `MAX_PAGES_CHECKED = 10` первых страниц | pypdf синхронен и работал в event loop под advisory-lock загрузки; текстовый слой, если есть, виден на первых страницах формы отчётности (ББ/ОФР — 1-3 листа), 10 — запас | PDF, у которого текст только после 10-й страницы (титулы-сканы), будет помечен `is_scan = true` — метрика «скан» чуть завысит долю сканов | -- 2.47.2 From 8c7ce7c172d289ad2271abfbb1a968d8290f9c63 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 03:56:47 +0400 Subject: [PATCH 37/44] =?UTF-8?q?fix(#190):=20I-2=20payload=20=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BA=D1=82=D1=83=20quality-facts,=20=D0=B2?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D1=81=D1=85?= =?UTF-8?q?=D0=B5=D0=BC=D1=8B=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8;=20I-5=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D0=B0=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20parse=5Fdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../metrics/quality-facts.schema.json | 109 ++++++++++++++++++ api/src/domain/quality_attribution.py | 17 ++- api/src/domain/quality_facts.py | 29 +++-- api/src/services/documents.py | 87 +++++++++++--- api/tests/quality_payload_contract.py | 40 +++++++ api/tests/test_api_documents.py | 109 +++++++++++++++++- api/tests/test_api_files_delete.py | 5 +- api/tests/test_auto_confirm.py | 9 +- api/tests/test_domain_quality_attribution.py | 21 ++++ api/tests/test_domain_quality_facts.py | 6 +- api/tests/test_result_mapping.py | 9 +- openspec/changes/quality-metrics/design.md | 8 +- openspec/changes/quality-metrics/proposal.md | 2 +- 13 files changed, 410 insertions(+), 41 deletions(-) create mode 100644 api/contracts/metrics/quality-facts.schema.json create mode 100644 api/tests/quality_payload_contract.py diff --git a/api/contracts/metrics/quality-facts.schema.json b/api/contracts/metrics/quality-facts.schema.json new file mode 100644 index 0000000..ff63bcf --- /dev/null +++ b/api/contracts/metrics/quality-facts.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/quality-facts/v1", + "title": "Payload фактов качества распознавания первички (AURORA-1759)", + "description": "Структура jsonb-столбца payload таблиц quality_intake_fact и quality_outcome_fact (model/data-model-v5.25.dbml, context/05-domain-rules.md §14). quality_failure_fact не описан здесь — у него нет payload, все поля плоские. НЕ валидируется make check / scripts/validate_contracts.py: этот скрипт жёстко завязан на три сообщения NATS-контракта распознавания (request-/result-message-/result- примеры с дедупом и версией сообщения); payload фактов не ходит по NATS и не требует той же идемпотентности. Схему проверяет тест в arm-core, где payload формируется (см. openspec/changes/quality-metrics/design.md §2 этого репозитория).", + "$defs": { + "money": { + "type": ["string", "null"], + "pattern": "^-?\\d+(\\.\\d{1,2})?$", + "description": "Decimal как строка (как contracts/recognition-result.schema.json#/$defs/money) — деньги не float" + }, + "intake-payload": { + "type": "object", + "description": "Снимок документа на момент приёма результата (до подтверждения). Для OSV — без lines/values, только osv_warnings (взаимоисключение — см. dependentSchemas ниже).", + "dependentSchemas": { + "osv_warnings": { + "not": { "anyOf": [{ "required": ["lines"] }, { "required": ["values"] }] } + } + }, + "properties": { + "header": { + "type": "object", + "description": "header_source и распознанные поля шапки (period, inn, units, organization_name, …)", + "additionalProperties": true + }, + "lines": { + "type": "array", + "description": "Отсутствует для document_type = OSV", + "items": { + "type": "object", + "required": ["ref", "line_status"], + "properties": { + "ref": { "type": "string" }, + "row_index": { "type": ["integer", "null"] }, + "line_code": { "type": ["string", "null"] }, + "line_code_text": { "type": ["string", "null"] }, + "line_status": { "enum": ["ok", "unreadable", "missing", "detail"] }, + "needs_review": { "type": "boolean" } + } + } + }, + "values": { + "type": "array", + "description": "Отсутствует для document_type = OSV", + "items": { + "type": "object", + "required": ["line_ref", "column"], + "properties": { + "line_ref": { "type": "string" }, + "column": { "type": "string" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"] }, + "source_text": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"] }, + "is_recognized": { "type": "boolean" }, + "needs_review": { "type": "boolean" } + } + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "required": ["code"], + "properties": { + "code": { "type": "string" }, + "level": { "enum": ["relation", "soft", "warning", "info"] } + } + } + }, + "raw_line_codes": { + "type": "array", + "description": "Только PDF: четырёхзначные токены сырого ответа ИС, стоящие отдельной строкой — вход для §14.2 (добавленная строка)", + "items": { "type": "string", "pattern": "^\\d{4}$" } + }, + "osv_warnings": { + "type": "array", + "description": "Только для document_type = OSV, вместо lines/values", + "items": { "type": "string" } + } + } + }, + "outcome-payload": { + "type": "object", + "description": "Подтверждённое состояние (то же строение header/lines/values, что и intake-payload) плюс список правок", + "properties": { + "header": { "$ref": "#/$defs/intake-payload/properties/header" }, + "lines": { "$ref": "#/$defs/intake-payload/properties/lines" }, + "values": { "$ref": "#/$defs/intake-payload/properties/values" }, + "fixes": { + "type": "array", + "items": { + "type": "object", + "required": ["field", "after", "source", "rule"], + "properties": { + "field": { "enum": ["value", "code", "added_row", "header", "osv_period_account"], "description": "header покрывает и правку даты колонки — §14.2 считает их вместе" }, + "before_raw": { "type": ["string", "null"], "description": "сырой текст сервиса на этом поле, если был" }, + "before_recognized": { "description": "как было распознано до правки" }, + "after": { "description": "подтверждённое значение" }, + "source": { "enum": ["service", "ours"], "description": "§14.2 — только два источника правки" }, + "rule": { "type": "string", "description": "строка таблицы §14.2, которая сработала" } + } + } + } + } + } + }, + "$comment": "Корневая схема не инстанцируется напрямую: тест в arm-core валидирует quality_intake_fact.payload против #/$defs/intake-payload и quality_outcome_fact.payload против #/$defs/outcome-payload (не oneOf — оба $defs допускают пустой объект, и oneOf отверг бы его как совпавший с обоими)." +} diff --git a/api/src/domain/quality_attribution.py b/api/src/domain/quality_attribution.py index 7fe1412..3979a3c 100644 --- a/api/src/domain/quality_attribution.py +++ b/api/src/domain/quality_attribution.py @@ -13,13 +13,15 @@ """ from collections.abc import Sequence +from datetime import date from typing import Literal -from arm_statements import Source, default_rules, fix_lookalikes, parse_amount, parse_code +from arm_statements import Source, default_rules, fix_lookalikes, parse_amount, parse_code, parse_date __all__ = [ "attribute_added_row", "attribute_code_fix", + "attribute_header_date_fix", "attribute_header_fix", "attribute_value_fix", ] @@ -84,3 +86,16 @@ def attribute_header_fix(confirmed: str, header_source_text: str) -> Attribution rules = default_rules() normalized_source = fix_lookalikes(header_source_text, rules.lookalikes) return "ours" if confirmed in normalized_source else "service" + + +def attribute_header_date_fix(confirmed: date, header_source_text: str) -> Attribution: + """§14.2 «Шапка», отчётная дата: сырой текст сервиса (`31.03.2025`, «на 31 марта 2025 г.») разбирается тем же + `parse_date` пакета `arm_statements` (после замены двойников §11.3) и сравнивается с подтверждённой датой как + дата, а не как подстрока ISO-строки. Совпала — «наш разбор» (сервис прочитал верно); не совпала — «сервис». + Текст не разобрался — «наш разбор» (анти-цель design §2: спорное не приписываем сервису). + """ + rules = default_rules() + parsed = parse_date(fix_lookalikes(header_source_text, rules.lookalikes), rules) + if parsed is None: + return "ours" + return "ours" if parsed == confirmed else "service" diff --git a/api/src/domain/quality_facts.py b/api/src/domain/quality_facts.py index a702689..3f3671c 100644 --- a/api/src/domain/quality_facts.py +++ b/api/src/domain/quality_facts.py @@ -25,8 +25,6 @@ __all__ = ["build_intake_payload", "build_outcome_payload"] _HEADER_FIELDS = ("document_type", "period_year", "period_quarter", "is_cumulative", "reporting_date") _LINE_FIELDS = ("row_index", "line_code", "line_code_text", "line_status", "needs_review") _VALUE_FIELDS = ( - "line_id", - "column_id", "amount", "string_value", "source_text", @@ -59,13 +57,14 @@ def build_intake_payload( # noqa: PLR0913 - сигнатура снимка ф if osv_row_count is not None: payload["row_count"] = osv_row_count return payload + line_refs = {getattr(line, "id", None): _line_ref(line) for line in lines or ()} header = _pick(document, _HEADER_FIELDS) if header_source is not None: header["header_source"] = {key: _plain_mapping(entry) for key, entry in header_source.items()} return { "header": header, "lines": [_line(line) for line in lines or ()], - "values": [_value(value) for value in values or ()], + "values": [_value(value, line_refs) for value in values or ()], "issues": [_pick(issue, _ISSUE_FIELDS) for issue in issues], } @@ -100,10 +99,16 @@ def build_outcome_payload( # noqa: PLR0913 - сигнатура снимка ф return payload +def _line_ref(line: Any) -> str: + """Ссылка на строку в payload: `source_ref`, а при его отсутствии — `str(id)` (контракт: `ref` — всегда строка).""" + source_ref = getattr(line, "source_ref", None) + return str(source_ref) if source_ref is not None else str(getattr(line, "id", None)) + + def _line(line: Any) -> dict[str, Any]: return { "id": _plain(getattr(line, "id", None)), - "ref": _plain(getattr(line, "source_ref", None)), + "ref": _line_ref(line), **_pick(line, _LINE_FIELDS), } @@ -115,8 +120,18 @@ def _plain_mapping(entry: Any) -> Any: return _plain(entry) -def _value(value: Any) -> dict[str, Any]: - return _pick(value, _VALUE_FIELDS) +def _value(value: Any, line_refs: Mapping[Any, str]) -> dict[str, Any]: + """Значение по контракту: `line_ref` — `ref` строки из `lines[]` (нет строки в снимке — `str(line_id)`), + `column` — `str(column_id)`, `confidence` — число (не деньги, float допустим), остальное — как в БД.""" + line_id = getattr(value, "line_id", None) + column_id = getattr(value, "column_id", None) + confidence = getattr(value, "confidence", None) + return { + "line_ref": line_refs.get(line_id, str(line_id)), + "column": str(column_id), + **_pick(value, _VALUE_FIELDS), + "confidence": float(confidence) if confidence is not None else None, + } def _pick(obj: Any, fields: Sequence[str]) -> dict[str, Any]: @@ -128,7 +143,7 @@ def _plain(value: Any) -> Any: if isinstance(value, Enum): return value.value if isinstance(value, Decimal): - return str(value) + return format(value, "f") if isinstance(value, UUID): return str(value) if isinstance(value, datetime | date): diff --git a/api/src/services/documents.py b/api/src/services/documents.py index ab604d3..c3fe6bd 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -27,6 +27,7 @@ from src.domain.quality_attribution import ( Attribution, attribute_added_row, attribute_code_fix, + attribute_header_date_fix, attribute_header_fix, attribute_value_fix, ) @@ -74,6 +75,12 @@ CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEED # `OUTCOME_FACT_DOCUMENT_TYPES` в `services/auto_confirm.py` (Task 6); не импортируется оттуда во избежание цикла # `documents → auto_confirm → statements → documents` (то же решение, что в Task 6) OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV}) +# строки таблицы §14.2 05-domain-rules.md → `fixes[].rule` в payload (контракт quality-facts) +RULE_VALUE = "Сумма" +RULE_CODE = "Код строки" +RULE_ADDED_ROW = "Добавленная строка" +RULE_HEADER = "Шапка" +RULE_OSV_PERIOD_ACCOUNT = "Период / счёт ОСВ" # поле шапки → (атрибут «правилось», имя поля в `financial_statement_meta`); порядок — как в §14.2 «Шапка» _HEADER_FIX_FIELDS: tuple[tuple[str, str], ...] = ( ("inn_modified", "inn"), @@ -107,7 +114,7 @@ _OURS_COUNTER_KEYS: tuple[str, ...] = ( ) -def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None: +def _value_fix(value: FinancialStatementValue, line_ref: str) -> dict[str, Any] | None: """Правка суммы (§14.2 «Сумма»): `None` — значение не правилось (`is_modified` не `True`). Нет сырого текста для сравнения (`source_text is None`) — классифицировать нельзя; считаем правку нашей, не @@ -120,10 +127,13 @@ def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None: "ours" if value.source_text is None else attribute_value_fix(confirmed=confirmed, raw_text=value.source_text) ) return { - "kind": "value", - "line_id": str(value.line_id), - "attribution": attribution, - "before": str(value.original_amount) if value.original_amount is not None else None, + "field": "value", + "line_ref": line_ref, + "column": str(value.column_id), + "source": attribution, + "rule": RULE_VALUE, + "before_raw": value.source_text, + "before_recognized": format(value.original_amount, "f") if value.original_amount is not None else None, "after": confirmed, } @@ -140,10 +150,12 @@ def _code_fix(line: FinancialStatementLine) -> dict[str, Any] | None: else attribute_code_fix(confirmed_code=confirmed_code, raw_text=line.line_code_text) ) return { - "kind": "code", - "line_id": str(line.id), - "attribution": attribution, - "before": line.line_code_original_value, + "field": "code", + "line_ref": _line_ref(line), + "source": attribution, + "rule": RULE_CODE, + "before_raw": line.line_code_text, + "before_recognized": line.line_code_original_value, "after": confirmed_code, } @@ -161,7 +173,20 @@ def _added_row_fix(line: FinancialStatementLine, *, is_excel: bool) -> dict[str, if line.row_index is not None: return None attribution: Attribution = attribute_added_row(line.line_code or "", (), is_excel=True) if is_excel else "ours" - return {"kind": "added_row", "line_id": str(line.id), "attribution": attribution, "after": line.line_code} + return { + "field": "added_row", + "line_ref": _line_ref(line), + "source": attribution, + "rule": RULE_ADDED_ROW, + "before_raw": None, + "before_recognized": None, + "after": line.line_code, + } + + +def _line_ref(line: FinancialStatementLine) -> str: + """Тот же `ref`, что в `lines[]` payload (`source_ref`, иначе `str(id)`).""" + return str(line.source_ref) if line.source_ref is not None else str(line.id) def _header_confirmed_value(meta: FinancialStatementMeta, field: str) -> str: @@ -177,11 +202,25 @@ def _header_fix(field: str, *, meta: FinancialStatementMeta, header_source: Mapp confirmed = _header_confirmed_value(meta, field) source_entry = header_source.get(field) if isinstance(header_source, Mapping) else None source_text = source_entry.get("text") if isinstance(source_entry, Mapping) else None - attribution: Attribution = ( - "ours" if source_text is None else attribute_header_fix(confirmed=confirmed, header_source_text=source_text) - ) + confirmed_date = meta.report_date if field == "report_date" else None + attribution: Attribution + if source_text is None: + attribution = "ours" + elif confirmed_date is not None: + # отчётная дата: сырой текст разбирается `parse_date`, сравниваются даты, а не подстрока ISO (I-5) + attribution = attribute_header_date_fix(confirmed=confirmed_date, header_source_text=source_text) + else: + attribution = attribute_header_fix(confirmed=confirmed, header_source_text=source_text) before = getattr(meta, f"{field}_original_value", None) - return {"kind": "header", "field": field, "attribution": attribution, "before": before, "after": confirmed} + return { + "field": "header", + "header_field": field, + "source": attribution, + "rule": RULE_HEADER, + "before_raw": source_text, + "before_recognized": before, + "after": confirmed, + } def _primary_cause(counters: Mapping[str, int], *, checks_accepted: bool) -> QualityCause: @@ -458,7 +497,7 @@ class DocumentService: if fix is None: return fixes.append(fix) - suffix = "service" if fix["attribution"] == "service" else "ours" + suffix = "service" if fix["source"] == "service" else "ours" counters[f"fixes_{prefix}_{suffix}"] += 1 if document.document_type is DocumentType.OSV: @@ -476,14 +515,28 @@ class DocumentService: ] if document.is_modified: counters["fixes_osv_period_account"] = 1 - fixes.append({"kind": "osv_period_account", "attribution": "ours"}) + fixes.append( + { + "field": "osv_period_account", + "source": "ours", + "rule": RULE_OSV_PERIOD_ACCOUNT, + "before_raw": None, + "before_recognized": None, + "after": { + "period_year": document.period_year, + "period_quarter": document.period_quarter.value if document.period_quarter else None, + "osv_account_code": document.osv_account_code, + }, + } + ) payload = build_outcome_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count, fixes=fixes) elif meta is not None: _, pairs = await statements_repo.load_form(self._session, meta.id) lines = [line for line, _ in pairs] values = [value for _, line_values in pairs for value in line_values] + refs = {line.id: _line_ref(line) for line in lines} for value in values: - _record(_value_fix(value), "value") + _record(_value_fix(value, refs.get(value.line_id, str(value.line_id))), "value") for line in lines: _record(_code_fix(line), "code") _record(_added_row_fix(line, is_excel=is_excel), "added_row") diff --git a/api/tests/quality_payload_contract.py b/api/tests/quality_payload_contract.py new file mode 100644 index 0000000..a20f8de --- /dev/null +++ b/api/tests/quality_payload_contract.py @@ -0,0 +1,40 @@ +"""Проверка payload фактов качества по контракту `contracts/metrics/quality-facts.schema.json` (JSON Schema 2020-12). + +Контракт живёт в docs, копия — `api/contracts/metrics/` (идентичность проверяет `test_contract.py`). Корневая схема +не инстанцируется: `quality_intake_fact.payload` валидируется против `#/$defs/intake-payload`, `quality_outcome_fact. +payload` — против `#/$defs/outcome-payload`. +""" + +import json +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator, FormatChecker + +SCHEMA_PATH = Path(__file__).resolve().parents[1] / "contracts" / "metrics" / "quality-facts.schema.json" +_SCHEMA = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + + +def _validator(def_name: str) -> Draft202012Validator: + schema = {"$schema": _SCHEMA["$schema"], "$defs": _SCHEMA["$defs"], "$ref": f"#/$defs/{def_name}"} + return Draft202012Validator(schema, format_checker=FormatChecker()) + + +_INTAKE = _validator("intake-payload") +_OUTCOME = _validator("outcome-payload") + + +def _errors(validator: Draft202012Validator, payload: dict[str, Any]) -> list[str]: + return [f"{'/'.join(map(str, e.absolute_path))}: {e.message}" for e in validator.iter_errors(payload)] + + +def assert_intake_payload_valid(payload: dict[str, Any]) -> None: + """Payload факта приёма соответствует `$defs/intake-payload`.""" + problems = _errors(_INTAKE, payload) + assert not problems, problems[:5] + + +def assert_outcome_payload_valid(payload: dict[str, Any]) -> None: + """Payload факта исхода соответствует `$defs/outcome-payload`.""" + problems = _errors(_OUTCOME, payload) + assert not problems, problems[:5] diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 1c700bc..43038db 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -48,6 +48,7 @@ from tests.factories import ( make_stored_file, ) from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot +from tests.quality_payload_contract import assert_outcome_payload_valid HTTP_OK = 200 HTTP_FORBIDDEN = 403 @@ -622,9 +623,11 @@ async def _break_2200_and_accept( async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact: - return ( + fact = ( await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) ).scalar_one() + assert_outcome_payload_valid(fact.payload) # payload соответствует контракту quality-facts + return fact @pytest.mark.fixtures @@ -676,7 +679,10 @@ async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text( original_amount=Decimal("1234000.00"), # наш разбор тысячного разделителя ошибся confirmed_amount=Decimal("1234.00"), # аналитик подтвердил то, что действительно в сыром тексте ) - fixed_line_id = fixed.line_id # до HTTP-запроса: после него объекты сессии теста могут устареть + fixed_column_id = fixed.column_id # до HTTP-запроса: после него объекты сессии теста могут устареть + fixed_line = await seeded.get(FinancialStatementLine, fixed.line_id) + assert fixed_line is not None + fixed_line_ref = fixed_line.source_ref or str(fixed_line.id) client = await auth_client(KA_RF) response = await client.post(f"/documents/{income.id}/confirm") @@ -687,10 +693,13 @@ async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text( assert (fact.fixes_value_ours, fact.fixes_value_service) == (1, 0) assert fact.payload["fixes"] == [ { - "kind": "value", - "line_id": str(fixed_line_id), - "attribution": "ours", - "before": "1234000.00", + "field": "value", + "line_ref": fixed_line_ref, + "column": str(fixed_column_id), + "source": "ours", + "rule": "Сумма", + "before_raw": "1 234", + "before_recognized": "1234000.00", "after": "1234.00", } ] @@ -740,6 +749,94 @@ async def test_confirm_writes_outcome_fact_client_document_when_checks_accepted( assert fact.payload["fixes"] == [] +@pytest.mark.fixtures +async def test_confirm_outcome_fact_report_date_fix_compares_parsed_dates( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """I-5: правка отчётной даты шапки сравнивается как дата (`parse_date`), а не как подстрока ISO в сыром тексте: + «31.03.2025» и подтверждённая 2025-03-31 — «наш разбор» (сервис прочитал верно).""" + office = await _rostov_office(seeded) + income, meta = await _income_form(seeded, fixtures_dir, office=office) + meta.report_date = datetime.date(2025, 3, 31) + meta.report_date_modified = True + meta.report_date_original_value = "2024-03-31" + meta.header_source = {"report_date": {"text": "31.03.2025", "confidence": "99.00"}} + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert (fact.fixes_header_ours, fact.fixes_header_service) == (1, 0) + assert fact.payload["fixes"] == [ + { + "field": "header", + "header_field": "report_date", + "source": "ours", + "rule": "Шапка", + "before_raw": "31.03.2025", + "before_recognized": "2024-03-31", + "after": "2025-03-31", + } + ] + + +@pytest.mark.fixtures +async def test_confirm_outcome_fact_code_fix_matches_contract( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Правка кода строки: запись `fixes[]` по контракту (`field`/`source`/`rule`/`before_raw`/`before_recognized`), + ссылка `line_ref` совпадает с `ref` строки в `lines[]`.""" + office = await _rostov_office(seeded) + income, meta = await _income_form(seeded, fixtures_dir, office=office) + line = ( + ( + await seeded.execute( + select(FinancialStatementLine).where( + FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.row_index.is_not(None) + ) + ) + ) + .scalars() + .first() + ) + assert line is not None + line.line_code_modified = True + line.line_code_original_value = "9999" + line.line_code_text = "9999\n" + (line.line_code or "") + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + (fix,) = fact.payload["fixes"] + assert (fix["field"], fix["source"], fix["rule"]) == ("code", "ours", "Код строки") + assert fix["before_recognized"] == "9999" + assert fix["before_raw"] == line.line_code_text + assert fix["line_ref"] in {entry["ref"] for entry in fact.payload["lines"]} + + +async def test_confirm_osv_outcome_fact_period_account_fix_matches_contract( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """ОСВ с уточнённым периодом/счётом: правка `osv_period_account` — «наш разбор», payload по контракту.""" + _, _, document = await _document(seeded) + document.is_modified = True + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, document.id) + (fix,) = fact.payload["fixes"] + assert (fix["field"], fix["source"], fix["rule"]) == ("osv_period_account", "ours", "Период / счёт ОСВ") + assert fix["after"]["osv_account_code"] == "60" + + @pytest.mark.fixtures async def test_disclosure_confirm_has_no_outcome_fact( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 500007b..7ae6f56 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -59,6 +59,7 @@ from tests.factories import ( make_stored_file, ) from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot +from tests.quality_payload_contract import assert_outcome_payload_valid HTTP_NO_CONTENT = 204 HTTP_FORBIDDEN = 403 @@ -95,9 +96,11 @@ async def _delete_events(session: AsyncSession) -> list[AuditEvent]: async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact: - return ( + fact = ( await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) ).scalar_one() + assert_outcome_payload_valid(fact.payload) # payload соответствует контракту quality-facts + return fact async def _outcome_facts(session: AsyncSession, document_id: uuid.UUID) -> list[QualityOutcomeFact]: diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py index d9635c9..b128e1a 100644 --- a/api/tests/test_auto_confirm.py +++ b/api/tests/test_auto_confirm.py @@ -43,6 +43,7 @@ from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, tr from tests.conftest import AuthClientFactory from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot +from tests.quality_payload_contract import assert_outcome_payload_valid pytestmark = pytest.mark.integration @@ -241,6 +242,7 @@ async def test_auto_confirm_outcome_fact_osv_payload_shape(seeded: AsyncSession) fact = ( await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document.id)) ).scalar_one() + assert_outcome_payload_valid(fact.payload) assert "lines" not in fact.payload assert "values" not in fact.payload assert fact.payload["osv_warnings"] == ["OSV_ROUNDING"] @@ -270,6 +272,7 @@ async def test_auto_confirm_outcome_fact_for_form_has_lines(seeded: AsyncSession await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == income.id)) ).scalar_one() assert fact.outcome == QualityOutcome.AUTO_CONFIRMED + assert_outcome_payload_valid(fact.payload) assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION assert fact.report_date == income.reporting_date assert fact.payload["fixes"] == [] @@ -402,8 +405,10 @@ async def test_recognized_disclosure_is_auto_confirmed_without_outcome_fact( assert disclosure.status is DocumentStatus.CONFIRMED facts = ( - await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id)) - ).scalars().all() + (await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id))) + .scalars() + .all() + ) assert facts == [] diff --git a/api/tests/test_domain_quality_attribution.py b/api/tests/test_domain_quality_attribution.py index a5897cf..f2341ac 100644 --- a/api/tests/test_domain_quality_attribution.py +++ b/api/tests/test_domain_quality_attribution.py @@ -1,8 +1,11 @@ """Атрибуция правки «сервис / наш разбор» (§14.2).""" +from datetime import date + from src.domain.quality_attribution import ( attribute_added_row, attribute_code_fix, + attribute_header_date_fix, attribute_header_fix, attribute_value_fix, ) @@ -60,3 +63,21 @@ def test_attribute_header_fix_service_when_confirmed_not_in_source_text(): def test_attribute_header_fix_ours_after_lookalikes_for_inn(): # ИНН — цифровое поле шапки: двойник буквы рядом с цифрой (О вместо 0) исправляется §11.3 перед сравнением assert attribute_header_fix("7000112233", "7OOO112233") == "ours" + + +def test_attribute_header_date_fix_ours_when_text_date_equals_confirmed(): + # ISO-строка «2025-03-31» в «31.03.2025» подстрокой не ищется, но даты равны → сервис прочитал верно → "ours" + assert attribute_header_date_fix(date(2025, 3, 31), "31.03.2025") == "ours" + + +def test_attribute_header_date_fix_ours_for_text_form_of_date(): + assert attribute_header_date_fix(date(2025, 3, 31), "на 31 марта 2025 г.") == "ours" + + +def test_attribute_header_date_fix_service_when_text_date_differs(): + assert attribute_header_date_fix(date(2025, 3, 31), "30.06.2025") == "service" + + +def test_attribute_header_date_fix_ours_when_text_unparsable(): + # спорное / не разобралось → «наш разбор» (анти-цель design §2) + assert attribute_header_date_fix(date(2025, 3, 31), "дата не указана") == "ours" diff --git a/api/tests/test_domain_quality_facts.py b/api/tests/test_domain_quality_facts.py index 796cd9f..7da30c3 100644 --- a/api/tests/test_domain_quality_facts.py +++ b/api/tests/test_domain_quality_facts.py @@ -87,12 +87,12 @@ def test_build_intake_payload_statement_serializes_line_value_and_issue_fields() ] assert payload["values"] == [ { - "line_id": str(line_id), - "column_id": str(column_id), + "line_ref": "row:3", + "column": str(column_id), "amount": "1500.00", "string_value": None, "source_text": "1 500", - "confidence": "95.00", + "confidence": 95.0, "is_recognized": True, "needs_review": False, } diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 7f8bcfe..2f1b1e0 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -89,6 +89,7 @@ from tests.factories import ( make_stored_file, make_version, ) +from tests.quality_payload_contract import assert_intake_payload_valid, assert_outcome_payload_valid from tests.test_recognition_inbox import envelope, fake_msg API_DIR = Path(__file__).resolve().parents[1] @@ -1547,9 +1548,11 @@ async def test_supersede_previous_reports_dropped_documents_without_changing_rep async def _outcome_fact(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> QualityOutcomeFact: async with maker() as session: - return ( + fact = ( await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) ).scalar_one() + assert_outcome_payload_valid(fact.payload) # payload соответствует контракту quality-facts + return fact async def test_supersede_previous_writes_superseded_fact_for_dropped_document( @@ -2244,9 +2247,11 @@ STATEMENT_VALUE_COUNT = 4 # 2 строки × 2 колонки async def _intake_fact(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> QualityIntakeFact: async with maker() as session: - return ( + fact = ( await session.execute(select(QualityIntakeFact).where(QualityIntakeFact.document_id == document_id)) ).scalar_one() + assert_intake_payload_valid(fact.payload) # payload соответствует контракту quality-facts + return fact async def test_map_candidate_writes_intake_fact_for_statement_document( diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 58a4643..2080733 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -57,7 +57,7 @@ Отдельная схема PostgreSQL (имя — настройка, по умолчанию `dkb_metrics`), миграции — общей цепочкой Alembic. Таблицы плоские: измерения — обычные столбцы, подробности — `jsonb` с версией схемы (`schema_version`). Без FK на рабочие таблицы; идентификаторы документов, файлов, заданий, ЮЛ, ГК — просто `uuid`, имена ЮЛ и ГК копируются на момент факта. -Контракт столбцов и `payload` — `../docs/contracts/quality-facts.schema.json`. Записи только добавляются. +Контракт столбцов и `payload` — `../docs/contracts/metrics/quality-facts.schema.json`. Записи только добавляются. ### 4.1 `quality_intake_fact` — документ «как распознано» @@ -252,3 +252,9 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | R-13 (final review I-3, рулинг владельца): `stored_file.is_scan` и `recognition_job.raw_s3_object_id` — последние колонки таблиц, и в ORM, и в DBML v5.25 (docs); версия DBML не меняется. Тест миграции 0006 проверяет «последняя колонка» на ревизии `0006_dbml_v5_18`, а не на head | миграция 0012 добавляет колонки `ADD COLUMN` (в конец), `test_migrated_schema_equals_orm_create_all` и 0006-тест требуют порядок ORM = порядок миграции; прецедент v5.18 `consolidation_id` | порядок колонок в DBML v5.25 не совпадает с черновиками аналитики; смена порядка в ORM требует правки docs PR #203 (merge вместе) | | 07.11.2026 | R-14 (final review I-4): при удалении файла, у которого есть `quality_intake_fact` с `file_id`, объект `uploads` файла не получает `expires_at` (исключён из `object_ids` и из префикса `uploads/{file_id}/`); определение — один запрос `has_intake_fact_for_file` по `file_id`, а не сравнение `upload_s3_key` | spec quality-metrics-model: объект нужен эталонному прогону (OQ-179); сравнение по `file_id` дешевле и покрывает все факты файла; объект `raw` в `object_ids` не входит и раньше | если у файла несколько документов и факт есть не у всех, объект сохраняется целиком (бакет растёт на размер файла); если `file_id` в факте ошибочен — объект экспирится и эталон потеряется | | 07.11.2026 | R-15 (final review M-3): `has_text_layer` вызывается через `asyncio.to_thread` и разбирает не больше `MAX_PAGES_CHECKED = 10` первых страниц | pypdf синхронен и работал в event loop под advisory-lock загрузки; текстовый слой, если есть, виден на первых страницах формы отчётности (ББ/ОФР — 1-3 листа), 10 — запас | PDF, у которого текст только после 10-й страницы (титулы-сканы), будет помечен `is_scan = true` — метрика «скан» чуть завысит долю сканов | +| 07.11.2026 | R-16 (final review I-2, рулинг владельца-сессии): Контракт `quality-facts.schema.json` перенесён в docs из `docs/contracts/` в `docs/contracts/metrics/` (git mv, коммит в docs-ветке feat/202); копия в arm-core — `api/contracts/metrics/quality-facts.schema.json` (cp, байт-в-байт); ссылки в docs (DBML-заметки, design/proposal/spec/план change) и arm-core обновлены. `contract_identity.py` НЕ менялся | для пути глубины 2 не из `examples/` `_is_required_in_copy` копию не требует, а `docs_pair` сверяет копию arm-core по тому же относительному пути; `tabular-parser` и `scan-gateway` остаются зелёными без копии (проверено в arm-core: `test_contract*` 296 passed с копией). Схема не привязана к NATS-контракту распознавания, подкаталог это подчёркивает | если docs PR #203 сольётся без переноса, тесты идентичности в двух сервисах покраснеют на `quality-facts.schema.json` в корне; обратный перенос потребует правки ссылок в обоих репозиториях | +| 07.11.2026 | R-17 (I-2): Payload `values[]` по контракту: `line_id`/`column_id` → `line_ref` (= `ref` строки в `lines[]`; нет строки в снимке — `str(line_id)`) и `column` (= `str(column_id)`); `confidence` — числом (`float(Decimal)`, не деньги); `lines[].ref` = `source_ref`, при `null` — `str(id)`; `Decimal` сериализуется `format(d, 'f')` (без экспоненты, как требует `$defs/money`) | схема требует `line_ref`/`column`/`ref` строкой и `confidence` number; `column` — идентификатор колонки, а не её подпись/индекс: у значения есть только `column_id`, подпись требовала бы дополнительной выборки колонок в каждом хуке | `column` — UUID, а не читаемая дата колонки: аналитик по payload колонку не опознаёт без join; смена на индекс колонки потребует передавать колонки во все хуки | +| 07.11.2026 | R-18 (I-2): Запись `fixes[]` по контракту: `kind`→`field`, `attribution`→`source`, новые `rule` (строка таблицы §14.2: «Сумма», «Код строки», «Добавленная строка», «Шапка», «Период / счёт ОСВ»), `before_raw` (сырой текст сервиса: `source_text` / `line_code_text` / `header_source[field].text`), `before_recognized` (бывшее `before`), `line_id`→`line_ref` (для значения ещё `column`), для шапки поле — `header_field` (в `field` — `header`, как требует enum). `after` у ОСВ — `{period_year, period_quarter, osv_account_code}` | схема требует `field`/`after`/`source`/`rule` и допускает дополнительные свойства: `line_ref`, `column`, `header_field` — для разбора правки без join; `after` у `osv_period_account` обязателен, а «значение до» в `document` не хранится (`before_*` = null) | `header_field` и `line_ref`/`column` вне обязательных свойств схемы — потребитель не может на них полагаться как на контракт; у ОСВ нет «до» значения периода/счёта | +| 07.11.2026 | R-19 (I-2): Схема контракта НЕ менялась | все поля контракта заполняются; `raw_line_codes` необязателен и по-прежнему не пишется (R-5b, deferred) | — | +| 07.11.2026 | R-20 (I-2): Тест валидации: хелпер `api/tests/quality_payload_contract.py` (jsonschema Draft 2020-12, `$defs/intake-payload` и `$defs/outcome-payload`); хелперы чтения фактов в интеграционных тестах (`_outcome_fact`, `_intake_fact` в test_api_documents/test_api_files_delete/test_result_mapping) валидируют каждый прочитанный payload, test_auto_confirm — явно. Покрыты: приём ББ/ОФР и ОСВ, AUTO_CONFIRMED (форма и ОСВ), CONFIRMED с правками суммы/кода/шапки/ОСВ, DELETED(_AFTER_CONFIRM), SUPERSEDED(_AFTER_CONFIRM) | payload строится реальными сервисными функциями на PostgreSQL, не ручными литералами; `jsonschema` уже в `requirements-dev.txt` | валидация — в тестах, не в рантайме: выход payload за контракт в проде не отсекается | +| 07.11.2026 | R-21 (I-5): Правка отчётной даты шапки (`report_date`): сырой текст `header_source['report_date'].text` разбирается `parse_date` пакета `arm_statements` (после `fix_lookalikes`, `default_rules()`), даты сравниваются как даты (`attribute_header_date_fix`): равны — «наш разбор», различаются — «сервис», текст не разобрался — «наш разбор». Прочие поля шапки — прежнее сравнение подстрокой | ISO `2025-03-31` как подстрока `31.03.2025` не находился → почти любая правка даты шла в «сервис», вопреки анти-цели §2 («спорное — в наш разбор»); §14.2 и design §5 называют `parse_date` | если `parse_date` ошибётся в формате, дата различится — правка уйдёт в «сервис» (завышение доли ошибок сервиса); правка «Дата колонки» по-прежнему не учитывается (R-5a) | diff --git a/openspec/changes/quality-metrics/proposal.md b/openspec/changes/quality-metrics/proposal.md index e296000..009c3a9 100644 --- a/openspec/changes/quality-metrics/proposal.md +++ b/openspec/changes/quality-metrics/proposal.md @@ -24,7 +24,7 @@ ## Затронутые репозитории - **arm-ka-docs** (отдельный change): DBML v5.24 (таблицы фактов, роль бакета `raw`, признак скана, право), правила - подсчёта — новый раздел `05-domain-rules.md` §14, контракт фактов `docs/contracts/quality-facts.schema.json`. + подсчёта — новый раздел `05-domain-rules.md` §14, контракт фактов `docs/contracts/metrics/quality-facts.schema.json`. - **arm-core** (этот change): запись фактов, атрибуция правок, расчёт метрик, API, экран, право в seed. - scan-gateway и tabular-parser не меняются. -- 2.47.2 From e14f1ab2aab4adb215f955be5f2d24231996dca1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 03:57:42 +0400 Subject: [PATCH 38/44] =?UTF-8?q?fix(#190):=20ruff=20format=20auto=5Fconfi?= =?UTF-8?q?rm=20=D0=B8=20recognition=5Fexchange=20(I-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/services/auto_confirm.py | 4 +--- api/src/services/recognition_exchange.py | 7 +++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/api/src/services/auto_confirm.py b/api/src/services/auto_confirm.py index ab1187e..ff25258 100644 --- a/api/src/services/auto_confirm.py +++ b/api/src/services/auto_confirm.py @@ -72,9 +72,7 @@ async def _outcome_fact_fields( issues = await statements_repo.issues(session, document.id) if document.document_type is DocumentType.OSV: row_count = ( - await session.execute( - select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id) - ) + await session.execute(select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id)) ).scalar_one() osv_warnings = [ issue.code diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index 325b1cb..74b38aa 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -249,8 +249,11 @@ async def _failure_dimensions_batch( rows = await session.execute(stmt) return { file_id: _FailureDimensions( - legal_entity_id=legal_entity_id, legal_entity_name=legal_entity_name, group_id=group_id, - group_name=group_name, is_scan=is_scan, + legal_entity_id=legal_entity_id, + legal_entity_name=legal_entity_name, + group_id=group_id, + group_name=group_name, + is_scan=is_scan, ) for file_id, is_scan, legal_entity_id, legal_entity_name, group_id, group_name in rows } -- 2.47.2 From 8f90d0c22fdec7753f54d2f480591e8015fbbfae Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 03:58:20 +0400 Subject: [PATCH 39/44] =?UTF-8?q?fix(#190):=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=20=D1=83=D1=87=D0=B8=D1=82=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=20QUALITY=5FMETRICS=5FVIEW=20(I-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/test_api_admin.py | 11 +++++++++-- api/tests/test_me_and_dev_token.py | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/api/tests/test_api_admin.py b/api/tests/test_api_admin.py index c5ff631..48c099e 100644 --- a/api/tests/test_api_admin.py +++ b/api/tests/test_api_admin.py @@ -39,8 +39,15 @@ ADMIN = "admin@armka.local" USERS = "/admin/users" ROSTOV = "Ростовский региональный филиал" HEAD_OFFICE = "Головной офис" -PERMISSIONS_TOTAL = 16 # seed: 16 кодов прав (§10) -EXPERT_PERMISSIONS = ["CONSOLIDATION_VIEW", "DIRECTORY_VIEW", "DOCUMENT_VIEW", "EX_VIEW", "KFO_VIEW"] +PERMISSIONS_TOTAL = 17 # seed: 17 кодов прав (§10) +EXPERT_PERMISSIONS = [ + "CONSOLIDATION_VIEW", + "DIRECTORY_VIEW", + "DOCUMENT_VIEW", + "EX_VIEW", + "KFO_VIEW", + "QUALITY_METRICS_VIEW", +] async def _office(session: AsyncSession, name: str) -> RegionalOffice: diff --git a/api/tests/test_me_and_dev_token.py b/api/tests/test_me_and_dev_token.py index 2d9fc7f..1393f78 100644 --- a/api/tests/test_me_and_dev_token.py +++ b/api/tests/test_me_and_dev_token.py @@ -49,12 +49,22 @@ KA_RF_PERMISSIONS = sorted( "KFO_EXPORT", "EX_VIEW", "EXPORT", + "QUALITY_METRICS_VIEW", } ) ADMIN_PERMISSIONS = sorted( - {"DIRECTORY_VIEW", "DIRECTORY_EDIT", "DIRECTORY_DELETE", "DIRECTORY_ADMIN", "EX_VIEW", "USERS_ADMIN", "AUDIT_VIEW"} + { + "DIRECTORY_VIEW", + "DIRECTORY_EDIT", + "DIRECTORY_DELETE", + "DIRECTORY_ADMIN", + "EX_VIEW", + "USERS_ADMIN", + "AUDIT_VIEW", + "QUALITY_METRICS_VIEW", + } ) -assert len(KA_RF_PERMISSIONS) == 10 # noqa: PLR2004,S101 - матрица §10 для KA_RF +assert len(KA_RF_PERMISSIONS) == 11 # noqa: PLR2004,S101 - матрица §10 для KA_RF @pytest.fixture(autouse=True) -- 2.47.2 From 3f6bd9638809b2fb9c8c780986e80f7d0cc47c20 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 04:01:45 +0400 Subject: [PATCH 40/44] =?UTF-8?q?docs(#190):=20=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=8B=20OpenSpec=20quality-metrics,=20README,=20CLAUDE.m?= =?UTF-8?q?d,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20design/proposal=20(?= =?UTF-8?q?I-7,=20M-4,=20M-5,=20M-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 ++ README.md | 29 ++++ openspec/changes/quality-metrics/design.md | 17 ++- openspec/changes/quality-metrics/proposal.md | 6 +- .../quality-metrics/specs/authn-authz/spec.md | 21 +++ .../specs/documents-api/spec.md | 84 ++++++++++++ .../specs/osv-result-mapping/spec.md | 107 +++++++++++++++ .../specs/primary-files/spec.md | 93 +++++++++++++ .../specs/quality-metrics-ui/spec.md | 43 ++++++ .../specs/quality-metrics/spec.md | 124 ++++++++++++++++++ .../specs/recognition-job-exchange/spec.md | 59 +++++++++ .../specs/reference-data-seed/spec.md | 53 ++++++++ 12 files changed, 638 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/quality-metrics/specs/authn-authz/spec.md create mode 100644 openspec/changes/quality-metrics/specs/documents-api/spec.md create mode 100644 openspec/changes/quality-metrics/specs/osv-result-mapping/spec.md create mode 100644 openspec/changes/quality-metrics/specs/primary-files/spec.md create mode 100644 openspec/changes/quality-metrics/specs/quality-metrics-ui/spec.md create mode 100644 openspec/changes/quality-metrics/specs/quality-metrics/spec.md create mode 100644 openspec/changes/quality-metrics/specs/recognition-job-exchange/spec.md create mode 100644 openspec/changes/quality-metrics/specs/reference-data-seed/spec.md diff --git a/CLAUDE.md b/CLAUDE.md index 3c828bb..dc9d92a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,16 @@ экспорта сводной ОСВ в xlsx — тот пишет `EXPORT`; OQ-153). `HeaderPatch.units` — непустая строка после обрезки (пробельная — 422). `GET /users/brief` — `current_user` без права; `documents_count` у ЮЛ — `repositories/directories`; CSV чек-листа — `?format=csv` у `GET …/checklist`. Контракт для фронта — `../docs/dev/api-contract-slice-3b.md`; решения — `openspec/changes/archive/2026-10-07-slice-3b-statements-api/design.md`. +- **Факты качества первички (AURORA-1759)** — отдельная схема `dkb_metrics` (`QUALITY_METRICS_SCHEMA`), таблицы `quality_intake_fact` / + `quality_outcome_fact` / `quality_failure_fact` (`models/quality_facts.py`, запись — `repositories/quality_facts.py`, payload — + `domain/quality_facts.py`, атрибуция правок — `domain/quality_attribution.py`, матрица — `domain/quality_metrics.py`, API — + `services/quality_metrics_service.py`, `endpoints/quality_metrics.py`). Факты append-only, без FK, **пишутся хуками в транзакции + события, без своего commit**: приём — `result_mapping._write_intake_facts` (в `_map_candidate` после `map_file`, до `auto_confirm`); + `AUTO_CONFIRMED` — `auto_confirm`; `CONFIRMED` — `DocumentService._write_confirm_outcome_fact` (в `confirm`, правки по живым колонкам, + не по payload приёма); `DELETED(_AFTER_CONFIRM)` — `primary_files.delete`; `SUPERSEDED(_AFTER_CONFIRM)` — автозамена в `result_mapping`; + отказы — `recognition_exchange` (`error`/`timeout`/`dlq`) и `result_mapping._write_mapping_failure_fact`. Новый код, меняющий исход + документа, обязан писать факт тем же способом; DISCLOSURE/OTHER фактов не получают. Метрики — чистая функция от фактов, не хранятся. + Сырой ответ ИС регистрируется в `s3_object` (`bucket_role = raw`); `uploads` и `raw` с фактом приёма не экспирятся при удалении файла. - **Консолидации (Срез 4)** — `api/endpoints/consolidations/` (periods, versions, locks, status, members, checklist, osv, export, mapping), сервисы: `consolidations` (создание, список, карточка, периметр `PUT …/members`), `consolidation_guard` (`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка), diff --git a/README.md b/README.md index d9c176a..fa2c942 100644 --- a/README.md +++ b/README.md @@ -964,6 +964,34 @@ audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `r делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён раньше. Откат снимает только NOT NULL. +## Метрики качества первички (AURORA-1759, DBML v5.25) + +Ядро пишет неизменяемые **факты качества** по документам ББ/ОФР/ОСВ (приём «как распознано», исход, технический отказ) +в отдельную схему `QUALITY_METRICS_SCHEMA` (`dkb_metrics`) и считает по ним метрики. Правила — `../docs/context/05-domain-rules.md` +§14, контракт `payload` — `api/contracts/metrics/quality-facts.schema.json` (копия `../docs/contracts/metrics/`), решения — +`openspec/changes/quality-metrics/design.md`. Считаются только документы, принятые после выкладки. + +API (`/api/v1/quality-metrics`, право `QUALITY_METRICS_VIEW`, выдаётся всем ролям): + +| Маршрут | Назначение | +|---|---| +| `GET /matrix` | матрица «источник × причина» за период (период по дате приёма, фильтры ГК, ЮЛ, тип, источник `pipeline`, скан/цифровой), технические отказы по файлам, «в работе», уникальные отчёты | +| `GET /series` | ряд по месяцам даты приёма для причины (`cause`) | +| `GET /cells/{cell}/documents` | постраничный список документов ячейки (`limit`, `offset`) | +| `GET /export` | выгрузка фактов за период, `fmt=csv` (UTF-8 с BOM) или `json`; пишет `audit_event(EXPORT)` | + +Экран веб-интерфейса — «Качество первички» (`web/src/widgets/QualityMetrics`). + +**Условия выкладки** +- Схема `dkb_metrics` создаётся миграцией; роли миграций нужно право `CREATE` на базу (или схему заранее создаёт платформа; + откат миграции схему не удаляет). Имя схемы — `OPENBAO__APP__QUALITY_METRICS_SCHEMA`, одинаковое для приложения и миграций. +- Superset платформы читает схему отдельной ролью БД **только на чтение** (`USAGE` на схему, `SELECT` на таблицы); роль + создаёт платформа, права ядру не нужны. +- **Обязателен `make seed`** (повторный) после обновления: пункт меню «Качество первички» и доступ к API появляются + только когда право `QUALITY_METRICS_VIEW` и его пары с ролями созданы; без seed меню скрыто у всех, API отвечает 403. +- Порядок merge: PR docs (DBML v5.25, §14, контракт) сливается раньше этого PR. +- Срок хранения сырых ответов ИС (`raw/`) и `uploads/`, на которые ссылаются факты, — вопрос платформы (OQ-179). + ## Ошибки API Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом: @@ -1083,6 +1111,7 @@ email без учёта регистра проверяет только сер | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | | `DB_SCHEMA` | `dkb` | схема PostgreSQL модели данных: MetaData ORM, `search_path` движка приложения, `CREATE SCHEMA` и таблица версий Alembic, схема в миграциях. Имя — строчный идентификатор `[a-z_][a-z0-9_]{0,62}`, не `pg_*` (подставляется в DDL без кавычек; иное — ошибка при старте). **Одно значение для приложения и для `alembic upgrade`:** сменить схему у уже мигрированной БД нельзя (таблицы остаются в старой); в контуре — `OPENBAO__APP__DB_SCHEMA`, в миграционном задании тоже. Читается при импорте моделей, поэтому скрипты и тесты видят его через те же `.env`/окружение | +| `QUALITY_METRICS_SCHEMA` | `dkb_metrics` | схема PostgreSQL фактов качества первички (`quality_*_fact`, AURORA-1759): те же правила имени, что у `DB_SCHEMA`; значение должно отличаться от `DB_SCHEMA` (иначе ошибка при старте). Миграция `0012_dbml_v5_25` создаёт схему (`CREATE SCHEMA IF NOT EXISTS`); в контуре — `OPENBAO__APP__QUALITY_METRICS_SCHEMA`, одно значение для приложения и для `alembic upgrade` | | `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | | `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны). В обоих режимах фронту открыт заголовок `Content-Disposition` (`Access-Control-Expose-Headers`) — имя файла экспорта (#173) | | `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) | diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 2080733..5d709d7 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -41,7 +41,7 @@ - Инварианты `../CLAUDE.md`: первичка не мутируется; единственный писатель в БД — arm-core; производные не хранятся. Факты — не производные консолидации, а журнал событий (как `audit_event` / `ex_snapshot`); метрики — чистая функция от фактов, не хранятся. -- Поля — только из DBML: таблицы фактов, роль бакета, признак скана, объект права появляются в DBML v5.24 (change в +- Поля — только из DBML: таблицы фактов, роль бакета, признак скана, объект права появляются в DBML v5.25 (change в `../docs`) до кода. - Нормализацию ББ/ОФР делают сервисы; в ядро приходит нормализованный `result.json`. «Сырой текст сервиса» в ядре — это `financial_statement_value.source_text`, `financial_statement_line.line_code_text`, @@ -201,7 +201,7 @@ Superset читает схему фактов отдельной ролью БД ## 11. Оставлено исполнителю -Имена таблиц и столбцов (в рамках DBML v5.24), формат `payload`, раскладка экрана, способ определения текстового слоя +Имена таблиц и столбцов (в рамках DBML v5.25), формат `payload`, раскладка экрана, способ определения текстового слоя PDF, точный вид выгрузки, разбиение на задачи. ## Decisions made without discussion @@ -229,7 +229,7 @@ PDF, точный вид выгрузки, разбиение на задачи. | 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана | | 06.11.2026 | Task 13: `web/src/api/types.ts` — `'QUALITY_METRICS'` дописан в конец union `PermissionObject` ОДНОЙ строкой (не многострочным форматом), итоговая строка ровно 140 символов — на пределе, но не сверх лимита ширины biome | сохраняет однострочный стиль, которым уже написана эта строка (и соседняя `PermissionAction`); многострочный вариант рассматривался как равноценная по типам альтернатива, выбор между ними непринципиален | нулевая — чисто форматирование, обе формы типобезопасно идентичны | | 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит | -| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл уже есть в `../docs/contracts/` (сиблинг-репозиторий arm-ka-docs, работа по нему уже слита туда), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | +| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл есть в `../docs/contracts/metrics/` (сиблинг-репозиторий arm-ka-docs, ветка feat/202, PR docs #203 — НЕ слита; порядок merge: docs PR #203 сливается раньше PR arm-core; позже файл вендорен — R-16), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | | 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 | | 06.11.2026 | Task 8 (`DELETED`/`DELETED_AFTER_CONFIRM`): (1) документ в статусе, отличном от `CONFIRMED`/`RECOGNIZED`/`NEEDS_CHECK` (практически только `ERROR`), при удалении файла НЕ получает факт исхода — у него никогда не было содержательного состояния «как распознано» для фиксации (нет формы/строк, не проходил ни через `AUTO_CONFIRMED`, ни через intake-факт); (2) `issue_count_at_outcome` вычисляется как `counts.error + counts.info` из уже посчитанного агрегата `issue_counts_by_document` вместо отдельного запроса — тот же смысл «все нарушения документа без фильтра», что `len(issues)` в Task 6/7, дешевле на один запрос; (3) `payload` для DELETED*-фактов — единый минимальный вызов `build_outcome_payload(document, fixes=())` без дополнительных запросов к строкам/значениям формы или ОСВ (эти строки всё равно будут физически удалены этим же вызовом `delete`, повторный их снимок здесь экономически не оправдан — а в матрицу §6 эти факты и не попадают, доступны только как «удалён/заменён», drill-down уже видит прежний исход через более ранний intake/outcome факт того же `document_id`) | бриф задачи 8 явно делегировал все три пункта исполнителю («если неоднозначно — зафиксировать выбор, не угадывать»); исполнитель выбрал и задокументировал в докстрингах кода, перенесено сюда контроллером по правилу маркировки находок (решение агента без обсуждения с человеком — в design.md) | (1): при появлении у `ERROR`-документов содержательного состояния до удаления (гипотетически) — нужно будет расширить список статусов; (2)/(3): минимальный риск — агрегаты эквивалентны, а payload не используется для атрибуции (там `primary_cause=None`, вне матрицы) | | 06.11.2026 | Task 10 (`quality_failure_fact`): `error_code` для отказов маппинга (`_fail`, `_register_failure`) — константа `MAPPING_FAILURE_ERROR_CODE = "MAPPING_FAILED"` (`result_mapping.py`), не свободный текст из `message`/описания исключения | `QualityFailureFact.error_code` не имеет свободнотекстового столбца-соседа для деталей отказа маппинга (в отличие от `recognition_job.error_message`); деталь остаётся в `metrics.mapping_error`/логе, код — единая константа, отличимая от кодов обмена (`ERROR_NO_ACK`/`ERROR_INVALID_REQUEST`), что проверяет отдельный тест | если деталь отказа маппинга понадобится в матрице причин без обращения к логам — нужен отдельный столбец/payload-поле, не замена константы | @@ -240,8 +240,8 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | Task 15 (API): последний факт исхода на документ — `DISTINCT ON (document_id) ORDER BY document_id, outcome_at DESC, ранг DESC, id DESC` во всех запросах; ранг (`*_AFTER_CONFIRM` 2, подтверждение 1, остальное 0) совпадает с tie-break домена | SQL и домен должны выбирать один и тот же факт | расхождение выбора даёт разные числа в матрице и в списке ячейки | | 07.11.2026 | Task 15: выборка идёт от фактов приёма (LEFT JOIN последнего исхода); исход без факта приёма в метрики не входит; границы периода включительные по UTC (`date_to` отсекается в 00:00 UTC следующего дня) | у исхода без приёма нет даты приёма (когорта); UTC — единая зона БД | для пользователей в МСК граница периода сдвинута на 3 часа | | 07.11.2026 | Task 15: технические отказы — поле `technical_failures {failed_files_count, total_files_count, share}`, период по `failed_at`, `total_files_count` — уникальные `file_id` из приёма и отказов; «в работе» (`in_progress_count`) и `unique_reports_count` (ЮЛ + отчётная дата + тип) — отдельные поля; в ответе отдельно `superseded_after_confirm_count`, `deleted_after_confirm_count` | design §6, решение 27.10 про отказы по файлам | доля отказов считается от другого знаменателя, чем доли причин | -| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` пишется (см. R-12; исходное «нет в `audit_action`» было неверно) | design §7/§8; ячейки шире причин | выгрузка не оставляет следа в аудите; Excel может показать BOM в чужих импортёрах | -| 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY | +| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` пишется (см. R-12; исходное «нет в `audit_action`» было неверно) | design §7/§8; ячейки шире причин | Excel может показать BOM в чужих импортёрах | +| 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3, R-9) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY | | 07.11.2026 | Task 16 (R-10): на экране нет фильтра по ЮЛ (есть по ГК, периоду, типу, источнику, скану); API параметр `legal_entity_id` поддерживает | нужен отдельный пикер ЮЛ (тысячи записей); владелец: «главное наличие данных» | в UI нельзя посмотреть качество по одному ЮЛ — только через API/выгрузку | | 07.11.2026 | Task 16 (R-11): матрица «источник × причина» — три параллельных запроса (`pipeline=image`, `pipeline=tabular`, без `pipeline`), колонки «PDF / Excel / Всего»; при выбранном источнике — одна колонка; «Всего» берётся с бэка, не суммой колонок | design §2/§7 требуют сравнения PDF и Excel; бэкенд отдаёт источник только фильтром | три запроса вместо одного на каждую смену фильтров | | 07.11.2026 | Task 16: «Правки: сервис / наш разбор» в списке документов — суммы полей `*_service` и `*_ours`, `osv_period_account` не входит | в задаче названы только эти два семейства | правки периода и счёта ОСВ в колонке не видны | @@ -258,3 +258,10 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | R-19 (I-2): Схема контракта НЕ менялась | все поля контракта заполняются; `raw_line_codes` необязателен и по-прежнему не пишется (R-5b, deferred) | — | | 07.11.2026 | R-20 (I-2): Тест валидации: хелпер `api/tests/quality_payload_contract.py` (jsonschema Draft 2020-12, `$defs/intake-payload` и `$defs/outcome-payload`); хелперы чтения фактов в интеграционных тестах (`_outcome_fact`, `_intake_fact` в test_api_documents/test_api_files_delete/test_result_mapping) валидируют каждый прочитанный payload, test_auto_confirm — явно. Покрыты: приём ББ/ОФР и ОСВ, AUTO_CONFIRMED (форма и ОСВ), CONFIRMED с правками суммы/кода/шапки/ОСВ, DELETED(_AFTER_CONFIRM), SUPERSEDED(_AFTER_CONFIRM) | payload строится реальными сервисными функциями на PostgreSQL, не ручными литералами; `jsonschema` уже в `requirements-dev.txt` | валидация — в тестах, не в рантайме: выход payload за контракт в проде не отсекается | | 07.11.2026 | R-21 (I-5): Правка отчётной даты шапки (`report_date`): сырой текст `header_source['report_date'].text` разбирается `parse_date` пакета `arm_statements` (после `fix_lookalikes`, `default_rules()`), даты сравниваются как даты (`attribute_header_date_fix`): равны — «наш разбор», различаются — «сервис», текст не разобрался — «наш разбор». Прочие поля шапки — прежнее сравнение подстрокой | ISO `2025-03-31` как подстрока `31.03.2025` не находился → почти любая правка даты шла в «сервис», вопреки анти-цели §2 («спорное — в наш разбор»); §14.2 и design §5 называют `parse_date` | если `parse_date` ошибётся в формате, дата различится — правка уйдёт в «сервис» (завышение доли ошибок сервиса); правка «Дата колонки» по-прежнему не учитывается (R-5a) | +| 06.11.2026 | R-4 (Task 7): атрибуция правок суммы/кода/шапки читает ЖИВЫЕ колонки (`is_modified`, `original_amount`, `source_text`, `line_code_modified`, `line_code_original_value`, `*_modified`/`*_original_value` шапки), а не JSON `quality_intake_fact.payload`, как буквально предлагал план; в `documents.py` docstring ссылается на «отчёт задачи 7» | `source_text` неизменяем (инвариант «первичка не мутируется»), поэтому живые колонки и снимок приёма равны по содержанию; типизированные колонки проще и надёжнее, чем разбор чужого JSON; функции `attribute_*` принимают строки и источник-агностичны | если понадобится сравнение по payload приёма — сменить источник в одном месте (`_write_confirm_outcome_fact`); docstring ссылается на файл отчёта, который удаляется вместе с SDD-workspace | +| 06.11.2026 | R-5a (Task 7): правка «Дата колонки» (§14.2) НЕ учитывается в счётчиках атрибуции | у `financial_statement_column` нет `*_modified`/`original_*`; правки дат колонок есть лишь в общем механизме аудита `services/statements.py` без чистого запроса «менялась ли дата колонки»; нужен новый столбец (схема) — вне объёма change | правки дат колонок в метриках не видны (занижены правки «наш разбор»/«сервис»); deferred, в Jira при закрытии (M-8) | +| 06.11.2026 | R-5b (Task 7): набор четырёхзначных кодов сырого ответа ИС (`raw_line_codes`, design §5) при приёме не извлекается и в снимок не кладётся; поле контракта необязательно и не пишется | задачи 2/5 извлечение не реализовали; адаптер шага 1 живёт в scan-gateway; извлечение вне объёма change | атрибуция добавленных строк PDF неполна; направление — см. R-6; deferred, в Jira (M-8) | +| 06.11.2026 | R-6 (Task 7, меняет направление R-5b): добавленная строка PDF при отсутствии набора кодов относится к «нашему разбору» (прямая атрибуция без вызова `attribute_added_row` с пустым набором) | пустой набор давал бы «сервис» всегда, вопреки уже принятому решению 27.10 («спорное — в наш разбор», строка про код добавленной строки) и анти-цели §2 | метрика сервиса чуть лучше реальной на добавленных строках (стоимость уже заложена в записи 27.10) | +| 06.11.2026 | R-7 (Task 7, сведён I-2): несоответствие формы `payload.fixes`/`values` контракту `quality-facts.schema.json` было отложено и вынесено в финальное ревью | ни одна задача плана не владела приведением payload к контракту, контракт не был вендорен, тестов валидации не было | закрыто в I-2: R-16…R-21 (вендоринг, приведение payload, тест валидации) | +| 07.11.2026 | R-22 (final review M-7): условие §14.4 «нарушенное соотношение не задето правками» НЕ проверяется: `checks_accepted = meta.checks_accepted_by is not None` (`documents._write_confirm_outcome_fact`), то есть любой акцепт нарушений при ручном подтверждении | на ячейку матрицы не влияет: при любой правке (`service`/`ours`) приоритет §6 п.2-3 выше «документа клиента», а без правок задетое правками соотношение невозможно; влияет только на поле `checks_accepted` в факте и на «документ клиента» у документа без правок — где условие выполняется тривиально. Точная проверка требует пересчёта соотношений до/после правок | если акцепт дан, а часть соотношений исправлена правками, поле `checks_accepted` у такого факта завышено; ячейка остаётся «сервис»/«наш разбор» | +| 07.11.2026 | R-23 (final review I-7): дельты OpenSpec — ADDED `quality-metrics`, `quality-metrics-ui`; MODIFIED `primary-files` (загрузка, удаление), `recognition-job-exchange` (inbox, таймаут), `documents-api` (подтверждение), `osv-result-mapping` (цикл маппинга, автоподтверждение), `authn-authz` (`GET /me`), `reference-data-seed` (seed, 16 → 17 прав) | `openspec validate --strict` требует полного текста изменяемого требования, поэтому MODIFIED копируют текущее требование из `openspec/specs/` с добавлением; авторизационная спека не перечисляет права, поэтому право отражено сценарием `/me` | при архивации дельты заменят требования целиком — правки спек основной ветки, сделанные параллельно, потеряются (проверить конфликт перед `openspec archive`) | diff --git a/openspec/changes/quality-metrics/proposal.md b/openspec/changes/quality-metrics/proposal.md index 009c3a9..5d73bc3 100644 --- a/openspec/changes/quality-metrics/proposal.md +++ b/openspec/changes/quality-metrics/proposal.md @@ -23,18 +23,18 @@ ## Затронутые репозитории -- **arm-ka-docs** (отдельный change): DBML v5.24 (таблицы фактов, роль бакета `raw`, признак скана, право), правила +- **arm-ka-docs** (отдельный change): DBML v5.25 (таблицы фактов, роль бакета `raw`, признак скана, право), правила подсчёта — новый раздел `05-domain-rules.md` §14, контракт фактов `docs/contracts/metrics/quality-facts.schema.json`. - **arm-core** (этот change): запись фактов, атрибуция правок, расчёт метрик, API, экран, право в seed. - scan-gateway и tabular-parser не меняются. Вне срезов `09-delivery-plan.md`: спринт 3, эпик AURORA-1353. -## Затрагиваемые таблицы DBML v5.23 +## Затрагиваемые таблицы DBML v5.25 `recognition_job` (ключ сырого ответа), `s3_object` / `s3_bucket_role` (роль `raw`), `stored_file` (признак скана), `document`, `financial_statement_meta|column|line|value`, `osv_entry`, `document_issue`, `audit_event` (читаются при -записи фактов), `permission_object` (новый объект). Новые таблицы фактов — в DBML v5.24 до кода. +записи фактов), `permission_object` (новый объект). Новые таблицы фактов — в DBML v5.25 до кода. ## Non-goals diff --git a/openspec/changes/quality-metrics/specs/authn-authz/spec.md b/openspec/changes/quality-metrics/specs/authn-authz/spec.md new file mode 100644 index 0000000..b8d93b8 --- /dev/null +++ b/openspec/changes/quality-metrics/specs/authn-authz/spec.md @@ -0,0 +1,21 @@ +# Spec Delta + +## Purpose + +Дельта change `quality-metrics` (метрики качества первички). + +## MODIFIED Requirements + +### Requirement: `GET /api/v1/me` +Маршрут SHALL возвращать текущего пользователя: `id`, `email`, `full_name`, `regional_office {id, name, is_head_office}`, +`business_segment`, `roles` (список `code`), `role_details` (те же активные роли списком `{code, name}`, `name` — `role.name`, +порядок как в `roles`), `permissions` (отсортированный список `code`). Это единственный маршрут +Среза 0, доступный любому аутентифицированному пользователю без `require`. Состав `permissions` определяется матрицей ролей (§10), включая `QUALITY_METRICS_VIEW`, который seed выдаёт всем ролям. + +#### Scenario: Ответ для dev-пользователя +- **WHEN** `ka_rf@armka.local` (роль `KA_RF`) запрашивает `/api/v1/me` +- **THEN** ответ 200 содержит `roles = ["KA_RF"]`, `role_details` с одним элементом `{code: "KA_RF", name: <непустое название>}` и `permissions`, равные правам `KA_RF` из матрицы §10 + +#### Scenario: Право метрик в профиле +- **WHEN** любой пользователь запрашивает `/api/v1/me` после `make seed` +- **THEN** `permissions` содержит `QUALITY_METRICS_VIEW`, отсортированно, перед `USERS_ADMIN` diff --git a/openspec/changes/quality-metrics/specs/documents-api/spec.md b/openspec/changes/quality-metrics/specs/documents-api/spec.md new file mode 100644 index 0000000..53fa8f5 --- /dev/null +++ b/openspec/changes/quality-metrics/specs/documents-api/spec.md @@ -0,0 +1,84 @@ +# Spec Delta + +## Purpose + +Дельта change `quality-metrics` (метрики качества первички). + +## MODIFIED Requirements + +### Requirement: Подтверждение документа +`POST /api/v1/documents/{document_id}/confirm` (`DOCUMENT_EDIT`, policy) SHALL переводить документ из `RECOGNIZED` +или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`; +из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом +другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Для `BALANCE`/`INCOME` дополнительно: пустые +`period_year` или `period_quarter` — 409 `PERIOD_REQUIRED`; строки `unreadable` / `missing` с непустой суммой или текстом значения — 409 `LINES_UNRESOLVED` (`detail.lines[] {line_id, row_index, line_code_text}`); вторая подтверждённая форма того же типа и периода в файле — 409 `DUPLICATE_FORM` (`detail.document_id`); записи `document_issue` вида `relation` без акцепта — 409 +`CHECKS_NOT_PASSED` со списком нарушений в `detail.failures`; при подтверждении SHALL записываться +`financial_statement_meta.reviewed_by/at`. Несовпадение контрагента и уверенность значений подтверждение MUST NOT +блокировать. Если совпали несколько условий, порядок: `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, +`DOCUMENT_NOT_CONFIRMABLE`, `PERIOD_REQUIRED`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `CHECKS_NOT_PASSED`. Подтверждение берёт блокировки в порядке «файл → +документ → (advisory-блокировка привязок ГК) → версии консолидации (по возрастанию `id`)»: файл — `FOR UPDATE`, +документ — `FOR NO KEY UPDATE` (R-3; совместима со вставкой привязки `FOR KEY SHARE`), версии — `FOR UPDATE`; состояние +перечитывается под этими блокировками; документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`. +В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`, +`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии, +`period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал +версии; false → `period_quarter` ≤ квартала версии). ОСВ за `Q1` равнозначна нарастающей и квартальной (R-14): она +подходит любой версии года при любом `is_cumulative`; ББ/ОФР за `Q1` — отчёт на конец квартала и подходят только версии +за `Q1` (R-14a). Привязанный ранее документ того же ЮЛ с тем же ключом (`document_type`, `osv_account_code`, +`period_quarter`, признак нарастающего: `is_cumulative` true или NULL; для ОСВ за `Q1` признак не различается) в той же +версии SHALL отвязываться (R-1). Каждая версия, получившая +привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = +подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. В той же транзакции ручное подтверждение документа `BALANCE` / `INCOME` / `OSV` SHALL записывать `quality_outcome_fact` (`outcome = CONFIRMED`) с отнесением правок по живым колонкам документа (`is_modified`, `original_*`, `source_text`), а не по `payload` приёма; `checks_accepted` — признак акцепта проверок шапкой (на ячейку матрицы не влияет: при правках приоритет у «сервиса» и «нашего разбора»). `DISCLOSURE` / `OTHER` факта не получают. + +#### Scenario: Причина ошибки маппинга в карточке файла +- **WHEN** файл в `ERROR` после неудачного маппинга (задание `done`, `error_code` пуст) +- **THEN** `job.mapping_error` содержит причину, `job.error_code` пуст + +#### Scenario: Документ не подтверждается +- **WHEN** подтверждается документ в статусе `ERROR` +- **THEN** 409 `DOCUMENT_NOT_CONFIRMABLE`, статус не изменился + +#### Scenario: Подтверждение +- **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала +- **THEN** `status = CONFIRMED`, `confirmed_by` = пользователь, `audit_event(DOCUMENT_CONFIRM)` с `before_value.status = NEEDS_CHECK` + +#### Scenario: Привязка к версии и переход статуса +- **WHEN** версия ГК 2024 Q3 в `INITIATED` с ЮЛ в периметре; подтверждается квартальная ОСВ 60 этого ЮЛ за 2024 Q2 +- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории + `INITIATED → DATA_CORRECTION` с `changed_by` = пользователь + +#### Scenario: Не подходит по периоду +- **WHEN** подтверждается нарастающая ОСВ 60 за 2024 Q2 при версии за 2024 Q3 +- **THEN** привязки нет, статус версии не изменился + +#### Scenario: Версия в CANCELLED +- **WHEN** подходящий документ подтверждается при версии `CANCELLED` +- **THEN** привязки нет + +#### Scenario: Форма с нарушением проверок +- **WHEN** подтверждается `BALANCE` с непройденным жёстким соотношением без акцепта +- **THEN** 409 `CHECKS_NOT_PASSED`, в `detail.failures` — нарушение; после акцепта подтверждение проходит + +#### Scenario: Форма без периода +- **WHEN** подтверждается форма из OCR, у которой нет ни даты отчёта, ни дат колонок +- **THEN** 409 `PERIOD_REQUIRED`; после правки даты отчёта подтверждение проходит + +#### Scenario: Форма в чек-листе +- **WHEN** версия ГК 2025 Q3 `INITIATED`, ЮЛ в периметре; подтверждается его `BALANCE` за 2025 Q3 +- **THEN** привязка создана, ячейка «Бух. баланс» чек-листа ЮЛ — `CONFIRMED`, `reviewed_by` шапки = пользователь + +#### Scenario: Неразобранная строка с суммой +- **WHEN** у формы строка `missing` с суммой и одновременно жёсткое нарушение +- **THEN** 409 `LINES_UNRESOLVED` с этой строкой в `detail.lines` + +#### Scenario: Неразобранная строка только с текстом +- **WHEN** у формы строка `unreadable` без сумм, но с мусором `211(1 |` в ячейке значения (`string_value`) +- **THEN** 409 `LINES_UNRESOLVED` — потерянная сумма тоже требует разбора + +#### Scenario: Вторая форма того же периода +- **WHEN** в файле два ББ на 30.09.2025, первый подтверждён, аналитик подтверждает второй +- **THEN** 409 `DUPLICATE_FORM` со ссылкой на первый + +#### Scenario: Подтверждение с правкой суммы +- **WHEN** аналитик исправил сумму и подтвердил ББ +- **THEN** записан `quality_outcome_fact` `CONFIRMED` со счётчиком правки значения по источнику и `payload.fixes` diff --git a/openspec/changes/quality-metrics/specs/osv-result-mapping/spec.md b/openspec/changes/quality-metrics/specs/osv-result-mapping/spec.md new file mode 100644 index 0000000..ef8cf52 --- /dev/null +++ b/openspec/changes/quality-metrics/specs/osv-result-mapping/spec.md @@ -0,0 +1,107 @@ +# Spec Delta + +## Purpose + +Дельта change `quality-metrics` (метрики качества первички). + +## MODIFIED Requirements + +### Requirement: Цикл маппинга отделён от inbox +Фоновая задача ядра SHALL с периодом `MAPPING_POLL_SECONDS` (5) выбирать одно задание со `status ∈ {done, +needs_check}`, чей файл имеет `status = PROCESSING` и `current_recognition_job_id` = это задание (`FOR UPDATE SKIP +LOCKED`), проверять, что `result_s3_object` относится к бакету хранилища и его ключ начинается с +`results/{file_id}/{job_id}/` (OQ-70), читать объект из S3 **вне** транзакции, валидировать `RecognitionResult` v1.x и +в одной транзакции создать документы и строки (листы ОСВ и формы ББ/ОФР), записать `recognition_job.group_schema`, +перевести файл в `PROCESSED` и выполнить автозамену. Результат без документов SHALL давать неповторяемую ошибку +маппинга «в результате нет документов»; результат с формами, но без ОСВ, ошибкой MUST NOT быть. Inbox MUST NOT читать +файл результата и MUST NOT создавать документы. После `map_file` и до автоподтверждения транзакция маппинга SHALL писать факты приёма `quality_intake_fact` по документам `BALANCE` / `INCOME` / `OSV`; неповторяемая ошибка маппинга (файл → `ERROR`) SHALL писать `quality_failure_fact` (`failure_kind = MAPPING`, `error_code = MAPPING_FAILED`). + +#### Scenario: Результат смапплен +- **WHEN** в S3 лежит результат парсера для `rb/2024-FY/ОСВ_66 за 2024.xls`, задание `done`, файл `PROCESSING` +- **THEN** не позднее чем через `MAPPING_POLL_SECONDS` + время маппинга файл — `PROCESSED`, создан один `document` + (`OSV`, `osv_account_code = "66"`, `period_year = 2024`, `period_quarter = Q4`, `is_cumulative = true`, `locator = sheet:Лист_1`), + `osv_entry` — все строки результата кроме `TOTAL`, `group_schema` задания содержит `[{"locator": "sheet:Лист_1", "groups": [...]}]` + +#### Scenario: Ключ результата вне папки задания +- **WHEN** `result_s3_object.key = results/<другой file_id>/.../result.json` +- **THEN** файл — `ERROR` с ошибкой маппинга, документы не созданы, повтор не выполняется + +#### Scenario: Сбой маппинга с повтором +- **WHEN** вставка строк падает по ошибке БД +- **THEN** транзакция откачена, `metrics.mapping_attempts = 1`, файл остаётся `PROCESSING`; на третьей неудаче — `ERROR` + с `metrics.mapping_error` + +#### Scenario: Повторный маппинг идемпотентен +- **WHEN** маппинг прерван после частичной вставки (имитация) и выполнен снова +- **THEN** у файла ровно один набор документов и строк, дублей по `(document_id, row_number)` нет + +#### Scenario: Файл только с формами +- **WHEN** смаплен golden-снимок `mezheninovskaya/2025-Q3/1_Форма1_бух_09` (одна форма `BALANCE`, ОСВ нет) +- **THEN** файл `PROCESSED`, создан документ `BALANCE`, ошибки «нет документов ОСВ» нет + +#### Scenario: Пустой результат +- **WHEN** результат без документов (`documents = []`): контракт v1.x такого результата не допускает — его останавливает проверка схемы, а прямой вызов `map_file` даёт `MappingFatal` +- **THEN** файл `ERROR`, причина в `metrics.mapping_error` (при проверке схемы — ошибка схемы, в `map_file` — «в результате нет документов»), повтор не выполняется + +#### Scenario: Факт приёма в транзакции маппинга +- **WHEN** результат смаплен и создан документ ОСВ +- **THEN** в той же транзакции записан `quality_intake_fact` документа до перехода в `CONFIRMED` + +#### Scenario: Неповторяемая ошибка маппинга +- **WHEN** маппинг завершился неповторяемой ошибкой +- **THEN** файл `ERROR` и записан `quality_failure_fact` с `failure_kind = MAPPING` + +### Requirement: Автоподтверждение чистого документа +В транзакции маппинга после автозамены прежних версий файла и до перехода версий по `NEEDS_CHECK` цикл маппинга SHALL +перевести каждый документ файла, получивший при маппинге статус `RECOGNIZED`, в `CONFIRMED` (ТЗ 03_9 п. 4, 10_10; +решение владельца 08.10.2026, OQ-157): `confirmed_by = NULL`, `confirmed_at = now`; у формы `BALANCE`/`INCOME` — +`financial_statement_meta.reviewed_by = NULL`, `reviewed_at = now`; `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS, +object_type = document)` с `user_id = NULL`, `before_value.status = RECOGNIZED`, `after_value.status = CONFIRMED` и +`description = «автоподтверждение: без замечаний»`. Форма `BALANCE`/`INCOME` с шапкой SHALL подтверждаться +автоматически, только если выполнены условия ручного подтверждения (`period_year` и `period_quarter` определены, нет +непройденных неакцептованных жёстких проверок §7); иначе она MUST оставаться `RECOGNIZED` без ошибки. Документы +`NEEDS_CHECK` MUST NOT подтверждаться автоматически. + +Автоподтверждённый документ SHALL привязываться к версиям так же, как при ручном подтверждении (`documents-api` +«Подтверждение документа»: все подходящие версии «в работе», замена привязки того же ключа R-1), и каждая версия, +получившая привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` с `changed_by = +stored_file.uploaded_by` и `comment = «автоподтверждение документа »` (счёт — только у +ОСВ). Если `uploaded_by` NULL, привязка SHALL создаваться, а переход — пропускаться с записью в лог. Пересчёт статуса +формы после правки или акцепта MUST NOT подтверждать документ автоматически. Для каждого автоподтверждённого документа `BALANCE` / `INCOME` / `OSV` SHALL записываться `quality_outcome_fact` (`AUTO_CONFIRMED`, `primary_cause = WITHOUT_PARTICIPATION`, `confirmed_by` пуст); автозамена прежних версий SHALL писать `SUPERSEDED` / `SUPERSEDED_AFTER_CONFIRM` по вытесненным документам. + +#### Scenario: Чистая ОСВ сразу «Загружен» +- **WHEN** смаплен результат `fixtures/osv/rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` для ЮЛ АО «Русский бисквит» + (задание `done`, шапка совпадает с владельцем, нарушений и предупреждений нет) +- **THEN** документ `OSV` 60 — `CONFIRMED`, `confirmed_by` NULL, `confirmed_at` заполнен; есть `audit_event(DOCUMENT_CONFIRM)` + с `user_id` NULL и описанием «автоподтверждение: без замечаний» + +#### Scenario: Привязка и переход версии +- **WHEN** версия ГК «Русский Бисквит» 2025 Q1 в `INITIATED`, РБ в периметре; смаплен чистый результат ОСВ 60 РБ за 2025 Q1, + файл загрузил `ka_go` +- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории + `INITIATED → DATA_CORRECTION` с `changed_by` = `ka_go` и комментарием «автоподтверждение документа OSV 60» + +#### Scenario: Документ с замечанием ждёт аналитика +- **WHEN** файл ОСВ загружен для ЮЛ «Пекарня Счастливочная», а в шапке `АО "Русский бисквит"` +- **THEN** документ `NEEDS_CHECK`, `confirmed_at` пуст, события `DOCUMENT_CONFIRM` нет, привязки нет + +#### Scenario: Чистая форма Excel +- **WHEN** смаплен `statements-xlsx/mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, период определён) +- **THEN** документ `INCOME` `CONFIRMED`, `reviewed_by` NULL, `reviewed_at` заполнен + +#### Scenario: Форма OCR не подтверждается сама +- **WHEN** смаплен эталон `ocr/expected_normalize/БО 1 кв 2025` (уверенность значений < 100, дата отчёта не разобрана) +- **THEN** документы `BALANCE` и `INCOME` — `NEEDS_CHECK`, не `CONFIRMED` + +#### Scenario: Файл без загрузившего +- **WHEN** версия в `INITIATED`, ЮЛ в периметре; смаплен чистый результат файла с `uploaded_by` NULL +- **THEN** документ `CONFIRMED` и привязан к версии; версия осталась `INITIATED`; в логе — строка о пропуске перехода + +#### Scenario: Автозамена чистой версией файла +- **WHEN** документ ОСВ 60 РБ за 2025 Q1 из файла A привязан к версии 2025 Q1; загружен и смаплен файл B того же ключа + (чистый), A заменён автозаменой +- **THEN** привязка документа A снята, документ B `CONFIRMED` и привязан к версии + +#### Scenario: Факт автоподтверждения +- **WHEN** документ автоподтверждён +- **THEN** есть `quality_outcome_fact` `AUTO_CONFIRMED` для этого документа diff --git a/openspec/changes/quality-metrics/specs/primary-files/spec.md b/openspec/changes/quality-metrics/specs/primary-files/spec.md new file mode 100644 index 0000000..b7fa962 --- /dev/null +++ b/openspec/changes/quality-metrics/specs/primary-files/spec.md @@ -0,0 +1,93 @@ +# Spec Delta + +## Purpose + +Дельта change `quality-metrics` (метрики качества первички). + +## MODIFIED Requirements + +### Requirement: Загрузка файлов организации +`POST /api/v1/legal-entities/{legal_entity_id}/files` (multipart, одно или несколько полей `files`; право +`DOCUMENT_EDIT`; ЮЛ в области видимости пользователя, иначе 404) SHALL для каждого файла в отдельной транзакции: +определить тип по расширению и сверить с сигнатурой содержимого (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`, +`jpg`); отвергнуть файл больше `UPLOAD_MAX_BYTES` (50 МиБ) или неподдерживаемого типа; вычислить sha256; создать +`s3_object` (`bucket_role = uploads`, ключ `uploads/{file_id}/{job_id}/{безопасное имя}`, `sha256`, `size_bytes`, +`content_type`, `created_by = user:{id}`), положить объект в S3, создать `stored_file` (`status = UPLOADED`, +`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`, `consolidation_id = NULL`) и +`recognition_job` (`status = queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`; +`max_attempts` 3 / 1), установить `stored_file.current_recognition_job_id`, записать +`audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)` с `after_value.consolidation_id = null`. Ответ SHALL +перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`, +`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT +запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`. +Query-параметр `consolidation_version_id` MUST NOT приниматься (удалён в v5.18): загрузка этим маршрутом — без +контекста консолидации и не меняет статусы версий; загрузка в консолидацию — требование «Загрузка файлов в +консолидацию». Для PDF `stored_file.is_scan` SHALL определяться при загрузке по наличию текстового слоя в первых 10 страницах (разбор pypdf — вне event loop, `asyncio.to_thread`): нет текста — скан (`true`), есть — цифровой (`false`); для не-PDF — NULL. PDF со скрытым OCR-слоем считается цифровым. + +#### Scenario: Реальная ОСВ принята и ушла в outbox +- **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала +- **THEN** 201, `accepted[0].file_id` существует; `stored_file.status = UPLOADED`, `file_type = XLS`, `content_hash` = + sha256 файла, `consolidation_id IS NULL`; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу + `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`; публикатор outbox публикует запрос с этим ключом + +#### Scenario: Смесь принятых и отклонённых +- **WHEN** в одном запросе xlsx, файл `.docx` и файл 51 МиБ +- **THEN** 201, `accepted` — один, `rejected` — два с кодами `UNSUPPORTED_FILE` и `FILE_TOO_LARGE` + +#### Scenario: Расширение не совпадает с содержимым +- **WHEN** загружается файл `osv.xlsx`, байты которого — BIFF8 +- **THEN** файл отклонён с кодом `UNSUPPORTED_FILE` + +#### Scenario: Чужое ЮЛ +- **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала +- **THEN** 404, объектов и записей нет + +#### Scenario: Загрузка в контексте версии +- **WHEN** у ГК ЮЛ есть версия `INITIATED` с этим ЮЛ в периметре, файл загружен маршрутом ЮЛ с query + `consolidation_version_id` (как делал клиент до v5.18) +- **THEN** 201, параметр игнорируется; `stored_file.consolidation_id IS NULL`; версия остаётся `INITIATED` + +#### Scenario: ЮЛ вне периметра +- **WHEN** ЮЛ не входит в периметр ни одной версии, файл загружен маршрутом ЮЛ +- **THEN** 201 — маршрут ЮЛ периметр не проверяет; проверка периметра — только у загрузки в консолидацию + +#### Scenario: Признак скана +- **WHEN** загружается PDF без текстового слоя +- **THEN** `stored_file.is_scan = true`; у xlsx `is_scan IS NULL` + +### Requirement: Удаление файла +`DELETE /api/v1/files/{file_id}` (`DOCUMENT_EDIT`, policy) SHALL удалить строки ОСВ, документы, задания и `stored_file`, +проставить `s3_object.expires_at = now()` (объект физически не удаляется, OQ-22) и записать `audit_event(FILE_DELETE, +before_value = карточка файла, after_value = {unbound_version_ids})`. Удаление MUST быть отклонено 409 +`FILE_HAS_CONFIRMED_DOCUMENTS`, если у файла есть документ `CONFIRMED`, подтверждённый пользователем (`confirmed_by` не +NULL), или если автоподтверждённый документ файла (`confirmed_by` NULL) привязан к версии в статусе, отличном от +`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION` (OQ-157, уточняет OQ-110); и 409 `FILE_SUPERSEDES_OTHERS`, если на +файл ссылаются `superseded_by_file_id`. При удалении файла с автоподтверждёнными документами их привязки +`consolidation_document` SHALL сниматься, статус версий MUST NOT меняться, `after_value.unbound_version_ids` — id версий, +где привязки сняты (по возрастанию; пустой список, если привязок не было). Блокировки: задания → файл → документы → +advisory-блокировка привязок ГК → версии по `id`. Файл `SUPERSEDED` удалить можно. Если у файла есть `quality_intake_fact` (по `file_id`), объект `uploads` файла MUST NOT получать `expires_at` — он нужен эталонному прогону качества; сырой ответ ИС (`raw`) при удалении `expires_at` не получает никогда. Для документов с исходом удаление SHALL писать факт исхода `DELETED` / `DELETED_AFTER_CONFIRM` в той же транзакции (спека `quality-metrics`). + +#### Scenario: Удаление ошибочного файла +- **WHEN** файл в `ERROR` удаляется владельцем +- **THEN** 204; `stored_file`, `recognition_job` отсутствуют; `s3_object.expires_at` заполнен; `audit_event(FILE_DELETE)` + с `after_value.unbound_version_ids = []` + +#### Scenario: Подтверждённый документ +- **WHEN** удаляется файл с документом `CONFIRMED`, у которого `confirmed_by` = пользователь +- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, ничего не изменилось + +#### Scenario: Автоподтверждённый документ в корректировке +- **WHEN** версия 2025 Q1 в `DATA_CORRECTION`, к ней привязан автоподтверждённый документ ОСВ 60 РБ; файл удаляется +- **THEN** 204; привязка снята, версия осталась в `DATA_CORRECTION`; `FILE_DELETE.after_value.unbound_version_ids` = [id версии] + +#### Scenario: Версия ушла на согласование +- **WHEN** автоподтверждённый документ файла привязан к версии в `CONSOLIDATION_RF` +- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, привязка и файл на месте + +#### Scenario: Автоподтверждённый без привязок +- **WHEN** удаляется файл, все документы которого автоподтверждены и ни к одной версии не привязаны +- **THEN** 204; `after_value.unbound_version_ids = []` + +#### Scenario: Файл с фактом приёма +- **WHEN** удаляется файл, по документам которого записан `quality_intake_fact` +- **THEN** 204; объект `uploads` и сырой ответ остаются без `expires_at`, результат (`results`) экспирится; записан факт исхода diff --git a/openspec/changes/quality-metrics/specs/quality-metrics-ui/spec.md b/openspec/changes/quality-metrics/specs/quality-metrics-ui/spec.md new file mode 100644 index 0000000..6670ea3 --- /dev/null +++ b/openspec/changes/quality-metrics/specs/quality-metrics-ui/spec.md @@ -0,0 +1,43 @@ +# Spec Delta + +## Purpose + +Экран «Качество первички» веб-интерфейса: матрица «источник × причина», динамика по месяцам, список документов ячейки, +выгрузка фактов; пункт меню по праву. + +## ADDED Requirements + +### Requirement: Экран «Качество первички» +Веб-интерфейс SHALL содержать экран «Качество первички» с фильтрами (период, ГК, тип документа, источник, скан/цифровой), +матрицей «источник × причина» (колонки PDF, Excel, Всего; технические отказы — отдельным столбцом по файлам с пометкой, +что фильтр типа на них не действует), динамикой по месяцам выбранной причины и кнопкой выгрузки фактов (CSV / JSON). +Фильтра по ЮЛ на экране нет (API его поддерживает). Справочник ГК для фильтра SHALL запрашиваться один раз за +жизнь экрана, в том числе при пустом ответе или ошибке. + +#### Scenario: Пустой справочник ГК +- **WHEN** справочник ГК пуст или запрос завершился ошибкой +- **THEN** экран не повторяет запрос бесконечно + +#### Scenario: Выбран источник +- **WHEN** в фильтре выбран PDF +- **THEN** матрица показывает одну колонку + +### Requirement: Переход из ячейки в документ +Клик по ячейке матрицы SHALL открывать список документов ячейки (постранично, со счётчиками правок «сервис» и «наш +разбор» и исходом); из строки списка, если документ ещё существует, SHALL быть переход в экран проверки документа. + +#### Scenario: Документ удалён +- **WHEN** документ ячейки уже удалён +- **THEN** строка показана без ссылки на документ + +### Requirement: Пункт меню по праву +Боковое меню SHALL показывать пункт «Качество первички» только при наличии `QUALITY_METRICS_VIEW` и снова SHALL +скрывать пункты по полю `permission`: «Пользователи и роли» скрыт без `USERS_ADMIN`. Доступ по-прежнему решает сервер. + +#### Scenario: Нет права +- **WHEN** в `/me` нет `QUALITY_METRICS_VIEW` +- **THEN** пункта «Качество первички» в меню нет + +#### Scenario: Нет USERS_ADMIN +- **WHEN** в `/me` нет `USERS_ADMIN` +- **THEN** пункт «Пользователи и роли» скрыт diff --git a/openspec/changes/quality-metrics/specs/quality-metrics/spec.md b/openspec/changes/quality-metrics/specs/quality-metrics/spec.md new file mode 100644 index 0000000..99b8059 --- /dev/null +++ b/openspec/changes/quality-metrics/specs/quality-metrics/spec.md @@ -0,0 +1,124 @@ +# Spec Delta + +## Purpose + +Факты качества первички (приём «как распознано», исход, технический отказ), отнесение правок аналитика к источнику +ошибки, матрица причин и API `/quality-metrics` с отдельным правом. Правила подсчёта — `05-domain-rules.md` §14, +модель — DBML v5.25, дизайн — `design.md` этого change. + +## ADDED Requirements + +### Requirement: Факты качества — отдельная схема, только добавление +Ядро SHALL хранить факты качества тремя плоскими таблицами `quality_intake_fact`, `quality_outcome_fact`, +`quality_failure_fact` в отдельной схеме PostgreSQL (`QUALITY_METRICS_SCHEMA`, по умолчанию `dkb_metrics`; значение MUST +отличаться от `DB_SCHEMA`). Таблицы MUST NOT иметь внешних ключей на рабочие таблицы: идентификаторы документов, файлов, +заданий, ЮЛ и ГК — просто `uuid`, имена ЮЛ и ГК копируются на момент факта. Записи SHALL только добавляться (UPDATE и +DELETE по фактам MUST NOT выполняться); `payload` — `jsonb` с `payload_schema_version`, форма по контракту +`quality-facts.schema.json`. Факты пишутся только для документов, принятых после выкладки. + +#### Scenario: Факт переживает удаление файла +- **WHEN** файл с документом, у которого есть факт приёма, удаляется +- **THEN** строки документа и файла удалены, а `quality_intake_fact` остаётся нетронутым + +#### Scenario: Схема метрик совпала со схемой модели +- **WHEN** `DB_SCHEMA` и `QUALITY_METRICS_SCHEMA` заданы одним значением +- **THEN** приложение не стартует с ошибкой конфигурации + +### Requirement: Факт приёма «как распознано» +В транзакции маппинга результата, после `map_file` и до автоподтверждения, ядро SHALL записать по одному +`quality_intake_fact` на каждый созданный документ `BALANCE`, `INCOME`, `OSV` (`DISCLOSURE` и `OTHER` факта не +получают): измерения (ЮЛ и ГК с именами, тип документа, `source_kind` pdf/excel, `is_scan` для PDF, сервис и версии, +`intake_at`, `intake_status`), счётчики строк, значений, нарушений и предупреждений, ключи S3, и `payload` со снимком +«как распознано». Запись факта MUST NOT менять документы, статусы и результат маппинга. + +#### Scenario: Приём ББ +- **WHEN** маппинг создал документ `BALANCE` из PDF +- **THEN** есть ровно один `quality_intake_fact` с `document_id`, `source_kind = pdf`, `intake_status` как у документа после + маппинга и снимком строк и значений в `payload` + +#### Scenario: Расшифровка не учитывается +- **WHEN** маппинг создал документ `DISCLOSURE` +- **THEN** факта приёма по нему нет + +### Requirement: Факт исхода документа +Ядро SHALL записать `quality_outcome_fact` в транзакции события, которое завершает документ: ручное подтверждение +(`CONFIRMED`), автоподтверждение (`AUTO_CONFIRMED`), удаление файла (`DELETED`), автозамена файла (`SUPERSEDED`). Если +документ на момент удаления или замены уже `CONFIRMED`, исход SHALL быть `DELETED_AFTER_CONFIRM` / +`SUPERSEDED_AFTER_CONFIRM`. Факт содержит `primary_cause`, счётчики правок по источнику (`service` / `ours` / `client`), +`checks_accepted`, число нарушений на момент исхода, подтверждённую отчётную дату. Повторное подтверждение ОСВ после +правки SHALL писать новый факт; метрики берут последний факт документа. + +#### Scenario: Автоподтверждение +- **WHEN** документ без замечаний автоподтверждён +- **THEN** факт с `outcome = AUTO_CONFIRMED`, `primary_cause = WITHOUT_PARTICIPATION`, `confirmed_by` пуст + +#### Scenario: Удаление после подтверждения +- **WHEN** удаляется файл с автоподтверждённым документом +- **THEN** новый факт `DELETED_AFTER_CONFIRM`; прежний факт `AUTO_CONFIRMED` не изменён + +### Requirement: Факт технического отказа +Ядро SHALL записывать `quality_failure_fact` при переходе задания распознавания в `error`, `timeout`, `dlq` и при +неповторяемой ошибке маппинга (файл → `ERROR`). `failure_kind` — `SERVICE_ERROR` / `TIMEOUT` / `NO_ACK` / `MAPPING`; +`error_code` отказа маппинга — константа `MAPPING_FAILED`. Повторная обработка создаёт новое задание: отказ первой +попытки остаётся фактом. + +#### Scenario: Таймаут задания +- **WHEN** вахтёр переводит задание в `timeout` +- **THEN** есть факт `failure_kind = TIMEOUT` с `job_id`, `file_id`, `failed_at` + +### Requirement: Отнесение правки к источнику ошибки +Правка аналитика (отличие подтверждённого результата от снимка приёма) SHALL относиться к источнику чистой функцией без +БД по правилу «верный ответ есть в сыром тексте сервиса — ошибка наша, иначе сервиса»: сумма, код строки, добавленная +строка, поля шапки (отчётная дата сравнивается как дата после разбора `parse_date`), период и счёт ОСВ. Для Excel любая +ошибка — «наш разбор». Спорные случаи и случаи без сырого текста SHALL относиться к «нашему разбору». Правки колонок дат +и набор кодов сырого ответа для добавленных строк PDF в этом change не учитываются (отложено). + +#### Scenario: Сервис прочитал верно +- **WHEN** аналитик исправил сумму, цифры которой совпадают с цифрами сырого текста +- **THEN** правка относится к «нашему разбору» + +#### Scenario: Сервис ошибся +- **WHEN** подтверждённый код строки не извлекается из сырого текста кода +- **THEN** правка относится к «ошибке сервиса» + +### Requirement: Причина документа и матрица +Каждый документ с исходом SHALL попадать ровно в одну ячейку по первому сработавшему правилу: удалён/заменён до +подтверждения; удалён/заменён после подтверждения; есть правки «сервис» → ошибка сервиса; есть правки «наш разбор» → +наш разбор; ручное подтверждение с акцептом жёстких нарушений → документ клиента; `AUTO_CONFIRMED` → без участия; +иначе → как есть. Документы без исхода — «в работе». Доли считаются от документов с исходом `AUTO_CONFIRMED` / +`CONFIRMED`; удалённые, заменённые и «в работе» в знаменатель не входят. Технические отказы считаются по файлам своей +долей. Расчёт — чистая функция от фактов, в БД не хранится; последний факт документа выбирается по времени исхода, при +равенстве — детерминированно. + +#### Scenario: Документ с правкой сервиса и клиента +- **WHEN** у подтверждённого документа есть правка «сервис» и акцепт нарушений +- **THEN** документ в ячейке «ошибка сервиса», счётчики обеих сторон сохранены в факте + +#### Scenario: Нулевой знаменатель +- **WHEN** за период нет документов с исходом +- **THEN** доли равны 0, `denominator = 0` + +### Requirement: API метрик качества +Система SHALL предоставлять под правом `QUALITY_METRICS_VIEW` маршруты `/api/v1/quality-metrics`: `GET /matrix` +(матрица за период с фильтрами период по дате приёма, ГК, ЮЛ, тип, источник, скан/цифровой, отказы по файлам, счётчик +уникальных отчётов), `GET /series` (ряд по месяцам даты приёма для причины, пропущенные месяцы — нули), `GET +/cells/{cell}/documents` (постраничный список документов ячейки со счётчиками правок, ссылкой на документ, если он +существует, и `confirmed_by`), `GET /export` (факты за период, CSV с BOM и JSON; значения, начинающиеся с символов +формулы, экранируются). Выгрузка SHALL писать `audit_event(EXPORT)` с `object_type = quality_metrics` и фильтрами. Агрегаты +MUST NOT содержать фамилий пользователей. + +#### Scenario: Матрица без права +- **WHEN** пользователь без `QUALITY_METRICS_VIEW` запрашивает `/quality-metrics/matrix` +- **THEN** 403 + +#### Scenario: Выгрузка оставляет след +- **WHEN** пользователь выгружает факты за период +- **THEN** записан `audit_event(EXPORT)` с форматом, фильтрами и числом строк + +### Requirement: Право QUALITY_METRICS_VIEW +Объект `QUALITY_METRICS` в `permission_object` и право `QUALITY_METRICS_VIEW` SHALL быть в seed и выдаваться всем +ролям; позже права сужаются данными (`role_permission`) без изменения кода. Видимость по филиалу и ГК не применяется. + +#### Scenario: Право у всех ролей +- **WHEN** выполнен `make seed` на чистой БД +- **THEN** `GET /me` любой роли содержит `QUALITY_METRICS_VIEW` diff --git a/openspec/changes/quality-metrics/specs/recognition-job-exchange/spec.md b/openspec/changes/quality-metrics/specs/recognition-job-exchange/spec.md new file mode 100644 index 0000000..089f7e2 --- /dev/null +++ b/openspec/changes/quality-metrics/specs/recognition-job-exchange/spec.md @@ -0,0 +1,59 @@ +# Spec Delta + +## Purpose + +Дельта change `quality-metrics` (метрики качества первички). + +## MODIFIED Requirements + +### Requirement: Inbox принимает результат идемпотентно +Для конверта со `status ∈ {done, needs_check, error}` ядро SHALL валидировать его по +`recognition-result-message.schema.json`, найти задание по `job_id` и: если `result_msg_id` задания уже равен +`Nats-Msg-Id` сообщения — ничего не менять; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`, +`created_by = `; только при наличии `result_s3`), обновить задание (`status`, `service_name`, +`service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`, +`result_msg_id`) и зафиксировать транзакцию. При `status = error` файл задания (если `current_recognition_job_id` равен +заданию) SHALL получить `stored_file.status = ERROR`; при `done`/`needs_check` файл остаётся `PROCESSING` и SHALL быть +обработан циклом маппинга (спека `osv-result-mapping`); inbox MUST NOT читать файл результата. Неизвестный `job_id` SHALL +быть залогирован. Результат для задания, уже находящегося в финальном статусе (`done`, `needs_check`, `error`, +`timeout`, `dlq`), SHALL логироваться и не применяться. Если в конверте есть `raw_s3_key`, ядро SHALL независимо от `status` зарегистрировать сырой ответ ИС в `s3_object` (`bucket_role = raw`, ключ `raw/{job_id}.json`) и проставить `recognition_job.raw_s3_object_id`. При `status = error` в той же транзакции SHALL записываться `quality_failure_fact` (`failure_kind = SERVICE_ERROR`, `error_code` из конверта). + +#### Scenario: Успешный результат +- **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results///result.json}` и + `Nats-Msg-Id = result::1` +- **THEN** задание получает `status = done`, `result_msg_id = result::1`, `result_s3_object_id` указывает на новую + строку `s3_object` с этим ключом, `finished_at` заполнен; файл остаётся `PROCESSING` до маппинга + +#### Scenario: Повторная доставка +- **WHEN** то же сообщение получено второй раз +- **THEN** строка `recognition_job` и `s3_object` не меняются + +#### Scenario: Ошибка сервиса +- **WHEN** конверт содержит `status = error`, `error = {UNSUPPORTED_FORMAT, …}` без `result_s3` +- **THEN** задание получает `status = error`, `error_code = UNSUPPORTED_FORMAT`, `error_message` из конверта, + `result_s3_object_id` пуст; файл — `ERROR` + +#### Scenario: Результат пришёл после таймаута +- **WHEN** задание уже `timeout`, и приходит конверт `done` +- **THEN** задание не меняется, событие залогировано + +#### Scenario: Сырой ответ зарегистрирован +- **WHEN** конверт `done` содержит `raw_s3_key = raw/.json` +- **THEN** создан `s3_object` с `bucket_role = raw`, `recognition_job.raw_s3_object_id` указывает на него + +#### Scenario: Факт отказа сервиса +- **WHEN** конверт содержит `status = error` +- **THEN** кроме `ERROR` файла записан `quality_failure_fact` с `failure_kind = SERVICE_ERROR` + +### Requirement: Таймаут ожидания результата +Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в +`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в +`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Ручной повтор после TIMEOUT/NO_ACK SHALL создавать новое задание по требованию «Новая попытка обработки файла». Каждый перевод в `timeout` и в `dlq` SHALL писать `quality_failure_fact` (`TIMEOUT` / `NO_ACK`) в той же транзакции. + +#### Scenario: Сервис взял, но не ответил +- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата +- **THEN** не позднее чем через 30 с задание имеет `status = timeout`, файл — `ERROR` с `error_code = TIMEOUT` + +#### Scenario: Факт таймаута +- **WHEN** вахтёр перевёл задание в `timeout` +- **THEN** записан `quality_failure_fact` с `failure_kind = TIMEOUT` diff --git a/openspec/changes/quality-metrics/specs/reference-data-seed/spec.md b/openspec/changes/quality-metrics/specs/reference-data-seed/spec.md new file mode 100644 index 0000000..9c9fcd7 --- /dev/null +++ b/openspec/changes/quality-metrics/specs/reference-data-seed/spec.md @@ -0,0 +1,53 @@ +# Spec Delta + +## Purpose + +Дельта change `quality-metrics` (метрики качества первички). + +## MODIFIED Requirements + +### Requirement: Идемпотентный seed справочников продукта +Команда `python -m scripts.seed` (из `api/`) SHALL загрузить из `api/seed/core/`: `regional_office` (Головной офис, +`is_head_office = true`), `role` — 7 системных ролей ТЗ 8.1 (`KA_RF`, `KA_GO`, `RKA`, `CLIENT_MANAGER`, `CURATOR_GO`, +`EXPERT`, `ADMIN`, `is_system = true`), `permission` — 17 сочетаний объект × действие из §10 правил с кодами +`_` (`EXPORT` при совпадении), `role_permission` по матрице §10 (ASSUMPTION(OQ-16)), `chart_of_account`, +`elimination_rule` + `elimination_rule_account` версии 1 для ББ (§4), `required_document_rule` для `BASIC` и `EXTENDED`. +Записи SHALL вставляться или обновляться по натуральному ключу; удаление MUST NOT выполняться. + +Исключение — матрица `role_permission` (ей после первичной загрузки владеет администратор, issue #9): в ПУСТУЮ таблицу +матрица файла SHALL загружаться целиком; в заполненную SHALL вставляться только пары, у которых роль или право +вставлены этим же запуском seed (их администратор снять не мог), а остальные пары файла MUST NOT добавляться — право, +снятое администратором, не возвращается, добавленное сверх файла не отзывается. Отчёт команды SHALL сообщать, что +матрица пропущена (или сколько добавлено пар новых ролей и прав). Право `QUALITY_METRICS_VIEW` (объект `QUALITY_METRICS`, действие `VIEW`) SHALL выдаваться всем ролям; на стенде, где матрица уже заполнена, пара добавляется только как пара впервые созданного права — для появления пункта меню нужен повторный `make seed`. + +#### Scenario: Первый запуск на пустой схеме +- **WHEN** после `alembic upgrade head` выполняется `python -m scripts.seed` +- **THEN** в `role` 7 строк, в `permission` 17, в `role_permission` — число строк равно сумме прав по матрице §10, + в `elimination_rule` 8 строк с `statement_type = balance`, `version_no = 1`, в `required_document_rule` для `BASIC` + 10 строк (BALANCE, INCOME, OSV × 01, 58, 60, 62, 66, 67, 76, 90) и для `EXTENDED` 27 строк + +#### Scenario: Повторный запуск +- **WHEN** `python -m scripts.seed` выполняется второй раз без изменения файлов +- **THEN** число строк во всех затронутых таблицах не меняется, команда завершается с кодом 0, в отчёте + `role_permission` помечена как пропущенная + +#### Scenario: Право, снятое администратором +- **WHEN** администратор снял с роли `EXPERT` право `EX_VIEW`, после чего seed запущен снова +- **THEN** связь `EXPERT × EX_VIEW` не восстановлена, остальная матрица не изменилась + +#### Scenario: Новые роль и право в файлах seed +- **WHEN** матрица заполнена, а в `permissions.yaml` добавлено новое право, в `roles.yaml` — новая роль, и в + `role_permissions.yaml` — их пары (новое право существующей роли, существующее право новой роли) +- **THEN** seed вставляет ровно эти пары; снятые ранее администратором связи существующих ролей и прав не возвращаются + +#### Scenario: Администратор снял все связи +- **WHEN** таблица `role_permission` пуста и seed запущен снова +- **THEN** матрица файла загружается целиком, как при первом запуске + +#### Scenario: Изменение названия роли в файле +- **WHEN** в `roles.yaml` изменено `name` роли `EXPERT` и seed запущен снова +- **THEN** строка `role` с `code = EXPERT` обновлена, новая не создана + +#### Scenario: Право метрик на заполненной матрице +- **WHEN** матрица заполнена, право `QUALITY_METRICS_VIEW` создаётся впервые и seed запущен +- **THEN** добавлены пары права со всеми ролями; ранее снятые администратором связи не возвращены -- 2.47.2 From 24fbec8988ea0d17eb7fa940abb8c0eeb13eb250 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 04:14:24 +0400 Subject: [PATCH 41/44] =?UTF-8?q?fix(#190):=20I-9=20is=5Frecognized=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82=20null?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B5=20quality-facts,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20NULL?= =?UTF-8?q?-=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B8;=20M-13=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20R-1..R-3=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../metrics/quality-facts.schema.json | 2 +- api/tests/test_api_documents.py | 21 +++++++++++++++++++ api/tests/test_domain_quality_facts.py | 21 +++++++++++++++++++ openspec/changes/quality-metrics/design.md | 6 +++++- 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/api/contracts/metrics/quality-facts.schema.json b/api/contracts/metrics/quality-facts.schema.json index ff63bcf..c29cece 100644 --- a/api/contracts/metrics/quality-facts.schema.json +++ b/api/contracts/metrics/quality-facts.schema.json @@ -52,7 +52,7 @@ "string_value": { "type": ["string", "null"] }, "source_text": { "type": ["string", "null"] }, "confidence": { "type": ["number", "null"] }, - "is_recognized": { "type": "boolean" }, + "is_recognized": { "type": ["boolean", "null"] }, "needs_review": { "type": "boolean" } } } diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 43038db..81962a5 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -665,6 +665,27 @@ async def test_confirm_writes_outcome_fact_as_is_when_no_fixes( assert fact.payload["fixes"] == [] +@pytest.mark.fixtures +async def test_confirm_outcome_fact_with_analyst_added_cell_matches_contract( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """I-9: ячейка, созданная правкой аналитика (`is_recognized = NULL`), попадает в payload и проходит схему.""" + office = await _rostov_office(seeded) + income, _meta = await _income_form(seeded, fixtures_dir, office=office) + value = await _set_value_fix( + seeded, income, source_text="1", original_amount=Decimal("1.00"), confirmed_amount=Decimal("2.00") + ) + value.is_recognized = None + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) # валидирует payload по контракту + assert any(item["is_recognized"] is None for item in fact.payload["values"]) + + @pytest.mark.fixtures async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory diff --git a/api/tests/test_domain_quality_facts.py b/api/tests/test_domain_quality_facts.py index 7da30c3..e701198 100644 --- a/api/tests/test_domain_quality_facts.py +++ b/api/tests/test_domain_quality_facts.py @@ -14,6 +14,7 @@ from src.models.enums import ( ) from src.models.files import Document from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue +from tests.quality_payload_contract import assert_intake_payload_valid, assert_outcome_payload_valid ROW_COUNT = 4_200 @@ -163,3 +164,23 @@ def test_header_source_and_row_count_absent_when_kwargs_omitted(): assert "row_count" not in osv outcome_osv = build_outcome_payload(make_document(document_type=DocumentType.OSV), osv_warnings=[], fixes=[]) assert "row_count" not in outcome_osv + + +def test_payload_with_null_is_recognized_matches_contract(): + """I-9: ячейка без признака распознавания (правка аналитика, пустая ячейка источника) проходит схему контракта.""" + document = make_document(document_type=DocumentType.BALANCE) + line = FinancialStatementLine( + id=uuid.uuid4(), + source_ref="row:1", + row_index=1, + line_code="1100", + line_status=StatementLineStatus.ok, + needs_review=False, + ) + value = FinancialStatementValue( + line_id=line.id, column_id=uuid.uuid4(), amount=Decimal("10.00"), is_recognized=None, needs_review=False + ) + intake = build_intake_payload(document, lines=[line], values=[value], issues=[]) + assert intake["values"][0]["is_recognized"] is None + assert_intake_payload_valid(intake) + assert_outcome_payload_valid(build_outcome_payload(document, lines=[line], values=[value], issues=[], fixes=[])) diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 5d709d7..b937cd3 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -229,7 +229,7 @@ PDF, точный вид выгрузки, разбиение на задачи. | 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана | | 06.11.2026 | Task 13: `web/src/api/types.ts` — `'QUALITY_METRICS'` дописан в конец union `PermissionObject` ОДНОЙ строкой (не многострочным форматом), итоговая строка ровно 140 символов — на пределе, но не сверх лимита ширины biome | сохраняет однострочный стиль, которым уже написана эта строка (и соседняя `PermissionAction`); многострочный вариант рассматривался как равноценная по типам альтернатива, выбор между ними непринципиален | нулевая — чисто форматирование, обе формы типобезопасно идентичны | | 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит | -| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл есть в `../docs/contracts/metrics/` (сиблинг-репозиторий arm-ka-docs, ветка feat/202, PR docs #203 — НЕ слита; порядок merge: docs PR #203 сливается раньше PR arm-core; позже файл вендорен — R-16), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда | +| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл есть в `../docs/contracts/metrics/` (сиблинг-репозиторий arm-ka-docs, ветка feat/202, PR docs #203 — НЕ слита; порядок merge: docs PR #203 сливается раньше PR arm-core; позже файл вендорен — R-16), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда Перекрыто R-16 (07.11.2026): контракт вендорен, `test_contract` зелёный. | | 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 | | 06.11.2026 | Task 8 (`DELETED`/`DELETED_AFTER_CONFIRM`): (1) документ в статусе, отличном от `CONFIRMED`/`RECOGNIZED`/`NEEDS_CHECK` (практически только `ERROR`), при удалении файла НЕ получает факт исхода — у него никогда не было содержательного состояния «как распознано» для фиксации (нет формы/строк, не проходил ни через `AUTO_CONFIRMED`, ни через intake-факт); (2) `issue_count_at_outcome` вычисляется как `counts.error + counts.info` из уже посчитанного агрегата `issue_counts_by_document` вместо отдельного запроса — тот же смысл «все нарушения документа без фильтра», что `len(issues)` в Task 6/7, дешевле на один запрос; (3) `payload` для DELETED*-фактов — единый минимальный вызов `build_outcome_payload(document, fixes=())` без дополнительных запросов к строкам/значениям формы или ОСВ (эти строки всё равно будут физически удалены этим же вызовом `delete`, повторный их снимок здесь экономически не оправдан — а в матрицу §6 эти факты и не попадают, доступны только как «удалён/заменён», drill-down уже видит прежний исход через более ранний intake/outcome факт того же `document_id`) | бриф задачи 8 явно делегировал все три пункта исполнителю («если неоднозначно — зафиксировать выбор, не угадывать»); исполнитель выбрал и задокументировал в докстрингах кода, перенесено сюда контроллером по правилу маркировки находок (решение агента без обсуждения с человеком — в design.md) | (1): при появлении у `ERROR`-документов содержательного состояния до удаления (гипотетически) — нужно будет расширить список статусов; (2)/(3): минимальный риск — агрегаты эквивалентны, а payload не используется для атрибуции (там `primary_cause=None`, вне матрицы) | | 06.11.2026 | Task 10 (`quality_failure_fact`): `error_code` для отказов маппинга (`_fail`, `_register_failure`) — константа `MAPPING_FAILURE_ERROR_CODE = "MAPPING_FAILED"` (`result_mapping.py`), не свободный текст из `message`/описания исключения | `QualityFailureFact.error_code` не имеет свободнотекстового столбца-соседа для деталей отказа маппинга (в отличие от `recognition_job.error_message`); деталь остаётся в `metrics.mapping_error`/логе, код — единая константа, отличимая от кодов обмена (`ERROR_NO_ACK`/`ERROR_INVALID_REQUEST`), что проверяет отдельный тест | если деталь отказа маппинга понадобится в матрице причин без обращения к логам — нужен отдельный столбец/payload-поле, не замена константы | @@ -265,3 +265,7 @@ PDF, точный вид выгрузки, разбиение на задачи. | 06.11.2026 | R-7 (Task 7, сведён I-2): несоответствие формы `payload.fixes`/`values` контракту `quality-facts.schema.json` было отложено и вынесено в финальное ревью | ни одна задача плана не владела приведением payload к контракту, контракт не был вендорен, тестов валидации не было | закрыто в I-2: R-16…R-21 (вендоринг, приведение payload, тест валидации) | | 07.11.2026 | R-22 (final review M-7): условие §14.4 «нарушенное соотношение не задето правками» НЕ проверяется: `checks_accepted = meta.checks_accepted_by is not None` (`documents._write_confirm_outcome_fact`), то есть любой акцепт нарушений при ручном подтверждении | на ячейку матрицы не влияет: при любой правке (`service`/`ours`) приоритет §6 п.2-3 выше «документа клиента», а без правок задетое правками соотношение невозможно; влияет только на поле `checks_accepted` в факте и на «документ клиента» у документа без правок — где условие выполняется тривиально. Точная проверка требует пересчёта соотношений до/после правок | если акцепт дан, а часть соотношений исправлена правками, поле `checks_accepted` у такого факта завышено; ячейка остаётся «сервис»/«наш разбор» | | 07.11.2026 | R-23 (final review I-7): дельты OpenSpec — ADDED `quality-metrics`, `quality-metrics-ui`; MODIFIED `primary-files` (загрузка, удаление), `recognition-job-exchange` (inbox, таймаут), `documents-api` (подтверждение), `osv-result-mapping` (цикл маппинга, автоподтверждение), `authn-authz` (`GET /me`), `reference-data-seed` (seed, 16 → 17 прав) | `openspec validate --strict` требует полного текста изменяемого требования, поэтому MODIFIED копируют текущее требование из `openspec/specs/` с добавлением; авторизационная спека не перечисляет права, поэтому право отражено сценарием `/me` | при архивации дельты заменят требования целиком — правки спек основной ветки, сделанные параллельно, потеряются (проверить конфликт перед `openspec archive`) | +| 05.11.2026 | R-1 (Task 3): имена экспорта `arm_statements` в плане (`fix_lookalikes`/`parse_amount`/`parse_code`/`parse_date`/`default_rules`) предварительные; исполнитель задачи 3 сверяет их с реальным `dir(arm_statements)` до написания `domain/quality_attribution.py` и фиксирует расхождения строкой в ledger | это собственная инструкция плана, в рулинг вынесена, чтобы попала в бриф задачи | если имена не совпали, а шаг пропущен — `quality_attribution.py` не импортируется, ловится красным тестом сразу, правка дешёвая | +| 06.11.2026 | R-2 (Task 11): регистрация сырого объекта в `recognition_exchange._apply_result` не зависит от `message.status`: срабатывает при `message.raw_s3_key is not None`, независимо от проверки `status is ERROR` для факта отказа (Task 10); оба хука живут в одной функции на разных условиях | план задачи 11 уже говорит «если `message.raw_s3_key is not None`» без условия по статусу; design §9: сырой ответ нужен эталонному прогону при любом исходе | если регистрация должна быть условной, упавшее задание получит лишнюю строку `s3_object` на полезный сырой ответ — дёшево и, скорее всего, правильно | +| 06.11.2026 | R-3 (Task 2): сигнатуры `build_intake_payload` / `build_outcome_payload` расширены необязательными `header_source: Mapping \| None` (кладётся в `header` для ББ/ОФР) и `osv_row_count: int \| None` (ключ `row_count` в ветке ОСВ) | исполнитель задачи 2 заметил, что бриф «Consumes» называет `FinancialStatementMeta`, а сигнатура в «Produces» параметра для него не давала; берётся существующее поле ORM `header_source`, новой формы не изобретали; параметры необязательные — прежние тесты не менялись | если реальному хуку задачи 5 нужна иная форма, сигнатуру правят позже — локальная и дешёвая правка | +| 07.11.2026 | R-24 (повторное ревью I-9): в схеме `quality-facts.schema.json` поле `intake-payload/values/items/is_recognized` — `["boolean", "null"]` (docs, пока PR #203 не слит; копия `api/contracts/metrics/` байт-в-байт). Остальные поля сверены скриптом по моделям: NULL допускают только `is_recognized` (правка аналитика `add_value`, пустая ячейка источника при приёме) и уже допускающие null поля | `FinancialStatementValue.is_recognized` — `bool \| None`; NULL означает «не из источника», а не «не распознана» (P3-54), поэтому тип поля менять не стали, а ослабили контракт; тесты: unit-валидация NULL-ячейки и `CONFIRMED` с ячейкой аналитика | потребитель фактов обязан обрабатывать `null` в `is_recognized`; ужесточение схемы позже потребует миграции данных | -- 2.47.2 From d1f4f45ab0e02e14e2d651c08ed3e91b152f2fc9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 04:14:24 +0400 Subject: [PATCH 42/44] =?UTF-8?q?fix(#190):=20M-17=20=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D1=88=D1=80=D1=83=D1=82=D1=8B=20/quality-metrics=20=D0=B2=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B5=20OpenAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/test_openapi_routes.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index bf47e58..fee970c 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -63,6 +63,11 @@ EXPECTED_ROUTES: dict[str, set[str]] = { # форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6) "/api/v1/documents/{document_id}/statement": {"get", "patch"}, "/api/v1/documents/{document_id}/statement/checks/accept": {"post"}, + # quality-metrics (AURORA-1759) + "/api/v1/quality-metrics/matrix": {"get"}, + "/api/v1/quality-metrics/series": {"get"}, + "/api/v1/quality-metrics/cells/{cell}/documents": {"get"}, + "/api/v1/quality-metrics/export": {"get"}, # consolidations-api (Срез 4) "/api/v1/consolidations": {"post"}, "/api/v1/company-groups/{group_id}/consolidations": {"get"}, @@ -109,6 +114,7 @@ CHECKED_PREFIXES = ( "/api/v1/documents", "/api/v1/consolidations", "/api/v1/company-groups", + "/api/v1/quality-metrics", ) # Модели, которые обязаны быть в схеме ровно один раз под коротким именем @@ -309,3 +315,16 @@ def test_retry_routes_declare_different_conflicts(schema: dict[str, Any]) -> Non ] assert "FILE_NOT_RETRYABLE" in single and "MAX_ATTEMPTS_REACHED" in single assert batch == "VERSION_NOT_EDITABLE" + + +def test_quality_metrics_routes_declare_documented_query_parameters(schema: dict[str, Any]) -> None: + """Query-параметры метрик качества: ряд — `cause`, ячейки — `limit`/`offset`, выгрузка — `format`, `filters` нет.""" + + def names(path: str) -> set[str]: + return {p["name"] for p in schema["paths"][f"/api/v1/quality-metrics{path}"]["get"].get("parameters", [])} + + assert "cause" in names("/series") + assert {"limit", "offset"} <= names("/cells/{cell}/documents") + assert "format" in names("/export") + for path in ("/matrix", "/series", "/cells/{cell}/documents", "/export"): + assert "filters" not in names(path), path -- 2.47.2 From 4593cdc62c4d0e2cadbbac0bbb19bbf1d106ff18 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 04:14:24 +0400 Subject: [PATCH 43/44] =?UTF-8?q?docs(#190):=20M-10=20format=3Dcsv=20?= =?UTF-8?q?=D0=B2=20README,=20M-14=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0?= =?UTF-8?q?=20R-4=20=D0=B2=20docstring,=20M-16=20CLAUDE.md=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20=D0=B2=D1=8B=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 2 +- api/src/domain/quality_attribution.py | 2 +- api/src/services/documents.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dc9d92a..e377366 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ `ObjectStorage.stream` (поток из S3, первая порция до заголовков → `FILE_CONTENT_NOT_FOUND`; сессию тело потока не держит; имя — RFC 5987, `X-Content-Type-Options: nosniff`; освобождение потока — `common/streaming.py`: `ClosingStreamingResponse` + `ChainedStream` закрывают источник и при обрыве клиента). Скачивание первички, CSV и комментарии аудит не пишут (в отличие от - экспорта сводной ОСВ в xlsx — тот пишет `EXPORT`; OQ-153). `HeaderPatch.units` — непустая строка после обрезки (пробельная — 422). `GET /users/brief` — `current_user` без права; `documents_count` у ЮЛ — `repositories/directories`; CSV чек-листа — + экспорта сводной ОСВ в xlsx и выгрузки метрик качества `GET /quality-metrics/export` — те пишут `EXPORT`; OQ-153). `HeaderPatch.units` — непустая строка после обрезки (пробельная — 422). `GET /users/brief` — `current_user` без права; `documents_count` у ЮЛ — `repositories/directories`; CSV чек-листа — `?format=csv` у `GET …/checklist`. Контракт для фронта — `../docs/dev/api-contract-slice-3b.md`; решения — `openspec/changes/archive/2026-10-07-slice-3b-statements-api/design.md`. - **Факты качества первички (AURORA-1759)** — отдельная схема `dkb_metrics` (`QUALITY_METRICS_SCHEMA`), таблицы `quality_intake_fact` / diff --git a/README.md b/README.md index fa2c942..7b69b41 100644 --- a/README.md +++ b/README.md @@ -978,7 +978,7 @@ API (`/api/v1/quality-metrics`, право `QUALITY_METRICS_VIEW`, выдаёт | `GET /matrix` | матрица «источник × причина» за период (период по дате приёма, фильтры ГК, ЮЛ, тип, источник `pipeline`, скан/цифровой), технические отказы по файлам, «в работе», уникальные отчёты | | `GET /series` | ряд по месяцам даты приёма для причины (`cause`) | | `GET /cells/{cell}/documents` | постраничный список документов ячейки (`limit`, `offset`) | -| `GET /export` | выгрузка фактов за период, `fmt=csv` (UTF-8 с BOM) или `json`; пишет `audit_event(EXPORT)` | +| `GET /export` | выгрузка фактов за период, `format=csv` (UTF-8 с BOM) или `json`; пишет `audit_event(EXPORT)` | Экран веб-интерфейса — «Качество первички» (`web/src/widgets/QualityMetrics`). diff --git a/api/src/domain/quality_attribution.py b/api/src/domain/quality_attribution.py index 3979a3c..07a3939 100644 --- a/api/src/domain/quality_attribution.py +++ b/api/src/domain/quality_attribution.py @@ -41,7 +41,7 @@ def attribute_value_fix(confirmed: str, raw_text: str) -> Attribution: `parse_amount` требует `Source`; здесь он не определяет исход («О»→«0» и выделение числового ядра в тексте работают одинаково для PDF и Excel), поэтому берём `Source.OCR` — распознавание различий между источниками в `parse_amount` касается только неоднозначных разделителей тысяч, а их чтение в эту цифровую проверку не - входит (решение задачи 3, см. отчёт). + входит (источник на цифровую проверку не влияет). """ rules = default_rules() parsed = parse_amount(raw_text, Source.OCR, rules) diff --git a/api/src/services/documents.py b/api/src/services/documents.py index c3fe6bd..99499c9 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -482,7 +482,7 @@ class DocumentService: """Факт исхода `CONFIRMED` с атрибуцией правок (§14.2-§14.4; design quality-metrics §4.2, §5, §6; AURORA-1759). Гейт — `OUTCOME_FACT_DOCUMENT_TYPES`: DISCLOSURE/OTHER без факта (как у факта приёма/ `AUTO_CONFIRMED`, Task 5/6). Правки ищутся по ЖИВЫМ колонкам `*_modified`/`original_*`/`source_text` - (первичка не мутируется) — не по JSON `quality_intake_fact.payload` (решение задачи 7, см. отчёт: тот + (первичка не мутируется) — не по JSON `quality_intake_fact.payload` (R-4, design.md: тот payload собран для другой цели и его форма — не для этого сравнения). Пишется в транзакции вызывающего (`confirm`), без `flush`/`commit`. """ -- 2.47.2 From 2f4817137ea3c9aa2d18ad80082466abc5f8556a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 7 Nov 2026 04:18:34 +0400 Subject: [PATCH 44/44] =?UTF-8?q?docs(#190):=20M-16=20=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D0=B0=20audit-trail=20=E2=80=94=20=D0=B0=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../quality-metrics/specs/audit-trail/spec.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 openspec/changes/quality-metrics/specs/audit-trail/spec.md diff --git a/openspec/changes/quality-metrics/specs/audit-trail/spec.md b/openspec/changes/quality-metrics/specs/audit-trail/spec.md new file mode 100644 index 0000000..8389001 --- /dev/null +++ b/openspec/changes/quality-metrics/specs/audit-trail/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Аудит выгрузки метрик качества +Каждый успешный `GET /quality-metrics/export` SHALL добавлять отдельное неизменяемое событие `audit_event` с +`action = EXPORT`, `section = EXPORT`, `object_type = quality_metrics`, `object_id = null`, `user_id` инициатора, +`before_value = null` и `after_value = {report, format, filters, rows}`: `report` — тип выгрузки, `format` — `csv` или +`json`, `filters` — применённые фильтры, `rows` — число выгруженных строк. Выгрузка сводной ОСВ и шахматки продолжают +писать собственные события `EXPORT`. + +#### Scenario: Выгрузка метрик оставляет след +- **WHEN** пользователь с `QUALITY_METRICS_VIEW` выгружает факты за период в CSV +- **THEN** записано одно событие `EXPORT` с `object_type = quality_metrics`, форматом, фильтрами и числом строк -- 2.47.2