Единая нормализация ББ/ОФР, ядро — часть А: DBML v5.19, очистка форм, агрегат по коду, правка формы, контракт 2.0 (arm-ka-docs#116) #129

Merged
aep merged 20 commits from feat/126-normalization-core-a into main 2026-10-18 18:44:13 +03:00
9 changed files with 562 additions and 7 deletions
Showing only changes of commit 3753e32219 - Show all commits

View File

@ -549,7 +549,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл
Коды ошибок этих маршрутов — в таблице «Ошибки API» (`FILE_TOO_LARGE`, `UNSUPPORTED_FILE`, `DUPLICATE_FILE`,
`STORAGE_UNAVAILABLE`, `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`,
`FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS`, `FILE_CONTENT_NOT_FOUND`) и форм ББ/ОФР (`DOCUMENT_NOT_STATEMENT`,
`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`,
`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, `LINE_CODE_TAKEN`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`,
`INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`,
`CHECKS_NOT_PASSED`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел
`PRIMARY_DOCUMENTS`), `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`; правка формы и акцепт).
@ -605,6 +606,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл
`DUPLICATE_COLUMN`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно
событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН
пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус.
`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`.
- **`POST /documents/{id}/statement/checks/accept`** — `{comment}` (1–2000 символов): акцепт нарушенных жёстких проверок
@ -932,6 +934,11 @@ email без учёта регистра проверяет только сер
| `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды |
| `UNKNOWN_COLUMN` | 422 | `PATCH …/statement`: `columns[].column_id` не этой формы; ничего не применено |
| `DUPLICATE_COLUMN` | 422 | `PATCH …/statement`: одна колонка указана в `columns[]` дважды |
| `LINE_CODE_TAKEN` | 409 | `PATCH …/statement`, `lines[] {line_id, line_code}`: код занят строкой, созданной правкой (`row_index IS NULL`), с суммами; ничего из запроса не применено. `detail` несёт `line_id` строки, которой назначается код, и `line_code` |
| `LINE_CODE_REQUIRED` | 422 | `PATCH …/statement`: `lines[] {line_status: "ok"}` — в `ok` строку переводит только назначение кода. `detail.line_id`, `detail.line_code` (`null`) |
| `INVALID_LINE_CODE` | 422 | `PATCH …/statement`: `lines[].line_code` не из 4–5 цифр. `detail.line_id`, `detail.line_code` |
| `INVALID_LINE_PATCH` | 422 | `PATCH …/statement`: в элементе `lines[]` не ровно одно действие (`line_code` / `line_status` / `confirmed`), `line_id` дважды в запросе, подтверждение кода у строки не `ok`, отметка расшифровкой строки, созданной правкой. `detail.line_id`, `detail.line_code` (код элемента или `null`) |
| `UNKNOWN_LINE` | 422 | `PATCH …/statement`: `lines[].line_id` не этой формы. `detail.line_id` — чужой id, `detail.line_code` |
| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
| `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта |
| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку |

View File

@ -10,7 +10,7 @@ import bisect
from collections.abc import Hashable, Sequence
from dataclasses import dataclass
__all__ = ["LayoutLine", "order_lines"]
__all__ = ["LayoutLine", "nearest_ok_parents", "order_lines"]
OK = "ok"
DETAIL = "detail"
@ -51,3 +51,16 @@ def order_lines(lines: Sequence[LayoutLine]) -> list[Hashable]:
result.append(line.key)
result.extend(item.key for item in attached.get(line.key, []))
return result
def nearest_ok_parents(lines: Sequence[LayoutLine]) -> dict[Hashable, int | None]:
"""Родитель строк `detail`: `row_index` ближайшей строки `ok` выше по `row_index` (строки без `row_index` —
шаблонные — родителями не бывают); нет такой — `None`. Временная: в части Б заменяется `detail_parents` пакета."""
ok_rows = sorted(line.row_index for line in lines if line.line_status == OK and line.row_index is not None)
parents: dict[Hashable, int | None] = {}
for line in lines:
if line.line_status != DETAIL:
continue
position = bisect.bisect_left(ok_rows, line.row_index) - 1 if line.row_index is not None else -1
parents[line.key] = ok_rows[position] if position >= 0 else None
return parents

