arm-core/api/src/services/documents.py
Andrey Epifancev 9bb9fba901 fix(#190): правка задачи 7 — PDF-добавленная строка «наш разбор», before у _header_fix
Ревью задачи 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>
2026-11-06 10:53:19 +04:00

566 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Документы первичных файлов: карточка, строки ОСВ, подтверждение (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)