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:
parent
02630e830f
commit
028512603a
@ -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]
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:<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: документ, шапка, элементы, пользователь, аудит
|
||||
session: AsyncSession,
|
||||
*,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:<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:*` не следует за датой отчёта |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user