feat(#190): API /quality-metrics — матрица, ряд по месяцам, список, выгрузка

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-07 02:43:36 +04:00
parent 779e13fca8
commit bebc678a96
6 changed files with 906 additions and 1 deletions

View 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)]

View 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})

View File

@ -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

View 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

View 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

View 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