feat(statements): агрегат формы по коду — проверки, межформенная, parent_code, достройка, правка по коду

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 14:00:21 +04:00
parent 1dc510fbb5
commit a15111ab60
9 changed files with 436 additions and 79 deletions

View File

@ -0,0 +1,87 @@
"""Агрегат формы ББ/ОФР по коду строки (спека `arm-core`, v5.19; P3-14).
Код строки в форме может повторяться (лист-продолжение, повтор в источнике, добавленная строка). Все чтения «по коду» —
проверки §7, межформенная проверка, `parent_code` подстрок, достройка по шаблону — идут через агрегат: суммы строк
`ok` с одним кодом складываются по колонке, пустая сумма не превращается в 0. Чистая функция, без БД.
"""
import uuid
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol
from src.models.enums import StatementLineStatus
__all__ = ["AggLine", "CodeAggregate", "aggregate_by_code", "checks_input"]
@dataclass(frozen=True)
class AggLine:
"""Строка формы для агрегата: `amounts` — сумма по `column_id` (`None` — пустая ячейка или её нет)."""
id: uuid.UUID
row_index: int | None
line_status: StatementLineStatus
line_code: str | None
amounts: Mapping[uuid.UUID, Decimal | None]
@dataclass(frozen=True)
class CodeAggregate:
code: str
amounts: dict[uuid.UUID, Decimal | None]
line_ids: tuple[uuid.UUID, ...] # по `row_index`, `NULL` (шаблонные) последними
reference_name: str | None
has_source_line: bool # есть строка источника (`row_index IS NOT NULL`)
def _no_name(_: str) -> str | None:
return None
def aggregate_by_code(
lines: Iterable[AggLine], reference_name: Callable[[str], str | None] = _no_name
) -> dict[str, CodeAggregate]:
"""Агрегат строк `ok` по коду, по возрастанию кода. Сумма по колонке — сумма непустых; нет непустых — `None`."""
groups: dict[str, list[AggLine]] = {}
for line in lines:
if line.line_status is StatementLineStatus.ok and line.line_code is not None:
groups.setdefault(line.line_code, []).append(line)
result: dict[str, CodeAggregate] = {}
for code in sorted(groups):
ordered = sorted(groups[code], key=lambda item: (item.row_index is None, item.row_index or 0, str(item.id)))
amounts: dict[uuid.UUID, Decimal | None] = {}
for line in ordered:
for column_id, amount in line.amounts.items():
previous = amounts.get(column_id)
amounts[column_id] = amount if previous is None else previous + (amount or Decimal(0))
result[code] = CodeAggregate(
code=code,
amounts=amounts,
line_ids=tuple(line.id for line in ordered),
reference_name=reference_name(code),
has_source_line=any(line.row_index is not None for line in ordered),
)
return result
class _Column(Protocol):
id: uuid.UUID
column_index: int
def checks_input(
columns: Sequence[_Column], aggregates: Mapping[str, CodeAggregate]
) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]:
"""Вход `run_checks`: индексы колонок (по возрастанию) и `(код, {column_index: сумма})` по возрастанию кода.
`columns` — колонки формы (`id`, `column_index`). Код без строки источника и без единой суммы (пустая шаблонная
строка) пропускается (design D-5 шаблонных строк)."""
index_by_id = {column.id: column.column_index for column in columns}
lines = [
(code, {index_by_id[cid]: amount for cid, amount in agg.amounts.items() if cid in index_by_id})
for code, agg in sorted(aggregates.items())
if agg.has_source_line or any(amount is not None for amount in agg.amounts.values())
]
return sorted(column.column_index for column in columns), lines

View File

