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:
parent
e944565e55
commit
e6292a24df
19
CLAUDE.md
19
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` +
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
*,
|
||||
|
||||
@ -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,
|
||||
*,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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
|
||||
|
||||
295
api/tests/test_statements_v2_edit.py
Normal file
295
api/tests/test_statements_v2_edit.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user