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`, Коды ошибок этих маршрутов — в таблице «Ошибки API» (`FILE_TOO_LARGE`, `UNSUPPORTED_FILE`, `DUPLICATE_FILE`,
`STORAGE_UNAVAILABLE`, `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`, `STORAGE_UNAVAILABLE`, `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`,
`FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS`, `FILE_CONTENT_NOT_FOUND`) и форм ББ/ОФР (`DOCUMENT_NOT_STATEMENT`, `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` (раздел `CHECKS_NOT_PASSED`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел
`PRIMARY_DOCUMENTS`), `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`; правка формы и акцепт). `PRIMARY_DOCUMENTS`), `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`; правка формы и акцепт).
@ -589,12 +589,20 @@ S3 (storage); без S3 — предупреждение в логе, цикл
Карточка документа (`GET /documents/{id}`) несёт `issues[]` (у документа любого типа) и `issue_counts {error, info}`; Карточка документа (`GET /documents/{id}`) несёт `issues[]` (у документа любого типа) и `issue_counts {error, info}`;
`documents[]` карточки файла — `issue_counts` (все виды записей по `severity`, в том числе акцептованные; один групповой `documents[]` карточки файла — `issue_counts` (все виды записей по `severity`, в том числе акцептованные; один групповой
запрос на список документов). запрос на список документов).
- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id | line_code, column_id, amount}]}` - **`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 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, (до 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`, ИНН не очищается). Правится только неподтверждённая форма пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма
(`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл — (`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл —
409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`, 409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`
одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно (в том числе подтверждение ячейки, которой нет в БД), колонка не этой формы — 422 `UNKNOWN_COLUMN`, одна колонка дважды — 422
`DUPLICATE_COLUMN`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно
событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН
пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус.
`values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме. `values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме.
@ -922,6 +930,8 @@ email без учёта регистра проверяет только сер
| `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) | | `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) |
| `UNKNOWN_CELL` | `422` | `PATCH …/statement`: `line_id`/`column_id` не этой формы; `line_code` уже есть в форме (передайте `line_id`), нет в справочнике редакции или редакция не определена | | `UNKNOWN_CELL` | `422` | `PATCH …/statement`: `line_id`/`column_id` не этой формы; `line_code` уже есть в форме (передайте `line_id`), нет в справочнике редакции или редакция не определена |
| `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | | `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды |
| `UNKNOWN_COLUMN` | 422 | `PATCH …/statement`: `columns[].column_id` не этой формы; ничего не применено |
| `DUPLICATE_COLUMN` | 422 | `PATCH …/statement`: одна колонка указана в `columns[]` дважды |
| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | | `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
| `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта | | `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта |
| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку | | `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 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" 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) @dataclass(frozen=True)
class IssueDraft: class IssueDraft:

View File

@ -3,18 +3,26 @@
import datetime import datetime
import uuid import uuid
from collections.abc import Callable, Sequence from collections.abc import Callable, Collection, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Any 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 sqlalchemy.ext.asyncio import AsyncSession
from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code
from src.domain.statement_issues import IssueDraft from src.domain.statement_issues import IssueDraft
from src.domain.statement_mapping import StatementDraft 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.files import Document, StoredFile
from src.models.statements import ( from src.models.statements import (
DocumentIssue, DocumentIssue,
@ -33,10 +41,12 @@ __all__ = [
"column_ids", "column_ids",
"delete_documents_statements", "delete_documents_statements",
"delete_file_statements", "delete_file_statements",
"delete_issues",
"IssueCounts", "IssueCounts",
"form_aggregate", "form_aggregate",
"form_lines", "form_lines",
"get_meta", "get_meta",
"has_review_values",
"has_source_lines", "has_source_lines",
"insert_statement", "insert_statement",
"issue_counts_by_document", "issue_counts_by_document",
@ -44,6 +54,7 @@ __all__ = [
"load_form", "load_form",
"lock_cells", "lock_cells",
"pair_amounts", "pair_amounts",
"settle_edited_values",
"value_flags", "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: async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None:
"""`document_issue`, значения, строки, колонки и шапки форм всех документов файла (в порядке FK).""" """`document_issue`, значения, строки, колонки и шапки форм всех документов файла (в порядке FK)."""
documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() 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] 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( async def lock_cells(
session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]] session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]]
) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]: ) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]:

View File

