feat(documents): порядок 409 подтверждения формы — LINES_UNRESOLVED, DUPLICATE_FORM
form_confirmable заменён на form_blocker: PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED. Ручное и автоподтверждение используют одну функцию. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
parent
b57431a234
commit
361e7094e3
@ -483,7 +483,7 @@ curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN
|
||||
- `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит) либо система при автоподтверждении чистого документа (`confirmed_by = null`, аудит `DOCUMENT_CONFIRM` без пользователя; OQ-157);
|
||||
- `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл).
|
||||
|
||||
Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_confirmable`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`.
|
||||
Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_blocker`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`.
|
||||
|
||||
`counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла;
|
||||
`period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде.
|
||||
@ -533,7 +533,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл
|
||||
| `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 |
|
||||
| `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` |
|
||||
| `GET /documents/{document_id}/entries` | `DOCUMENT_VIEW` | строки ОСВ по `row_number` (`limit` ≤ 2000, по умолчанию 500); дерево — по `parent_entry_id` |
|
||||
| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для автоподтверждённого документа — 409 `ALREADY_CONFIRMED` (D-6); для формы ББ/ОФР — ещё `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` |
|
||||
| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для автоподтверждённого документа — 409 `ALREADY_CONFIRMED` (D-6); для формы ББ/ОФР — ещё (в порядке проверки) `PERIOD_REQUIRED`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `CHECKS_NOT_PASSED` |
|
||||
| `GET /documents/{document_id}/statement` | `DOCUMENT_VIEW` | форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР») |
|
||||
| `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` |
|
||||
| `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) |
|
||||
@ -924,6 +924,8 @@ email без учёта регистра проверяет только сер
|
||||
| `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды |
|
||||
| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
|
||||
| `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта |
|
||||
| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку |
|
||||
| `DUPLICATE_FORM` | 409 | подтверждение формы: в файле уже подтверждена форма того же типа, года и квартала; `detail.document_id` — подтверждённая форма |
|
||||
| `CHECKS_NOT_PASSED` | 409 | подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; `detail.failures[] {column_index, relation, expected, actual}` |
|
||||
| `TOO_MANY_IDS` | 422 | `GET /users/brief`: больше 200 id |
|
||||
| `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID |
|
||||
|
||||
@ -20,7 +20,7 @@ from src.models.access import AppUser
|
||||
from src.models.audit_ex import Comment
|
||||
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
|
||||
from src.models.directories import LegalEntity
|
||||
from src.models.enums import FileStatus, S3BucketRole
|
||||
from src.models.enums import DocumentStatus, FileStatus, S3BucketRole
|
||||
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
||||
from src.models.osv import OsvEntry
|
||||
from src.repositories import statements as statements_repo
|
||||
@ -194,6 +194,26 @@ async def lock_file_documents(session: AsyncSession, file_id: uuid.UUID) -> list
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def confirmed_twin(session: AsyncSession, document: Document) -> uuid.UUID | None:
|
||||
"""Подтверждённая форма того же файла, типа, года и квартала (другой `file_ordinal`) — для `DUPLICATE_FORM`."""
|
||||
if document.period_year is None or document.period_quarter is None:
|
||||
return None
|
||||
stmt = (
|
||||
select(Document.id)
|
||||
.where(
|
||||
Document.stored_file_id == document.stored_file_id,
|
||||
Document.document_type == document.document_type,
|
||||
Document.period_year == document.period_year,
|
||||
Document.period_quarter == document.period_quarter,
|
||||
Document.status == DocumentStatus.CONFIRMED,
|
||||
Document.id != document.id,
|
||||
)
|
||||
.order_by(Document.file_ordinal)
|
||||
.limit(1)
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def supersedes_others(session: AsyncSession, file_id: uuid.UUID) -> bool:
|
||||
"""На файл ссылаются `superseded_by_file_id` других файлов (файл заменил другие версии)."""
|
||||
stmt = select(select(StoredFile.id).where(StoredFile.superseded_by_file_id == file_id).exists())
|
||||
|
||||
@ -8,7 +8,7 @@ from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, insert, select
|
||||
from sqlalchemy import delete, func, insert, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code
|
||||
@ -203,6 +203,29 @@ async def has_source_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool:
|
||||
return bool((await session.execute(stmt)).scalar_one())
|
||||
|
||||
|
||||
async def unresolved_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[uuid.UUID, int | None, str | None]]:
|
||||
"""Строки `unreadable`/`missing` с непустой ячейкой (`amount` или `string_value`) по `row_index`:
|
||||
`(line_id, row_index, line_code_text)`. Строки без значений подтверждение не блокируют."""
|
||||
has_value = (
|
||||
select(FinancialStatementValue.id)
|
||||
.where(
|
||||
FinancialStatementValue.line_id == FinancialStatementLine.id,
|
||||
or_(FinancialStatementValue.amount.is_not(None), FinancialStatementValue.string_value.is_not(None)),
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
stmt = (
|
||||
select(FinancialStatementLine.id, FinancialStatementLine.row_index, FinancialStatementLine.line_code_text)
|
||||
.where(
|
||||
FinancialStatementLine.meta_id == meta_id,
|
||||
FinancialStatementLine.line_status.in_((StatementLineStatus.unreadable, StatementLineStatus.missing)),
|
||||
has_value,
|
||||
)
|
||||
.order_by(FinancialStatementLine.row_index.asc().nulls_last(), FinancialStatementLine.id)
|
||||
)
|
||||
return [(row[0], row[1], row[2]) for row in (await session.execute(stmt)).all()]
|
||||
|
||||
|
||||
async def form_aggregate(
|
||||
session: AsyncSession,
|
||||
meta_id: uuid.UUID,
|
||||
|
||||
@ -17,7 +17,7 @@ from src.models.files import Document, StoredFile
|
||||
from src.repositories import statements as statements_repo
|
||||
from src.services import consolidation_documents
|
||||
from src.services.audit import AuditService
|
||||
from src.services.statements import FORM_TYPES, form_confirmable
|
||||
from src.services.statements import FORM_TYPES, form_blocker
|
||||
|
||||
AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний"
|
||||
OBJECT_TYPE = "document"
|
||||
@ -34,24 +34,25 @@ def transition_comment(document: Document) -> str:
|
||||
async def auto_confirm(
|
||||
session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime
|
||||
) -> list[Document]:
|
||||
"""Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие
|
||||
не трогает. Возвращает подтверждённые документы по возрастанию `id`."""
|
||||
"""Подтверждает документы `RECOGNIZED` файла (форма — только при `form_blocker is None`, D-3); `NEEDS_CHECK`
|
||||
и прочие не трогает. Возвращает подтверждённые документы по возрастанию `id`."""
|
||||
audit = AuditService(session)
|
||||
confirmed: list[Document] = []
|
||||
for document in sorted(documents, key=lambda item: item.id):
|
||||
if document.status is not DocumentStatus.RECOGNIZED:
|
||||
continue
|
||||
if not await form_confirmable(session, document):
|
||||
continue
|
||||
meta = None
|
||||
if document.document_type in FORM_TYPES:
|
||||
meta = await statements_repo.get_meta(session, document.id, refresh=True)
|
||||
if meta is not None and await form_blocker(session, document, meta) is not None:
|
||||
continue
|
||||
document.status = DocumentStatus.CONFIRMED
|
||||
document.confirmed_by = None
|
||||
document.confirmed_at = now
|
||||
document.updated_at = now
|
||||
if document.document_type in FORM_TYPES:
|
||||
meta = await statements_repo.get_meta(session, document.id)
|
||||
if meta is not None:
|
||||
meta.reviewed_by = None
|
||||
meta.reviewed_at = now
|
||||
if meta is not None:
|
||||
meta.reviewed_by = None
|
||||
meta.reviewed_at = now
|
||||
await session.flush()
|
||||
await consolidation_documents.on_document_confirmed(
|
||||
session,
|
||||
|
||||
@ -244,45 +244,33 @@ class DocumentService:
|
||||
return await files_repo.list_entries(self._session, document.id, limit=page.limit, offset=page.offset)
|
||||
|
||||
async def _check_statement(self, document: Document) -> FinancialStatementMeta | None:
|
||||
"""Условия подтверждения формы ББ/ОФР (D-9): период определён, жёсткие проверки §7 пройдены или акцептованы.
|
||||
"""Условия подтверждения формы ББ/ОФР (D-9): `form_blocker` — период, нераспознанные строки со значениями,
|
||||
дубль формы, жёсткие проверки §7 (пройдены или акцептованы).
|
||||
|
||||
Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт).
|
||||
Для не-формы и документа без шапки — `None`, условий нет.
|
||||
"""
|
||||
from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта
|
||||
from src.services.statements import FORM_TYPES, form_blocker # noqa: PLC0415 - цикл импорта
|
||||
|
||||
if document.document_type not in FORM_TYPES:
|
||||
return None
|
||||
meta = await statements_repo.get_meta(self._session, document.id, refresh=True)
|
||||
if meta is None:
|
||||
return None
|
||||
if document.period_year is None or document.period_quarter is None:
|
||||
raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта")
|
||||
failures = await statement_hard_failures(self._session, document, meta)
|
||||
if failures:
|
||||
raise ConflictError(
|
||||
CHECKS_NOT_PASSED,
|
||||
"не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием",
|
||||
failures=[
|
||||
{
|
||||
"column_index": failure.column_index,
|
||||
"relation": failure.relation,
|
||||
"expected": str(failure.expected),
|
||||
"actual": str(failure.actual),
|
||||
}
|
||||
for failure in failures
|
||||
],
|
||||
)
|
||||
blocker = await form_blocker(self._session, document, meta)
|
||||
if blocker is not None:
|
||||
raise ConflictError(blocker.code, blocker.message, **blocker.extra)
|
||||
return meta
|
||||
|
||||
async def confirm(self, document_id: uuid.UUID) -> DocumentOut:
|
||||
"""`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`.
|
||||
|
||||
409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус —
|
||||
`DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён) и
|
||||
`CHECKS_NOT_PASSED` (неакцептованные нарушения проверок §7 в `failures`; D-9). Порядок блокировок и
|
||||
перечитывание — см. docstring модуля. В той же транзакции документ привязывается к подходящим версиям
|
||||
«в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят в `DATA_CORRECTION`
|
||||
`DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён),
|
||||
`LINES_UNRESOLVED`, `DUPLICATE_FORM` и `CHECKS_NOT_PASSED` (неакцептованные нарушения §7 в
|
||||
`failures`; D-9). Порядок блокировок и перечитывание — см. docstring модуля. В той же транзакции документ
|
||||
привязывается к подходящим версиям «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING`
|
||||
переходят в `DATA_CORRECTION`
|
||||
(`on_document_confirmed`, spec documents-api «Подтверждение документа»).
|
||||
"""
|
||||
visible = await self._visible(document_id)
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@ -43,7 +44,13 @@ from src.repositories import files as files_repo
|
||||
from src.repositories import statements as repo
|
||||
from src.repositories.counterparties import CounterpartyIndex
|
||||
from src.schemas import statements as schemas
|
||||
from src.services.documents import CONFIRMABLE_STATUSES, DOCUMENT_NOT_CONFIRMABLE, FILE_SUPERSEDED
|
||||
from src.services.documents import (
|
||||
CHECKS_NOT_PASSED,
|
||||
CONFIRMABLE_STATUSES,
|
||||
DOCUMENT_NOT_CONFIRMABLE,
|
||||
FILE_SUPERSEDED,
|
||||
PERIOD_REQUIRED,
|
||||
)
|
||||
|
||||
DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего
|
||||
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
|
||||
@ -53,6 +60,8 @@ UNKNOWN_CELL = "UNKNOWN_CELL"
|
||||
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"
|
||||
INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
|
||||
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
|
||||
@ -80,18 +89,59 @@ async def statement_hard_failures(
|
||||
return tuple(report.failed)
|
||||
|
||||
|
||||
async def form_confirmable(session: AsyncSession, document: Document) -> bool:
|
||||
"""Условия подтверждения формы ББ/ОФР без исключений (§7, D-9 Среза 3b; автоподтверждение — design D-3, OQ-157):
|
||||
период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b)
|
||||
— `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409."""
|
||||
if document.document_type not in FORM_TYPES:
|
||||
return True
|
||||
meta = await repo.get_meta(session, document.id, refresh=True)
|
||||
if meta is None:
|
||||
return True
|
||||
@dataclass(frozen=True)
|
||||
class FormBlocker:
|
||||
"""Причина отказа в подтверждении формы: код 409, сообщение и поля `detail`."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
extra: dict[str, Any]
|
||||
|
||||
|
||||
async def form_blocker(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> FormBlocker | None:
|
||||
"""Первое препятствие подтверждению формы ББ/ОФР или `None` (общий путь ручного и автоподтверждения).
|
||||
|
||||
Порядок: `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (строки `unreadable`/`missing` с непустой ячейкой) →
|
||||
`DUPLICATE_FORM` (уже подтверждена форма того же файла, типа и периода) → `CHECKS_NOT_PASSED` (жёсткие §7
|
||||
без акцепта).
|
||||
"""
|
||||
if document.period_year is None or document.period_quarter is None:
|
||||
return False
|
||||
return not await statement_hard_failures(session, document, meta)
|
||||
return FormBlocker(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта", {})
|
||||
lines = await repo.unresolved_lines(session, meta.id)
|
||||
if lines:
|
||||
return FormBlocker(
|
||||
LINES_UNRESOLVED,
|
||||
"в форме есть нераспознанные строки со значениями: назначьте код или очистите строку",
|
||||
{
|
||||
"lines": [
|
||||
{"line_id": str(line_id), "row_index": row_index, "line_code_text": code_text}
|
||||
for line_id, row_index, code_text in lines
|
||||
]
|
||||
},
|
||||
)
|
||||
twin = await files_repo.confirmed_twin(session, document)
|
||||
if twin is not None:
|
||||
return FormBlocker(
|
||||
DUPLICATE_FORM, "форма этого типа и периода в файле уже подтверждена", {"document_id": str(twin)}
|
||||
)
|
||||
failures = await statement_hard_failures(session, document, meta)
|
||||
if failures:
|
||||
return FormBlocker(
|
||||
CHECKS_NOT_PASSED,
|
||||
"не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием",
|
||||
{
|
||||
"failures": [
|
||||
{
|
||||
"column_index": failure.column_index,
|
||||
"relation": failure.relation,
|
||||
"expected": str(failure.expected),
|
||||
"actual": str(failure.actual),
|
||||
}
|
||||
for failure in failures
|
||||
]
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class StatementService:
|
||||
|
||||
@ -908,6 +908,155 @@ async def test_confirm_requires_checks(
|
||||
assert meta.reviewed_by is not None and meta.reviewed_at is not None
|
||||
|
||||
|
||||
async def _meta_of(session: AsyncSession, document: Document) -> FinancialStatementMeta:
|
||||
return (
|
||||
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
|
||||
).scalar_one()
|
||||
|
||||
|
||||
async def _add_unresolved( # noqa: PLR0913 - фабрика тестовой строки
|
||||
session: AsyncSession,
|
||||
document: Document,
|
||||
*,
|
||||
status: StatementLineStatus,
|
||||
row_index: int,
|
||||
code_text: str | None = None,
|
||||
amount: Decimal | None = None,
|
||||
string_value: str | None = None,
|
||||
) -> FinancialStatementLine:
|
||||
meta = await _meta_of(session, document)
|
||||
line = FinancialStatementLine(meta_id=meta.id, line_status=status, row_index=row_index, line_code_text=code_text)
|
||||
session.add(line)
|
||||
await session.flush()
|
||||
column = (
|
||||
await session.execute(
|
||||
select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1)
|
||||
)
|
||||
).scalar_one()
|
||||
session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=amount, string_value=string_value))
|
||||
await session.flush()
|
||||
return line
|
||||
|
||||
|
||||
async def _add_confirmed_twin(session: AsyncSession, document: Document) -> Document:
|
||||
twin = Document(
|
||||
stored_file_id=document.stored_file_id,
|
||||
legal_entity_id=document.legal_entity_id,
|
||||
document_type=document.document_type,
|
||||
file_ordinal=document.file_ordinal + 1,
|
||||
period_year=document.period_year,
|
||||
period_quarter=document.period_quarter,
|
||||
status=DocumentStatus.CONFIRMED,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
)
|
||||
session.add(twin)
|
||||
await session.flush()
|
||||
return twin
|
||||
|
||||
|
||||
async def test_confirm_unresolved_line_with_amount(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
document = await form(seeded, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
await _break_balance(client, document.id) # жёсткое нарушение не мешает: LINES_UNRESOLVED раньше
|
||||
line = await _add_unresolved(seeded, document, status=StatementLineStatus.missing, row_index=40, amount=Decimal(7))
|
||||
blocked = await client.post(f"/documents/{document.id}/confirm")
|
||||
assert blocked.status_code == HTTPStatus.CONFLICT
|
||||
detail = blocked.json()["detail"]
|
||||
assert detail["code"] == "LINES_UNRESOLVED"
|
||||
assert detail["lines"] == [{"line_id": str(line.id), "row_index": 40, "line_code_text": None}]
|
||||
|
||||
|
||||
async def test_confirm_unresolved_line_with_text_only(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
document = await form(seeded, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
await _add_unresolved(
|
||||
seeded,
|
||||
document,
|
||||
status=StatementLineStatus.unreadable,
|
||||
row_index=41,
|
||||
code_text="ИЧ,250",
|
||||
string_value="211(1 |",
|
||||
)
|
||||
blocked = await client.post(f"/documents/{document.id}/confirm")
|
||||
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "LINES_UNRESOLVED"
|
||||
|
||||
|
||||
async def test_unresolved_line_without_values_does_not_block(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
document = await form(seeded, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
await _add_unresolved(seeded, document, status=StatementLineStatus.unreadable, row_index=41)
|
||||
confirmed = await client.post(f"/documents/{document.id}/confirm")
|
||||
assert confirmed.status_code == HTTPStatus.OK and confirmed.json()["status"] == "CONFIRMED"
|
||||
|
||||
|
||||
async def test_confirm_duplicate_form(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
|
||||
"""Review Focus 1: вторая форма того же типа и периода в файле после подтверждения первой — `DUPLICATE_FORM`."""
|
||||
first = await form(seeded, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
first_confirmed = await client.post(f"/documents/{first.id}/confirm")
|
||||
assert first_confirmed.status_code == HTTPStatus.OK
|
||||
second = Document(
|
||||
stored_file_id=first.stored_file_id,
|
||||
legal_entity_id=first.legal_entity_id,
|
||||
document_type=first.document_type,
|
||||
file_ordinal=first.file_ordinal + 1,
|
||||
period_year=first.period_year,
|
||||
period_quarter=first.period_quarter,
|
||||
status=DocumentStatus.NEEDS_CHECK,
|
||||
created_at=first.created_at,
|
||||
updated_at=first.updated_at,
|
||||
)
|
||||
seeded.add(second)
|
||||
await seeded.flush()
|
||||
first_meta = await _meta_of(seeded, first)
|
||||
seeded.add(
|
||||
FinancialStatementMeta(
|
||||
document_id=second.id,
|
||||
recognition_job_id=first_meta.recognition_job_id,
|
||||
statement_type=first_meta.statement_type,
|
||||
)
|
||||
)
|
||||
await seeded.flush()
|
||||
blocked = await client.post(f"/documents/{second.id}/confirm")
|
||||
assert blocked.status_code == HTTPStatus.CONFLICT
|
||||
assert blocked.json()["detail"]["code"] == "DUPLICATE_FORM"
|
||||
assert blocked.json()["detail"]["document_id"] == str(first.id)
|
||||
|
||||
|
||||
async def test_conflict_order(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
|
||||
"""PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED."""
|
||||
document = await form(seeded, fixtures_dir)
|
||||
client = await auth_client(KA_RF)
|
||||
await _break_balance(client, document.id)
|
||||
await _add_confirmed_twin(seeded, document)
|
||||
line = await _add_unresolved(seeded, document, status=StatementLineStatus.missing, row_index=40, amount=Decimal(7))
|
||||
quarter = document.period_quarter
|
||||
document.period_quarter = None
|
||||
await seeded.flush()
|
||||
|
||||
async def code() -> str:
|
||||
return (await client.post(f"/documents/{document.id}/confirm")).json()["detail"]["code"]
|
||||
|
||||
seen = [await code()]
|
||||
document.period_quarter = quarter
|
||||
await seeded.flush()
|
||||
seen.append(await code())
|
||||
await seeded.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id))
|
||||
await seeded.flush()
|
||||
seen.append(await code())
|
||||
await seeded.execute(delete(Document).where(Document.status == DocumentStatus.CONFIRMED))
|
||||
await seeded.flush()
|
||||
seen.append(await code())
|
||||
assert seen == ["PERIOD_REQUIRED", "LINES_UNRESOLVED", "DUPLICATE_FORM", "CHECKS_NOT_PASSED"]
|
||||
|
||||
|
||||
async def test_confirm_after_non_quarter_date_edit(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
OQ-157; design D-1–D-3)."""
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@ -22,9 +23,15 @@ from src.models.enums import (
|
||||
FileStatus,
|
||||
PeriodQuarter,
|
||||
RecognitionStatus,
|
||||
StatementLineStatus,
|
||||
)
|
||||
from src.models.files import Document, StoredFile
|
||||
from src.models.statements import FinancialStatementMeta
|
||||
from src.models.statements import (
|
||||
FinancialStatementColumn,
|
||||
FinancialStatementLine,
|
||||
FinancialStatementMeta,
|
||||
FinancialStatementValue,
|
||||
)
|
||||
from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file
|
||||
@ -210,6 +217,35 @@ async def test_recognized_form_without_period_stays(seeded: AsyncSession, fixtur
|
||||
assert income.status is DocumentStatus.RECOGNIZED
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
async def test_auto_confirm_skips_blocked_form(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
|
||||
import json # noqa: PLC0415 - нужен только здесь
|
||||
|
||||
data = json.loads(path.read_text("utf-8"))
|
||||
inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn"))
|
||||
owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn)
|
||||
(income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
|
||||
meta = (
|
||||
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
|
||||
).scalar_one()
|
||||
line = FinancialStatementLine(meta_id=meta.id, line_status=StatementLineStatus.missing, row_index=900)
|
||||
seeded.add(line)
|
||||
await seeded.flush()
|
||||
column = (
|
||||
await seeded.execute(
|
||||
select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1)
|
||||
)
|
||||
).scalar_one()
|
||||
seeded.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=Decimal(5)))
|
||||
await seeded.flush()
|
||||
file = await seeded.get(StoredFile, income.stored_file_id)
|
||||
assert file is not None
|
||||
|
||||
assert await auto_confirm(seeded, file=file, documents=[income], now=utc_now()) == []
|
||||
assert income.status is DocumentStatus.RECOGNIZED
|
||||
|
||||
|
||||
async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`."""
|
||||
office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b)."""
|
||||
"""Условия подтверждения формы ББ/ОФР: ручной и автоматический путь (OQ-157, design D-3; D-9 Среза 3b; A11)."""
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.directories import LegalEntity
|
||||
from src.models.enums import DocumentType
|
||||
from src.models.enums import DocumentStatus, DocumentType, StatementLineStatus
|
||||
from src.models.files import Document
|
||||
from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue
|
||||
from src.services.statements import form_confirmable, statement_hard_failures
|
||||
from src.models.statements import (
|
||||
FinancialStatementColumn,
|
||||
FinancialStatementLine,
|
||||
FinancialStatementMeta,
|
||||
FinancialStatementValue,
|
||||
)
|
||||
from src.services.statements import form_blocker, statement_hard_failures
|
||||
from tests.factories import make_legal_entity
|
||||
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
|
||||
|
||||
@ -35,15 +41,78 @@ async def _income(session: AsyncSession, fixtures_dir: Path) -> Document:
|
||||
return income
|
||||
|
||||
|
||||
async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta:
|
||||
return (
|
||||
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
|
||||
).scalar_one()
|
||||
|
||||
|
||||
async def _unresolved_line( # noqa: PLR0913 - фабрика тестовой строки
|
||||
session: AsyncSession,
|
||||
meta: FinancialStatementMeta,
|
||||
*,
|
||||
status: StatementLineStatus,
|
||||
row_index: int,
|
||||
code_text: str | None = None,
|
||||
amount: Decimal | None = None,
|
||||
string_value: str | None = None,
|
||||
) -> FinancialStatementLine:
|
||||
"""Строка без кода (ORM-фабрика) с одной ячейкой в первой колонке формы."""
|
||||
line = FinancialStatementLine(meta_id=meta.id, line_status=status, row_index=row_index, line_code_text=code_text)
|
||||
session.add(line)
|
||||
await session.flush()
|
||||
column = (
|
||||
await session.execute(
|
||||
select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1)
|
||||
)
|
||||
).scalar_one()
|
||||
session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=amount, string_value=string_value))
|
||||
await session.flush()
|
||||
return line
|
||||
|
||||
|
||||
async def _confirmed_twin(session: AsyncSession, document: Document) -> Document:
|
||||
"""Вторая форма того же файла, типа и периода со статусом `CONFIRMED` (другой `file_ordinal`)."""
|
||||
twin = Document(
|
||||
stored_file_id=document.stored_file_id,
|
||||
legal_entity_id=document.legal_entity_id,
|
||||
document_type=document.document_type,
|
||||
file_ordinal=document.file_ordinal + 1,
|
||||
period_year=document.period_year,
|
||||
period_quarter=document.period_quarter,
|
||||
status=DocumentStatus.CONFIRMED,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
)
|
||||
session.add(twin)
|
||||
await session.flush()
|
||||
return twin
|
||||
|
||||
|
||||
async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> None:
|
||||
values = (
|
||||
await session.execute(
|
||||
select(FinancialStatementValue)
|
||||
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
|
||||
.where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200")
|
||||
)
|
||||
).scalars()
|
||||
for value in values:
|
||||
value.amount = (value.amount or 0) + 1000
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
assert await form_confirmable(seeded, await _income(seeded, fixtures_dir)) is True
|
||||
income = await _income(seeded, fixtures_dir)
|
||||
assert await form_blocker(seeded, income, await _meta(seeded, income)) is None
|
||||
|
||||
|
||||
async def test_form_without_period_is_not_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
income = await _income(seeded, fixtures_dir)
|
||||
income.period_quarter = None
|
||||
await seeded.flush()
|
||||
assert await form_confirmable(seeded, income) is False
|
||||
blocker = await form_blocker(seeded, income, await _meta(seeded, income))
|
||||
assert blocker is not None and blocker.code == "PERIOD_REQUIRED"
|
||||
|
||||
|
||||
async def test_form_with_hard_failure_is_not_confirmable_until_accepted(
|
||||
@ -67,17 +136,76 @@ async def test_form_with_hard_failure_is_not_confirmable_until_accepted(
|
||||
await seeded.flush()
|
||||
|
||||
assert await statement_hard_failures(seeded, income, meta) != ()
|
||||
assert await form_confirmable(seeded, income) is False
|
||||
blocker = await form_blocker(seeded, income, meta)
|
||||
assert blocker is not None and blocker.code == "CHECKS_NOT_PASSED" and blocker.extra["failures"]
|
||||
|
||||
meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор
|
||||
await seeded.flush()
|
||||
assert await form_confirmable(seeded, income) is True
|
||||
assert await form_blocker(seeded, income, meta) is None
|
||||
|
||||
|
||||
async def test_disclosure_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
owner = await make_legal_entity(seeded, name="ООО Синтетика")
|
||||
documents = await map_golden_snapshot(
|
||||
seeded, entity=owner, path=fixtures_dir / SYNTH / "st06_header_variants.json", uploaded_by=None
|
||||
async def test_unresolved_line_with_amount_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
income = await _income(seeded, fixtures_dir)
|
||||
meta = await _meta(seeded, income)
|
||||
line = await _unresolved_line(seeded, meta, status=StatementLineStatus.missing, row_index=40, amount=Decimal(5))
|
||||
blocker = await form_blocker(seeded, income, meta)
|
||||
assert blocker is not None and blocker.code == "LINES_UNRESOLVED"
|
||||
assert blocker.extra == {"lines": [{"line_id": str(line.id), "row_index": 40, "line_code_text": None}]}
|
||||
|
||||
|
||||
async def test_unresolved_line_with_text_only_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
income = await _income(seeded, fixtures_dir)
|
||||
meta = await _meta(seeded, income)
|
||||
await _unresolved_line(
|
||||
seeded, meta, status=StatementLineStatus.unreadable, row_index=41, code_text="ИЧ,250", string_value="211(1 |"
|
||||
)
|
||||
disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE)
|
||||
assert await form_confirmable(seeded, disclosure) is True
|
||||
blocker = await form_blocker(seeded, income, meta)
|
||||
assert blocker is not None and blocker.code == "LINES_UNRESOLVED"
|
||||
assert blocker.extra["lines"][0]["line_code_text"] == "ИЧ,250"
|
||||
|
||||
|
||||
async def test_unresolved_line_without_values_does_not_block(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
income = await _income(seeded, fixtures_dir)
|
||||
meta = await _meta(seeded, income)
|
||||
await _unresolved_line(seeded, meta, status=StatementLineStatus.unreadable, row_index=41)
|
||||
assert await form_blocker(seeded, income, meta) is None
|
||||
|
||||
|
||||
async def test_duplicate_form_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
"""Review Focus 1: подтверждённая форма того же файла, типа и периода -> `DUPLICATE_FORM` с её id."""
|
||||
income = await _income(seeded, fixtures_dir)
|
||||
twin = await _confirmed_twin(seeded, income)
|
||||
blocker = await form_blocker(seeded, income, await _meta(seeded, income))
|
||||
assert blocker is not None and blocker.code == "DUPLICATE_FORM" and blocker.extra == {"document_id": str(twin.id)}
|
||||
|
||||
|
||||
async def test_other_period_twin_does_not_block(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
income = await _income(seeded, fixtures_dir)
|
||||
twin = await _confirmed_twin(seeded, income)
|
||||
twin.period_year = (income.period_year or 0) - 1
|
||||
await seeded.flush()
|
||||
assert await form_blocker(seeded, income, await _meta(seeded, income)) is None
|
||||
|
||||
|
||||
async def test_conflict_order(seeded: AsyncSession, fixtures_dir: Path) -> None:
|
||||
"""PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED."""
|
||||
income = await _income(seeded, fixtures_dir)
|
||||
meta = await _meta(seeded, income)
|
||||
await _break_2200(seeded, meta)
|
||||
await _confirmed_twin(seeded, income)
|
||||
await _unresolved_line(seeded, meta, status=StatementLineStatus.missing, row_index=40, amount=Decimal(5))
|
||||
quarter = income.period_quarter
|
||||
income.period_quarter = None
|
||||
await seeded.flush()
|
||||
codes = []
|
||||
codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr]
|
||||
income.period_quarter = quarter
|
||||
await seeded.flush()
|
||||
codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr]
|
||||
await seeded.execute(delete(FinancialStatementValue).where(FinancialStatementValue.amount == Decimal(5)))
|
||||
await seeded.flush()
|
||||
codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr]
|
||||
await seeded.execute(delete(Document).where(Document.status == DocumentStatus.CONFIRMED))
|
||||
await seeded.flush()
|
||||
codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr]
|
||||
assert codes == ["PERIOD_REQUIRED", "LINES_UNRESOLVED", "DUPLICATE_FORM", "CHECKS_NOT_PASSED"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user