diff --git a/README.md b/README.md index 06bdcc7..3408a9c 100644 --- a/README.md +++ b/README.md @@ -549,7 +549,7 @@ 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`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`, +`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел `PRIMARY_DOCUMENTS`), `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`; правка формы и акцепт). @@ -589,12 +589,20 @@ S3 (storage); без S3 — предупреждение в логе, цикл Карточка документа (`GET /documents/{id}`) несёт `issues[]` (у документа любого типа) и `issue_counts {error, info}`; `documents[]` карточки файла — `issue_counts` (все виды записей по `severity`, в том числе акцептованные; один групповой запрос на список документов). -- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id | line_code, column_id, amount}]}` - (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, +- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?, organization_name?}, values?: [{line_id | line_code, column_id, amount | confirmed}], columns?: [{column_id, period_date}]}` + (до 5000 ячеек, до 100 колонок; в элементе `values[]` ровно одно из `amount` и `confirmed`, `confirmed: true | false` — подтвердить ячейку + или снять подтверждение без правки суммы, только с `line_id`; `columns[].period_date` обязательна, `null` — 422 массивом; ручная дата + ставит колонке `period_kind = absolute` и пересчитывает `is_current` и период документа; дата, равная текущей, у колонки с + `COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN` — подтверждение; `header.organization_name` — непустая строка до 1000 символов, + пересчитывает `counterparty_check`; `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты; + правка поля шапки или даты колонки снимает предупреждения `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`, + `NO_ORGANIZATION_NO_INN`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`; подтверждения акцепт проверок не сбрасывают; + `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма (`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл — - 409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`, - одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно + 409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL` + (в том числе подтверждение ячейки, которой нет в БД), колонка не этой формы — 422 `UNKNOWN_COLUMN`, одна колонка дважды — 422 + `DUPLICATE_COLUMN`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. `values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме. @@ -922,6 +930,8 @@ email без учёта регистра проверяет только сер | `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) | | `UNKNOWN_CELL` | `422` | `PATCH …/statement`: `line_id`/`column_id` не этой формы; `line_code` уже есть в форме (передайте `line_id`), нет в справочнике редакции или редакция не определена | | `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | +| `UNKNOWN_COLUMN` | 422 | `PATCH …/statement`: `columns[].column_id` не этой формы; ничего не применено | +| `DUPLICATE_COLUMN` | 422 | `PATCH …/statement`: одна колонка указана в `columns[]` дважды | | `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_issues.py b/api/src/domain/statement_issues.py index 5c03500..9d2d644 100644 --- a/api/src/domain/statement_issues.py +++ b/api/src/domain/statement_issues.py @@ -10,10 +10,20 @@ from decimal import Decimal from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource -__all__ = ["MAPPING_WARNING_CODE", "IssueDraft", "mapping_warning"] +__all__ = ["COLUMN_CLEARS", "HEADER_CLEARS", "MAPPING_WARNING_CODE", "IssueDraft", "mapping_warning"] MAPPING_WARNING_CODE = "MAPPING_WARNING" +# предупреждения сервиса, которые снимает правка поля шапки (в том числе подтверждение даты тем же значением) — +# spec arm-core «Записи document_issue» +HEADER_CLEARS: dict[str, frozenset[str]] = { + "report_date": frozenset({"PERIOD_UNKNOWN", "REPORT_DATE_UNPARSED", "REPORT_DATE_UNCERTAIN"}), + "inn": frozenset({"NO_ORGANIZATION_NO_INN"}), + "organization_name": frozenset({"NO_ORGANIZATION_NO_INN"}), +} +# ... и правка (подтверждение) даты колонки — предупреждения этой колонки +COLUMN_CLEARS = frozenset({"COLUMN_WITHOUT_DATE", "COLUMN_DATE_UNCERTAIN"}) + @dataclass(frozen=True) class IssueDraft: diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 782d40e..6c1a9ee 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -3,18 +3,26 @@ import datetime import uuid -from collections.abc import Callable, Sequence +from collections.abc import Callable, Collection, Sequence from dataclasses import dataclass from decimal import Decimal from typing import Any -from sqlalchemy import delete, func, insert, or_, select +from sqlalchemy import delete, exists, func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code from src.domain.statement_issues import IssueDraft from src.domain.statement_mapping import StatementDraft -from src.models.enums import DocumentStatus, DocumentType, FileStatus, IssueSeverity, StatementLineStatus +from src.models.enums import ( + DocumentIssueKind, + DocumentStatus, + DocumentType, + FileStatus, + IssueSeverity, + IssueSource, + StatementLineStatus, +) from src.models.files import Document, StoredFile from src.models.statements import ( DocumentIssue, @@ -33,10 +41,12 @@ __all__ = [ "column_ids", "delete_documents_statements", "delete_file_statements", + "delete_issues", "IssueCounts", "form_aggregate", "form_lines", "get_meta", + "has_review_values", "has_source_lines", "insert_statement", "issue_counts_by_document", @@ -44,6 +54,7 @@ __all__ = [ "load_form", "lock_cells", "pair_amounts", + "settle_edited_values", "value_flags", ] @@ -170,6 +181,28 @@ async def add_issues( ) +async def delete_issues( # noqa: PLR0913 - фильтры записей независимы + session: AsyncSession, + document_id: uuid.UUID, + *, + codes: Collection[str], + kind: DocumentIssueKind | None = None, + source: IssueSource | None = None, + column_id: uuid.UUID | None = None, +) -> int: + """Удалить записи `document_issue` документа с кодами `codes` (и, если заданы, видом, источником, колонкой); + вернуть число удалённых.""" + stmt = delete(DocumentIssue).where(DocumentIssue.document_id == document_id, DocumentIssue.code.in_(codes)) + if kind is not None: + stmt = stmt.where(DocumentIssue.kind == kind) + if source is not None: + stmt = stmt.where(DocumentIssue.source == source) + if column_id is not None: + stmt = stmt.where(DocumentIssue.column_id == column_id) + result = await session.execute(stmt) + return int(result.rowcount or 0) # type: ignore[attr-defined] + + async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None: """`document_issue`, значения, строки, колонки и шапки форм всех документов файла (в порядке FK).""" documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() @@ -285,6 +318,41 @@ async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[b return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows] +async def has_review_values(session: AsyncSession, meta_id: uuid.UUID) -> bool: + """Есть ли в форме значение с `needs_review` у строки не `detail` (вход условия `RECOGNIZED`; значения строк + `detail` не требуют проверки, Р-23).""" + stmt = select( + exists().where( + FinancialStatementValue.line_id == FinancialStatementLine.id, + FinancialStatementLine.meta_id == meta_id, + FinancialStatementLine.line_status != StatementLineStatus.detail, + FinancialStatementValue.needs_review.is_(True), + ) + ) + return bool((await session.execute(stmt)).scalar()) + + +async def settle_edited_values(session: AsyncSession, meta_id: uuid.UUID) -> None: + """Правило пересчёта: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; значения строк `detail` — + тоже `false` (Р-23).""" + lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta_id) + detail = lines.where(FinancialStatementLine.line_status == StatementLineStatus.detail) + await session.execute( + update(FinancialStatementValue) + .where( + FinancialStatementValue.line_id.in_(lines), + FinancialStatementValue.needs_review.is_(True), + or_( + FinancialStatementValue.is_modified.is_(True), + FinancialStatementValue.is_confirmed.is_(True), + FinancialStatementValue.line_id.in_(detail), + ), + ) + .values(needs_review=False) + .execution_options(synchronize_session="fetch") + ) + + async def lock_cells( session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]] ) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]: diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index 5e973a6..8dd9566 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -419,37 +419,60 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч class CellPatch(BaseModel): - """Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить. + """Правка ячейки: новая сумма (не более двух знаков и 16 цифр до запятой — `numeric(18,2)`, `null` — очистить) либо + подтверждение (`confirmed`) без изменения значения. Ровно одно из двух; наличие `amount` — по `model_fields_set`. Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` — - строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4).""" + строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4). + `confirmed` — только с `line_id` (подтверждать нечего у несозданной строки).""" model_config = ConfigDict(extra="forbid") 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) + amount: Decimal | None = Field(None, max_digits=18, decimal_places=2) + confirmed: bool | None = None @model_validator(mode="after") - def _one_address(self) -> "CellPatch": + def _one_address_one_action(self) -> "CellPatch": if (self.line_id is None) == (self.line_code is None): raise ValueError("укажите ровно одно из line_id и line_code") + if ("amount" in self.model_fields_set) == (self.confirmed is not None): + raise ValueError("укажите ровно одно из amount и confirmed") + if self.confirmed is not None and self.line_id is None: + raise ValueError("confirmed допустим только с line_id") return self + @property + def sets_amount(self) -> bool: + return self.confirmed is None + class HeaderPatch(BaseModel): """Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422 `INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента). `units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50 - символов (пробельная строка — 422).""" + символов (пробельная строка — 422). `organization_name` — непустая строка после обрезки, до 1000 символов.""" model_config = ConfigDict(extra="forbid") inn: str | None = Field(None, description="10 или 12 цифр; null не допускается (422 INVALID_INN)") report_date: datetime.date | None = None units: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)] | None = None + organization_name: ( + Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)] | None + ) = None + + +class ColumnPatch(BaseModel): + """Дата колонки: обязательная (`null` — 422 валидации тела).""" + + model_config = ConfigDict(extra="forbid") + + column_id: uuid.UUID + period_date: datetime.date class StatementPatch(BaseModel): @@ -457,6 +480,7 @@ class StatementPatch(BaseModel): header: HeaderPatch | None = None values: list[CellPatch] = Field(default_factory=list, max_length=5000) + columns: list[ColumnPatch] = Field(default_factory=list, max_length=100) class ChecksAcceptIn(BaseModel): diff --git a/api/src/services/statements.py b/api/src/services/statements.py index d22f2ea..95f1617 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -7,6 +7,7 @@ import re import uuid from dataclasses import dataclass +from decimal import Decimal from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -25,21 +26,29 @@ from src.domain.statement_checks import ( run_checks, subline_parents, ) +from src.domain.statement_issues import COLUMN_CLEARS, HEADER_CLEARS 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, value_clean from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, AuditSection, DocumentCheckResult, + DocumentIssueKind, DocumentStatus, DocumentType, FileStatus, + IssueSource, RecognitionStatus, StatementLineStatus, ) from src.models.files import Document, RecognitionJob -from src.models.statements import FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) from src.repositories import files as files_repo from src.repositories import statements as repo from src.repositories.counterparties import CounterpartyIndex @@ -57,12 +66,15 @@ DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" DOCUMENT_READONLY = "DOCUMENT_READONLY" LINE_EXISTS = "строка уже есть в форме — передайте line_id" UNKNOWN_CELL = "UNKNOWN_CELL" +UNKNOWN_COLUMN = "UNKNOWN_COLUMN" +DUPLICATE_COLUMN = "DUPLICATE_COLUMN" DUPLICATE_CELL = "DUPLICATE_CELL" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" INVALID_INN = "INVALID_INN" LINES_UNRESOLVED = "LINES_UNRESOLVED" DUPLICATE_FORM = "DUPLICATE_FORM" OBJECT_TYPE = "document" +ABSOLUTE = "absolute" INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII) FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) @@ -144,6 +156,28 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia return None +@dataclass +class _Edit: + """Накопитель одного PATCH: `before/after` для единственного события аудита и признак правки данных (сброс + акцепта — только ею; подтверждения акцепт не сбрасывают, Р-25).""" + + before: dict[str, Any] + after: dict[str, Any] + data_edited: bool = False + + def record(self, key: str, old: Any, new: Any, *, data: bool = True) -> None: # noqa: ANN401 - значения разных типов + self.before[key], self.after[key] = old, new + self.data_edited = self.data_edited or data + + +def _value_needs_review(value: FinancialStatementValue, line: FinancialStatementLine) -> bool: + """Правило `needs_review` значения при приёме `1.x` и без пакета: правка/подтверждение и строка `detail` снимают + сомнение, иначе — по уверенности распознавания (`value_clean`).""" + if value.is_modified or value.is_confirmed or line.line_status is StatementLineStatus.detail: + return False + return not value_clean(value.confidence, value.is_recognized) + + class StatementService: def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: self._session = session @@ -230,36 +264,34 @@ class StatementService: """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы).""" return await statement_hard_failures(self._session, document, meta) - async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None: - """D-6 после правки или акцепта; `CONFIRMED` не трогает. + async def recompute(self, document: Document, meta: FinancialStatementMeta) -> None: + """Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6); `CONFIRMED` + не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б. Предупреждения маппинга файла не хранятся на уровне документа, поэтому передаются пустыми (`warnings=()`). """ if document.status is DocumentStatus.CONFIRMED: return + await repo.settle_edited_values(self._session, meta.id) job = await self._session.get(RecognitionJob, meta.recognition_job_id) document.status = statement_status( job_done=job is not None and job.status is RecognitionStatus.done, checks_passed=not await self.hard_failures(document, meta), counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, period_known=document.period_year is not None and document.period_quarter is not None, - values_ok=values_ok(await repo.value_flags(self._session, meta.id)), + values_ok=not await repo.has_review_values(self._session, meta.id), has_lines=await repo.has_source_lines(self._session, meta.id), warnings=(), ) document.updated_at = utc_now() async def _apply_cells( - self, - document: Document, - meta: FinancialStatementMeta, - body: schemas.StatementPatch, - before: dict[str, Any], - after: dict[str, Any], + self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch, edit: _Edit ) -> None: - """Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): `original_amount` при первой - правке, `is_modified`, `modified_by/at`. Ячейка, которой нет в БД, создаётся при первой непустой правке; строка - по `line_code` — из справочника редакции документа. Вся адресация проверяется до первой вставки.""" + """Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): сумма — `original_amount` при + первой правке, `is_modified`, `modified_by/at`; `confirmed` — подтверждение без правки значения (Р-25). + Ячейка, которой нет в БД, создаётся при первой непустой правке суммы; строка по `line_code` — из справочника + редакции документа. Вся адресация проверяется до первой вставки.""" if not body.values: return keys = [(cell.line_id or cell.line_code, cell.column_id) for cell in body.values] @@ -269,30 +301,64 @@ class StatementService: existing = await repo.lock_cells( self._session, meta.id, [(line.id, cell.column_id) for cell, line, _ in targets if line is not None] ) + for cell, line, _ in targets: + if cell.confirmed is not None and line is not None and (line.id, cell.column_id) not in existing: + raise DomainValidationError(UNKNOWN_CELL, "подтверждать нечего: значения в этой ячейке нет") created: dict[str, FinancialStatementLine] = {} now = utc_now() for cell, line, item in targets: value = existing.get((line.id, cell.column_id)) if line is not None else None + if cell.confirmed is not None: + if value is not None and line is not None: + self._apply_confirmation(value, line, cell.confirmed, edit, now) + continue 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: + line = line or created.get(item.code if item else "") # noqa: PLW2901 - строка, созданная выше + if line is None and item is not None: + line = created[item.code] = await repo.add_line(self._session, meta.id, item.code, item.name) # noqa: PLW2901 + if line 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}" - before[key], after[key] = value.amount, cell.amount - if not value.is_modified and value.original_amount is None: - value.original_amount = value.amount - value.amount = cell.amount - value.is_modified = value.amount != value.original_amount - value.modified_by, value.modified_at = self._audit.user_id, now + value = await repo.add_value(self._session, line.id, cell.column_id) + if line is not None: + self._apply_amount(value, line, cell.amount, edit, now) + + def _apply_amount( + self, + value: FinancialStatementValue, + line: FinancialStatementLine, + amount: Decimal | None, + edit: _Edit, + now: Any, # noqa: ANN401 - datetime + ) -> None: + """Новая сумма ячейки: `original_amount` при первой правке, `is_modified`, `modified_by/at`.""" + if value.amount == amount: + return + edit.record(f"value:{value.id}", value.amount, amount) + if not value.is_modified and value.original_amount is None: + value.original_amount = value.amount + value.amount = amount + value.is_modified = value.amount != value.original_amount + value.modified_by, value.modified_at = self._audit.user_id, now + value.needs_review = _value_needs_review(value, line) + + def _apply_confirmation( + self, + value: FinancialStatementValue, + line: FinancialStatementLine, + confirmed: bool, # noqa: FBT001 - значение поля запроса + edit: _Edit, + now: Any, # noqa: ANN401 - datetime + ) -> None: + """Подтверждение ячейки (`is_confirmed`, `confirmed_by/at`) без правки значения; `is_modified` не ставится. + Снятие возвращает `needs_review` по уверенности распознавания. Акцепт проверок не сбрасывает (Р-25).""" + if value.is_confirmed == confirmed: + return + edit.record(f"value:{value.id}:confirmed", value.is_confirmed, confirmed, data=False) + value.is_confirmed = confirmed + value.confirmed_by, value.confirmed_at = (self._audit.user_id, now) if confirmed else (None, None) + value.needs_review = _value_needs_review(value, line) async def _resolve_cells( self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch @@ -324,19 +390,76 @@ class StatementService: targets.append((cell, None, item)) return targets + async def _apply_columns( + self, meta: FinancialStatementMeta, columns: list[schemas.ColumnPatch], edit: _Edit + ) -> bool: + """Даты колонок (spec arm-core «PATCH формы»): новая дата — `period_kind = absolute`, снимаются предупреждения + колонки; дата, равная текущей, у колонки с `COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN` — подтверждение, + иначе запрос не меняет ничего. Возвращает `True`, если даты колонок изменились (нужен пересчёт + `is_current` и периода).""" + if not columns: + return False + by_id = {column.id: column for column in (await repo.load_form(self._session, meta.id))[0]} + changed = False + for patch in columns: + column = by_id[patch.column_id] + cleared = await repo.delete_issues( + self._session, + meta.document_id, + codes=COLUMN_CLEARS, + kind=DocumentIssueKind.warning, + source=IssueSource.service, + column_id=column.id, + ) + if patch.period_date != column.period_date: + edit.record(f"column:{column.id}", column.period_date, patch.period_date) + column.period_date, column.period_kind = patch.period_date, ABSOLUTE + changed = True + elif cleared: + edit.record(f"column:{column.id}:date_confirmed", False, True, data=False) + column.period_kind = ABSOLUTE + return changed + + async def _check_columns(self, meta: FinancialStatementMeta, columns: list[schemas.ColumnPatch]) -> None: + """Адресация `columns[]` до первой записи (P3-10): колонка этой формы, без повторов.""" + if not columns: + return + known = await repo.column_ids(self._session, meta.id) + if any(patch.column_id not in known for patch in columns): + raise DomainValidationError(UNKNOWN_COLUMN, "колонка не принадлежит форме") + if len({patch.column_id for patch in columns}) != len(columns): + raise DomainValidationError(DUPLICATE_COLUMN, "колонка указана в запросе дважды") + + async def _clear_issues(self, document: Document, field: str) -> None: + """Снять предупреждения сервиса, которые снимает правка (подтверждение) поля шапки.""" + await repo.delete_issues( + self._session, + document.id, + codes=HEADER_CLEARS[field], + kind=DocumentIssueKind.warning, + source=IssueSource.service, + ) + async def _apply_header( - self, - document: Document, - meta: FinancialStatementMeta, - header: schemas.HeaderPatch, - before: dict[str, Any], - after: dict[str, Any], - ) -> None: - """Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента и периода.""" + self, document: Document, meta: FinancialStatementMeta, header: schemas.HeaderPatch, edit: _Edit + ) -> bool: + """Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента. Дата отчёта, + равная текущей, у поля из `needs_review_fields` — подтверждение даты. Возвращает `True`, если дата отчёта + изменилась (нужен пересчёт периода).""" fields = header.model_fields_set + who_changed = False if "inn" in fields and header.inn != meta.inn: - before["inn"], after["inn"] = meta.inn, header.inn + edit.record("inn", meta.inn, header.inn) meta.inn, meta.inn_modified = header.inn, True + await self._clear_issues(document, "inn") + who_changed = True + name = header.organization_name + if "organization_name" in fields and name is not None and name != meta.organization_name: + edit.record("organization_name", meta.organization_name, name) + meta.organization_name, meta.organization_name_modified = name, True + await self._clear_issues(document, "organization_name") + who_changed = True + if who_changed: owner = await self._session.get(LegalEntity, document.legal_entity_id) if owner is None: raise NotFoundError(DOCUMENT_NOT_FOUND) @@ -344,48 +467,76 @@ class StatementService: document.counterparty_check = counterparty_check( meta.inn, meta.organization_name, owner.inn, index.owner_names(owner) ) - if "report_date" in fields and header.report_date != meta.report_date: - before["report_date"], after["report_date"] = meta.report_date, header.report_date - meta.report_date, meta.report_date_modified = header.report_date, True - columns, _ = await repo.load_form(self._session, meta.id) - period = statement_period(meta.report_date, [column.period_date for column in columns]) - document.reporting_date = period - document.period_year = period.year if period is not None else None - document.period_quarter = quarter_of(period) if period is not None else None + date_changed = False + review = [str(item) for item in meta.needs_review_fields] if isinstance(meta.needs_review_fields, list) else [] + if "report_date" in fields: + if header.report_date != meta.report_date: + edit.record("report_date", meta.report_date, header.report_date) + meta.report_date, meta.report_date_modified = header.report_date, True + date_changed = True + await self._clear_issues(document, "report_date") + if "report_date" in review: + meta.needs_review_fields = [item for item in review if item != "report_date"] + elif header.report_date is not None and "report_date" in review: + edit.record( + "needs_review_fields", review, [item for item in review if item != "report_date"], data=False + ) + meta.needs_review_fields = [item for item in review if item != "report_date"] + await self._clear_issues(document, "report_date") if "units" in fields and header.units is not None and header.units != meta.units: - before["units"], after["units"] = meta.units, header.units + edit.record("units", meta.units, header.units) meta.units, meta.units_modified = header.units, True + return date_changed + + async def _refresh_period(self, document: Document, meta: FinancialStatementMeta, *, columns_changed: bool) -> None: + """Период документа — по §9.5 п. 2 (дата отчёта, иначе самая поздняя дата колонки); `is_current` колонок — + колонка с самой поздней датой (меньший `column_index` при равенстве), как при приёме.""" + columns, _ = await repo.load_form(self._session, meta.id) + if columns_changed: + latest = max((c.period_date for c in columns if c.period_date is not None), default=None) + first = min((c.column_index for c in columns if c.period_date == latest), default=None) + for column in columns: + column.is_current = None if latest is None else column.column_index == first + period = statement_period(meta.report_date, [column.period_date for column in columns]) + document.reporting_date = period + document.period_year = period.year if period is not None else None + 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`, сброс акцепта, пересчёт статуса.""" + """Правка значений, дат колонок и шапки (D-7): одна транзакция, один `audit_event`, один пересчёт. Порядок + частей — `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 цифр") - before: dict[str, Any] = {} - after: dict[str, Any] = {} - await self._apply_cells(document, meta, body, before, after) + await self._check_columns(meta, body.columns) + edit = _Edit({}, {}) + await self._apply_cells(document, meta, body, edit) + columns_changed = await self._apply_columns(meta, body.columns, edit) + date_changed = False if header is not None: - await self._apply_header(document, meta, header, before, after) - if not after: + date_changed = await self._apply_header(document, meta, header, edit) + if not edit.after: return await self.build_out(document, meta) - if meta.checks_accepted_at is not None: - before["checks_accepted"] = { - "by": meta.checks_accepted_by, - "at": meta.checks_accepted_at, - "comment": meta.checks_accepted_comment, - } - after["checks_accepted"] = None + if columns_changed or date_changed: + await self._refresh_period(document, meta, columns_changed=columns_changed) + if edit.data_edited and meta.checks_accepted_at is not None: + edit.record( + "checks_accepted", + {"by": meta.checks_accepted_by, "at": meta.checks_accepted_at, "comment": meta.checks_accepted_comment}, + None, + ) meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None await self._session.flush() - await self.recompute_status(document, meta) + await self.recompute(document, meta) await self._audit.record( action=AuditAction.RECOGNIZED_DATA_EDIT, section=AuditSection.RECOGNITION, object_type=OBJECT_TYPE, object_id=document.id, - before_value=before, - after_value=after, + before_value=edit.before, + after_value=edit.after, ) return await self.build_out(document, meta) @@ -398,7 +549,7 @@ class StatementService: by = self._audit.user_id meta.checks_accepted_by, meta.checks_accepted_at, meta.checks_accepted_comment = by, now, body.comment await self._session.flush() - await self.recompute_status(document, meta) + await self.recompute(document, meta) await self._audit.record( action=AuditAction.RECOGNIZED_DATA_EDIT, section=AuditSection.RECOGNITION, diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 2d3efde..618fd86 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -1897,3 +1897,414 @@ async def test_lines_order_follows_layout( unreadable = next(i for i, line in enumerate(lines) if line["line_status"] == "unreadable") assert unreadable > 0 assert lines[unreadable]["line_code_text"] == "12?0" + + +# --- A12: PATCH — подтверждение ячейки, дата колонки, наименование организации, подтверждение даты --- + +NOW = datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC) + + +async def _user_id(session: AsyncSession) -> uuid.UUID: + return (await session.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() + + +async def _accept(session: AsyncSession, document: Document) -> None: + """Акцепт проверок в БД теста (как будто аналитик акцептовал нарушение).""" + meta = await _meta(session, document) + meta.checks_accepted_by, meta.checks_accepted_at = await _user_id(session), NOW + meta.checks_accepted_comment = "ок" + await session.flush() + + +async def _add_issue( + session: AsyncSession, + document: Document, + code: str, + *, + column_id: uuid.UUID | None = None, + field: str | None = None, +) -> None: + session.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.warning, + code=code, + severity=IssueSeverity.info, + message=code, + column_id=column_id, + field=field, + source=IssueSource.service, + created_at=NOW, + ) + ) + await session.flush() + + +async def _issue_codes(session: AsyncSession, document: Document) -> list[str]: + stmt = select(DocumentIssue.code).where(DocumentIssue.document_id == document.id).order_by(DocumentIssue.code) + return list((await session.execute(stmt)).scalars()) + + +async def _db_value(session: AsyncSession, line_id: str, column_id: str) -> FinancialStatementValue: + return ( + await session.execute( + select(FinancialStatementValue) + .where( + FinancialStatementValue.line_id == uuid.UUID(line_id), + FinancialStatementValue.column_id == uuid.UUID(column_id), + ) + .execution_options(populate_existing=True) + ) + ).scalar_one() + + +async def _fresh_meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + return ( + await session.execute( + select(FinancialStatementMeta) + .where(FinancialStatementMeta.document_id == document.id) + .execution_options(populate_existing=True) + ) + ).scalar_one() + + +async def _db_columns(session: AsyncSession, document: Document) -> list[FinancialStatementColumn]: + meta = await _meta(session, document) + return list( + ( + await session.execute( + select(FinancialStatementColumn) + .where(FinancialStatementColumn.meta_id == meta.id) + .order_by(FinancialStatementColumn.column_index) + .execution_options(populate_existing=True) + ) + ).scalars() + ) + + +async def _undate_columns(session: AsyncSession, document: Document) -> list[FinancialStatementColumn]: + """Форма без дат: колонки без даты, дата отчёта пуста, период документа не определён.""" + columns = await _db_columns(session, document) + for column in columns: + column.period_date, column.period_kind, column.is_current = None, "none", None + meta = await _meta(session, document) + meta.report_date = None + document.reporting_date = document.period_year = document.period_quarter = None + await session.flush() + return columns + + +async def test_confirm_cell_without_change( + 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) + value = await _db_value(seeded, line["id"], cell["column_id"]) + value.needs_review = True + await _accept(seeded, document) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": True}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + edited = next(item for item in body["lines"] if item["id"] == line["id"])["values"][0] + assert edited["is_confirmed"] is True and edited["is_modified"] is False and edited["needs_review"] is False + assert edited["amount"] == cell["amount"] + value = await _db_value(seeded, line["id"], cell["column_id"]) + assert value.confirmed_by == await _user_id(seeded) and value.confirmed_at is not None + assert body["checks"]["accepted"] is not None + assert len(await _edit_events(seeded, document)) == 1 + + +async def test_confirm_cell_twice_is_noop( + 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) + item = {"line_id": line["id"], "column_id": cell["column_id"], "confirmed": True} + for _ in range(2): + assert ( + await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) + ).status_code == HTTPStatus.OK + assert len(await _edit_events(seeded, document)) == 1 + + +async def test_unconfirm_cell(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) + value = await _db_value(seeded, line["id"], cell["column_id"]) + value.confidence, value.is_confirmed, value.needs_review = Decimal(80), True, False + value.confirmed_by, value.confirmed_at = await _user_id(seeded), NOW + await seeded.flush() + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": False}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] + assert edited["is_confirmed"] is False and edited["needs_review"] is True + value = await _db_value(seeded, line["id"], cell["column_id"]) + assert value.confirmed_by is None and value.confirmed_at is None + assert len(await _edit_events(seeded, document)) == 1 + + +async def test_cell_amount_and_confirmed_is_422( + 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) + address = {"line_id": line["id"], "column_id": cell["column_id"]} + for item in ({**address, "amount": "1", "confirmed": True}, {**address}, {**address, "confirmed": None}): + response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, item + assert isinstance(response.json()["detail"], list) + + +async def test_cell_confirmed_by_line_code_is_422( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + _, cell = await _first_cell(client, document.id) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_code": "1105", "column_id": cell["column_id"], "confirmed": True}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert isinstance(response.json()["detail"], list) + + +async def test_confirm_missing_cell_unknown( + 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) + await seeded.execute( + delete(FinancialStatementValue).where( + FinancialStatementValue.line_id == uuid.UUID(line["id"]), + FinancialStatementValue.column_id == uuid.UUID(cell["column_id"]), + ) + ) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": True}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_CELL" + assert await _edit_events(seeded, document) == [] + + +async def test_patch_organization_name( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО Ромашка") + documents = await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None) + document = next(d for d in documents if d.document_type == DocumentType.BALANCE) + meta = await _meta(seeded, document) + meta.inn, meta.organization_name, meta.organization_name_original_value = None, "прочее", "прочее" + document.counterparty_check = None + await _add_issue(seeded, document, "NO_ORGANIZATION_NO_INN") + await _add_issue(seeded, document, "SEGMENT_COLUMNS_MISMATCH") + await _accept(seeded, document) + client = await auth_client(KA_RF) + response = await client.patch( + f"/documents/{document.id}/statement", json={"header": {"organization_name": " ООО Ромашка "}} + ) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["header"]["organization_name"] == "ООО Ромашка" + assert body["header"]["organization_name_modified"] is True + assert body["document"]["counterparty_check"] == "MATCH" + assert body["checks"]["accepted"] is None + meta = await _fresh_meta(seeded, document) + assert meta.organization_name_original_value == "прочее" + assert await _issue_codes(seeded, document) == ["SEGMENT_COLUMNS_MISMATCH"] + [event] = await _edit_events(seeded, document) + assert event.before_value["organization_name"] == "прочее" + assert event.after_value["organization_name"] == "ООО Ромашка" + + +async def test_patch_organization_name_invalid_is_422( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + for name in ("", " ", "я" * 1001): + response = await client.patch( + f"/documents/{document.id}/statement", json={"header": {"organization_name": name}} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, name + assert isinstance(response.json()["detail"], list) + + +async def test_confirm_report_date(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + meta.needs_review_fields = ["report_date"] + await _add_issue(seeded, document, "REPORT_DATE_UNCERTAIN", field="report_date") + await _accept(seeded, document) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + current = (await client.get(url)).json()["header"]["report_date"]["value"] + assert current is not None + response = await client.patch(url, json={"header": {"report_date": current}}) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["header"]["needs_review_fields"] == [] and body["checks"]["accepted"] is not None + assert body["header"]["report_date"]["modified"] is not True + assert await _issue_codes(seeded, document) == [] + assert len(await _edit_events(seeded, document)) == 1 + again = await client.patch(url, json={"header": {"report_date": current}}) # подтверждать уже нечего + assert again.status_code == HTTPStatus.OK + assert len(await _edit_events(seeded, document)) == 1 + + +async def test_report_date_change_clears_warnings( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + for code in ("PERIOD_UNKNOWN", "REPORT_DATE_UNPARSED", "REPORT_DATE_UNCERTAIN", "SEGMENT_COLUMNS_MISMATCH"): + await _add_issue(seeded, document, code) + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}}) + assert response.status_code == HTTPStatus.OK, response.text + assert await _issue_codes(seeded, document) == ["SEGMENT_COLUMNS_MISMATCH"] + + +async def test_inn_change_clears_no_organization( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + inn = meta.inn + meta.inn = None + await _add_issue(seeded, document, "NO_ORGANIZATION_NO_INN") + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": inn}}) + assert response.status_code == HTTPStatus.OK, response.text + assert await _issue_codes(seeded, document) == [] + + +async def test_column_date(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + columns = await _undate_columns(seeded, document) + await _add_issue(seeded, document, "COLUMN_WITHOUT_DATE", column_id=columns[0].id) + await _add_issue(seeded, document, "COLUMN_WITHOUT_DATE", column_id=columns[1].id) + await _accept(seeded, document) + client = await auth_client(KA_RF) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"columns": [{"column_id": str(columns[0].id), "period_date": "2024-12-31"}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + column = next(c for c in body["columns"] if c["id"] == str(columns[0].id)) + assert column["period_date"] == "2024-12-31" and column["period_kind"] == "absolute" + assert column["is_current"] is True + assert body["document"]["reporting_date"] == "2024-12-31" and body["document"]["period_quarter"] == "Q4" + assert body["checks"]["accepted"] is None + [issue] = (await _statement(client, document))["issues"] + assert issue["column_id"] == str(columns[1].id) # предупреждение другой колонки осталось + [event] = await _edit_events(seeded, document) + assert event.after_value + + +async def test_column_date_null_is_422( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + column = await _column(client, document.id, 0) + for item in ({"column_id": column, "period_date": None}, {"column_id": column}): + response = await client.patch(f"/documents/{document.id}/statement", json={"columns": [item]}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, item + assert isinstance(response.json()["detail"], list) + + +async def test_unknown_column_is_422(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + other = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) + client = await auth_client(KA_RF) + foreign = await _column(client, other.id, 0) + mine = await _column(client, document.id, 0) + for column in (str(uuid.uuid4()), foreign): + response = await client.patch( + f"/documents/{document.id}/statement", + json={"columns": [{"column_id": column, "period_date": "2024-12-31"}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_COLUMN" + duplicate = await client.patch( + f"/documents/{document.id}/statement", + json={ + "columns": [ + {"column_id": mine, "period_date": "2024-12-31"}, + {"column_id": mine, "period_date": "2024-06-30"}, + ] + }, + ) + assert duplicate.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert duplicate.json()["detail"]["code"] == "DUPLICATE_COLUMN" + assert await _edit_events(seeded, document) == [] + + +async def test_confirm_column_date(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + column = (await _db_columns(seeded, document))[0] + column.period_kind = "relative:prev_year_end" + await _add_issue(seeded, document, "COLUMN_DATE_UNCERTAIN", column_id=column.id) + await _accept(seeded, document) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + item = {"column_id": str(column.id), "period_date": column.period_date.isoformat()} + response = await client.patch(url, json={"columns": [item]}) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert next(c for c in body["columns"] if c["id"] == str(column.id))["period_kind"] == "absolute" + assert body["checks"]["accepted"] is not None and body["issues"] == [] + assert len(await _edit_events(seeded, document)) == 1 + again = await client.patch(url, json={"columns": [item]}) # без предупреждения — без событий + assert again.status_code == HTTPStatus.OK + assert len(await _edit_events(seeded, document)) == 1 + + +async def test_relative_column_becomes_absolute( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + column = (await _db_columns(seeded, document))[1] + column.period_kind, column.period_date = "relative:prev_year_end", datetime.date(2024, 12, 30) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"columns": [{"column_id": str(column.id), "period_date": "2024-12-31"}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + changed = next(c for c in response.json()["columns"] if c["id"] == str(column.id)) + assert changed["period_kind"] == "absolute" and changed["period_date"] == "2024-12-31" + + +async def test_segment_warning_stays_after_value_edit( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + await _add_issue(seeded, document, "SEGMENT_COLUMNS_MISMATCH") + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)} + response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) + assert response.status_code == HTTPStatus.OK, response.text + assert await _issue_codes(seeded, document) == ["SEGMENT_COLUMNS_MISMATCH"] + revert = {**item, "amount": cell["amount"]} + assert ( + await client.patch(f"/documents/{document.id}/statement", json={"values": [revert]}) + ).status_code == HTTPStatus.OK + confirm = await client.post(f"/documents/{document.id}/confirm") + assert confirm.status_code == HTTPStatus.OK, confirm.text