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]
|
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]
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:*` не следует за датой отчёта |
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user