arm-core/api/src/services/statements.py
2026-10-08 17:50:37 +04:00

263 lines
15 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 считаются на лету и не хранятся.
"""
import re
import uuid
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_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks
from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok
from src.models.directories import LegalEntity
from src.models.enums import (
AuditAction,
AuditSection,
DocumentCheckResult,
DocumentStatus,
DocumentType,
FileStatus,
RecognitionStatus,
)
from src.models.files import Document, RecognitionJob
from src.models.statements import FinancialStatementMeta
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 CONFIRMABLE_STATUSES, DOCUMENT_NOT_CONFIRMABLE, FILE_SUPERSEDED
DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
DOCUMENT_READONLY = "DOCUMENT_READONLY"
UNKNOWN_CELL = "UNKNOWN_CELL"
DUPLICATE_CELL = "DUPLICATE_CELL"
NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT"
INVALID_INN = "INVALID_INN"
OBJECT_TYPE = "document"
INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
async def statement_hard_failures(
session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b)."""
if meta.checks_accepted_at is not None:
return ()
column_indexes, amounts = await repo.statement_amounts(session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
return tuple(report.failed)
async def form_confirmable(session: AsyncSession, document: Document) -> bool:
"""Условия подтверждения формы ББ/ОФР без исключений (§7, D-9 Среза 3b; автоподтверждение — design D-3, OQ-157):
период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b)
— `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409."""
if document.document_type not in FORM_TYPES:
return True
meta = await repo.get_meta(session, document.id, refresh=True)
if meta is None:
return True
if document.period_year is None or document.period_quarter is None:
return False
return not await statement_hard_failures(session, document, meta)
class StatementService:
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
self._session = session
self._audit = audit
self._scope = scope
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, meta: FinancialStatementMeta
) -> tuple[ChecksReport, FailedCheck | None]:
"""Проверки §7 по текущим суммам формы и мягкая межформенная проверка (D-5)."""
rules = load_check_rules()
column_indexes, amounts = await repo.statement_amounts(self._session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, rules)
balance_1370, income_2400 = await repo.pair_amounts(self._session, document)
return report, cross_form_check(balance_1370, income_2400, rules.tolerance)
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:
"""Ответ формы по текущему состоянию БД (используется и ответами правки/акцепта)."""
columns, lines = await repo.load_form(self._session, meta.id)
report, cross = await self.checks_report(document, meta)
return schemas.statement_out(
document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date)
)
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
return await statement_hard_failures(self._session, document, meta)
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None:
"""D-6 после правки или акцепта; `CONFIRMED` не трогает.
Предупреждения маппинга файла не хранятся на уровне документа, поэтому передаются пустыми (`warnings=()`).
"""
if document.status is DocumentStatus.CONFIRMED:
return
job = await self._session.get(RecognitionJob, meta.recognition_job_id)
document.status = statement_status(
job_done=job is not None and job.status is RecognitionStatus.done,
checks_passed=not await self.hard_failures(document, meta),
counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN,
period_known=document.period_year is not None and document.period_quarter is not None,
values_ok=values_ok(await repo.value_flags(self._session, meta.id)),
has_lines=await repo.has_lines(self._session, meta.id),
warnings=(),
)
document.updated_at = utc_now()
async def _apply_cells(
self, meta: FinancialStatementMeta, body: schemas.StatementPatch, before: dict[str, Any], after: dict[str, Any]
) -> None:
"""Значения `body.values` (D-7): `original_amount` при первой правке, `is_modified`, `modified_by/at`."""
keys = [(cell.line_id, cell.column_id) for cell in body.values]
if len(keys) != len(set(keys)):
raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды")
cells = await repo.lock_cells(self._session, meta.id, keys)
if len(cells) != len(set(keys)):
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
now = utc_now()
for cell in body.values:
value = cells[(cell.line_id, cell.column_id)]
if value.amount == cell.amount:
continue
key = f"value:{value.id}"
before[key], after[key] = value.amount, cell.amount
if not value.is_modified and value.original_amount is None:
value.original_amount = value.amount
value.amount = cell.amount
value.is_modified = value.amount != value.original_amount
value.modified_by, value.modified_at = self._audit.user_id, now
async def _apply_header(
self,
document: Document,
meta: FinancialStatementMeta,
header: schemas.HeaderPatch,
before: dict[str, Any],
after: dict[str, Any],
) -> None:
"""Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента и периода."""
fields = header.model_fields_set
if "inn" in fields and header.inn != meta.inn:
before["inn"], after["inn"] = meta.inn, header.inn
meta.inn, meta.inn_modified = header.inn, True
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)
)
if "report_date" in fields and header.report_date != meta.report_date:
before["report_date"], after["report_date"] = meta.report_date, header.report_date
meta.report_date, meta.report_date_modified = header.report_date, True
columns, _ = await repo.load_form(self._session, meta.id)
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
if "units" in fields and header.units is not None and header.units != meta.units:
before["units"], after["units"] = meta.units, header.units
meta.units, meta.units_modified = header.units, True
async def patch(self, document_id: uuid.UUID, body: schemas.StatementPatch) -> schemas.StatementOut:
"""Правка значений и шапки (D-7): одна транзакция, один `audit_event`, сброс акцепта, пересчёт статуса."""
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 цифр")
before: dict[str, Any] = {}
after: dict[str, Any] = {}
await self._apply_cells(meta, body, before, after)
if header is not None:
await self._apply_header(document, meta, header, before, after)
if not after:
return await self.build_out(document, meta)
if meta.checks_accepted_at is not None:
before["checks_accepted"] = {
"by": meta.checks_accepted_by,
"at": meta.checks_accepted_at,
"comment": meta.checks_accepted_comment,
}
after["checks_accepted"] = None
meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None
await self._session.flush()
await self.recompute_status(document, meta)
await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION,
object_type=OBJECT_TYPE,
object_id=document.id,
before_value=before,
after_value=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_status(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)