feat(mapping): §7 и вариант формы при приёме 1.x копией arm_statements (Р-30)

checks_on_intake_1x: один recompute по вставленной форме, запись form_variant и
relation/soft (core); run_checks из приёма убран. P3-36.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 21:03:06 +04:00
parent 065153e470
commit 02630e830f
5 changed files with 108 additions and 17 deletions

View File

@ -26,7 +26,6 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
import asyncio import asyncio
import dataclasses import dataclasses
import functools
import uuid import uuid
from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
@ -50,11 +49,8 @@ from src.domain.osv_mapping import (
document_status, document_status,
opening_matches, 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_issues import mapping_warning from src.domain.statement_issues import mapping_warning
from src.domain.statement_lines import load_line_catalog from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok
from src.domain.statement_mapping import StatementDraft, build_statement, edition_for, statement_status, values_ok
from src.domain.statement_status import duplicate_period_forms from src.domain.statement_status import duplicate_period_forms
from src.models.accounting import ChartOfAccount from src.models.accounting import ChartOfAccount
from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.consolidation import Consolidation, ConsolidationVersion
@ -78,6 +74,8 @@ from src.repositories.counterparties import CounterpartyIndex
from src.services import consolidation_documents from src.services import consolidation_documents
from src.services.audit import AuditService from src.services.audit import AuditService
from src.services.file_status import set_file_status_for_job from src.services.file_status import set_file_status_for_job
from src.services.statement_recompute import checks_on_intake_1x
from src.services.statements_engine import StatementsEngine, default_engine
__all__ = [ __all__ = [
"ENTRY_BATCH_SIZE", "ENTRY_BATCH_SIZE",
@ -425,27 +423,21 @@ async def _insert_statements( # noqa: PLR0913 - все входы именов
repeats: Mapping[uuid.UUID, str], repeats: Mapping[uuid.UUID, str],
job_done: bool, job_done: bool,
now: datetime, now: datetime,
engine: StatementsEngine | None = None,
) -> None: ) -> None:
"""Шапка, колонки, строки и значения каждой формы; предупреждения черновика и повтор формы — `MAPPING_WARNING` """Шапка, колонки, строки и значения каждой формы; предупреждения черновика и повтор формы — `MAPPING_WARNING`
документа; статус документа по D-6 (акцепта при маппинге нет).""" документа; статус документа по D-6 (акцепта при маппинге нет)."""
if not forms: if not forms:
return return
rules = load_check_rules() engine = engine or default_engine()
catalog = load_line_catalog()
for document, draft in forms: 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) meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=draft)
warnings = [*draft.warnings, *([repeats[document.id]] if document.id in repeats else [])] warnings = [*draft.warnings, *([repeats[document.id]] if document.id in repeats else [])]
await statements_repo.add_issues(session, document.id, [mapping_warning(text) for text in warnings], now=now) await statements_repo.add_issues(session, document.id, [mapping_warning(text) for text in warnings], now=now)
columns, aggregates = await statements_repo.form_aggregate( checks_passed = await checks_on_intake_1x(session, document=document, meta=meta, engine=engine)
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( document.status = statement_status(
job_done=job_done, job_done=job_done,
checks_passed=not report.failed, checks_passed=checks_passed,
counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN,
period_known=document.period_year is not None and document.period_quarter is not None, period_known=document.period_year is not None and document.period_quarter is not None,
values_ok=values_ok(await statements_repo.value_flags(session, meta.id)), values_ok=values_ok(await statements_repo.value_flags(session, meta.id)),

View File

@ -47,6 +47,7 @@ __all__ = [
"RELATION_FAILED", "RELATION_FAILED",
"RELATION_SOFT", "RELATION_SOFT",
"STATEMENTS_VERSION_MISMATCH", "STATEMENTS_VERSION_MISMATCH",
"checks_on_intake_1x",
"code_issues", "code_issues",
"name_issues", "name_issues",
"recompute_form", "recompute_form",
@ -273,3 +274,24 @@ async def recompute_form(
duplicate_in_file=document.id in duplicate_period_forms(forms), duplicate_in_file=document.id in duplicate_period_forms(forms),
) )
document.updated_at = utc_now() document.updated_at = utc_now()
async def checks_on_intake_1x(
session: AsyncSession,
*,
document: Document,
meta: FinancialStatementMeta,
engine: StatementsEngine,
) -> bool:
"""§7 и вариант формы при приёме результата `1.x` (Р-30): один вызов `engine.recompute` по вставленной форме.
Пишет `meta.form_variant` и записи `relation` / `soft` (`source = core`); `needs_review` значений и строк не
трогает (у `1.x` они по правилу приёма, A9), проверки ядра уровня `info` не пишет (P3-7). Возвращает «жёстких
нарушений нет» — условие D-6 «проверки сошлись». Без 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)
meta.form_variant = StatementFormVariant(result.form_variant.value) if result.form_variant is not None else None
_, aggregates = await repo.form_aggregate(session, meta.id)
drafts = relations_to_issues(result.checks, columns=columns, aggregates=aggregates, source=IssueSource.core)
await repo.add_issues(session, document.id, drafts, now=utc_now())
return not result.checks.relations_failed

View File

@ -793,6 +793,26 @@ async def _break_balance(client, document_id) -> None:
) )
async def test_1x_form_with_violation_not_confirmable(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path
) -> None:
"""Форма `1.x` с нарушением §7 с приёма: `NEEDS_CHECK`, `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED`."""
data = json.loads((fixtures_dir / MEZH_BALANCE).read_text("utf-8"))
for line in data["documents"][0]["lines"]:
if line["line_code"] == "1600":
line["values"][0]["amount"] = str(Decimal(line["values"][0]["amount"] or 0) + 1000)
path = tmp_path / "violation.json"
path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
entity = await rostov_entity(seeded, path)
(document,) = await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=None)
assert document.status is DocumentStatus.NEEDS_CHECK
client = await auth_client(KA_RF)
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED"
failed = (await _statement(client, document))["checks"]["failed"]
assert any(item["codes"] == ["1600", "1700"] for item in failed)
async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir) document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
@ -1759,7 +1779,7 @@ async def test_get_statement_v5_19_fields_on_1x_form(
) )
cells = [value for line in source for value in line["values"] if value["id"] is not None] cells = [value for line in source for value in line["values"] if value["id"] is not None]
assert all(value["text"] == value["string_value"] for value in cells) assert all(value["text"] == value["string_value"] for value in cells)
assert body["document"]["form_variant"] is None assert body["document"]["form_variant"] == "full" # Р-30: вариант определяется при приёме 1.x
assert body["header"]["needs_review_fields"] == [] and body["header"]["organization_name_modified"] is False assert body["header"]["needs_review_fields"] == [] and body["header"]["organization_name_modified"] is False
assert {column["period_kind"] for column in body["columns"]} <= {"absolute", "none"} assert {column["period_kind"] for column in body["columns"]} <= {"absolute", "none"}
assert body["issues"] == [] assert body["issues"] == []

View File

@ -4,6 +4,7 @@
import json import json
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -14,11 +15,14 @@ from src.models.accounting import ChartOfAccount
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
from src.models.enums import ( from src.models.enums import (
DocumentCheckResult, DocumentCheckResult,
DocumentIssueKind,
DocumentStatus, DocumentStatus,
DocumentType, DocumentType,
FileStatus, FileStatus,
IssueSource,
PeriodQuarter, PeriodQuarter,
RecognitionStatus, RecognitionStatus,
StatementFormVariant,
) )
from src.models.files import Document, RecognitionJob, StoredFile from src.models.files import Document, RecognitionJob, StoredFile
from src.models.statements import ( from src.models.statements import (
@ -98,6 +102,56 @@ async def test_clean_income_recognized(seeded: AsyncSession, fixtures_dir: Path)
assert income.status is DocumentStatus.RECOGNIZED assert income.status is DocumentStatus.RECOGNIZED
def _balance_with(tmp_path: Path, source: Path, edit) -> Path: # noqa: ANN001 - правка списка строк снимка
data = json.loads(source.read_text("utf-8"))
data["documents"][0]["lines"] = edit(data["documents"][0]["lines"])
path = tmp_path / "edited.json"
path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
return path
async def _core_relations(session: AsyncSession, document: Document) -> list[DocumentIssue]:
stmt = select(DocumentIssue).where(
DocumentIssue.document_id == document.id, DocumentIssue.kind == DocumentIssueKind.relation
)
return list((await session.execute(stmt)).scalars())
async def test_1x_balance_with_violation(seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> None:
"""Форма `1.x` с нарушением: `RELATION_FAILED` источника `core` при приёме, документ `NEEDS_CHECK`."""
source = fixtures_dir / MEZH / "1_Форма1_бух_09.json"
owner = await _owner(seeded, source)
def edit(lines: list[dict]) -> list[dict]:
for line in lines:
if line["line_code"] == "1600":
line["values"][0]["amount"] = str(Decimal(line["values"][0]["amount"] or 0) + 1000)
return lines
path = _balance_with(tmp_path, source, edit)
(balance,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
assert balance.status is DocumentStatus.NEEDS_CHECK
failed = [i for i in await _core_relations(seeded, balance) if i.code == "RELATION_FAILED"]
assert failed and all(i.source is IssueSource.core for i in failed)
assert any(i.codes == ["1600", "1700"] for i in failed)
async def test_1x_simplified_variant(seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> None:
"""Вариант формы при приёме `1.x` (Р-30): ББ с 1600 и без 1100/1200 — `simplified`."""
source = fixtures_dir / MEZH / "1_Форма1_бух_09.json"
owner = await _owner(seeded, source)
path = _balance_with(tmp_path, source, lambda lines: [x for x in lines if x["line_code"] in {"1600", "1700"}])
(balance,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
assert (await _meta(seeded, balance)).form_variant is StatementFormVariant.simplified
async def test_1x_clean_form_has_no_relation_issues(seeded: AsyncSession, fixtures_dir: Path) -> None:
path = fixtures_dir / MEZH / "1_Форма2_бух_09.json"
owner = await _owner(seeded, path)
(income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
assert income.status is DocumentStatus.RECOGNIZED and await _core_relations(seeded, income) == []
async def test_book_with_two_forms(seeded: AsyncSession, fixtures_dir: Path) -> None: async def test_book_with_two_forms(seeded: AsyncSession, fixtures_dir: Path) -> None:
path = next((fixtures_dir / NPZ).glob("*.json")) path = next((fixtures_dir / NPZ).glob("*.json"))
owner = await _owner(seeded, path) owner = await _owner(seeded, path)
@ -290,6 +344,7 @@ async def test_repeated_form_mapping_is_idempotent(seeded: AsyncSession, fixture
) )
documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
before = await _statement_totals(seeded) before = await _statement_totals(seeded)
issues_before = (await seeded.execute(select(func.count()).select_from(DocumentIssue))).scalar_one()
file_id = documents[0].stored_file_id file_id = documents[0].stored_file_id
await make_document_issue(seeded, document_id=documents[0].id) # v5.19: остаток прежнего маппинга await make_document_issue(seeded, document_id=documents[0].id) # v5.19: остаток прежнего маппинга
file = await seeded.get(StoredFile, file_id) file = await seeded.get(StoredFile, file_id)
@ -312,7 +367,8 @@ async def test_repeated_form_mapping_is_idempotent(seeded: AsyncSession, fixture
) )
assert await _statement_totals(seeded) == before assert await _statement_totals(seeded) == before
assert (await seeded.execute(select(func.count()).select_from(DocumentIssue))).scalar_one() == 0 # записи прежнего маппинга (в том числе подложенная) удалены, новые §7 приёма — тот же набор (Р-30)
assert (await seeded.execute(select(func.count()).select_from(DocumentIssue))).scalar_one() == issues_before
again = (await seeded.execute(select(Document).where(Document.stored_file_id == file_id))).scalars().all() again = (await seeded.execute(select(Document).where(Document.stored_file_id == file_id))).scalars().all()
assert {d.document_type for d in again} == {DocumentType.BALANCE, DocumentType.INCOME} assert {d.document_type for d in again} == {DocumentType.BALANCE, DocumentType.INCOME}
assert {d.id for d in again}.isdisjoint({d.id for d in documents}) # документы пересозданы, не дублированы assert {d.id for d in again}.isdisjoint({d.id for d in documents}) # документы пересозданы, не дублированы

View File

@ -44,3 +44,4 @@ Goals и Non-goals — см. `proposal.md`.
| 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-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-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 больше не затираются навсегда) | нет | | 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 больше не затираются навсегда) | нет |
| 2026-10-18 | **P3-36 (B3).** `checks_on_intake_1x` живёт в `statement_recompute.py`; движок передаётся в `_insert_statements` необязательным параметром (по умолчанию `default_engine()`), `map_file` сигнатуру не меняет. Приём `1.x` пишет `meta.form_variant` и записи `relation` / `soft` (`core`), но не `CODE_NOT_IN_*` / `NAME_MISMATCH` (P3-7) и не трогает `needs_review` (A9); условие D-6 «проверки сошлись» — `not result.checks.relations_failed` (нарушение с `expected = NULL` — жёсткое) | задание B3, спека «Пересчёт при приёме»; расхождение с прежним `run_checks` (его `failed`) возможно только на формах с пустой целью, где пакет строже | форма `1.x` с пустой целью получит `NEEDS_CHECK` вместо `RECOGNIZED` |