View File

@ -388,6 +388,48 @@ async def form_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[Financia
return list((await session.execute(stmt)).scalars())
async def template_lines_by_code(
session: AsyncSession, meta_id: uuid.UUID, codes: Collection[str]
) -> list[tuple[FinancialStatementLine, bool]]:
"""Строки, созданные правкой (`row_index IS NULL`, статус `ok`), с кодами `codes`, и признак «есть непустая
сумма» (`amount IS NOT NULL` хотя бы в одной ячейке)."""
has_amount = (
select(FinancialStatementValue.id)
.where(
FinancialStatementValue.line_id == FinancialStatementLine.id, FinancialStatementValue.amount.is_not(None)
)
.exists()
)
stmt = select(FinancialStatementLine, has_amount).where(
FinancialStatementLine.meta_id == meta_id,
FinancialStatementLine.row_index.is_(None),
FinancialStatementLine.line_status == StatementLineStatus.ok,
FinancialStatementLine.line_code.in_(codes),
)
return [(row[0], bool(row[1])) for row in (await session.execute(stmt)).all()]
async def delete_line(session: AsyncSession, line_id: uuid.UUID) -> None:
"""Удалить строку формы вместе со значениями (порядок FK); записи `document_issue` чистит вызывающий."""
await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line_id))
await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.id == line_id))
await session.flush()
async def remove_line_from_issues(session: AsyncSession, document_id: uuid.UUID, line_id: uuid.UUID) -> None:
"""Убрать `id` удалённой строки из `line_ids` всех записей `document_issue` документа (у `line_ids` нет FK)."""
await session.execute(
update(DocumentIssue)
.where(
DocumentIssue.document_id == document_id,
DocumentIssue.line_ids.is_not(None),
DocumentIssue.line_ids.contains([str(line_id)]),
)
.values(line_ids=DocumentIssue.line_ids.op("-")(str(line_id)))
.execution_options(synchronize_session="fetch")
)
async def column_ids(session: AsyncSession, meta_id: uuid.UUID) -> set[uuid.UUID]:
"""Id колонок формы."""
stmt = select(FinancialStatementColumn.id).where(FinancialStatementColumn.meta_id == meta_id)

View File

@ -475,9 +475,24 @@ class ColumnPatch(BaseModel):
period_date: datetime.date
class LinePatch(BaseModel):
"""Правка строки формы: ровно одно действие — `line_code` (назначить или сменить код), `line_status: "detail"`
(расшифровка), `confirmed` (подтвердить код или снять подтверждение). Число действий и формат кода проверяет
сервис: ошибки элемента несут `line_id` и `line_code` в `detail` (P3-24), а 422 валидации тела их не несёт.
`line_status: "ok"` принимается схемой, чтобы сервис ответил `LINE_CODE_REQUIRED`."""
model_config = ConfigDict(extra="forbid")
line_id: uuid.UUID
line_code: str | None = Field(None, max_length=10)
line_status: Literal["detail", "ok"] | None = None
confirmed: bool | None = None
class StatementPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
lines: list[LinePatch] = Field(default_factory=list, max_length=5000)
header: HeaderPatch | None = None
values: list[CellPatch] = Field(default_factory=list, max_length=5000)
columns: list[ColumnPatch] = Field(default_factory=list, max_length=100)

View File

