feat(#190): метрики качества первички (AURORA-1759) #229
@ -10,24 +10,48 @@
|
||||
перечитываются под блокировкой: файл, заменённый параллельной автозаменой, даёт 409 `FILE_SUPERSEDED` (Review Focus 5).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.clock import utc_now
|
||||
from src.common.exceptions import ConflictError, NotFoundError
|
||||
from src.domain.file_types import PIPELINES
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.domain.quality_attribution import (
|
||||
Attribution,
|
||||
attribute_added_row,
|
||||
attribute_code_fix,
|
||||
attribute_header_fix,
|
||||
attribute_value_fix,
|
||||
)
|
||||
from src.domain.quality_facts import build_outcome_payload
|
||||
from src.models.access import AppUser
|
||||
from src.models.audit_ex import Comment
|
||||
from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, IssueSeverity
|
||||
from src.models.enums import (
|
||||
AuditAction,
|
||||
AuditSection,
|
||||
DocumentIssueKind,
|
||||
DocumentStatus,
|
||||
DocumentType,
|
||||
FileStatus,
|
||||
IssueSeverity,
|
||||
IssueSource,
|
||||
QualityCause,
|
||||
QualityOutcome,
|
||||
RecognitionPipeline,
|
||||
)
|
||||
from src.models.files import Document, RecognitionJob, StoredFile
|
||||
from src.models.osv import OsvEntry
|
||||
from src.models.statements import FinancialStatementMeta
|
||||
from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue
|
||||
from src.repositories import files as files_repo
|
||||
from src.repositories import statements as statements_repo
|
||||
from src.repositories.quality_facts import insert_outcome_fact
|
||||
from src.schemas.comments import CommentAuthorOut, CommentIn, CommentOut
|
||||
from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut
|
||||
from src.schemas.files import DocumentBrief, FileOut, FileWarningOut, JobBrief
|
||||
@ -45,6 +69,124 @@ DOCUMENT_NOT_CONFIRMABLE = "DOCUMENT_NOT_CONFIRMABLE"
|
||||
PERIOD_REQUIRED = "PERIOD_REQUIRED"
|
||||
CHECKS_NOT_PASSED = "CHECKS_NOT_PASSED"
|
||||
CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK})
|
||||
# §14.2-§14.4 (design quality-metrics §4.2, §5, §6; AURORA-1759): факт исхода `CONFIRMED` пишется только для этих
|
||||
# типов — тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES` в `services/result_mapping.py` (Task 5) и
|
||||
# `OUTCOME_FACT_DOCUMENT_TYPES` в `services/auto_confirm.py` (Task 6); не импортируется оттуда во избежание цикла
|
||||
# `documents → auto_confirm → statements → documents` (то же решение, что в Task 6)
|
||||
OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV})
|
||||
# поле шапки → (атрибут «правилось», имя поля в `financial_statement_meta`); порядок — как в §14.2 «Шапка»
|
||||
_HEADER_FIX_FIELDS: tuple[tuple[str, str], ...] = (
|
||||
("inn_modified", "inn"),
|
||||
("report_date_modified", "report_date"),
|
||||
("units_modified", "units"),
|
||||
("organization_name_modified", "organization_name"),
|
||||
)
|
||||
_FIX_COUNTER_KEYS: tuple[str, ...] = (
|
||||
"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",
|
||||
)
|
||||
_SERVICE_COUNTER_KEYS: tuple[str, ...] = (
|
||||
"fixes_value_service",
|
||||
"fixes_code_service",
|
||||
"fixes_added_row_service",
|
||||
"fixes_header_service",
|
||||
)
|
||||
_OURS_COUNTER_KEYS: tuple[str, ...] = (
|
||||
"fixes_value_ours",
|
||||
"fixes_code_ours",
|
||||
"fixes_added_row_ours",
|
||||
"fixes_header_ours",
|
||||
"fixes_osv_period_account",
|
||||
)
|
||||
|
||||
|
||||
def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None:
|
||||
"""Правка суммы (§14.2 «Сумма»): `None` — значение не правилось (`is_modified` не `True`).
|
||||
|
||||
Нет сырого текста для сравнения (`source_text is None`) — классифицировать нельзя; по решению design §5
|
||||
«спорное — в наш разбор» (decision log 27.10.2026: анти-цель — не приписывать сервису наши недоработки)
|
||||
считаем правку нашей, не пропускаем (решение задачи 7, см. отчёт)."""
|
||||
if not value.is_modified:
|
||||
return None
|
||||
confirmed = str(value.amount) if value.amount is not None else ""
|
||||
attribution: Attribution = (
|
||||
"ours" if value.source_text is None else attribute_value_fix(confirmed=confirmed, raw_text=value.source_text)
|
||||
)
|
||||
return {
|
||||
"kind": "value",
|
||||
"line_id": str(value.line_id),
|
||||
"attribution": attribution,
|
||||
"before": str(value.original_amount) if value.original_amount is not None else None,
|
||||
"after": confirmed,
|
||||
}
|
||||
|
||||
|
||||
def _code_fix(line: FinancialStatementLine) -> dict[str, Any] | None:
|
||||
"""Правка кода строки (§14.2 «Код строки»): `None` — код не правился. Нет сырого текста — «наш разбор»
|
||||
(то же решение, что у правки суммы)."""
|
||||
if not line.line_code_modified:
|
||||
return None
|
||||
confirmed_code = line.line_code or ""
|
||||
attribution: Attribution = (
|
||||
"ours"
|
||||
if line.line_code_text is None
|
||||
else attribute_code_fix(confirmed_code=confirmed_code, raw_text=line.line_code_text)
|
||||
)
|
||||
return {
|
||||
"kind": "code",
|
||||
"line_id": str(line.id),
|
||||
"attribution": attribution,
|
||||
"before": line.line_code_original_value,
|
||||
"after": confirmed_code,
|
||||
}
|
||||
|
||||
|
||||
def _added_row_fix(line: FinancialStatementLine, *, is_excel: bool) -> dict[str, Any] | None:
|
||||
"""Добавленная строка (§14.2 «Добавленная строка», `row_index IS NULL` — v5.19). Для PDF — известный пробел:
|
||||
сырые четырёхзначные токены ответа ИС Сканирование Task 2/5 не извлекали, поэтому `raw_line_codes` всегда
|
||||
пуст и результат для PDF всегда «сервис» (design §11 «оставлено исполнителю»; задача 7 это не чинит — вне
|
||||
рамок, см. отчёт). Для Excel — всегда «наш разбор» (`attribute_added_row` сам это решает по `is_excel`)."""
|
||||
if line.row_index is not None:
|
||||
return None
|
||||
attribution = attribute_added_row(line.line_code or "", (), is_excel=is_excel)
|
||||
return {"kind": "added_row", "line_id": str(line.id), "attribution": attribution, "after": line.line_code}
|
||||
|
||||
|
||||
def _header_confirmed_value(meta: FinancialStatementMeta, field: str) -> str:
|
||||
value = getattr(meta, field)
|
||||
if isinstance(value, datetime.date):
|
||||
return value.isoformat()
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
def _header_fix(field: str, *, meta: FinancialStatementMeta, header_source: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Правка поля шапки (§14.2 «Шапка»); вызывается только когда `meta.<field>_modified` уже `True`. Нет сырого
|
||||
текста поля в `header_source` — «наш разбор» (то же решение, что у правки суммы/кода)."""
|
||||
confirmed = _header_confirmed_value(meta, field)
|
||||
source_entry = header_source.get(field) if isinstance(header_source, Mapping) else None
|
||||
source_text = source_entry.get("text") if isinstance(source_entry, Mapping) else None
|
||||
attribution: Attribution = (
|
||||
"ours" if source_text is None else attribute_header_fix(confirmed=confirmed, header_source_text=source_text)
|
||||
)
|
||||
return {"kind": "header", "field": field, "attribution": attribution, "after": confirmed}
|
||||
|
||||
|
||||
def _primary_cause(counters: Mapping[str, int], *, checks_accepted: bool) -> QualityCause:
|
||||
"""Приоритет §14.3 для `CONFIRMED` (design §5, §6): сервис > наш разбор > документ клиента > как есть."""
|
||||
if any(counters[key] > 0 for key in _SERVICE_COUNTER_KEYS):
|
||||
return QualityCause.SERVICE_ERROR
|
||||
if any(counters[key] > 0 for key in _OURS_COUNTER_KEYS):
|
||||
return QualityCause.OUR_ERROR
|
||||
if checks_accepted:
|
||||
return QualityCause.CLIENT_DOCUMENT
|
||||
return QualityCause.AS_IS
|
||||
|
||||
|
||||
def _metrics(job: RecognitionJob | None) -> dict[str, Any]:
|
||||
@ -289,6 +431,81 @@ class DocumentService:
|
||||
raise ConflictError(blocker.code, blocker.message, **blocker.extra)
|
||||
return meta
|
||||
|
||||
async def _write_confirm_outcome_fact(
|
||||
self, document: Document, file: StoredFile, meta: FinancialStatementMeta | None, now: datetime.datetime
|
||||
) -> None:
|
||||
"""Факт исхода `CONFIRMED` с атрибуцией правок (§14.2-§14.4; design quality-metrics §4.2, §5, §6;
|
||||
AURORA-1759). Гейт — `OUTCOME_FACT_DOCUMENT_TYPES`: DISCLOSURE/OTHER без факта (как у факта приёма/
|
||||
`AUTO_CONFIRMED`, Task 5/6). Правки ищутся по ЖИВЫМ колонкам `*_modified`/`original_*`/`source_text`
|
||||
(первичка не мутируется) — не по JSON `quality_intake_fact.payload` (решение задачи 7, см. отчёт: тот
|
||||
payload собран для другой цели и его форма — не для этого сравнения). Пишется в транзакции вызывающего
|
||||
(`confirm`), без `flush`/`commit`.
|
||||
"""
|
||||
if document.document_type not in OUTCOME_FACT_DOCUMENT_TYPES:
|
||||
return
|
||||
is_excel = PIPELINES[file.file_type] is RecognitionPipeline.tabular
|
||||
issues = await statements_repo.issues(self._session, document.id)
|
||||
fixes: list[dict[str, Any]] = []
|
||||
counters: dict[str, int] = dict.fromkeys(_FIX_COUNTER_KEYS, 0)
|
||||
|
||||
def _record(fix: dict[str, Any] | None, prefix: str) -> None:
|
||||
if fix is None:
|
||||
return
|
||||
fixes.append(fix)
|
||||
suffix = "service" if fix["attribution"] == "service" else "ours"
|
||||
counters[f"fixes_{prefix}_{suffix}"] += 1
|
||||
|
||||
if document.document_type is DocumentType.OSV:
|
||||
# ОСВ: без построчного снимка сумм (правок сумм ОСВ в ядре нет, design §5) — только предупреждения
|
||||
# сервиса и число строк; правка периода/счёта — document.is_modified, всегда «наш разбор» (Excel)
|
||||
row_count = (
|
||||
await self._session.execute(
|
||||
select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id)
|
||||
)
|
||||
).scalar_one()
|
||||
osv_warnings = [
|
||||
issue.code
|
||||
for issue in issues
|
||||
if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service
|
||||
]
|
||||
if document.is_modified:
|
||||
counters["fixes_osv_period_account"] = 1
|
||||
fixes.append({"kind": "osv_period_account", "attribution": "ours"})
|
||||
payload = build_outcome_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count, fixes=fixes)
|
||||
elif meta is not None:
|
||||
_, pairs = await statements_repo.load_form(self._session, meta.id)
|
||||
lines = [line for line, _ in pairs]
|
||||
values = [value for _, line_values in pairs for value in line_values]
|
||||
for value in values:
|
||||
_record(_value_fix(value), "value")
|
||||
for line in lines:
|
||||
_record(_code_fix(line), "code")
|
||||
_record(_added_row_fix(line, is_excel=is_excel), "added_row")
|
||||
for modified_attr, field_name in _HEADER_FIX_FIELDS:
|
||||
if getattr(meta, modified_attr):
|
||||
_record(_header_fix(field_name, meta=meta, header_source=meta.header_source), "header")
|
||||
payload = build_outcome_payload(
|
||||
document, lines=lines, values=values, issues=issues, header_source=meta.header_source, fixes=fixes
|
||||
)
|
||||
else:
|
||||
# BALANCE/INCOME без шапки (в текущем маппинге не возникает, см. Task 5/6) — снимок без построчных данных
|
||||
payload = build_outcome_payload(document, issues=issues, fixes=fixes)
|
||||
|
||||
checks_accepted = meta is not None and meta.checks_accepted_by is not None
|
||||
await insert_outcome_fact(
|
||||
self._session,
|
||||
document_id=document.id,
|
||||
outcome=QualityOutcome.CONFIRMED,
|
||||
outcome_at=now,
|
||||
primary_cause=_primary_cause(counters, checks_accepted=checks_accepted),
|
||||
report_date=document.reporting_date,
|
||||
confirmed_by=self._audit.user_id,
|
||||
issue_count_at_outcome=len(issues),
|
||||
checks_accepted=checks_accepted,
|
||||
payload=payload,
|
||||
**counters,
|
||||
)
|
||||
|
||||
async def confirm(self, document_id: uuid.UUID) -> DocumentOut:
|
||||
"""`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`.
|
||||
|
||||
@ -338,4 +555,5 @@ class DocumentService:
|
||||
before_value={"status": before.value},
|
||||
after_value={"status": DocumentStatus.CONFIRMED.value},
|
||||
)
|
||||
await self._write_confirm_outcome_fact(document, file, meta, now)
|
||||
return await self._card(document, file)
|
||||
|
||||
@ -10,6 +10,7 @@ import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@ -25,12 +26,19 @@ from src.models.enums import (
|
||||
AuditSection,
|
||||
CounterpartyMatchKind,
|
||||
DocumentStatus,
|
||||
DocumentType,
|
||||
FileStatus,
|
||||
PeriodQuarter,
|
||||
QualityCause,
|
||||
QualityOutcome,
|
||||
RecognitionStatus,
|
||||
)
|
||||
from src.models.files import Document, RecognitionJob, StoredFile
|
||||
from src.models.osv import OsvEntry
|
||||
from src.models.quality_facts import QualityOutcomeFact
|
||||
from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue
|
||||
from src.services.statement_recompute import recompute_form
|
||||
from src.services.statements_engine import default_engine
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import (
|
||||
make_company_group,
|
||||
@ -39,6 +47,7 @@ from tests.factories import (
|
||||
make_legal_entity,
|
||||
make_stored_file,
|
||||
)
|
||||
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
|
||||
|
||||
HTTP_OK = 200
|
||||
HTTP_FORBIDDEN = 403
|
||||
@ -61,8 +70,12 @@ LOCK_WAIT_SECONDS = 10.0
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _rostov_office(session: AsyncSession) -> RegionalOffice:
|
||||
return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
|
||||
|
||||
|
||||
async def _rostov_entity(session: AsyncSession, name: str = "ООО Документы") -> LegalEntity:
|
||||
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
|
||||
office = await _rostov_office(session)
|
||||
group = await make_company_group(session, name=f"ГК {name}", office=office)
|
||||
return await make_legal_entity(session, name=name, group=group)
|
||||
|
||||
@ -512,3 +525,239 @@ async def test_osv_v2_warnings_in_card_issues(seeded: AsyncSession, auth_client:
|
||||
assert {issue["code"] for issue in service} == {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"}
|
||||
assert all(issue["kind"] == "warning" and issue["severity"] == "info" for issue in service)
|
||||
assert body["status"] == "NEEDS_CHECK"
|
||||
|
||||
|
||||
# --- факт исхода CONFIRMED (§14.2-14.4, design quality-metrics §4.2/§5/§6, AURORA-1759) -------------------------------
|
||||
|
||||
INCOME_FORM = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
|
||||
SYNTHETIC_DISCLOSURE = GOLDEN_STATEMENTS / "synthetic" / "st06_header_variants.json"
|
||||
|
||||
|
||||
async def _golden_owner(session: AsyncSession, path: Path, *, office: RegionalOffice, name: str) -> LegalEntity:
|
||||
"""Владелец с ИНН шапки golden-снимка `path` (совпадает с шапкой — `counterparty_check = MATCH`)."""
|
||||
data = json.loads(path.read_text("utf-8"))
|
||||
inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None)
|
||||
return await make_legal_entity(session, name=name, inn=inn, office=office)
|
||||
|
||||
|
||||
async def _income_form(
|
||||
session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice
|
||||
) -> tuple[Document, FinancialStatementMeta]:
|
||||
"""Чистая (confirmable) форма ОФР из golden-снимка — база для тестов факта исхода `CONFIRMED`."""
|
||||
path = fixtures_dir / INCOME_FORM
|
||||
owner = await _golden_owner(session, path, office=office, name="ООО Владелец Исход")
|
||||
(income,) = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None)
|
||||
assert income.document_type is DocumentType.INCOME
|
||||
meta = (
|
||||
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
|
||||
).scalar_one()
|
||||
return income, meta
|
||||
|
||||
|
||||
async def _synthetic_disclosure(session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice) -> Document:
|
||||
"""RECOGNIZED `DISCLOSURE` из синтетического снимка st06 (та же фикстура, что в test_auto_confirm.py)."""
|
||||
path = fixtures_dir / SYNTHETIC_DISCLOSURE
|
||||
owner = await _golden_owner(session, path, office=office, name="ООО Синтетика Документы")
|
||||
documents = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None)
|
||||
disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE)
|
||||
assert disclosure.status is DocumentStatus.RECOGNIZED
|
||||
return disclosure
|
||||
|
||||
|
||||
async def _set_value_fix(
|
||||
session: AsyncSession,
|
||||
income: Document,
|
||||
*,
|
||||
source_text: str,
|
||||
original_amount: Decimal,
|
||||
confirmed_amount: Decimal,
|
||||
) -> FinancialStatementValue:
|
||||
"""Правит одно значение формы напрямую (ORM, минуя `PATCH .../statement`) — `is_modified=True` и `source_text`
|
||||
для проверки отнесения правки (§14.2 «Сумма»)."""
|
||||
meta = (
|
||||
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
|
||||
).scalar_one()
|
||||
value = (
|
||||
await session.execute(
|
||||
select(FinancialStatementValue)
|
||||
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
|
||||
.where(FinancialStatementLine.meta_id == meta.id)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one()
|
||||
value.is_modified = True
|
||||
value.source_text = source_text
|
||||
value.original_amount = original_amount
|
||||
value.amount = confirmed_amount
|
||||
await session.flush()
|
||||
return value
|
||||
|
||||
|
||||
async def _break_2200_and_accept(
|
||||
session: AsyncSession,
|
||||
document: Document,
|
||||
meta: FinancialStatementMeta,
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
now: datetime.datetime,
|
||||
) -> None:
|
||||
"""Ломает итог 2200 (как `test_form_confirmable.py::_break_2200`) и сразу акцептует нарушение — документ
|
||||
остаётся подтверждаемым (`form_blocker` не видит нарушения при акцепте), но `checks_accepted_by` задан."""
|
||||
values = (
|
||||
await session.execute(
|
||||
select(FinancialStatementValue)
|
||||
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
|
||||
.where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200")
|
||||
)
|
||||
).scalars()
|
||||
for value in values:
|
||||
value.amount = (value.amount or 0) + 1000
|
||||
await session.flush()
|
||||
job = await session.get(RecognitionJob, meta.recognition_job_id)
|
||||
await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job)
|
||||
meta.checks_accepted_by = user_id
|
||||
meta.checks_accepted_at = now
|
||||
meta.checks_accepted_comment = "акцептовано для теста факта исхода"
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact:
|
||||
return (
|
||||
await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
|
||||
).scalar_one()
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
async def test_confirm_writes_outcome_fact_as_is_when_no_fixes(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""§14.3 п. 6: ручное подтверждение чистой формы без правок и без акцепта — `AS_IS`."""
|
||||
office = await _rostov_office(seeded)
|
||||
income, _meta = await _income_form(seeded, fixtures_dir, office=office)
|
||||
user_id = await _user_id(seeded, KA_RF)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(f"/documents/{income.id}/confirm")
|
||||
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
fact = await _outcome_fact(seeded, income.id)
|
||||
assert fact.outcome == QualityOutcome.CONFIRMED
|
||||
assert fact.primary_cause == QualityCause.AS_IS
|
||||
assert fact.checks_accepted is False
|
||||
assert fact.confirmed_by == user_id
|
||||
assert all(
|
||||
getattr(fact, key) == 0
|
||||
for key in (
|
||||
"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",
|
||||
)
|
||||
)
|
||||
assert fact.payload["fixes"] == []
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""§14.2 «Сумма»: верный ответ есть в сыром тексте сервиса (разбор ошибся мы) — `OUR_ERROR`."""
|
||||
office = await _rostov_office(seeded)
|
||||
income, _meta = await _income_form(seeded, fixtures_dir, office=office)
|
||||
fixed = await _set_value_fix(
|
||||
seeded,
|
||||
income,
|
||||
source_text="1 234", # сервис прочитал верно (тысячи через пробел)
|
||||
original_amount=Decimal("1234000.00"), # наш разбор тысячного разделителя ошибся
|
||||
confirmed_amount=Decimal("1234.00"), # аналитик подтвердил то, что действительно в сыром тексте
|
||||
)
|
||||
fixed_line_id = fixed.line_id # до HTTP-запроса: после него объекты сессии теста могут устареть
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(f"/documents/{income.id}/confirm")
|
||||
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
fact = await _outcome_fact(seeded, income.id)
|
||||
assert fact.primary_cause == QualityCause.OUR_ERROR
|
||||
assert (fact.fixes_value_ours, fact.fixes_value_service) == (1, 0)
|
||||
assert fact.payload["fixes"] == [
|
||||
{
|
||||
"kind": "value",
|
||||
"line_id": str(fixed_line_id),
|
||||
"attribution": "ours",
|
||||
"before": "1234000.00",
|
||||
"after": "1234.00",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
async def test_confirm_writes_outcome_fact_service_error_when_fix_not_in_raw_text(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""§14.2 «Сумма»: верного ответа в сыром тексте нет — сервис прочитал не те цифры — `SERVICE_ERROR`."""
|
||||
office = await _rostov_office(seeded)
|
||||
income, _meta = await _income_form(seeded, fixtures_dir, office=office)
|
||||
await _set_value_fix(
|
||||
seeded,
|
||||
income,
|
||||
source_text="99", # сервис прочитал совсем другие цифры
|
||||
original_amount=Decimal("99.00"),
|
||||
confirmed_amount=Decimal("1234.00"),
|
||||
)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(f"/documents/{income.id}/confirm")
|
||||
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
fact = await _outcome_fact(seeded, income.id)
|
||||
assert fact.primary_cause == QualityCause.SERVICE_ERROR
|
||||
assert (fact.fixes_value_service, fact.fixes_value_ours) == (1, 0)
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
async def test_confirm_writes_outcome_fact_client_document_when_checks_accepted(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""§14.3/§14.4: подтверждение с акцептом нарушенного жёсткого соотношения §7, без правок — `CLIENT_DOCUMENT`."""
|
||||
office = await _rostov_office(seeded)
|
||||
income, meta = await _income_form(seeded, fixtures_dir, office=office)
|
||||
user_id = await _user_id(seeded, KA_RF)
|
||||
await _break_2200_and_accept(seeded, income, meta, user_id=user_id, now=datetime.datetime.now(datetime.UTC))
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(f"/documents/{income.id}/confirm")
|
||||
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
fact = await _outcome_fact(seeded, income.id)
|
||||
assert fact.checks_accepted is True
|
||||
assert fact.primary_cause == QualityCause.CLIENT_DOCUMENT
|
||||
assert fact.payload["fixes"] == []
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
async def test_disclosure_confirm_has_no_outcome_fact(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""Гейт типа документа (тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES`/`OUTCOME_FACT_DOCUMENT_TYPES`, Task 5/6):
|
||||
`DISCLOSURE` подтверждается (симметрично `test_recognized_disclosure_is_manually_confirmable`,
|
||||
test_auto_confirm.py), но факт исхода `CONFIRMED` не пишется."""
|
||||
office = await _rostov_office(seeded)
|
||||
disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(f"/documents/{disclosure.id}/confirm")
|
||||
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert response.json()["status"] == "CONFIRMED"
|
||||
facts = (
|
||||
(await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert facts == []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user