fix(statements): fix-волна части Б — is_recognized NULL, единый источник ошибки файла, тесты правки форм 2.0

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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 22:38:24 +04:00
parent e944565e55
commit e6292a24df
10 changed files with 415 additions and 17 deletions

View File

@ -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` +

View File

@ -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`.

View File

@ -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()

View File

@ -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),
)

View File

@ -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,
*,

View File

@ -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,
*,

View File

@ -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

View File

@ -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")]

View File

@ -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

View File

@ -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