fix(statements): правки ревью B1+B2 — T2.1, T1.1, T1.2, T2.4, T2.6, T2.8

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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 21:15:21 +04:00
parent 02630e830f
commit 028512603a
8 changed files with 155 additions and 22 deletions

View File

@ -38,12 +38,20 @@ def _confidence(value: object) -> float | None:
return None if value is None else float(value) # type: ignore[arg-type] 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: def _stored_column(column: FinancialStatementColumn) -> StoredColumn:
return StoredColumn( return StoredColumn(
key=str(column.id), key=str(column.id),
column_index=column.column_index, column_index=column.column_index,
period_date=column.period_date, 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]]: def edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]:
"""Пары `(line_code, уверенный)` строк `ok` результата по `row_index` — вход `engine.edition`.""" """Пары `(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) 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]

View File

@ -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: def _failed_issue(issue: DocumentIssue, column_index: Mapping[uuid.UUID, int]) -> FailedCheckOut:
"""Нарушение §7 из записи `document_issue` (`relation` / `soft`): `codes` и `line_ids` — из записи.""" """Нарушение §7 из записи `document_issue` (`relation` / `soft`): `codes` и `line_ids` — из записи."""
index = issue.column_index index = issue_column_index(issue, column_index)
if issue.column_id is not None and issue.column_id in column_index:
index = column_index[issue.column_id]
codes = issue.codes if isinstance(issue.codes, list) else [] codes = issue.codes if isinstance(issue.codes, list) else []
line_ids = issue.line_ids if isinstance(issue.line_ids, list) else [] line_ids = issue.line_ids if isinstance(issue.line_ids, list) else []
return FailedCheckOut( return FailedCheckOut(

View File

@ -12,7 +12,6 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import ConflictError, DomainError, DomainValidationError 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.enums import StatementLineStatus
from src.models.files import Document from src.models.files import Document
from src.models.statements import FinancialStatementLine, FinancialStatementMeta 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) 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: def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool:
"""Применить действие элемента к строке; `True` — изменены данные (код, статус), а не подтверждение.""" """Применить действие элемента к строке; `True` — изменены данные (код, статус), а не подтверждение."""
if item.line_code is not None: if item.line_code is not None:
@ -84,7 +73,6 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool:
return False # тот же код у строки `ok` — менять нечего return False # тот же код у строки `ok` — менять нечего
line.line_code, line.line_status = item.line_code, StatementLineStatus.ok line.line_code, line.line_status = item.line_code, StatementLineStatus.ok
line.line_code_confidence, line.line_code_modified = ASSIGNED_CONFIDENCE, True line.line_code_confidence, line.line_code_modified = ASSIGNED_CONFIDENCE, True
line.needs_review = False
line.is_confirmed = False # M-4: подтверждение относилось к прежнему коду line.is_confirmed = False # M-4: подтверждение относилось к прежнему коду
line.parent_row_index = None # M-4: у строки `ok` родителя нет (был у `detail`) line.parent_row_index = None # M-4: у строки `ok` родителя нет (был у `detail`)
return True return True
@ -92,15 +80,38 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool:
if line.line_status is StatementLineStatus.detail: if line.line_status is StatementLineStatus.detail:
return False return False
line.line_code, line.line_status = None, StatementLineStatus.detail line.line_code, line.line_status = None, StatementLineStatus.detail
line.needs_review = False
line.is_confirmed = False # M-4: у расшифровки кода нет — подтверждать нечего line.is_confirmed = False # M-4: у расшифровки кода нет — подтверждать нечего
return True return True
if item.confirmed is not None: if item.confirmed is not None:
line.is_confirmed = item.confirmed line.is_confirmed = item.confirmed
line.needs_review = False if item.confirmed else _unconfirmed_needs_review(line)
return False 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:<id>` с одним ключом (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: документ, шапка, элементы, пользователь, аудит async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13: документ, шапка, элементы, пользователь, аудит
session: AsyncSession, session: AsyncSession,
*, *,

View File

@ -52,7 +52,7 @@ from src.services.documents import (
FILE_SUPERSEDED, FILE_SUPERSEDED,
PERIOD_REQUIRED, 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.statement_recompute import recompute_form
from src.services.statements_engine import PeriodKind, StatementsEngine, default_engine 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) failures = await statement_hard_failures(session, document, meta)
if failures: if failures:
by_column = {column.id: column.column_index for column in (await repo.load_form(session, meta.id))[0]}
return FormBlocker( return FormBlocker(
CHECKS_NOT_PASSED, CHECKS_NOT_PASSED,
"не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием",
{ {
"failures": [ "failures": [
{ {
"column_index": failure.column_index, "column_index": schemas.issue_column_index(failure, by_column),
"relation": failure.relation, "relation": failure.relation,
"expected": None if failure.expected is None else str(failure.expected), "expected": None if failure.expected is None else str(failure.expected),
"actual": None if failure.actual is None else str(failure.actual), "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]: 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:"): if column.period_kind is None or not str(column.period_kind).startswith("relative:"):
continue 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: if resolved != column.period_date:
edit.record(f"column:{column.id}", column.period_date, resolved, data=False) edit.record(f"column:{column.id}", column.period_date, resolved, data=False)
column.period_date = resolved column.period_date = resolved
@ -491,6 +496,7 @@ class StatementService:
raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр")
await self._check_columns(meta, body.columns) await self._check_columns(meta, body.columns)
edit = _Edit({}, {}) edit = _Edit({}, {})
parents_before = await line_parents(self._session, meta) if body.lines else {}
if await apply_line_edits( if await apply_line_edits(
self._session, self._session,
document=document, document=document,
@ -521,6 +527,8 @@ class StatementService:
meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None
await self._session.flush() await self._session.flush()
await self.recompute(document, meta) 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( await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT, action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION, section=AuditSection.RECOGNITION,

View File

@ -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) 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( async def test_line_edit_audit_and_accept(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None: ) -> None:

View File

@ -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) 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( async def test_checks_from_issues_with_codes_and_line_ids(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None: ) -> 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") 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.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED"
assert blocked.json()["detail"]["failures"][0]["expected"] is None 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( async def test_one_patch_one_recompute(

View File

@ -56,6 +56,54 @@ def test_edition_codes_and_engine_edition() -> None:
assert ENGINE.recompute(form).edition == edition 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: def test_engine_scenario_recompute_after_edit() -> None:
column = make_column() column = make_column()
effective = datetime.date(2025, 9, 30) effective = datetime.date(2025, 9, 30)

View File

@ -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-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-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-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:<id>` с одним ключом `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:*` не следует за датой отчёта |