From 028512603a1b14fd7a7ef1a5362ef9f106ee8193 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 21:15:21 +0400 Subject: [PATCH] =?UTF-8?q?fix(statements):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20B1+B2=20?= =?UTF-8?q?=E2=80=94=20T2.1,=20T1.1,=20T1.2,=20T2.4,=20T2.6,=20T2.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.1: аудит lines[] включает итоговую смену parent_row_index после пересчёта. T1.1: тест паритета редакции приёма и пересчёта для строки с confidence < 100 и code_fixed. T1.2: edition_codes пропускает строку ok без кода. T2.4: общий issue_column_index для ответа формы и detail отказа подтверждения. T2.6: удалено дублирование needs_review в statement_line_edits. T2.8: неизвестный relative:* не даёт 500 (адаптер stored_form и _resolve_relative_columns). Co-Authored-By: Claude Opus 5.5 --- api/src/domain/stored_form.py | 12 ++++- api/src/schemas/statements.py | 12 +++-- api/src/services/statement_line_edits.py | 39 +++++++++------ api/src/services/statements.py | 14 ++++-- api/tests/test_api_statement_lines.py | 30 ++++++++++++ api/tests/test_api_statements.py | 20 ++++++++ api/tests/test_statements_engine.py | 48 +++++++++++++++++++ .../unified-normalization-core-b/design.md | 2 + 8 files changed, 155 insertions(+), 22 deletions(-) diff --git a/api/src/domain/stored_form.py b/api/src/domain/stored_form.py index b6548b9..4bdcd32 100644 --- a/api/src/domain/stored_form.py +++ b/api/src/domain/stored_form.py @@ -38,12 +38,20 @@ def _confidence(value: object) -> float | None: return None if value is None else float(value) # type: ignore[arg-type] +def _period_kind(raw: str | None) -> PeriodKind: + """Вид периода колонки; неизвестное значение (в том числе `relative:*` вне перечня пакета) — `NONE` (T2.8).""" + try: + return PeriodKind(raw) if raw else PeriodKind.NONE + except ValueError: + return PeriodKind.NONE + + def _stored_column(column: FinancialStatementColumn) -> StoredColumn: return StoredColumn( key=str(column.id), column_index=column.column_index, period_date=column.period_date, - period_kind=PeriodKind(column.period_kind) if column.period_kind else PeriodKind.NONE, + period_kind=_period_kind(column.period_kind), ) @@ -123,4 +131,4 @@ def build_stored_form( def edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]: """Пары `(line_code, уверенный)` строк `ok` результата по `row_index` — вход `engine.edition`.""" ok_lines = sorted((ln for ln in doc.lines if ln.line_status == "ok"), key=lambda ln: ln.row_index) - return [(str(ln.line_code), ln.line_code_confidence == _SURE) for ln in ok_lines] + return [(ln.line_code, ln.line_code_confidence == _SURE) for ln in ok_lines if ln.line_code is not None] diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index a53b520..6441b49 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -197,11 +197,17 @@ def _failed(check: FailedCheck, aggregates: Mapping[str, CodeAggregate]) -> Fail ) +def issue_column_index(issue: DocumentIssue, column_index: Mapping[uuid.UUID, int]) -> int | None: + """Индекс колонки записи: `issue.column_index`, а при `NULL` — по `column_id` через колонки формы (T2.4); + общий для ответа формы и `detail` отказа подтверждения.""" + if issue.column_id is not None and issue.column_id in column_index: + return column_index[issue.column_id] + return issue.column_index + + def _failed_issue(issue: DocumentIssue, column_index: Mapping[uuid.UUID, int]) -> FailedCheckOut: """Нарушение §7 из записи `document_issue` (`relation` / `soft`): `codes` и `line_ids` — из записи.""" - index = issue.column_index - if issue.column_id is not None and issue.column_id in column_index: - index = column_index[issue.column_id] + index = issue_column_index(issue, column_index) codes = issue.codes if isinstance(issue.codes, list) else [] line_ids = issue.line_ids if isinstance(issue.line_ids, list) else [] return FailedCheckOut( diff --git a/api/src/services/statement_line_edits.py b/api/src/services/statement_line_edits.py index 5ba24ca..e9a7576 100644 --- a/api/src/services/statement_line_edits.py +++ b/api/src/services/statement_line_edits.py @@ -12,7 +12,6 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from src.common.exceptions import ConflictError, DomainError, DomainValidationError -from src.domain.statement_mapping import FULL_CONFIDENCE from src.models.enums import StatementLineStatus from src.models.files import Document from src.models.statements import FinancialStatementLine, FinancialStatementMeta @@ -67,16 +66,6 @@ def _check(items: Sequence[LinePatch], by_id: dict[uuid.UUID, FinancialStatement raise _bad(INVALID_LINE_PATCH, "подтверждать можно только код строки со статусом ok", item) -def _unconfirmed_needs_review(line: FinancialStatementLine) -> bool: - """`needs_review` строки без подтверждения: код есть, не назначен аналитиком и распознан неуверенно.""" - return ( - line.line_status is StatementLineStatus.ok - and not line.line_code_modified - and line.line_code_confidence is not None - and line.line_code_confidence < FULL_CONFIDENCE - ) - - def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool: """Применить действие элемента к строке; `True` — изменены данные (код, статус), а не подтверждение.""" if item.line_code is not None: @@ -84,7 +73,6 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool: return False # тот же код у строки `ok` — менять нечего line.line_code, line.line_status = item.line_code, StatementLineStatus.ok line.line_code_confidence, line.line_code_modified = ASSIGNED_CONFIDENCE, True - line.needs_review = False line.is_confirmed = False # M-4: подтверждение относилось к прежнему коду line.parent_row_index = None # M-4: у строки `ok` родителя нет (был у `detail`) return True @@ -92,15 +80,38 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool: if line.line_status is StatementLineStatus.detail: return False line.line_code, line.line_status = None, StatementLineStatus.detail - line.needs_review = False line.is_confirmed = False # M-4: у расшифровки кода нет — подтверждать нечего return True if item.confirmed is not None: line.is_confirmed = item.confirmed - line.needs_review = False if item.confirmed else _unconfirmed_needs_review(line) return False +async def line_parents(session: AsyncSession, meta: FinancialStatementMeta) -> dict[uuid.UUID, int | None]: + """`parent_row_index` строк формы — снимок до правки для `merge_parent_changes` (T2.1).""" + return {line.id: line.parent_row_index for line in await repo.form_lines(session, meta.id)} + + +async def merge_parent_changes( + session: AsyncSession, + meta: FinancialStatementMeta, + old: dict[uuid.UUID, int | None], + before: dict[str, Any], + after: dict[str, Any], +) -> None: + """После пересчёта (он назначает родителей расшифровок) довнести в `before/after` итоговую смену + `parent_row_index`: у строк правки значение заменяется итоговым, у остальных строк появляется запись + `line:` с одним ключом (T2.1).""" + for line in await repo.form_lines(session, meta.id): + if line.id not in old or old[line.id] == line.parent_row_index: + continue + key = f"line:{line.id}" + if key in after and after[key] is None: + continue + before.setdefault(key, {})["parent_row_index"] = old[line.id] + after.setdefault(key, {})["parent_row_index"] = line.parent_row_index + + async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13: документ, шапка, элементы, пользователь, аудит session: AsyncSession, *, diff --git a/api/src/services/statements.py b/api/src/services/statements.py index c6fcd91..03a770f 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -52,7 +52,7 @@ from src.services.documents import ( FILE_SUPERSEDED, PERIOD_REQUIRED, ) -from src.services.statement_line_edits import apply_line_edits +from src.services.statement_line_edits import apply_line_edits, line_parents, merge_parent_changes from src.services.statement_recompute import recompute_form from src.services.statements_engine import PeriodKind, StatementsEngine, default_engine @@ -131,13 +131,14 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia ) failures = await statement_hard_failures(session, document, meta) if failures: + by_column = {column.id: column.column_index for column in (await repo.load_form(session, meta.id))[0]} return FormBlocker( CHECKS_NOT_PASSED, "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", { "failures": [ { - "column_index": failure.column_index, + "column_index": schemas.issue_column_index(failure, by_column), "relation": failure.relation, "expected": None if failure.expected is None else str(failure.expected), "actual": None if failure.actual is None else str(failure.actual), @@ -460,7 +461,11 @@ class StatementService: for column in (await repo.load_form(self._session, meta.id))[0]: if column.period_kind is None or not str(column.period_kind).startswith("relative:"): continue - resolved = self._engine.resolve_relative(PeriodKind(str(column.period_kind)), meta.report_date) + try: + kind = PeriodKind(str(column.period_kind)) + except ValueError: + continue # T2.8: `relative:*` вне перечня пакета — колонка не пересчитывается (не 500) + resolved = self._engine.resolve_relative(kind, meta.report_date) if resolved != column.period_date: edit.record(f"column:{column.id}", column.period_date, resolved, data=False) column.period_date = resolved @@ -491,6 +496,7 @@ class StatementService: raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") await self._check_columns(meta, body.columns) edit = _Edit({}, {}) + parents_before = await line_parents(self._session, meta) if body.lines else {} if await apply_line_edits( self._session, document=document, @@ -521,6 +527,8 @@ class StatementService: meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None await self._session.flush() await self.recompute(document, meta) + if body.lines: # T2.1: родителей расшифровок пишет пересчёт — их итоговая смена попадает в аудит + await merge_parent_changes(self._session, meta, parents_before, edit.before, edit.after) await self._audit.record( action=AuditAction.RECOGNIZED_DATA_EDIT, section=AuditSection.RECOGNITION, diff --git a/api/tests/test_api_statement_lines.py b/api/tests/test_api_statement_lines.py index c1ce13f..a297946 100644 --- a/api/tests/test_api_statement_lines.py +++ b/api/tests/test_api_statement_lines.py @@ -286,6 +286,36 @@ async def test_parent_moves_up(seeded: AsyncSession, fixtures_dir: Path, auth_cl assert all(s.parent_row_index is None for s in stored if s) +async def test_audit_includes_parent_changes_from_recompute( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """T2.1: родителей расшифровок назначает пересчёт — итоговая смена `parent_row_index` есть в аудите.""" + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + for existing in ( + await seeded.execute(select(FinancialStatementLine).where(FinancialStatementLine.meta_id == meta.id)) + ).scalars(): + existing.row_index = None if existing.row_index is None else existing.row_index + 100 + await insert_line(seeded, document, code="1140", row_index=3) + parent = await insert_line(seeded, document, code="1150", row_index=4) + details = [await insert_line(seeded, document, code=None, row_index=index, status="detail") for index in (5, 6)] + for item in details: + item.parent_row_index = 4 + client = await auth_client(KA_RF) + response = await _patch(client, document, {"lines": [{"line_id": str(parent.id), "line_status": "detail"}]}) + assert response.status_code == HTTPStatus.OK, response.text + (event,) = await _edit_events(seeded, document) + for item in details: # не правились, родитель сменился пересчётом 4 -> 3 + key = f"line:{item.id}" + assert event.before_value[key] == {"parent_row_index": 4} + assert event.after_value[key] == {"parent_row_index": 3} + assert event.after_value[f"line:{parent.id}"] == event.after_value[f"line:{details[0].id}"] | { + "line_code": None, + "line_status": "detail", + "is_confirmed": False, + } + + async def test_line_edit_audit_and_accept( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 23e22f5..a96b59f 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -2486,6 +2486,24 @@ async def test_relative_column_follows_report_date( assert fresh[1].period_date is None and fresh[2].period_date == datetime.date(2024, 6, 30) +async def test_unknown_relative_kind_is_skipped( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """T2.8: `relative:*` вне перечня пакета — правка даты отчёта не падает, колонка не пересчитывается.""" + document = await form(seeded, fixtures_dir) + columns = await _db_columns(seeded, document) + unknown, known = columns[1], columns[2] + unknown.period_kind = "relative:unknown_kind" + known.period_kind = "relative:prev_year_end" + before = unknown.period_date + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2026-03-31"}}) + assert response.status_code == HTTPStatus.OK, response.text + fresh = await _db_columns(seeded, document) + assert fresh[1].period_date == before and fresh[2].period_date == datetime.date(2025, 12, 31) + + async def test_checks_from_issues_with_codes_and_line_ids( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: @@ -2519,6 +2537,8 @@ async def test_checks_from_issues_with_codes_and_line_ids( blocked = await client.post(f"/documents/{document.id}/confirm") assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" assert blocked.json()["detail"]["failures"][0]["expected"] is None + # T2.4: `column_index` записи пуст, колонка известна по `column_id` — индекс один и в ответе формы, и в detail + assert failed["column_index"] == 0 and blocked.json()["detail"]["failures"][0]["column_index"] == 0 async def test_one_patch_one_recompute( diff --git a/api/tests/test_statements_engine.py b/api/tests/test_statements_engine.py index a5a3723..39f6465 100644 --- a/api/tests/test_statements_engine.py +++ b/api/tests/test_statements_engine.py @@ -56,6 +56,54 @@ def test_edition_codes_and_engine_edition() -> None: assert ENGINE.recompute(form).edition == edition +@pytest.mark.parametrize(("modified", "confirmed"), [(False, False), (True, False), (False, True)]) +def test_edition_parity_for_unsure_fixed_code(modified: bool, confirmed: bool) -> None: + """T1.1: строка `ok` с `line_code_confidence < 100` и `code_fixed` (назначенная / подтверждённая) — редакция + приёма (`edition_codes` + `engine.edition`) совпадает с редакцией пересчёта из БД.""" + doc = StatementDocumentV2.model_validate(_documents("result-statement-minimal.json")[0]) + first_ok = next(ln for ln in doc.lines if ln.line_status == "ok") + doc = doc.model_copy( + update={ + "lines": [ + ln.model_copy(update={"line_code_confidence": Decimal(50)}) if ln is first_ok else ln + for ln in doc.lines + ] + } + ) + unsure = next(ln for ln in doc.lines if ln.line_status == "ok") + effective = datetime.date(2025, 9, 30) + edition = ENGINE.edition(DocumentType.BALANCE, edition_codes(doc), effective, FormVariant.FULL) + + column = make_column() + lines = [] + for ln in doc.lines: + fixed = ln is unsure + line = make_line( + ln.line_code, + row_index=ln.row_index, + status=StatementLineStatus(ln.line_status), + confidence=None if ln.line_code_confidence is None else str(ln.line_code_confidence), + modified=modified and fixed, + confirmed=confirmed and fixed, + ) + lines.append((line, [make_value(line, column, v.amount, confidence=str(v.confidence)) for v in ln.values])) + form = build_stored_form(make_document(effective), make_meta(organization_name="ООО"), [column], lines, []) + assert ENGINE.recompute(form).edition == edition + + +def test_edition_codes_skip_ok_line_without_code() -> None: + """T1.2: строка `ok` без кода не даёт пару `("None", …)`.""" + doc = StatementDocumentV2.model_validate(_documents("result-statement-minimal.json")[0]) + ok_line = next(ln for ln in doc.lines if ln.line_status == "ok") + count = len(edition_codes(doc)) + doc = doc.model_copy( + update={"lines": [ln.model_copy(update={"line_code": None}) if ln is ok_line else ln for ln in doc.lines]} + ) + pairs = edition_codes(doc) + assert len(pairs) == count - 1 + assert all(code != "None" for code, _ in pairs) + + def test_engine_scenario_recompute_after_edit() -> None: column = make_column() effective = datetime.date(2025, 9, 30) diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 5ea8ff0..bc17ea6 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -45,3 +45,5 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-34 (B2).** `NAME_MISMATCH` пересчитывается для всех кодов (R-1 ограничивает только `CODE_NOT_IN_*`); `CODE_NOT_IN_*` не пишутся, если пакет не определил редакцию (а `CODE_NOT_IN_EDITION` — и вариант); словарь имён берётся по редакции пакета, не по дате | R-1 и спека (редакция документа — `edition` пакета) | пятизначная подстрока с уверенностью < 100 даёт `NAME_MISMATCH` (кода нет в словаре) — если поток окажется шумным, ограничить `name_mismatches` четырёхзначными | | 2026-10-18 | **P3-35 (B2).** Временный код A12/A13 удалён: тело `StatementService.recompute`, `settle_edited_values`, `has_review_values`, `has_source_lines`, `_value_needs_review`, `nearest_ok_parents` и `_recompute_parents`; `parent_row_index` `detail` пишет только пересчёт пакета | задание B2; T13.3 (флаги detail больше не затираются навсегда) | нет | | 2026-10-18 | **P3-36 (B3).** `checks_on_intake_1x` живёт в `statement_recompute.py`; движок передаётся в `_insert_statements` необязательным параметром (по умолчанию `default_engine()`), `map_file` сигнатуру не меняет. Приём `1.x` пишет `meta.form_variant` и записи `relation` / `soft` (`core`), но не `CODE_NOT_IN_*` / `NAME_MISMATCH` (P3-7) и не трогает `needs_review` (A9); условие D-6 «проверки сошлись» — `not result.checks.relations_failed` (нарушение с `expected = NULL` — жёсткое) | задание B3, спека «Пересчёт при приёме»; расхождение с прежним `run_checks` (его `failed`) возможно только на формах с пустой целью, где пакет строже | форма `1.x` с пустой целью получит `NEEDS_CHECK` вместо `RECOGNIZED` | +| 2026-10-18 | **P3-37 (fix1, T2.1).** Аудит `lines[]` довносит смену `parent_row_index` после пересчёта: у правленных строк значение заменяется итоговым, у остальных появляется запись `line:` с одним ключом `parent_row_index` (`{"parent_row_index": старый}` → `{…: новый}`) | родителей пишет `recompute_form` уже после снимка строк правки; полный снимок для не правленных строк не нужен | в событии `RECOGNIZED_DATA_EDIT` у не правленных строк неполный набор ключей | +| 2026-10-18 | **P3-38 (fix1, T1.1/T1.2/T2.4/T2.6/T2.8).** Паритет редакции приёма и пересчёта для строки с `line_code_confidence < 100` и `code_fixed` подтверждён тестом (расхождения нет, код не менялся); `edition_codes` пропускает строку `ok` без кода; `column_index` нарушения — общим помощником `issue_column_index` (запись, иначе колонка по `column_id`) в ответе и в `detail` отказа; `_unconfirmed_needs_review` и присвоения `needs_review` в `_apply_item` удалены (флаг пишет только пересчёт, он всегда идёт после правки строк); неизвестный `relative:*` в `_resolve_relative_columns` пропускается без ошибки | замечания ревью B1+B2 | колонка с неизвестным `relative:*` не следует за датой отчёта |