@ -0,0 +1,157 @@
"""Правка строк формы ББ/ОФР — часть `lines` запроса `PATCH …/statement` (spec arm-core «PATCH формы — строки…»;
P3-10, P3-16, P3-24). Вызывается первой в `StatementService.patch`: проверка элементов идёт до первой записи,
поэтому `LINE_CODE_TAKEN` и 422 оставляют форму нетронутой.
"""
import re
import uuid
from collections.abc import Sequence
from decimal import Decimal
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import ConflictError, DomainError, DomainValidationError
from src.domain.statement_layout import LayoutLine, nearest_ok_parents
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
from src.repositories import statements as repo
from src.schemas.statements import LinePatch
LINE_CODE_TAKEN = "LINE_CODE_TAKEN"
LINE_CODE_REQUIRED = "LINE_CODE_REQUIRED"
INVALID_LINE_CODE = "INVALID_LINE_CODE"
INVALID_LINE_PATCH = "INVALID_LINE_PATCH"
UNKNOWN_LINE = "UNKNOWN_LINE"
ASSIGNED_CONFIDENCE = Decimal("100.00") # `numeric(5,2)`: ответ отдаёт то, что лежит в сессии, без перечитывания
LINE_CODE_RE = re.compile(r"\d{4,5}", re.ASCII)
def _bad(code: str, message: str, item: LinePatch, *, conflict: bool = False) -> DomainError:
"""Ошибка элемента `lines[]`: в `detail` — `line_id` элемента и его `line_code` или `null` (P3-24)."""
error = ConflictError if conflict else DomainValidationError
return error(code, message, line_id=str(item.line_id), line_code=item.line_code)
def _snapshot(line: FinancialStatementLine) -> dict[str, Any]:
return {
"line_code": line.line_code,
"line_status": line.line_status.value,
"parent_row_index": line.parent_row_index,
"is_confirmed": line.is_confirmed,
}
def _check(items: Sequence[LinePatch], by_id: dict[uuid.UUID, FinancialStatementLine]) -> None:
"""Проверки элементов по порядку запроса (первый неверный — в ответ): действие, повтор `line_id`, строка формы,
`line_status`, формат кода, допустимость действия для строки."""
seen: set[uuid.UUID] = set()
for item in items:
actions = [item.line_code is not None, item.line_status is not None, item.confirmed is not None]
if sum(actions) != 1:
raise _bad(INVALID_LINE_PATCH, "в элементе должно быть ровно одно действие", item)
if item.line_id in seen:
raise _bad(INVALID_LINE_PATCH, "строка указана в запросе дважды", item)
seen.add(item.line_id)
line = by_id.get(item.line_id)
if line is None:
raise _bad(UNKNOWN_LINE, "строка не принадлежит форме", item)
if item.line_status == "ok":
raise _bad(LINE_CODE_REQUIRED, "в статус ok строку переводит только назначение кода", item)
if item.line_code is not None and not LINE_CODE_RE.fullmatch(item.line_code):
raise _bad(INVALID_LINE_CODE, "код строки — 4 или 5 цифр", item)
if item.line_status == "detail" and line.row_index is None:
raise _bad(INVALID_LINE_PATCH, "строку, созданную правкой, нельзя отметить расшифровкой", item)
if item.confirmed is not None and line.line_status is not StatementLineStatus.ok:
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:
if line.line_status is StatementLineStatus.ok and line.line_code == item.line_code:
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
return True
if item.line_status == "detail":
if line.line_status is StatementLineStatus.detail:
return False
line.line_code, line.line_status = None, StatementLineStatus.detail
line.needs_review = False
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
def _recompute_parents(lines: Sequence[FinancialStatementLine]) -> None:
"""Родитель всех строк `detail` — ближайшая строка `ok` выше по `row_index` (spec «Пересчёт после правки»)."""
layout = [
LayoutLine(line.id, line.row_index, line.line_status.value, line.line_code, line.parent_row_index)
for line in lines
]
parents = nearest_ok_parents(layout)
for line in lines:
if line.line_status is StatementLineStatus.detail:
line.parent_row_index = parents.get(line.id)
async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13: документ, шапка, элементы, пользователь, аудит
session: AsyncSession,
*,
document: Document,
meta: FinancialStatementMeta,
items: Sequence[LinePatch],
user_id: uuid.UUID | None, # noqa: ARG001 - у строки нет полей автора правки; интерфейс сохранён для части Б
before: dict[str, Any],
after: dict[str, Any],
) -> bool:
"""Применить `lines[]`; ключи `line:<id>` ложатся в `before/after` единственного события аудита. Возвращает `True`,
если менялись данные (назначение кода, отметка расшифровкой) — тогда акцепт проверок сбрасывается; подтверждение
кода акцепт не сбрасывает (Р-25)."""
if not items:
return False
lines = await repo.form_lines(session, meta.id)
by_id = {line.id: line for line in lines}
_check(items, by_id)
codes = {item.line_code for item in items if item.line_code is not None}
templates = {
line.id: (line, has_amount) for line, has_amount in await repo.template_lines_by_code(session, meta.id, codes)
}
for item in items:
for template, has_amount in templates.values():
if has_amount and template.line_code == item.line_code and template.id != item.line_id:
raise _bad(LINE_CODE_TAKEN, "код занят строкой, созданной правкой, с суммами", item, conflict=True)
# запись: пустые шаблонные строки назначаемых кодов уходят, их id — из `document_issue.line_ids`
targets = {item.line_id for item in items}
old = {line.id: _snapshot(line) for line in lines}
for template, _ in templates.values():
if template.id in targets or template.line_code not in codes:
continue
await repo.delete_line(session, template.id)
await repo.remove_line_from_issues(session, document.id, template.id)
lines = [line for line in lines if line.id != template.id]
old.pop(template.id, None)
data_edited = any([_apply_item(by_id[item.line_id], item) for item in items])
_recompute_parents(lines)
await session.flush()
for line in lines:
now = _snapshot(line)
if now != old[line.id]:
before[f"line:{line.id}"], after[f"line:{line.id}"] = old[line.id], now
return data_edited

