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>
This commit is contained in:
Andrey Epifancev 2026-10-18 20:26:40 +04:00
parent 6ce36b3e2d
commit 065153e470
13 changed files with 825 additions and 168 deletions

View File

@ -11,6 +11,7 @@ from src.domain.policy import VisibilityScope
from src.services.documents import DocumentService
from src.services.primary_files import PrimaryFilesService
from src.services.statements import StatementService
from src.services.statements_engine import StatementsEngine, default_engine
def get_primary_files_service(
@ -43,13 +44,19 @@ def get_document_service(
DocumentServiceDep = Annotated[DocumentService, Depends(get_document_service)]
def get_statements_engine() -> StatementsEngine:
"""Общий код нормализации `arm_statements` (правила — один раз на процесс); тесты подменяют зависимость."""
return default_engine()
def get_statement_service(
session: SessionDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
scope: Annotated[VisibilityScope, Depends(get_scope)],
engine: Annotated[StatementsEngine, Depends(get_statements_engine)],
) -> StatementService:
"""Сервис формы ББ/ОФР: чтение, проверки §7 (видимость — через ЮЛ-владельца)."""
return StatementService(session, audit, scope)
"""Сервис формы ББ/ОФР: чтение, пересчёт после правки, проверки §7 (видимость — через ЮЛ-владельца)."""
return StatementService(session, audit, scope, engine)
StatementServiceDep = Annotated[StatementService, Depends(get_statement_service)]

View File

@ -10,7 +10,7 @@ import bisect
from collections.abc import Hashable, Sequence
from dataclasses import dataclass
__all__ = ["LayoutLine", "nearest_ok_parents", "order_lines"]
__all__ = ["LayoutLine", "order_lines"]
OK = "ok"
DETAIL = "detail"
@ -51,16 +51,3 @@ def order_lines(lines: Sequence[LayoutLine]) -> list[Hashable]:
result.append(line.key)
result.extend(item.key for item in attached.get(line.key, []))
return result
def nearest_ok_parents(lines: Sequence[LayoutLine]) -> dict[Hashable, int | None]:
"""Родитель строк `detail`: `row_index` ближайшей строки `ok` выше по `row_index` (строки без `row_index` —
шаблонные — родителями не бывают); нет такой — `None`. Временная: в части Б заменяется `detail_parents` пакета."""
ok_rows = sorted(line.row_index for line in lines if line.line_status == OK and line.row_index is not None)
parents: dict[Hashable, int | None] = {}
for line in lines:
if line.line_status != DETAIL:
continue
position = bisect.bisect_left(ok_rows, line.row_index) - 1 if line.row_index is not None else -1
parents[line.key] = ok_rows[position] if position >= 0 else None
return parents

View File

@ -8,7 +8,7 @@ from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from sqlalchemy import delete, exists, func, insert, or_, select, update
from sqlalchemy import delete, func, insert, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.expression import ScalarSelect
@ -22,6 +22,7 @@ from src.models.enums import (
FileStatus,
IssueSeverity,
IssueSource,
PeriodQuarter,
StatementLineStatus,
)
from src.models.files import Document, StoredFile
@ -47,15 +48,14 @@ __all__ = [
"form_aggregate",
"form_lines",
"get_meta",
"has_review_values",
"has_source_lines",
"insert_statement",
"issue_counts_by_document",
"issues",
"load_form",
"lock_cells",
"file_form_periods",
"pair_amounts",
"settle_edited_values",
"replace_issues",
"value_flags",
]
@ -224,15 +224,40 @@ async def _delete_statements(session: AsyncSession, documents: ScalarSelect[uuid
await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents)))
async def has_source_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool:
"""В форме есть строка источника (`row_index IS NOT NULL`) — единое определение «есть строки» для статуса D-6;
шаблонные строки (созданные правкой) не в счёт."""
stmt = select(
select(FinancialStatementLine.id)
.where(FinancialStatementLine.meta_id == meta_id, FinancialStatementLine.row_index.is_not(None))
.exists()
async def replace_issues( # noqa: PLR0913 - фильтр замены и новые записи независимы
session: AsyncSession,
document_id: uuid.UUID,
drafts: Sequence[IssueDraft],
*,
kinds: Collection[DocumentIssueKind],
core_codes: Collection[str],
now: datetime.datetime,
) -> None:
"""Пересчёт после правки: удаляет записи документа видов `kinds` (любого источника) и записи источника `core` с
кодами `core_codes`, затем вставляет `drafts`. Прочие записи (`warning` источника `service`, `MAPPING_WARNING` и
т. п.) сохраняются."""
await session.execute(
delete(DocumentIssue).where(
DocumentIssue.document_id == document_id,
or_(
DocumentIssue.kind.in_(kinds),
(DocumentIssue.source == IssueSource.core) & DocumentIssue.code.in_(core_codes),
),
)
)
return bool((await session.execute(stmt)).scalar_one())
await add_issues(session, document_id, drafts, now=now)
async def file_form_periods(
session: AsyncSession, file_id: uuid.UUID
) -> list[tuple[uuid.UUID, DocumentType, int | None, PeriodQuarter | None]]:
"""Формы ББ/ОФР файла (документы с шапкой): `(id, тип, год, квартал)` — вход `duplicate_period_forms` (Р-18)."""
stmt = (
select(Document.id, Document.document_type, Document.period_year, Document.period_quarter)
.join(FinancialStatementMeta, FinancialStatementMeta.document_id == Document.id)
.where(Document.stored_file_id == file_id)
)
return [(row[0], row[1], row[2], row[3]) for row in (await session.execute(stmt)).all()]
async def unresolved_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[uuid.UUID, int | None, str | None]]:
@ -317,41 +342,6 @@ async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[b
return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows]
async def has_review_values(session: AsyncSession, meta_id: uuid.UUID) -> bool:
"""Есть ли в форме значение с `needs_review` у строки не `detail` (вход условия `RECOGNIZED`; значения строк
`detail` не требуют проверки, Р-23)."""
stmt = select(
exists().where(
FinancialStatementValue.line_id == FinancialStatementLine.id,
FinancialStatementLine.meta_id == meta_id,
FinancialStatementLine.line_status != StatementLineStatus.detail,
FinancialStatementValue.needs_review.is_(True),
)
)
return bool((await session.execute(stmt)).scalar())
async def settle_edited_values(session: AsyncSession, meta_id: uuid.UUID) -> None:
"""Правило пересчёта: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; значения строк `detail` —
тоже `false` (Р-23)."""
lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta_id)
detail = lines.where(FinancialStatementLine.line_status == StatementLineStatus.detail)
await session.execute(
update(FinancialStatementValue)
.where(
FinancialStatementValue.line_id.in_(lines),
FinancialStatementValue.needs_review.is_(True),
or_(
FinancialStatementValue.is_modified.is_(True),
FinancialStatementValue.is_confirmed.is_(True),
FinancialStatementValue.line_id.in_(detail),
),
)
.values(needs_review=False)
.execution_options(synchronize_session="fetch")
)
async def lock_cells(
session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]]
) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]:

View File

