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`,
|
Коды ошибок этих маршрутов — в таблице «Ошибки 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` — назначьте код или очистите строку |
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user