From e6292a24df299a99b7b54ea838f2a35ff3dd73af Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 22:38:24 +0400 Subject: [PATCH] =?UTF-8?q?fix(statements):=20fix-=D0=B2=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=20=D0=91=20=E2=80=94=20?= =?UTF-8?q?is=5Frecognized=20NULL,=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC=202.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-2: NULL у is_recognized не сомнение (P3-54); I-3: metrics.file_warnings при приёме 2.0 — единый источник для пересчёта (P3-53, R-11); I-4/T4.4/T6.1: тесты правки, акцепта и веток приёма 2.0; T4.2: now в checks_on_intake_1x; M-4: row_by_ref по принятым строкам; документация (M-5, M-6, M-7). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 19 +- README.md | 7 +- api/src/domain/statement_mapping_v2.py | 9 +- api/src/domain/stored_form.py | 6 +- api/src/services/result_mapping.py | 24 +- api/src/services/statement_intake_v2.py | 24 +- api/src/services/statement_recompute.py | 9 +- api/tests/test_domain_statement_mapping_v2.py | 29 ++ api/tests/test_domain_stored_form.py | 10 +- api/tests/test_statements_v2_edit.py | 295 ++++++++++++++++++ 10 files changed, 415 insertions(+), 17 deletions(-) create mode 100644 api/tests/test_statements_v2_edit.py diff --git a/CLAUDE.md b/CLAUDE.md index 094aecf..e9c53a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,15 +97,26 @@ `engine.tolerance()`) и `subline_parents` (`parent_code` ответа). Акцепт — `checks_accepted_by/at/comment` в шапке. Правка формы и акцепт — `services/statements.py` (`PATCH …/statement`, `POST …/statement/checks/accept`): **только до подтверждения** (`CONFIRMED` → `DOCUMENT_READONLY`), аудит `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type=document`; один запрос — одно событие), первичка - рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); любая правка сбрасывает акцепт и пересчитывает статус - документа. **Порядок блокировок «файл → документ»**, шапка (`meta`) читается ПОД блокировкой с `refresh=True` — иначе в сессии + рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); акцепт сбрасывает только правка данных (подтверждения + ячеек, кодов и дат — нет, Р-25), статус документа пересчитывается после каждой правки. **Порядок блокировок «файл → документ»**, шапка (`meta`) читается ПОД блокировкой с `refresh=True` — иначе в сессии остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400) не блокируют. Полный состав формы — `domain/statement_lines.py` по справочнику `api/rules/statement_lines.yaml` (редакции `66n`/`fsbu4` из шаблонов банка; это справочник имён и состава экрана — перечни кодов редакций живут в `arm_statements` (`Rules.code_orders`, `engine.editions()`/`engine.code_order(...)`), согласованность проверяет `test_name_dictionary_codes_in_code_orders`): `GET …/statement` достраивает строки и ячейки с - `id = null`, `PATCH` по `line_code` создаёт строку (`line_code_original_value = NULL` — «не из источника»); пустые - созданные строки не участвуют в §7 (`statement_amounts`). Решения — `openspec/changes/statement-template-lines/design.md`. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`, + `id = null`, `PATCH` по `line_code` создаёт строку (`line_code_original_value = NULL` — «не из источника»); пустая созданная + строка вклада в §7 не даёт, а пустая цель соотношения при заполненных членах — нарушение с `expected = NULL` (P3-30, P1-21; + потерявшая итоговую строку форма не подтверждается до правки или акцепта). **Приём результата `2.0` (часть Б)** — `_load_result` + выбирает маппер по MAJOR `schema_version` (`contract.versions.parse_result`: 1 — `RecognitionResult`, 2 — `RecognitionResultV2`; иной + MAJOR и битый/не-JSON файл — `MappingFatal` с итогом проверки схемы). Формы `2.0` (`services/statement_intake_v2`) пишутся как пришли: + §7, `needs_review` и предупреждения берутся из результата, ядро добавляет только проверки `info` (`core_info_issues`) и страховку + статуса (`engine.contract_document_status`, `RESULT_STATUS_INCONSISTENT`); предупреждения и нарушения сервиса — `document_issue` + с `source = service`, первый же пересчёт после правки заменяет `relation` / `soft` записями `core`, прочее сохраняет. ОСВ `2.0` — по + Р-28: статус листа из `osvDocument.status`, сошедшихся сумм, ошибки файла и предупреждений маппинга; предупреждения листа + (`OSV_*`) — в `document_issue` документа ОСВ и на статус не влияют (P3-49). **Ошибка файла — единый источник (P3-53, R-11):** + `metrics.warnings` результата `2.0` сохраняются в `recognition_job.metrics.file_warnings`, пересчёт читает их оттуда (без ключа — + `metrics.warnings` конверта, `1.x`). `recompute_form`/`checks_on_intake_1x` — `services/statement_recompute.py`, вход пакету + собирает `domain/stored_form.py` (`is_recognized = NULL` — не сомнение, P3-54). Решения — `openspec/changes/statement-template-lines/design.md`. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`, без аудита; `comments_count` в карточках). `GET /files/{id}/content` — `services/primary_files.content` + `ObjectStorage.stream` (поток из S3, первая порция до заголовков → `FILE_CONTENT_NOT_FOUND`; сессию тело потока не держит; имя — RFC 5987, `X-Content-Type-Options: nosniff`; освобождение потока — `common/streaming.py`: `ClosingStreamingResponse` + diff --git a/README.md b/README.md index 2ab2f94..47f00ed 100644 --- a/README.md +++ b/README.md @@ -568,6 +568,11 @@ S3 (storage); без S3 — предупреждение в логе, цикл (`relation` / `soft`). Копии словаря §7 в ядре нет; в `domain/statement_checks.py` остались межформенная проверка 1370↔2400 (на лету, допуск — из правил пакета) и `subline_parents`. +**Формы, принятые кодом части А (M-5).** У форм ББ/ОФР, загруженных до выкладки части Б, записей §7 в `document_issue` нет +(нарушения считались на лету). После выкладки они не проверяются на `CHECKS_NOT_PASSED` при подтверждении, пока не пройдут +первую правку (она запишет `relation` / `soft`) или не будут пересозданы повторной загрузкой; на стендах формы очищает +`scripts/purge_statements.py` (раздел «Выкладка v5.19: очистка форм»). + - **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks, issues[], edition_lines[]}`: `header` — `inn`, `report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы (`organization_name`, `okud`, `okpo`, …); `lines[].values[]` — суммы строкой, `is_modified`, `original_amount`; `lines[].parent_code` — у пятизначной подстроки код её четырёхзначной строки-родителя (правило проверки «сумма подстрок = родитель», `domain/statement_checks.subline_parents`: ближайшая выше строка с теми же тремя цифрами, иначе строка с кодом из первых четырёх цифр; родитель считается по строкам, участвующим в проверках, — достроенные из справочника пустые строки родителями не бывают), у остальных строк `null`; поле вычисляется на лету, в БД не хранится; мягкая проверка «сумма подстрок = родитель» по колонке выполняется, только если хотя бы одна подстрока родителя в ней непустая (`amount` не `null`; ноль — значение), иначе не проверяется; `checks` — @@ -606,7 +611,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл (в том числе подтверждение ячейки, которой нет в БД), колонка не этой формы — 422 `UNKNOWN_COLUMN`, одна колонка дважды — 422 `DUPLICATE_COLUMN`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН - пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. + пересчитывает `counterparty_check`, даты отчёта — период документа; акцепт сбрасывает только правка данных (подтверждения ячеек и дат — нет, Р-25), статус пересчитывается после каждой правки. `lines[] {line_id, line_code | line_status: "detail" | confirmed}` (ровно одно действие) — назначить или сменить код, отметить расшифровкой, подтвердить код (порядок частей: `lines` → `values` → `columns` → `header`). Все ошибки элемента проверяются до первой записи; первая неверная несёт в `detail` `line_id` и `line_code`; `LINE_CODE_TAKEN` — ничего не применено. `values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме. Запрос без изменений — 200 без аудита. Ответ — та же форма, что у `GET`. diff --git a/api/src/domain/statement_mapping_v2.py b/api/src/domain/statement_mapping_v2.py index 619c390..227d7aa 100644 --- a/api/src/domain/statement_mapping_v2.py +++ b/api/src/domain/statement_mapping_v2.py @@ -133,7 +133,14 @@ def _lines_and_values( doc: StatementDocumentV2, source_columns: Sequence[StatementColumnV2], warnings: list[str] ) -> tuple[list[LineDraft], list[ValueDraft]]: known_columns = {column.column_index for column in source_columns} - row_by_ref = {line.ref: line.row_index for line in doc.lines} + # M-4: `ref` → `row_index` только по принятым строкам (первый вход побеждает) — родитель, на который указывает + # `parent_ref` повторённого `ref`, — первая принятая строка, а не пропущенный дубль; ссылка вперёд допустима + row_by_ref: dict[str, int] = {} + accepted_rows: set[int] = set() + for line in doc.lines: + if line.row_index not in accepted_rows and line.ref not in row_by_ref: + accepted_rows.add(line.row_index) + row_by_ref[line.ref] = line.row_index lines: list[LineDraft] = [] values: list[ValueDraft] = [] seen_rows: set[int] = set() diff --git a/api/src/domain/stored_form.py b/api/src/domain/stored_form.py index 4bdcd32..ff7806f 100644 --- a/api/src/domain/stored_form.py +++ b/api/src/domain/stored_form.py @@ -1,8 +1,8 @@ """Вход пересчёта формы ББ/ОФР из строк БД: сборка `StoredForm` пакета `arm_statements` (часть Б, B1). Единственное место ядра (вместе с `services/statements_engine.py`), где импортируется пакет. Здесь же все соглашения -границы: перечисления пакета — `StrEnum`, уверенности — `float | None`, `NULL` у `is_recognized` — `False`, -`period_kind = NULL` — `PeriodKind.NONE`, ключи строки и колонки — `str(uuid)`. +границы: перечисления пакета — `StrEnum`, уверенности — `float | None`, `NULL` у `is_recognized` — `True` (I-2: +сомнение только при `false`), `period_kind = NULL` — `PeriodKind.NONE`, ключи строки и колонки — `str(uuid)`. """ from collections.abc import Sequence @@ -61,7 +61,7 @@ def _stored_value(value: FinancialStatementValue) -> StoredValue: amount=value.amount, text=value.source_text, confidence=_confidence(value.confidence), - is_recognized=bool(value.is_recognized), + is_recognized=value.is_recognized is not False, # I-2: NULL (заглушки, ячейки правки) — не «не распознано» value_fixed=bool(value.is_modified) or bool(value.is_confirmed), ) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 4ee7802..a6ccb9a 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -81,7 +81,14 @@ 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_intake_v2 import FormV2, OtherV2, file_warnings, insert_forms_v2, map_statement_docs_v2 +from src.services.statement_intake_v2 import ( + FILE_WARNINGS_KEY, + FormV2, + OtherV2, + file_warnings, + insert_forms_v2, + map_statement_docs_v2, +) from src.services.statement_recompute import checks_on_intake_1x from src.services.statements_engine import StatementsEngine, default_engine @@ -486,7 +493,7 @@ async def _insert_statements( # noqa: PLR0913 - все входы именов 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) - checks_passed = await checks_on_intake_1x(session, document=document, meta=meta, engine=engine) + checks_passed = await checks_on_intake_1x(session, document=document, meta=meta, engine=engine, now=now) document.status = statement_status( job_done=job_done, checks_passed=checks_passed, @@ -697,7 +704,7 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай await _insert_forms(session, plan, job=job, result=result, job_done=job_done, now=now, engine=engine) job.group_schema = [_group_schema(sheet) for sheet in sheets] - job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings} + job.metrics = _mapped_metrics(job, result, warnings) await session.flush() if not await set_file_status_for_job( session, job, FileStatus.PROCESSED, now=now, only_from={FileStatus.PROCESSING} @@ -707,6 +714,17 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай return await _supersede_and_warn(session, job=job, file=file, documents=documents, warnings=warnings, now=now) +def _mapped_metrics( + job: RecognitionJob, result: RecognitionResult | RecognitionResultV2, warnings: list[str] +) -> dict[str, Any]: + """`metrics` задания после маппинга: предупреждения маппинга и, для результата `2.0`, предупреждения файла + результата отдельным ключом — единый источник «ошибки файла» для пересчёта после правки (P3-53, R-11).""" + metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings} + if isinstance(result, RecognitionResultV2): + metrics[FILE_WARNINGS_KEY] = file_warnings(result) + return metrics + + async def _supersede_and_warn( # noqa: PLR0913 - входы автозамены именованные session: AsyncSession, *, diff --git a/api/src/services/statement_intake_v2.py b/api/src/services/statement_intake_v2.py index f623e51..ea2352c 100644 --- a/api/src/services/statement_intake_v2.py +++ b/api/src/services/statement_intake_v2.py @@ -34,7 +34,19 @@ from src.repositories import statements as statements_repo from src.services.statements_engine import DocStatus, FormVariant, StatementsEngine from src.services.statements_engine import DocumentType as PackageDocumentType -__all__ = ["FormV2", "OtherV2", "file_warnings", "insert_forms_v2", "map_statement_docs_v2"] +__all__ = [ + "FILE_WARNINGS_KEY", + "FormV2", + "OtherV2", + "file_error_in_metrics", + "file_warnings", + "insert_forms_v2", + "map_statement_docs_v2", +] + +# P3-53 (R-11): предупреждения файла результата `2.0` (`result.metrics.warnings`) хранятся в `recognition_job.metrics` +# отдельным ключом при приёме результата; пересчёт после правки читает их оттуда, а не из `metrics.warnings` конверта +FILE_WARNINGS_KEY = "file_warnings" @dataclass(frozen=True) @@ -135,6 +147,16 @@ def file_warnings(result: RecognitionResultV2) -> list[dict[str, object]]: return [warning.model_dump(mode="json") for warning in result.metrics.warnings] +def file_error_in_metrics(metrics: object) -> bool: + """Единый источник «ошибка файла» для пересчёта (P3-53): `metrics.file_warnings` (результат `2.0`), при его + отсутствии — `metrics.warnings` конверта (результат `1.x` и задания до части Б).""" + if not isinstance(metrics, Mapping): + return False + if FILE_WARNINGS_KEY in metrics: + return has_error_warning(metrics[FILE_WARNINGS_KEY]) + return has_error_warning(metrics.get("warnings")) + + async def insert_forms_v2( # noqa: PLR0913 - все входы именованные session: AsyncSession, *, diff --git a/api/src/services/statement_recompute.py b/api/src/services/statement_recompute.py index 17b7aeb..67f3a33 100644 --- a/api/src/services/statement_recompute.py +++ b/api/src/services/statement_recompute.py @@ -7,6 +7,7 @@ """ from collections.abc import Mapping, Sequence +from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession @@ -28,7 +29,7 @@ from src.domain.statement_issues import ( version_issue, ) from src.domain.statement_lines import load_line_catalog -from src.domain.statement_status import duplicate_period_forms, form_status_v2, has_error_warning +from src.domain.statement_status import duplicate_period_forms, form_status_v2 from src.domain.stored_form import build_stored_form from src.models.enums import ( DocumentCheckResult, @@ -42,6 +43,7 @@ from src.models.enums import ( from src.models.files import Document, RecognitionJob from src.models.statements import FinancialStatementColumn, FinancialStatementMeta from src.repositories import statements as repo +from src.services.statement_intake_v2 import file_error_in_metrics from src.services.statements_engine import FailedRelation, StatementChecks, StatementsEngine __all__ = [ @@ -174,7 +176,7 @@ async def recompute_form( 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), + file_error_warning=file_error_in_metrics(job.metrics 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, @@ -189,6 +191,7 @@ async def checks_on_intake_1x( document: Document, meta: FinancialStatementMeta, engine: StatementsEngine, + now: datetime, ) -> bool: """§7 и вариант формы при приёме результата `1.x` (Р-30): один вызов `engine.recompute` по вставленной форме. Пишет `meta.form_variant` и записи `relation` / `soft` (`source = core`); `needs_review` значений и строк не @@ -200,5 +203,5 @@ async def checks_on_intake_1x( 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()) + await repo.add_issues(session, document.id, drafts, now=now) return not result.checks.relations_failed diff --git a/api/tests/test_domain_statement_mapping_v2.py b/api/tests/test_domain_statement_mapping_v2.py index cd7004c..04fd406 100644 --- a/api/tests/test_domain_statement_mapping_v2.py +++ b/api/tests/test_domain_statement_mapping_v2.py @@ -75,6 +75,35 @@ def test_detail_parent_row_index() -> None: assert any("r:missing" in warning for warning in draft.warnings) +def test_duplicate_row_index_or_ref_skipped_with_warning() -> None: + """T4.4: повтор `row_index` или `ref` — строка пропущена вместе со значениями, о повторе предупреждение (P3-9).""" + lines = [ + line_v2(0, "1210", "Запасы", ref="r:a", amount="1"), + line_v2(0, "1220", "Другая", ref="r:b", amount="2"), # тот же row_index + line_v2(1, "1230", "Третья", ref="r:a", amount="3"), # тот же ref + line_v2(2, "1240", "Четвёртая", ref="r:d", amount="4"), + ] + draft = build_statement_v2(_doc(lines=lines)) + assert [(line.row_index, line.line_code) for line in draft.lines] == [(0, "1210"), (2, "1240")] + assert [(v.row_index, v.amount) for v in draft.values] == [(0, Decimal(1)), (2, Decimal(4))] + assert len([w for w in draft.warnings if "повторяет прежнюю" in w]) == 2 # noqa: PLR2004 + + +def test_parent_ref_of_skipped_duplicate_points_to_first_accepted() -> None: + """M-4: `ref` пропущенного дубля не подменяет принятую строку — детализация ссылается на первую принятую; ссылка + вперёд (родитель позже в списке) допустима.""" + lines = [ + line_v2(0, "2110", "Выручка", ref="r:a"), + line_v2(5, "2120", "Дубль ref", ref="r:a"), # пропущена: ref уже принят + line_v2(1, None, "в том числе", status="detail", ref="r:b", parent_ref="r:a", code_confidence=None), + line_v2(2, None, "вперёд", status="detail", ref="r:c", parent_ref="r:later", code_confidence=None), + line_v2(3, "2200", "Позже", ref="r:later"), + ] + draft = build_statement_v2(_doc(document_type="INCOME", lines=lines)) + assert [(line.row_index, line.parent_row_index) for line in draft.lines] == [(0, None), (1, 0), (2, 3), (3, None)] + assert not any("не найден" in warning for warning in draft.warnings) + + def test_value_for_every_cell_and_empty_record() -> None: """Значение на каждую пару «строка × колонка»; отсутствующее — пустая запись с `needs_review = false`.""" columns = [column_v2(2), column_v2(3, date="2024-12-31")] diff --git a/api/tests/test_domain_stored_form.py b/api/tests/test_domain_stored_form.py index 258eed0..7d619ee 100644 --- a/api/tests/test_domain_stored_form.py +++ b/api/tests/test_domain_stored_form.py @@ -168,7 +168,15 @@ def test_stored_form_types() -> None: assert stored.line_code_confidence == CONFIDENCE assert type(stored.line_code_confidence) is float assert type(stored.values[0].confidence) is float - assert stored.values[0].is_recognized is False + assert stored.values[0].is_recognized is True # I-2: NULL — не сомнение, сомнение только `False` + explicit = make_value(line, column, "1", recognized=False) + assert ( + build_stored_form(make_document(), make_meta(), [column], [(line, [explicit])], []) + .lines[0] + .values[0] + .is_recognized + is False + ) assert form.columns[0].period_kind is PeriodKind.NONE assert form.columns[0].key == str(column.id) assert stored.line_status is LineStatus.OK diff --git a/api/tests/test_statements_v2_edit.py b/api/tests/test_statements_v2_edit.py new file mode 100644 index 0000000..93780ba --- /dev/null +++ b/api/tests/test_statements_v2_edit.py @@ -0,0 +1,295 @@ +"""Правка и пересчёт формы, принятой маппером 2.0 (fix-волна части Б: I-2, I-3, I-4, T4.2, T4.4, T6.1; P3-53). + +Форма загружается `map_file` с результатом `2.0` и реальным пакетом `arm_statements`, правится через +`PATCH …/statement`. Проверяется, что пересчёт заменяет только записи ядра (`relation` / `soft` и коды +`RECOMPUTED_CORE_CODES`), сохраняет записи сервиса и факты приёма и не меняет статус от несвязанной правки. +""" + +from http import HTTPStatus +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import DocumentIssueKind, DocumentStatus, IssueSeverity, IssueSource +from src.models.files import Document, RecognitionJob +from src.models.statements import FinancialStatementValue +from src.services.statement_intake_v2 import FILE_WARNINGS_KEY, file_error_in_metrics +from src.services.statements_engine import DocumentType as DocumentType_ +from src.services.statements_engine import default_engine +from tests.conftest import AuthClientFactory +from tests.factories_v2 import balance_lines, column_v2, header_v2, line_v2, statement_doc_v2 +from tests.test_api_statements import _cell +from tests.test_mapping_statements_v2 import ( + ERROR_JSON_FAILED, + KA_RF, + _confirm_clean, + _entity, + _issues, + _lines, + _map, + _meta, + _osv_doc, +) + +pytestmark = pytest.mark.integration + +TABLE_ERROR = {"code": "TABLE_PARSE_ERROR", "severity": "error", "message": "таблица разобрана с ошибкой"} + + +def _consistent_balance() -> dict[str, Any]: + """Баланс, у которого сходится и пересчёт ядра: 1310 = 1300 = 1700, 1210 = 1200 = 1600 (реальный пакет). Строка + 9999 вне соотношений §7 — её ячейку не «покрывает» ни одна проверка, поэтому `needs_review` зависит только от + `is_recognized` и уверенности (I-2).""" + lines = [*balance_lines(), line_v2(5, "1310", "Уставный капитал"), line_v2(6, "9999", "Прочая строка", amount="5")] + return statement_doc_v2(lines=lines) + + +async def _form(session: AsyncSession, *documents: dict[str, Any], **kwargs: Any) -> Document: + """Первая форма файла 2.0 владельца с доступом `ka_rf` (по умолчанию — `_consistent_balance`).""" + entity = await _entity(session, rostov=True) + return (await _map(session, entity, list(documents) or [_consistent_balance()], **kwargs))[0] + + +async def _patch_units(client: Any, document: Document, units: str = "млн руб.") -> None: + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": units}}) + assert response.status_code == HTTPStatus.OK, response.text + + +async def _stub_values(session: AsyncSession, document: Document) -> int: + """Заглушки маппера 1.x/2.0: `is_recognized = NULL` у всех значений формы; возвращает их число.""" + line_ids = [line.id for line in await _lines(session, document)] + stmt = select(FinancialStatementValue).where(FinancialStatementValue.line_id.in_(line_ids)) + values = list((await session.execute(stmt)).scalars()) + for value in values: # через ORM: значения уже в сессии теста, SQL-UPDATE пересчёт не увидел бы + value.is_recognized = None + await session.flush() + return len(values) + + +# --- I-2: NULL у is_recognized не сомнение --------------------------------------------------------------------------- + + +async def test_null_is_recognized_stub_does_not_need_review_after_header_edit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """I-2: после правки шапки значения с `is_recognized = NULL` (заглушки) не получают `needs_review`, форма остаётся + `RECOGNIZED`.""" + document = await _form(seeded) + assert document.status is DocumentStatus.RECOGNIZED + assert await _stub_values(seeded, document) > 0 + await _patch_units(await auth_client(KA_RF), document) + await seeded.refresh(document) + assert document.status is DocumentStatus.RECOGNIZED + values = (await seeded.execute(select(FinancialStatementValue))).scalars().all() + assert values and not any(value.needs_review for value in values) + assert not any(line.needs_review for line in await _lines(seeded, document)) + + +async def test_cell_filled_and_cleared_back_keeps_recognized( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """I-2: ячейка с `is_recognized = NULL` заполнена другой суммой и возвращена к исходной — `RECOGNIZED`.""" + document = await _form(seeded) + await _stub_values(seeded, document) + client = await auth_client(KA_RF) + line_id, column_id, original = await _cell(client, document, "1210", 2) + assert original is not None + url = f"/documents/{document.id}/statement" + changed = await client.patch(url, json={"values": [{"line_id": line_id, "column_id": column_id, "amount": "7"}]}) + assert changed.status_code == HTTPStatus.OK, changed.text + back = await client.patch(url, json={"values": [{"line_id": line_id, "column_id": column_id, "amount": original}]}) + assert back.status_code == HTTPStatus.OK, back.text + await seeded.refresh(document) + assert document.status is DocumentStatus.RECOGNIZED + assert not any(value.needs_review for value in (await seeded.execute(select(FinancialStatementValue))).scalars()) + + +# --- I-3 (R-11, P3-53): единый источник «ошибка файла» -------------------------------------------------------------- + + +def test_file_error_in_metrics_prefers_file_warnings_and_falls_back() -> None: + error = [{"code": "X", "severity": "error", "message": "m"}] + assert file_error_in_metrics({FILE_WARNINGS_KEY: error}) is True + assert file_error_in_metrics({FILE_WARNINGS_KEY: [], "warnings": error}) is False # приоритет у ключа приёма 2.0 + assert file_error_in_metrics({"warnings": error}) is True # результат 1.x и задания до части Б + assert file_error_in_metrics({"warnings": ["строка 1.x"]}) is False + assert file_error_in_metrics(None) is False and file_error_in_metrics({}) is False + + +async def test_file_error_warning_is_saved_and_holds_needs_check_after_edit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """P3-53: предупреждение файла результата `error` сохраняется в `metrics.file_warnings`, форма `NEEDS_CHECK`, и + несвязанная правка шапки статус не меняет (конверт `metrics.warnings` в тесте пуст — источник один).""" + document = await _form(seeded, file_warnings=[ERROR_JSON_FAILED]) + job = await seeded.get(RecognitionJob, (await _meta(seeded, document)).recognition_job_id) + assert job is not None and job.metrics is not None + assert [w["code"] for w in job.metrics[FILE_WARNINGS_KEY]] == ["JSON_FAILED"] + assert document.status is DocumentStatus.NEEDS_CHECK + await _patch_units(await auth_client(KA_RF), document) + await seeded.refresh(document) + assert document.status is DocumentStatus.NEEDS_CHECK + + +async def test_info_file_warning_keeps_recognized_after_edit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + info = {"code": "SOMETHING", "severity": "info", "message": "к сведению"} + document = await _form(seeded, file_warnings=[info]) + await _patch_units(await auth_client(KA_RF), document) + await seeded.refresh(document) + assert document.status is DocumentStatus.RECOGNIZED + + +# --- I-4: правка и акцепт формы 2.0 ---------------------------------------------------------------------------------- + + +async def test_service_relations_replaced_by_core_after_edit( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """I-4: записи `relation` / `soft` источника `service` после первой правки заменяются записями `core` (суммы + сходятся — нарушений ядра нет), warning сервиса уровня `error` остаётся и держит `NEEDS_CHECK`.""" + checks = { + "relations_failed": [ + {"column_index": 2, "relation": "1600 = 1700", "codes": ["1600", "1700"], "expected": "1", "actual": "2"} + ], + "soft_relations_failed": [ + {"column_index": 2, "relation": "1210 = 1200", "codes": ["1210", "1200"], "expected": "1", "actual": "2"} + ], + } + doc = {**_consistent_balance(), "status": "needs_check", "warnings": [TABLE_ERROR], "checks": checks} + document = await _form(seeded, doc) + before = await _issues(seeded, document) + service_checks = [i for i in before if i.kind in (DocumentIssueKind.relation, DocumentIssueKind.soft)] + assert len(service_checks) == 2 and all(i.source is IssueSource.service for i in service_checks) # noqa: PLR2004 + await _patch_units(await auth_client(KA_RF), document) + after = await _issues(seeded, document) + assert not [i for i in after if i.kind in (DocumentIssueKind.relation, DocumentIssueKind.soft)] + kept = [i for i in after if i.code == "TABLE_PARSE_ERROR"] + assert len(kept) == 1 and kept[0].source is IssueSource.service and kept[0].severity is IssueSeverity.error + await seeded.refresh(document) + assert document.status is DocumentStatus.NEEDS_CHECK + + +async def test_result_status_inconsistent_survives_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """I-4: `RESULT_STATUS_INCONSISTENT` — факт приёма, правка шапки его не удаляет; форма остаётся `NEEDS_CHECK`.""" + lines = [*balance_lines(), line_v2(9, None, "Строка", status="unreadable", amount="50", code_text="???")] + document = await _form(seeded, statement_doc_v2(lines=lines, status="done")) + assert [i.code for i in await _issues(seeded, document) if i.code == "RESULT_STATUS_INCONSISTENT"] + await _patch_units(await auth_client(KA_RF), document) + assert len([i for i in await _issues(seeded, document) if i.code == "RESULT_STATUS_INCONSISTENT"]) == 1 + await seeded.refresh(document) + assert document.status is DocumentStatus.NEEDS_CHECK + + +async def test_recognized_form_stays_recognized_after_header_edit_and_accept( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """I-4: статус `RECOGNIZED` формы 2.0 не меняется от правки шапки (units); акцепт без нарушений — 409.""" + document = await _form(seeded) + assert document.status is DocumentStatus.RECOGNIZED + client = await auth_client(KA_RF) + await _patch_units(client, document) + await seeded.refresh(document) + assert document.status is DocumentStatus.RECOGNIZED + nothing = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "проверено"}) + assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT" + + +async def test_accept_form_with_core_violation_after_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """I-4: у формы 2.0 с нарушением, найденным ядром после правки (1600 ≠ 1700), акцепт возможен; запись `relation` + (`core`) остаётся, форма подтверждается (`CHECKS_NOT_PASSED` снят акцептом).""" + lines = [*balance_lines(), line_v2(5, "1310", "Уставный капитал", amount="50")] + document = await _form(seeded, statement_doc_v2(lines=lines)) + client = await auth_client(KA_RF) + await _patch_units(client, document) + assert [i for i in await _issues(seeded, document) if i.kind is DocumentIssueKind.relation] + await seeded.refresh(document) + assert document.status is DocumentStatus.NEEDS_CHECK + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" + accepted = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "проверено"}) + assert accepted.status_code == HTTPStatus.OK, accepted.text + assert [i for i in await _issues(seeded, document) if i.kind is DocumentIssueKind.relation] + confirmed = await client.post(f"/documents/{document.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK, confirmed.text + + +# --- T4.4: ветки приёма 2.0 ------------------------------------------------------------------------------------------- + + +async def test_intake_duplicate_row_and_missing_parent_warnings(seeded: AsyncSession) -> None: + """T4.4: дубль `row_index` / `ref` пропущен, родитель не найден — предупреждения маппинга (`MAPPING_WARNING`).""" + lines = [ + *balance_lines(), + line_v2(0, "1230", "Дубль строки 0"), + line_v2(20, None, "сирота", status="detail", ref="r:orphan", parent_ref="r:none", code_confidence=None), + ] + document = await _form(seeded, statement_doc_v2(lines=lines)) + messages = [i.message for i in await _issues(seeded, document) if i.code == "MAPPING_WARNING"] + assert any("повторяет прежнюю" in m for m in messages) and any("r:none" in m for m in messages) + assert len(await _lines(seeded, document)) == len(balance_lines()) + 1 # дубль не сохранён, сирота — да + + +async def test_intake_statements_version_mismatch(seeded: AsyncSession) -> None: + """T4.4: версия пакета сервиса отличается от версии копии ядра — `STATEMENTS_VERSION_MISMATCH` (`info`, `core`).""" + document = await _form(seeded, statements_version="0000000") + mismatch = [i for i in await _issues(seeded, document) if i.code == "STATEMENTS_VERSION_MISMATCH"] + assert len(mismatch) == 1 and mismatch[0].severity is IssueSeverity.info and mismatch[0].source is IssueSource.core + document_ok = await _form_again_same_version(seeded) + assert not [i for i in await _issues(seeded, document_ok) if i.code == "STATEMENTS_VERSION_MISMATCH"] + + +async def _form_again_same_version(session: AsyncSession) -> Document: + entity = await _entity(session, inn="7711111111", rostov=True) + return (await _map(session, entity, [statement_doc_v2()], statements_version=default_engine().version))[0] + + +async def test_intake_disclosure_with_error_warning_is_recognized(seeded: AsyncSession) -> None: + """T4.4 (= P3-41, вопрос владельцу): `DISCLOSURE` с предупреждением `error` — один документ `RECOGNIZED`, запись + предупреждения сохранена.""" + disclosure = statement_doc_v2("DISCLOSURE", lines=[], columns=[], form_variant=None, warnings=[TABLE_ERROR]) + entity = await _entity(seeded, rostov=True) + balance, other = await _map(seeded, entity, [statement_doc_v2(), disclosure]) + assert other.status is DocumentStatus.RECOGNIZED and balance.status is DocumentStatus.RECOGNIZED + (issue,) = await _issues(seeded, other) + assert (issue.code, issue.severity) == ("TABLE_PARSE_ERROR", IssueSeverity.error) + + +async def test_intake_edition_none_has_no_code_issues_but_name_mismatch(seeded: AsyncSession) -> None: + """T4.4: редакция не определена (`edition is None`) — `CODE_NOT_IN_*` не пишутся, `NAME_MISMATCH` остаётся.""" + # коды общие для обеих редакций и дата неизвестна — ничья без даты, редакцию выбрать нельзя + names = [("1200", "Итого по разделу I"), ("1600", "БАЛАНС")] + lines = [line_v2(i, code, name, code_confidence=50) for i, (code, name) in enumerate(names)] + undated = statement_doc_v2( + lines=lines, + header=header_v2(report_date=None, report_date_text=None, report_date_confidence=None), + columns=[column_v2(2, date=None, kind="none", label="Показатель")], + ) + document = await _form(seeded, undated) + pairs = [(code, False) for code, _ in names] + assert default_engine().edition(DocumentType_.BALANCE, pairs, None, None) is None + assert document.reporting_date is None + codes = {i.code for i in await _issues(seeded, document)} + assert "NAME_MISMATCH" in codes + assert not codes & {"CODE_NOT_IN_DICTIONARY", "CODE_NOT_IN_EDITION"} + + +# --- T6.1: предупреждение листа ОСВ уровня error (P3-49) ------------------------------------------------------------ + + +async def test_osv_v2_sheet_warning_error_does_not_block(seeded: AsyncSession) -> None: + """T6.1 (P3-49): предупреждение листа ОСВ `severity = error` при `done` листа и сошедшихся суммах подтверждение не + блокирует — решает статус листа и ошибка файла, а не уровень предупреждения листа.""" + entity = await _entity(seeded) + osv = _osv_doc() + osv["warnings"] = [{"code": "OSV_HIERARCHY_BY_OUTLINE", "severity": "error", "message": "иерархия по outline"}] + (document,) = await _map(seeded, entity, [osv]) + assert document.status is DocumentStatus.RECOGNIZED + issue = (await _issues(seeded, document))[0] + assert issue.code == "OSV_HIERARCHY_BY_OUTLINE" and issue.severity is IssueSeverity.error + assert issue.source is IssueSource.service + await _confirm_clean(seeded, [document]) + assert document.status is DocumentStatus.CONFIRMED