fix(#190): C-1 query-модели эндпоинтов метрик, M-1 без payload в DISTINCT ON, M-2 аудит EXPORT

- 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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-07 03:40:43 +04:00
parent 25c568b5be
commit 85c5ab8596
10 changed files with 132 additions and 29 deletions

View File

@ -6,18 +6,20 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query, Response 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.auth import require
from src.api.dependencies.quality_metrics import QualityMetricsServiceDep from src.api.dependencies.quality_metrics import QualityMetricsServiceDep
from src.common.filenames import content_disposition from src.common.filenames import content_disposition
from src.models.enums import QualityCause from src.schemas.common import Page
from src.schemas.common import Page, PageParams
from src.schemas.quality_metrics import ( from src.schemas.quality_metrics import (
ExportFormat,
QualityCell, QualityCell,
QualityCellDocumentOut, QualityCellDocumentOut,
QualityCellParams,
QualityExportParams,
QualityFilters, QualityFilters,
QualityMatrixOut, QualityMatrixOut,
QualityMonthlySeriesOut, QualityMonthlySeriesOut,
QualitySeriesParams,
) )
router = APIRouter( router = APIRouter(
@ -36,30 +38,27 @@ async def get_matrix(
@router.get("/series", summary="Ряд по месяцам даты приёма для причины") @router.get("/series", summary="Ряд по месяцам даты приёма для причины")
async def get_series( async def get_series(
service: QualityMetricsServiceDep, service: QualityMetricsServiceDep, params: Annotated[QualitySeriesParams, Query()]
filters: Annotated[QualityFilters, Query()],
cause: Annotated[QualityCause, Query()],
) -> QualityMonthlySeriesOut: ) -> QualityMonthlySeriesOut:
return await service.series(filters, cause) return await service.series(params, params.cause)
@router.get("/cells/{cell}/documents", summary="Документы ячейки матрицы") @router.get("/cells/{cell}/documents", summary="Документы ячейки матрицы")
async def get_cell_documents( async def get_cell_documents(
cell: QualityCell, cell: QualityCell,
service: QualityMetricsServiceDep, service: QualityMetricsServiceDep,
filters: Annotated[QualityFilters, Query()], params: Annotated[QualityCellParams, Query()],
page: Annotated[PageParams, Query()],
) -> Page[QualityCellDocumentOut]: ) -> Page[QualityCellDocumentOut]:
"""Ячейка — причина (`QualityCause`), `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM` или `IN_PROGRESS`.""" """Ячейка — причина (`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)") @router.get("/export", response_class=Response, summary="Выгрузка фактов за период (CSV / JSON)")
async def export_facts( async def export_facts(
service: QualityMetricsServiceDep, service: QualityMetricsServiceDep,
filters: Annotated[QualityFilters, Query()], params: Annotated[QualityExportParams, Query()],
fmt: Annotated[ExportFormat, Query(alias="format")] = "csv", audit: Annotated[RequestAudit, Depends(get_request_audit)],
) -> Response: ) -> 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}") 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}) return Response(content=content, media_type=media_type, headers={"Content-Disposition": header})

View File

@ -6,19 +6,23 @@ import enum
import uuid import uuid
from typing import Literal 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.models.enums import DocumentType, QualityCause, QualityOutcome, RecognitionPipeline
from src.schemas.common import DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT
__all__ = [ __all__ = [
"ExportFormat", "ExportFormat",
"MonthPointOut", "MonthPointOut",
"QualityCell", "QualityCell",
"QualityCellDocumentOut", "QualityCellDocumentOut",
"QualityCellParams",
"QualityExportParams",
"QualityFilters", "QualityFilters",
"QualityFixesOut", "QualityFixesOut",
"QualityMatrixOut", "QualityMatrixOut",
"QualityMonthlySeriesOut", "QualityMonthlySeriesOut",
"QualitySeriesParams",
"TechnicalFailuresOut", "TechnicalFailuresOut",
] ]
@ -58,6 +62,28 @@ class QualityFilters(BaseModel):
return self 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): class TechnicalFailuresOut(BaseModel):
"""Технические отказы считаются по файлам (design §6); фильтр по типу документа на них не действует. """Технические отказы считаются по файлам (design §6); фильтр по типу документа на них не действует.
`total_files_count` — уникальные файлы с фактом приёма или отказа в выбранных период/ГК/ЮЛ/источнике/скане.""" `total_files_count` — уникальные файлы с фактом приёма или отказа в выбранных период/ГК/ЮЛ/источнике/скане."""

View File

@ -22,13 +22,14 @@ from fastapi.encoders import jsonable_encoder
from sqlalchemy import Select, case, distinct, func, select, union from sqlalchemy import Select, case, distinct, func, select, union
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.domain.quality_metrics import ( from src.domain.quality_metrics import (
QualityOutcomeFactRow, QualityOutcomeFactRow,
compute_matrix, compute_matrix,
compute_monthly_series, compute_monthly_series,
) )
from src.models.access import AppUser 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.files import Document
from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact
from src.schemas.common import Page from src.schemas.common import Page
@ -50,6 +51,8 @@ _IN_DENOMINATOR = (QualityOutcome.AUTO_CONFIRMED, QualityOutcome.CONFIRMED)
_BEFORE_CONFIRM = (QualityOutcome.DELETED, QualityOutcome.SUPERSEDED) _BEFORE_CONFIRM = (QualityOutcome.DELETED, QualityOutcome.SUPERSEDED)
_AFTER_CONFIRM = (QualityOutcome.DELETED_AFTER_CONFIRM, QualityOutcome.SUPERSEDED_AFTER_CONFIRM) _AFTER_CONFIRM = (QualityOutcome.DELETED_AFTER_CONFIRM, QualityOutcome.SUPERSEDED_AFTER_CONFIRM)
_HEAVY_OUTCOME_COLUMNS = ("payload", "payload_schema_version") # в DISTINCT ON не тянем: метрикам не нужны
EXPORT_COLUMNS = ( EXPORT_COLUMNS = (
"document_id", "file_id", "legal_entity_id", "legal_entity_name", "group_id", "group_name", "document_type", "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", "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` (см. модульную строку).""" """Последний факт исхода на `document_id` (см. модульную строку)."""
outcome = QualityOutcomeFact.outcome outcome = QualityOutcomeFact.outcome
rank = case((outcome.in_(_AFTER_CONFIRM), 2), (outcome.in_(_IN_DENOMINATOR), 1), else_=0) 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 ( return (
select(QualityOutcomeFact) select(*columns)
.distinct(QualityOutcomeFact.document_id) .distinct(QualityOutcomeFact.document_id)
.order_by( .order_by(
QualityOutcomeFact.document_id, QualityOutcomeFact.document_id,
@ -337,9 +341,22 @@ class QualityMetricsService:
).order_by(i.intake_at, i.document_id) # fmt: skip ).order_by(i.intake_at, i.document_id) # fmt: skip
return [dict(r._mapping) for r in (await self._session.execute(stmt)).all()] 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]: async def export(self, f: QualityFilters, fmt: ExportFormat, audit: RequestAudit) -> tuple[bytes, str, str]:
"""Файл выгрузки: (содержимое, media type, расширение). CSV — UTF-8 с BOM (для Excel), разделитель — запятая.""" """Файл выгрузки: (содержимое, media type, расширение). CSV — UTF-8 с BOM (для Excel), разделитель — запятая.
Массовая выгрузка с названиями ЮЛ оставляет `audit_event(EXPORT)` (R-12): отчёт, фильтры, формат, число
строк; запись — в транзакции запроса, как у экспорта сводной ОСВ."""
records = await self.export_records(f) 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": if fmt == "json":
return ( return (
json.dumps(jsonable_encoder(records), ensure_ascii=False).encode(), json.dumps(jsonable_encoder(records), ensure_ascii=False).encode(),

View File

@ -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: async def _write_mapping_failure_fact(session: AsyncSession, job: RecognitionJob, *, failed_at: datetime) -> None:
"""Факт отказа `MAPPING` (§14.5) — `job` уже дошёл до `done`/`needs_check` (`service_name`/`attempt` сервиса """Факт отказа `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) dims = await _failure_dimensions(session, job.file_id)
if dims is None: if dims is None:
logger.warning( logger.warning(

View File

@ -14,13 +14,17 @@ from typing import Any
import httpx import httpx
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.auth import current_user from src.api.dependencies.auth import current_user
from src.api.router import router from src.api.router import router
from src.core.exception_handlers import register_exception_handlers from src.core.exception_handlers import register_exception_handlers
from src.db.db import get_async_session from src.db.db import get_async_session
from src.models.audit_ex import AuditEvent
from src.models.enums import ( from src.models.enums import (
AuditAction,
AuditSection,
DocumentStatus, DocumentStatus,
DocumentType, DocumentType,
QualityCause, QualityCause,
@ -57,7 +61,7 @@ async def _forbidden_client(permissions: frozenset[str]) -> AsyncIterator[httpx.
async def principal() -> UserWithAccess: async def principal() -> UserWithAccess:
return UserWithAccess( return UserWithAccess(
user=SimpleNamespace(), user=SimpleNamespace(id=uuid.uuid4()),
regional_office=SimpleNamespace(), regional_office=SimpleNamespace(),
roles=(), roles=(),
permissions=permissions, # type: ignore[arg-type] 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"] 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 @pytest.mark.integration
async def test_quality_metrics_equal_outcome_at_prefers_after_confirm( async def test_quality_metrics_equal_outcome_at_prefers_after_confirm(
auth_client: AuthClientFactory, seeded: AsyncSession auth_client: AuthClientFactory, seeded: AsyncSession

View File

@ -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 file_id, job1_id = file.id, job1.id
# первая попытка: вахтёр переводит задание в timeout -> файл ERROR -> факт отказа TIMEOUT (attempt=1, задача 10 # первая попытка: вахтёр переводит задание в timeout -> файл ERROR -> факт отказа TIMEOUT (attempt=1, задача 10
# sites 1-4, уже на этой ветке) # sites 1-3, уже на этой ветке)
assert await expire_deadlines(maker) == 1 assert await expire_deadlines(maker) == 1
assert (await _file(maker, file_id)).status is FileStatus.ERROR assert (await _file(maker, file_id)).status is FileStatus.ERROR

View File

@ -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 (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: выборка идёт от фактов приёма (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: технические отказы — поле `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 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-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 (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: `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 | Task 16: тесты фронта — на чистые функции (`qualityView.ts`), не на рендер: в проекте нет jsdom/testing-library; экран в браузере не проверялся (нет стенда); экспорт — через `apiFetchBlob` (токен), `getExportUrl` из брифа не создан | нет инфраструктуры рендер-тестов; обычная ссылка не передаёт токен | раскладка, Drawer и скачивание проверены только чтением кода — обязательна ручная проверка на стенде до merge |
| 07.11.2026 | Выкладка: пункт меню «Качество первички» виден только после `make seed` на стенде (seed добавляет пары только для впервые созданного права) | поведение seed | без seed меню скрыто у всех, API отвечает 403 | | 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 было ошибочным | без записи выгрузка не оставляла следа; при записи в аудит каждый экспорт добавляет строку журнала |

View File

@ -1,6 +1,6 @@
import { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd'; import { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import { exportFacts, getMatrix } from '../../services/api/qualityMetrics'; import { exportFacts, getMatrix } from '../../services/api/qualityMetrics';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks'; import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
@ -11,7 +11,7 @@ import { saveBlob } from '../../utils/download';
import { DocumentDrillList } from './DocumentDrillList'; import { DocumentDrillList } from './DocumentDrillList';
import { type MatrixColumnData, MatrixTable } from './MatrixTable'; import { type MatrixColumnData, MatrixTable } from './MatrixTable';
import { MonthlyTrend } from './MonthlyTrend'; import { MonthlyTrend } from './MonthlyTrend';
import { formatShare, sourceColumns } from './qualityView'; import { formatShare, requestOnce, sourceColumns } from './qualityView';
const DOC_TYPES: { value: DocumentType; label: string }[] = [ const DOC_TYPES: { value: DocumentType; label: string }[] = [
{ value: 'BALANCE', label: 'Бухгалтерский баланс' }, { value: 'BALANCE', label: 'Бухгалтерский баланс' },
@ -48,10 +48,11 @@ export function QualityMetricsPage() {
// biome-ignore lint/nursery/useAtIndex: в lib проекта (tsconfig) нет Array.prototype.at // biome-ignore lint/nursery/useAtIndex: в lib проекта (tsconfig) нет Array.prototype.at
const matrix = matrices[matrices.length - 1]?.data ?? null; const matrix = matrices[matrices.length - 1]?.data ?? null;
// список ГК для фильтра: загружаем, если ещё не загружен // список ГК для фильтра: запрашиваем один раз за жизнь экрана (пустой ответ и ошибка не должны вызывать повтор)
const groupsRequested = useRef(false);
useEffect(() => { useEffect(() => {
if (groups.length === 0 && !groupsLoading) dispatch(getCompanyGroupsThunk()); requestOnce(groupsRequested, groups.length === 0 && !groupsLoading, () => dispatch(getCompanyGroupsThunk()));
}, [groups.length, groupsLoading, dispatch]); }, [groups.length, groupsLoading, dispatch]); // повторные срабатывания гасит groupsRequested
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;

View File

@ -1,7 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { QualityMatrixOut } from '../../types/qualityMetrics'; import type { QualityMatrixOut } from '../../types/qualityMetrics';
import { filterByPermission } from '../Sidebar/visibility'; 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 = { const matrixFixture: QualityMatrixOut = {
counts: { SERVICE_ERROR: 3, OUR_ERROR: 2, CLIENT_DOCUMENT: 1, WITHOUT_PARTICIPATION: 12, AS_IS: 5 }, 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('—'); 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();
});
});

View File

@ -137,3 +137,10 @@ export function buildSourceRows(columns: { key: string; data: QualityMatrixOut }
return { key: row.key, label: row.label, cells }; 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();
}