Ревью задачи 7, раунд 1: (1) добавлена строка design.md 06.11.2026 — решение «нет сырого текста → наш разбор» для value/code/header фиксов задокументировано отдельно (не то же, что 1250\n1260); тем же рядом исправлено направление для PDF-добавленной строки — "наш разбор", не "сервис" (пустой raw_line_codes не должен давать service через attribute_added_row, это противоречило уже принятому 27.10.2026 решению). (2) _added_row_fix для PDF больше не вызывает attribute_added_row с заведомо пустым raw_line_codes — атрибуция "ours" прямо. (3) _header_fix теперь возвращает "before" (meta.<field>_original_value), как у _value_fix/_code_fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
566 lines
30 KiB
Python
566 lines
30 KiB
Python
"""Документы первичных файлов: карточка, строки ОСВ, подтверждение (spec documents-api «Документ и строки»,
|
||
«Подтверждение документа»; audit-trail `DOCUMENT_CONFIRM`; design D-11, R-6) и сборка карточки файла (`file_out`).
|
||
|
||
Видимость — через ЮЛ-владельца (`repositories.files`); документ вне видимости — тот же 404, что несуществующий.
|
||
|
||
Подтверждение и автозамена (`services.result_mapping.supersede_previous`) могут идти одновременно. Порядок блокировок
|
||
подтверждения — `stored_file … FOR UPDATE` → `document … FOR NO KEY UPDATE` (R-3) → advisory ГК → версии консолидации
|
||
«в работе» по `id` (`consolidation_documents.on_document_confirmed`, D-9, R-2): автозамена блокирует файлы (новый, затем
|
||
старые по id), затем advisory ГК и версии, документы не блокирует, поэтому взаимной блокировки нет. Файл и документ
|
||
перечитываются под блокировкой: файл, заменённый параллельной автозаменой, даёт 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,
|
||
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 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
|
||
from src.schemas.issues import IssueCountsOut, IssueOut, issue_out
|
||
from src.services import consolidation_documents
|
||
from src.services.result_mapping import MAPPING_ERROR_KEY, MAPPING_WARNINGS_KEY
|
||
|
||
DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего
|
||
OBJECT_TYPE = "document"
|
||
LEGACY_WARNING_CODE = "LEGACY_WARNING" # строка предупреждения контракта 1.x в ответе API
|
||
WARNINGS_KEY = "warnings" # `recognition_job.metrics.warnings` — предупреждения сервиса распознавания
|
||
FILE_SUPERSEDED = "FILE_SUPERSEDED"
|
||
ALREADY_CONFIRMED = "ALREADY_CONFIRMED"
|
||
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.md 06.11.2026 — уточнение решения 27.10.2026 «спорное — в наш разбор» на случай «данных
|
||
нет вовсе», а не «есть, но двусмысленно»; анти-цель §2/§5 — не приписывать сервису наши недоработки)."""
|
||
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` — код не правился. Нет сырого текста — «наш разбор»
|
||
(то же решение design.md 06.11.2026, что у правки суммы)."""
|
||
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).
|
||
|
||
Excel — `attribute_added_row` (всегда «наш разбор» при `is_excel=True`, без зависимости от токенов). PDF —
|
||
сырые четырёхзначные токены ответа ИС Сканирование Task 2/5 не извлекали, `raw_line_codes` для PDF нет совсем
|
||
(не просто пуст): `attribute_added_row` здесь НЕ вызывается — передача пустой последовательности ошибочно
|
||
вернула бы «сервис», что противоречит уже принятому 27.10.2026 направлению design.md («совпадение с суммой
|
||
даёт наш разбор — по правилу „спорное — нам“»). Атрибуция для PDF — «наш разбор» прямо (design.md, 06.11.2026,
|
||
поправка к задаче 7: деферренный пробел — когда токены появятся, здесь будет настоящий вызов
|
||
`attribute_added_row` с реальным `raw_line_codes`, а не прямая атрибуция)."""
|
||
if line.row_index is not None:
|
||
return None
|
||
attribution: Attribution = attribute_added_row(line.line_code or "", (), is_excel=True) if is_excel else "ours"
|
||
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` — «наш разбор» (то же решение, что у правки суммы/кода; design.md 06.11.2026)."""
|
||
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)
|
||
)
|
||
before = getattr(meta, f"{field}_original_value", None)
|
||
return {"kind": "header", "field": field, "attribution": attribution, "before": before, "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]:
|
||
return job.metrics if job is not None and isinstance(job.metrics, dict) else {}
|
||
|
||
|
||
def _strings(value: Any) -> list[str]:
|
||
"""Список строк из JSONB (`metrics.*`); иное содержимое — пустой список."""
|
||
return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
|
||
|
||
|
||
def file_warnings(value: Any) -> list[FileWarningOut]:
|
||
"""Предупреждения файла из `recognition_job.metrics.warnings`. Строка (задание контракта 1.x) — `LEGACY_WARNING`
|
||
уровня info; объект с `code`, `severity` ∈ {error, info} и `message` — как есть; всё прочее (и значение, не
|
||
являющееся списком) пропускается. Данные в БД не переписываются."""
|
||
if not isinstance(value, list):
|
||
return []
|
||
result: list[FileWarningOut] = []
|
||
for item in value:
|
||
if isinstance(item, str):
|
||
result.append(FileWarningOut(code=LEGACY_WARNING_CODE, severity="info", message=item, locator=None))
|
||
elif isinstance(item, dict):
|
||
code, severity, message, locator = (item.get(k) for k in ("code", "severity", "message", "locator"))
|
||
if (
|
||
isinstance(code, str)
|
||
and severity in ("error", "info")
|
||
and isinstance(message, str)
|
||
and (locator is None or isinstance(locator, str))
|
||
):
|
||
result.append(FileWarningOut(code=code, severity=severity, message=message, locator=locator))
|
||
return result
|
||
|
||
|
||
def job_brief(job: RecognitionJob) -> JobBrief:
|
||
"""Текущее задание файла для карточки: статус, ошибки сервиса и маппинга, предупреждения."""
|
||
metrics = _metrics(job)
|
||
mapping_error = metrics.get(MAPPING_ERROR_KEY)
|
||
return JobBrief(
|
||
id=job.id,
|
||
status=job.status,
|
||
error_code=job.error_code,
|
||
error_message=job.error_message,
|
||
mapping_error=mapping_error if isinstance(mapping_error, str) else None,
|
||
warnings=file_warnings(metrics.get(WARNINGS_KEY)),
|
||
mapping_warnings=_strings(metrics.get(MAPPING_WARNINGS_KEY)),
|
||
finished_at=job.finished_at,
|
||
)
|
||
|
||
|
||
def file_out(
|
||
file: StoredFile,
|
||
job: RecognitionJob | None,
|
||
documents: Sequence[Document],
|
||
counts: Mapping[uuid.UUID, int] | None = None,
|
||
issue_counts: Mapping[uuid.UUID, statements_repo.IssueCounts] | None = None,
|
||
) -> FileOut:
|
||
"""Карточка файла (spec documents-api «Файлы организации и карточка файла»).
|
||
|
||
`counts` — `document_id → число комментариев` (`files_repo.comments_count_by_document`); нет — нули.
|
||
`issue_counts` — `document_id → число записей document_issue по severity`
|
||
(`statements_repo.issue_counts_by_document`); нет — нули.
|
||
"""
|
||
counts = counts or {}
|
||
issue_counts = issue_counts or {}
|
||
return FileOut(
|
||
id=file.id,
|
||
legal_entity_id=file.legal_entity_id,
|
||
original_name=file.original_name,
|
||
file_type=file.file_type,
|
||
status=file.status,
|
||
version_no=file.version_no,
|
||
size_bytes=file.size_bytes,
|
||
content_hash=file.content_hash,
|
||
uploaded_at=file.uploaded_at,
|
||
uploaded_by=file.uploaded_by,
|
||
superseded_by_file_id=file.superseded_by_file_id,
|
||
consolidation_id=file.consolidation_id,
|
||
job=job_brief(job) if job is not None else None,
|
||
documents=[
|
||
DocumentBrief.model_validate(document).model_copy(
|
||
update={
|
||
"comments_count": counts.get(document.id, 0),
|
||
"issue_counts": _counts_out(issue_counts.get(document.id)),
|
||
}
|
||
)
|
||
for document in documents
|
||
],
|
||
)
|
||
|
||
|
||
def _group_schema(job: RecognitionJob | None, locator: str | None) -> list[GroupOut] | None:
|
||
"""Группировки листа документа: элемент `job.group_schema` с тем же `locator` (R-6); нет — `None`."""
|
||
if job is None or locator is None or not isinstance(job.group_schema, list):
|
||
return None
|
||
for sheet in job.group_schema:
|
||
if isinstance(sheet, dict) and sheet.get("locator") == locator:
|
||
groups = sheet.get("groups")
|
||
return [GroupOut.model_validate(group) for group in groups] if isinstance(groups, list) else []
|
||
return None
|
||
|
||
|
||
def _counts_out(counts: statements_repo.IssueCounts | None) -> IssueCountsOut:
|
||
return IssueCountsOut(error=counts.error, info=counts.info) if counts is not None else IssueCountsOut()
|
||
|
||
|
||
def document_out( # noqa: PLR0913, PLR0917 - карточка собирается из частей разных запросов
|
||
document: Document,
|
||
job: RecognitionJob | None,
|
||
comments_count: int = 0,
|
||
issues: Sequence[IssueOut] = (),
|
||
issue_counts: statements_repo.IssueCounts | None = None,
|
||
) -> DocumentOut:
|
||
"""Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга),
|
||
`comments_count` — число комментариев документа, `issues` — его записи `document_issue`."""
|
||
return DocumentOut(
|
||
id=document.id,
|
||
file_id=document.stored_file_id,
|
||
legal_entity_id=document.legal_entity_id,
|
||
document_type=document.document_type,
|
||
osv_account_code=document.osv_account_code,
|
||
period_year=document.period_year,
|
||
period_quarter=document.period_quarter,
|
||
is_cumulative=document.is_cumulative,
|
||
locator=document.locator,
|
||
status=document.status,
|
||
period_check=document.period_check,
|
||
counterparty_check=document.counterparty_check,
|
||
confirmed_by=document.confirmed_by,
|
||
confirmed_at=document.confirmed_at,
|
||
group_schema=_group_schema(job, document.locator),
|
||
# ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла
|
||
warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)),
|
||
is_modified=document.is_modified,
|
||
original_values=document.original_values,
|
||
modified_by=document.modified_by,
|
||
modified_at=document.modified_at,
|
||
comments_count=comments_count,
|
||
issues=list(issues),
|
||
issue_counts=_counts_out(issue_counts),
|
||
)
|
||
|
||
|
||
def _issue_counts(issues: Sequence[Any]) -> statements_repo.IssueCounts:
|
||
return statements_repo.IssueCounts(
|
||
error=sum(1 for issue in issues if issue.severity is IssueSeverity.error),
|
||
info=sum(1 for issue in issues if issue.severity is IssueSeverity.info),
|
||
)
|
||
|
||
|
||
def _comment_out(comment: Comment, full_name: str) -> CommentOut:
|
||
return CommentOut(
|
||
id=comment.id,
|
||
text=comment.text,
|
||
author=CommentAuthorOut(id=comment.author_id, full_name=full_name),
|
||
created_at=comment.created_at,
|
||
)
|
||
|
||
|
||
class DocumentService:
|
||
"""Чтение и подтверждение документов от имени пользователя запроса (права — на маршруте, видимость — здесь)."""
|
||
|
||
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
|
||
self._session = session
|
||
self._audit = audit
|
||
self._scope = scope
|
||
|
||
async def _visible(self, document_id: uuid.UUID) -> Document:
|
||
document = await files_repo.get_document(self._session, self._scope, document_id)
|
||
if document is None:
|
||
raise NotFoundError(DOCUMENT_NOT_FOUND)
|
||
return document
|
||
|
||
async def _current_job(self, file: StoredFile | None) -> RecognitionJob | None:
|
||
if file is None or file.current_recognition_job_id is None:
|
||
return None
|
||
return await self._session.get(RecognitionJob, file.current_recognition_job_id)
|
||
|
||
async def _comments_count(self, document_id: uuid.UUID) -> int:
|
||
counts = await files_repo.comments_count_by_document(self._session, [document_id])
|
||
return counts.get(document_id, 0)
|
||
|
||
async def _card(self, document: Document, file: StoredFile | None) -> DocumentOut:
|
||
issues = await statements_repo.issues(self._session, document.id)
|
||
return document_out(
|
||
document,
|
||
await self._current_job(file),
|
||
await self._comments_count(document.id),
|
||
[issue_out(issue) for issue in issues],
|
||
_issue_counts(issues),
|
||
)
|
||
|
||
async def comments(self, document_id: uuid.UUID) -> list[CommentOut]:
|
||
"""Комментарии видимого документа по `created_at` с ФИО автора; иначе 404."""
|
||
document = await self._visible(document_id)
|
||
return [
|
||
_comment_out(comment, full_name)
|
||
for comment, full_name in await files_repo.list_comments(self._session, document.id)
|
||
]
|
||
|
||
async def add_comment(self, document_id: uuid.UUID, body: CommentIn) -> CommentOut:
|
||
"""Комментарий к видимому документу в любом статусе, в том числе файла `SUPERSEDED`; аудит не пишется (D-11)."""
|
||
document = await self._visible(document_id)
|
||
comment = Comment(
|
||
object_type=files_repo.COMMENT_OBJECT_TYPE,
|
||
object_id=document.id,
|
||
text=body.text,
|
||
author_id=self._audit.user_id,
|
||
created_at=utc_now(),
|
||
)
|
||
self._session.add(comment)
|
||
await self._session.flush()
|
||
author = await self._session.get(AppUser, comment.author_id)
|
||
return _comment_out(comment, author.full_name if author is not None else "")
|
||
|
||
async def get(self, document_id: uuid.UUID) -> DocumentOut:
|
||
"""Карточка видимого документа; иначе 404."""
|
||
document = await self._visible(document_id)
|
||
file = await self._session.get(StoredFile, document.stored_file_id)
|
||
return await self._card(document, file)
|
||
|
||
async def entries(self, document_id: uuid.UUID, page: EntriesPageParams) -> tuple[list[OsvEntry], int]:
|
||
"""Страница строк видимого документа по `row_number`; иначе 404."""
|
||
document = await self._visible(document_id)
|
||
return await files_repo.list_entries(self._session, document.id, limit=page.limit, offset=page.offset)
|
||
|
||
async def _check_statement(self, document: Document) -> FinancialStatementMeta | None:
|
||
"""Условия подтверждения формы ББ/ОФР (D-9): `form_blocker` — период, нераспознанные строки со значениями,
|
||
дубль формы, жёсткие проверки §7 (пройдены или акцептованы).
|
||
|
||
Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт).
|
||
Для не-формы и документа без шапки — `None`, условий нет.
|
||
"""
|
||
from src.services.statements import FORM_TYPES, form_blocker # noqa: PLC0415 - цикл импорта
|
||
|
||
if document.document_type not in FORM_TYPES:
|
||
return None
|
||
meta = await statements_repo.get_meta(self._session, document.id, refresh=True)
|
||
if meta is None:
|
||
return None
|
||
blocker = await form_blocker(self._session, document, meta)
|
||
if blocker is not None:
|
||
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`.
|
||
|
||
409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус —
|
||
`DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён),
|
||
`LINES_UNRESOLVED`, `DUPLICATE_FORM` и `CHECKS_NOT_PASSED` (неакцептованные нарушения §7 в
|
||
`failures`; D-9). Порядок блокировок и перечитывание — см. docstring модуля. В той же транзакции документ
|
||
привязывается к подходящим версиям «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING`
|
||
переходят в `DATA_CORRECTION`
|
||
(`on_document_confirmed`, spec documents-api «Подтверждение документа»).
|
||
"""
|
||
visible = await self._visible(document_id)
|
||
file = await files_repo.lock_file(self._session, visible.stored_file_id)
|
||
document = await files_repo.lock_document(self._session, document_id)
|
||
if file is None or document is None: # удалены параллельно, пока ждали блокировку
|
||
raise NotFoundError(DOCUMENT_NOT_FOUND)
|
||
if file.status is FileStatus.SUPERSEDED:
|
||
raise ConflictError(
|
||
FILE_SUPERSEDED, "файл документа заменён новой версией: подтвердите документ нового файла"
|
||
)
|
||
if document.status is DocumentStatus.CONFIRMED:
|
||
raise ConflictError(ALREADY_CONFIRMED, "документ уже подтверждён")
|
||
if document.status not in CONFIRMABLE_STATUSES:
|
||
raise ConflictError(
|
||
DOCUMENT_NOT_CONFIRMABLE,
|
||
f"документ в статусе {document.status.value} подтвердить нельзя: нужен RECOGNIZED или NEEDS_CHECK",
|
||
)
|
||
meta = await self._check_statement(document)
|
||
before = document.status
|
||
now = utc_now()
|
||
document.status = DocumentStatus.CONFIRMED
|
||
document.confirmed_by = self._audit.user_id
|
||
document.confirmed_at = now
|
||
document.updated_at = now
|
||
if meta is not None:
|
||
meta.reviewed_by = self._audit.user_id
|
||
meta.reviewed_at = now
|
||
await self._session.flush()
|
||
await consolidation_documents.on_document_confirmed(
|
||
self._session, self._audit.service, document=document, changed_by=self._audit.user_id, now=now
|
||
)
|
||
await self._audit.record(
|
||
action=AuditAction.DOCUMENT_CONFIRM,
|
||
section=AuditSection.PRIMARY_DOCUMENTS,
|
||
object_type=OBJECT_TYPE,
|
||
object_id=document.id,
|
||
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)
|