@ -14,11 +14,12 @@ from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
from src.domain.statement_aggregate import CodeAggregate
from src.domain.statement_checks import ChecksReport, FailedCheck
from src.domain.statement_checks import FailedCheck
from src.domain.statement_layout import LayoutLine, order_lines
from src.domain.statement_lines import LineCatalog, TemplateLine
from src.models.enums import (
DocumentCheckResult,
DocumentIssueKind,
DocumentStatus,
DocumentType,
PeriodQuarter,
@ -133,8 +134,8 @@ class FailedCheckOut(BaseModel):
column_index: int
relation: str
expected: Decimal
actual: Decimal
expected: Decimal | None # None — цель или все члены пусты (P1-21)
actual: Decimal | None
codes: list[str] = []
line_ids: list[uuid.UUID] = []
@ -196,6 +197,23 @@ def _failed(check: FailedCheck, aggregates: Mapping[str, CodeAggregate]) -> Fail
)
def _failed_issue(issue: DocumentIssue, column_index: Mapping[uuid.UUID, int]) -> FailedCheckOut:
"""Нарушение §7 из записи `document_issue` (`relation` / `soft`): `codes` и `line_ids` — из записи."""
index = issue.column_index
if issue.column_id is not None and issue.column_id in column_index:
index = column_index[issue.column_id]
codes = issue.codes if isinstance(issue.codes, list) else []
line_ids = issue.line_ids if isinstance(issue.line_ids, list) else []
return FailedCheckOut(
column_index=index if index is not None else -1,
relation=issue.relation or "",
expected=issue.expected,
actual=issue.actual,
codes=[str(item) for item in codes],
line_ids=[uuid.UUID(str(item)) for item in line_ids],
)
def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int]) -> ValueOut:
return ValueOut(
id=value.id,
@ -345,7 +363,6 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
meta: FinancialStatementMeta,
columns: Sequence[FinancialStatementColumn],
lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]],
report: ChecksReport,
cross: FailedCheck | None,
*,
edition: str | None,
@ -358,6 +375,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
"""Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по
справочнику редакции (D-3)."""
form = document.document_type.value
by_column = {column.id: column.column_index for column in columns}
return StatementOut(
document=StatementDocumentOut(
id=document.id,
@ -408,8 +426,8 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
columns=[ColumnOut.model_validate(column) for column in columns],
lines=_lines_out(columns, lines, template, parents, lambda code: catalog.reference_name(edition, form, code)),
checks=ChecksOut(
failed=[_failed(check, aggregates) for check in report.failed],
soft_failed=[_failed(check, aggregates) for check in report.soft_failed],
failed=[_failed_issue(i, by_column) for i in issues if i.kind is DocumentIssueKind.relation],
soft_failed=[_failed_issue(i, by_column) for i in issues if i.kind is DocumentIssueKind.soft],
cross_form=_failed(cross, aggregates) if cross is not None else None,
accepted=_accepted(meta),
),

View File

@ -12,7 +12,6 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import ConflictError, DomainError, DomainValidationError
from src.domain.statement_layout import LayoutLine, nearest_ok_parents
from src.domain.statement_mapping import FULL_CONFIDENCE
from src.models.enums import StatementLineStatus
from src.models.files import Document
@ -102,18 +101,6 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool:
return False
def _recompute_parents(lines: Sequence[FinancialStatementLine]) -> None:
"""Родитель всех строк `detail` — ближайшая строка `ok` выше по `row_index` (spec «Пересчёт после правки»)."""
layout = [
LayoutLine(line.id, line.row_index, line.line_status.value, line.line_code, line.parent_row_index)
for line in lines
]
parents = nearest_ok_parents(layout)
for line in lines:
if line.line_status is StatementLineStatus.detail:
line.parent_row_index = parents.get(line.id)
async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13: документ, шапка, элементы, пользователь, аудит
session: AsyncSession,
*,
@ -152,7 +139,6 @@ async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13
lines = [line for line in lines if line.id != template.id]
deleted.append(template.id) # T13.2: удаление пустой шаблонной строки — в аудит как `line:<id> -> null`
data_edited = any([_apply_item(by_id[item.line_id], item) for item in items])
_recompute_parents(lines)
await session.flush()
for line in lines:
now = _snapshot(line)

View File

@ -0,0 +1,275 @@
"""Пересчёт формы ББ/ОФР после правки общим кодом `arm_statements` (часть Б, B2; spec arm-core «Пересчёт после правки»).
Единая точка: вход пересчёта собирается из БД (`build_stored_form`), считает пакет (`engine.recompute`), результат
пишется обратно — `needs_review` строк и значений, `form_variant`, `parent_row_index` расшифровок, записи
`document_issue` видов `relation` / `soft` и проверок ядра (P3-8), статус документа. §7 хранится только здесь и
в приёме результата (B3/B4); экран и подтверждение читают его из `document_issue`.
"""
import re
import uuid
from collections.abc import Hashable, Mapping, Sequence
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.clock import utc_now
from src.domain.statement_aggregate import CodeAggregate
from src.domain.statement_issues import IssueDraft
from src.domain.statement_lines import load_line_catalog
from src.domain.statement_status import (
NameCheckLine,
duplicate_period_forms,
form_status_v2,
has_error_warning,
name_mismatches,
)
from src.domain.stored_form import build_stored_form
from src.models.enums import (
DocumentCheckResult,
DocumentIssueKind,
DocumentStatus,
IssueSeverity,
IssueSource,
StatementFormVariant,
StatementLineStatus,
)
from src.models.files import Document, RecognitionJob
from src.models.statements import FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta
from src.repositories import statements as repo
from src.services.statements_engine import FailedRelation, StatementChecks, StatementsEngine
__all__ = [
"CODE_NOT_IN_DICTIONARY",
"CODE_NOT_IN_EDITION",
"NAME_MISMATCH",
"RECOMPUTED_CORE_CODES",
"RELATION_FAILED",
"RELATION_SOFT",
"STATEMENTS_VERSION_MISMATCH",
"code_issues",
"name_issues",
"recompute_form",
"relations_to_issues",
"version_issue",
]
RELATION_FAILED = "RELATION_FAILED"
RELATION_SOFT = "RELATION_SOFT"
CODE_NOT_IN_DICTIONARY = "CODE_NOT_IN_DICTIONARY"
CODE_NOT_IN_EDITION = "CODE_NOT_IN_EDITION"
NAME_MISMATCH = "NAME_MISMATCH"
STATEMENTS_VERSION_MISMATCH = "STATEMENTS_VERSION_MISMATCH"
# записи `core`, которые пересчёт заменяет целиком (P3-8); прочие (`MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`,
# `LEGACY_WARNING`) — факт приёма, их пересчёт сохраняет
RECOMPUTED_CORE_CODES = (CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, STATEMENTS_VERSION_MISMATCH)
_CHECKED_CODE = re.compile(r"\d{4}", re.ASCII) # R-1 (Р-40): проверяются только четырёхзначные коды
_EMPTY = "пусто"
def _amount_text(value: Decimal | None) -> str:
return _EMPTY if value is None else str(value)
def _find_column(
columns: Sequence[FinancialStatementColumn], failure: FailedRelation
) -> FinancialStatementColumn | None:
"""Колонка нарушения: по ключу `str(column_id)` (пересчёт из БД), иначе по `str(column_index)` (проверки `2.0`)."""
for column in columns:
if str(column.id) == failure.column_key:
return column
for column in columns:
if str(column.column_index) == failure.column_key:
return column
return None
def relations_to_issues(
checks: StatementChecks,
*,
columns: Sequence[FinancialStatementColumn],
aggregates: Mapping[str, CodeAggregate],
source: IssueSource,
) -> list[IssueDraft]:
"""Нарушения пакета → черновики `document_issue`: `relations_failed` — `relation` / `RELATION_FAILED` / `error`,
`soft_relations_failed` — `soft` / `RELATION_SOFT` / `info`. `line_ids` — все строки `ok` кодов `codes`;
`expected` / `actual` могут быть `None` (цель или все члены пусты, P1-21) — в сообщении «пусто», в записи `NULL`."""
drafts: list[IssueDraft] = []
groups = (
(checks.relations_failed, DocumentIssueKind.relation, RELATION_FAILED, IssueSeverity.error),
(checks.soft_relations_failed, DocumentIssueKind.soft, RELATION_SOFT, IssueSeverity.info),
)
for failures, kind, code, severity in groups:
for failure in failures:
column = _find_column(columns, failure)
line_ids = dict.fromkeys(
line_id
for form_code in failure.codes
if form_code in aggregates
for line_id in aggregates[form_code].line_ids
)
drafts.append(
IssueDraft(
kind=kind,
code=code,
severity=severity,
message=(
f"не сошлось {failure.relation}: "
f"{_amount_text(failure.expected)} ≠ {_amount_text(failure.actual)}"
),
source=source,
line_ids=tuple(line_ids),
column_id=column.id if column is not None else None,
column_index=failure.column_index,
relation=failure.relation,
codes=tuple(failure.codes),
expected=failure.expected,
actual=failure.actual,
)
)
return drafts
def _core_info(code: str, message: str, line_ids: tuple[uuid.UUID, ...] = ()) -> IssueDraft:
return IssueDraft(
kind=DocumentIssueKind.warning,
code=code,
severity=IssueSeverity.info,
message=message,
source=IssueSource.core,
line_ids=line_ids,
)
def code_issues(
lines: Sequence[FinancialStatementLine],
*,
in_dictionary: Mapping[str, bool],
in_edition: Mapping[str, bool] | None,
) -> list[IssueDraft]:
"""`CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` — по записи на код документа (`line_ids` — все строки `ok` с
этим кодом), только для кодов из ровно четырёх цифр (R-1, Р-40). `in_dictionary` — код есть в словаре имён редакции,
`in_edition` — код из перечня редакции и варианта (`None` — редакция или вариант не определены, не проверяется)."""
by_code: dict[str, list[FinancialStatementLine]] = {}
for line in lines:
if line.line_status is StatementLineStatus.ok and line.line_code and _CHECKED_CODE.fullmatch(line.line_code):
by_code.setdefault(line.line_code, []).append(line)
drafts: list[IssueDraft] = []
for code in sorted(by_code):
ids = tuple(
line.id for line in sorted(by_code[code], key=lambda item: (item.row_index is None, item.row_index or 0))
)
if not in_dictionary.get(code, True):
drafts.append(_core_info(CODE_NOT_IN_DICTIONARY, f"кода {code} нет в словаре наименований редакции", ids))
if in_edition is not None and not in_edition.get(code, True):
drafts.append(_core_info(CODE_NOT_IN_EDITION, f"код {code} не входит в перечень кодов редакции формы", ids))
return drafts
def name_issues(lines: Sequence[FinancialStatementLine], mismatched: Sequence[Hashable]) -> list[IssueDraft]:
"""`NAME_MISMATCH` — по записи на строку из `name_mismatches`."""
by_id = {line.id: line for line in lines}
drafts: list[IssueDraft] = []
for key in mismatched:
line = by_id[key] # type: ignore[index]
drafts.append(
_core_info(
NAME_MISMATCH,
f"наименование «{line.line_name or ''}» не совпало со справочным для кода {line.line_code}",
(line.id,),
)
)
return drafts
def version_issue(job_version: str | None, engine_version: str | None) -> IssueDraft | None:
"""`STATEMENTS_VERSION_MISMATCH`: версия копии пакета у задания известна и не равна версии копии ядра."""
if job_version is None or job_version == engine_version:
return None
return _core_info(
STATEMENTS_VERSION_MISMATCH,
f"форма распознана общим кодом версии {job_version}, в ядре версия {engine_version or 'не определена'}",
)
def _name_lines(lines: Sequence[FinancialStatementLine]) -> list[NameCheckLine]:
return [
NameCheckLine(
key=line.id,
line_status=line.line_status.value,
line_code=line.line_code,
line_code_confidence=line.line_code_confidence,
line_name=line.line_name,
code_fixed=bool(line.line_code_modified) or bool(line.is_confirmed),
)
for line in lines
]
async def recompute_form(
session: AsyncSession,
*,
document: Document,
meta: FinancialStatementMeta,
engine: StatementsEngine,
job: RecognitionJob | None,
) -> None:
"""Пересчёт формы по сохранённым данным (один вызов `engine.recompute`) и запись результата; статус `CONFIRMED`
не меняется. Без commit."""
columns, loaded = await repo.load_form(session, meta.id)
stored = build_stored_form(document, meta, columns, loaded, await repo.issues(session, document.id))
result = engine.recompute(stored)
lines = [line for line, _ in loaded]
row_of = {str(line.id): line.row_index for line in lines}
for line, values in loaded:
key = str(line.id)
line.needs_review = bool(result.line_needs_review.get(key, False))
if line.line_status is StatementLineStatus.detail:
parent_key = result.detail_parents.get(key)
line.parent_row_index = row_of.get(parent_key) if parent_key is not None else None
for value in values:
value.needs_review = bool(result.value_needs_review.get((key, str(value.column_id)), False))
meta.form_variant = StatementFormVariant(result.form_variant.value) if result.form_variant is not None else None
form = document.document_type.value
catalog = load_line_catalog()
edition = result.edition.value if result.edition is not None else None
in_dictionary = {
line.line_code: catalog.reference_name(edition, form, line.line_code) is not None
for line in lines
if line.line_code is not None
}
in_edition: dict[str, bool] | None = None
if result.edition is not None and result.form_variant is not None:
order = set(engine.code_order(result.edition, stored.document_type, result.form_variant))
in_edition = {line.line_code: line.line_code in order for line in lines if line.line_code is not None}
mismatched = name_mismatches(_name_lines(lines), catalog, form)
drafts = [
*(code_issues(lines, in_dictionary=in_dictionary, in_edition=in_edition) if edition is not None else []),
*name_issues(lines, mismatched),
]
mismatch = version_issue(job.statements_version if job is not None else None, engine.version)
if mismatch is not None:
drafts.append(mismatch)
_, aggregates = await repo.form_aggregate(session, meta.id)
drafts += relations_to_issues(result.checks, columns=columns, aggregates=aggregates, source=IssueSource.core)
await repo.replace_issues(
session,
document.id,
drafts,
kinds=(DocumentIssueKind.relation, DocumentIssueKind.soft),
core_codes=RECOMPUTED_CORE_CODES,
now=utc_now(),
)
if document.status is not DocumentStatus.CONFIRMED:
forms = await repo.file_form_periods(session, document.stored_file_id)
document.status = form_status_v2(
service_done=result.status.value == "done",
file_error_warning=has_error_warning((job.metrics or {}).get("warnings") if job is not None else None),
counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN,
period_known=document.period_year is not None and document.period_quarter is not None,
names_ok=not mismatched,
duplicate_in_file=document.id in duplicate_period_forms(forms),
)
document.updated_at = utc_now()

View File

@ -1,7 +1,7 @@
"""Форма ББ/ОФР для экрана «Проверка распознавания» (spec documents-api; design D-5, D-7, D-8, D-14).
Видимость — через ЮЛ-владельца (`files_repo.get_document`): чужой документ — тот же 404, что несуществующий.
Проверки §7 считаются на лету и не хранятся.
Нарушения §7 хранятся в `document_issue` и пересчитываются после каждой правки общим кодом `arm_statements`.
"""
import re
@ -18,32 +18,24 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound
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 (
ChecksReport,
FailedCheck,
cross_form_check,
load_check_rules,
run_checks,
subline_parents,
)
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, statement_status, value_clean
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,
DocumentCheckResult,
DocumentIssueKind,
DocumentStatus,
DocumentType,
FileStatus,
IssueSource,
RecognitionStatus,
StatementLineStatus,
)
from src.models.files import Document, RecognitionJob
from src.models.statements import (
DocumentIssue,
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
@ -61,6 +53,8 @@ from src.services.documents import (
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"
@ -92,14 +86,12 @@ async def form_aggregate(
async def statement_hard_failures(
session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b)."""
) -> tuple[DocumentIssue, ...]:
"""Нарушенные жёсткие проверки §7 — записи `document_issue` вида `relation`; при акцепте — пустой кортеж (D-8
Среза 3b). Форма без записей (до первого пересчёта) нарушений не показывает."""
if meta.checks_accepted_at is not None:
return ()
columns, aggregates = await form_aggregate(session, document, meta)
column_indexes, amounts = checks_input(columns, aggregates)
report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
return tuple(report.failed)
return tuple(issue for issue in await repo.issues(session, document.id) if issue.kind is DocumentIssueKind.relation)
@dataclass(frozen=True)
@ -147,8 +139,8 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia
{
"column_index": failure.column_index,
"relation": failure.relation,
"expected": str(failure.expected),
"actual": str(failure.actual),
"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
]
@ -171,19 +163,14 @@ class _Edit:
self.data_edited = self.data_edited or data
def _value_needs_review(value: FinancialStatementValue, line: FinancialStatementLine) -> bool:
"""Правило `needs_review` значения при приёме `1.x` и без пакета: правка/подтверждение и строка `detail` снимают
сомнение, иначе — по уверенности распознавания (`value_clean`)."""
if value.is_modified or value.is_confirmed or line.line_status is StatementLineStatus.detail:
return False
return not value_clean(value.confidence, value.is_recognized)
class StatementService:
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
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` — не форма."""
@ -216,23 +203,15 @@ class StatementService:
return document, meta
async def checks_report(
self, document: Document, meta: FinancialStatementMeta
) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]:
"""Проверки §7 по текущим суммам формы, мягкая межформенная проверка (D-5) и родители подстрок
(`parent_code` ответа; AURORA-1527). Всё считается по агрегату формы по коду (`form_aggregate`): родители — по
тем же кодам и в том же порядке, что и проверка «сумма подстрок = родитель» (ответ и проверка не расходятся)."""
columns, aggregates = await form_aggregate(self._session, document, meta)
return await self._report(document, columns, aggregates)
async def _report(
self, document: Document, columns: list[FinancialStatementColumn], aggregates: dict[str, CodeAggregate]
) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]:
rules = load_check_rules()
column_indexes, amounts = checks_input(columns, aggregates)
report = run_checks(document.document_type.value, amounts, column_indexes, rules)
) -> 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 report, cross_form_check(balance_1370, income_2400, rules.tolerance), parents
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)
@ -243,7 +222,7 @@ class StatementService:
ответами правки/акцепта). В БД ничего не пишет."""
columns, lines = await repo.load_form(self._session, meta.id)
_, aggregates = await form_aggregate(self._session, document, meta)
report, cross, parents = await self._report(document, columns, aggregates)
cross, parents = await self.checks_report(document, columns, aggregates)
edition = edition_for(document.reporting_date)
catalog = load_line_catalog()
return schemas.statement_out(
@ -251,7 +230,6 @@ class StatementService:
meta,
columns,
lines,
report,
cross,
edition=edition,
template=catalog.template_lines(edition, document.document_type.value),
@ -261,32 +239,17 @@ class StatementService:
catalog=catalog,
)
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
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); `CONFIRMED`
не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б.
`warnings=()`: предупреждения приёма (`MAPPING_WARNING`) хранятся как `document_issue` уровня `info` и
определяют статус только при приёме (D-6); после правки аналитика статус строится по данным формы,
проверкам, контрагенту и периоду, а эти записи остаются видимыми в `issues` и статус не удерживают.
"""
"""Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6): общий код
`arm_statements`, запись результата — `recompute_form`. `CONFIRMED` не трогает."""
if document.status is DocumentStatus.CONFIRMED:
return
await repo.settle_edited_values(self._session, meta.id)
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=not await repo.has_review_values(self._session, meta.id),
has_lines=await repo.has_source_lines(self._session, meta.id),
warnings=(),
)
document.updated_at = utc_now()
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
@ -344,7 +307,6 @@ class StatementService:
value.amount = amount
value.is_modified = value.amount != value.original_amount
value.modified_by, value.modified_at = self._audit.user_id, now
value.needs_review = _value_needs_review(value, line)
def _apply_confirmation(
self,
@ -361,7 +323,6 @@ class StatementService:
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)
value.needs_review = _value_needs_review(value, line)
async def _resolve_cells(
self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch
@ -491,6 +452,21 @@ class StatementService:
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` при равенстве), как при приёме."""
@ -530,6 +506,8 @@ class StatementService:
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:

View File

@ -14,10 +14,12 @@ from arm_statements import (
DocStatus,
DocumentType,
Edition,
FailedRelation,
FormVariant,
PeriodKind,
RecomputeResult,
Rules,
StatementChecks,
StoredForm,
choose_edition,
default_rules,
@ -28,7 +30,14 @@ from arm_statements import (
status_input_from_contract,
)
__all__ = ["ArmStatementsEngine", "StatementsEngine", "default_engine"]
__all__ = [
"ArmStatementsEngine",
"FailedRelation",
"PeriodKind",
"StatementChecks",
"StatementsEngine",
"default_engine",
]
class StatementsEngine(Protocol):

View File

@ -10,9 +10,11 @@ from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.files import get_statements_engine
from src.domain.statement_checks import CROSS_FORM_RELATION
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
@ -29,7 +31,7 @@ from src.models.enums import (
RecognitionStatus,
StatementLineStatus,
)
from src.models.files import Document, StoredFile
from src.models.files import Document, RecognitionJob, StoredFile
from src.models.statements import (
DocumentIssue,
FinancialStatementColumn,
@ -37,6 +39,9 @@ from src.models.statements import (
FinancialStatementMeta,
FinancialStatementValue,
)
from src.services.statement_recompute import recompute_form
from src.services.statements import form_blocker
from src.services.statements_engine import default_engine
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
@ -45,6 +50,7 @@ from tests.factories import (
make_legal_entity,
make_stored_file,
)
from tests.fake_engine import FakeEngine
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
@ -259,6 +265,7 @@ async def test_get_statement_parent_code_matches_subline_check(
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
await set_amount(seeded, document, "1110", Decimal("999999"), current=True)
await set_amount(seeded, document, "11101", Decimal("1"), current=True) # детализация есть — проверка работает
await recompute_now(seeded, document)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
kids = sorted(line["line_code"] for line in body["lines"] if line["parent_code"] == "1110")
@ -303,6 +310,15 @@ async def _meta(session: AsyncSession, document: Document) -> FinancialStatement
).scalar_one()
async def recompute_now(session: AsyncSession, document: Document) -> None:
"""Пересчёт формы общим кодом, как после первой правки: §7 хранится в `document_issue`, а не считается на лету.
До приёма B3 у загруженных маппером форм записей нарушений нет — тест, меняющий суммы в БД напрямую, зовёт это."""
meta = await _meta(session, document)
job = await session.get(RecognitionJob, meta.recognition_job_id)
await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job)
await session.flush()
async def drop_line(session: AsyncSession, document: Document, line_code: str) -> dict[int, Decimal | None]:
"""Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`."""
meta = await _meta(session, document)
@ -373,6 +389,7 @@ async def test_get_completes_lost_line_without_writing(
"""Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
await recompute_now(seeded, document)
lines_in_db = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
@ -391,8 +408,10 @@ async def test_get_completes_lost_line_without_writing(
)
assert [v["column_index"] for v in line["values"]] == sorted(c["column_index"] for c in body["columns"])
relations = {(f["relation"], f["column_index"]) for f in body["checks"]["failed"]}
# цель 1700 не проверяется; «1600 = 1700» (цель 1600 есть, 1700 = 0) нарушено — как и без достройки
assert not any(relation.startswith("1700 =") for relation, _ in relations)
# пакет (P1-21): посчитанное соотношение с пустой целью — нарушение с `expected = null`; «1600 = 1700» (цель 1600
# есть, 1700 пуста) нарушено — как и без достройки
lost_target = [f for f in body["checks"]["failed"] if f["relation"].startswith("1700 =")]
assert lost_target and all(f["expected"] is None for f in lost_target)
assert {index for relation, index in relations if relation == "1600 = 1700"} == {0, 1, 2}
assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой
assert await db_line_count(seeded, document) == lines_in_db
@ -456,6 +475,8 @@ async def test_cross_form_violation_is_reported_on_both_forms(
await set_amount(seeded, balance, "1370", Decimal("500"), current=True)
await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31))
await set_amount(seeded, income, "2400", Decimal("150"), current=True)
await recompute_now(seeded, balance)
await recompute_now(seeded, income)
client = await auth_client(KA_RF)
for document in (balance, income):
body = (await client.get(f"/documents/{document.id}/statement")).json()
@ -1573,7 +1594,10 @@ async def test_template_line_checks_follow_filled_amounts(
)
assert response.status_code == HTTPStatus.OK, response.text
after_clear = response.json()["checks"]["failed"]
assert not any(f["relation"].startswith("1700 =") for f in after_clear) # очищенная 1700 снова не цель
# пакет (P1-21): пустая цель при непустых членах — нарушение с `expected = null` (прежнее «очищенная не цель»
# не действует): 1700 пуста во всех колонках — нарушение в каждой
emptied = [f for f in after_clear if f["relation"] == relation]
assert sorted((f["column_index"], f["expected"]) for f in emptied) == [(0, None), (1, None), (2, None)]
assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2}
@ -1849,6 +1873,7 @@ async def test_failed_check_has_codes_and_line_ids(
) -> None:
document = await form(seeded, fixtures_dir)
await set_amount(seeded, document, "1600", Decimal("1"), current=True)
await recompute_now(seeded, document)
body = await _statement(await auth_client(KA_RF), document)
failed = body["checks"]["failed"]
assert failed, body["checks"]
@ -2046,12 +2071,36 @@ async def test_unconfirm_cell(seeded: AsyncSession, fixtures_dir: Path, auth_cli
)
assert response.status_code == HTTPStatus.OK, response.text
edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
assert edited["is_confirmed"] is False and edited["needs_review"] is True
# ячейка покрыта сошедшимся соотношением — пакет считает её чистой и после снятия подтверждения
assert edited["is_confirmed"] is False and edited["needs_review"] is False
value = await _db_value(seeded, line["id"], cell["column_id"])
assert value.confirmed_by is None and value.confirmed_at is None
assert len(await _edit_events(seeded, document)) == 1
async def test_unconfirm_uncovered_cell(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Сценарий «Снятие подтверждения»: ячейка вне соотношений после снятия подтверждения — `needs_review`."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
db_line = await seeded.get(FinancialStatementLine, uuid.UUID(line["id"]))
assert db_line is not None
db_line.line_code = "1999" # кода нет ни в одном соотношении
value = await _db_value(seeded, line["id"], cell["column_id"])
value.confidence, value.is_confirmed, value.needs_review = Decimal(80), True, False
value.confirmed_by, value.confirmed_at = await _user_id(seeded), NOW
await seeded.flush()
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": False}]},
)
assert response.status_code == HTTPStatus.OK, response.text
edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
assert edited["is_confirmed"] is False and edited["needs_review"] is True
async def test_cell_amount_and_confirmed_is_422(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
@ -2308,3 +2357,168 @@ async def test_segment_warning_stays_after_value_edit(
).status_code == HTTPStatus.OK
confirm = await client.post(f"/documents/{document.id}/confirm")
assert confirm.status_code == HTTPStatus.OK, confirm.text
# --- B2: пересчёт общим кодом, §7 из document_issue --------------------------------------------------------------
async def _issues(session: AsyncSession, document: Document) -> list[DocumentIssue]:
stmt = (
select(DocumentIssue).where(DocumentIssue.document_id == document.id).execution_options(populate_existing=True)
)
return list((await session.execute(stmt)).scalars())
async def _cell(client: Any, document: Document, code: str, column_index: int) -> tuple[str, str, str | None]:
"""`(line_id, column_id, amount)` ячейки строки `ok` с кодом `code` в колонке `column_index`."""
body = await _statement(client, document)
line = next(item for item in body["lines"] if item["line_code"] == code and item["id"] is not None)
value = next(v for v in line["values"] if v["column_index"] == column_index)
return line["id"], value["column_id"], value["amount"]
async def test_1x_form_after_patch_recomputed(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Сценарий «1.x-документ после PATCH»: запись `relation` по 1200 заменена, статус пересчитан, версии нет."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line_id, column_id, original = await _cell(client, document, "1230", 0)
assert original is not None
await set_amount(seeded, document, "1230", Decimal(original) + 1000, current=True)
await recompute_now(seeded, document) # до B3 запись приёма вставляется пересчётом
broken = [i for i in await _issues(seeded, document) if (i.relation or "").startswith("1200 =")]
assert broken and all(i.kind is DocumentIssueKind.relation and i.source is IssueSource.core for i in broken)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line_id, "column_id": column_id, "amount": original}]},
)
assert response.status_code == HTTPStatus.OK, response.text
assert not [i for i in await _issues(seeded, document) if (i.relation or "").startswith("1200 =")]
assert not any(f["relation"].startswith("1200 =") for f in response.json()["checks"]["failed"])
assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document)
async def test_migrated_form_shows_violation_after_first_patch(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Сценарий «Миграция на непустой базе»: форма без записей §7 нарушений не показывает и не блокирует
подтверждение; после первой правки появляется `RELATION_FAILED`."""
document = await form(seeded, fixtures_dir)
await set_amount(seeded, document, "1600", Decimal("1"), current=True) # 1600 ≠ 1700, записей нет
client = await auth_client(KA_RF)
assert (await _statement(client, document))["checks"]["failed"] == []
assert await form_blocker(seeded, document, await _meta(seeded, document)) is None # CHECKS_NOT_PASSED нет
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "млн руб."}})
assert response.status_code == HTTPStatus.OK, response.text
found = [i for i in await _issues(seeded, document) if i.code == "RELATION_FAILED" and i.codes == ["1600", "1700"]]
assert found and all(i.line_ids for i in found)
assert response.json()["checks"]["failed"]
async def test_statements_version_mismatch(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
meta = await _meta(seeded, document)
job = await seeded.get(RecognitionJob, meta.recognition_job_id)
assert job is not None
client = await auth_client(KA_RF)
job.statements_version = "abc1234"
await seeded.flush()
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.OK, response.text
mismatch = [i for i in await _issues(seeded, document) if i.code == "STATEMENTS_VERSION_MISMATCH"]
assert len(mismatch) == 1
assert mismatch[0].severity is IssueSeverity.info and mismatch[0].source is IssueSource.core
job.statements_version = None # у задания 1.x версии нет — запись не пишется (и прежняя заменяется)
await seeded.flush()
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "тыс. руб."}})
assert response.status_code == HTTPStatus.OK, response.text
assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document)
job.statements_version = default_engine().version # версия совпала — тоже без записи
await seeded.flush()
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "млн руб."}})
assert response.status_code == HTTPStatus.OK, response.text
assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document)
async def test_relative_column_follows_report_date(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
columns = await _db_columns(seeded, document)
relative, manual = columns[1], columns[2]
relative.period_kind = manual.period_kind = "relative:prev_year_end"
await seeded.flush()
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
assert (await client.patch(url, json={"header": {"report_date": "2026-03-31"}})).status_code == HTTPStatus.OK
assert (await _db_columns(seeded, document))[1].period_date == datetime.date(2025, 12, 31)
# ручная дата делает колонку absolute — дальше дата отчёта её не двигает (A12)
response = await client.patch(url, json={"columns": [{"column_id": str(manual.id), "period_date": "2024-06-30"}]})
assert response.status_code == HTTPStatus.OK, response.text
assert (await client.patch(url, json={"header": {"report_date": "2025-09-30"}})).status_code == HTTPStatus.OK
fresh = await _db_columns(seeded, document)
assert fresh[1].period_date == datetime.date(2024, 12, 31) and fresh[2].period_date == datetime.date(2024, 6, 30)
assert (await client.patch(url, json={"header": {"report_date": None}})).status_code == HTTPStatus.OK
fresh = await _db_columns(seeded, document)
assert fresh[1].period_date is None and fresh[2].period_date == datetime.date(2024, 6, 30)
async def test_checks_from_issues_with_codes_and_line_ids(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""`checks.failed`, `NOTHING_TO_ACCEPT` и `CHECKS_NOT_PASSED` читают запись `document_issue`, а не сумм формы."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
nothing = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "x"})
assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT"
line_1600, column_id, _ = await _cell(client, document, "1600", 0)
seeded.add(
DocumentIssue(
document_id=document.id,
kind=DocumentIssueKind.relation,
code="RELATION_FAILED",
severity=IssueSeverity.error,
message="не сошлось 1600 = 1700: пусто ≠ 5",
line_ids=[line_1600],
column_id=uuid.UUID(column_id),
codes=["1600", "1700"],
source=IssueSource.core,
relation="1600 = 1700",
expected=None,
actual=Decimal(5),
created_at=NOW,
)
)
await seeded.flush()
(failed,) = (await _statement(client, document))["checks"]["failed"]
assert failed["codes"] == ["1600", "1700"] and failed["line_ids"] == [line_1600]
assert failed["relation"] == "1600 = 1700" and failed["expected"] is None and failed["actual"] == "5.00"
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED"
assert blocked.json()["detail"]["failures"][0]["expected"] is None
async def test_one_patch_one_recompute(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, app: FastAPI
) -> None:
"""`PATCH` из трёх частей: один вызов пересчёта пакета и одно событие аудита."""
fake = FakeEngine()
app.dependency_overrides[get_statements_engine] = lambda: fake
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
line_id, column_id, amount = line["id"], cell["column_id"], cell["amount"]
response = await client.patch(
f"/documents/{document.id}/statement",
json={
"lines": [{"line_id": line_id, "confirmed": True}],
"values": [{"line_id": line_id, "column_id": column_id, "amount": str(Decimal(amount) + 1)}],
"header": {"units": "руб."},
},
)
assert response.status_code == HTTPStatus.OK, response.text
assert len(fake.forms) == 1
assert len(await _edit_events(seeded, document)) == 1

View File

@ -1,6 +1,6 @@
"""Порядок строк ответа `GET …/statement` (P3-17): строки `ok` по коду, вложения источника — за своей строкой."""
from src.domain.statement_layout import LayoutLine, nearest_ok_parents, order_lines
from src.domain.statement_layout import LayoutLine, order_lines
def L( # noqa: N802 - короткая фабрика строки для таблиц тестов
@ -44,14 +44,3 @@ def test_attachments_keep_row_order() -> None:
def test_empty() -> None:
assert order_lines([]) == []
def test_nearest_ok_parents() -> None:
lines = [
L("a", 0, "ok", "1100", None),
L("b", 1, "detail", None, None),
L("t", None, "ok", "1250", None),
L("c", 3, "detail", None, None),
]
assert nearest_ok_parents(lines) == {"b": 0, "c": 0}
assert nearest_ok_parents([L("d", 1, "detail", None, None), L("u", 2, "unreadable", None, None)]) == {"d": None}

View File

@ -10,14 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.models.directories import LegalEntity
from src.models.enums import DocumentStatus, DocumentType, StatementLineStatus
from src.models.files import Document
from src.models.files import Document, RecognitionJob
from src.models.statements import (
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from src.services.statement_recompute import recompute_form
from src.services.statements import form_blocker, statement_hard_failures
from src.services.statements_engine import default_engine
from tests.factories import make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
@ -89,6 +91,13 @@ async def _confirmed_twin(session: AsyncSession, document: Document) -> Document
return twin
async def _recompute(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> None:
"""§7 хранится в `document_issue`: после прямой правки сумм в БД теста нарушения появляются пересчётом."""
job = await session.get(RecognitionJob, meta.recognition_job_id)
await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job)
await session.flush()
async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> None:
values = (
await session.execute(
@ -100,6 +109,9 @@ async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> No
for value in values:
value.amount = (value.amount or 0) + 1000
await session.flush()
document = await session.get(Document, meta.document_id)
assert document is not None
await _recompute(session, document, meta)
async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
@ -134,6 +146,7 @@ async def test_form_with_hard_failure_is_not_confirmable_until_accepted(
for value in values:
value.amount = (value.amount or 0) + 1000
await seeded.flush()
await _recompute(seeded, income, meta)
assert await statement_hard_failures(seeded, income, meta) != ()
blocker = await form_blocker(seeded, income, meta)

View File

@ -0,0 +1,183 @@
"""Пересчёт формы после правки общим кодом (часть Б, B2): чистые преобразования и запись результата в БД
(spec arm-core «Пересчёт после правки», P3-8, R-1)."""
import uuid
from decimal import Decimal
from http import HTTPStatus
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.statement_aggregate import CodeAggregate
from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource, StatementLineStatus
from src.models.statements import FinancialStatementColumn, FinancialStatementLine
from src.services.statement_recompute import code_issues, name_issues, relations_to_issues, version_issue
from src.services.statements_engine import FailedRelation, StatementChecks
from tests.conftest import AuthClientFactory
from tests.test_api_statements import (
KA_RF,
_db_value,
_issues,
_statement,
form,
recompute_now,
)
def _failure(key: str, relation: str, codes: tuple[str, ...], expected: Decimal | None, actual: Decimal | None) -> Any:
return FailedRelation(
column_index=1, column_key=key, relation=relation, codes=codes, expected=expected, actual=actual
)
def _aggregate(code: str, *line_ids: uuid.UUID) -> CodeAggregate:
return CodeAggregate(code=code, amounts={}, line_ids=line_ids, reference_name=None, has_source_line=True)
def test_relations_to_issues_kinds_columns_and_empty_amounts() -> None:
column = FinancialStatementColumn(id=uuid.uuid4(), column_index=1)
line_a, line_b, line_c = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
checks = StatementChecks(
relations_failed=(
_failure(str(column.id), "1600 = 1700", ("1600", "1700"), None, Decimal(5)),
_failure("1", "1100 = 1110", ("1100", "1110", "9999"), Decimal(2), Decimal(3)), # ключ — индекс (`2.0`)
),
soft_relations_failed=(_failure("77", "1110 = 11101", ("1110", "11101"), Decimal(1), None),),
)
aggregates = {
"1600": _aggregate("1600", line_a),
"1700": _aggregate("1700", line_b, line_c),
"1100": _aggregate("1100", line_a),
}
hard_empty, hard_by_index, soft = relations_to_issues(
checks, columns=[column], aggregates=aggregates, source=IssueSource.core
)
assert (hard_empty.kind, hard_empty.code, hard_empty.severity) == (
DocumentIssueKind.relation,
"RELATION_FAILED",
IssueSeverity.error,
)
assert hard_empty.column_id == column.id and hard_empty.line_ids == (line_a, line_b, line_c)
assert hard_empty.expected is None and hard_empty.actual == Decimal(5)
assert hard_empty.message == "не сошлось 1600 = 1700: пусто ≠ 5"
assert hard_by_index.column_id == column.id # ключ `1` — индекс колонки
assert hard_by_index.line_ids == (line_a,) # кода нет в форме — вклада нет
assert (soft.kind, soft.code, soft.severity) == (DocumentIssueKind.soft, "RELATION_SOFT", IssueSeverity.info)
assert soft.column_id is None and soft.column_index == 1 and soft.message.endswith("1 ≠ пусто")
def _line(
code: str | None, *, row: int, status: StatementLineStatus = StatementLineStatus.ok
) -> FinancialStatementLine:
return FinancialStatementLine(
id=uuid.uuid4(),
meta_id=uuid.uuid4(),
line_code=code,
row_index=row,
line_status=StatementLineStatus.ok,
line_name=code,
)
def test_code_issues_only_four_digit_codes_one_record_per_code() -> None:
"""R-1 (Р-40): пятизначные коды не проверяются; одна запись на код, `line_ids` — все строки `ok` кода."""
first, second, five, detail = _line("1999", row=1), _line("1999", row=2), _line("19999", row=3), _line(None, row=4)
drafts = code_issues(
[second, first, five, detail],
in_dictionary={"1999": False, "19999": False},
in_edition={"1999": False, "19999": False},
)
assert [(d.code, d.severity, d.source) for d in drafts] == [
("CODE_NOT_IN_DICTIONARY", IssueSeverity.info, IssueSource.core),
("CODE_NOT_IN_EDITION", IssueSeverity.info, IssueSource.core),
]
assert all(d.line_ids == (first.id, second.id) for d in drafts)
assert code_issues([first], in_dictionary={"1999": False}, in_edition=None)[0].code == "CODE_NOT_IN_DICTIONARY"
def test_name_and_version_issues() -> None:
line = _line("1120", row=1)
(draft,) = name_issues([line], [line.id])
assert draft.code == "NAME_MISMATCH" and draft.line_ids == (line.id,) and draft.source is IssueSource.core
assert version_issue(None, "def5678") is None and version_issue("def5678", "def5678") is None
mismatch = version_issue("abc1234", "def5678")
assert mismatch is not None and mismatch.code == "STATEMENTS_VERSION_MISMATCH"
assert mismatch.severity is IssueSeverity.info and mismatch.source is IssueSource.core
@pytest.mark.integration
@pytest.mark.fixtures
async def test_code_change_recomputes_core_issues(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""P3-8 (T13.4): после смены кода `CODE_NOT_IN_*` пересчитываются — только четырёхзначные (R-1)."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
body = await _statement(client, document)
ok_lines = [line for line in body["lines"] if line["id"] is not None and line["line_status"] == "ok"]
first, second = ok_lines[0], ok_lines[1]
url = f"/documents/{document.id}/statement"
response = await client.patch(
url,
json={
"lines": [{"line_id": first["id"], "line_code": "1999"}, {"line_id": second["id"], "line_code": "19999"}]
},
)
assert response.status_code == HTTPStatus.OK, response.text
issues = await _issues(seeded, document)
by_code = {i.code: i for i in issues if i.code.startswith("CODE_NOT_IN_")}
assert set(by_code) == {"CODE_NOT_IN_DICTIONARY", "CODE_NOT_IN_EDITION"}
assert all(i.line_ids == [first["id"]] for i in by_code.values()) # пятизначный 19999 не проверяется
response = await client.patch(url, json={"lines": [{"line_id": first["id"], "line_code": first["line_code"]}]})
assert response.status_code == HTTPStatus.OK, response.text
assert not [i for i in await _issues(seeded, document) if i.code.startswith("CODE_NOT_IN_")]
@pytest.mark.integration
@pytest.mark.fixtures
async def test_name_mismatch_follows_code_confirmation(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""P3-8: `NAME_MISMATCH` пересчитывается; подтверждённому (`code_fixed`) коду не ставится."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
body = await _statement(client, document)
target = next(line for line in body["lines"] if line["line_code"] == "1120" and line["id"] is not None)
line = await seeded.get(FinancialStatementLine, uuid.UUID(target["id"]))
assert line is not None
line.line_code_confidence, line.line_name = Decimal(50), "Нематериальные активы"
await seeded.flush()
await recompute_now(seeded, document)
mismatch = [i for i in await _issues(seeded, document) if i.code == "NAME_MISMATCH"]
assert [i.line_ids for i in mismatch] == [[target["id"]]]
response = await client.patch(
f"/documents/{document.id}/statement", json={"lines": [{"line_id": target["id"], "confirmed": True}]}
)
assert response.status_code == HTTPStatus.OK, response.text
assert not [i for i in await _issues(seeded, document) if i.code == "NAME_MISMATCH"]
@pytest.mark.integration
@pytest.mark.fixtures
async def test_values_of_line_leaving_detail_are_recomputed(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""T13.3: `needs_review` значений строки, ушедшей в `detail`, не залипает на `false` — после назначения кода его
даёт пересчёт пакета."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
body = await _statement(client, document)
target = next(line for line in body["lines"] if line["id"] is not None and line["values"][0]["amount"] is not None)
column_id = target["values"][0]["column_id"]
value = await _db_value(seeded, target["id"], column_id)
value.confidence = Decimal(80)
await seeded.flush()
url = f"/documents/{document.id}/statement"
response = await client.patch(url, json={"lines": [{"line_id": target["id"], "line_status": "detail"}]})
assert response.status_code == HTTPStatus.OK, response.text
assert (await _db_value(seeded, target["id"], column_id)).needs_review is False
response = await client.patch(url, json={"lines": [{"line_id": target["id"], "line_code": "1999"}]})
assert response.status_code == HTTPStatus.OK, response.text
assert (await _db_value(seeded, target["id"], column_id)).needs_review is True # код вне соотношений — не покрыта

View File

@ -36,3 +36,11 @@ Goals и Non-goals — см. `proposal.md`.
| 2026-10-18 | **P3-25 (B1).** Тип формы во входе пересчёта — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`), а не `meta.statement_type` (только `balance` / `income`); план писал «тип формы» без источника | `DISCLOSURE` не представим в `statement_type`; тип документа — единственный источник для всех трёх типов | форма `DISCLOSURE` собралась бы с ошибкой `KeyError` / потерей типа; правка — одна строка в `build_stored_form` |
| 2026-10-18 | **P3-26 (B1, O-3 часть А).** Копия `statement-code-orders.yaml` в `api/contracts/` не заводится: перечень кодов читает пакет из своей байтовой копии `api/arm_statements/rules/statement_code_orders.yaml` (`loader._ORDERS`), ядро файла не открывает; идентичность копии пакета с `docs/contracts/statement-code-orders.yaml` проверяет `sync-statements --check`; тест идентичности контракта сверяет только `*.json`. Сейчас копия пакета побайтно равна файлу docs | второй экземпляр файла в ядре никто бы не читал и он мог бы разойтись с пакетом | нет; если sync-statements когда-либо перестанет сверять YAML — добавить сверку в `contract_identity.py` |
| 2026-10-18 | **P3-27 (B1).** `edition_codes` берёт строки `ok` результата `2.0`, сортирует по `row_index` и помечает код уверенным при `line_code_confidence == 100` (ровно), `None` — неуверенный | как в разделе «Что ядро вызывает» и в `recompute` пакета (код правленый/подтверждённый там — `code_fixed`) | редакция приёма разойдётся с редакцией пересчёта; сверку делает `test_edition_codes_and_engine_edition` |
| 2026-10-18 | **P3-28 (B2).** `StatementService` получает `StatementsEngine` через зависимость `get_statements_engine` (тесты подменяют её `FakeEngine`); `FailedRelation`, `StatementChecks`, `PeriodKind` реэкспортируются из `services/statements_engine.py`, чтобы `import arm_statements` оставался только в двух файлах | единственная точка импорта пакета (common.md); подмена без патчей | нет |
| 2026-10-18 | **P3-29 (B2).** Статус после правки — `form_status_v2`: `service_done = (result.status == DONE)`, `file_error_warning` — `metrics.warnings` задания, `names_ok` — нет `NAME_MISMATCH` пересчёта, `duplicate_in_file` — `duplicate_period_forms` по формам файла (`file_form_periods`); `statement_status` D-6 с пути правки убран | один источник статуса с приёмом `2.0` (Р-16, Р-18, Р-19) | форма с повтором периода в файле после правки остаётся `NEEDS_CHECK` — как при приёме |
| 2026-10-18 | **P3-30 (B2).** Пустая цель при непустых членах — нарушение с `expected = NULL` (P1-21); тесты части А «очищенная 1700 не цель» и «цель потерянной строки не проверяется» переписаны под это; потерявшая строку 1700 форма блокирует подтверждение до правки или акцепта | следствие P1-21 пакета; спека («посчитанное соотношение») | форма, потерявшая итоговую строку, теперь требует действия аналитика — если владелец против, правится правило пакета (план 1), не ядро |
| 2026-10-18 | **P3-31 (B2).** `NOTHING_TO_ACCEPT` и `CHECKS_NOT_PASSED` читают записи `relation`; акцептованная форма не считается нарушенной (`statement_hard_failures` пуст при `checks_accepted_at`), поэтому повторный акцепт — 409 `NOTHING_TO_ACCEPT`, как в части А | сохранение поведения А; буква брифа («нет записей `relation`») допускает оба прочтения | повторный акцепт с новым комментарием станет возможен, если снять условие |
| 2026-10-18 | **P3-32 (B2).** «Эффективная дата» для `relative:*` колонок при правке `header.report_date` — новое значение `meta.report_date` (в том числе `NULL` ⇒ `period_date = NULL`), не `document.reporting_date`; изменение дат колонок идёт в аудит ключом `column:<id>` без признака правки данных | период документа сам выводится из даты отчёта и дат колонок (§9.5 п. 2), поэтому циклическая зависимость недопустима | колонка при пустой дате отчёта теряет дату; аналитик ставит её вручную |
| 2026-10-18 | **P3-33 (B2).** Запись `relation` / `soft` несёт и `column_id` (колонка найдена по `column_key`: `str(column_id)`, затем `str(column_index)`), и `column_index` нарушения; ответ берёт индекс из колонки, иначе из записи, иначе `-1` | ключ колонки B4 для `2.0` — индекс | нет |
| 2026-10-18 | **P3-34 (B2).** `NAME_MISMATCH` пересчитывается для всех кодов (R-1 ограничивает только `CODE_NOT_IN_*`); `CODE_NOT_IN_*` не пишутся, если пакет не определил редакцию (а `CODE_NOT_IN_EDITION` — и вариант); словарь имён берётся по редакции пакета, не по дате | R-1 и спека (редакция документа — `edition` пакета) | пятизначная подстрока с уверенностью < 100 даёт `NAME_MISMATCH` (кода нет в словаре) — если поток окажется шумным, ограничить `name_mismatches` четырёхзначными |
| 2026-10-18 | **P3-35 (B2).** Временный код A12/A13 удалён: тело `StatementService.recompute`, `settle_edited_values`, `has_review_values`, `has_source_lines`, `_value_needs_review`, `nearest_ok_parents` и `_recompute_parents`; `parent_row_index` `detail` пишет только пересчёт пакета | задание B2; T13.3 (флаги detail больше не затираются навсегда) | нет |