@ -419,37 +419,60 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
class CellPatch(BaseModel): class CellPatch(BaseModel):
"""Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить. """Правка ячейки: новая сумма (не более двух знаков и 16 цифр до запятой — `numeric(18,2)`, `null` — очистить) либо
подтверждение (`confirmed`) без изменения значения. Ровно одно из двух; наличие `amount` — по `model_fields_set`.
Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` — Ячейка адресуется ровно одним из: `line_id` — строка формы (если ячейки в колонке нет, она создаётся); `line_code` —
строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4).""" строка справочника редакции, которой в форме нет (создаётся при первой непустой правке; design D-4).
`confirmed` — только с `line_id` (подтверждать нечего у несозданной строки)."""
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
line_id: uuid.UUID | None = None line_id: uuid.UUID | None = None
line_code: str | None = Field(None, max_length=10) line_code: str | None = Field(None, max_length=10)
column_id: uuid.UUID column_id: uuid.UUID
amount: Decimal | None = Field(max_digits=18, decimal_places=2) amount: Decimal | None = Field(None, max_digits=18, decimal_places=2)
confirmed: bool | None = None
@model_validator(mode="after") @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): if (self.line_id is None) == (self.line_code is None):
raise ValueError("укажите ровно одно из line_id и line_code") 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 return self
@property
def sets_amount(self) -> bool:
return self.confirmed is None
class HeaderPatch(BaseModel): class HeaderPatch(BaseModel):
"""Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422 """Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422
`INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента). `INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента).
`units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50 `units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50
символов (пробельная строка — 422).""" символов (пробельная строка — 422). `organization_name` — непустая строка после обрезки, до 1000 символов."""
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
inn: str | None = Field(None, description="10 или 12 цифр; null не допускается (422 INVALID_INN)") inn: str | None = Field(None, description="10 или 12 цифр; null не допускается (422 INVALID_INN)")
report_date: datetime.date | None = None report_date: datetime.date | None = None
units: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)] | 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): class StatementPatch(BaseModel):
@ -457,6 +480,7 @@ class StatementPatch(BaseModel):
header: HeaderPatch | None = None header: HeaderPatch | None = None
values: list[CellPatch] = Field(default_factory=list, max_length=5000) values: list[CellPatch] = Field(default_factory=list, max_length=5000)
columns: list[ColumnPatch] = Field(default_factory=list, max_length=100)
class ChecksAcceptIn(BaseModel): class ChecksAcceptIn(BaseModel):

View File

@ -7,6 +7,7 @@
import re import re
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -25,21 +26,29 @@ from src.domain.statement_checks import (
run_checks, run_checks,
subline_parents, 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_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.directories import LegalEntity
from src.models.enums import ( from src.models.enums import (
AuditAction, AuditAction,
AuditSection, AuditSection,
DocumentCheckResult, DocumentCheckResult,
DocumentIssueKind,
DocumentStatus, DocumentStatus,
DocumentType, DocumentType,
FileStatus, FileStatus,
IssueSource,
RecognitionStatus, RecognitionStatus,
StatementLineStatus, StatementLineStatus,
) )
from src.models.files import Document, RecognitionJob 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 files as files_repo
from src.repositories import statements as repo from src.repositories import statements as repo
from src.repositories.counterparties import CounterpartyIndex from src.repositories.counterparties import CounterpartyIndex
@ -57,12 +66,15 @@ DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
DOCUMENT_READONLY = "DOCUMENT_READONLY" DOCUMENT_READONLY = "DOCUMENT_READONLY"
LINE_EXISTS = "строка уже есть в форме — передайте line_id" LINE_EXISTS = "строка уже есть в форме — передайте line_id"
UNKNOWN_CELL = "UNKNOWN_CELL" UNKNOWN_CELL = "UNKNOWN_CELL"
UNKNOWN_COLUMN = "UNKNOWN_COLUMN"
DUPLICATE_COLUMN = "DUPLICATE_COLUMN"
DUPLICATE_CELL = "DUPLICATE_CELL" DUPLICATE_CELL = "DUPLICATE_CELL"
NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT"
INVALID_INN = "INVALID_INN" INVALID_INN = "INVALID_INN"
LINES_UNRESOLVED = "LINES_UNRESOLVED" LINES_UNRESOLVED = "LINES_UNRESOLVED"
DUPLICATE_FORM = "DUPLICATE_FORM" DUPLICATE_FORM = "DUPLICATE_FORM"
OBJECT_TYPE = "document" OBJECT_TYPE = "document"
ABSOLUTE = "absolute"
INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII) INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
@ -144,6 +156,28 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia
return None 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: class StatementService:
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
self._session = session self._session = session
@ -230,36 +264,34 @@ class StatementService:
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы).""" """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
return await statement_hard_failures(self._session, document, meta) return await statement_hard_failures(self._session, document, meta)
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None: async def recompute(self, document: Document, meta: FinancialStatementMeta) -> None:
"""D-6 после правки или акцепта; `CONFIRMED` не трогает. """Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6); `CONFIRMED`
не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б.
Предупреждения маппинга файла не хранятся на уровне документа, поэтому передаются пустыми (`warnings=()`). Предупреждения маппинга файла не хранятся на уровне документа, поэтому передаются пустыми (`warnings=()`).
""" """
if document.status is DocumentStatus.CONFIRMED: if document.status is DocumentStatus.CONFIRMED:
return return
await repo.settle_edited_values(self._session, meta.id)
job = await self._session.get(RecognitionJob, meta.recognition_job_id) job = await self._session.get(RecognitionJob, meta.recognition_job_id)
document.status = statement_status( document.status = statement_status(
job_done=job is not None and job.status is RecognitionStatus.done, job_done=job is not None and job.status is RecognitionStatus.done,
checks_passed=not await self.hard_failures(document, meta), checks_passed=not await self.hard_failures(document, meta),
counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN,
period_known=document.period_year is not None and document.period_quarter is not None, 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), has_lines=await repo.has_source_lines(self._session, meta.id),
warnings=(), warnings=(),
) )
document.updated_at = utc_now() document.updated_at = utc_now()
async def _apply_cells( async def _apply_cells(
self, self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch, edit: _Edit
document: Document,
meta: FinancialStatementMeta,
body: schemas.StatementPatch,
before: dict[str, Any],
after: dict[str, Any],
) -> None: ) -> None:
"""Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): `original_amount` при первой """Значения `body.values` (D-7 Среза 3b; design D-4 statement-template-lines): сумма — `original_amount` при
правке, `is_modified`, `modified_by/at`. Ячейка, которой нет в БД, создаётся при первой непустой правке; строка первой правке, `is_modified`, `modified_by/at`; `confirmed` — подтверждение без правки значения (Р-25).
по `line_code` — из справочника редакции документа. Вся адресация проверяется до первой вставки.""" Ячейка, которой нет в БД, создаётся при первой непустой правке суммы; строка по `line_code` — из справочника
редакции документа. Вся адресация проверяется до первой вставки."""
if not body.values: if not body.values:
return return
keys = [(cell.line_id or cell.line_code, cell.column_id) for cell in body.values] 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( existing = await repo.lock_cells(
self._session, meta.id, [(line.id, cell.column_id) for cell, line, _ in targets if line is not None] 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] = {} created: dict[str, FinancialStatementLine] = {}
now = utc_now() now = utc_now()
for cell, line, item in targets: for cell, line, item in targets:
value = existing.get((line.id, cell.column_id)) if line is not None else None 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 value is None:
if cell.amount is None: if cell.amount is None:
continue # пустая сумма в несуществующую ячейку — не изменение continue # пустая сумма в несуществующую ячейку — не изменение
target = line line = line or created.get(item.code if item else "") # noqa: PLW2901 - строка, созданная выше
if target is None and item is not None: if line is None and item is not None:
target = created.get(item.code) line = created[item.code] = await repo.add_line(self._session, meta.id, item.code, item.name) # noqa: PLW2901
if target is None: if line is None:
target = created[item.code] = await repo.add_line(self._session, meta.id, item.code, item.name)
if target is None:
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
value = await repo.add_value(self._session, target.id, cell.column_id) value = await repo.add_value(self._session, line.id, cell.column_id)
if value.amount == cell.amount: if line is not None:
continue self._apply_amount(value, line, cell.amount, edit, now)
key = f"value:{value.id}"
before[key], after[key] = value.amount, cell.amount def _apply_amount(
if not value.is_modified and value.original_amount is None: self,
value.original_amount = value.amount value: FinancialStatementValue,
value.amount = cell.amount line: FinancialStatementLine,
value.is_modified = value.amount != value.original_amount amount: Decimal | None,
value.modified_by, value.modified_at = self._audit.user_id, now 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( async def _resolve_cells(
self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch
@ -324,19 +390,76 @@ class StatementService:
targets.append((cell, None, item)) targets.append((cell, None, item))
return targets 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( async def _apply_header(
self, self, document: Document, meta: FinancialStatementMeta, header: schemas.HeaderPatch, edit: _Edit
document: Document, ) -> bool:
meta: FinancialStatementMeta, """Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента. Дата отчёта,
header: schemas.HeaderPatch, равная текущей, у поля из `needs_review_fields` — подтверждение даты. Возвращает `True`, если дата отчёта
before: dict[str, Any], изменилась (нужен пересчёт периода)."""
after: dict[str, Any],
) -> None:
"""Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента и периода."""
fields = header.model_fields_set fields = header.model_fields_set
who_changed = False
if "inn" in fields and header.inn != meta.inn: 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 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) owner = await self._session.get(LegalEntity, document.legal_entity_id)
if owner is None: if owner is None:
raise NotFoundError(DOCUMENT_NOT_FOUND) raise NotFoundError(DOCUMENT_NOT_FOUND)
@ -344,48 +467,76 @@ class StatementService:
document.counterparty_check = counterparty_check( document.counterparty_check = counterparty_check(
meta.inn, meta.organization_name, owner.inn, index.owner_names(owner) meta.inn, meta.organization_name, owner.inn, index.owner_names(owner)
) )
if "report_date" in fields and header.report_date != meta.report_date: date_changed = False
before["report_date"], after["report_date"] = meta.report_date, header.report_date review = [str(item) for item in meta.needs_review_fields] if isinstance(meta.needs_review_fields, list) else []
meta.report_date, meta.report_date_modified = header.report_date, True if "report_date" in fields:
columns, _ = await repo.load_form(self._session, meta.id) if header.report_date != meta.report_date:
period = statement_period(meta.report_date, [column.period_date for column in columns]) edit.record("report_date", meta.report_date, header.report_date)
document.reporting_date = period meta.report_date, meta.report_date_modified = header.report_date, True
document.period_year = period.year if period is not None else None date_changed = True
document.period_quarter = quarter_of(period) if period is not None else None 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: 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 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: 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) document, meta = await self._editable(document_id)
header = body.header header = body.header
if header is not None and "inn" in header.model_fields_set and not INN_RE.fullmatch(header.inn or ""): 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 цифр") raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр")
before: dict[str, Any] = {} await self._check_columns(meta, body.columns)
after: dict[str, Any] = {} edit = _Edit({}, {})
await self._apply_cells(document, meta, body, before, after) 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: if header is not None:
await self._apply_header(document, meta, header, before, after) date_changed = await self._apply_header(document, meta, header, edit)
if not after: if not edit.after:
return await self.build_out(document, meta) return await self.build_out(document, meta)
if meta.checks_accepted_at is not None: if columns_changed or date_changed:
before["checks_accepted"] = { await self._refresh_period(document, meta, columns_changed=columns_changed)
"by": meta.checks_accepted_by, if edit.data_edited and meta.checks_accepted_at is not None:
"at": meta.checks_accepted_at, edit.record(
"comment": meta.checks_accepted_comment, "checks_accepted",
} {"by": meta.checks_accepted_by, "at": meta.checks_accepted_at, "comment": meta.checks_accepted_comment},
after["checks_accepted"] = None None,
)
meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None
await self._session.flush() await self._session.flush()
await self.recompute_status(document, meta) await self.recompute(document, meta)
await self._audit.record( await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT, action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION, section=AuditSection.RECOGNITION,
object_type=OBJECT_TYPE, object_type=OBJECT_TYPE,
object_id=document.id, object_id=document.id,
before_value=before, before_value=edit.before,
after_value=after, after_value=edit.after,
) )
return await self.build_out(document, meta) return await self.build_out(document, meta)
@ -398,7 +549,7 @@ class StatementService:
by = self._audit.user_id by = self._audit.user_id
meta.checks_accepted_by, meta.checks_accepted_at, meta.checks_accepted_comment = by, now, body.comment meta.checks_accepted_by, meta.checks_accepted_at, meta.checks_accepted_comment = by, now, body.comment
await self._session.flush() await self._session.flush()
await self.recompute_status(document, meta) await self.recompute(document, meta)
await self._audit.record( await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT, action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION, 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") unreadable = next(i for i, line in enumerate(lines) if line["line_status"] == "unreadable")
assert unreadable > 0 assert unreadable > 0
assert lines[unreadable]["line_code_text"] == "12?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