feat(statements): PATCH lines — назначение кода, расшифровка, подтверждение кода, LINE_CODE_TAKEN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
parent
f1fcb861a6
commit
3753e32219
@ -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` — назначьте код или очистите строку |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
157
api/src/services/statement_line_edits.py
Normal file
157
api/src/services/statement_line_edits.py
Normal 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
|
||||
@ -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
|
||||
|
||||
299
api/tests/test_api_statement_lines.py
Normal file
299
api/tests/test_api_statement_lines.py
Normal 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"}
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user