feat(#190): API /quality-metrics — матрица, ряд по месяцам, список, выгрузка
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
779e13fca8
commit
bebc678a96
16
api/src/api/dependencies/quality_metrics.py
Normal file
16
api/src/api/dependencies/quality_metrics.py
Normal file
@ -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)]
|
||||
65
api/src/api/endpoints/quality_metrics.py
Normal file
65
api/src/api/endpoints/quality_metrics.py
Normal file
@ -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})
|
||||
@ -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
|
||||
|
||||
134
api/src/schemas/quality_metrics.py
Normal file
134
api/src/schemas/quality_metrics.py
Normal file
@ -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
|
||||
365
api/src/services/quality_metrics_service.py
Normal file
365
api/src/services/quality_metrics_service.py
Normal file
@ -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
|
||||
324
api/tests/test_api_quality_metrics.py
Normal file
324
api/tests/test_api_quality_metrics.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user