From 3753e3221906ec095d0171f18c98dcf2778b37fb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 17:38:24 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20PATCH=20lines=20=E2=80=94?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=D0=B0,=20=D1=80=D0=B0=D1=81=D1=88=D0=B8?= =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=B2=D0=BA=D0=B0,=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=B0,=20LINE=5FCODE=5FTAKEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 9 +- api/src/domain/statement_layout.py | 15 +- api/src/repositories/statements.py | 42 +++ api/src/schemas/statements.py | 15 ++ api/src/services/statement_line_edits.py | 157 ++++++++++++ api/src/services/statements.py | 17 +- api/tests/test_api_statement_lines.py | 299 ++++++++++++++++++++++ api/tests/test_api_statements.py | 2 +- api/tests/test_domain_statement_layout.py | 13 +- 9 files changed, 562 insertions(+), 7 deletions(-) create mode 100644 api/src/services/statement_line_edits.py create mode 100644 api/tests/test_api_statement_lines.py diff --git a/README.md b/README.md index 3408a9c..e9ebc90 100644 --- a/README.md +++ b/README.md @@ -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` — назначьте код или очистите строку | diff --git a/api/src/domain/statement_layout.py b/api/src/domain/statement_layout.py index dc7870c..3df26e5 100644 --- a/api/src/domain/statement_layout.py +++ b/api/src/domain/statement_layout.py @@ -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 diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 6c1a9ee..217eb97 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -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) diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index 8dd9566..060936e 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -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) diff --git a/api/src/services/statement_line_edits.py b/api/src/services/statement_line_edits.py new file mode 100644 index 0000000..e9b39e4 --- /dev/null +++ b/api/src/services/statement_line_edits.py @@ -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:` ложатся в `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 diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 95f1617..029e7f2 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -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 diff --git a/api/tests/test_api_statement_lines.py b/api/tests/test_api_statement_lines.py new file mode 100644 index 0000000..d3a1050 --- /dev/null +++ b/api/tests/test_api_statement_lines.py @@ -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"} diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 618fd86..64ce77f 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -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, diff --git a/api/tests/test_domain_statement_layout.py b/api/tests/test_domain_statement_layout.py index 0cac669..6e824d9 100644 --- a/api/tests/test_domain_statement_layout.py +++ b/api/tests/test_domain_statement_layout.py @@ -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}