From 37e7f40890412e09723966d289aee3ebf6a83f49 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 12:03:59 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20PATCH=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D1=91=D1=82=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B8=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D1=83=20=D0=BF=D0=BE?= =?UTF-8?q?=20line=5Fcode=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/src/repositories/statements.py | 53 +++++++ api/src/schemas/statements.py | 16 +- api/src/services/statements.py | 76 ++++++++-- api/tests/test_api_statements.py | 226 +++++++++++++++++++++++++++++ 4 files changed, 357 insertions(+), 14 deletions(-) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index dcdafb8..cb49e5d 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -22,7 +22,11 @@ from src.models.statements import ( __all__ = [ "BATCH", + "add_line", + "add_value", + "column_ids", "delete_file_statements", + "form_lines", "get_meta", "insert_statement", "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( session: AsyncSession, document_id: uuid.UUID, *, refresh: bool = False ) -> FinancialStatementMeta | None: diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index a957b8c..6ca6546 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -11,7 +11,7 @@ from collections.abc import Sequence from decimal import Decimal 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_lines import TemplateLine @@ -289,14 +289,24 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч 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") - line_id: uuid.UUID + line_id: uuid.UUID | None = None + line_code: str | None = Field(None, max_length=10) column_id: uuid.UUID 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): """Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422 diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 4e5d1ae..916dd8f 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -16,7 +16,7 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound from src.domain.osv_mapping import counterparty_check 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_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.models.directories import LegalEntity from src.models.enums import ( @@ -29,7 +29,7 @@ from src.models.enums import ( RecognitionStatus, ) 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 statements as repo 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_STATEMENT = "DOCUMENT_NOT_STATEMENT" DOCUMENT_READONLY = "DOCUMENT_READONLY" +LINE_EXISTS = "строка уже есть в форме — передайте line_id" UNKNOWN_CELL = "UNKNOWN_CELL" DUPLICATE_CELL = "DUPLICATE_CELL" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" @@ -156,18 +157,40 @@ class StatementService: document.updated_at = utc_now() 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: - """Значения `body.values` (D-7): `original_amount` при первой правке, `is_modified`, `modified_by/at`.""" - keys = [(cell.line_id, cell.column_id) for cell in body.values] + """Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): `original_amount` при первой + правке, `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)): raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды") - cells = await repo.lock_cells(self._session, meta.id, keys) - if len(cells) != len(set(keys)): - raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") + targets = await self._resolve_cells(document, meta, body) + existing = await repo.lock_cells( + 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() - for cell in body.values: - value = cells[(cell.line_id, cell.column_id)] + for cell, line, item in targets: + 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: continue key = f"value:{value.id}" @@ -178,6 +201,37 @@ class StatementService: value.is_modified = value.amount != value.original_amount 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( self, document: Document, @@ -218,7 +272,7 @@ class StatementService: raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") before: 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: await self._apply_header(document, meta, header, before, after) if not after: diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index fe4d693..cc452ee 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -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": "руб."}}) assert response.status_code == HTTPStatus.OK, response.text 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"