feat(statements): PATCH создаёт строку и ячейку по line_code (#80)

CellPatch адресует ячейку ровно одним из line_id / line_code. Строка справочника
редакции, которой нет в форме, создаётся при первой непустой правке
(line_code_original_value = NULL — признак «не из источника»); отсутствующая
ячейка существующей строки тоже создаётся. Адресация (колонка, строка, код)
проверяется целиком до первой вставки, две ячейки новой строки делят одну
строку, повтор line_code после создания даёт 422 UNKNOWN_CELL. Аудит — одно
RECOGNIZED_DATA_EDIT на запрос, у созданной ячейки before = null.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-09 12:03:59 +04:00
parent 3a543b5cd1
commit 37e7f40890
4 changed files with 357 additions and 14 deletions

View File

@ -22,7 +22,11 @@ from src.models.statements import (
__all__ = [ __all__ = [
"BATCH", "BATCH",
"add_line",
"add_value",
"column_ids",
"delete_file_statements", "delete_file_statements",
"form_lines",
"get_meta", "get_meta",
"insert_statement", "insert_statement",
"load_form", "load_form",
@ -189,6 +193,55 @@ async def lock_cells(
} }
async def form_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[FinancialStatementLine]:
"""Строки формы (без значений) — адресация ячеек правки по `line_id` и `line_code`."""
stmt = select(FinancialStatementLine).where(FinancialStatementLine.meta_id == meta_id)
return list((await session.execute(stmt)).scalars())
async def column_ids(session: AsyncSession, meta_id: uuid.UUID) -> set[uuid.UUID]:
"""Id колонок формы."""
stmt = select(FinancialStatementColumn.id).where(FinancialStatementColumn.meta_id == meta_id)
return set((await session.execute(stmt)).scalars())
async def add_line(session: AsyncSession, meta_id: uuid.UUID, code: str, name: str) -> FinancialStatementLine:
"""Строка справочника, созданная правкой аналитика: `*_original_value = NULL` — признак «не из источника»."""
line = FinancialStatementLine(
id=uuid.uuid4(),
meta_id=meta_id,
line_code=code,
line_code_original_value=None,
line_code_confidence=None,
line_code_modified=False,
line_name=name,
line_name_original_value=None,
line_name_confidence=None,
line_name_modified=False,
)
session.add(line)
await session.flush()
return line
async def add_value(session: AsyncSession, line_id: uuid.UUID, column_id: uuid.UUID) -> FinancialStatementValue:
"""Пустая ячейка, созданная правкой: не распознана (`is_recognized = NULL`), исходной суммы нет."""
value = FinancialStatementValue(
id=uuid.uuid4(),
line_id=line_id,
column_id=column_id,
amount=None,
string_value=None,
confidence=None,
is_recognized=None,
is_modified=False,
original_amount=None,
)
session.add(value)
await session.flush()
return value
async def get_meta( async def get_meta(
session: AsyncSession, document_id: uuid.UUID, *, refresh: bool = False session: AsyncSession, document_id: uuid.UUID, *, refresh: bool = False
) -> FinancialStatementMeta | None: ) -> FinancialStatementMeta | None:

View File

@ -11,7 +11,7 @@ from collections.abc import Sequence
from decimal import Decimal from decimal import Decimal
from typing import Annotated, Literal from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, StringConstraints from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator
from src.domain.statement_checks import ChecksReport, FailedCheck from src.domain.statement_checks import ChecksReport, FailedCheck
from src.domain.statement_lines import TemplateLine from src.domain.statement_lines import TemplateLine
@ -289,14 +289,24 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
class CellPatch(BaseModel): class CellPatch(BaseModel):
"""Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить.""" """Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить.
Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` —
строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4)."""
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
line_id: uuid.UUID line_id: uuid.UUID | None = None
line_code: str | None = Field(None, max_length=10)
column_id: uuid.UUID column_id: uuid.UUID
amount: Decimal | None = Field(max_digits=18, decimal_places=2) amount: Decimal | None = Field(max_digits=18, decimal_places=2)
@model_validator(mode="after")
def _one_address(self) -> "CellPatch":
if (self.line_id is None) == (self.line_code is None):
raise ValueError("укажите ровно одно из line_id и line_code")
return self
class HeaderPatch(BaseModel): class HeaderPatch(BaseModel):
"""Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422 """Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422

View File

@ -16,7 +16,7 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound
from src.domain.osv_mapping import counterparty_check from src.domain.osv_mapping import counterparty_check
from src.domain.policy import VisibilityScope from src.domain.policy import VisibilityScope
from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks
from src.domain.statement_lines import load_line_catalog from src.domain.statement_lines import TemplateLine, load_line_catalog
from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
from src.models.enums import ( from src.models.enums import (
@ -29,7 +29,7 @@ from src.models.enums import (
RecognitionStatus, RecognitionStatus,
) )
from src.models.files import Document, RecognitionJob from src.models.files import Document, RecognitionJob
from src.models.statements import FinancialStatementMeta from src.models.statements import FinancialStatementLine, FinancialStatementMeta
from src.repositories import files as files_repo from src.repositories import files as files_repo
from src.repositories import statements as repo from src.repositories import statements as repo
from src.repositories.counterparties import CounterpartyIndex from src.repositories.counterparties import CounterpartyIndex
@ -39,6 +39,7 @@ from src.services.documents import CONFIRMABLE_STATUSES, DOCUMENT_NOT_CONFIRMABL
DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
DOCUMENT_READONLY = "DOCUMENT_READONLY" DOCUMENT_READONLY = "DOCUMENT_READONLY"
LINE_EXISTS = "строка уже есть в форме — передайте line_id"
UNKNOWN_CELL = "UNKNOWN_CELL" UNKNOWN_CELL = "UNKNOWN_CELL"
DUPLICATE_CELL = "DUPLICATE_CELL" DUPLICATE_CELL = "DUPLICATE_CELL"
NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT"
@ -156,18 +157,40 @@ class StatementService:
document.updated_at = utc_now() document.updated_at = utc_now()
async def _apply_cells( async def _apply_cells(
self, meta: FinancialStatementMeta, body: schemas.StatementPatch, before: dict[str, Any], after: dict[str, Any] self,
document: Document,
meta: FinancialStatementMeta,
body: schemas.StatementPatch,
before: dict[str, Any],
after: dict[str, Any],
) -> None: ) -> None:
"""Значения `body.values` (D-7): `original_amount` при первой правке, `is_modified`, `modified_by/at`.""" """Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): `original_amount` при первой
keys = [(cell.line_id, cell.column_id) for cell in body.values] правке, `is_modified`, `modified_by/at`. Ячейка, которой нет в БД, создаётся при первой непустой правке; строка
по `line_code` — из справочника редакции документа. Вся адресация проверяется до первой вставки."""
if not body.values:
return
keys = [(cell.line_id or cell.line_code, cell.column_id) for cell in body.values]
if len(keys) != len(set(keys)): if len(keys) != len(set(keys)):
raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды") raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды")
cells = await repo.lock_cells(self._session, meta.id, keys) targets = await self._resolve_cells(document, meta, body)
if len(cells) != len(set(keys)): existing = await repo.lock_cells(
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") self._session, meta.id, [(line.id, cell.column_id) for cell, line, _ in targets if line is not None]
)
created: dict[str, FinancialStatementLine] = {}
now = utc_now() now = utc_now()
for cell in body.values: for cell, line, item in targets:
value = cells[(cell.line_id, cell.column_id)] value = existing.get((line.id, cell.column_id)) if line is not None else None
if value is None:
if cell.amount is None:
continue # пустая сумма в несуществующую ячейку — не изменение
target = line
if target is None and item is not None:
target = created.get(item.code)
if target is None:
target = created[item.code] = await repo.add_line(self._session, meta.id, item.code, item.name)
if target is None:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
value = await repo.add_value(self._session, target.id, cell.column_id)
if value.amount == cell.amount: if value.amount == cell.amount:
continue continue
key = f"value:{value.id}" key = f"value:{value.id}"
@ -178,6 +201,37 @@ class StatementService:
value.is_modified = value.amount != value.original_amount value.is_modified = value.amount != value.original_amount
value.modified_by, value.modified_at = self._audit.user_id, now value.modified_by, value.modified_at = self._audit.user_id, now
async def _resolve_cells(
self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch
) -> list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]]:
"""Адресация ячеек правки: строка формы (`line_id`) либо строка справочника редакции (`line_code`). Ничего не
вставляет — любая ошибка адресации (422) происходит до первой записи."""
columns = await repo.column_ids(self._session, meta.id)
lines = await repo.form_lines(self._session, meta.id)
by_id = {line.id: line for line in lines}
codes = {line.line_code for line in lines}
template = {
item.code: item
for item in load_line_catalog().lines(edition_for(document.reporting_date), document.document_type.value)
}
targets: list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]] = []
for cell in body.values:
if cell.column_id not in columns:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
if cell.line_id is not None:
line = by_id.get(cell.line_id)
if line is None:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
targets.append((cell, line, None))
continue
if cell.line_code in codes:
raise DomainValidationError(UNKNOWN_CELL, LINE_EXISTS)
item = template.get(cell.line_code or "")
if item is None:
raise DomainValidationError(UNKNOWN_CELL, "строки с таким кодом нет в форме этой редакции")
targets.append((cell, None, item))
return targets
async def _apply_header( async def _apply_header(
self, self,
document: Document, document: Document,
@ -218,7 +272,7 @@ class StatementService:
raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр")
before: dict[str, Any] = {} before: dict[str, Any] = {}
after: dict[str, Any] = {} after: dict[str, Any] = {}
await self._apply_cells(meta, body, before, after) await self._apply_cells(document, meta, body, before, after)
if header is not None: if header is not None:
await self._apply_header(document, meta, header, before, after) await self._apply_header(document, meta, header, before, after)
if not after: if not after:

View File

@ -1060,3 +1060,229 @@ async def test_status_with_lines_but_no_columns_is_stable_after_edit(
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.OK, response.text assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["lines"] and response.json()["document"]["status"] == "RECOGNIZED" assert response.json()["lines"] and response.json()["document"]["status"] == "RECOGNIZED"
async def _column(client, document_id, index: int) -> str:
body = (await client.get(f"/documents/{document_id}/statement")).json()
return next(c["id"] for c in body["columns"] if c["column_index"] == index)
async def _db_line(session: AsyncSession, document: Document, code: str) -> FinancialStatementLine | None:
meta = await _meta(session, document)
return (
await session.execute(
select(FinancialStatementLine)
.where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == code)
.execution_options(populate_existing=True)
)
).scalar_one_or_none()
async def _edit_events(session: AsyncSession, document: Document) -> list[AuditEvent]:
return list(
(
await session.execute(
select(AuditEvent).where(
AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT
)
)
).scalars()
)
async def test_patch_creates_template_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": "2371333"}]},
)
assert response.status_code == HTTPStatus.OK, response.text
line = next(item for item in response.json()["lines"] if item["line_code"] == "1700")
assert line["id"] is not None and line["origin"] == "template" and line["line_name"] == "БАЛАНС"
cell = next(v for v in line["values"] if v["column_id"] == col0)
assert Decimal(cell["amount"]) == Decimal("2371333") and cell["original_amount"] is None
assert cell["is_modified"] is True and cell["is_recognized"] is None and cell["modified_by"] is not None
other = [v for v in line["values"] if v["column_id"] != col0]
assert other and all(v["id"] is None for v in other) # остальные колонки не созданы
db_line = await _db_line(seeded, document, "1700")
assert db_line is not None and db_line.line_code_original_value is None and db_line.line_name_original_value is None
(event,) = await _edit_events(seeded, document)
key = f"value:{cell['id']}"
assert event.before_value == {key: None} and Decimal(event.after_value[key]) == Decimal("2371333")
async def test_patch_two_cells_of_new_line_create_one_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 2."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
before = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
col0, col1 = await _column(client, document.id, 0), await _column(client, document.id, 1)
response = await client.patch(
f"/documents/{document.id}/statement",
json={
"values": [
{"line_code": "1700", "column_id": col0, "amount": "1"},
{"line_code": "1700", "column_id": col1, "amount": "2"},
]
},
)
assert response.status_code == HTTPStatus.OK, response.text
assert await db_line_count(seeded, document) == before + 1
line = next(item for item in response.json()["lines"] if item["line_code"] == "1700")
assert sum(1 for v in line["values"] if v["id"] is not None) == 2 # noqa: PLR2004
async def test_patch_repeat_line_code_after_creation_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 3: второй «Сохранить» со старым черновиком по коду — 422, строка не задвоена."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
item = {"line_code": "1700", "column_id": col0, "amount": "1"}
assert (
await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})
).status_code == HTTPStatus.OK
count = await db_line_count(seeded, document)
response = await client.patch(f"/documents/{document.id}/statement", json={"values": [{**item, "amount": "5"}]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert "line_id" in response.json()["detail"]["message"]
assert await db_line_count(seeded, document) == count
async def test_patch_line_code_existing_or_unknown_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
count = await db_line_count(seeded, document)
for code in ("1150", "1105"): # 1150 есть в форме; 1105 нет в справочнике fsbu4
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, code
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert await db_line_count(seeded, document) == count
assert await _edit_events(seeded, document) == []
async def test_patch_line_code_without_edition_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
document.reporting_date = None
await seeded.flush()
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_null_into_missing_cell_is_noop(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
count = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": None}]},
)
assert response.status_code == HTTPStatus.OK, response.text
assert await db_line_count(seeded, document) == count
assert await _edit_events(seeded, document) == []
async def test_patch_creates_missing_cell_of_existing_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_value(seeded, document, "1150", 1)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
line = next(item for item in body["lines"] if item["line_code"] == "1150")
col1 = next(c["id"] for c in body["columns"] if c["column_index"] == 1)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": col1, "amount": "77"}]},
)
assert response.status_code == HTTPStatus.OK, response.text
cell = next(
v
for v in next(i for i in response.json()["lines"] if i["id"] == line["id"])["values"]
if v["column_id"] == col1
)
assert cell["id"] is not None and Decimal(cell["amount"]) == Decimal("77") and cell["original_amount"] is None
assert cell["is_modified"] is True
async def test_patch_bad_column_rolls_back_new_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 1: одна ячейка корректна (новая строка), другая — чужая колонка: 422, строка не создана."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
count = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={
"values": [
{"line_code": "1700", "column_id": col0, "amount": "1"},
{"line_code": "1700", "column_id": str(uuid.uuid4()), "amount": "2"},
]
},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert await db_line_count(seeded, document) == count
async def test_patch_cell_address_must_be_exactly_one(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
for item in (
{"line_id": line["id"], "line_code": "1700", "column_id": cell["column_id"], "amount": "1"},
{"column_id": cell["column_id"], "amount": "1"},
):
response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert isinstance(response.json()["detail"], list) # 422 валидации тела — массив
async def test_patch_duplicate_line_code_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
item = {"line_code": "1700", "column_id": col0, "amount": "1"}
response = await client.patch(
f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]}
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "DUPLICATE_CELL"