Автоподтверждение чистых документов (#64, OQ-157) #69
@ -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` («что загружено к периоду
|
||||
|
||||
32
README.md
32
README.md
@ -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`), БД снимается.
|
||||
|
||||
|
||||
@ -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 "
|
||||
"(файл заменил более ранние версии)",
|
||||
},
|
||||
}
|
||||
|
||||
75
api/src/services/auto_confirm.py
Normal file
75
api/src/services/auto_confirm.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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` не трогает.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": []}
|
||||
|
||||
225
api/tests/test_auto_confirm.py
Normal file
225
api/tests/test_auto_confirm.py
Normal 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
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
83
api/tests/test_form_confirmable.py
Normal file
83
api/tests/test_form_confirmable.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
1189
docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md
Normal file
1189
docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-08
|
||||
118
openspec/changes/auto-confirm-clean-documents/design.md
Normal file
118
openspec/changes/auto-confirm-clean-documents/design.md
Normal 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»), привязки при отмене версии не снимаются | буквальное чтение спеки | файл из отменённой версии удалить нельзя — вопрос владельцу |
|
||||
57
openspec/changes/auto-confirm-clean-documents/proposal.md
Normal file
57
openspec/changes/auto-confirm-clean-documents/proposal.md
Normal 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` — признак
|
||||
автоподтверждения.
|
||||
@ -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 = «автоподтверждение: без замечаний»`
|
||||
@ -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` с шапкой, строк нет (колонок в снимке тоже нет — создаются те, что есть в результате)
|
||||
@ -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 = []`
|
||||
Loading…
x
Reference in New Issue
Block a user