diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/design.md similarity index 100% rename from openspec/changes/slice-4-consolidation/design.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/design.md diff --git a/openspec/changes/slice-4-consolidation/proposal.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/proposal.md similarity index 100% rename from openspec/changes/slice-4-consolidation/proposal.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/proposal.md diff --git a/openspec/changes/slice-4-consolidation/specs/audit-trail/spec.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/audit-trail/spec.md similarity index 100% rename from openspec/changes/slice-4-consolidation/specs/audit-trail/spec.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/audit-trail/spec.md diff --git a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/consolidated-osv/spec.md similarity index 100% rename from openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/consolidated-osv/spec.md diff --git a/openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/consolidations-api/spec.md similarity index 100% rename from openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/consolidations-api/spec.md diff --git a/openspec/changes/slice-4-consolidation/specs/data-model/spec.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/data-model/spec.md similarity index 100% rename from openspec/changes/slice-4-consolidation/specs/data-model/spec.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/data-model/spec.md diff --git a/openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/directory-policy/spec.md similarity index 100% rename from openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/directory-policy/spec.md diff --git a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/documents-api/spec.md similarity index 100% rename from openspec/changes/slice-4-consolidation/specs/documents-api/spec.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/documents-api/spec.md diff --git a/openspec/changes/slice-4-consolidation/specs/osv-result-mapping/spec.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/osv-result-mapping/spec.md similarity index 100% rename from openspec/changes/slice-4-consolidation/specs/osv-result-mapping/spec.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/osv-result-mapping/spec.md diff --git a/openspec/changes/slice-4-consolidation/specs/primary-files/spec.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/primary-files/spec.md similarity index 100% rename from openspec/changes/slice-4-consolidation/specs/primary-files/spec.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/specs/primary-files/spec.md diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/archive/2026-10-06-slice-4-consolidation/tasks.md similarity index 100% rename from openspec/changes/slice-4-consolidation/tasks.md rename to openspec/changes/archive/2026-10-06-slice-4-consolidation/tasks.md diff --git a/openspec/specs/audit-trail/spec.md b/openspec/specs/audit-trail/spec.md index e787506..1a73ec1 100644 --- a/openspec/specs/audit-trail/spec.md +++ b/openspec/specs/audit-trail/spec.md @@ -111,3 +111,30 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD #### Scenario: Загрузка двух файлов - **WHEN** принято два файла одним запросом - **THEN** два события `FILE_UPLOAD` с `object_id` каждого файла и `after_value` (`original_name`, `content_hash`, `job_id`) + +### Requirement: События консолидации +Действия с консолидацией SHALL писать `audit_event` через сервис аудита с заполненным `consolidation_version_id`: + +| Действие | `action` | `section` | `object_type` | `before_value` / `after_value` | +|---|---|---|---|---| +| консолидация создана | `CONSOLIDATION_CREATE` | `CONSOLIDATION` | `consolidation_version` | — / `{consolidation_id, year, quarter, members}` | +| периметр изменён | `MEMBERSHIP_CHANGE` | `CONSOLIDATION` | `consolidation_version` | списки ЮЛ до / после | +| взята в работу, снята | `CONSOLIDATION_LOCK` | `CONSOLIDATION` | `consolidation_version` | — / `{locked_by, released}` | +| перехват | `CONSOLIDATION_LOCK_TAKEOVER` | `CONSOLIDATION` | `consolidation_version` | `{locked_by: прежний}` / `{locked_by: новый}`, `comment` = причина | +| смена статуса (ручная и автоматическая) | `CONSOLIDATION_STATUS_CHANGE` | `CONSOLIDATION` | `consolidation_version` | `{status}` / `{status}`, `comment` | +| контрагент сопоставлен вручную | `OSV_COUNTERPARTY_MAP` | `CONSOLIDATION` | `osv_entry` | `{counterparty_id, match_kind}` / `{counterparty_id, match_kind, propagated_entry_ids, alias_created, alias_moved_from}` | +| экспорт свода | `EXPORT` | `EXPORT` | `consolidation_version` | — / `{report: "consolidated_osv", filters, rows}` | + +Автоматические переходы статуса SHALL писаться с `user_id` инициатора события (загрузивший, подтвердивший, +`stored_file.uploaded_by`). Создание и удаление алиасов при ручном сопоставлении SHALL дополнительно писать +`LEGAL_ENTITY_CHANGE` (раздел `DIRECTORIES`, как в Срезе 1). `FILE_REPLACE` SHALL добавлять в `after_value` +`unbound_version_ids`. + +#### Scenario: Перехват +- **WHEN** Б перехватывает блокировку у А с причиной «Отпуск» +- **THEN** одно событие `CONSOLIDATION_LOCK_TAKEOVER` с `before_value.locked_by = A`, `after_value.locked_by = B`, + `comment = "Отпуск"`, `consolidation_version_id` версии + +#### Scenario: Автоматический переход +- **WHEN** подтверждение документа перевело версию в `DATA_CORRECTION` +- **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED` diff --git a/openspec/specs/consolidated-osv/spec.md b/openspec/specs/consolidated-osv/spec.md new file mode 100644 index 0000000..7e4c7db --- /dev/null +++ b/openspec/specs/consolidated-osv/spec.md @@ -0,0 +1,148 @@ +# consolidated-osv Specification + +## Purpose +Сводная ОСВ версии как чистая функция (DECISION-5; §2, §3 `05-domain-rules.md`; ТЗ DOC-221278; макет `30`, +`32-osv-no-inn`, `32-osv-map-counterparty`): приведение квартальных ОСВ к нарастающим, признак контрагента, кандидаты и +ручное сопоставление, экспорт xlsx. Маршруты — под `/api/v1/consolidations/versions/{id}`. + +## Requirements + +### Requirement: Выбор источников и приведение периодов +Для каждой пары (ЮЛ периметра, `osv_account_code` документа) ядро SHALL выбирать источники среди ОСВ-документов, +привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии +→ единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала +версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; для версии за `Q2`–`Q4` +ОСВ за `Q1` с любым `is_cumulative` служит квартальной `Q1` (нарастающий с начала года за первый квартал и есть +квартал `Q1`, R-14); при наличии и +нарастающего, и квартальных — нарастающий. Если какого-то квартала нет, пара SHALL попадать в `gaps` с +`missing_quarters`, а строки по ней MUST NOT строиться (решение владельца 06.10.2026, OQ-03). Квартальные строки +SHALL склеиваться по ключу (`account_code` строки, `counterparty_name.strip()`, ИНН без пробелов): `opening_*` из +строки `Q1`, `turnover_*` = сумма по кварталам, `closing_*` из строки последнего квартала; строка, отсутствующая в +квартале, считается нулями; две строки с одним ключом внутри квартала суммируются. `entry_ids` строки свода SHALL +перечислять все исходные `osv_entry`. Расчёт — функция в `domain/` без ORM; результат MUST NOT сохраняться. + +#### Scenario: Три квартала +- **WHEN** версия 2024 Q3; у ЮЛ привязаны квартальные ОСВ 60 за Q1, Q2, Q3; контрагент «А» есть во всех трёх со + значениями `opening_debit` 100/300/450 и `turnover_debit` 200/150/50, `closing_debit` 300/450/500 +- **THEN** одна строка «А»: `opening_debit = 100`, `turnover_debit = 400`, `closing_debit = 500`, `period = "2024 Q3"`, + `is_cumulative = false`, `entry_ids` — три идентификатора + +#### Scenario: Контрагент только во втором квартале +- **WHEN** контрагент «Б» есть только в ОСВ Q2 (`opening 0`, `turnover_debit 70`, `closing_debit 70`) +- **THEN** строка «Б»: `opening_* = 0`, `turnover_debit = 70`, `closing_* = 0` + +#### Scenario: Пробел +- **WHEN** у ЮЛ привязаны ОСВ 60 за Q1 и Q3, за Q2 нет +- **THEN** строк по счёту 60 этого ЮЛ нет; `summary.gaps` содержит `{legal_entity_id, account_code: "60", + missing_quarters: ["Q2"]}` + +#### Scenario: Нарастающий рядом с квартальными +- **WHEN** у ЮЛ привязаны квартальные ОСВ 62 за Q1–Q3 и нарастающая ОСВ 62 за Q3 +- **THEN** строки берутся из нарастающей один в один, `is_cumulative = true` + +### Requirement: Строки свода, признак и дорезолв +Строка свода SHALL содержать: `entry_ids`, ЮЛ (`id`, `name`, `inn`), `account_code`, `account_root`, `period` +(`"<год> Q"`), `is_cumulative`, `row_number`, `counterparty_name` и `counterparty_inn` как в файле, +`counterparty_id`, `counterparty_entity_name`, `counterparty_type` ∈ {`GSK`, `GK`, `EXTERNAL`}, `match_kind` ∈ +{`INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`, null}, `match_stored`, шесть сумм (`Decimal`, в JSON — строки), +`warnings[]`. В свод SHALL входить только строки `group_type = COUNTERPARTY` с `data_type IN (BU, SUM)`; строки +MUST NOT агрегироваться между контрагентами. `counterparty_id` SHALL браться из исходных строк (непустое значение +последнего квартала; расхождение между кварталами — в `warnings`); при пустом — резолв по ИНН → имя → алиас на лету +по всему справочнику без policy, результат показывается с `match_stored = false` и MUST NOT записываться. +`counterparty_type`: `GSK` — `counterparty_id` ∈ `consolidation_member` версии; `GK` — `legal_entity.company_group_id` += ГК консолидации; иначе `EXTERNAL` (в том числе при пустом `counterparty_id`). + +#### Scenario: Признаки на фикстурах РБ +- **WHEN** версия ГК «Русский бисквит» 2024 Q3 с периметром {АО РБ, ООО Пекарня Счастливочная}; строка Счастливочной + по 60 с контрагентом «Русский бисквит», ИНН 3528052718 смаплена по ИНН; строка РБ «Пекарня Счастливочная ООО» без ИНН + не сопоставлена; строка РБ с контрагентом ЧКФ (в ГК, не в ГСК) +- **THEN** первая — `GSK`, `match_kind = INN`, `match_stored = true`; вторая — `EXTERNAL`, `counterparty_id` null; + третья — `GK` + +#### Scenario: Дорезолв на чтение +- **WHEN** у строки `counterparty_id` пуст, а после маппинга в справочнике появился алиас, по которому имя резолвится + однозначно +- **THEN** в ответе `counterparty_id` заполнен, `match_kind = ALIAS`, `match_stored = false`; в `osv_entry` значение + не изменилось + +### Requirement: Страница свода и сводка +`GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`, +`row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`, +`counterparty_type`, `unmapped=true` (`counterparty_id` пуст после дорезолва), `match_kind` (повторяемый), `q` +(подстрока без регистра в `counterparty_name` или префикс `counterparty_inn`). `GET …/osv/summary` SHALL отдавать +`rows_total`, `unmapped_total`, `auto_matched_total` (строки с `match_kind` ∈ {`NAME`, `ALIAS`} независимо от +`match_stored`), `documents_total` (привязанные ОСВ-документы) и `gaps[]` (`legal_entity_id`, `legal_entity_name`, +`account_code`, `missing_quarters`). Версия в любом статусе, включая `CANCELLED`, SHALL читаться. + +#### Scenario: Только несопоставленные +- **WHEN** запрошено `GET …/osv?unmapped=true` +- **THEN** все строки ответа с `counterparty_id = null`, `total` = `summary.unmapped_total` + +#### Scenario: Фильтр по способу +- **WHEN** запрошено `GET …/osv?match_kind=NAME&match_kind=ALIAS` +- **THEN** в ответе только строки с этими `match_kind`, `total` = `summary.auto_matched_total` + +### Requirement: Кандидаты для сопоставления +`GET …/counterparty-candidates` (`CONSOLIDATION_VIEW`, policy) с параметрами `q`, `scope` ∈ {`group` (по умолчанию), +`all`}, `entry_id` SHALL возвращать до 100 ЮЛ: `scope=group` — ЮЛ периметра версии и остальные ЮЛ той же ГК, `q` +необязателен; `scope=all` — весь справочник без policy видимости, `q` SHALL быть не короче 3 символов (иначе 422 +`QUERY_TOO_SHORT`). Поиск: ИНН по префиксу, имя и алиасы — подстрока без регистра. Кандидат: `id`, `inn`, `name`, +`company_group {id, name}`, `gsk {id, name}` / `gsz {id, name}` (текущее членство), `counterparty_type` относительно +версии, `recommended` — `normalize_alias(name)` или алиас совпали с `normalize_alias(counterparty_name)` строки +`entry_id`. Рекомендуемые — первыми, затем по имени. + +#### Scenario: Рекомендуемая Счастливочная +- **WHEN** запрошены кандидаты для строки РБ «Пекарня Счастливочная ООО» без `q` +- **THEN** первой идёт ООО «Пекарня Счастливочная» с `recommended = true`, `counterparty_type = GSK`; ЧКФ в списке с + `recommended = false`, `counterparty_type = GK` + +### Requirement: Ручное сопоставление контрагента +`POST …/osv/entries/{entry_id}/counterparty` (`CONSOLIDATION_EDIT`, policy, статус «в работе», своя блокировка) с телом +`{legal_entity_id, move_alias = false}` SHALL в одной транзакции: проверить, что строка принадлежит ОСВ-документу, +привязанному к версии (иначе 404 `NOT_FOUND` с сообщением `ENTRY_NOT_FOUND`; код фиксированный, идентификатор — текст сообщения), и что ЮЛ существует (без policy; иначе 404 `NOT_FOUND` с сообщением `LEGAL_ENTITY_NOT_FOUND`); +вычислить `normalized = normalize_alias(counterparty_name)`: пустой → алиас не создаётся; алиас с таким `normalized` +у других ЮЛ и `move_alias = false` → 409 `ALIAS_TAKEN` с `owners[] {legal_entity_id, name}`; с `move_alias = true` → +их алиасы удаляются (`audit_event(LEGAL_ENTITY_CHANGE)` на каждый); у выбранного ЮЛ алиаса нет → создаётся +(`audit_event(LEGAL_ENTITY_CHANGE)`); строке — `counterparty_id`, `counterparty_match_kind = MANUAL`, `modified_by/at` +не трогаются (`counterparty_name/inn` неизменны); автоприменение — всем строкам ОСВ-документов, привязанных к версии, с +тем же `counterparty_name.strip()`, пустым `counterparty_id` и ИНН пустым или равным ИНН выбранного ЮЛ → +`counterparty_id`, `PROPAGATED`; один `audit_event(OSV_COUNTERPARTY_MAP, section = CONSOLIDATION, object = osv_entry, +consolidation_version_id)` с `before_value {counterparty_id, match_kind}`, `after_value {counterparty_id, match_kind, +propagated_entry_ids, alias_created, alias_moved_from}`. Уже связанную строку SHALL быть можно перевязать. Ответ — +`{row, propagated, alias_created, alias_moved_from}`. `DIRECTORY_DELETE` для переноса алиаса MUST NOT требоваться. + +#### Scenario: Сопоставление из плана среза +- **WHEN** в версии РБ 2024 Q3 строка РБ «Пекарня Счастливочная ООО» (счёт 60) связана с ООО «Пекарня Счастливочная» +- **THEN** 200; у строки `counterparty_type = GSK`, `match_kind = MANUAL`; создан `legal_entity_alias` «Пекарня + Счастливочная ООО»; все остальные строки РБ с тем же именем во всех привязанных ОСВ (60, 62, 66 …) получили тот же + `counterparty_id` и `PROPAGATED`; `propagated` ≥ 1; `summary.unmapped_total` уменьшился на число СТРОК сводной ОСВ, + получивших контрагента (квартальные записи одного контрагента склеены в одну строку), а `1 + propagated` равно числу + записей `osv_entry` с этим именем во всех привязанных ОСВ, включая пары-пробелы и записи, склеенные в одну строку + +#### Scenario: Алиас занят +- **WHEN** `normalized` имени строки уже есть алиасом у другого ЮЛ, `move_alias = false` +- **THEN** 409 `ALIAS_TAKEN` с `owners`; строка и алиасы не изменились + +#### Scenario: Перенос алиаса +- **WHEN** тот же запрос с `move_alias = true` +- **THEN** 200; алиас у прежнего владельца удалён, у выбранного создан, `alias_moved_from` = прежний владелец; два + `audit_event(LEGAL_ENTITY_CHANGE)` и один `OSV_COUNTERPARTY_MAP` + +#### Scenario: ИНН противоречит +- **WHEN** у другой строки с тем же именем `counterparty_inn` заполнен и не равен ИНН выбранного ЮЛ +- **THEN** она не получает `counterparty_id` при автоприменении + +### Requirement: Экспорт xlsx +`GET …/osv/export` (`CONSOLIDATION_VIEW`, policy; фильтры как у `GET …/osv`, без пагинации) SHALL отдавать файл +`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` с `Content-Disposition: attachment; +filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<квартал>_v.xlsx`. Лист «Сводная ОСВ»: строка +заголовков — Компания ГК, ИНН компании, Счёт, Период, Контрагент, ИНН контрагента, Сальдо начало Дт, Сальдо начало Кт, +Обороты Дт, Обороты Кт, Сальдо конец Дт, Сальдо конец Кт, Тип контрагента, Сопоставлено с, Способ сопоставления; суммы +— числовые ячейки с форматом `#,##0.00` в рублях без деления; порядок строк как у API. Лист «Пробелы»: Компания ГК, +Счёт, Нет ОСВ за кварталы; при отсутствии пробелов — одна ячейка «пробелов нет». Файл SHALL строиться в памяти без S3; +`audit_event(EXPORT, section = EXPORT, object = consolidation_version, after_value = {filters, rows})`. + +#### Scenario: Экспорт читается обратно +- **WHEN** экспорт версии с 3 строками и 1 пробелом открыт `openpyxl` +- **THEN** лист «Сводная ОСВ» содержит заголовок и 3 строки, суммы — числа с двумя знаками, равные API; лист «Пробелы» + — одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3` diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md new file mode 100644 index 0000000..5e459ac --- /dev/null +++ b/openspec/specs/consolidations-api/spec.md @@ -0,0 +1,151 @@ +# consolidations-api Specification + +## Purpose +Консолидация ГК за период: создание, дерево версий, карточка версии, периметр, блокировка «взять в работу», статусы +Среза 4 и чек-лист комплекта документов (§9.1, §10 `05-domain-rules.md`; DBML `consolidation*`, `required_document_rule`; +макет `34`, `35`, `36`, `37`, `42`). Префикс всех маршрутов — `/api/v1`. + +## Requirements + +### Requirement: Создание консолидации +`POST /consolidations` (`CONSOLIDATION_EDIT`, policy по ГК) с телом `{company_group_id, related_group_id, year, quarter}` +SHALL в одной транзакции создать `consolidation` (`reporting_date`: Q1 → 01.04.year, Q2 → 01.07.year, Q3 → 01.10.year, +Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATED`, `scenario = CREATE`, `approach = BASIC`, +`goodwill_enabled = false`, `initiator_id` и `initiator_office_id` из пользователя, `is_in_work = true`), +`consolidation_member` из текущих членов ГСК (`is_head_entity = false`), первую запись `consolidation_status_history` +(`from_status` NULL → `INITIATED`), подхват подтверждённых документов (см. `documents-api` «Подхват документов») и +`audit_event(CONSOLIDATION_CREATE)`; ответ 201 с карточкой версии. ГСК SHALL принадлежать ГК и иметь `kind = gsk`, +иначе 422 `RELATED_GROUP_MISMATCH`; членов меньше двух → 422 `PERIMETER_TOO_SMALL`; пара (ГК, год, квартал) уже есть +→ 409 `CONSOLIDATION_EXISTS` (в том числе при гонке двух запросов — UNIQUE `(company_group_id, year, quarter)`); +`year` вне 2000..2100 → 422. Блокировка на создателя MUST NOT ставиться. `related_group_id` в `consolidation` SHALL +оставаться NULL (фиксируется при верификации, Срез 7). + +#### Scenario: Консолидация РБ за Q3 2024 +- **WHEN** `ka_rf` Санкт-Петербургского РФ создаёт консолидацию ГК «Русский бисквит», ГСК из двух ЮЛ, 2024, Q3 +- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью, + `audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет + +#### Scenario: ГСК из одного ЮЛ +- **WHEN** в ГСК один текущий член +- **THEN** 422 `PERIMETER_TOO_SMALL`; `consolidation` и версия не созданы + +#### Scenario: Повтор периода +- **WHEN** консолидация ГК за 2024 Q3 уже есть, создаётся ещё одна +- **THEN** 409 `CONSOLIDATION_EXISTS` + +### Requirement: Список консолидаций ГК и карточка версии +`GET /company-groups/{id}/consolidations` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать периоды ГК (`consolidation_id`, +`year`, `quarter`, `reporting_date`) по убыванию года и квартала, в каждом — версии по убыванию `version_no` +(`id`, `version_no`, `status`, `scenario`, `approach`, `lock` — активная блокировка или null, `created_at`, +`members_count`); параметр `in_work_only=true` SHALL оставлять только версии со статусом не из +{`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`}. `GET /consolidations/versions/{id}` SHALL отдавать карточку: поля +версии и консолидации, `company_group {id, name}`, инициатора и его офис, `lock`, `members[]` (ЮЛ: `id`, `name`, `inn`, +`is_head_entity`, `checklist[]` — ячейки по правилам подхода, см. «Чек-лист комплекта»), `history[]` (`from_status`, +`to_status`, `changed_by`, `changed_at`, `comment`). Консолидация ГК вне видимости и несуществующий идентификатор — +404 `NOT_FOUND` с сообщением `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND` и одинаковым телом (так же во всех +спецификациях среза: идентификаторы `…_NOT_FOUND` — текст сообщения при фиксированном коде `NOT_FOUND`). + +#### Scenario: Дерево версий +- **WHEN** у ГК консолидации 2024 Q1 (версии 1 и 2) и 2024 Q3 (версия 1), запрошен список +- **THEN** первым период 2024 Q3, затем 2024 Q1 с версиями `[2, 1]`; `members_count` у каждой версии + +#### Scenario: Чужой филиал +- **WHEN** `ka_rf` другого филиала запрашивает карточку версии +- **THEN** 404 `NOT_FOUND` (сообщение `VERSION_NOT_FOUND`), тело совпадает с ответом на случайный `uuid` + +### Requirement: Периметр версии +`PUT /consolidations/versions/{id}/members` (`CONSOLIDATION_EDIT`, policy, статус «в работе», своя активная блокировка) +с телом `{legal_entity_ids: [...]}` SHALL заменить периметр версии: все ЮЛ SHALL принадлежать ГК консолидации +(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем; меньше двух → 422 +`PERIMETER_TOO_SMALL`; дубли в списке схлопываются. Для добавленных ЮЛ SHALL выполняться подхват подтверждённых +документов, для удалённых — отвязка их `consolidation_document`; `audit_event(MEMBERSHIP_CHANGE, section = +CONSOLIDATION, object = consolidation_version)` с `before_value`/`after_value` — полные списки ЮЛ. `related_group_member` +MUST NOT изменяться. Ответ — карточка версии. + +#### Scenario: Добавление ЮЛ с подтверждёнными документами +- **WHEN** в периметр версии 2024 Q3 добавлено ЮЛ ГК, у которого подтверждена ОСВ 60 за 2024 Q3 (нарастающая) +- **THEN** 200; в `members` три ЮЛ; у нового ЮЛ ячейка ОСВ 60 `CONFIRMED`; `consolidation_document` на этот документ есть + +#### Scenario: ЮЛ другой ГК +- **WHEN** в список попало ЮЛ, `company_group_id` которого не равен ГК консолидации +- **THEN** 422 `ENTITY_NOT_IN_GROUP`, периметр не изменился + +#### Scenario: Без блокировки +- **WHEN** у версии нет активной блокировки, пользователь с `CONSOLIDATION_EDIT` меняет периметр +- **THEN** 409 `LOCK_REQUIRED` + +### Requirement: Взять в работу, перехват, снятие +`POST /consolidations/versions/{id}/lock` (`CONSOLIDATION_EDIT`, policy; версия в любом статусе, кроме `CANCELLED` — +409 `VERSION_NOT_EDITABLE`) SHALL: при отсутствии активной блокировки создать `consolidation_lock(locked_by, locked_at)` +и `audit_event(CONSOLIDATION_LOCK)` → 201; при своей активной — вернуть её без изменений → 200; при чужой без +`takeover_reason` → 409 `LOCKED_BY_OTHER` с `locked_by`, `locked_by_name`, `locked_at` в `detail`; при чужой с +непустым `takeover_reason` (≤ 1000 символов) — закрыть прежнюю (`released_at = now`), создать новую с +`taken_over_from` и `takeover_reason`, `audit_event(CONSOLIDATION_LOCK_TAKEOVER, before = прежний владелец, after = +новый, comment = причина)` → 201. Два одновременных `POST` без активной блокировки → у одного 201, у другого 409 +`LOCKED_BY_OTHER` (частичный UNIQUE). `DELETE /consolidations/versions/{id}/lock` SHALL снять свою активную +блокировку (`released_at = now`, `audit_event(CONSOLIDATION_LOCK, after_value.released = true)`) → 204; чужую — 409 +`NOT_LOCK_OWNER`; без активной — 204. Блокировка MUST NOT истекать по времени (OQ-119). + +#### Scenario: Перехват с причиной +- **WHEN** версию держит `ka_rf` А, `ka_rf` Б того же филиала вызывает `POST …/lock {takeover_reason: "Отпуск"}` +- **THEN** 201; у Б активная блокировка с `taken_over_from = A`; у А `released_at` заполнен; + `audit_event(CONSOLIDATION_LOCK_TAKEOVER)` с `comment = "Отпуск"` + +#### Scenario: Перехват без причины +- **WHEN** Б вызывает `POST …/lock` без тела при активной блокировке А +- **THEN** 409 `LOCKED_BY_OTHER`, `detail.locked_by = A` + +#### Scenario: Чужое снятие +- **WHEN** Б вызывает `DELETE …/lock` при активной блокировке А +- **THEN** 409 `NOT_LOCK_OWNER`, блокировка А активна + +### Requirement: Статусы Среза 4 +Версия SHALL менять статус только через сервис статусов, который пишет `consolidation_status_history(from, to, +changed_by, changed_at, comment)` и `audit_event(CONSOLIDATION_STATUS_CHANGE, before = from, after = to, comment)`. +Автоматические переходы: загрузка файла с `consolidation_version_id` (см. `primary-files`) — `INITIATED → +DOCUMENTS_PROCESSING`; маппинг документа `NEEDS_CHECK`, подходящего версии (см. `osv-result-mapping`) — +`DOCUMENTS_PROCESSING → DATA_CORRECTION`; подтверждение документа, привязанного к версии (см. `documents-api`) — +`INITIATED` или `DOCUMENTS_PROCESSING → DATA_CORRECTION`. Ручной `POST /consolidations/versions/{id}/status` +(`CONSOLIDATION_EDIT`, policy, своя блокировка) с телом `{to_status, comment}` SHALL допускать только: `CANCELLED` из +любого статуса «в работе» (`comment` обязателен, иначе 422 `COMMENT_REQUIRED`; `cancelled_reason = comment`, +`is_in_work = false`, активная блокировка снимается) и `INITIATED` из `DOCUMENTS_PROCESSING` / `DATA_CORRECTION` +(`comment` необязателен; `consolidation_document` сохраняются). Любой другой `to_status` → 409 `TRANSITION_NOT_ALLOWED` +с текущим и запрошенным статусами. Повтор того же статуса → 409 `TRANSITION_NOT_ALLOWED`. Статус «в работе» = +{`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`}. + +#### Scenario: Аннулирование +- **WHEN** владелец блокировки переводит версию `DATA_CORRECTION` в `CANCELLED` с комментарием +- **THEN** 200; `status = CANCELLED`, `cancelled_reason` = комментарий, `is_in_work = false`, блокировка снята; запись + истории `DATA_CORRECTION → CANCELLED` с комментарием; `audit_event(CONSOLIDATION_STATUS_CHANGE)` + +#### Scenario: Переход Среза 7 +- **WHEN** запрошен `to_status = CONSOLIDATION_RF` +- **THEN** 409 `TRANSITION_NOT_ALLOWED`, статус не изменился + +#### Scenario: Аннулирование без комментария +- **WHEN** `to_status = CANCELLED`, `comment` пуст +- **THEN** 422 `COMMENT_REQUIRED` + +### Requirement: Чек-лист комплекта +`GET /consolidations/versions/{id}/checklist` (`CONSOLIDATION_VIEW`, policy) SHALL вернуть `rules[]` (правила +`required_document_rule` подхода версии: `document_type`, `account`, `is_mandatory`; порядок — `BALANCE`, `INCOME`, +затем ОСВ по коду счёта) и `rows[]` по ЮЛ периметра (по имени) с `cells[]` в порядке `rules`. Ячейка `{status, +missing_quarters, document_ids}`: `CONFIRMED` — пара (ЮЛ, правило) полностью покрыта привязанными документами (ОСВ: по +правилу выбора источников `consolidated-osv`; ББ/ОФР: документ за квартал версии); `NEEDS_CHECK` / `RECOGNIZED` — +есть неподтверждённый документ ЮЛ того же типа, счёта и года, подходящий версии (приоритет `NEEDS_CHECK`); +`PROCESSING` — у ЮЛ есть файл `UPLOADED` или `PROCESSING`; `ERROR` — последний по `uploaded_at` файл ЮЛ в `ERROR`; +иначе `MISSING`. `missing_quarters` SHALL перечислять кварталы без квартальной ОСВ при частичном покрытии. Правило ОСВ +SHALL закрываться только документом с `osv_account_code`, равным `account` правила (OQ-04). Те же ячейки SHALL +отдаваться в карточке версии у каждого члена. + +#### Scenario: Пробел Q2 +- **WHEN** у ЮЛ привязаны квартальные ОСВ 60 за Q1 и Q3 2024, версия за 2024 Q3 +- **THEN** ячейка ОСВ 60: `status = MISSING`, `missing_quarters = ["Q2"]`, `document_ids` — два документа + +#### Scenario: Субсчёт не закрывает правило +- **WHEN** у ЮЛ подтверждена только ОСВ 58.03 за квартал версии +- **THEN** ячейка ОСВ 58 — `MISSING` + +#### Scenario: Файл в обработке +- **WHEN** у ЮЛ загружен файл, задание ещё не завершено, других документов нет +- **THEN** все ячейки ЮЛ — `PROCESSING` diff --git a/openspec/specs/data-model/spec.md b/openspec/specs/data-model/spec.md index 8ec3464..ccdc9b2 100644 --- a/openspec/specs/data-model/spec.md +++ b/openspec/specs/data-model/spec.md @@ -6,19 +6,23 @@ ## Requirements -### Requirement: Схема БД равна DBML v5.15 -ORM-метаданные SHALL содержать ровно 43 таблицы и 27 enum'ов из `../docs/model/data-model-v5.15.dbml` в схеме `dkb`; +### Requirement: Схема БД равна DBML v5.16 +ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.16.dbml` в схеме `dkb`; имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member` `(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)`. `related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке). `audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`; `osv_data_type` SHALL содержать `NU` (после `BU`). -`app_user.email` SHALL храниться в нижнем регистре (нормализация при записи seed'ом и администрированием). Поля, -отсутствующие в DBML, MUST NOT существовать. +Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`; колонка +`osv_entry.counterparty_match_kind` (nullable) SHALL быть NULL тогда и только тогда, когда `counterparty_id` NULL +(проверяет ядро при записи). `app_user.email` SHALL храниться в нижнем регистре. Миграция `0004_dbml_v5_16` SHALL +создавать enum и колонку и заполнять её по уже смапленным строкам (ИНН строки без пробелов = `legal_entity.inn` → `INN`; +`trim(counterparty_name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS`); откат SHALL удалять +колонку и enum. Поля, отсутствующие в DBML, MUST NOT существовать. #### Scenario: Сверка ORM с DBML -- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.15.dbml` +- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.16.dbml` - **THEN** вывод пуст и код возврата 0 #### Scenario: Лишняя колонка в ORM @@ -37,6 +41,11 @@ ORM-метаданные SHALL содержать ровно 43 таблицы - **WHEN** в `osv_entry` вставляется строка с `data_type = 'NU'` - **THEN** вставка проходит +#### Scenario: Backfill при миграции +- **WHEN** до миграции 0004 есть строки с `counterparty_id`, найденным по ИНН, по имени и по алиасу, и строка без `counterparty_id` +- **THEN** после `alembic upgrade head` у них `counterparty_match_kind` = `INN`, `NAME`, `ALIAS` и NULL соответственно; + `alembic downgrade 0003` удаляет колонку и enum + ### Requirement: Миграции применяются с нуля и откатываются Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` SHALL создавать схему `dkb` (если нет), все enum'ы, таблицы, ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`) diff --git a/openspec/specs/directory-policy/spec.md b/openspec/specs/directory-policy/spec.md index f44892d..9a15638 100644 --- a/openspec/specs/directory-policy/spec.md +++ b/openspec/specs/directory-policy/spec.md @@ -36,3 +36,26 @@ #### Scenario: Создание ГК из филиала - **WHEN** пользователь филиала X с `DIRECTORY_EDIT` создаёт ГК без филиала - **THEN** у ГК `base_regional_office_id` = X, и она видна создателю + +### Requirement: Видимость консолидаций и редактируемость версии +Консолидация и её версии SHALL быть видимы тем же пользователям, что и её ГК (`company_group`); чужие и +несуществующие — 404 с одинаковым телом (код `NOT_FOUND`, сообщение `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND`; +так устроены все 404 этого среза: `NotFoundError` несёт только текст сообщения). Правка версии (периметр, +ручная смена статуса, сопоставление контрагента) SHALL проходить проверки в порядке: право → видимость → статус +версии ∈ {`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`} (иначе 409 `VERSION_NOT_EDITABLE`) → активная +`consolidation_lock` есть (иначе 409 `LOCK_REQUIRED`) и принадлежит пользователю (иначе 409 `LOCKED_BY_OTHER` с +владельцем). Проверка реализуется чистой функцией в `domain/policy.py` без обращения к коду роли. Чтение (карточка, +свод, чек-лист, экспорт, кандидаты) и загрузка/подтверждение документов от статуса блокировки MUST NOT зависеть. +Зоны ответственности не учитываются (ASSUMPTION(OQ-86)). + +#### Scenario: Чужая блокировка +- **WHEN** версию держит А, Б с `CONSOLIDATION_EDIT` вызывает `PUT …/members` +- **THEN** 409 `LOCKED_BY_OTHER`, `detail.locked_by = A` + +#### Scenario: Аннулированная версия +- **WHEN** версия в `CANCELLED`, владелец прежней блокировки вызывает `POST …/osv/entries/{id}/counterparty` +- **THEN** 409 `VERSION_NOT_EDITABLE` + +#### Scenario: Куратор ГО читает +- **WHEN** `curator_go` (только `CONSOLIDATION_VIEW`) запрашивает свод версии любого филиала +- **THEN** 200 diff --git a/openspec/specs/documents-api/spec.md b/openspec/specs/documents-api/spec.md index a16b296..2ebac25 100644 --- a/openspec/specs/documents-api/spec.md +++ b/openspec/specs/documents-api/spec.md @@ -32,8 +32,19 @@ или `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`. +`FILE_SUPERSEDED`. Подтверждение берёт блокировки в порядке «файл → документ → (advisory-блокировка привязок ГК) → версии консолидации +(по возрастанию `id`)»: файл — `FOR UPDATE`, документ — `FOR NO KEY UPDATE` (R-3; совместима со вставкой привязки +`FOR KEY SHARE`), версии — `FOR UPDATE`; состояние перечитывается под этими блокировками; документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`. +В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`, +`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии, +`period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал +версии; false → `period_quarter` ≤ квартала версии). ОСВ за `Q1` равнозначна нарастающей и квартальной (R-14): она +подходит любой версии года при любом `is_cumulative`; ББ/ОФР за `Q1` — отчёт на конец квартала и подходят только версии +за `Q1` (R-14a). Привязанный ранее документ того же ЮЛ с тем же ключом (`document_type`, `osv_account_code`, +`period_quarter`, признак нарастающего: `is_cumulative` true или NULL; для ОСВ за `Q1` признак не различается) в той же +версии SHALL отвязываться (R-1). Каждая версия, получившая +привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = +подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. #### Scenario: Причина ошибки маппинга в карточке файла - **WHEN** файл в `ERROR` после неудачного маппинга (задание `done`, `error_code` пуст) @@ -46,3 +57,31 @@ #### Scenario: Подтверждение - **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала - **THEN** `status = CONFIRMED`, `confirmed_by` = пользователь, `audit_event(DOCUMENT_CONFIRM)` с `before_value.status = NEEDS_CHECK` + +#### Scenario: Привязка к версии и переход статуса +- **WHEN** версия ГК 2024 Q3 в `INITIATED` с ЮЛ в периметре; подтверждается квартальная ОСВ 60 этого ЮЛ за 2024 Q2 +- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории + `INITIATED → DATA_CORRECTION` с `changed_by` = пользователь + +#### Scenario: Не подходит по периоду +- **WHEN** подтверждается нарастающая ОСВ 60 за 2024 Q2 при версии за 2024 Q3 +- **THEN** привязки нет, статус версии не изменился + +#### Scenario: Версия в CANCELLED +- **WHEN** подходящий документ подтверждается при версии `CANCELLED` +- **THEN** привязки нет + +### Requirement: Подхват документов при создании версии и расширении периметра +При создании версии и при добавлении ЮЛ в периметр ядро SHALL привязать уже подтверждённые документы этого ЮЛ из +незаменённых файлов, подходящие версии (условия — как при подтверждении); на один ключ (`document_type`, +`osv_account_code`, `period_quarter`, признак «нарастающий»; NULL признака — нарастающий; для ОСВ за Q1 признак не +различается, R-1, R-14) — документ с самым поздним `confirmed_at`. Удаление ЮЛ из периметра SHALL +снимать привязку всех его документов в этой версии. Подхват и отвязка статус версии MUST NOT менять. + +#### Scenario: Фикстуры, подтверждённые до консолидации +- **WHEN** ОСВ 60 РБ за Q1, Q2, Q3 2024 подтверждены, затем создана консолидация ГК за 2024 Q3 +- **THEN** три `consolidation_document` на эти документы; в чек-листе ячейка ОСВ 60 РБ — `CONFIRMED`; версия в `INITIATED` + +#### Scenario: Две подтверждённые версии одного документа +- **WHEN** у ЮЛ два незаменённых файла с `CONFIRMED` ОСВ 62 за 2024 Q3 (разные `locator`), создана версия 2024 Q3 +- **THEN** привязан документ с более поздним `confirmed_at` diff --git a/openspec/specs/osv-result-mapping/spec.md b/openspec/specs/osv-result-mapping/spec.md index 7bce6a5..3c77f02 100644 --- a/openspec/specs/osv-result-mapping/spec.md +++ b/openspec/specs/osv-result-mapping/spec.md @@ -66,11 +66,14 @@ LOCKED`), проверять, что `result_s3_object` относится к б Для строк `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 выполняться по всему справочнику без фильтра видимости. +одного кандидата на шаге → `NULL`. Резолв SHALL выполняться по всему справочнику без фильтра видимости. Вместе с +`counterparty_id` SHALL записываться `counterparty_match_kind`: `INN`, `NAME` или `ALIAS` по шагу, на котором нашёлся +единственный кандидат (нормализованное имя ЮЛ и алиас — оба `ALIAS`); при `NULL` — NULL. #### Scenario: По ИНН, по алиасу, неоднозначно - **WHEN** в справочнике есть ЮЛ с ИНН `3528052718`, ЮЛ с алиасом `русский бисквит`, и два ЮЛ с одинаковым нормализованным именем `альфа` -- **THEN** строка с ИНН `3528052718` → это ЮЛ; строка «Русский бисквит» без ИНН → ЮЛ по алиасу; строка «ООО Альфа» → `counterparty_id = NULL` +- **THEN** строка с ИНН `3528052718` → это ЮЛ, `counterparty_match_kind = INN`; строка «Русский бисквит» без ИНН → ЮЛ по + алиасу, `ALIAS`; строка «ООО Альфа» → `counterparty_id = NULL`, `counterparty_match_kind = NULL` ### Requirement: Проверки шапки и статус документа `counterparty_check` SHALL быть `MATCH`, если ИНН шапки равен ИНН владельца, или (без ИНН) нормализованное имя шапки @@ -92,3 +95,18 @@ LOCKED`), проверять, что `result_s3_object` относится к б #### Scenario: Чистый результат - **WHEN** задание `done`, шапка совпадает с владельцем, нарушений нет - **THEN** `status = RECOGNIZED`, `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` при маппинге MUST NOT создаваться. + +#### Scenario: Предупреждение маппинга двигает статус +- **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл ЮЛ периметра с ОСВ 62 за 2024 Q3 в `NEEDS_CHECK` +- **THEN** версия в `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; `consolidation_document` нет + +#### Scenario: Документ RECOGNIZED +- **WHEN** смапплен файл с единственным документом `RECOGNIZED` +- **THEN** статус версии не изменился diff --git a/openspec/specs/primary-files/spec.md b/openspec/specs/primary-files/spec.md index b901281..426b9ba 100644 --- a/openspec/specs/primary-files/spec.md +++ b/openspec/specs/primary-files/spec.md @@ -19,6 +19,11 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`, `message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`. +Необязательный query-параметр `consolidation_version_id` SHALL проверяться до приёма файлов: версия видима (иначе 404 +`NOT_FOUND` с сообщением `VERSION_NOT_FOUND`), статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`), ЮЛ ∈ периметру (иначе 422 +`ENTITY_NOT_IN_PERIMETER`); если принят хотя бы один файл и версия в `INITIATED`, она SHALL перейти в +`DOCUMENTS_PROCESSING` (`changed_by` = загрузивший) в отдельной транзакции после приёма файлов. Блокировка версии +для загрузки MUST NOT требоваться. #### Scenario: Реальная ОСВ принята и ушла в outbox - **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала @@ -38,6 +43,14 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → - **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала - **THEN** 404, объектов и записей нет +#### Scenario: Загрузка в контексте версии +- **WHEN** версия в `INITIATED`, ЮЛ в периметре, загружен один принятый файл с `?consolidation_version_id=` +- **THEN** 201; версия в `DOCUMENTS_PROCESSING`, запись истории с `changed_by` = пользователь + +#### Scenario: ЮЛ вне периметра +- **WHEN** `consolidation_version_id` указан, ЮЛ не в периметре версии +- **THEN** 422 `ENTITY_NOT_IN_PERIMETER`, файлы не приняты + ### Requirement: Дедуп по содержимому Файл с тем же sha256, что у существующего `stored_file` того же ЮЛ со статусом не `SUPERSEDED`, SHALL отклоняться с кодом `DUPLICATE_FILE` и `file_id` существующего. Тот же файл у другого ЮЛ SHALL приниматься. @@ -65,15 +78,22 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → документ с теми же `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`. +Привязки `consolidation_document` документов заменённых файлов к версиям «в работе» SHALL удаляться в той же +транзакции (версии блокируются `FOR UPDATE` по возрастанию `id` после старых файлов); привязки к версиям в других +статусах не трогаются; статус версии не меняется. Аудит `FILE_REPLACE` (`object_id` заменённого, `after_value` с +новым файлом и `unbound_version_ids`, в `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`; список файлов ЮЛ по умолчанию показывает только новый +#### Scenario: Заменённый документ был в версии +- **WHEN** заменённый `CONFIRMED` документ ОСВ 60 за 2024 Q2 был привязан к версии 2024 Q3 в `DATA_CORRECTION` +- **THEN** привязка удалена; в чек-листе ячейка ОСВ 60 показывает `missing_quarters = ["Q2"]` (или `NEEDS_CHECK` + по новому документу), пока новый документ не подтверждён; статус версии прежний + ### Requirement: Удаление файла `DELETE /api/v1/files/{file_id}` (`DOCUMENT_EDIT`, policy) SHALL удалить строки ОСВ, документы, задания и `stored_file`, проставить `s3_object.expires_at = now()` (объект физически не удаляется, OQ-22) и записать `audit_event(FILE_DELETE,