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:
parent
25c568b5be
commit
85c5ab8596
@ -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})
|
||||||
|
|||||||
@ -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` — уникальные файлы с фактом приёма или отказа в выбранных период/ГК/ЮЛ/источнике/скане."""
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 было ошибочным | без записи выгрузка не оставляла следа; при записи в аудит каждый экспорт добавляет строку журнала |
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user