feat(statements): PATCH — подтверждение ячейки, дата колонки, наименование организации, подтверждение даты

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 16:33:52 +04:00
parent 361e7094e3
commit f1fcb861a6
6 changed files with 753 additions and 79 deletions

View File

@ -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` — назначьте код или очистите строку |

View File

@ -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:

View File

@ -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]:

View File

@ -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):

View File

@ -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,

View File

@ -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