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

View File

@ -10,7 +10,7 @@ import bisect
from collections.abc import Hashable, Sequence from collections.abc import Hashable, Sequence
from dataclasses import dataclass from dataclasses import dataclass
__all__ = ["LayoutLine", "nearest_ok_parents", "order_lines"] __all__ = ["LayoutLine", "order_lines"]
OK = "ok" OK = "ok"
DETAIL = "detail" DETAIL = "detail"
@ -51,16 +51,3 @@ def order_lines(lines: Sequence[LayoutLine]) -> list[Hashable]:
result.append(line.key) result.append(line.key)
result.extend(item.key for item in attached.get(line.key, [])) result.extend(item.key for item in attached.get(line.key, []))
return result 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 decimal import Decimal
from typing import Any 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.ext.asyncio import AsyncSession
from sqlalchemy.sql.expression import ScalarSelect from sqlalchemy.sql.expression import ScalarSelect
@ -22,6 +22,7 @@ from src.models.enums import (
FileStatus, FileStatus,
IssueSeverity, IssueSeverity,
IssueSource, IssueSource,
PeriodQuarter,
StatementLineStatus, StatementLineStatus,
) )
from src.models.files import Document, StoredFile from src.models.files import Document, StoredFile
@ -47,15 +48,14 @@ __all__ = [
"form_aggregate", "form_aggregate",
"form_lines", "form_lines",
"get_meta", "get_meta",
"has_review_values",
"has_source_lines",
"insert_statement", "insert_statement",
"issue_counts_by_document", "issue_counts_by_document",
"issues", "issues",
"load_form", "load_form",
"lock_cells", "lock_cells",
"file_form_periods",
"pair_amounts", "pair_amounts",
"settle_edited_values", "replace_issues",
"value_flags", "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))) await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents)))
async def has_source_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool: async def replace_issues( # noqa: PLR0913 - фильтр замены и новые записи независимы
"""В форме есть строка источника (`row_index IS NOT NULL`) — единое определение «есть строки» для статуса D-6; session: AsyncSession,
шаблонные строки (созданные правкой) не в счёт.""" document_id: uuid.UUID,
stmt = select( drafts: Sequence[IssueDraft],
select(FinancialStatementLine.id) *,
.where(FinancialStatementLine.meta_id == meta_id, FinancialStatementLine.row_index.is_not(None)) kinds: Collection[DocumentIssueKind],
.exists() 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]]: 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] 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( async def lock_cells(
session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]] session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]]
) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]: ) -> 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 pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
from src.domain.statement_aggregate import CodeAggregate 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_layout import LayoutLine, order_lines
from src.domain.statement_lines import LineCatalog, TemplateLine from src.domain.statement_lines import LineCatalog, TemplateLine
from src.models.enums import ( from src.models.enums import (
DocumentCheckResult, DocumentCheckResult,
DocumentIssueKind,
DocumentStatus, DocumentStatus,
DocumentType, DocumentType,
PeriodQuarter, PeriodQuarter,
@ -133,8 +134,8 @@ class FailedCheckOut(BaseModel):
column_index: int column_index: int
relation: str relation: str
expected: Decimal expected: Decimal | None # None — цель или все члены пусты (P1-21)
actual: Decimal actual: Decimal | None
codes: list[str] = [] codes: list[str] = []
line_ids: list[uuid.UUID] = [] 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: def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int]) -> ValueOut:
return ValueOut( return ValueOut(
id=value.id, id=value.id,
@ -345,7 +363,6 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
meta: FinancialStatementMeta, meta: FinancialStatementMeta,
columns: Sequence[FinancialStatementColumn], columns: Sequence[FinancialStatementColumn],
lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]],
report: ChecksReport,
cross: FailedCheck | None, cross: FailedCheck | None,
*, *,
edition: str | None, edition: str | None,
@ -358,6 +375,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
"""Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по
справочнику редакции (D-3).""" справочнику редакции (D-3)."""
form = document.document_type.value form = document.document_type.value
by_column = {column.id: column.column_index for column in columns}
return StatementOut( return StatementOut(
document=StatementDocumentOut( document=StatementDocumentOut(
id=document.id, id=document.id,
@ -408,8 +426,8 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
columns=[ColumnOut.model_validate(column) for column in columns], columns=[ColumnOut.model_validate(column) for column in columns],
lines=_lines_out(columns, lines, template, parents, lambda code: catalog.reference_name(edition, form, code)), lines=_lines_out(columns, lines, template, parents, lambda code: catalog.reference_name(edition, form, code)),
checks=ChecksOut( checks=ChecksOut(
failed=[_failed(check, aggregates) for check in report.failed], failed=[_failed_issue(i, by_column) for i in issues if i.kind is DocumentIssueKind.relation],
soft_failed=[_failed(check, aggregates) for check in report.soft_failed], 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, cross_form=_failed(cross, aggregates) if cross is not None else None,
accepted=_accepted(meta), accepted=_accepted(meta),
), ),

View File

@ -12,7 +12,6 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import ConflictError, DomainError, DomainValidationError 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.domain.statement_mapping import FULL_CONFIDENCE
from src.models.enums import StatementLineStatus from src.models.enums import StatementLineStatus
from src.models.files import Document from src.models.files import Document
@ -102,18 +101,6 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool:
return False 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: документ, шапка, элементы, пользователь, аудит async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13: документ, шапка, элементы, пользователь, аудит
session: AsyncSession, 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] lines = [line for line in lines if line.id != template.id]
deleted.append(template.id) # T13.2: удаление пустой шаблонной строки — в аудит как `line:<id> -> null` deleted.append(template.id) # T13.2: удаление пустой шаблонной строки — в аудит как `line:<id> -> null`
data_edited = any([_apply_item(by_id[item.line_id], item) for item in items]) data_edited = any([_apply_item(by_id[item.line_id], item) for item in items])
_recompute_parents(lines)
await session.flush() await session.flush()
for line in lines: for line in lines:
now = _snapshot(line) 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). """Форма ББ/ОФР для экрана «Проверка распознавания» (spec documents-api; design D-5, D-7, D-8, D-14).
Видимость — через ЮЛ-владельца (`files_repo.get_document`): чужой документ — тот же 404, что несуществующий. Видимость — через ЮЛ-владельца (`files_repo.get_document`): чужой документ — тот же 404, что несуществующий.
Проверки §7 считаются на лету и не хранятся. Нарушения §7 хранятся в `document_issue` и пересчитываются после каждой правки общим кодом `arm_statements`.
""" """
import re 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.osv_mapping import counterparty_check
from src.domain.policy import VisibilityScope from src.domain.policy import VisibilityScope
from src.domain.statement_aggregate import CodeAggregate, checks_input from src.domain.statement_aggregate import CodeAggregate, checks_input
from src.domain.statement_checks import ( from src.domain.statement_checks import FailedCheck, cross_form_check, subline_parents
ChecksReport,
FailedCheck,
cross_form_check,
load_check_rules,
run_checks,
subline_parents,
)
from src.domain.statement_issues import COLUMN_CLEARS, HEADER_CLEARS from src.domain.statement_issues import COLUMN_CLEARS, HEADER_CLEARS
from src.domain.statement_lines import TemplateLine, load_line_catalog 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.directories import LegalEntity
from src.models.enums import ( from src.models.enums import (
AuditAction, AuditAction,
AuditSection, AuditSection,
DocumentCheckResult,
DocumentIssueKind, DocumentIssueKind,
DocumentStatus, DocumentStatus,
DocumentType, DocumentType,
FileStatus, FileStatus,
IssueSource, IssueSource,
RecognitionStatus,
StatementLineStatus, StatementLineStatus,
) )
from src.models.files import Document, RecognitionJob from src.models.files import Document, RecognitionJob
from src.models.statements import ( from src.models.statements import (
DocumentIssue,
FinancialStatementColumn, FinancialStatementColumn,
FinancialStatementLine, FinancialStatementLine,
FinancialStatementMeta, FinancialStatementMeta,
@ -61,6 +53,8 @@ from src.services.documents import (
PERIOD_REQUIRED, PERIOD_REQUIRED,
) )
from src.services.statement_line_edits import apply_line_edits 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_FOUND = "документ не найден" # одинаково для чужого и несуществующего
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
@ -92,14 +86,12 @@ async def form_aggregate(
async def statement_hard_failures( async def statement_hard_failures(
session: AsyncSession, document: Document, meta: FinancialStatementMeta session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[FailedCheck, ...]: ) -> tuple[DocumentIssue, ...]:
"""Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b).""" """Нарушенные жёсткие проверки §7 — записи `document_issue` вида `relation`; при акцепте — пустой кортеж (D-8
Среза 3b). Форма без записей (до первого пересчёта) нарушений не показывает."""
if meta.checks_accepted_at is not None: if meta.checks_accepted_at is not None:
return () return ()
columns, aggregates = await form_aggregate(session, document, meta) return tuple(issue for issue in await repo.issues(session, document.id) if issue.kind is DocumentIssueKind.relation)
column_indexes, amounts = checks_input(columns, aggregates)
report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
return tuple(report.failed)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -147,8 +139,8 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia
{ {
"column_index": failure.column_index, "column_index": failure.column_index,
"relation": failure.relation, "relation": failure.relation,
"expected": str(failure.expected), "expected": None if failure.expected is None else str(failure.expected),
"actual": str(failure.actual), "actual": None if failure.actual is None else str(failure.actual),
} }
for failure in failures for failure in failures
] ]
@ -171,19 +163,14 @@ class _Edit:
self.data_edited = self.data_edited or data 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: 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._session = session
self._audit = audit self._audit = audit
self._scope = scope 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]: async def _visible_statement(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]:
"""Документ формы в пределах видимости: 404 — нет или чужой; 409 `DOCUMENT_NOT_STATEMENT` — не форма.""" """Документ формы в пределах видимости: 404 — нет или чужой; 409 `DOCUMENT_NOT_STATEMENT` — не форма."""
@ -216,23 +203,15 @@ class StatementService:
return document, meta return document, meta
async def checks_report( 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] self, document: Document, columns: list[FinancialStatementColumn], aggregates: dict[str, CodeAggregate]
) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]: ) -> tuple[FailedCheck | None, dict[str, str]]:
rules = load_check_rules() """Мягкая межформенная проверка (D-5) и родители подстрок (`parent_code` ответа; AURORA-1527). Нарушения §7
column_indexes, amounts = checks_input(columns, aggregates) сюда не входят: ответ читает их из `document_issue`. Родители — по агрегату формы по коду, в том же порядке,
report = run_checks(document.document_type.value, amounts, column_indexes, rules) что и проверка «сумма подстрок = родитель» пакета."""
_, amounts = checks_input(columns, aggregates)
parents = subline_parents([code for code, _ in amounts]) parents = subline_parents([code for code, _ in amounts])
balance_1370, income_2400 = await repo.pair_amounts(self._session, document) 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: async def get(self, document_id: uuid.UUID) -> schemas.StatementOut:
document, meta = await self._visible_statement(document_id) document, meta = await self._visible_statement(document_id)
@ -243,7 +222,7 @@ class StatementService:
ответами правки/акцепта). В БД ничего не пишет.""" ответами правки/акцепта). В БД ничего не пишет."""
columns, lines = await repo.load_form(self._session, meta.id) columns, lines = await repo.load_form(self._session, meta.id)
_, aggregates = await form_aggregate(self._session, document, meta) _, 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) edition = edition_for(document.reporting_date)
catalog = load_line_catalog() catalog = load_line_catalog()
return schemas.statement_out( return schemas.statement_out(
@ -251,7 +230,6 @@ class StatementService:
meta, meta,
columns, columns,
lines, lines,
report,
cross, cross,
edition=edition, edition=edition,
template=catalog.template_lines(edition, document.document_type.value), template=catalog.template_lines(edition, document.document_type.value),
@ -261,32 +239,17 @@ class StatementService:
catalog=catalog, 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; при акцепте — пустой кортеж (используется и подтверждением формы).""" """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
return await statement_hard_failures(self._session, document, meta) return await statement_hard_failures(self._session, document, meta)
async def recompute(self, document: Document, meta: FinancialStatementMeta) -> None: async def recompute(self, document: Document, meta: FinancialStatementMeta) -> None:
"""Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6); `CONFIRMED` """Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6): общий код
не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б. `arm_statements`, запись результата — `recompute_form`. `CONFIRMED` не трогает."""
`warnings=()`: предупреждения приёма (`MAPPING_WARNING`) хранятся как `document_issue` уровня `info` и
определяют статус только при приёме (D-6); после правки аналитика статус строится по данным формы,
проверкам, контрагенту и периоду, а эти записи остаются видимыми в `issues` и статус не удерживают.
"""
if document.status is DocumentStatus.CONFIRMED: if document.status is DocumentStatus.CONFIRMED:
return return
await repo.settle_edited_values(self._session, meta.id)
job = await self._session.get(RecognitionJob, meta.recognition_job_id) job = await self._session.get(RecognitionJob, meta.recognition_job_id)
document.status = statement_status( await recompute_form(self._session, document=document, meta=meta, engine=self._engine, job=job)
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()
async def _apply_cells( async def _apply_cells(
self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch, edit: _Edit self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch, edit: _Edit
@ -344,7 +307,6 @@ class StatementService:
value.amount = amount value.amount = amount
value.is_modified = value.amount != value.original_amount value.is_modified = value.amount != value.original_amount
value.modified_by, value.modified_at = self._audit.user_id, now value.modified_by, value.modified_at = self._audit.user_id, now
value.needs_review = _value_needs_review(value, line)
def _apply_confirmation( def _apply_confirmation(
self, self,
@ -361,7 +323,6 @@ class StatementService:
edit.record(f"value:{value.id}:confirmed", value.is_confirmed, confirmed, data=False) edit.record(f"value:{value.id}:confirmed", value.is_confirmed, confirmed, data=False)
value.is_confirmed = confirmed value.is_confirmed = confirmed
value.confirmed_by, value.confirmed_at = (self._audit.user_id, now) if confirmed else (None, None) 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( async def _resolve_cells(
self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch
@ -491,6 +452,21 @@ class StatementService:
meta.units, meta.units_modified = header.units, True meta.units, meta.units_modified = header.units, True
return date_changed 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: async def _refresh_period(self, document: Document, meta: FinancialStatementMeta, *, columns_changed: bool) -> None:
"""Период документа — по §9.5 п. 2 (дата отчёта, иначе самая поздняя дата колонки); `is_current` колонок — """Период документа — по §9.5 п. 2 (дата отчёта, иначе самая поздняя дата колонки); `is_current` колонок —
колонка с самой поздней датой (меньший `column_index` при равенстве), как при приёме.""" колонка с самой поздней датой (меньший `column_index` при равенстве), как при приёме."""
@ -530,6 +506,8 @@ class StatementService:
date_changed = False date_changed = False
if header is not None: if header is not None:
date_changed = await self._apply_header(document, meta, header, edit) 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: if not edit.after:
return await self.build_out(document, meta) return await self.build_out(document, meta)
if columns_changed or date_changed: if columns_changed or date_changed:

View File

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

View File

@ -10,9 +10,11 @@ from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from fastapi import FastAPI
from sqlalchemy import delete, select, update from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession 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.domain.statement_checks import CROSS_FORM_RELATION
from src.models.access import AppUser from src.models.access import AppUser
from src.models.audit_ex import AuditEvent from src.models.audit_ex import AuditEvent
@ -29,7 +31,7 @@ from src.models.enums import (
RecognitionStatus, RecognitionStatus,
StatementLineStatus, StatementLineStatus,
) )
from src.models.files import Document, StoredFile from src.models.files import Document, RecognitionJob, StoredFile
from src.models.statements import ( from src.models.statements import (
DocumentIssue, DocumentIssue,
FinancialStatementColumn, FinancialStatementColumn,
@ -37,6 +39,9 @@ from src.models.statements import (
FinancialStatementMeta, FinancialStatementMeta,
FinancialStatementValue, 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.conftest import AuthClientFactory
from tests.factories import ( from tests.factories import (
make_company_group, make_company_group,
@ -45,6 +50,7 @@ from tests.factories import (
make_legal_entity, make_legal_entity,
make_stored_file, make_stored_file,
) )
from tests.fake_engine import FakeEngine
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures] 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) document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
await set_amount(seeded, document, "1110", Decimal("999999"), current=True) await set_amount(seeded, document, "1110", Decimal("999999"), current=True)
await set_amount(seeded, document, "11101", Decimal("1"), current=True) # детализация есть — проверка работает await set_amount(seeded, document, "11101", Decimal("1"), current=True) # детализация есть — проверка работает
await recompute_now(seeded, document)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json() 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") 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() ).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]: async def drop_line(session: AsyncSession, document: Document, line_code: str) -> dict[int, Decimal | None]:
"""Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`.""" """Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`."""
meta = await _meta(session, document) meta = await _meta(session, document)
@ -373,6 +389,7 @@ async def test_get_completes_lost_line_without_writing(
"""Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки.""" """Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки."""
document = await form(seeded, fixtures_dir) document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700") await drop_line(seeded, document, "1700")
await recompute_now(seeded, document)
lines_in_db = await db_line_count(seeded, document) lines_in_db = await db_line_count(seeded, document)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json() 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"]) 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"]} relations = {(f["relation"], f["column_index"]) for f in body["checks"]["failed"]}
# цель 1700 не проверяется; «1600 = 1700» (цель 1600 есть, 1700 = 0) нарушено — как и без достройки # пакет (P1-21): посчитанное соотношение с пустой целью — нарушение с `expected = null`; «1600 = 1700» (цель 1600
assert not any(relation.startswith("1700 =") for relation, _ in relations) # есть, 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 {index for relation, index in relations if relation == "1600 = 1700"} == {0, 1, 2}
assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой
assert await db_line_count(seeded, document) == lines_in_db 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("500"), current=True)
await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) 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 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) client = await auth_client(KA_RF)
for document in (balance, income): for document in (balance, income):
body = (await client.get(f"/documents/{document.id}/statement")).json() 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 assert response.status_code == HTTPStatus.OK, response.text
after_clear = response.json()["checks"]["failed"] 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} 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: ) -> None:
document = await form(seeded, fixtures_dir) document = await form(seeded, fixtures_dir)
await set_amount(seeded, document, "1600", Decimal("1"), current=True) await set_amount(seeded, document, "1600", Decimal("1"), current=True)
await recompute_now(seeded, document)
body = await _statement(await auth_client(KA_RF), document) body = await _statement(await auth_client(KA_RF), document)
failed = body["checks"]["failed"] failed = body["checks"]["failed"]
assert failed, body["checks"] 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 assert response.status_code == HTTPStatus.OK, response.text
edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] 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"]) value = await _db_value(seeded, line["id"], cell["column_id"])
assert value.confirmed_by is None and value.confirmed_at is None assert value.confirmed_by is None and value.confirmed_at is None
assert len(await _edit_events(seeded, document)) == 1 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( async def test_cell_amount_and_confirmed_is_422(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None: ) -> None:
@ -2308,3 +2357,168 @@ async def test_segment_warning_stays_after_value_edit(
).status_code == HTTPStatus.OK ).status_code == HTTPStatus.OK
confirm = await client.post(f"/documents/{document.id}/confirm") confirm = await client.post(f"/documents/{document.id}/confirm")
assert confirm.status_code == HTTPStatus.OK, confirm.text 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` по коду, вложения источника — за своей строкой.""" """Порядок строк ответа `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 - короткая фабрика строки для таблиц тестов def L( # noqa: N802 - короткая фабрика строки для таблиц тестов
@ -44,14 +44,3 @@ def test_attachments_keep_row_order() -> None:
def test_empty() -> None: def test_empty() -> None:
assert order_lines([]) == [] 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.directories import LegalEntity
from src.models.enums import DocumentStatus, DocumentType, StatementLineStatus 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 ( from src.models.statements import (
FinancialStatementColumn, FinancialStatementColumn,
FinancialStatementLine, FinancialStatementLine,
FinancialStatementMeta, FinancialStatementMeta,
FinancialStatementValue, FinancialStatementValue,
) )
from src.services.statement_recompute import recompute_form
from src.services.statements import form_blocker, statement_hard_failures 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.factories import make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot 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 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: async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> None:
values = ( values = (
await session.execute( await session.execute(
@ -100,6 +109,9 @@ async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> No
for value in values: for value in values:
value.amount = (value.amount or 0) + 1000 value.amount = (value.amount or 0) + 1000
await session.flush() 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: 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: for value in values:
value.amount = (value.amount or 0) + 1000 value.amount = (value.amount or 0) + 1000
await seeded.flush() await seeded.flush()
await _recompute(seeded, income, meta)
assert await statement_hard_failures(seeded, income, meta) != () assert await statement_hard_failures(seeded, income, meta) != ()
blocker = await form_blocker(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-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-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-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 больше не затираются навсегда) | нет |