Автоподтверждение чистых документов (#64, OQ-157) #69

Merged
aep merged 10 commits from feat/64-auto-confirm into main 2026-10-08 17:08:36 +03:00
22 changed files with 2436 additions and 79 deletions

View File

@ -76,14 +76,14 @@
advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status`
по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга:
`pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции →
автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `domain/osv_mapping.py` (чистые функции:
автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `services/auto_confirm.py` (автоподтверждение чистых документов в транзакции маппинга: `_map_candidate`: `map_file` → автозамена → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157), `domain/osv_mapping.py` (чистые функции:
`normalize_account_code`, `build_entries`, `counterparty_check`, `document_status`), `repositories/counterparties.py`
(`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при
`NATS_ENABLED`; цикл маппинга — ещё и при подключённом S3 (storage)): публикатор outbox, вахтёр сроков и цикл маппинга
(`MAPPING_POLL_SECONDS`, `MAPPING_MAX_ATTEMPTS`); inbox результат не читает и документов не создаёт. **Порядок блокировок: задание → файл → документы** (inbox, вахтёр, публикатор, маппер,
`confirm`, `delete`; с Среза 4 далее — advisory-lock ГК → версии консолидации, см. «Консолидации») — иначе
взаимоблокировки; автозамена дополнительно берёт advisory-lock по ЮЛ. Аудит: `FILE_UPLOAD`,
`FILE_REPLACE` (системное, `user_id` пуст), `FILE_DELETE`, `DOCUMENT_CONFIRM`; смена статусов обмена и маппинга не аудируется
`FILE_REPLACE` (системное, `user_id` пуст), `FILE_DELETE`, `DOCUMENT_CONFIRM` (пользователь или система при автоподтверждении); смена статусов обмена и маппинга не аудируется
(OQ-55). `osv_entry` пишет только маппер, один раз на файл (очистка перед вставкой); `TOTAL` не хранится.
- **Формы ББ/ОФР (Срез 3b)** — `map_file` (`services/result_mapping.py`) мапит и формы ББ/ОФР: `domain/statement_mapping.py` (чистые
функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать);
@ -110,8 +110,8 @@
mapping), сервисы: `consolidations` (создание, список, карточка, периметр `PUT …/members`), `consolidation_guard`
(`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка),
`consolidation_locks` (взять/перехватить/снять; без истечения, OQ-119), `consolidation_status` (`transition` + ручной
`POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed` /
`on_documents_superseded` / `on_documents_mapped`), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты,
`POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed(changed_by, comment)` /
`on_documents_superseded` / `on_documents_mapped`; `bound_versions` / `unbind_documents` — снятие привязок автоподтверждённых документов при удалении файла, OQ-157), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты,
экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»).
Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие
(`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду

View File

@ -420,9 +420,12 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о
POST /legal-entities/{id}/files → S3 uploads/… + stored_file(UPLOADED) + recognition_job(queued) + audit FILE_UPLOAD
→ публикатор (outbox) → NATS recognition.tabular.request → tabular-parser (читает S3, пишет results/…)
→ recognition.result → inbox (задание done, файл PROCESSING) → цикл маппинга (document + osv_entry, файл PROCESSED)
→ автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm
→ автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm (только для NEEDS_CHECK и форм после правки)
```
Чистый документ подтверждается самим маппингом (автоподтверждение, OQ-157); `POST /documents/{id}/confirm` нужен для
`NEEDS_CHECK` и для форм ББ/ОФР после правки.
Загрузка — `multipart/form-data`, одно или несколько полей `files`; право `DOCUMENT_EDIT`, ЮЛ в области видимости
пользователя (чужое — 404, как и несуществующее). Поддерживаются `xls` (BIFF8), `xlsx`, `xlsb`, `pdf`, `png`, `jpg`
(`.jpeg` — как `jpg`): тип определяется по расширению и сверяется с сигнатурой содержимого. Таблицы (`xls/xlsx/xlsb`)
@ -466,7 +469,7 @@ curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN
`PROCESSED` и `SUPERSEDED` события обмена не перезаписывают; запоздавший ack не воскрешает `ERROR`.
**Документ** (`document.status`) создаётся маппером на каждый лист ОСВ, на каждую форму ББ/ОФР (Срез 3b, D-2) и по одному на
тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, сразу `RECOGNIZED`). Условия для листа ОСВ:
тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, при маппинге `RECOGNIZED`, затем автоподтверждаются системой). Условия для листа ОСВ:
- `RECOGNIZED` — задание `done`, проверки парсера без нарушений, шапка не противоречит владельцу
(`counterparty_check ≠ MISMATCH`), предупреждений маппинга у листа нет;
@ -474,9 +477,11 @@ curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN
в файле, строки без родителя, нарастающая по данным Q1;
- для формы ББ/ОФР условия `RECOGNIZED`/`NEEDS_CHECK` — правило D-6 (раздел «Формы ББ/ОФР»): проверки §7 по суммам формы вместо
проверок парсера;
- `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит);
- `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`.
`counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла;
`period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде.
@ -523,7 +528,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`; для формы ББ/ОФР — ещё `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` и `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}`) |
@ -600,9 +605,8 @@ advisory-блокировкой.
**Удаление.** `DELETE /files/{id}` удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3
(`uploads`, `results`) из папок этого файла (`uploads/{file_id}/`, `results/{file_id}/`) `expires_at = now()`
(физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом `CONFIRMED` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`)
и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке
«задания → файл → документы».
(физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом, подтверждённым пользователем (`confirmed_by` задан), и с автоподтверждённым документом, привязанным к версии вне статусов «в работе» `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`); автоподтверждённые привязки к версиям «в работе» снимаются (`FILE_DELETE.after_value.unbound_version_ids`, OQ-157). Нельзя также удалить файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке
«задания → файл → документы → advisory ГК → версии по id».
## Ограничения Среза 2c
@ -612,7 +616,7 @@ advisory-блокировкой.
- Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены
(Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы.
- Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»).
Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED`,
Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED` при маппинге, затем автоподтверждение;
`locator` — перечень листов через запятую; OQ-23). Отдельной обработки по конвейеру (`tabular`/`image`) в маппере нет: разбирается
любой результат, проходящий схему `recognition-result`.
- Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД»
@ -621,8 +625,10 @@ advisory-блокировкой.
- ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу.
- ОСВ «нарастающая по данным Q1» (Q2–Q4 с `opening` корня, равным `opening` Q1) получает предупреждение маппинга,
поэтому документ остаётся `NEEDS_CHECK`: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца).
- `period_check` — `UNKNOWN` (OQ-120); к версии консолидации привязываются только `CONFIRMED` документы, а их файл удалить
нельзя — удаление файла с документом в версии невозможно (OQ-110, Срез 4); FK `ex_snapshot_item` — Срез 7; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d).
- `period_check` — `UNKNOWN` (OQ-120); к версии консолидации привязываются только `CONFIRMED` документы; файл с документом, подтверждённым
пользователем, удалить нельзя, файл с автоподтверждённым документом — можно, пока все версии с его привязками в
`INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (привязки снимаются; иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS`; OQ-110,
уточнено OQ-157); FK `ex_snapshot_item` — Срез 7; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d).
- Два экземпляра ядра могут одновременно мапить один файл (второй проиграет блокировку и пропустит кандидата);
при уменьшении `MAPPING_MAX_ATTEMPTS` файлы с исчерпанным счётчиком остаются `PROCESSING` до ручного вмешательства.
@ -698,7 +704,7 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
(подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним `confirmed_at`).
- **Отвязка:** автозамена файла (привязки всех документов заменённого файла; версии — в `FILE_REPLACE.after_value.unbound_version_ids`)
и удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются.
- **Файл с документом в версии удалить нельзя** (привязываются только `CONFIRMED`, а их файл удалять запрещено — OQ-110).
- **Удаление файла с документом в версии:** файл с документом, подтверждённым пользователем, удалить нельзя; файл с автоподтверждённым документом — можно, пока все версии с его привязками «в работе» (привязки снимаются), иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (OQ-110, уточнено OQ-157).
- `period_check` документа остаётся `UNKNOWN` — подходящесть — функция (документ, версия), а не свойство документа (OQ-120).
### Чек-лист
@ -840,7 +846,7 @@ email без учёта регистра проверяет только сер
| `ALREADY_CONFIRMED` | 409 | подтверждение: документ уже `CONFIRMED` |
| `FILE_SUPERSEDED` | 409 | подтверждение: документ файла, заменённого новой версией |
| `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) |
| `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с `CONFIRMED` документом |
| `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с документом, подтверждённым пользователем, или с автоподтверждённым документом в версии вне «в работе» (OQ-157) |
| `FILE_CONTENT_NOT_FOUND` | 404 | `GET /files/{id}/content`: файл есть, объекта нет в S3 (истёк или пропал) |
| `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement`: документ — не форма ББ/ОФР (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) |
| `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) |
@ -966,7 +972,7 @@ email без учёта регистра проверяет только сер
Тест `api/tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`, `fixtures`) проверяет весь путь Среза 2c с
РЕАЛЬНЫМ tabular-parser: `POST /legal-entities/{id}/files` (ОСВ 66 за 2024 из `../fixtures`) → outbox → NATS →
tabular-parser (MinIO) → inbox → маппинг → `GET /files/{id}` = `PROCESSED`, документ 66/2024/Q4 (нарастающий,
`RECOGNIZED`, `counterparty_check = MATCH`) и 9 строк ОСВ. Приложение — настоящий `src.app.app` с lifespan на
`CONFIRMED` автоподтверждением, `counterparty_check = MATCH`) и 9 строк ОСВ. Приложение — настоящий `src.app.app` с lifespan на
отдельной временной БД с seed, NATS и MinIO — из `api/.env.test`; в конце файл удаляется через `DELETE /files/{id}`
(объекту S3 ставится `expires_at`), БД снимается.

View File

@ -31,7 +31,8 @@ DELETE_RESPONSES: dict[int | str, dict[str, Any]] = {
**NOT_FOUND,
status.HTTP_409_CONFLICT: {
"model": ErrorDetail,
"description": "FILE_HAS_CONFIRMED_DOCUMENTS (есть подтверждённый документ) или FILE_SUPERSEDES_OTHERS "
"description": "FILE_HAS_CONFIRMED_DOCUMENTS (документ подтверждён пользователем или автоподтверждённый "
"документ привязан к версии вне корректировки данных) или FILE_SUPERSEDES_OTHERS "
"(файл заменил более ранние версии)",
},
}

View File

@ -0,0 +1,75 @@
"""Автоподтверждение чистых документов при маппинге результата (osv-result-mapping «Автоподтверждение чистого
документа»; `05-domain-rules.md` §9.3, §9.5; OQ-157; design D-1–D-3 change `auto-confirm-clean-documents`).
Документ, получивший при маппинге `RECOGNIZED` (ТЗ 03_9 п. 4: «при отсутствии замечаний — „Загружен“»), становится
`CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без
пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором
`stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5).
"""
import datetime
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.enums import AuditAction, AuditSection, DocumentStatus
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
AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний"
OBJECT_TYPE = "document"
def transition_comment(document: Document) -> str:
"""Комментарий перехода версии: «автоподтверждение документа <тип> [<счёт>]» (design решение №6)."""
parts = ["автоподтверждение документа", document.document_type.value]
if document.osv_account_code:
parts.append(document.osv_account_code)
return " ".join(parts)
async def auto_confirm(
session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime
) -> list[Document]:
"""Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, 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
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
await session.flush()
await consolidation_documents.on_document_confirmed(
session,
audit,
document=document,
changed_by=file.uploaded_by,
comment=transition_comment(document),
now=now,
)
await audit.record(
action=AuditAction.DOCUMENT_CONFIRM,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type=OBJECT_TYPE,
object_id=document.id,
user_id=None,
description=AUTO_CONFIRM_DESCRIPTION,
before_value={"status": DocumentStatus.RECOGNIZED.value},
after_value={"status": DocumentStatus.CONFIRMED.value},
)
confirmed.append(document)
return confirmed

View File

@ -39,10 +39,12 @@ from src.services.audit import AuditService
__all__ = [
"bind_confirmed",
"bound_versions",
"fits",
"on_document_confirmed",
"on_documents_mapped",
"on_documents_superseded",
"unbind_documents",
"unbind_entity",
]
@ -197,18 +199,54 @@ async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_
return _rowcount(result)
async def on_document_confirmed(
async def bound_versions(
session: AsyncSession, *, documents: Sequence[Document]
) -> list[tuple[uuid.UUID, ConsolidationStatus]]:
"""Привязки `documents` к версиям любого статуса — `(version_id, status)` по возрастанию `version_id` (удаление
файла с автоподтверждёнными документами, OQ-157, design D-4). Сначала блокируются версии «в работе» каждого ЮЛ
(`lock_versions_for_entity`, advisory ГК → версии по `id`), чтобы статус не сменился между проверкой и снятием
привязки; версии вне «в работе» читаются без блокировки — из них удаление и так запрещено."""
if not documents:
return []
for entity_id in sorted({document.legal_entity_id for document in documents}):
await repo.lock_versions_for_entity(session, entity_id, IN_WORK_STATUSES)
stmt = (
select(ConsolidationDocument.consolidation_version_id, ConsolidationVersion.status)
.join(ConsolidationVersion, ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id)
.where(ConsolidationDocument.document_id.in_([document.id for document in documents]))
.order_by(ConsolidationDocument.consolidation_version_id)
.execution_options(populate_existing=True)
)
return [(version_id, status) for version_id, status in (await session.execute(stmt)).all()]
async def unbind_documents(session: AsyncSession, *, document_ids: Collection[uuid.UUID]) -> int:
"""Снимает все привязки `document_ids` (удаление файла, D-4); статус версий не меняется. Версии — под блокировкой
вызывающего (`bound_versions`)."""
if not document_ids:
return 0
result = await session.execute(
delete(ConsolidationDocument).where(ConsolidationDocument.document_id.in_(list(document_ids)))
)
return _rowcount(result)
async def on_document_confirmed( # noqa: PLR0913 - автор и комментарий перехода задаёт вызывающий (OQ-157)
session: AsyncSession,
audit: AuditService,
*,
document: Document,
user_id: uuid.UUID,
changed_by: uuid.UUID | None,
now: datetime.datetime,
comment: str | None = None,
) -> list[uuid.UUID]:
"""Хук подтверждения (spec «Подтверждение документа»): привязывает уже `CONFIRMED` документ ко всем версиям «в
работе», которым он подходит, заменяя привязанный документ того же ключа (R-1); версия из `INITIATED` /
`DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` (`changed_by = user_id`, D-6). Возвращает id версий,
получивших привязку (по возрастанию).
"""Хук подтверждения (spec «Подтверждение документа», osv-result-mapping «Автоподтверждение чистого документа»):
привязывает уже `CONFIRMED` документ ко всем версиям «в работе», которым он подходит, заменяя привязанный документ
того же ключа (R-1); версия из `INITIATED` / `DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` с `changed_by` и
`comment` вызывающего (ручное подтверждение — пользователь и `None`, автоподтверждение — загрузивший файл и
«автоподтверждение документа …», D-2). `changed_by is None` (файл без `uploaded_by`) — привязка создаётся, переход
пропускается с записью в лог (`consolidation_status_history.changed_by` NOT NULL). Возвращает id версий, получивших
привязку (по возрастанию).
Вызывать после блокировки файла и документа (D-9): версии блокирует `repo.lock_versions_for_entity` — advisory ГК
(создание версии ∥ подтверждение) и `FOR UPDATE` надмножества версий ГК (правка периметра ∥ подтверждение, R-2).
@ -227,15 +265,23 @@ async def on_document_confirmed(
continue
await _bind(session, version.id, document)
if version.status in MOVES_TO_CORRECTION:
await transition(
session,
audit,
version=version,
to=ConsolidationStatus.DATA_CORRECTION,
changed_by=user_id,
comment=None,
now=now,
)
if changed_by is None:
logger.info(
"Переход версии по подтверждению пропущен: нет автора (changed_by обязателен). "
"version_id={version_id}, document_id={document_id}",
version_id=version.id,
document_id=document.id,
)
else:
await transition(
session,
audit,
version=version,
to=ConsolidationStatus.DATA_CORRECTION,
changed_by=changed_by,
comment=comment,
now=now,
)
bound.append(version.id)
return bound

View File

@ -209,7 +209,7 @@ class DocumentService:
Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт).
Для не-формы и документа без шапки — `None`, условий нет.
"""
from src.services.statements import FORM_TYPES, StatementService # noqa: PLC0415 - цикл импорта
from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта
if document.document_type not in FORM_TYPES:
return None
@ -218,7 +218,7 @@ class DocumentService:
return None
if document.period_year is None or document.period_quarter is None:
raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта")
failures = await StatementService(self._session, self._audit, self._scope).hard_failures(document, meta)
failures = await statement_hard_failures(self._session, document, meta)
if failures:
raise ConflictError(
CHECKS_NOT_PASSED,
@ -273,7 +273,7 @@ class DocumentService:
meta.reviewed_at = now
await self._session.flush()
await consolidation_documents.on_document_confirmed(
self._session, self._audit.service, document=document, user_id=self._audit.user_id, now=now
self._session, self._audit.service, document=document, changed_by=self._audit.user_id, now=now
)
await self._audit.record(
action=AuditAction.DOCUMENT_CONFIRM,

View File

@ -54,6 +54,7 @@ from src.repositories import directories as directories_repo
from src.repositories import files as files_repo
from src.schemas.common import PageParams
from src.schemas.files import FileOut
from src.services import consolidation_documents
from src.services.audit import AuditService
from src.services.consolidation_guard import VERSION_NOT_FOUND
from src.services.documents import file_out
@ -283,9 +284,11 @@ class PrimaryFilesService:
Порядок блокировок — как в обмене и маппинге: задания файла `FOR UPDATE`, затем `stored_file`, затем документы
(confirm берёт «файл → документ», автозамена — advisory → старые файлы по id). Состояние перечитывается
под блокировками, и 409 проверяются после них: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ `CONFIRMED`,
`FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл,
исчезнувший, пока ждали блокировку, — 404.
под блокировками, и 409 проверяются после них, в порядке: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ,
подтверждённый пользователем; `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка
версий не рвётся); `FILE_HAS_CONFIRMED_DOCUMENTS` — автоподтверждённый документ привязан к версии вне «в
работе» (OQ-157). Привязки автоподтверждённых документов снимаются (после блокировки advisory ГК → версии),
id версий — в `after_value.unbound_version_ids`. Файл, исчезнувший, пока ждали блокировку, — 404.
Удаляются формы ББ/ОФР (`financial_statement_*`), комментарии документов, `osv_entry`, `document`, все задания
и `stored_file`; `s3_object` файла и результатов заданий
@ -300,15 +303,27 @@ class PrimaryFilesService:
if file is None: # удалён параллельно, пока ждали блокировку
raise NotFoundError(FILE_NOT_FOUND)
documents = await files_repo.lock_file_documents(self._session, file_id)
if any(document.status is DocumentStatus.CONFIRMED for document in documents):
confirmed = [document for document in documents if document.status is DocumentStatus.CONFIRMED]
if any(document.confirmed_by is not None for document in confirmed):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть подтверждённые документы"
FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть документы, подтверждённые пользователем"
)
if await files_repo.supersedes_others(self._session, file_id):
raise ConflictError(
FILE_SUPERSEDES_OTHERS,
"файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется",
)
# OQ-157, design D-4: автоподтверждённые документы удаляются, пока их версии «в работе»; привязки снимаются
bound = await consolidation_documents.bound_versions(self._session, documents=confirmed)
if any(status not in IN_WORK_STATUSES for _, status in bound):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS,
"файл нельзя удалить: документ привязан к версии консолидации вне корректировки данных",
)
unbound_version_ids = sorted({version_id for version_id, _ in bound})
await consolidation_documents.unbind_documents(
self._session, document_ids=[document.id for document in confirmed]
)
current = next((job for job in jobs if job.id == file.current_recognition_job_id), None)
counts = await files_repo.comments_count_by_document(self._session, [document.id for document in documents])
before = file_out(file, current, documents, counts).model_dump(mode="json")
@ -326,6 +341,7 @@ class PrimaryFilesService:
object_type=OBJECT_TYPE,
object_id=file_id,
before_value=before,
after_value={"unbound_version_ids": unbound_version_ids},
)
async def _cards(self, files: list[StoredFile]) -> list[FileOut]:

View File

@ -12,7 +12,8 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат
старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла,
документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 3b и расшифровки), `group_schema` (только листы ОСВ),
предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`).
предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`) →
автоподтверждение чистых документов (`services/auto_confirm.py`, OQ-157) → переход версий по `NEEDS_CHECK` (D-6).
Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов)
— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции
@ -895,10 +896,15 @@ async def _map_candidate(
index = await CounterpartyIndex.load(session)
now = clock()
warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now)
# Переход версий (D-6) — ПОСЛЕ `map_file`, а значит после автозамены (`supersede_previous` берёт advisory ЮЛ →
# старые файлы → advisory ГК → версии): версии блокируются последними, порядок «задание → файл → документы →
# версии» (D-9) не нарушается. До автозамены хук держал бы версии и затем ждал блокировки старых файлов.
# После `map_file`, а значит после автозамены (advisory ЮЛ → старые файлы → advisory ГК → версии): версии
# блокируются последними, порядок «задание → файл → документы → версии» (D-9) не нарушается. Сначала
# автоподтверждение чистых документов (привязка и переход версии, OQ-157, design D-1), затем переход по
# `NEEDS_CHECK` (D-6) — он уже видит версию после автоподтверждения и второго перехода не делает.
documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars())
# отложенный импорт: цикл documents → result_mapping → auto_confirm → statements → documents
from src.services.auto_confirm import auto_confirm # noqa: PLC0415
await auto_confirm(session, file=file, documents=documents, now=now)
await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now)
logger.info(
"Результат распознавания смапплен: файл PROCESSED. job_id={job_id}, file_id={file_id}, warnings={warnings}",

View File

@ -47,6 +47,31 @@ INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
async def statement_hard_failures(
session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b)."""
if meta.checks_accepted_at is not None:
return ()
column_indexes, amounts = await repo.statement_amounts(session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
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
if document.period_year is None or document.period_quarter is None:
return False
return not await statement_hard_failures(session, document, meta)
class StatementService:
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
self._session = session
@ -107,11 +132,7 @@ class StatementService:
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
if meta.checks_accepted_at is not None:
return ()
column_indexes, amounts = await repo.statement_amounts(self._session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
return tuple(report.failed)
return await statement_hard_failures(self._session, document, meta)
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None:
"""D-6 после правки или акцепта; `CONFIRMED` не трогает.

View File

@ -141,13 +141,13 @@ async def test_real_osv_is_parsed_end_to_end(
assert (brief["period_year"], brief["period_quarter"], brief["is_cumulative"]) == (2024, "Q4", True)
# MATCH достигается именно по имени: ИНН в шапке ОСВ нет (D-8), имя ЮЛ совпадает с шапкой
assert brief["counterparty_check"] == "MATCH"
assert brief["status"] == "RECOGNIZED"
assert brief["status"] == "CONFIRMED" # чистая ОСВ подтверждена автоматически (OQ-157)
document = (await client.get(f"/documents/{brief['id']}")).json()
assert document["file_id"] == str(file_id)
assert document["legal_entity_id"] == str(entity.id)
assert (document["period_year"], document["period_quarter"]) == (2024, "Q4")
assert (document["counterparty_check"], document["status"]) == ("MATCH", "RECOGNIZED")
assert (document["counterparty_check"], document["status"]) == ("MATCH", "CONFIRMED")
entries = (await client.get(f"/documents/{brief['id']}/entries")).json()
assert entries["total"] == ENTRY_ROWS

View File

@ -16,11 +16,21 @@ import pytest
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.common.clock import utc_now
from src.core.config import get_settings
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent, Comment
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus
from src.models.enums import (
AuditAction,
AuditSection,
ConsolidationStatus,
DocumentStatus,
FileStatus,
PeriodQuarter,
RecognitionStatus,
)
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry
from src.models.statements import (
@ -33,6 +43,7 @@ from src.services.result_mapping import process_once
from tests.conftest import AuthClientFactory, FakeStorage
from tests.factories import (
make_company_group,
make_consolidation,
make_document,
make_entries_from_result,
make_legal_entity,
@ -108,7 +119,7 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa
assert event.before_value["status"] == "ERROR"
assert event.before_value["job"]["id"] == str(job_id)
assert event.before_value["job"]["error_code"] == "PARSE_ERROR"
assert event.after_value is None
assert event.after_value == {"unbound_version_ids": []}
MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json"
@ -248,7 +259,9 @@ async def test_confirmed_document_blocks_delete(seeded: AsyncSession, auth_clien
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.NEEDS_CHECK)
await make_document(seeded, file=file, job=job, account="62", status=DocumentStatus.CONFIRMED)
confirmed = await make_document(seeded, file=file, job=job, account="62", status=DocumentStatus.CONFIRMED)
confirmed.confirmed_by = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one().id
await seeded.flush()
file_id, job_id = file.id, job.id
client = await auth_client(KA_RF)
@ -383,8 +396,11 @@ async def test_delete_rereads_state_after_locks(
deletion = asyncio.create_task(client.delete(f"/files/{file_id}"))
await _wait_for_lock_waiter(committed_sessionmaker)
assert not deletion.done()
user_id = (await confirmer.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
await confirmer.execute(
update(Document).where(Document.id == document_id).values(status=DocumentStatus.CONFIRMED)
update(Document)
.where(Document.id == document_id)
.values(status=DocumentStatus.CONFIRMED, confirmed_by=user_id)
)
response = await asyncio.wait_for(deletion, LOCK_WAIT_SECONDS)
@ -441,3 +457,105 @@ async def test_mapping_loop_ignores_deleted_file(
result_key = f"results/{file_id}/{job_id}/result.json"
expires = (await session.execute(select(S3Object.expires_at).where(S3Object.key == result_key))).scalar_one()
assert expires is not None
# --- автоподтверждённые документы (OQ-157, design D-4) ---------------------------------------------------------------
async def _auto_confirmed_in_versions(
session: AsyncSession, statuses: list[ConsolidationStatus]
) -> tuple[StoredFile, Document, list[ConsolidationVersion]]:
"""ЮЛ Ростова в ГК; по версии на каждый статус (2024 Q3, Q4 — разные консолидации); файл с автоподтверждённой ОСВ 60
за 2024 Q2 (`confirmed_by` NULL), привязанной ко всем версиям."""
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
entity = await make_legal_entity(session, name="ООО Автоподтверждение", group=group)
other = await make_legal_entity(session, name="ООО Второе", group=group)
initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
versions = []
for quarter, status in zip([PeriodQuarter.Q3, PeriodQuarter.Q4], statuses, strict=False):
_, version = await make_consolidation(
session,
group=group,
year=2024,
quarter=quarter,
members=[entity, other],
initiator=initiator,
status=status,
)
versions.append(version)
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
document = await make_document(
session,
file=file,
job=job,
account="60",
year=2024,
quarter=PeriodQuarter.Q2,
status=DocumentStatus.CONFIRMED,
is_cumulative=False,
)
document.confirmed_at = utc_now()
for version in versions:
session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
await session.flush()
return file, document, versions
async def test_delete_auto_confirmed_in_correction_unbinds(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
file, document, [version] = await _auto_confirmed_in_versions(seeded, [ConsolidationStatus.DATA_CORRECTION])
file_id, version_id = file.id, version.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0
assert (
await _count(seeded, ConsolidationDocument, ConsolidationDocument.consolidation_version_id == version_id) == 0
)
refreshed = await seeded.get(ConsolidationVersion, version_id, populate_existing=True)
assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION
[event] = await _delete_events(seeded)
assert event.after_value == {"unbound_version_ids": [str(version_id)]}
async def test_delete_blocked_if_any_bound_version_left_in_work(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Review Focus 5: одна из двух версий на согласовании — 409, ни одна привязка не снята."""
file, document, versions = await _auto_confirmed_in_versions(
seeded, [ConsolidationStatus.DATA_CORRECTION, ConsolidationStatus.CONSOLIDATION_RF]
)
file_id, document_id = file.id, document.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS"
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1
assert await _count(seeded, ConsolidationDocument, ConsolidationDocument.document_id == document_id) == len(
versions
)
assert await _delete_events(seeded) == []
async def test_delete_auto_confirmed_without_bindings(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.CONFIRMED) # confirmed_by NULL
file_id = file.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
[event] = await _delete_events(seeded)
assert event.after_value == {"unbound_version_ids": []}

View File

@ -0,0 +1,225 @@
"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»;
OQ-157; design D-1–D-3)."""
import uuid
from pathlib import Path
import pytest
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.clock import utc_now
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.consolidation import ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
from src.models.enums import (
AuditAction,
ConsolidationStatus,
DocumentStatus,
DocumentType,
FileStatus,
PeriodQuarter,
RecognitionStatus,
)
from src.models.files import Document, StoredFile
from src.models.statements import FinancialStatementMeta
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
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
pytestmark = pytest.mark.integration
ROSTOV = "Ростовский региональный филиал"
KA_RF = "ka_rf@armka.local"
YEAR = 2024
Q2, Q3 = PeriodQuarter.Q2, PeriodQuarter.Q3
HTTP_CONFLICT = 409
async def _user(session: AsyncSession) -> AppUser:
return (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
async def _group(session: AsyncSession) -> tuple[CompanyGroup, list[LegalEntity]]:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
return group, [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)]
async def _version(
session: AsyncSession, group: CompanyGroup, members: list[LegalEntity], status: ConsolidationStatus
) -> ConsolidationVersion:
_, version = await make_consolidation(
session, group=group, year=YEAR, quarter=Q3, members=members, initiator=await _user(session), status=status
)
return version
async def _file_with(
session: AsyncSession, entity: LegalEntity, statuses: list[DocumentStatus], uploaded_by: uuid.UUID | None
) -> tuple[StoredFile, list[Document]]:
"""Файл `PROCESSED` с квартальными ОСВ 60, 62, … за 2024 Q2 в статусах `statuses`."""
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, uploaded_by=uploaded_by
)
documents = [
await make_document(
session, file=file, job=job, account=account, year=YEAR, quarter=Q2, status=status, is_cumulative=False
)
for account, status in zip(["60", "62", "76"], statuses, strict=False)
]
return file, documents
async def _bound(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id)
return set((await session.execute(stmt)).scalars())
async def _confirm_events(session: AsyncSession) -> list[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM)
return list((await session.execute(stmt)).scalars())
def test_transition_comment() -> None:
osv = Document(document_type=DocumentType.OSV, osv_account_code="60")
income = Document(document_type=DocumentType.INCOME, osv_account_code=None)
assert transition_comment(osv) == "автоподтверждение документа OSV 60"
assert transition_comment(income) == "автоподтверждение документа INCOME"
async def test_clean_document_is_confirmed_by_system(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
user = await _user(seeded)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)
now = utc_now()
confirmed = await auto_confirm(seeded, file=file, documents=[document], now=now)
assert confirmed == [document]
assert (document.status, document.confirmed_by, document.confirmed_at) == (DocumentStatus.CONFIRMED, None, now)
[event] = await _confirm_events(seeded)
assert (event.object_id, event.user_id, event.description) == (document.id, None, AUTO_CONFIRM_DESCRIPTION)
assert (event.before_value, event.after_value) == ({"status": "RECOGNIZED"}, {"status": "CONFIRMED"})
async def test_needs_check_is_left_for_analyst(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.NEEDS_CHECK], (await _user(seeded)).id)
assert await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) == []
assert document.status is DocumentStatus.NEEDS_CHECK and document.confirmed_at is None
assert await _confirm_events(seeded) == []
assert await _bound(seeded, version.id) == set()
async def test_binds_and_moves_version_with_uploader_and_comment(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
user = await _user(seeded)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id)
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
assert await _bound(seeded, version.id) == {document.id}
refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION
last = (
await seeded.execute(
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version.id)
.order_by(ConsolidationStatusHistory.changed_at.desc())
.limit(1)
)
).scalar_one()
assert (last.changed_by, last.comment) == (user.id, "автоподтверждение документа OSV 60")
async def test_file_without_uploader_binds_without_transition(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], None)
messages: list[str] = []
sink = logger.add(lambda message: messages.append(str(message)), level="INFO")
try:
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
finally:
logger.remove(sink)
assert document.status is DocumentStatus.CONFIRMED
assert await _bound(seeded, version.id) == {document.id}
assert any("Переход версии по подтверждению пропущен" in m and str(version.id) in m for m in messages)
refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True)
assert refreshed is not None and refreshed.status is ConsolidationStatus.INITIATED
async def test_mixed_documents_confirm_only_clean(seeded: AsyncSession) -> None:
group, entities = await _group(seeded)
file, documents = await _file_with(
seeded, entities[0], [DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK], (await _user(seeded)).id
)
confirmed = await auto_confirm(seeded, file=file, documents=documents, now=utc_now())
assert confirmed == [documents[0]]
assert [d.status for d in documents] == [DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK]
@pytest.mark.fixtures
async def test_clean_form_sets_reviewed_at(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)
assert income.status is DocumentStatus.RECOGNIZED
file = await seeded.get(StoredFile, income.stored_file_id)
assert file is not None
now = utc_now()
assert await auto_confirm(seeded, file=file, documents=[income], now=now) == [income]
meta = (
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
).scalar_one()
assert (income.status, meta.reviewed_by, meta.reviewed_at) == (DocumentStatus.CONFIRMED, None, now)
@pytest.mark.fixtures
async def test_recognized_form_without_period_stays(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)
income.period_quarter = None # рассинхрон правил: RECOGNIZED без периода не должен подтверждаться сам
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()
entity = await make_legal_entity(seeded, name="ООО Ростов", office=office)
file, [document] = await _file_with(seeded, entity, [DocumentStatus.RECOGNIZED], None)
await auto_confirm(seeded, file=file, documents=[document], now=utc_now())
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "ALREADY_CONFIRMED"
assert len(await _confirm_events(seeded)) == 1

View File

@ -473,7 +473,7 @@ async def test_on_document_confirmed_returns_bound_versions(seeded: AsyncSession
user = await _user(seeded)
bound = await consolidation_documents.on_document_confirmed(
seeded, AuditService(seeded), document=document, user_id=user.id, now=utc_now()
seeded, AuditService(seeded), document=document, changed_by=user.id, now=utc_now()
)
assert bound == sorted([q2.id, q3.id])
@ -481,6 +481,49 @@ async def test_on_document_confirmed_returns_bound_versions(seeded: AsyncSession
assert await _bound(seeded, q4.id) == set()
@pytest.mark.integration
async def test_on_document_confirmed_without_changed_by_binds_but_keeps_status(seeded: AsyncSession) -> None:
"""Автоподтверждение файла без `uploaded_by` (OQ-157, D-2): привязка есть, перехода версии нет."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
document = await _confirmed(seeded, entities[0], quarter=Q2)
history = len(await _history(seeded, version.id))
bound = await consolidation_documents.on_document_confirmed(
seeded, AuditService(seeded), document=document, changed_by=None, now=utc_now()
)
assert bound == [version.id]
assert document.id in await _bound(seeded, version.id)
assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED
assert len(await _history(seeded, version.id)) == history
@pytest.mark.integration
async def test_on_document_confirmed_writes_comment_to_history(seeded: AsyncSession) -> None:
"""Комментарий перехода от вызывающего (автоподтверждение) попадает в историю статусов."""
group, entities = await _group(seeded)
version = await _version(seeded, group, entities)
user = await _user(seeded)
document = await _confirmed(seeded, entities[0], quarter=Q2)
await consolidation_documents.on_document_confirmed(
seeded,
AuditService(seeded),
document=document,
changed_by=user.id,
comment="автоподтверждение документа OSV 60",
now=utc_now(),
)
last = (await _history(seeded, version.id))[-1]
assert (last.to_status, last.changed_by, last.comment) == (
ConsolidationStatus.DATA_CORRECTION,
user.id,
"автоподтверждение документа OSV 60",
)
# --- подхват ----------------------------------------------------------------------------------------------------------
@ -700,7 +743,7 @@ async def _confirm_in(session: AsyncSession, data: dict[str, Any]) -> list[uuid.
document.confirmed_at = now
await session.flush()
return await consolidation_documents.on_document_confirmed(
session, AuditService(session), document=document, user_id=data["user_id"], now=now
session, AuditService(session), document=document, changed_by=data["user_id"], now=now
)

View File

@ -0,0 +1,83 @@
"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b)."""
import json
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.directories import LegalEntity
from src.models.enums import DocumentType
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 tests.factories import make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3"
SYNTH = GOLDEN_STATEMENTS / "synthetic"
async def _owner(session: AsyncSession, path: Path) -> LegalEntity:
"""Владелец с ИНН шапки формы (MATCH)."""
data = json.loads(path.read_text("utf-8"))
inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None)
return await make_legal_entity(session, name="ООО Владелец", inn=inn)
async def _income(session: AsyncSession, fixtures_dir: Path) -> Document:
path = fixtures_dir / MEZH / "1_Форма2_бух_09.json"
(income,) = await map_golden_snapshot(session, entity=await _owner(session, path), path=path, uploaded_by=None)
assert income.document_type is DocumentType.INCOME
return income
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
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
async def test_form_with_hard_failure_is_not_confirmable_until_accepted(
seeded: AsyncSession, fixtures_dir: Path
) -> None:
income = await _income(seeded, fixtures_dir)
meta = (
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
).scalar_one()
# ломаем итог 2200 во всех колонках: строгое соотношение «2200 = 2100 - 2210 - 2220» перестаёт сходиться
# (соотношения с 2400 в словаре мягкие — strict: false, поэтому на них жёсткое нарушение не проверить)
values = (
await seeded.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 seeded.flush()
assert await statement_hard_failures(seeded, income, meta) != ()
assert await form_confirmable(seeded, income) is False
meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор
await seeded.flush()
assert await form_confirmable(seeded, income) is True
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
)
disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE)
assert await form_confirmable(seeded, disclosure) is True

View File

@ -49,6 +49,7 @@ from src.models.enums import (
from src.models.files import Document, RecognitionJob, StoredFile
from src.models.osv import OsvEntry
from src.repositories.counterparties import CounterpartyIndex
from src.services import consolidation_documents
from src.services import result_mapping as rm
from src.services.recognition_exchange import expire_deadlines, handle_message
from src.services.result_mapping import (
@ -236,7 +237,7 @@ async def test_golden_osv_66_mapped( # noqa: PLR0915 - один сценари
assert document.is_cumulative is True
assert document.locator == "sheet:Лист_1"
assert document.legal_entity_id == entity_id
assert document.status is DocumentStatus.RECOGNIZED # done, проверки чистые, шапка = владелец по имени
assert document.status is DocumentStatus.CONFIRMED # done, чистые проверки, шапка = владелец → автоподтверждение
assert document.counterparty_check is DocumentCheckResult.MATCH
assert document.period_check is DocumentCheckResult.UNKNOWN
@ -617,19 +618,20 @@ async def test_cumulative_by_q1_data(
assert document.status is DocumentStatus.NEEDS_CHECK
else:
assert warnings == []
assert document.status is DocumentStatus.RECOGNIZED
assert document.status is DocumentStatus.CONFIRMED # чистый → автоподтверждение (OQ-157)
@pytest.mark.parametrize(
("job_status", "expected"),
[(RecognitionStatus.done, DocumentStatus.RECOGNIZED), (RecognitionStatus.needs_check, DocumentStatus.NEEDS_CHECK)],
[(RecognitionStatus.done, DocumentStatus.CONFIRMED), (RecognitionStatus.needs_check, DocumentStatus.NEEDS_CHECK)],
)
async def test_clean_result(
committed_sessionmaker: async_sessionmaker[AsyncSession],
job_status: RecognitionStatus,
expected: DocumentStatus,
):
"""«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `RECOGNIZED`, `MATCH`, `UNKNOWN`;
"""«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `CONFIRMED` (автоподтверждение,
OQ-157), `MATCH`, `UNKNOWN`;
задание `needs_check` → `NEEDS_CHECK`."""
maker, storage = committed_sessionmaker, FakeStorage()
file_id, _, _ = await _prepare(maker, storage, _minimal(), owner_inn="7700000000", job_status=job_status)
@ -1409,13 +1411,14 @@ async def _load_entity(maker: async_sessionmaker[AsyncSession], entity_id: uuid.
@pytest.mark.parametrize(
("job_status", "moved"),
[(RecognitionStatus.needs_check, True), (RecognitionStatus.done, False)], # NEEDS_CHECK / RECOGNIZED (чистый)
[(RecognitionStatus.needs_check, True), (RecognitionStatus.done, False)], # NEEDS_CHECK / CONFIRMED (чистый)
)
async def test_mapping_moves_documents_processing_version_only_for_needs_check(
committed_sessionmaker: async_sessionmaker[AsyncSession], job_status: RecognitionStatus, moved: bool
):
"""«Предупреждение маппинга двигает статус» и «Документ RECOGNIZED»: ОСВ 60 за 2025 Q1 (квартальная) подходит версии
2025 Q3; `NEEDS_CHECK` → `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; привязок нет."""
2025 Q3; `NEEDS_CHECK` → `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший, привязок нет; чистый
документ → `CONFIRMED`, привязка и тот же переход (OQ-157)."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
file_id, _, _ = await _prepare(
@ -1430,22 +1433,14 @@ async def test_mapping_moves_documents_processing_version_only_for_needs_check(
assert await process_once(maker, storage, _settings()) is True
[document] = await _documents(maker, file_id)
assert document.status is (DocumentStatus.NEEDS_CHECK if moved else DocumentStatus.RECOGNIZED)
assert document.status is (DocumentStatus.NEEDS_CHECK if moved else DocumentStatus.CONFIRMED)
async with maker() as session:
version = await session.get(ConsolidationVersion, version_id)
assert version is not None
history = list(
(
await session.execute(
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version_id)
.order_by(ConsolidationStatusHistory.changed_at)
)
).scalars()
)
history = await _history_of(maker, version_id)
bindings = (await session.execute(select(func.count()).select_from(ConsolidationDocument))).scalar_one()
assert bindings == 0
if moved:
assert bindings == 0
assert version.status is ConsolidationStatus.DATA_CORRECTION
last = history[-1]
assert (last.from_status, last.to_status, last.changed_by) == (
@ -1454,8 +1449,16 @@ async def test_mapping_moves_documents_processing_version_only_for_needs_check(
user_id,
)
else:
assert version.status is ConsolidationStatus.DOCUMENTS_PROCESSING
assert len(history) == FACTORY_HISTORY # создание и переход фабрики в DOCUMENTS_PROCESSING; новых записей нет
# чистый документ подтверждён системой (OQ-157): привязан, версия перешла от загрузившего с комментарием
assert bindings == 1
assert version.status is ConsolidationStatus.DATA_CORRECTION
last = history[-1]
assert (last.from_status, last.to_status, last.changed_by, last.comment) == (
ConsolidationStatus.DOCUMENTS_PROCESSING,
ConsolidationStatus.DATA_CORRECTION,
user_id,
"автоподтверждение документа OSV 60",
)
async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: async_sessionmaker[AsyncSession]):
@ -1479,6 +1482,147 @@ async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: a
assert version.status is ConsolidationStatus.INITIATED
async def _history_of(
maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID
) -> list[ConsolidationStatusHistory]:
async with maker() as session:
stmt = (
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version_id)
.order_by(ConsolidationStatusHistory.changed_at)
)
return list((await session.execute(stmt)).scalars())
async def _bound_ids(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> set[uuid.UUID]:
async with maker() as session:
stmt = select(ConsolidationDocument.document_id).where(
ConsolidationDocument.consolidation_version_id == version_id
)
return set((await session.execute(stmt)).scalars())
async def test_clean_document_is_auto_confirmed_bound_and_moves_version(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""osv-result-mapping «Привязка и переход версии»: чистая ОСВ 60 за 2025 Q1 → `CONFIRMED` системой, привязана к
версии 2025 Q3 (`DOCUMENTS_PROCESSING`), переход в `DATA_CORRECTION` от загрузившего с комментарием."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
file_id, _, _ = await _prepare(
maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id
)
assert await process_once(maker, storage, _settings()) is True
[document] = await _documents(maker, file_id)
assert (document.status, document.confirmed_by) == (DocumentStatus.CONFIRMED, None)
assert document.confirmed_at is not None
assert await _bound_ids(maker, version_id) == {document.id}
last = (await _history_of(maker, version_id))[-1]
assert (last.to_status, last.changed_by, last.comment) == (
ConsolidationStatus.DATA_CORRECTION,
user_id,
"автоподтверждение документа OSV 60",
)
async with maker() as session:
events = (
(
await session.execute(
select(AuditEvent).where(
AuditEvent.action == AuditAction.DOCUMENT_CONFIRM, AuditEvent.object_id == document.id
)
)
)
.scalars()
.all()
)
assert [(event.user_id, event.description) for event in events] == [(None, "автоподтверждение: без замечаний")]
async def test_mixed_file_confirms_clean_and_moves_version_once(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""Review Focus 1: в файле чистый и `NEEDS_CHECK` документы — версия получает ровно один переход."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
result = _minimal()
second = copy.deepcopy(result["documents"][0])
second["header"]["account_code_raw"] = "62"
second["locator"] = "sheet:Лист_2"
second["checks"] = {"total_row_matches_root": False} # нарушение проверок → NEEDS_CHECK
result["documents"].append(second)
file_id, _, _ = await _prepare(
maker, storage, result, entity=await _load_entity(maker, entity_id), uploaded_by=user_id
)
assert await process_once(maker, storage, _settings()) is True
statuses = sorted((d.osv_account_code, d.status) for d in await _documents(maker, file_id))
assert statuses == [("60", DocumentStatus.CONFIRMED), ("62", DocumentStatus.NEEDS_CHECK)]
history = await _history_of(maker, version_id)
assert [h.to_status for h in history].count(ConsolidationStatus.DATA_CORRECTION) == 1
# переход сделал именно автоподтверждение (T4.2): при перестановке с on_documents_mapped комментарий иной
assert history[-1].to_status is ConsolidationStatus.DATA_CORRECTION
assert history[-1].comment == "автоподтверждение документа OSV 60"
clean = next(d for d in await _documents(maker, file_id) if d.osv_account_code == "60")
assert await _bound_ids(maker, version_id) == {clean.id} # NEEDS_CHECK-документ 62 не привязан
async def test_supersede_rebinds_auto_confirmed_document(committed_sessionmaker: async_sessionmaker[AsyncSession]):
"""Review Focus 2: новая чистая версия файла того же ключа заменяет привязку старой."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
entity = await _load_entity(maker, entity_id)
first_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id)
assert await process_once(maker, storage, _settings()) is True
[first] = await _documents(maker, first_id)
assert await _bound_ids(maker, version_id) == {first.id}
second_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id)
assert await process_once(maker, storage, _settings()) is True
[second] = await _documents(maker, second_id)
assert second.status is DocumentStatus.CONFIRMED
assert await _bound_ids(maker, version_id) == {second.id}
assert (await _file(maker, first_id)).status is FileStatus.SUPERSEDED
async def test_auto_confirm_rolls_back_with_mapping(
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
):
"""Review Focus 3: сбой после автоподтверждения откатывает и его; повтор — одно событие `DOCUMENT_CONFIRM`."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, _, user_id = await _version_in(maker)
file_id, job_id, _ = await _prepare(
maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id
)
original = consolidation_documents.on_documents_mapped
calls = {"n": 0}
async def flaky(*args: Any, **kwargs: Any) -> list[uuid.UUID]:
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("сбой после автоподтверждения")
return await original(*args, **kwargs)
monkeypatch.setattr(consolidation_documents, "on_documents_mapped", flaky)
assert await process_once(maker, storage, _settings()) is False # повторяемая неудача (R-15)
assert await _documents(maker, file_id) == [] # маппинг вместе с автоподтверждением откатился целиком
assert await process_once(maker, storage, _settings()) is True
[document] = await _documents(maker, file_id)
assert document.status is DocumentStatus.CONFIRMED
async with maker() as session:
count = (
await session.execute(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM)
)
).scalar_one()
assert count == 1
async def test_mapping_without_uploader_skips_transition_without_error(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
@ -1518,8 +1662,9 @@ async def _audit_count(maker: async_sessionmaker[AsyncSession]) -> int:
async def test_exchange_and_mapping_write_no_audit_events(committed_sessionmaker: async_sessionmaker[AsyncSession]):
"""spec audit-trail «События файлов и документов»: смена статусов задания и файла (ack, результат, маппинг) и
автозамена без заменяемых файлов `audit_event` не пишут: путь `UPLOADED → PROCESSING → PROCESSED` оставляет таблицу
аудита неизменной."""
автозамена без заменяемых файлов `audit_event` не пишут: путь `UPLOADED → PROCESSING → PROCESSED` для документа
`NEEDS_CHECK` (у владельца нет ИНН) оставляет таблицу аудита неизменной. Автоподтверждение чистого документа пишет
`DOCUMENT_CONFIRM` — это проверяет отдельный тест."""
maker, storage = committed_sessionmaker, FakeStorage()
file_id, job_id, _ = await _prepare(
maker, storage, _minimal(), job_status=RecognitionStatus.published, file_status=FileStatus.UPLOADED
@ -1535,6 +1680,8 @@ async def test_exchange_and_mapping_write_no_audit_events(committed_sessionmaker
assert await process_once(maker, storage, _settings()) is True
assert (await _file(maker, file_id)).status is FileStatus.PROCESSED
[document] = await _documents(maker, file_id)
assert document.status is DocumentStatus.NEEDS_CHECK
assert await _audit_count(maker) == before

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-08

View File

@ -0,0 +1,118 @@
# Design: автоподтверждение чистых документов
## Context
Маппинг результата (`services/result_mapping.py`, `_map_candidate`) в одной транзакции: `map_file` создаёт документы
файла со статусом `RECOGNIZED` или `NEEDS_CHECK` (ОСВ — `domain/osv_mapping.document_status`, §9.3; формы —
`domain/statement_mapping`, §9.5 D-6; `DISCLOSURE`/`OTHER` — всегда `RECOGNIZED`), автозамена снимает привязки
документов прежних файлов (`on_documents_superseded`), затем хук `on_documents_mapped` переводит версии в `DATA_CORRECTION`
по документам `NEEDS_CHECK` (`changed_by = file.uploaded_by`, design №2 Среза 4). Привязки к версиям создаёт только
ручное подтверждение (`DocumentService.confirm` → `on_document_confirmed`) и подхват при создании версии и правке
периметра (`bind_confirmed`). Удаление файла (`PrimaryFilesService.delete`) запрещено при любом `CONFIRMED` документе.
Порядок блокировок (`CLAUDE.md` arm-core): задание → файл → документы → advisory ГК → версии по `id`. Маппинг уже
держит задание, файл и документы, когда вызывает хуки версий.
## Goals / Non-Goals
**Goals:** чистый документ сразу `CONFIRMED` и в своде без действий аналитика; поведение привязки и статусов версии —
то же, что при ручном подтверждении; ошибочно загруженный чистый файл можно удалить, пока версия не ушла дальше
корректировки.
**Non-Goals:** см. proposal — новые поля, UI, миграция данных, пересмотр `NEEDS_CHECK`.
## Decisions
### D-1. Где: шаг маппинга после автозамены
`_map_candidate`: `map_file` → автозамена (как сейчас) → **`auto_confirm(session, file, documents, now)`** →
`on_documents_mapped`. После автозамены — чтобы привязки прежних версий файла были уже сняты, а замена по ключу (R-1)
работала как при ручном подтверждении. До `on_documents_mapped` — тот смотрит только `NEEDS_CHECK`, автоподтверждённые
его не касаются. Всё в одной транзакции маппинга: при ошибке откатывается и маппинг, и подтверждение (повтор задания —
по счётчику попыток, как сейчас).
### D-2. Кто: система за документ, загрузивший — за переход версии (решение владельца 08.10.2026)
`document.confirmed_by = NULL`, `confirmed_at = now`; у формы `financial_statement_meta.reviewed_by = NULL`,
`reviewed_at = now`. `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS, object_type = document)`: `user_id = NULL`,
`before_value.status = RECOGNIZED`, `after_value.status = CONFIRMED`, `description = «автоподтверждение: без замечаний»`
(DBML: `audit_event.user_id` — «NULL для системных событий»). Переход версии — через `consolidation_status.transition`
с `changed_by = stored_file.uploaded_by` (`consolidation_status_history.changed_by` NOT NULL в DBML) и `comment =
«автоподтверждение документа <тип> <счёт>»`. `on_document_confirmed` получает `changed_by: uuid | None` и `comment`
от вызывающего: ручное подтверждение передаёт пользователя и `None`, автоподтверждение — `file.uploaded_by` и
комментарий; при `changed_by = None` привязка создаётся, переход пропускается с записью в лог.
Признак автоподтверждения — `CONFIRMED` и `confirmed_by IS NULL`: ручное подтверждение всегда пишет пользователя
(`DOCUMENT_EDIT` на маршруте).
### D-3. Что: документ, ставший `RECOGNIZED` при маппинге, и только он
Кандидаты — документы файла со статусом `RECOGNIZED` сразу после `map_file`. Для `BALANCE`/`INCOME` с шапкой
дополнительно — условия ручного подтверждения (D-9 Среза 3b): `period_year` и `period_quarter` определены, жёстких
нарушений §7 нет (на момент маппинга акцептов нет). Условия выносятся из `DocumentService._check_statement` в общую
функцию (одна точка правды; ручной путь продолжает отвечать 409 `PERIOD_REQUIRED`/`CHECKS_NOT_PASSED`, автоматический —
просто оставляет `RECOGNIZED`). По §9.5 D-6 `RECOGNIZED` формы уже требует период и пройденные проверки, так что
расхождение на практике — только защита от рассинхронизации правил. `DISCLOSURE`/`OTHER` (`RECOGNIZED` по D-3 Среза 3b)
подтверждаются тоже: проверять в них нечего, в версии они не привязываются (`fits` — ОСВ и формы).
Пересчёт статуса формы после правки или акцепта (`RECOGNIZED`/`NEEDS_CHECK`) автоподтверждения не запускает: после
исправлений аналитик нажимает «Подтвердить данные» (ТЗ 03_9, 10_10).
### D-4. Удаление: только автоподтверждённое и только до согласования
`PrimaryFilesService.delete` под существующими блокировками (задания → файл → документы) проверяет: если у файла есть
`CONFIRMED` документ с `confirmed_by IS NOT NULL` — 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (как сейчас). Если все
`CONFIRMED` документы автоподтверждены — дальше блокируются версии ЮЛ «в работе» (`lock_versions_for_entity` берёт только `IN_WORK_STATUSES`; версии вне
«в работе» читаются без блокировки: удаление из них запрещено, возврата в «в работе» нет; порядок «… → документы → advisory ГК → версии» соблюдён) и проверяется, что каждая
версия с привязкой в `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION`; иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` с
текстом «документ привязан к версии консолидации вне корректировки данных». Затем привязки снимаются (`consolidation_document`), файл
удаляется как сейчас; `FILE_DELETE.after_value = {unbound_version_ids: [...]}` (по возрастанию; пусто — пустой список),
`before_value` — карточка файла, как сейчас. Статус версии не меняется (свод — чистая функция, пересчитается).
`FILE_SUPERSEDES_OTHERS` — без изменений.
### D-5. Блокировки
`auto_confirm` вызывается под заданием, файлом и документами маппинга; версии берёт `on_document_confirmed` через
`lock_versions_for_entity` (advisory ГК → версии `FOR UPDATE` по `id`) — тот же путь, что у ручного подтверждения.
Несколько документов файла — повторные вызовы в одной транзакции по одному ЮЛ: блокировки уже взяты, порядок не
нарушается. Документы файла уже под `FOR NO KEY UPDATE` маппинга (R-3 совместимость со вставкой привязки).
### D-6. Данные и совместимость
Миграции схемы и данных нет. Старые `RECOGNIZED` остаются и подтверждаются кнопкой. API не меняется:
`DocumentOut.confirmed_by` уже `nullable`; `POST …/confirm` для автоподтверждённого отвечает 409 `ALREADY_CONFIRMED`.
Чек-лист: ячейка с автоподтверждённым документом — `CONFIRMED` («Загружен»).
## Risks / Trade-offs
- **Неверный, но «чистый» файл сразу в своде** (например, ОСВ не того ЮЛ с совпавшим ИНН шапки). Раньше его ловил
взгляд аналитика при подтверждении. Смягчение: удаление разрешено до согласования (D-4), автозамена новой версией
файла работает как раньше; документ виден в панели и чек-листе как «Загружен».
- **Нагрузка на транзакцию маппинга** растёт на блокировку версий ЮЛ для каждого документа — то же, что делает хук
`on_documents_mapped`; в комплекте десятки документов, не тысячи.
- **Журнал**: событие `DOCUMENT_CONFIRM` без пользователя — системное, как `FILE_REPLACE`; фильтр журнала по
пользователю его не покажет.
## Migration Plan
Выкатка обычная: миграций нет. После обновления ядра новые результаты подтверждаются автоматически; уже загруженные
`RECOGNIZED` — вручную (или перезагрузкой файла).
## Open Questions
- OQ-157 (новый, docs): автоподтверждение чистых документов — решено владельцем 08.10.2026; пересматривает OQ-05
(`RECOGNIZED` остаётся статусом модели, но после маппинга на нём документ не задерживается) и уточняет OQ-110
(удаление при консолидациях — для автоподтверждённых до `DATA_CORRECTION` включительно).
## Decisions made without discussion
| № | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-08 | Автоподтверждение — после автозамены и до `on_documents_mapped` в той же транзакции маппинга | замена привязки по ключу работает как при ручном подтверждении; откат целиком при ошибке | неверный порядок — лишняя/потерянная привязка, ловится тестом автозамены |
| 2 | 2026-10-08 | `DISCLOSURE`/`OTHER` тоже подтверждаются автоматически | в них нечего проверять; ТЗ 03_9 п. 4 не различает типы | расшифровку нельзя удалить без условий D-4 — те же, что у ОСВ |
| 3 | 2026-10-08 | Условия формы — общая функция с ручным подтверждением; при невыполнении документ остаётся `RECOGNIZED` без ошибки | одна точка правды; автоматический путь не должен падать | форма без периода ждёт кнопку, как раньше |
| 4 | 2026-10-08 | После правки/акцепта формы автоподтверждения нет | ТЗ: «Подтвердить данные» после исправлений | лишний клик после правки — осознанный |
| 5 | 2026-10-08 | Существующие `RECOGNIZED` не мигрируются | контур без продовых данных; миграция данных с аудитом без пользователя — лишний риск | на стенде старые документы подтверждаются кнопкой |
| 6 | 2026-10-08 | Комментарий перехода версии — «автоподтверждение документа <тип> <счёт>», описание аудита — «автоподтверждение: без замечаний» | история версии и журнал объясняют, откуда переход без клика | тексты меняются без миграции |
| 7 | 2026-10-08 | Удаление: проверка статусов версий только тех, к которым привязаны документы файла; `FILE_DELETE.after_value.unbound_version_ids` | симметрично `FILE_REPLACE.unbound_version_ids` | — |
| 8 | 2026-10-08 | Тест `form_confirmable` ломает строку 2200 (строгое соотношение), а не 2400 (соотношения с 2400 в `statement_checks.yaml` нежёсткие) | иначе тест не проверяет жёсткое нарушение | только тест |
| 9 | 2026-10-08 | Тесты автоподтверждения, маппинга и удаления берут `uploaded_by` у реального пользователя (FK `app_user`), а не случайный uuid (ошибка плана) | случайный uuid не проходит FK | нет |
| 10 | 2026-10-08 | В `tests/test_api_files_delete.py` тесты, имитирующие подтверждение пользователем (`test_confirmed_document_blocks_delete`, `test_delete_rereads_state_after_locks`), получили `confirmed_by` | иначе по правилу удаления (D-4) документ считается автоподтверждённым и удаление проходит | нет |
| 11 | 2026-10-08 | Импорт `auto_confirm` в `result_mapping._map_candidate` — локальный | цикл `documents` → `result_mapping` → `auto_confirm` → `statements` → `documents` | техдолг: вынести `form_confirmable`/`FORM_TYPES` в модуль без обратной зависимости |
| 12 | 2026-10-08 | `make check-full` запускается один раз в конце, после финального ревью и fix-волны (а не внутри Task 6 плана) | не гонять полную проверку дважды | если check-full что-то найдёт — правка после ревью |
| 13 | 2026-10-08 | M-3 (гонка в `bound_versions` при будущем маршруте «вернуть на доработку» Среза 7) и вынос `form_confirmable` из цикла импортов оставлены техдолгом (follow-up) | сейчас возврата версии в «в работе» нет, цикл импортов обойдён локальным импортом | при добавлении возврата на доработку надо отвечать 409 для версий, не попавших в множество, заблокированное `lock_versions_for_entity` |
| 14 | 2026-10-08 | Отменённая/отклонённая/архивная версия с привязкой автоподтверждённого документа блокирует удаление файла (по спеке «вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION»), привязки при отмене версии не снимаются | буквальное чтение спеки | файл из отменённой версии удалить нельзя — вопрос владельцу |

View File

@ -0,0 +1,57 @@
# Proposal: автоподтверждение чистых документов
**Срез:** доработка Срезов 2c, 3b и 4 (`../docs/context/09-delivery-plan.md`) — путь «загрузка → свод» без пустых кликов.
**Репозиторий:** arm-core. **Issue:** RSHB/arm-core#64. Правило и OQ — bounded-PR в `../docs` (`05-domain-rules.md` §9.3,
§9.5; `07-open-questions.md` OQ-157, пометка к OQ-05 и OQ-110; гайды `dev/qa-guide.md`, `dev/frontend-api-guide.md`).
DBML и контракты обмена не меняются.
## Why
Документ без замечаний сейчас получает `RECOGNIZED` и попадает в консолидацию только после ручного «Подтвердить»
(`CONFIRMED`, §3 п. 1). Для ОСВ этот клик ничего не проверяет: строки ОСВ не правятся (OQ-114), ошибка исправляется
перезагрузкой файла. На комплекте РБ + Счастливочная за 1 кв. 2025 это 16 нажатий подряд (прогон владельца 08.10.2026).
ТЗ этого шага не требует: 03_9, п. 4 загрузки — «при отсутствии замечаний документ получает статус „Загружен“, при
замечаниях — „Требуется проверка“»; 10_10 — «Загружен» при прохождении всех проверок; «Подтвердить данные» — шаг после
исправлений. Решение владельца 08.10.2026: чистый документ сразу «Загружен».
## What Changes
- **Автоподтверждение при маппинге.** Документ, который маппинг сделал бы `RECOGNIZED`, сразу становится `CONFIRMED`:
`confirmed_by = NULL`, `confirmed_at = now`, у формы ББ/ОФР `reviewed_by = NULL`, `reviewed_at = now`;
`audit_event(DOCUMENT_CONFIRM)` с `user_id = NULL` и `description = «автоподтверждение: без замечаний»`. Форма
подтверждается автоматически только при условиях ручного подтверждения (период определён, жёсткие проверки пройдены),
иначе остаётся `RECOGNIZED`. `DISCLOSURE`/`OTHER` — тоже сразу `CONFIRMED`.
- **Привязка и статус версии — как при ручном подтверждении.** Документ привязывается ко всем подходящим версиям «в
работе» с заменой по ключу (R-1); версия из `INITIATED`/`DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` с
`changed_by = stored_file.uploaded_by` и комментарием «автоподтверждение документа …». Файл без `uploaded_by` —
привязка есть, перехода нет, запись в лог (как у хука `on_documents_mapped`, design №2 Среза 4).
- **`NEEDS_CHECK` и правки — вручную, как раньше.** Форма, ставшая чистой после правки или акцепта, подтверждается
кнопкой (ТЗ 03_9: «Подтвердить данные» после исправлений). `RECOGNIZED` остаётся в модели: старые документы и формы
после правок.
- **Удаление файла с автоподтверждёнными документами.** `DELETE /files/{id}` разрешён, если все `CONFIRMED` документы
файла подтверждены автоматически (`confirmed_by IS NULL`) и каждая версия, к которой они привязаны, в `INITIATED`,
`DOCUMENTS_PROCESSING` или `DATA_CORRECTION`; привязки снимаются, статус версии не меняется,
`FILE_DELETE.after_value.unbound_version_ids`. Иначе — 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, как сейчас.
Таблицы DBML v5.17: `document` (`status`, `confirmed_by`, `confirmed_at`), `financial_statement_meta` (`reviewed_by`,
`reviewed_at`), `consolidation_document`, `consolidation_version`, `consolidation_status_history`, `audit_event`,
`stored_file` (`uploaded_by`). Новых полей нет.
## Non-goals
- Признак «подтверждено автоматически» отдельным полем или в API сверх `confirmed_by = null` — не вводится.
- Подсказка в UI arm-web «подтверждено системой», кнопка удаления файла в UI — вне объёма (arm-web).
- Миграция данных: существующие `RECOGNIZED` документы не подтверждаются задним числом.
- Повторный автозапуск после правки формы, межформенная проверка как условие — нет (мягкая, §7).
- Изменение правил `NEEDS_CHECK` (OQ-116, предупреждения маппинга) — нет.
## Impact
- Код: `services/result_mapping.py` (`_map_candidate`), `services/consolidation_documents.py`
(`on_document_confirmed` с внешним `changed_by`/`comment`, новый `auto_confirm`), `services/documents.py` (общая
проверка условий формы), `services/primary_files.py` (`delete`; привязки снимает существующий
`consolidation_documents.unbind_documents`).
- Спеки (дельты): `osv-result-mapping`, `primary-files`, `audit-trail`. Ручной путь `POST /documents/{id}/confirm` не
меняется, поэтому дельты `documents-api` нет.
- Поведение для фронта: чистые документы приходят сразу `CONFIRMED` («Загружен»); `confirmed_by = null` — признак
автоподтверждения.

View File

@ -0,0 +1,28 @@
# Spec Delta: audit-trail
## MODIFIED Requirements
### Requirement: События файлов и документов
Загрузка, автозамена, удаление файла и подтверждение документа SHALL писать `audit_event` через сервис аудита:
| Действие | `action` | `section` | `object_type` | `user_id` |
|---|---|---|---|---|
| файл принят | `FILE_UPLOAD` | `PRIMARY_DOCUMENTS` | `stored_file` | инициатор |
| прежний файл заменён новым (автозамена после маппинга) | `FILE_REPLACE` | `PRIMARY_DOCUMENTS` | `stored_file` (заменённый) | NULL (система; инициатор загрузки — в `description`) |
| файл удалён | `FILE_DELETE` | `PRIMARY_DOCUMENTS` | `stored_file` | инициатор |
| документ подтверждён пользователем | `DOCUMENT_CONFIRM` | `PRIMARY_DOCUMENTS` | `document` | инициатор |
| документ подтверждён автоматически при маппинге (чистый) | `DOCUMENT_CONFIRM` | `PRIMARY_DOCUMENTS` | `document` | NULL (система; `description` = «автоподтверждение: без замечаний») |
`FILE_REPLACE` SHALL содержать в `after_value` новый `file_id`, в `description` — перечень заменённых документов и
`confirmed=true`, если среди них был `CONFIRMED`. `FILE_DELETE` SHALL содержать в `after_value.unbound_version_ids`
версии, где сняты привязки удалённых автоподтверждённых документов. Смена статусов задания и файла в обмене и в цикле
маппинга `audit_event` не пишет (`ASSUMPTION(OQ-55)`).
#### Scenario: Загрузка двух файлов
- **WHEN** принято два файла одним запросом
- **THEN** два события `FILE_UPLOAD` с `object_id` каждого файла и `after_value` (`original_name`, `content_hash`, `job_id`)
#### Scenario: Автоподтверждение
- **WHEN** смаплен чистый результат ОСВ
- **THEN** одно событие `DOCUMENT_CONFIRM` с `object_id` документа, `user_id` NULL, `before_value.status = RECOGNIZED`,
`after_value.status = CONFIRMED`, `description = «автоподтверждение: без замечаний»`

View File

@ -0,0 +1,140 @@
# Spec Delta: osv-result-mapping
## ADDED Requirements
### Requirement: Автоподтверждение чистого документа
В транзакции маппинга после автозамены прежних версий файла и до перехода версий по `NEEDS_CHECK` цикл маппинга SHALL
перевести каждый документ файла, получивший при маппинге статус `RECOGNIZED`, в `CONFIRMED` (ТЗ 03_9 п. 4, 10_10;
решение владельца 08.10.2026, OQ-157): `confirmed_by = NULL`, `confirmed_at = now`; у формы `BALANCE`/`INCOME` —
`financial_statement_meta.reviewed_by = NULL`, `reviewed_at = now`; `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS,
object_type = document)` с `user_id = NULL`, `before_value.status = RECOGNIZED`, `after_value.status = CONFIRMED` и
`description = «автоподтверждение: без замечаний»`. Форма `BALANCE`/`INCOME` с шапкой SHALL подтверждаться
автоматически, только если выполнены условия ручного подтверждения (`period_year` и `period_quarter` определены, нет
непройденных неакцептованных жёстких проверок §7); иначе она MUST оставаться `RECOGNIZED` без ошибки. Документы
`NEEDS_CHECK` MUST NOT подтверждаться автоматически.
Автоподтверждённый документ SHALL привязываться к версиям так же, как при ручном подтверждении (`documents-api`
«Подтверждение документа»: все подходящие версии «в работе», замена привязки того же ключа R-1), и каждая версия,
получившая привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` с `changed_by =
stored_file.uploaded_by` и `comment = «автоподтверждение документа <document_type> <osv_account_code>»` (счёт — только у
ОСВ). Если `uploaded_by` NULL, привязка SHALL создаваться, а переход — пропускаться с записью в лог. Пересчёт статуса
формы после правки или акцепта MUST NOT подтверждать документ автоматически.
#### Scenario: Чистая ОСВ сразу «Загружен»
- **WHEN** смаплен результат `fixtures/osv/rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` для ЮЛ АО «Русский бисквит»
(задание `done`, шапка совпадает с владельцем, нарушений и предупреждений нет)
- **THEN** документ `OSV` 60 — `CONFIRMED`, `confirmed_by` NULL, `confirmed_at` заполнен; есть `audit_event(DOCUMENT_CONFIRM)`
с `user_id` NULL и описанием «автоподтверждение: без замечаний»
#### Scenario: Привязка и переход версии
- **WHEN** версия ГК «Русский Бисквит» 2025 Q1 в `INITIATED`, РБ в периметре; смаплен чистый результат ОСВ 60 РБ за 2025 Q1,
файл загрузил `ka_go`
- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории
`INITIATED → DATA_CORRECTION` с `changed_by` = `ka_go` и комментарием «автоподтверждение документа OSV 60»
#### Scenario: Документ с замечанием ждёт аналитика
- **WHEN** файл ОСВ загружен для ЮЛ «Пекарня Счастливочная», а в шапке `АО "Русский бисквит"`
- **THEN** документ `NEEDS_CHECK`, `confirmed_at` пуст, события `DOCUMENT_CONFIRM` нет, привязки нет
#### Scenario: Чистая форма Excel
- **WHEN** смаплен `statements-xlsx/mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, период определён)
- **THEN** документ `INCOME` `CONFIRMED`, `reviewed_by` NULL, `reviewed_at` заполнен
#### Scenario: Форма OCR не подтверждается сама
- **WHEN** смаплен эталон `ocr/expected_normalize/БО 1 кв 2025` (уверенность значений < 100, дата отчёта не разобрана)
- **THEN** документы `BALANCE` и `INCOME` — `NEEDS_CHECK`, не `CONFIRMED`
#### Scenario: Файл без загрузившего
- **WHEN** версия в `INITIATED`, ЮЛ в периметре; смаплен чистый результат файла с `uploaded_by` NULL
- **THEN** документ `CONFIRMED` и привязан к версии; версия осталась `INITIATED`; в логе — строка о пропуске перехода
#### Scenario: Автозамена чистой версией файла
- **WHEN** документ ОСВ 60 РБ за 2025 Q1 из файла A привязан к версии 2025 Q1; загружен и смаплен файл B того же ключа
(чистый), A заменён автозаменой
- **THEN** привязка документа A снята, документ B `CONFIRMED` и привязан к версии
## MODIFIED Requirements
### Requirement: Проверки шапки и статус документа
`counterparty_check` SHALL быть `MATCH`, если ИНН шапки равен ИНН владельца, или (без ИНН) нормализованное имя шапки
совпадает с нормализованным именем или алиасом владельца; `MISMATCH` иначе; `UNKNOWN` при пустой шапке.
`period_check` SHALL быть `UNKNOWN` до появления версии консолидации (Срез 4). Для `period_quarter ∈ {Q2, Q3, Q4}` с
`is_cumulative = false` ядро SHALL сверить сальдо на начало корневой строки с документом `Q1` того же года, ЮЛ и
счёта (файл не `SUPERSEDED`) и при совпадении (±0.01) установить `is_cumulative = true` с предупреждением
(`ASSUMPTION(OQ-02)`). Статус документа при маппинге SHALL быть `RECOGNIZED`, если задание `done`, `checks` без
нарушений, `counterparty_check ≠ MISMATCH` и нет предупреждений маппинга; иначе `NEEDS_CHECK` (OQ-05). Документ
`RECOGNIZED` в той же транзакции SHALL подтверждаться автоматически (требование «Автоподтверждение чистого документа»).
#### Scenario: Шапка другой организации
- **WHEN** файл загружен для ЮЛ «Пекарня Счастливочная», а в шапке `АО "Русский бисквит"`
- **THEN** `counterparty_check = MISMATCH`, `status = NEEDS_CHECK`
#### Scenario: Нарастающая по данным
- **WHEN** у ЮЛ есть документ ОСВ 60 за 2024 Q1 (`opening_*` корня = X) и маппится ОСВ 60 «за 3 квартал 2024» с `opening_*` корня = X
- **THEN** `is_cumulative = true`, предупреждение «нарастающая по данным Q1»
#### Scenario: Чистый результат
- **WHEN** задание `done`, шапка совпадает с владельцем, нарушений нет
- **THEN** после маппинга `status = CONFIRMED` (автоподтверждение), `counterparty_check = MATCH`, `period_check = UNKNOWN`
### Requirement: Документ NEEDS_CHECK переводит версию в корректировку
После фиксации документов файла и автоподтверждения чистых документов цикл маппинга SHALL для каждой версии в
`DOCUMENTS_PROCESSING`, в периметр которой входит ЮЛ файла, проверить документы файла: если среди них есть документ
`NEEDS_CHECK`, подходящий версии по периоду (правила привязки `documents-api`), версия SHALL перейти в `DATA_CORRECTION`
с `changed_by = stored_file.uploaded_by` в той же транзакции маппинга (версии блокируются `FOR UPDATE` по `id` после
файла). Если `uploaded_by` NULL, переход SHALL пропускаться с записью в лог. Привязка `consolidation_document` для
документа `NEEDS_CHECK` при маппинге MUST NOT создаваться (привязку при маппинге создаёт только автоподтверждение).
#### Scenario: Предупреждение маппинга двигает статус
- **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл ЮЛ периметра с ОСВ 62 за 2024 Q3 в `NEEDS_CHECK`
- **THEN** версия в `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; `consolidation_document` нет
#### Scenario: Документ RECOGNIZED
- **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл с единственным чистым документом за 2024 Q3
- **THEN** документ `CONFIRMED` и привязан; переход версии в `DATA_CORRECTION` записан автоподтверждением (комментарий
«автоподтверждение документа …»), этот хук второго перехода не делает
#### Scenario: Документ не подходит версии
- **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл с единственным документом `NEEDS_CHECK` за 2025 Q1
- **THEN** статус версии не изменился
### Requirement: Статус документа формы
Документ `BALANCE`/`INCOME` SHALL быть `RECOGNIZED`, только если одновременно: задание `done`; нет непройденных
неакцептованных жёстких проверок §7 (`statement-checks`); `counterparty_check ≠ MISMATCH`; `period_year` и
`period_quarter` определены; у каждого значения `is_modified = true` либо (`is_recognized` не `false` и `confidence`
равна 100 или `NULL`); нет предупреждений маппинга по документу; есть хотя бы одна строка. Иначе `NEEDS_CHECK`. Статус
SHALL пересчитываться при маппинге, после правки формы и после акцепта, кроме документа `CONFIRMED`. Документ
`RECOGNIZED` после маппинга SHALL подтверждаться автоматически (требование «Автоподтверждение чистого документа»);
`RECOGNIZED`, полученный пересчётом после правки или акцепта, — только вручную. Документ формы `NEEDS_CHECK` SHALL
участвовать в переходе версии в `DATA_CORRECTION` наравне с ОСВ (требование «Документ NEEDS_CHECK переводит версию в
корректировку»).
#### Scenario: Чистая форма Excel
- **WHEN** смаплен `mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, ИНН шапки = ИНН владельца файла)
- **THEN** документ `INCOME` `CONFIRMED` (автоподтверждение)
#### Scenario: Форма OCR
- **WHEN** смаплен эталон `ocr/expected_normalize/aep_3` (уверенность значений < 100, ИНН шапки ≠ ИНН владельца)
- **THEN** документ `BALANCE` `NEEDS_CHECK`, `counterparty_check = MISMATCH`
#### Scenario: Форма двигает версию
- **WHEN** версия ГК 2025 Q3 в `DOCUMENTS_PROCESSING`, в периметре ЮЛ файла; смаплена форма `BALANCE` за 2025 Q3 в `NEEDS_CHECK`
- **THEN** версия в `DATA_CORRECTION`, привязки `consolidation_document` нет
#### Scenario: Чистая после правки — вручную
- **WHEN** форма `NEEDS_CHECK` исправлена аналитиком (`PATCH …/statement`) и пересчёт дал `RECOGNIZED`
- **THEN** документ остаётся `RECOGNIZED` до `POST …/confirm`
### Requirement: Повтор формы в файле и прочие документы
Второй документ `BALANCE` или `INCOME` в одном файле MUST NOT создаваться: предупреждение маппинга, первый документ —
`NEEDS_CHECK`. Документы `DISCLOSURE` и `OTHER` результата SHALL давать по одному `document` каждого типа на файл без
данных (`locator` — все `locator` этого типа через запятую, статус при маппинге `RECOGNIZED`, затем автоподтверждение →
`CONFIRMED`). Форма `BALANCE`/`INCOME` без строк SHALL создаваться с шапкой и колонками (если колонки есть в результате).
#### Scenario: Расшифровки
- **WHEN** смаплен синтетический снимок `st06_header_variants` (форма `INCOME` и пять `DISCLOSURE`)
- **THEN** у файла два документа: `INCOME` и один `DISCLOSURE` с пятью `locator` через запятую; `DISCLOSURE` — `CONFIRMED`
#### Scenario: Форма без колонки кодов
- **WHEN** смаплен синтетический снимок `st03_no_codes` (`BALANCE`, строк 0)
- **THEN** документ `BALANCE` `NEEDS_CHECK` с шапкой, строк нет (колонок в снимке тоже нет — создаются те, что есть в результате)

View File

@ -0,0 +1,36 @@
# Spec Delta: primary-files
## MODIFIED Requirements
### Requirement: Удаление файла
`DELETE /api/v1/files/{file_id}` (`DOCUMENT_EDIT`, policy) SHALL удалить строки ОСВ, документы, задания и `stored_file`,
проставить `s3_object.expires_at = now()` (объект физически не удаляется, OQ-22) и записать `audit_event(FILE_DELETE,
before_value = карточка файла, after_value = {unbound_version_ids})`. Удаление MUST быть отклонено 409
`FILE_HAS_CONFIRMED_DOCUMENTS`, если у файла есть документ `CONFIRMED`, подтверждённый пользователем (`confirmed_by` не
NULL), или если автоподтверждённый документ файла (`confirmed_by` NULL) привязан к версии в статусе, отличном от
`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION` (OQ-157, уточняет OQ-110); и 409 `FILE_SUPERSEDES_OTHERS`, если на
файл ссылаются `superseded_by_file_id`. При удалении файла с автоподтверждёнными документами их привязки
`consolidation_document` SHALL сниматься, статус версий MUST NOT меняться, `after_value.unbound_version_ids` — id версий,
где привязки сняты (по возрастанию; пустой список, если привязок не было). Блокировки: задания → файл → документы →
advisory-блокировка привязок ГК → версии по `id`. Файл `SUPERSEDED` удалить можно.
#### Scenario: Удаление ошибочного файла
- **WHEN** файл в `ERROR` удаляется владельцем
- **THEN** 204; `stored_file`, `recognition_job` отсутствуют; `s3_object.expires_at` заполнен; `audit_event(FILE_DELETE)`
с `after_value.unbound_version_ids = []`
#### Scenario: Подтверждённый документ
- **WHEN** удаляется файл с документом `CONFIRMED`, у которого `confirmed_by` = пользователь
- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, ничего не изменилось
#### Scenario: Автоподтверждённый документ в корректировке
- **WHEN** версия 2025 Q1 в `DATA_CORRECTION`, к ней привязан автоподтверждённый документ ОСВ 60 РБ; файл удаляется
- **THEN** 204; привязка снята, версия осталась в `DATA_CORRECTION`; `FILE_DELETE.after_value.unbound_version_ids` = [id версии]
#### Scenario: Версия ушла на согласование
- **WHEN** автоподтверждённый документ файла привязан к версии в `CONSOLIDATION_RF`
- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, привязка и файл на месте
#### Scenario: Автоподтверждённый без привязок
- **WHEN** удаляется файл, все документы которого автоподтверждены и ни к одной версии не привязаны
- **THEN** 204; `after_value.unbound_version_ids = []`