Merge pull request 'openspec: архив change slice-2c-osv-ingest' (#16) from chore/archive-slice-2c-osv-ingest into main
Reviewed-on: #16
This commit is contained in:
commit
714832efd9
@ -93,3 +93,21 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD
|
|||||||
#### Scenario: Не попытка входа
|
#### Scenario: Не попытка входа
|
||||||
- **WHEN** запрос пришёл без заголовка `Authorization` или пользователь получил 403 «недостаточно прав»
|
- **WHEN** запрос пришёл без заголовка `Authorization` или пользователь получил 403 «недостаточно прав»
|
||||||
- **THEN** строки `LOGIN_FAILED` нет
|
- **THEN** строки `LOGIN_FAILED` нет
|
||||||
|
|
||||||
|
### 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` | инициатор |
|
||||||
|
|
||||||
|
`FILE_REPLACE` SHALL содержать в `after_value` новый `file_id`, в `description` — перечень заменённых документов и
|
||||||
|
`confirmed=true`, если среди них был `CONFIRMED`. Смена статусов задания и файла в обмене и в цикле маппинга
|
||||||
|
`audit_event` не пишет (`ASSUMPTION(OQ-55)`).
|
||||||
|
|
||||||
|
#### Scenario: Загрузка двух файлов
|
||||||
|
- **WHEN** принято два файла одним запросом
|
||||||
|
- **THEN** два события `FILE_UPLOAD` с `object_id` каждого файла и `after_value` (`original_name`, `content_hash`, `job_id`)
|
||||||
|
|||||||
@ -6,17 +6,19 @@
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Схема БД равна DBML v5.14
|
### Requirement: Схема БД равна DBML v5.15
|
||||||
ORM-метаданные SHALL содержать ровно 43 таблицы и 27 enum'ов из `../docs/model/data-model-v5.14.dbml` в схеме `dkb`;
|
ORM-метаданные SHALL содержать ровно 43 таблицы и 27 enum'ов из `../docs/model/data-model-v5.15.dbml` в схеме `dkb`;
|
||||||
имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения
|
имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения
|
||||||
по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member`
|
по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member`
|
||||||
`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE
|
`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE
|
||||||
`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`.
|
`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`.
|
||||||
`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке).
|
`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке).
|
||||||
`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`. Поля, отсутствующие в DBML, MUST NOT существовать.
|
`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`; `osv_data_type` SHALL содержать `NU` (после `BU`).
|
||||||
|
`app_user.email` SHALL храниться в нижнем регистре (нормализация при записи seed'ом и администрированием). Поля,
|
||||||
|
отсутствующие в DBML, MUST NOT существовать.
|
||||||
|
|
||||||
#### Scenario: Сверка ORM с DBML
|
#### Scenario: Сверка ORM с DBML
|
||||||
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.14.dbml`
|
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.15.dbml`
|
||||||
- **THEN** вывод пуст и код возврата 0
|
- **THEN** вывод пуст и код возврата 0
|
||||||
|
|
||||||
#### Scenario: Лишняя колонка в ORM
|
#### Scenario: Лишняя колонка в ORM
|
||||||
@ -31,11 +33,17 @@ ORM-метаданные SHALL содержать ровно 43 таблицы
|
|||||||
- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz`
|
- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz`
|
||||||
- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс
|
- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс
|
||||||
|
|
||||||
|
#### Scenario: Строка налогового учёта
|
||||||
|
- **WHEN** в `osv_entry` вставляется строка с `data_type = 'NU'`
|
||||||
|
- **THEN** вставка проходит
|
||||||
|
|
||||||
### Requirement: Миграции применяются с нуля и откатываются
|
### Requirement: Миграции применяются с нуля и откатываются
|
||||||
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` SHALL создавать схему `dkb` (если нет), все enum'ы, таблицы,
|
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` SHALL создавать схему `dkb` (если нет), все
|
||||||
ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`) на пустой базе
|
enum'ы, таблицы, ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`)
|
||||||
PostgreSQL 16 (требуется ≥ 15); `downgrade` до `base` SHALL удалять всё созданное (включая новые значения `audit_action`
|
на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type`, приводить
|
||||||
через пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок.
|
`app_user.email` к нижнему регистру и создавать индексы `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`,
|
||||||
|
`ix_stored_file_current_recognition_job_id` (DBML v5.15 п. 4); `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через
|
||||||
|
пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок.
|
||||||
|
|
||||||
#### Scenario: Полный цикл
|
#### Scenario: Полный цикл
|
||||||
- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head`
|
- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head`
|
||||||
@ -43,20 +51,19 @@ PostgreSQL 16 (требуется ≥ 15); `downgrade` до `base` SHALL уда
|
|||||||
и в `pg_type` 27 enum-типов
|
и в `pg_type` 27 enum-типов
|
||||||
|
|
||||||
#### Scenario: Откат на один шаг
|
#### Scenario: Откат на один шаг
|
||||||
- **WHEN** после `upgrade head` выполняется `alembic downgrade 0001_dbml_v5_13`
|
- **WHEN** после `upgrade head` выполняется `alembic downgrade 0002_dbml_v5_14`
|
||||||
- **THEN** колонка `related_group_member.kind` отсутствует, индекс `(legal_entity_id) WHERE is_current` восстановлен,
|
- **THEN** `osv_data_type` не содержит `NU`, индексов v5.15 п. 4 нет; регистр `app_user.email` не возвращается (данные)
|
||||||
`audit_action` не содержит `LEGAL_ENTITY_CHANGE`/`USER_CHANGE`
|
|
||||||
|
|
||||||
#### Scenario: Автогенерация ничего не находит
|
#### Scenario: Автогенерация ничего не находит
|
||||||
- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД
|
- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД
|
||||||
- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`)
|
- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`)
|
||||||
|
|
||||||
### Requirement: Версия DBML зафиксирована в коде
|
### Requirement: Версия DBML зафиксирована в коде
|
||||||
Пакет моделей SHALL объявлять константу версии DBML (`5.14`); скрипт сверки SHALL сравнивать её с версией в шапке файла
|
Пакет моделей SHALL объявлять константу версии DBML (`5.15`); скрипт сверки SHALL сравнивать её с версией в шапке файла
|
||||||
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
|
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
|
||||||
|
|
||||||
#### Scenario: Новая версия DBML без обновления ORM
|
#### Scenario: Новая версия DBML без обновления ORM
|
||||||
- **WHEN** в `docs/model/` появляется `data-model-v5.15.dbml`, а константа в ORM — `5.14`
|
- **WHEN** в `docs/model/` появляется `data-model-v5.16.dbml`, а константа в ORM — `5.15`
|
||||||
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
|
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
|
||||||
|
|
||||||
### Requirement: Деньги и проценты — точные типы
|
### Requirement: Деньги и проценты — точные типы
|
||||||
|
|||||||
48
openspec/specs/documents-api/spec.md
Normal file
48
openspec/specs/documents-api/spec.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# documents-api Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Чтение файлов, документов и строк ОСВ для панели «Первичные документы» (подсрез 2d) и подтверждение документа.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Файлы организации и карточка файла
|
||||||
|
`GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL
|
||||||
|
возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file`, текущее задание (`status`, `error_code`,
|
||||||
|
`error_message`, `mapping_error` (`metrics.mapping_error`: причина `ERROR` файла после неудачного маппинга, когда у задания
|
||||||
|
`error_code` пуст), `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`,
|
||||||
|
`osv_account_code`, `period_year`, `period_quarter`, `is_cumulative`, `status`, `period_check`, `counterparty_check`).
|
||||||
|
`GET /api/v1/files/{file_id}` SHALL отдавать ту же карточку; чужой или несуществующий файл — 404.
|
||||||
|
|
||||||
|
#### Scenario: Список после обработки
|
||||||
|
- **WHEN** у ЮЛ два файла: `PROCESSED` с документом и `ERROR`
|
||||||
|
- **THEN** оба в списке; у первого `documents` непустой, у второго `job.error_code` заполнен
|
||||||
|
|
||||||
|
### Requirement: Документ и строки
|
||||||
|
`GET /api/v1/documents/{document_id}` (`DOCUMENT_VIEW`, policy по ЮЛ владельца) SHALL отдавать документ, статус,
|
||||||
|
проверки, `locator`, схему группировок листа из `recognition_job.group_schema` и `file_id`.
|
||||||
|
`GET /api/v1/documents/{document_id}/entries` SHALL отдавать `Page` строк по возрастанию `row_number` (`limit` ≤ 2000,
|
||||||
|
по умолчанию 500) с полями `osv_entry`, включая `parent_entry_id`, `account_id`, `counterparty_id`.
|
||||||
|
|
||||||
|
#### Scenario: Строки деревом
|
||||||
|
- **WHEN** запрошены строки документа ОСВ 66
|
||||||
|
- **THEN** первая строка — счёт `66` уровня 0 без родителя, у строк уровня 2 `parent_entry_id` указывает на субсчёт
|
||||||
|
|
||||||
|
### Requirement: Подтверждение документа
|
||||||
|
`POST /api/v1/documents/{document_id}/confirm` (`DOCUMENT_EDIT`, policy) SHALL переводить документ из `RECOGNIZED`
|
||||||
|
или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`;
|
||||||
|
из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом
|
||||||
|
другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Если совпали несколько условий, приоритет у
|
||||||
|
`FILE_SUPERSEDED`. Подтверждение берёт блокировки в порядке «файл → документ» и перечитывает оба под `FOR UPDATE`;
|
||||||
|
документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`.
|
||||||
|
|
||||||
|
#### Scenario: Причина ошибки маппинга в карточке файла
|
||||||
|
- **WHEN** файл в `ERROR` после неудачного маппинга (задание `done`, `error_code` пуст)
|
||||||
|
- **THEN** `job.mapping_error` содержит причину, `job.error_code` пуст
|
||||||
|
|
||||||
|
#### Scenario: Документ не подтверждается
|
||||||
|
- **WHEN** подтверждается документ в статусе `ERROR`
|
||||||
|
- **THEN** 409 `DOCUMENT_NOT_CONFIRMABLE`, статус не изменился
|
||||||
|
|
||||||
|
#### Scenario: Подтверждение
|
||||||
|
- **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала
|
||||||
|
- **THEN** `status = CONFIRMED`, `confirmed_by` = пользователь, `audit_event(DOCUMENT_CONFIRM)` с `before_value.status = NEEDS_CHECK`
|
||||||
94
openspec/specs/osv-result-mapping/spec.md
Normal file
94
openspec/specs/osv-result-mapping/spec.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# osv-result-mapping Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Маппинг файла результата распознавания (`recognition-result.schema.json` v1.x, `osvDocument`) в `document` и
|
||||||
|
`osv_entry` отдельным фоновым циклом (§1.3, §1.5, §3 п. 4, §9.3); резолв счетов и контрагентов; проверки шапки;
|
||||||
|
статус документа.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Цикл маппинга отделён от inbox
|
||||||
|
Фоновая задача ядра SHALL с периодом `MAPPING_POLL_SECONDS` (5) выбирать одно задание со `status ∈ {done,
|
||||||
|
needs_check}`, чей файл имеет `status = PROCESSING` и `current_recognition_job_id` = это задание (`FOR UPDATE SKIP
|
||||||
|
LOCKED`), проверять, что `result_s3_object` относится к бакету хранилища и его ключ начинается с
|
||||||
|
`results/{file_id}/{job_id}/` (OQ-70), читать объект из S3 **вне** транзакции, валидировать `RecognitionResult` v1.x и
|
||||||
|
в одной транзакции создать документы и строки, записать `recognition_job.group_schema`, перевести файл в `PROCESSED`
|
||||||
|
и выполнить автозамену. Inbox MUST NOT читать файл результата и MUST NOT создавать документы.
|
||||||
|
|
||||||
|
#### Scenario: Результат смапплен
|
||||||
|
- **WHEN** в S3 лежит результат парсера для `rb/2024-FY/ОСВ_66 за 2024.xls`, задание `done`, файл `PROCESSING`
|
||||||
|
- **THEN** не позднее чем через `MAPPING_POLL_SECONDS` + время маппинга файл — `PROCESSED`, создан один `document`
|
||||||
|
(`OSV`, `osv_account_code = "66"`, `period_year = 2024`, `period_quarter = Q4`, `is_cumulative = true`, `locator = sheet:Лист_1`),
|
||||||
|
`osv_entry` — все строки результата кроме `TOTAL`, `group_schema` задания содержит `[{"locator": "sheet:Лист_1", "groups": [...]}]`
|
||||||
|
|
||||||
|
#### Scenario: Ключ результата вне папки задания
|
||||||
|
- **WHEN** `result_s3_object.key = results/<другой file_id>/.../result.json`
|
||||||
|
- **THEN** файл — `ERROR` с ошибкой маппинга, документы не созданы, повтор не выполняется
|
||||||
|
|
||||||
|
#### Scenario: Сбой маппинга с повтором
|
||||||
|
- **WHEN** вставка строк падает по ошибке БД
|
||||||
|
- **THEN** транзакция откачена, `metrics.mapping_attempts = 1`, файл остаётся `PROCESSING`; на третьей неудаче — `ERROR`
|
||||||
|
с `metrics.mapping_error`
|
||||||
|
|
||||||
|
#### Scenario: Повторный маппинг идемпотентен
|
||||||
|
- **WHEN** маппинг прерван после частичной вставки (имитация) и выполнен снова
|
||||||
|
- **THEN** у файла ровно один набор документов и строк, дублей по `(document_id, row_number)` нет
|
||||||
|
|
||||||
|
### Requirement: Документ по листу ОСВ
|
||||||
|
На каждый `osvDocument` ядро SHALL создать `document` с `document_type = OSV`, `osv_account_code` = нормализованный
|
||||||
|
код из шапки (`60.1 → 60.01`, `58.03`, буквенные сегменты как есть), `period_year`, `period_quarter`, `is_cumulative`
|
||||||
|
по шапке, `locator`, `legal_entity_id` владельца файла. Второй лист того же файла с тем же счётом MUST NOT создавать
|
||||||
|
документ: в `metrics.mapping_warnings` пишется предупреждение, первый документ получает `NEEDS_CHECK`.
|
||||||
|
|
||||||
|
#### Scenario: Два листа одного счёта
|
||||||
|
- **WHEN** результат содержит два `osvDocument` со счётом `60`
|
||||||
|
- **THEN** один `document`, статус `NEEDS_CHECK`, предупреждение называет оба `locator`
|
||||||
|
|
||||||
|
### Requirement: Строки ОСВ
|
||||||
|
Ядро SHALL сохранить все строки `osvDocument.rows` кроме `group_type = TOTAL` (OQ-01) как `osv_entry`: `row_number`,
|
||||||
|
`level`, `row_value`, `group_type`, `group_name`, `data_type` (включая `NU`), шесть сумм без изменений,
|
||||||
|
`counterparty_name`/`counterparty_inn` как в результате, `parent_entry_id` по `parent_row_number`, `account_code`
|
||||||
|
(как в результате), `account_root` (первый сегмент, `None` если он не цифровой — `ASSUMPTION(OQ-103)`), `account_id`
|
||||||
|
(точное совпадение нормализованного кода с `chart_of_account.code`, иначе NULL), `owner_legal_entity_id`,
|
||||||
|
`recognition_job_id`, `is_modified = false`.
|
||||||
|
|
||||||
|
#### Scenario: Дерево и счета
|
||||||
|
- **WHEN** смапплен результат `rb/2024-FY/ОСВ_66 за 2024.xls`
|
||||||
|
- **THEN** строк `osv_entry` = строк результата − 1 (TOTAL); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `parent_entry_id`
|
||||||
|
указывает на строку `66.03`, `account_code = "66.03"`, `account_root = "66"`, `account_id` = строка плана счетов `66.03`
|
||||||
|
|
||||||
|
#### Scenario: Все golden-снимки парсера
|
||||||
|
- **WHEN** в БД маппятся все `../fixtures/golden/expected_osv_parse/**` (82 результата, маркер `fixtures`)
|
||||||
|
- **THEN** для каждого: число строк = `rows` без TOTAL, у каждой строки уровня > 0 есть родитель, суммы равны снимку,
|
||||||
|
`account_id` заполнен у всех строк-счетов, код которых есть в плане счетов seed
|
||||||
|
|
||||||
|
### Requirement: Резолв контрагента
|
||||||
|
Для строк `group_type = COUNTERPARTY` с `data_type ∈ {BU, SUM}` ядро SHALL искать `counterparty_id` по §3 п. 4: по
|
||||||
|
`counterparty_inn` в `legal_entity.inn`; иначе по точному `counterparty_name` в `legal_entity.name`; иначе по
|
||||||
|
`normalize_alias(counterparty_name)` среди нормализованных имён ЮЛ и `legal_entity_alias.normalized_alias`; больше
|
||||||
|
одного кандидата на шаге → `NULL`. Резолв SHALL выполняться по всему справочнику без фильтра видимости.
|
||||||
|
|
||||||
|
#### Scenario: По ИНН, по алиасу, неоднозначно
|
||||||
|
- **WHEN** в справочнике есть ЮЛ с ИНН `3528052718`, ЮЛ с алиасом `русский бисквит`, и два ЮЛ с одинаковым нормализованным именем `альфа`
|
||||||
|
- **THEN** строка с ИНН `3528052718` → это ЮЛ; строка «Русский бисквит» без ИНН → ЮЛ по алиасу; строка «ООО Альфа» → `counterparty_id = NULL`
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
#### 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 = RECOGNIZED`, `counterparty_check = MATCH`, `period_check = UNKNOWN`
|
||||||
90
openspec/specs/primary-files/spec.md
Normal file
90
openspec/specs/primary-files/spec.md
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
# primary-files Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Загрузка файлов первичной отчётности в ядро: физический файл в S3 и `stored_file`, задание распознавания в outbox,
|
||||||
|
дедуп, жизненный цикл статуса файла (§9.2), автозамена прежних версий и удаление.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Загрузка файлов организации
|
||||||
|
`POST /api/v1/legal-entities/{legal_entity_id}/files` (multipart, одно или несколько полей `files`; право
|
||||||
|
`DOCUMENT_EDIT`; ЮЛ в области видимости пользователя, иначе 404) SHALL для каждого файла в отдельной транзакции:
|
||||||
|
определить тип по расширению и сверить с сигнатурой содержимого (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`,
|
||||||
|
`jpg`); отвергнуть файл больше `UPLOAD_MAX_BYTES` (50 МиБ) или неподдерживаемого типа; вычислить sha256; создать
|
||||||
|
`s3_object` (`bucket_role = uploads`, ключ `uploads/{file_id}/{job_id}/{безопасное имя}`, `sha256`, `size_bytes`,
|
||||||
|
`content_type`, `created_by = user:{id}`), положить объект в S3, создать `stored_file` (`status = UPLOADED`,
|
||||||
|
`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`) и `recognition_job` (`status =
|
||||||
|
queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`; `max_attempts` 3 / 1), установить
|
||||||
|
`stored_file.current_recognition_job_id`, записать `audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)`. Ответ SHALL
|
||||||
|
перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`,
|
||||||
|
`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT
|
||||||
|
запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`.
|
||||||
|
|
||||||
|
#### Scenario: Реальная ОСВ принята и ушла в outbox
|
||||||
|
- **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала
|
||||||
|
- **THEN** 201, `accepted[0].file_id` существует; `stored_file.status = UPLOADED`, `file_type = XLS`, `content_hash` =
|
||||||
|
sha256 файла; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`;
|
||||||
|
публикатор outbox публикует запрос с этим ключом
|
||||||
|
|
||||||
|
#### Scenario: Смесь принятых и отклонённых
|
||||||
|
- **WHEN** в одном запросе xlsx, файл `.docx` и файл 51 МиБ
|
||||||
|
- **THEN** 201, `accepted` — один, `rejected` — два с кодами `UNSUPPORTED_FILE` и `FILE_TOO_LARGE`
|
||||||
|
|
||||||
|
#### Scenario: Расширение не совпадает с содержимым
|
||||||
|
- **WHEN** загружается файл `osv.xlsx`, байты которого — BIFF8
|
||||||
|
- **THEN** файл отклонён с кодом `UNSUPPORTED_FILE`
|
||||||
|
|
||||||
|
#### Scenario: Чужое ЮЛ
|
||||||
|
- **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала
|
||||||
|
- **THEN** 404, объектов и записей нет
|
||||||
|
|
||||||
|
### Requirement: Дедуп по содержимому
|
||||||
|
Файл с тем же sha256, что у существующего `stored_file` того же ЮЛ со статусом не `SUPERSEDED`, SHALL отклоняться с
|
||||||
|
кодом `DUPLICATE_FILE` и `file_id` существующего. Тот же файл у другого ЮЛ SHALL приниматься.
|
||||||
|
|
||||||
|
#### Scenario: Повторная загрузка того же файла
|
||||||
|
- **WHEN** тот же xls загружается во второй раз для того же ЮЛ
|
||||||
|
- **THEN** `rejected[0].code = DUPLICATE_FILE`, `rejected[0].file_id` — прежний файл; новых записей нет
|
||||||
|
|
||||||
|
### Requirement: Жизненный цикл статуса файла
|
||||||
|
`stored_file.status` SHALL меняться только по заданию `current_recognition_job_id`: `UPLOADED` при загрузке;
|
||||||
|
`PROCESSING` при ack сервиса; `ERROR` при финальной ошибке задания (`error`, `timeout`, `dlq`) и при невозможности
|
||||||
|
маппинга; `PROCESSED` после успешного маппинга; `SUPERSEDED` при автозамене. Карточка файла SHALL показывать причину
|
||||||
|
`ERROR` (`error_code`/`error_message` задания или ошибку маппинга).
|
||||||
|
|
||||||
|
#### Scenario: Парсер вернул ошибку
|
||||||
|
- **WHEN** конверт `error UNSUPPORTED_FORMAT` для задания файла
|
||||||
|
- **THEN** `stored_file.status = ERROR`, в карточке файла `error_code = UNSUPPORTED_FORMAT`
|
||||||
|
|
||||||
|
#### Scenario: Сервис не ответил
|
||||||
|
- **WHEN** задание переведено вахтёром в `timeout`
|
||||||
|
- **THEN** файл — `ERROR` с `error_code = TIMEOUT`
|
||||||
|
|
||||||
|
### Requirement: Автозамена прежних версий
|
||||||
|
После успешного маппинга файла ядро SHALL найти другие файлы того же ЮЛ со статусом не `SUPERSEDED`, у которых есть
|
||||||
|
документ с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и перевести их в
|
||||||
|
`SUPERSEDED` (`superseded_by_file_id` = новый файл, `updated_at`); новый файл получает `version_no` = максимум среди
|
||||||
|
заменённых + 1. Документы и строки заменённых файлов SHALL сохраняться (история), но по умолчанию не показываться.
|
||||||
|
Аудит `FILE_REPLACE` (`object_id` заменённого, `after_value` с новым файлом, в `description` — перечень заменённых
|
||||||
|
документов и признак `confirmed`, если заменён документ `CONFIRMED`). Решение владельца 05.10.2026: замена происходит
|
||||||
|
и для `CONFIRMED`.
|
||||||
|
|
||||||
|
#### Scenario: Новая ОСВ 60 за 1 кв. 2025
|
||||||
|
- **WHEN** у ЮЛ есть `PROCESSED` файл с документом OSV 60 / 2025 / Q1 (`CONFIRMED`), загружен и смапплен новый файл с таким же документом
|
||||||
|
- **THEN** прежний файл — `SUPERSEDED`, `superseded_by_file_id` = новый, у нового `version_no = 2`; `audit_event(FILE_REPLACE)`
|
||||||
|
с `description`, содержащим `confirmed=true`; список файлов ЮЛ по умолчанию показывает только новый
|
||||||
|
|
||||||
|
### 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 = карточка файла)`. Удаление MUST быть отклонено 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, если у файла есть
|
||||||
|
документ `CONFIRMED`, и 409 `FILE_SUPERSEDES_OTHERS`, если на файл ссылаются `superseded_by_file_id`. Файл
|
||||||
|
`SUPERSEDED` удалить можно.
|
||||||
|
|
||||||
|
#### Scenario: Удаление ошибочного файла
|
||||||
|
- **WHEN** файл в `ERROR` удаляется владельцем
|
||||||
|
- **THEN** 204; `stored_file`, `recognition_job` отсутствуют; `s3_object.expires_at` заполнен; `audit_event(FILE_DELETE)`
|
||||||
|
|
||||||
|
#### Scenario: Подтверждённый документ
|
||||||
|
- **WHEN** удаляется файл с документом `CONFIRMED`
|
||||||
|
- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, ничего не изменилось
|
||||||
@ -25,12 +25,13 @@
|
|||||||
|
|
||||||
### Requirement: Ack сервиса переводит задание в `processing`
|
### Requirement: Ack сервиса переводит задание в `processing`
|
||||||
Получив в `recognition.result` конверт со `status = processing` для задания в `published`, ядро SHALL установить
|
Получив в `recognition.result` конверт со `status = processing` для задания в `published`, ядро SHALL установить
|
||||||
`status = processing`, `started_at`, `service_name`, `service_version`. Повторный ack для задания в `processing` SHALL
|
`status = processing`, `started_at`, `service_name`, `service_version` и перевести файл задания в
|
||||||
игнорироваться; ack для задания в другом статусе SHALL логироваться и не менять задание.
|
`stored_file.status = PROCESSING`, если `current_recognition_job_id` файла равен этому заданию. Повторный ack для
|
||||||
|
задания в `processing` SHALL игнорироваться; ack для задания в другом статусе SHALL логироваться и не менять задание.
|
||||||
|
|
||||||
#### Scenario: Сервис подтвердил
|
#### Scenario: Сервис подтвердил
|
||||||
- **WHEN** сервис опубликовал конверт `processing` с `job_id` опубликованного задания
|
- **WHEN** сервис опубликовал конверт `processing` с `job_id` опубликованного задания
|
||||||
- **THEN** задание имеет `status = processing`, `started_at` заполнен, `service_name = tabular-parser`
|
- **THEN** задание имеет `status = processing`, `started_at` заполнен, `service_name = tabular-parser`, файл — `PROCESSING`
|
||||||
|
|
||||||
### Requirement: Запрос без ack публикуется повторно, затем — `dlq`
|
### Requirement: Запрос без ack публикуется повторно, затем — `dlq`
|
||||||
Если задание находится в `published` дольше `ACK_TIMEOUT_SECONDS` (по умолчанию 30) без ack, публикатор SHALL
|
Если задание находится в `published` дольше `ACK_TIMEOUT_SECONDS` (по умолчанию 30) без ack, публикатор SHALL
|
||||||
@ -53,22 +54,26 @@
|
|||||||
`Nats-Msg-Id` сообщения — ничего не менять; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`,
|
`Nats-Msg-Id` сообщения — ничего не менять; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`,
|
||||||
`created_by = <service.name>`; только при наличии `result_s3`), обновить задание (`status`, `service_name`,
|
`created_by = <service.name>`; только при наличии `result_s3`), обновить задание (`status`, `service_name`,
|
||||||
`service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`,
|
`service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`,
|
||||||
`result_msg_id`) и зафиксировать транзакцию. Неизвестный `job_id` SHALL быть залогирован. Результат для задания, уже
|
`result_msg_id`) и зафиксировать транзакцию. При `status = error` файл задания (если `current_recognition_job_id` равен
|
||||||
находящегося в финальном статусе (`done`, `error`, `timeout`, `dlq`), SHALL логироваться и не применяться.
|
заданию) SHALL получить `stored_file.status = ERROR`; при `done`/`needs_check` файл остаётся `PROCESSING` и SHALL быть
|
||||||
|
обработан циклом маппинга (спека `osv-result-mapping`); inbox MUST NOT читать файл результата. Неизвестный `job_id` SHALL
|
||||||
|
быть залогирован. Результат для задания, уже находящегося в финальном статусе (`done`, `needs_check`, `error`,
|
||||||
|
`timeout`, `dlq`), SHALL логироваться и не применяться.
|
||||||
|
|
||||||
#### Scenario: Успешный результат
|
#### Scenario: Успешный результат
|
||||||
- **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results/<file_id>/<job_id>/result.json}` и
|
- **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results/<file_id>/<job_id>/result.json}` и
|
||||||
`Nats-Msg-Id = result:<job_id>:1`
|
`Nats-Msg-Id = result:<job_id>:1`
|
||||||
- **THEN** задание получает `status = done`, `result_msg_id = result:<job_id>:1`, `result_s3_object_id` указывает на новую
|
- **THEN** задание получает `status = done`, `result_msg_id = result:<job_id>:1`, `result_s3_object_id` указывает на новую
|
||||||
строку `s3_object` с этим ключом, `finished_at` заполнен
|
строку `s3_object` с этим ключом, `finished_at` заполнен; файл остаётся `PROCESSING` до маппинга
|
||||||
|
|
||||||
#### Scenario: Повторная доставка
|
#### Scenario: Повторная доставка
|
||||||
- **WHEN** то же сообщение получено второй раз
|
- **WHEN** то же сообщение получено второй раз
|
||||||
- **THEN** строка `recognition_job` и `s3_object` не меняются
|
- **THEN** строка `recognition_job` и `s3_object` не меняются
|
||||||
|
|
||||||
#### Scenario: Ошибка сервиса
|
#### Scenario: Ошибка сервиса
|
||||||
- **WHEN** конверт содержит `status = error`, `error = {NOT_IMPLEMENTED, …}` без `result_s3`
|
- **WHEN** конверт содержит `status = error`, `error = {UNSUPPORTED_FORMAT, …}` без `result_s3`
|
||||||
- **THEN** задание получает `status = error`, `error_code = NOT_IMPLEMENTED`, `error_message` из конверта, `result_s3_object_id` пуст
|
- **THEN** задание получает `status = error`, `error_code = UNSUPPORTED_FORMAT`, `error_message` из конверта,
|
||||||
|
`result_s3_object_id` пуст; файл — `ERROR`
|
||||||
|
|
||||||
#### Scenario: Результат пришёл после таймаута
|
#### Scenario: Результат пришёл после таймаута
|
||||||
- **WHEN** задание уже `timeout`, и приходит конверт `done`
|
- **WHEN** задание уже `timeout`, и приходит конверт `done`
|
||||||
@ -76,11 +81,13 @@
|
|||||||
|
|
||||||
### Requirement: Таймаут ожидания результата
|
### Requirement: Таймаут ожидания результата
|
||||||
Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в
|
Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в
|
||||||
`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`. Replay задания (новая попытка, `attempt + 1`) — вне Среза 0.
|
`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в
|
||||||
|
`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Replay
|
||||||
|
задания (новая попытка, `attempt + 1`) — вне этого среза.
|
||||||
|
|
||||||
#### Scenario: Сервис взял, но не ответил
|
#### Scenario: Сервис взял, но не ответил
|
||||||
- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата
|
- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата
|
||||||
- **THEN** не позднее чем через 30 с задание имеет `status = timeout`
|
- **THEN** не позднее чем через 30 с задание имеет `status = timeout`, файл — `ERROR` с `error_code = TIMEOUT`
|
||||||
|
|
||||||
### Requirement: Соединение с NATS по шаблону, без JetStream
|
### Requirement: Соединение с NATS по шаблону, без JetStream
|
||||||
Ядро SHALL подключаться к NATS с nkeys-seed и JWT, если заданы `OPENBAO__NATS__NATS_SEED/NATS_JWT`, и без учётных данных,
|
Ядро SHALL подключаться к NATS с nkeys-seed и JWT, если заданы `OPENBAO__NATS__NATS_SEED/NATS_JWT`, и без учётных данных,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user