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:
parent
361e7094e3
commit
f1fcb861a6
20
README.md
20
README.md
@ -549,7 +549,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл
|
||||
Коды ошибок этих маршрутов — в таблице «Ошибки API» (`FILE_TOO_LARGE`, `UNSUPPORTED_FILE`, `DUPLICATE_FILE`,
|
||||
`STORAGE_UNAVAILABLE`, `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`,
|
||||
`FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS`, `FILE_CONTENT_NOT_FOUND`) и форм ББ/ОФР (`DOCUMENT_NOT_STATEMENT`,
|
||||
`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`,
|
||||
`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`,
|
||||
`CHECKS_NOT_PASSED`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел
|
||||
`PRIMARY_DOCUMENTS`), `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`; правка формы и акцепт).
|
||||
|
||||
@ -589,12 +589,20 @@ S3 (storage); без S3 — предупреждение в логе, цикл
|
||||
Карточка документа (`GET /documents/{id}`) несёт `issues[]` (у документа любого типа) и `issue_counts {error, info}`;
|
||||
`documents[]` карточки файла — `issue_counts` (все виды записей по `severity`, в том числе акцептованные; один групповой
|
||||
запрос на список документов).
|
||||
- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id | line_code, column_id, amount}]}`
|
||||
(до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов,
|
||||
- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?, organization_name?}, values?: [{line_id | line_code, column_id, amount | confirmed}], columns?: [{column_id, period_date}]}`
|
||||
(до 5000 ячеек, до 100 колонок; в элементе `values[]` ровно одно из `amount` и `confirmed`, `confirmed: true | false` — подтвердить ячейку
|
||||
или снять подтверждение без правки суммы, только с `line_id`; `columns[].period_date` обязательна, `null` — 422 массивом; ручная дата
|
||||
ставит колонке `period_kind = absolute` и пересчитывает `is_current` и период документа; дата, равная текущей, у колонки с
|
||||
`COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN` — подтверждение; `header.organization_name` — непустая строка до 1000 символов,
|
||||
пересчитывает `counterparty_check`; `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты;
|
||||
правка поля шапки или даты колонки снимает предупреждения `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`,
|
||||
`NO_ORGANIZATION_NO_INN`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`; подтверждения акцепт проверок не сбрасывают;
|
||||
`amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов,
|
||||
пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма
|
||||
(`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл —
|
||||
409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`,
|
||||
одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно
|
||||
409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`
|
||||
(в том числе подтверждение ячейки, которой нет в БД), колонка не этой формы — 422 `UNKNOWN_COLUMN`, одна колонка дважды — 422
|
||||
`DUPLICATE_COLUMN`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно
|
||||
событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН
|
||||
пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус.
|
||||
`values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме.
|
||||
@ -922,6 +930,8 @@ email без учёта регистра проверяет только сер
|
||||
| `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) |
|
||||
| `UNKNOWN_CELL` | `422` | `PATCH …/statement`: `line_id`/`column_id` не этой формы; `line_code` уже есть в форме (передайте `line_id`), нет в справочнике редакции или редакция не определена |
|
||||
| `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды |
|
||||
| `UNKNOWN_COLUMN` | 422 | `PATCH …/statement`: `columns[].column_id` не этой формы; ничего не применено |
|
||||
| `DUPLICATE_COLUMN` | 422 | `PATCH …/statement`: одна колонка указана в `columns[]` дважды |
|
||||
| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
|
||||
| `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта |
|
||||
| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку |
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user