View File

@ -60,6 +60,7 @@ from src.services.documents import (
FILE_SUPERSEDED,
PERIOD_REQUIRED,
)
from src.services.statement_line_edits import apply_line_edits
DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
@ -503,15 +504,25 @@ class StatementService:
document.period_quarter = quarter_of(period) if period is not None else None
async def patch(self, document_id: uuid.UUID, body: schemas.StatementPatch) -> schemas.StatementOut:
"""Правка значений, дат колонок и шапки (D-7): одна транзакция, один `audit_event`, один пересчёт. Порядок
частей — `values` → `columns` → `header` (P3-10); адресация проверяется до первой записи. Акцепт сбрасывает
только правка данных; подтверждения (ячейки, даты) — нет (Р-25)."""
"""Правка строк, значений, дат колонок и шапки (D-7): одна транзакция, один `audit_event`, один пересчёт.
Порядок частей — `lines` → `values` → `columns` → `header` (P3-10); адресация проверяется до первой
записи. Акцепт сбрасывает только правка данных; подтверждения (ячейки, даты) — нет (Р-25)."""
document, meta = await self._editable(document_id)
header = body.header
if header is not None and "inn" in header.model_fields_set and not INN_RE.fullmatch(header.inn or ""):
raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр")
await self._check_columns(meta, body.columns)
edit = _Edit({}, {})
if await apply_line_edits(
self._session,
document=document,
meta=meta,
items=body.lines,
user_id=self._audit.user_id,
before=edit.before,
after=edit.after,
):
edit.data_edited = True
await self._apply_cells(document, meta, body, edit)
columns_changed = await self._apply_columns(meta, body.columns, edit)
date_changed = False

View File