@ -72,6 +72,7 @@ class FailedCheck:
relation: str
expected: Decimal
actual: Decimal
codes: tuple[str, ...] = () # цель и члены правила (P3-14); у межформенной — 1370 и 2400
@dataclass(frozen=True)
@ -144,7 +145,7 @@ def subline_parents(codes: Sequence[str]) -> dict[str, str]:
Подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами, иначе — к строке с кодом из
её первых четырёх цифр, если такая есть; без родителя в результат не попадает. Порядок строк в БД не хранится
(DBML порядка не знает): «выше» — порядок `codes`, и вызывающий передаёт его тот же, в каком `statement_amounts`
(DBML порядка не знает): «выше» — порядок `codes`, и вызывающий передаёт его тот же, в каком `checks_input`
отдаёт коды проверке (по возрастанию кода; M-9 2b). Порядок результата — порядок подстрок в `codes`."""
present = {code for code in codes if len(code) == _PARENT_LEN}
result: dict[str, str] = {}
@ -211,7 +212,8 @@ def run_checks(
holds, expected, actual = _holds(amounts, rule, rules.tolerance)
if holds:
continue
failed = FailedCheck(column_index, rule.relation, expected, actual)
codes = (rule.target, *(code for _, code in rule.terms))
failed = FailedCheck(column_index, rule.relation, expected, actual, codes)
scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)])
rounding = scale * rules.rounding_tolerance_percent / _HUNDRED
if rule.strict and abs(expected - actual) > rounding:
@ -234,4 +236,4 @@ def cross_form_check(
delta = current - year_start
if abs(delta - income_2400) <= tolerance:
return None
return FailedCheck(-1, CROSS_FORM_RELATION, delta, income_2400)
return FailedCheck(-1, CROSS_FORM_RELATION, delta, income_2400, ("1370", "2400"))

View File

@ -3,15 +3,16 @@
import datetime
import uuid
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from decimal import Decimal
from typing import Any
from sqlalchemy import delete, insert, select
from sqlalchemy import delete, func, insert, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code
from src.domain.statement_mapping import StatementDraft
from src.models.enums import DocumentStatus, DocumentType, FileStatus
from src.models.enums import DocumentStatus, DocumentType, FileStatus, StatementLineStatus
from src.models.files import Document, StoredFile
from src.models.statements import (
DocumentIssue,
@ -25,16 +26,18 @@ __all__ = [
"BATCH",
"add_line",
"add_value",
"code_amount",
"column_ids",
"delete_documents_statements",
"delete_file_statements",
"form_aggregate",
"form_lines",
"get_meta",
"has_source_lines",
"insert_statement",
"load_form",
"lock_cells",
"pair_amounts",
"statement_amounts",
"value_flags",
]
@ -72,12 +75,13 @@ async def insert_statement(
await session.execute(insert(FinancialStatementColumn).values(columns))
line_ids: dict[str, uuid.UUID] = {}
lines: list[dict[str, Any]] = []
for line in draft.lines:
for row_index, line in enumerate(draft.lines):
line_ids[line.line_code] = uuid.uuid4()
lines.append(
{
"id": line_ids[line.line_code],
"meta_id": meta.id,
"row_index": row_index, # строка источника (до маппера `2.0`: порядок черновика)
"line_code": line.line_code,
"line_code_original_value": line.line_code,
"line_code_confidence": line.line_code_confidence,
@ -130,52 +134,60 @@ async def _delete_statements(session: AsyncSession, documents: Any) -> None:
await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents)))
async def has_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool:
"""В форме есть строки `financial_statement_line` (единое определение «есть строки» для статуса D-6)."""
stmt = select(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta_id).exists())
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()
)
return bool((await session.execute(stmt)).scalar_one())
async def statement_amounts(
session: AsyncSession, meta_id: uuid.UUID
) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]:
"""Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`. Порядок строк
формы в БД не хранится (DBML не хранит), поэтому порядок строк — лексикографический порядок кодов.
Созданные правкой строки (`line_code_original_value IS NULL`) без единой суммы не участвуют
(spec statement-checks, design D-5)."""
columns = (
async def form_aggregate(
session: AsyncSession,
meta_id: uuid.UUID,
reference_name: Callable[[str], str | None] = lambda _: None,
) -> tuple[list[FinancialStatementColumn], dict[str, CodeAggregate]]:
"""Колонки формы (по `column_index`) и агрегат строк `ok` по коду (`domain/statement_aggregate`) — вход проверок
§7, `parent_code` и достройки по шаблону. Порядок строк формы в БД задаёт `row_index`, но проверки §7 читают коды
по возрастанию (M-9 2b)."""
columns = list(
(
await session.execute(
select(FinancialStatementColumn)
.where(FinancialStatementColumn.meta_id == meta_id)
.order_by(FinancialStatementColumn.column_index)
)
).scalars()
)
lines = (
await session.execute(
select(FinancialStatementColumn.id, FinancialStatementColumn.column_index)
.where(FinancialStatementColumn.meta_id == meta_id)
.order_by(FinancialStatementColumn.column_index)
select(FinancialStatementLine).where(
FinancialStatementLine.meta_id == meta_id,
FinancialStatementLine.line_status == StatementLineStatus.ok,
FinancialStatementLine.line_code.is_not(None),
)
)
).all()
index_by_id = {column_id: index for column_id, index in columns}
).scalars()
by_line: dict[uuid.UUID, dict[uuid.UUID, Decimal | None]] = {}
rows = (
await session.execute(
select(
FinancialStatementLine.line_code,
FinancialStatementLine.line_code_original_value,
FinancialStatementValue.column_id,
FinancialStatementValue.amount,
select(FinancialStatementValue.line_id, FinancialStatementValue.column_id, FinancialStatementValue.amount)
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.where(
FinancialStatementLine.meta_id == meta_id,
FinancialStatementLine.line_status == StatementLineStatus.ok,
)
.join(FinancialStatementValue, FinancialStatementValue.line_id == FinancialStatementLine.id)
.where(FinancialStatementLine.meta_id == meta_id)
.order_by(FinancialStatementLine.line_code)
)
).all()
by_code: dict[str, dict[int, Decimal | None]] = {}
added: set[str] = set() # строки, созданные правкой аналитика (design D-5)
for code, original, column_id, amount in rows:
by_code.setdefault(code or "", {})[index_by_id[column_id]] = amount
if original is None:
added.add(code or "")
lines = [
(code, amounts)
for code, amounts in by_code.items()
if code not in added or any(amount is not None for amount in amounts.values())
for line_id, column_id, amount in rows:
by_line.setdefault(line_id, {})[column_id] = amount
agg_lines = [
AggLine(line.id, line.row_index, line.line_status, line.line_code, by_line.get(line.id, {})) for line in lines
]
return [index for _, index in columns], lines
return columns, aggregate_by_code(agg_lines, reference_name)
async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]:
@ -234,7 +246,7 @@ async def column_ids(session: AsyncSession, meta_id: uuid.UUID) -> set[uuid.UUID
async def add_line(session: AsyncSession, meta_id: uuid.UUID, code: str, name: str) -> FinancialStatementLine:
"""Строка справочника, созданная правкой аналитика: `*_original_value = NULL` — признак «не из источника»."""
"""Шаблонная строка, созданная правкой аналитика: `row_index = NULL` — признак «не из источника»; статус `ok`."""
line = FinancialStatementLine(
id=uuid.uuid4(),
meta_id=meta_id,
@ -246,6 +258,9 @@ async def add_line(session: AsyncSession, meta_id: uuid.UUID, code: str, name: s
line_name_original_value=None,
line_name_confidence=None,
line_name_modified=False,
row_index=None,
source_ref=None,
line_status=StatementLineStatus.ok,
)
session.add(line)
await session.flush()
@ -283,7 +298,8 @@ async def get_meta(
async def load_form(
session: AsyncSession, meta_id: uuid.UUID
) -> tuple[list[FinancialStatementColumn], list[tuple[FinancialStatementLine, list[FinancialStatementValue]]]]:
"""Колонки по `column_index` и строки по `line_code` с их значениями по `column_index`."""
"""Колонки по `column_index` и строки по `line_code`, `row_index` (`NULL` последними), `id` с их значениями по
`column_index`."""
columns = list(
(
await session.execute(
@ -299,7 +315,11 @@ async def load_form(
await session.execute(
select(FinancialStatementLine)
.where(FinancialStatementLine.meta_id == meta_id)
.order_by(FinancialStatementLine.line_code, FinancialStatementLine.id)
.order_by(
FinancialStatementLine.line_code,
FinancialStatementLine.row_index.asc().nulls_last(),
FinancialStatementLine.id,
)
)
).scalars()
)
@ -316,27 +336,34 @@ async def load_form(
return columns, [(line, by_line[line.id]) for line in lines]
async def _line_amount(
async def code_amount(
session: AsyncSession,
meta_id: uuid.UUID,
line_code: str,
code: str,
*,
current: bool = False,
period_date: datetime.date | None = None,
) -> Decimal | None:
"""Сумма строки `line_code` в колонке `is_current` (`current`) или с датой `period_date`; нет — `None`."""
stmt = (
select(FinancialStatementValue.amount)
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id)
.where(FinancialStatementLine.meta_id == meta_id, FinancialStatementLine.line_code == line_code)
.order_by(FinancialStatementColumn.column_index)
.limit(1)
)
"""`SUM(amount)` строк `ok` с кодом `code` в колонке `is_current` (`current`) или с датой `period_date`; колонки
нет или непустых сумм нет — `None`."""
column_stmt = select(FinancialStatementColumn.id).where(FinancialStatementColumn.meta_id == meta_id)
if current:
stmt = stmt.where(FinancialStatementColumn.is_current.is_(True))
column_stmt = column_stmt.where(FinancialStatementColumn.is_current.is_(True))
else:
stmt = stmt.where(FinancialStatementColumn.period_date == period_date)
column_stmt = column_stmt.where(FinancialStatementColumn.period_date == period_date)
column_id = (await session.execute(column_stmt.order_by(FinancialStatementColumn.column_index).limit(1))).scalar()
if column_id is None:
return None
stmt = (
select(func.sum(FinancialStatementValue.amount))
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.where(
FinancialStatementLine.meta_id == meta_id,
FinancialStatementLine.line_code == code,
FinancialStatementLine.line_status == StatementLineStatus.ok,
FinancialStatementValue.column_id == column_id,
)
)
return (await session.execute(stmt)).scalar_one_or_none()
@ -376,8 +403,8 @@ async def pair_amounts(
if balance_meta is not None:
year_start = datetime.date(document.period_year - 1, 12, 31)
balance = (
await _line_amount(session, balance_meta.id, "1370", current=True),
await _line_amount(session, balance_meta.id, "1370", period_date=year_start),
await code_amount(session, balance_meta.id, "1370", current=True),
await code_amount(session, balance_meta.id, "1370", period_date=year_start),
)
income = await _line_amount(session, income_meta.id, "2400", current=True) if income_meta is not None else None
income = await code_amount(session, income_meta.id, "2400", current=True) if income_meta is not None else None
return balance, income

View File

@ -15,7 +15,7 @@ from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_vali
from src.domain.statement_checks import ChecksReport, FailedCheck
from src.domain.statement_lines import TemplateLine
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementLineStatus
from src.models.files import Document
from src.models.statements import (
FinancialStatementColumn,
@ -155,8 +155,8 @@ def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int
def line_origin(line: FinancialStatementLine) -> LineOrigin:
"""`template` — строка создана правкой аналитика (маппинг всегда пишет `line_code_original_value`)."""
return "template" if line.line_code_original_value is None else "source"
"""`template` — строка создана правкой аналитика (`row_index IS NULL`); у строк источника индекс задан всегда."""
return "template" if line.row_index is None else "source"
def _empty_value(column: FinancialStatementColumn) -> ValueOut:
@ -202,7 +202,8 @@ def _lines_out(
values=cells,
)
)
codes = {line.line_code for line, _ in lines}
# достройка по шаблону — только для кода без строки `ok` (строка `unreadable` с тем же текстом кода не мешает)
codes = {line.line_code for line, _ in lines if line.line_status is StatementLineStatus.ok}
for item in template:
if item.code in codes:
continue

View File

@ -26,6 +26,7 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
import asyncio
import dataclasses
import functools
import uuid
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
@ -49,8 +50,10 @@ from src.domain.osv_mapping import (
document_status,
opening_matches,
)
from src.domain.statement_aggregate import checks_input
from src.domain.statement_checks import load_check_rules, run_checks
from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok
from src.domain.statement_lines import load_line_catalog
from src.domain.statement_mapping import StatementDraft, build_statement, edition_for, statement_status, values_ok
from src.models.accounting import ChartOfAccount
from src.models.consolidation import Consolidation, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity
@ -415,9 +418,15 @@ async def _insert_statements(
if not forms:
return
rules = load_check_rules()
catalog = load_line_catalog()
for document, draft in forms:
edition = edition_for(document.reporting_date)
form = document.document_type.value
meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=draft)
column_indexes, amounts = await statements_repo.statement_amounts(session, meta.id)
columns, aggregates = await statements_repo.form_aggregate(
session, meta.id, functools.partial(catalog.reference_name, edition, form)
)
column_indexes, amounts = checks_input(columns, aggregates)
report = run_checks(document.document_type.value, amounts, column_indexes, rules)
document.status = statement_status(
job_done=job_done,

View File

@ -15,6 +15,7 @@ from src.common.clock import utc_now
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
from src.domain.osv_mapping import counterparty_check
from src.domain.policy import VisibilityScope
from src.domain.statement_aggregate import CodeAggregate, checks_input
from src.domain.statement_checks import (
ChecksReport,
FailedCheck,
@ -34,9 +35,10 @@ from src.models.enums import (
DocumentType,
FileStatus,
RecognitionStatus,
StatementLineStatus,
)
from src.models.files import Document, RecognitionJob
from src.models.statements import FinancialStatementLine, FinancialStatementMeta
from src.models.statements import FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta
from src.repositories import files as files_repo
from src.repositories import statements as repo
from src.repositories.counterparties import CounterpartyIndex
@ -56,13 +58,24 @@ INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
async def form_aggregate(
session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[list[FinancialStatementColumn], dict[str, CodeAggregate]]:
"""Колонки и агрегат формы по коду со справочными наименованиями редакции документа (P3-14)."""
catalog = load_line_catalog()
edition = edition_for(document.reporting_date)
form = document.document_type.value
return await repo.form_aggregate(session, meta.id, lambda code: catalog.reference_name(edition, form, code))
async def statement_hard_failures(
session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b)."""
if meta.checks_accepted_at is not None:
return ()
column_indexes, amounts = await repo.statement_amounts(session, meta.id)
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)
@ -121,10 +134,16 @@ class StatementService:
self, document: Document, meta: FinancialStatementMeta
) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]:
"""Проверки §7 по текущим суммам формы, мягкая межформенная проверка (D-5) и родители подстрок
(`parent_code` ответа; AURORA-1527). Родители считаются по тем же кодам и в том же порядке
(`statement_amounts`), что и проверка «сумма подстрок = родитель», — ответ и проверка не расходятся."""
(`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 = await repo.statement_amounts(self._session, meta.id)
column_indexes, amounts = checks_input(columns, aggregates)
report = run_checks(document.document_type.value, amounts, column_indexes, rules)
parents = subline_parents([code for code, _ in amounts])
balance_1370, income_2400 = await repo.pair_amounts(self._session, document)
@ -163,7 +182,7 @@ class StatementService:
counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN,
period_known=document.period_year is not None and document.period_quarter is not None,
values_ok=values_ok(await repo.value_flags(self._session, meta.id)),
has_lines=await repo.has_lines(self._session, meta.id),
has_lines=await repo.has_source_lines(self._session, meta.id),
warnings=(),
)
document.updated_at = utc_now()
@ -221,7 +240,7 @@ class StatementService:
columns = await repo.column_ids(self._session, meta.id)
lines = await repo.form_lines(self._session, meta.id)
by_id = {line.id: line for line in lines}
codes = {line.line_code for line in lines}
codes = {line.line_code for line in lines if line.line_status is StatementLineStatus.ok}
edition = edition_for(document.reporting_date)
catalog_lines = load_line_catalog().template_lines(edition, document.document_type.value)
template = {item.code: item for item in catalog_lines}

