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(); +}