@ -0,0 +1,299 @@
"""`PATCH …/statement`, часть `lines` (spec arm-core «PATCH формы — строки…»; P3-10, P3-16, P3-24; Review Focus 3, 4).
Код 1215 — «только шаблонный» в ББ Межениновской (в файле такой строки нет), поэтому он годится для проверки
шаблонных строк и повторов; в брифе вместо него 1250.
"""
import uuid
from decimal import Decimal
from http import HTTPStatus
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource
from src.models.files import Document
from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue
from src.repositories import statements as repo
from tests.conftest import AuthClientFactory
from tests.test_api_statements import (
KA_RF,
NOW,
_accept,
_edit_events,
_meta,
form,
insert_line,
two_forms,
)
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
ROW_INDEX = 2
CODE = "1215"
FULL = Decimal(100)
async def _db(session: AsyncSession, line_id: uuid.UUID) -> FinancialStatementLine | None:
return (
await session.execute(
select(FinancialStatementLine)
.where(FinancialStatementLine.id == line_id)
.execution_options(populate_existing=True)
)
).scalar_one_or_none()
async def _patch(client: Any, document: Document, body: dict[str, Any]) -> Any:
return await client.patch(f"/documents/{document.id}/statement", json=body)
async def _code_total(session: AsyncSession, document: Document, code: str) -> Decimal | None:
meta = await _meta(session, document)
columns, aggregate = await repo.form_aggregate(session, meta.id)
current = next(c for c in columns if c.is_current)
return aggregate[code].amounts[current.id] if code in aggregate else None
async def test_assign_code_to_garbage_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
garbage = await insert_line(
seeded, document, code=None, row_index=2, status="unreadable", amount=Decimal(5), code_text="ИЧ,215"
)
client = await auth_client(KA_RF)
response = await _patch(client, document, {"lines": [{"line_id": str(garbage.id), "line_code": CODE}]})
assert response.status_code == HTTPStatus.OK, response.text
line = next(item for item in response.json()["lines"] if item["id"] == str(garbage.id))
assert line["line_status"] == "ok" and line["line_code"] == CODE and line["line_code_confidence"] == "100.00"
assert line["line_code_modified"] is True and line["row_index"] == ROW_INDEX and line["origin"] == "source"
assert line["needs_review"] is False
stored = await _db(seeded, garbage.id)
assert stored is not None and stored.line_code_original_value is None
assert await _code_total(seeded, document, CODE) == Decimal(5) # сумма вошла в агрегат
async def test_same_code_two_source_lines(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
first = await insert_line(seeded, document, code=None, row_index=7, status="unreadable")
second = await insert_line(seeded, document, code=None, row_index=2, status="missing", amount=Decimal(3))
client = await auth_client(KA_RF)
for line in (first, second):
response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_code": CODE}]})
assert response.status_code == HTTPStatus.OK, response.text
stored = [await _db(seeded, line.id) for line in (first, second)]
assert [(s.line_status.value, s.line_code) for s in stored if s] == [("ok", CODE), ("ok", CODE)]
assert await _code_total(seeded, document, CODE) == Decimal(3)
async def test_code_taken_by_template_with_amount(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
template = await insert_line(seeded, document, code=CODE, row_index=None, amount=Decimal(9))
unreadable = await insert_line(seeded, document, code=None, row_index=5, status="unreadable", amount=Decimal(1))
other = await insert_line(seeded, document, code="1100", row_index=6, amount=Decimal(2))
await seeded.flush()
meta = await _meta(seeded, document)
units, review = meta.units, other.line_status
client = await auth_client(KA_RF)
columns = (await client.get(f"/documents/{document.id}/statement")).json()["columns"]
response = await _patch(
client,
document,
{
"lines": [
{"line_id": str(unreadable.id), "line_code": CODE},
{"line_id": str(other.id), "line_status": "detail"},
],
"values": [{"line_id": str(template.id), "column_id": columns[0]["id"], "amount": "77"}],
"header": {"units": "руб."},
},
)
assert response.status_code == HTTPStatus.CONFLICT, response.text
detail = response.json()["detail"]
assert detail["code"] == "LINE_CODE_TAKEN" and detail["line_id"] == str(unreadable.id)
assert detail["line_code"] == CODE
stored_unreadable, stored_other = await _db(seeded, unreadable.id), await _db(seeded, other.id)
assert stored_unreadable is not None and stored_unreadable.line_status.value == "unreadable"
assert stored_other is not None and stored_other.line_status == review
assert (await _meta(seeded, document)).units == units
assert await _code_total(seeded, document, CODE) == Decimal(9)
assert await _edit_events(seeded, document) == []
async def test_code_taken_by_empty_template(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
template = await insert_line(seeded, document, code=CODE, row_index=None)
other = await insert_line(seeded, document, code="1100", row_index=6, amount=Decimal(2))
meta = await _meta(seeded, document)
column = (await repo.form_aggregate(seeded, meta.id))[0][0]
seeded.add(FinancialStatementValue(line_id=template.id, column_id=column.id, amount=None))
seeded.add(
DocumentIssue(
document_id=document.id,
kind=DocumentIssueKind.warning,
code="X",
severity=IssueSeverity.info,
message="x",
line_ids=[str(template.id), str(other.id)],
source=IssueSource.core,
created_at=NOW,
)
)
source = await insert_line(seeded, document, code=None, row_index=3, status="unreadable", amount=Decimal(4))
client = await auth_client(KA_RF)
response = await _patch(client, document, {"lines": [{"line_id": str(source.id), "line_code": CODE}]})
assert response.status_code == HTTPStatus.OK, response.text
assert await _db(seeded, template.id) is None
assert (
await seeded.execute(select(FinancialStatementValue).where(FinancialStatementValue.line_id == template.id))
).first() is None
issue = (
await seeded.execute(
select(DocumentIssue).where(DocumentIssue.document_id == document.id, DocumentIssue.code == "X")
)
).scalar_one()
await seeded.refresh(issue)
assert issue.line_ids == [str(other.id)]
stored = await _db(seeded, source.id)
assert stored is not None and stored.line_status.value == "ok" and stored.line_code == CODE
assert [item["line_code"] for item in response.json()["lines"] if item["line_code"] == CODE] == [CODE]
async def test_mark_detail(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
missing = await insert_line(seeded, document, code=None, row_index=8, status="missing", amount=Decimal(6))
client = await auth_client(KA_RF)
unresolved = await client.post(f"/documents/{document.id}/confirm", json={})
assert unresolved.status_code == HTTPStatus.CONFLICT and unresolved.json()["detail"]["code"] == "LINES_UNRESOLVED"
response = await _patch(client, document, {"lines": [{"line_id": str(missing.id), "line_status": "detail"}]})
assert response.status_code == HTTPStatus.OK, response.text
stored = await _db(seeded, missing.id)
assert stored is not None and stored.line_status.value == "detail" and stored.line_code is None
again = await client.post(f"/documents/{document.id}/confirm", json={})
assert again.json().get("detail", {}).get("code") != "LINES_UNRESOLVED"
async def test_status_ok_without_code(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
line = await insert_line(seeded, document, code=None, row_index=8, status="missing")
client = await auth_client(KA_RF)
response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_status": "ok"}]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
detail = response.json()["detail"]
assert detail["code"] == "LINE_CODE_REQUIRED" and detail["line_id"] == str(line.id) and detail["line_code"] is None
async def test_invalid_line_patch(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
line = await insert_line(seeded, document, code=None, row_index=8, status="missing")
client = await auth_client(KA_RF)
lid = str(line.id)
cases = [
([{"line_id": lid}], None),
([{"line_id": lid, "line_code": "1215", "confirmed": True}], "1215"),
([{"line_id": lid, "line_code": "1215"}, {"line_id": lid, "line_status": "detail"}], None),
]
for items, code in cases:
response = await _patch(client, document, {"lines": items})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text
detail = response.json()["detail"]
assert detail["code"] == "INVALID_LINE_PATCH" and detail["line_id"] == lid and detail["line_code"] == code
stored = await _db(seeded, line.id)
assert stored is not None and stored.line_status.value == "missing"
async def test_unknown_line(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document, foreign = await two_forms(seeded, fixtures_dir)
other = await insert_line(seeded, foreign, code=None, row_index=8, status="missing")
client = await auth_client(KA_RF)
response = await _patch(client, document, {"lines": [{"line_id": str(other.id), "line_code": CODE}]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text
detail = response.json()["detail"]
assert detail["code"] == "UNKNOWN_LINE" and detail["line_id"] == str(other.id) and detail["line_code"] == CODE
async def test_invalid_line_code(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
line = await insert_line(seeded, document, code=None, row_index=8, status="missing")
client = await auth_client(KA_RF)
for bad in ("123", "123456", "12a4"):
response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_code": bad}]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text
detail = response.json()["detail"]
assert set(detail) == {"code", "message", "line_id", "line_code"}
assert detail["code"] == "INVALID_LINE_CODE" and detail["line_code"] == bad
response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_code": "11101"}]})
assert response.status_code == HTTPStatus.OK, response.text
async def test_confirm_code(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
line = await insert_line(seeded, document, code="1120", row_index=9, code_original="1120")
line.line_code_confidence, line.needs_review = Decimal(50), True
await _accept(seeded, document)
client = await auth_client(KA_RF)
response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "confirmed": True}]})
assert response.status_code == HTTPStatus.OK, response.text
stored = await _db(seeded, line.id)
assert stored is not None and stored.is_confirmed is True and stored.needs_review is False
assert stored.line_code_confidence == Decimal(50) and not stored.line_code_modified
assert response.json()["checks"]["accepted"] is not None
response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "confirmed": False}]})
stored = await _db(seeded, line.id)
assert stored is not None and stored.is_confirmed is False and stored.needs_review is True
async def test_parent_moves_up(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
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
top = 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
first = await insert_line(seeded, document, code="1130", row_index=1)
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
stored = [await _db(seeded, line.id) for line in (parent, *details)]
assert [s.parent_row_index for s in stored if s] == [3, 3, 3]
# без строк `ok` выше — None
response = await _patch(client, document, {"lines": [{"line_id": str(top.id), "line_status": "detail"}]})
assert response.status_code == HTTPStatus.OK
stored = [await _db(seeded, line.id) for line in (top, parent, *details)]
assert [s.parent_row_index for s in stored if s] == [1, 1, 1, 1]
response = await _patch(client, document, {"lines": [{"line_id": str(first.id), "line_status": "detail"}]})
stored = [await _db(seeded, line.id) for line in (parent, *details)]
assert all(s.parent_row_index is None for s in stored if s)
async def test_line_edit_audit_and_accept(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
garbage = await insert_line(seeded, document, code=None, row_index=2, status="unreadable", amount=Decimal(5))
await _accept(seeded, document)
client = await auth_client(KA_RF)
response = await _patch(client, document, {"lines": [{"line_id": str(garbage.id), "line_code": CODE}]})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["checks"]["accepted"] is None # назначение кода — правка данных
events = await _edit_events(seeded, document)
assert len(events) == 1
key = f"line:{garbage.id}"
assert events[0].before_value[key]["line_status"] == "unreadable"
assert events[0].after_value[key]["line_status"] == "ok" and events[0].after_value[key]["line_code"] == CODE
assert set(events[0].after_value[key]) == {"line_code", "line_status", "parent_row_index", "is_confirmed"}

View File

@ -1611,7 +1611,7 @@ async def insert_line( # noqa: PLR0913 - строка формы теста: к
meta_id=meta.id,
line_code=code,
line_code_original_value=code_original,
line_status=status,
line_status=StatementLineStatus(status),
line_code_text=code_text,
line_name="тест",
row_index=row_index,

View File

@ -1,6 +1,6 @@
"""Порядок строк ответа `GET …/statement` (P3-17): строки `ok` по коду, вложения источника — за своей строкой."""
from src.domain.statement_layout import LayoutLine, order_lines
from src.domain.statement_layout import LayoutLine, nearest_ok_parents, order_lines
def L( # noqa: N802 - короткая фабрика строки для таблиц тестов
@ -44,3 +44,14 @@ def test_attachments_keep_row_order() -> None:
def test_empty() -> None:
assert order_lines([]) == []
def test_nearest_ok_parents() -> None:
lines = [
L("a", 0, "ok", "1100", None),
L("b", 1, "detail", None, None),
L("t", None, "ok", "1250", None),
L("c", 3, "detail", None, None),
]
assert nearest_ok_parents(lines) == {"b": 0, "c": 0}
assert nearest_ok_parents([L("d", 1, "detail", None, None), L("u", 2, "unreadable", None, None)]) == {"d": None}