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:
parent
065153e470
commit
02630e830f
@ -26,7 +26,6 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import functools
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
@ -50,11 +49,8 @@ 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_issues import mapping_warning
|
||||
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.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok
|
||||
from src.domain.statement_status import duplicate_period_forms
|
||||
from src.models.accounting import ChartOfAccount
|
||||
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.audit import AuditService
|
||||
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__ = [
|
||||
"ENTRY_BATCH_SIZE",
|
||||
@ -425,27 +423,21 @@ async def _insert_statements( # noqa: PLR0913 - все входы именов
|
||||
repeats: Mapping[uuid.UUID, str],
|
||||
job_done: bool,
|
||||
now: datetime,
|
||||
engine: StatementsEngine | None = None,
|
||||
) -> None:
|
||||
"""Шапка, колонки, строки и значения каждой формы; предупреждения черновика и повтор формы — `MAPPING_WARNING`
|
||||
документа; статус документа по D-6 (акцепта при маппинге нет)."""
|
||||
if not forms:
|
||||
return
|
||||
rules = load_check_rules()
|
||||
catalog = load_line_catalog()
|
||||
engine = engine or default_engine()
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
checks_passed = await checks_on_intake_1x(session, document=document, meta=meta, engine=engine)
|
||||
document.status = statement_status(
|
||||
job_done=job_done,
|
||||
checks_passed=not report.failed,
|
||||
checks_passed=checks_passed,
|
||||
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 statements_repo.value_flags(session, meta.id)),
|
||||
|
||||
@ -47,6 +47,7 @@ __all__ = [
|
||||
"RELATION_FAILED",
|
||||
"RELATION_SOFT",
|
||||
"STATEMENTS_VERSION_MISMATCH",
|
||||
"checks_on_intake_1x",
|
||||
"code_issues",
|
||||
"name_issues",
|
||||
"recompute_form",
|
||||
@ -273,3 +274,24 @@ async def recompute_form(
|
||||
duplicate_in_file=document.id in duplicate_period_forms(forms),
|
||||
)
|
||||
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
|
||||
|
||||
@ -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:
|
||||
document = await form(seeded, fixtures_dir)
|
||||
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]
|
||||
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 {column["period_kind"] for column in body["columns"]} <= {"absolute", "none"}
|
||||
assert body["issues"] == []
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@ -14,11 +15,14 @@ from src.models.accounting import ChartOfAccount
|
||||
from src.models.directories import LegalEntity
|
||||
from src.models.enums import (
|
||||
DocumentCheckResult,
|
||||
DocumentIssueKind,
|
||||
DocumentStatus,
|
||||
DocumentType,
|
||||
FileStatus,
|
||||
IssueSource,
|
||||
PeriodQuarter,
|
||||
RecognitionStatus,
|
||||
StatementFormVariant,
|
||||
)
|
||||
from src.models.files import Document, RecognitionJob, StoredFile
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
path = next((fixtures_dir / NPZ).glob("*.json"))
|
||||
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)
|
||||
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
|
||||
await make_document_issue(seeded, document_id=documents[0].id) # v5.19: остаток прежнего маппинга
|
||||
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 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()
|
||||
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}) # документы пересозданы, не дублированы
|
||||
|
||||
@ -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-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-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` |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user