View File

@ -1377,3 +1377,132 @@ async def test_template_line_checks_follow_filled_amounts(
after_clear = response.json()["checks"]["failed"]
assert not any(f["relation"].startswith("1700 =") for f in after_clear) # очищенная 1700 снова не цель
assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2}
# --- агрегат формы по коду (v5.19, A7) ---
async def insert_line( # noqa: PLR0913 - строка формы теста: код, статус, индекс, сумма
session: AsyncSession,
document: Document,
*,
code: str | None,
row_index: int | None,
status: str = "ok",
amount: Decimal | None = None,
current: bool = True,
code_original: str | None = None,
code_text: str | None = None,
) -> FinancialStatementLine:
"""Строка формы напрямую в БД; ячейка с `amount` — в колонке отчётной даты (`current`) или нулевой."""
meta = await _meta(session, document)
columns = (
(
await session.execute(
select(FinancialStatementColumn)
.where(FinancialStatementColumn.meta_id == meta.id)
.order_by(FinancialStatementColumn.column_index)
)
)
.scalars()
.all()
)
column = next(c for c in columns if c.is_current) if current else columns[0]
line = FinancialStatementLine(
id=uuid.uuid4(),
meta_id=meta.id,
line_code=code,
line_code_original_value=code_original,
line_status=status,
line_code_text=code_text,
line_name="тест",
row_index=row_index,
)
session.add(line)
await session.flush()
if amount is not None:
session.add(
FinancialStatementValue(
id=uuid.uuid4(), line_id=line.id, column_id=column.id, amount=amount, is_modified=False
)
)
await session.flush()
return line
async def test_cross_form_uses_aggregate(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 2: две строки 1370 в колонке отчётной даты — 500 + 300 = 800."""
balance, income = await two_forms(seeded, fixtures_dir)
await set_amount(seeded, balance, "1370", Decimal("500"), current=True)
await set_amount(seeded, balance, "1370", Decimal("0"), period_date=datetime.date(2024, 12, 31))
await insert_line(seeded, balance, code="1370", row_index=99, amount=Decimal("300"), code_original="1370")
client = await auth_client(KA_RF)
await set_amount(seeded, income, "2400", Decimal("800"), current=True)
assert (await client.get(f"/documents/{income.id}/statement")).json()["checks"]["cross_form"] is None
await set_amount(seeded, income, "2400", Decimal("500"), current=True)
cross = (await client.get(f"/documents/{income.id}/statement")).json()["checks"]["cross_form"]
assert cross is not None and cross["expected"] == "800.00" and cross["actual"] == "500.00"
async def test_parent_code_with_repeated_parent(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
await insert_line(seeded, document, code="1150", row_index=98, amount=Decimal("1"), code_original="1150")
await insert_line(seeded, document, code="11501", row_index=99, amount=Decimal("1"), code_original="11501")
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
sub = [line for line in body["lines"] if line["line_code"] == "11501"]
assert [line["parent_code"] for line in sub] == ["1150", "1150"][: len(sub)] and sub
assert len([line for line in body["lines"] if line["line_code"] == "1150"]) == 2 # noqa: PLR2004
async def test_template_not_completed_for_code_with_ok_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await insert_line(seeded, document, code="1215", row_index=90, amount=Decimal("5"), code_original="1215")
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
lines = [line for line in body["lines"] if line["line_code"] == "1215"]
assert [(line["origin"], line["id"] is None) for line in lines] == [("source", False)]
async def test_template_completed_for_code_only_unreadable(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
bad = await insert_line(
seeded, document, code=None, row_index=91, status="unreadable", amount=Decimal("5"), code_text="ИЧ,215"
)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
template = [line for line in body["lines"] if line["line_code"] == "1215"]
assert [(line["origin"], line["id"]) for line in template] == [("template", None)]
assert any(line["id"] == str(bad.id) for line in body["lines"])
async def test_patch_by_line_code_with_ok_line_is_unknown_cell(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await insert_line(seeded, document, code="1215", row_index=90, amount=Decimal("5"), code_original="1215")
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
column_id = body["columns"][0]["id"]
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1215", "column_id": column_id, "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_origin_by_row_index(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
line = await insert_line(seeded, document, code="1250", row_index=7, amount=Decimal("5"), code_original=None)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
assert next(item for item in body["lines"] if item["id"] == str(line.id))["origin"] == "source"

View File

@ -0,0 +1,83 @@
"""Агрегат формы по коду (спека `arm-core` v5.19; Review Focus 2)."""
import uuid
from dataclasses import dataclass
from decimal import Decimal
from src.domain.statement_aggregate import AggLine, aggregate_by_code, checks_input
from src.models.enums import StatementLineStatus
C1 = uuid.UUID(int=1)
C2 = uuid.UUID(int=2)
L1, L5 = uuid.UUID(int=101), uuid.UUID(int=105)
def line( # noqa: PLR0913 - поля строки теста
status: str,
code: str | None,
*,
r: int | None,
c1: str | None = None,
c2: str | None = None,
id_: uuid.UUID | None = None,
) -> AggLine:
amounts = {C1: Decimal(c1) if c1 is not None else None, C2: Decimal(c2) if c2 is not None else None}
return AggLine(id_ or uuid.uuid4(), r, StatementLineStatus(status), code, amounts)
def ok(code: str, *, r: int | None, c1: str | None = None, c2: str | None = None, id_: uuid.UUID | None = None):
return line("ok", code, r=r, c1=c1, c2=c2, id_=id_)
def test_repeated_code_sums() -> None:
agg = aggregate_by_code([ok("2410", r=1, c1="100", id_=L1), ok("2410", r=5, c1="649", id_=L5)])
assert agg["2410"].amounts[C1] == Decimal("749") and agg["2410"].line_ids == (L1, L5)
def test_empty_amounts_stay_null() -> None:
assert aggregate_by_code([ok("1110", r=0), ok("1110", r=3)])["1110"].amounts[C1] is None
assert aggregate_by_code([ok("1110", r=0), ok("1110", r=3, c1="5")])["1110"].amounts[C1] == Decimal("5")
def test_non_ok_lines_excluded() -> None:
agg = aggregate_by_code(
[
line("detail", None, r=2, c1="7"),
line("unreadable", None, r=3, c1="9"),
line("missing", None, r=4, c1="1"),
ok("1250", r=5, c1="3"),
]
)
assert list(agg) == ["1250"]
def test_template_line_counts_as_ok() -> None:
agg = aggregate_by_code([ok("1250", r=None, c1="10")])
assert agg["1250"].amounts[C1] == Decimal("10") and agg["1250"].has_source_line is False
def test_codes_ascending_and_template_line_ids_last() -> None:
agg = aggregate_by_code([ok("2400", r=None, id_=L5), ok("1600", r=2), ok("2400", r=7, id_=L1)])
assert list(agg) == ["1600", "2400"] and agg["2400"].line_ids == (L1, L5)
assert agg["2400"].has_source_line is True
def test_reference_name_from_callback() -> None:
agg = aggregate_by_code([ok("1250", r=1)], reference_name=lambda code: f"имя {code}")
assert agg["1250"].reference_name == "имя 1250"
@dataclass
class Col:
id: uuid.UUID
column_index: int
def test_checks_input_skips_empty_template_codes() -> None:
agg = aggregate_by_code(
[ok("1250", r=None), ok("1260", r=None, c1="4"), ok("1270", r=3), ok("1280", r=4, c1="1"), ok("1280", r=None)]
)
indexes, lines = checks_input([Col(C2, 1), Col(C1, 0)], agg)
assert indexes == [0, 1]
assert [code for code, _ in lines] == ["1260", "1270", "1280"]
assert dict(lines)["1260"] == {0: Decimal("4"), 1: None}

View File

@ -35,7 +35,7 @@ def test_hard_violation() -> None:
report = run_checks(
"BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "1000"})), [0], RULES
)
assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900")) in report.failed
assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900"), ("1600", "1100", "1200")) in report.failed
def test_relation_skipped_without_target() -> None:
@ -107,7 +107,7 @@ def test_relation_checked_per_column_and_none_is_zero() -> None:
)
report = run_checks("BALANCE", lines, [0, 1, 2], RULES)
assert _by_relation(report.failed, "1600 = 1100 + 1200") == [
FailedCheck(1, "1600 = 1100 + 1200", D("1000"), D("900"))
FailedCheck(1, "1600 = 1100 + 1200", D("1000"), D("900"), ("1600", "1100", "1200"))
]
assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == []
@ -117,7 +117,7 @@ def test_tolerance_exceeded_by_one_unit_is_violation() -> None:
lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "902"}))
report = run_checks("BALANCE", lines, [0], RULES)
assert _by_relation(report.failed, "1600 = 1100 + 1200") == [
FailedCheck(0, "1600 = 1100 + 1200", D("902"), D("900"))
FailedCheck(0, "1600 = 1100 + 1200", D("902"), D("900"), ("1600", "1100", "1200"))
]
@ -179,7 +179,7 @@ def test_rounding_threshold_boundary() -> None:
(
(D("500"), D("300")),
D("150"),
FailedCheck(-1, "1370 − 1370(начало года) = 2400", D("200"), D("150")),
FailedCheck(-1, "1370 − 1370(начало года) = 2400", D("200"), D("150"), ("1370", "2400")),
),
((None, D("300")), D("200"), None),
((D("500"), D("300")), None, None),