arm-core/api/src/services/statements.py
Andrey Epifancev 065153e470 feat(statements): пересчёт после правки точкой входа arm_statements, §7 только из document_issue
recompute_form (services/statement_recompute.py) собирает вход из БД, зовёт engine.recompute и пишет needs_review
строк и значений, form_variant, parent_row_index расшифровок, записи relation/soft и core по P3-8, статус через
form_status_v2. GET/confirm/accept читают нарушения §7 из document_issue. Правка header.report_date пересчитывает
relative:* колонки. Временный код A12/A13 удалён.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:26:40 +04:00

553 lines
32 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; design D-5, D-7, D-8, D-14).
Видимость — через ЮЛ-владельца (`files_repo.get_document`): чужой документ — тот же 404, что несуществующий.
Нарушения §7 хранятся в `document_issue` и пересчитываются после каждой правки общим кодом `arm_statements`.
"""
import re
import uuid
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
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, DomainValidationError, NotFoundError
from src.domain.osv_mapping import counterparty_check
from src.domain.policy import VisibilityScope
from src.domain.statement_aggregate import CodeAggregate, checks_input
from src.domain.statement_checks import FailedCheck, cross_form_check, subline_parents
from src.domain.statement_issues import COLUMN_CLEARS, HEADER_CLEARS
from src.domain.statement_lines import TemplateLine, load_line_catalog
from src.domain.statement_mapping import edition_for, quarter_of, statement_period
from src.models.directories import LegalEntity
from src.models.enums import (
AuditAction,
AuditSection,
DocumentIssueKind,
DocumentStatus,
DocumentType,
FileStatus,
IssueSource,
StatementLineStatus,
)
from src.models.files import Document, RecognitionJob
from src.models.statements import (
DocumentIssue,
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from src.repositories import files as files_repo
from src.repositories import statements as repo
from src.repositories.counterparties import CounterpartyIndex
from src.schemas import statements as schemas
from src.services.documents import (
CHECKS_NOT_PASSED,
CONFIRMABLE_STATUSES,
DOCUMENT_NOT_CONFIRMABLE,
FILE_SUPERSEDED,
PERIOD_REQUIRED,
)
from src.services.statement_line_edits import apply_line_edits
from src.services.statement_recompute import recompute_form
from src.services.statements_engine import PeriodKind, StatementsEngine, default_engine
DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
DOCUMENT_READONLY = "DOCUMENT_READONLY"
LINE_EXISTS = "строка уже есть в форме — передайте line_id"
UNKNOWN_CELL = "UNKNOWN_CELL"
UNKNOWN_COLUMN = "UNKNOWN_COLUMN"
DUPLICATE_COLUMN = "DUPLICATE_COLUMN"
DUPLICATE_CELL = "DUPLICATE_CELL"
NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT"
INVALID_INN = "INVALID_INN"
LINES_UNRESOLVED = "LINES_UNRESOLVED"
DUPLICATE_FORM = "DUPLICATE_FORM"
OBJECT_TYPE = "document"
ABSOLUTE = "absolute"
INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
async def form_aggregate(
session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[list[FinancialStatementColumn], dict[str, CodeAggregate]]:
"""Колонки и агрегат формы по коду со справочными наименованиями редакции документа (P3-14)."""
catalog = load_line_catalog()
edition = edition_for(document.reporting_date)
form = document.document_type.value
return await repo.form_aggregate(session, meta.id, lambda code: catalog.reference_name(edition, form, code))
async def statement_hard_failures(
session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[DocumentIssue, ...]:
"""Нарушенные жёсткие проверки §7 — записи `document_issue` вида `relation`; при акцепте — пустой кортеж (D-8
Среза 3b). Форма без записей (до первого пересчёта) нарушений не показывает."""
if meta.checks_accepted_at is not None:
return ()
return tuple(issue for issue in await repo.issues(session, document.id) if issue.kind is DocumentIssueKind.relation)
@dataclass(frozen=True)
class FormBlocker:
"""Причина отказа в подтверждении формы: код 409, сообщение и поля `detail`."""
code: str
message: str
extra: dict[str, Any]
async def form_blocker(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> FormBlocker | None:
"""Первое препятствие подтверждению формы ББ/ОФР или `None` (общий путь ручного и автоподтверждения).
Порядок: `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (строки `unreadable`/`missing` с непустой ячейкой) →
`DUPLICATE_FORM` (уже подтверждена форма того же файла, типа и периода) → `CHECKS_NOT_PASSED` (жёсткие §7
без акцепта).
"""
if document.period_year is None or document.period_quarter is None:
return FormBlocker(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта", {})
lines = await repo.unresolved_lines(session, meta.id)
if lines:
return FormBlocker(
LINES_UNRESOLVED,
"в форме есть нераспознанные строки со значениями: назначьте код или отметьте строку расшифровкой",
{
"lines": [
{"line_id": str(line_id), "row_index": row_index, "line_code_text": code_text}
for line_id, row_index, code_text in lines
]
},
)
twin = await files_repo.confirmed_twin(session, document)
if twin is not None:
return FormBlocker(
DUPLICATE_FORM, "форма этого типа и периода в файле уже подтверждена", {"document_id": str(twin)}
)
failures = await statement_hard_failures(session, document, meta)
if failures:
return FormBlocker(
CHECKS_NOT_PASSED,
"не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием",
{
"failures": [
{
"column_index": failure.column_index,
"relation": failure.relation,
"expected": None if failure.expected is None else str(failure.expected),
"actual": None if failure.actual is None else str(failure.actual),
}
for failure in failures
]
},
)
return None
@dataclass
class _Edit:
"""Накопитель одного PATCH: `before/after` для единственного события аудита и признак правки данных (сброс
акцепта — только ею; подтверждения акцепт не сбрасывают, Р-25)."""
before: dict[str, Any]
after: dict[str, Any]
data_edited: bool = False
def record(self, key: str, old: Any, new: Any, *, data: bool = True) -> None: # noqa: ANN401 - значения разных типов
self.before[key], self.after[key] = old, new
self.data_edited = self.data_edited or data
class StatementService:
def __init__(
self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, engine: StatementsEngine | None = None
) -> None:
self._session = session
self._audit = audit
self._scope = scope
self._engine = engine if engine is not None else default_engine()
async def _visible_statement(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]:
"""Документ формы в пределах видимости: 404 — нет или чужой; 409 `DOCUMENT_NOT_STATEMENT` — не форма."""
document = await files_repo.get_document(self._session, self._scope, document_id)
if document is None:
raise NotFoundError(DOCUMENT_NOT_FOUND)
meta = await repo.get_meta(self._session, document.id) if document.document_type in FORM_TYPES else None
if meta is None:
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
return document, meta
async def _editable(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]:
"""Видимость → блокировки «файл → документ» → проверки D-7 (порядок 409 как у подтверждения)."""
visible, _ = await self._visible_statement(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(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите новый файл")
if document.status not in CONFIRMABLE_STATUSES:
raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится")
# читаем под блокировкой: `_visible_statement` загрузил шапку до неё, а параллельный запрос мог её изменить
# (например, поставить акцепт) — без `refresh` в сессии остался бы устаревший экземпляр
meta = await repo.get_meta(self._session, document.id, refresh=True)
if meta is None:
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
return document, meta
async def checks_report(
self, document: Document, columns: list[FinancialStatementColumn], aggregates: dict[str, CodeAggregate]
) -> tuple[FailedCheck | None, dict[str, str]]:
"""Мягкая межформенная проверка (D-5) и родители подстрок (`parent_code` ответа; AURORA-1527). Нарушения §7
сюда не входят: ответ читает их из `document_issue`. Родители — по агрегату формы по коду, в том же порядке,
что и проверка «сумма подстрок = родитель» пакета."""
_, amounts = checks_input(columns, aggregates)
parents = subline_parents([code for code, _ in amounts])
balance_1370, income_2400 = await repo.pair_amounts(self._session, document)
return cross_form_check(balance_1370, income_2400, self._engine.tolerance()), parents
async def get(self, document_id: uuid.UUID) -> schemas.StatementOut:
document, meta = await self._visible_statement(document_id)
return await self.build_out(document, meta)
async def build_out(self, document: Document, meta: FinancialStatementMeta) -> schemas.StatementOut:
"""Ответ формы по текущему состоянию БД, достроенный по справочнику редакции (design D-3; используется и
ответами правки/акцепта). В БД ничего не пишет."""
columns, lines = await repo.load_form(self._session, meta.id)
_, aggregates = await form_aggregate(self._session, document, meta)
cross, parents = await self.checks_report(document, columns, aggregates)
edition = edition_for(document.reporting_date)
catalog = load_line_catalog()
return schemas.statement_out(
document,
meta,
columns,
lines,
cross,
edition=edition,
template=catalog.template_lines(edition, document.document_type.value),
parents=parents,
aggregates=aggregates,
issues=await repo.issues(self._session, document.id),
catalog=catalog,
)
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[DocumentIssue, ...]:
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
return await statement_hard_failures(self._session, document, meta)
async def recompute(self, document: Document, meta: FinancialStatementMeta) -> None:
"""Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6): общий код
`arm_statements`, запись результата — `recompute_form`. `CONFIRMED` не трогает."""
if document.status is DocumentStatus.CONFIRMED:
return
job = await self._session.get(RecognitionJob, meta.recognition_job_id)
await recompute_form(self._session, document=document, meta=meta, engine=self._engine, job=job)
async def _apply_cells(
self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch, edit: _Edit
) -> None:
"""Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): сумма — `original_amount` при
первой правке, `is_modified`, `modified_by/at`; `confirmed` — подтверждение без правки значения (Р-25).
Ячейка, которой нет в БД, создаётся при первой непустой правке суммы; строка по `line_code` — из справочника
редакции документа. Вся адресация проверяется до первой вставки."""
if not body.values:
return
keys = [(cell.line_id or cell.line_code, cell.column_id) for cell in body.values]
if len(keys) != len(set(keys)):
raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды")
targets = await self._resolve_cells(document, meta, body)
existing = await repo.lock_cells(
self._session, meta.id, [(line.id, cell.column_id) for cell, line, _ in targets if line is not None]
)
for cell, line, _ in targets:
if cell.confirmed is not None and line is not None and (line.id, cell.column_id) not in existing:
raise DomainValidationError(UNKNOWN_CELL, "подтверждать нечего: значения в этой ячейке нет")
created: dict[str, FinancialStatementLine] = {}
now = utc_now()
for cell, line, item in targets:
value = existing.get((line.id, cell.column_id)) if line is not None else None
if cell.confirmed is not None:
if value is not None and line is not None:
self._apply_confirmation(value, line, cell.confirmed, edit, now)
continue
if value is None:
if cell.amount is None:
continue # пустая сумма в несуществующую ячейку — не изменение
line = line or created.get(item.code if item else "") # noqa: PLW2901 - строка, созданная выше
if line is None and item is not None:
line = created[item.code] = await repo.add_line(self._session, meta.id, item.code, item.name) # noqa: PLW2901
if line is None:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
value = await repo.add_value(self._session, line.id, cell.column_id)
if line is not None:
self._apply_amount(value, line, cell.amount, edit, now)
def _apply_amount(
self,
value: FinancialStatementValue,
line: FinancialStatementLine,
amount: Decimal | None,
edit: _Edit,
now: Any, # noqa: ANN401 - datetime
) -> None:
"""Новая сумма ячейки: `original_amount` при первой правке, `is_modified`, `modified_by/at`."""
if value.amount == amount:
return
edit.record(f"value:{value.id}", value.amount, amount)
if not value.is_modified and value.original_amount is None:
value.original_amount = value.amount
value.amount = amount
value.is_modified = value.amount != value.original_amount
value.modified_by, value.modified_at = self._audit.user_id, now
def _apply_confirmation(
self,
value: FinancialStatementValue,
line: FinancialStatementLine,
confirmed: bool, # noqa: FBT001 - значение поля запроса
edit: _Edit,
now: Any, # noqa: ANN401 - datetime
) -> None:
"""Подтверждение ячейки (`is_confirmed`, `confirmed_by/at`) без правки значения; `is_modified` не ставится.
Снятие возвращает `needs_review` по уверенности распознавания. Акцепт проверок не сбрасывает (Р-25)."""
if value.is_confirmed == confirmed:
return
edit.record(f"value:{value.id}:confirmed", value.is_confirmed, confirmed, data=False)
value.is_confirmed = confirmed
value.confirmed_by, value.confirmed_at = (self._audit.user_id, now) if confirmed else (None, None)
async def _resolve_cells(
self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch
) -> list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]]:
"""Адресация ячеек правки: строка формы (`line_id`) либо строка справочника редакции (`line_code`). Ничего не
вставляет — любая ошибка адресации (422) происходит до первой записи."""
columns = await repo.column_ids(self._session, meta.id)
lines = await repo.form_lines(self._session, meta.id)
by_id = {line.id: line for line in lines}
codes = {line.line_code for line in lines if line.line_status is StatementLineStatus.ok}
edition = edition_for(document.reporting_date)
catalog_lines = load_line_catalog().template_lines(edition, document.document_type.value)
template = {item.code: item for item in catalog_lines}
targets: list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]] = []
for cell in body.values:
if cell.column_id not in columns:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
if cell.line_id is not None:
line = by_id.get(cell.line_id)
if line is None:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
targets.append((cell, line, None))
continue
if cell.line_code in codes:
raise DomainValidationError(UNKNOWN_CELL, LINE_EXISTS)
item = template.get(cell.line_code or "")
if item is None:
raise DomainValidationError(UNKNOWN_CELL, "строки с таким кодом нет в форме этой редакции")
targets.append((cell, None, item))
return targets
async def _apply_columns(
self, meta: FinancialStatementMeta, columns: list[schemas.ColumnPatch], edit: _Edit
) -> bool:
"""Даты колонок (spec arm-core «PATCH формы»): новая дата — `period_kind = absolute`, снимаются предупреждения
колонки; дата, равная текущей, у колонки с `COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN` — подтверждение,
иначе запрос не меняет ничего. Возвращает `True`, если даты колонок изменились (нужен пересчёт
`is_current` и периода)."""
if not columns:
return False
by_id = {column.id: column for column in (await repo.load_form(self._session, meta.id))[0]}
changed = False
for patch in columns:
column = by_id[patch.column_id]
cleared = await repo.delete_issues(
self._session,
meta.document_id,
codes=COLUMN_CLEARS,
kind=DocumentIssueKind.warning,
source=IssueSource.service,
column_id=column.id,
)
if patch.period_date != column.period_date:
edit.record(f"column:{column.id}", column.period_date, patch.period_date)
column.period_date, column.period_kind = patch.period_date, ABSOLUTE
changed = True
elif cleared:
edit.record(f"column:{column.id}:date_confirmed", False, True, data=False)
column.period_kind = ABSOLUTE
return changed
async def _check_columns(self, meta: FinancialStatementMeta, columns: list[schemas.ColumnPatch]) -> None:
"""Адресация `columns[]` до первой записи (P3-10): колонка этой формы, без повторов."""
if not columns:
return
known = await repo.column_ids(self._session, meta.id)
if any(patch.column_id not in known for patch in columns):
raise DomainValidationError(UNKNOWN_COLUMN, "колонка не принадлежит форме")
if len({patch.column_id for patch in columns}) != len(columns):
raise DomainValidationError(DUPLICATE_COLUMN, "колонка указана в запросе дважды")
async def _clear_issues(self, document: Document, field: str) -> None:
"""Снять предупреждения сервиса, которые снимает правка (подтверждение) поля шапки."""
await repo.delete_issues(
self._session,
document.id,
codes=HEADER_CLEARS[field],
kind=DocumentIssueKind.warning,
source=IssueSource.service,
)
async def _apply_header(
self, document: Document, meta: FinancialStatementMeta, header: schemas.HeaderPatch, edit: _Edit
) -> bool:
"""Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента. Дата отчёта,
равная текущей, у поля из `needs_review_fields` — подтверждение даты. Возвращает `True`, если дата отчёта
изменилась (нужен пересчёт периода)."""
fields = header.model_fields_set
who_changed = False
if "inn" in fields and header.inn != meta.inn:
edit.record("inn", meta.inn, header.inn)
meta.inn, meta.inn_modified = header.inn, True
await self._clear_issues(document, "inn")
who_changed = True
name = header.organization_name
if "organization_name" in fields and name is not None and name != meta.organization_name:
edit.record("organization_name", meta.organization_name, name)
meta.organization_name, meta.organization_name_modified = name, True
await self._clear_issues(document, "organization_name")
who_changed = True
if who_changed:
owner = await self._session.get(LegalEntity, document.legal_entity_id)
if owner is None:
raise NotFoundError(DOCUMENT_NOT_FOUND)
index = await CounterpartyIndex.load(self._session)
document.counterparty_check = counterparty_check(
meta.inn, meta.organization_name, owner.inn, index.owner_names(owner)
)
date_changed = False
review = [str(item) for item in meta.needs_review_fields] if isinstance(meta.needs_review_fields, list) else []
if "report_date" in fields:
if header.report_date != meta.report_date:
edit.record("report_date", meta.report_date, header.report_date)
meta.report_date, meta.report_date_modified = header.report_date, True
date_changed = True
await self._clear_issues(document, "report_date")
if "report_date" in review:
meta.needs_review_fields = [item for item in review if item != "report_date"]
elif header.report_date is not None and "report_date" in review:
edit.record(
"needs_review_fields", review, [item for item in review if item != "report_date"], data=False
)
meta.needs_review_fields = [item for item in review if item != "report_date"]
await self._clear_issues(document, "report_date")
if "units" in fields and header.units is not None and header.units != meta.units:
edit.record("units", meta.units, header.units)
meta.units, meta.units_modified = header.units, True
return date_changed
async def _resolve_relative_columns(self, meta: FinancialStatementMeta, edit: _Edit) -> bool:
"""Правка `header.report_date` (в том числе `null`): `period_date` колонок с `period_kind` `relative:*`
пересчитывается от новой даты отчёта до пересчёта формы; ручные (`absolute`) даты не трогаются. `True` —
даты колонок изменились (нужен пересчёт `is_current` и периода)."""
changed = False
for column in (await repo.load_form(self._session, meta.id))[0]:
if column.period_kind is None or not str(column.period_kind).startswith("relative:"):
continue
resolved = self._engine.resolve_relative(PeriodKind(str(column.period_kind)), meta.report_date)
if resolved != column.period_date:
edit.record(f"column:{column.id}", column.period_date, resolved, data=False)
column.period_date = resolved
changed = True
return changed
async def _refresh_period(self, document: Document, meta: FinancialStatementMeta, *, columns_changed: bool) -> None:
"""Период документа — по §9.5 п. 2 (дата отчёта, иначе самая поздняя дата колонки); `is_current` колонок —
колонка с самой поздней датой (меньший `column_index` при равенстве), как при приёме."""
columns, _ = await repo.load_form(self._session, meta.id)
if columns_changed:
latest = max((c.period_date for c in columns if c.period_date is not None), default=None)
first = min((c.column_index for c in columns if c.period_date == latest), default=None)
for column in columns:
column.is_current = None if latest is None else column.column_index == first
period = statement_period(meta.report_date, [column.period_date for column in columns])
document.reporting_date = period
document.period_year = period.year if period is not None else None
document.period_quarter = quarter_of(period) if period is not None else None
async def patch(self, document_id: uuid.UUID, body: schemas.StatementPatch) -> schemas.StatementOut:
"""Правка строк, значений, дат колонок и шапки (D-7): одна транзакция, один `audit_event`, один пересчёт.
Порядок частей — `lines` → `values` → `columns` → `header` (P3-10); адресация проверяется до первой
записи. Акцепт сбрасывает только правка данных; подтверждения (ячейки, даты) — нет (Р-25)."""
document, meta = await self._editable(document_id)
header = body.header
if header is not None and "inn" in header.model_fields_set and not INN_RE.fullmatch(header.inn or ""):
raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр")
await self._check_columns(meta, body.columns)
edit = _Edit({}, {})
if await apply_line_edits(
self._session,
document=document,
meta=meta,
items=body.lines,
user_id=self._audit.user_id,
before=edit.before,
after=edit.after,
):
edit.data_edited = True
await self._apply_cells(document, meta, body, edit)
columns_changed = await self._apply_columns(meta, body.columns, edit)
date_changed = False
if header is not None:
date_changed = await self._apply_header(document, meta, header, edit)
if date_changed:
columns_changed = await self._resolve_relative_columns(meta, edit) or columns_changed
if not edit.after:
return await self.build_out(document, meta)
if columns_changed or date_changed:
await self._refresh_period(document, meta, columns_changed=columns_changed)
if edit.data_edited and meta.checks_accepted_at is not None:
edit.record(
"checks_accepted",
{"by": meta.checks_accepted_by, "at": meta.checks_accepted_at, "comment": meta.checks_accepted_comment},
None,
)
meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None
await self._session.flush()
await self.recompute(document, meta)
await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION,
object_type=OBJECT_TYPE,
object_id=document.id,
before_value=edit.before,
after_value=edit.after,
)
return await self.build_out(document, meta)
async def accept_checks(self, document_id: uuid.UUID, body: schemas.ChecksAcceptIn) -> schemas.StatementOut:
"""Акцепт непройденных жёстких проверок (D-8): поля акцепта в шапке, пересчёт статуса, аудит."""
document, meta = await self._editable(document_id)
if not await self.hard_failures(document, meta):
raise ConflictError(NOTHING_TO_ACCEPT, "непройденных жёстких проверок нет")
now = utc_now()
by = self._audit.user_id
meta.checks_accepted_by, meta.checks_accepted_at, meta.checks_accepted_comment = by, now, body.comment
await self._session.flush()
await self.recompute(document, meta)
await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION,
object_type=OBJECT_TYPE,
object_id=document.id,
before_value={"checks_accepted": None},
after_value={"checks_accepted": {"by": by, "at": now, "comment": body.comment}},
)
return await self.build_out(document, meta)