Compare commits

...

22 Commits

Author SHA1 Message Date
Andrey Epifancev
fdb1f179cc docs(openspec): elimination-pairs-rule-totals — дельта вместо правки основной спеки, решения, README (ревью #175)
Основная спека consolidations-api возвращена к main: фиксация версии правил,
пары тройки и список версии — в дельте change (MODIFIED «Создание
консолидации», ADDED «Пары ВГО тройки», «Пары ВГО версии», «Правила
элиминации зафиксированной версии», REMOVED старое требование правил);
data-model — DBML 5.23 и миграция 0010. design.md — решения владельца 23.10
и «Decisions made without discussion» R-1…R-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:44:31 +04:00
Andrey Epifancev
b4522addc0 feat: DBML v5.23 — версия правил обязательна; список пар ВГО версии (ревью #175, AURORA-1536)
- consolidation_version.elimination_rules_version NOT NULL: миграция 0010
  проставляет существующим версиям номер правил, действовавших на дату
  создания (по Москве), иначе последних открытых; без правил — отказ;
  создание версии без действующих правил — 409 ELIMINATION_RULES_MISSING;
  ветки «правила не зафиксированы» убраны (rules, preset, is_version_fixed
  всегда true);
- GET …/elimination-totals: все тройки «правило ББ × упорядоченная пара
  периметра» с ΣA, ΣB, расхождением, суммой элиминации, статусом
  AUTO/DISCREPANCY/ONE_SIDED/MANUAL и osv_entry_ids; сводная ОСВ строится
  один раз; сбор строк сторон вынесен в _collect_rule_sides — общий
  с упаковкой тройки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:42:10 +04:00
Andrey Epifancev
812e530fe5 style: ruff format elimination_packing (ревью #175)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:42:10 +04:00
Andrey Epifancev
888ad4b401 Merge origin/main в AURORA-1536 (ревью #175)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:34:35 +04:00
aep
1b3f85c88c Merge pull request 'Архив OpenSpec retry-recognition-and-edit-osv-header (PR #161)' (#176) from chore/161-archive-retry-recognition into main 2026-10-23 17:20:23 +03:00
Andrey Epifancev
70c5743c77 Архив OpenSpec retry-recognition-and-edit-osv-header (PR #161)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:20:08 +04:00
aep
4d3e596337 Merge pull request 'AURORA-1442: ручки возобновления обработки файла или версии консолидации, ручка обновления данных по осв' (#161) from feat/renew-processing-timeout into main 2026-10-23 17:19:20 +03:00
Andrey Epifancev
3bc71a912d refactor(retry): версии уже отобраны по статусу в запросе (ревью #161, раунд 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:19:08 +04:00
Andrey Epifancev
b5520696ef chore: DBML v5.22 — версия ORM и ссылки (ревью #161, AURORA-1446)
v5.22 структуру БД не меняет (comment.object_type: убраны chess_cell и
kfo_result_line из описания), миграция не нужна.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:59:35 +04:00
Andrey Epifancev
e13cd88fea Merge origin/main в feat/renew-processing-timeout (ревью #161, раунд 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:59:00 +04:00
aep
f2c70935b2 Merge pull request 'fix(api): CORS открывает фронту Content-Disposition (#173, AURORA-1532)' (#174) from feat/173-cors-expose-content-disposition into main 2026-10-23 16:19:03 +03:00
Andrey Epifancev
f3abc8c01c fix(api): CORS открывает фронту Content-Disposition (#173, AURORA-1532)
При фронте на другом источнике (web :3000, api :8000) браузер не отдавал JS
заголовок Content-Disposition, и экспорт сводной ОСВ, шахматки, таблицы ВГО
и CSV чек-листа сохранялся под запасным именем (qa-guide W35).
expose_headers=["Content-Disposition"] в обоих режимах cors_options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:54:23 +04:00
aep
5ba3bc9971 Merge pull request 'Архив OpenSpec document-switcher-history (PR #171)' (#172) from chore/171-archive-document-switcher-history into main 2026-10-23 15:29:38 +03:00
Andrey Epifancev
013b04d4c3 Архив OpenSpec document-switcher-history (PR #171)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:29:20 +04:00
aep
8dfa068c6d Merge pull request 'fix: AURORA-1692 - кнопка 'Назад' при просмотре документов' (#171) from AURORA-1692 into main 2026-10-23 15:27:34 +03:00
Andrey Epifancev
21f40bc9bf docs(openspec): переключатель документа без записи в истории (AURORA-1692)
Change document-switcher-history: дельта document-review-ui — ADDED
«Переключатель документа» (состав списка, выбор заменой записи истории,
«Назад» к экрану, с которого открыт первый документ).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:25:29 +04:00
maxim
1197a94f3c fix: AURORA-1692 - кнопка 'Назад' при просмотре документов 2026-10-23 14:37:27 +03:00
622a60e4e0 AURORA-1442: Правки по раунду 1 2026-10-23 13:39:30 +03:00
Andrey Epifancev
98f00c0faa docs: change retry-recognition-and-edit-osv-header, README и CLAUDE.md — DBML v5.21 (ревью #161)
Дельта data-model переименована на v5.21, пакет — только файлы в ERROR,
tasks — ссылки на docs PR #157 и задачи ревью; config.yaml — DBML v5.21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:13:41 +04:00
Andrey Epifancev
b32383eff7 test: синтетические тесты уточнения шапки ОСВ и пакетного повтора (ревью #161, AURORA-1442)
PATCH osv-header без fixtures: порядок ошибок, no-op, original_values, сброс
подтверждения и отвязка, аудит, DUPLICATE_ACCOUNT, неизменность osv_entry.
Пакет: смешанный (OCR и исчерпанный лимит в skipped), NO_ACK на пределе.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:13:41 +04:00
Andrey Epifancev
5c70fb5387 test: .env.test — порт PostgreSQL 5432 по compose (ревью #161, AURORA-1442)
Свой порт задаётся переменной OPENBAO__DATABASE__DATABASE_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:13:41 +04:00
4f44d34d59 AURORA-1442: ручки возобновления обработки файла или версии консолидации, ручка обновления данных по осв 2026-10-22 14:25:19 +03:00
76 changed files with 3416 additions and 259 deletions

View File

@ -175,7 +175,7 @@
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
## Правила репозитория
- ORM 1:1 с `../docs/model/data-model-v5.19.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- ORM 1:1 с `../docs/model/data-model-v5.23.dbml` (текущая версия; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
- Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и

View File

@ -3,7 +3,7 @@ VENV := api/.venv
BIN := $(CURDIR)/$(VENV)/bin
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
# DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/
DBML ?= ../../docs/model/data-model-v5.19.dbml
DBML ?= ../../docs/model/data-model-v5.23.dbml
.PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check statements-check check-full up migrate seed seed-dev

View File

@ -38,6 +38,71 @@ PostgreSQL 16, core NATS без JetStream), `web/` — фронтенд (React +
статусы, привязка подтверждённых документов к версии, чек-лист, сводная ОСВ, ручное сопоставление контрагента, экспорт xlsx.
Спецификация — `openspec/changes/slice-4-consolidation/`; раздел ниже: «Консолидация (Срез 4)».
## Повторная обработка и уточнение ОСВ (AURORA-1442, DBML v5.20–v5.21)
Все маршруты ниже требуют `DOCUMENT_EDIT` и видимость ЮЛ; чужой объект отвечает тем же 404, что несуществующий.
| Запрос (префикс `/api/v1`) | Успех | Назначение |
|---|---|---|
| `POST /files/{file_id}/retry` | 202 `FileOut` | Новая попытка обработки одного файла |
| `POST /consolidations/versions/{version_id}/files/retry` | 202 `{accepted, skipped}` | Подходящие файлы консолидации по периметру выбранной версии |
| `PATCH /documents/{document_id}/osv-header` | 200 `DocumentOut` | Уточнение периода и счёта ОСВ |
Ручной повтор разрешён для файла `ERROR`, только если его текущее задание `tabular` завершилось
`timeout/TIMEOUT` или `dlq/NO_ACK`, документов нет и `attempt < max_attempts`. Создаётся новый job
`queued`, `attempt + 1`; файл становится `UPLOADED`, исходный S3-объект сохраняется, старый job остаётся
в истории. NATS не требуется в момент запроса: публикация выполняется существующим outbox после commit.
OCR, `error` сервиса и ошибки маппинга не повторяются. Ошибки: 409 `FILE_NOT_RETRYABLE`,
`MAX_ATTEMPTS_REACHED`, `VERSION_NOT_EDITABLE`.
Пакет включает только файлы `ERROR` с `stored_file.consolidation_id` выбранной консолидации и ЮЛ из периметра выбранной
версии; файлы другой консолидации и загрузки без контекста не включаются. Версия должна быть «в работе»
(`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), пользовательская блокировка не нужна.
Версии одной консолидации могут видеть общий файл. Другие статусы файлов не блокируются и не попадают
в ответ. Неподходящие `ERROR`-файлы пропускаются:
`accepted: [{file_id, job_id, attempt}]`, `skipped: [{file_id, code}]`. Пустой пакет также отвечает 202.
Для одиночного файла консолидации требуется хотя бы одна рабочая версия с ЮЛ файла в периметре.
Повтор не меняет статусы версий. Два конкурентных запроса не создают две новые попытки.
`PATCH …/osv-header` принимает любые из четырёх полей; отсутствующие не меняются. Пример:
```json
{"period_year": 2025, "period_quarter": "Q3", "is_cumulative": true, "osv_account_code": "60.1"}
```
Типы тела строгие: `period_year` — integer, `period_quarter` и `osv_account_code` — string,
`is_cumulative` — boolean; приведения строк и чисел нет. Неверный тип — стандартный 422 валидации
до доменных проверок. Явный `null` передаётся в домен, отсутствующие поля не меняются.
Счёт нормализуется (`60.1` → `60.01`). Если передано поле периода, итоговые три поля периода обязательны:
год 1900–2099, квартал Q1–Q4, boolean; у Q1 `is_cumulative=true`. Правка только счёта неполный период не трогает.
Новые значения сохраняются в прежнем `document`; перед первой фактической правкой все четыре исходных
значения сохраняются в `original_values`, неизменном при следующих правках. `is_modified` показывает отличие
текущих значений от оригинала, `modified_by/at` — последнюю правку. Возврат к оригиналу снимает признак,
но сохраняет снимок. Файл, JSON результата, `file_ordinal` и строки `osv_entry` не меняются.
Правка `CONFIRMED` сбрасывает статус в `NEEDS_CHECK` и очищает `confirmed_by/at`; привязки к рабочим версиям
снимаются, `CANCELLED` сохраняются, статусы версий не меняются. Нужно новое ручное подтверждение;
автоподтверждения и автозамены файлов нет. Колонка «ОСВ», подходящесть версиям и чек-лист читают актуальные
поля; `period_check/counterparty_check` не меняются. Флаг `CONSOLIDATION_PERIOD_FROM_VERSION` остаётся.
Запрос без фактических изменений — 200 без аудита, снимка и сброса подтверждения.
Ошибки PATCH по порядку: 409 `DOCUMENT_NOT_OSV`, `FILE_SUPERSEDED`; 422 `INVALID_PERIOD`,
`INVALID_ACCOUNT_CODE`; 409 `DUPLICATE_ACCOUNT` (`detail.document_id`); 409 `VERSION_NOT_EDITABLE`
(`detail.version_ids`). Последняя означает привязку к версии вне рабочих статусов и `CANCELLED`.
Аудит: `FILE_REPROCESS`, раздел `PRIMARY_DOCUMENTS`, объект `stored_file`, на каждый принятый файл.
`before_value={job_id, attempt, status, error_code}` описывает прежнее задание;
`after_value={job_id, attempt}` — новое; для пакета добавляется `consolidation_id`.
Пропущенные файлы и автоматические republish таких событий не пишут.
Правка ОСВ — одно `RECOGNIZED_DATA_EDIT` (`RECOGNITION`, `document`) с изменёнными полями,
сброшенным подтверждением и `after_value.unbound_version_ids`. Аудит и мутация фиксируются вместе.
Поставка: сначала `0008_dbml_v5_20` (четыре поля document), затем `0009_dbml_v5_21` (только enum
`FILE_REPROCESS`), затем приложение. Итоговая сверка — DBML v5.21. Откат 0009 → 0008 запрещён при
наличии `FILE_REPROCESS` в аудите; откат 0008 → 0007 — при `document.is_modified=true`.
При возврате к исходным значениям признак false, откат v5.20 удалит снимок; файл/JSON в S3 и аудит сохраняются.
UI повторной обработки и правки ОСВ поставляется отдельными задачами.
## Предварительные требования
- **Python 3.11** (версия платформы) и **[`uv`](https://docs.astral.sh/uv/)** — `make venv` создаёт окружение и ставит
@ -875,8 +940,9 @@ API: список счетов `GET /directories/accounts`, правила ве
`GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`.
Режим ALL — только балансовые счета (без 001–012), MANUAL принимает и забалансовые. `one_sided` — ровно одна сумма
пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651).
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии
`consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату
создания, OQ-174) и с DBML v5.23 есть всегда, `is_version_fixed = true`. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на
`_rule-balance-<rule_no>.xlsx`; в шапке — номер, статьи и версия правила, строка «Показатель»,
audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `rule_id`, `rule_no` и фактический
@ -886,6 +952,17 @@ audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `r
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.
Расчёт читает сводную ОСВ версии, результат не хранится.
**Пары ВГО §5 (AURORA-1536, PR #175).** `GET …/elimination-pairs?rule_id=&company_a_id=&company_b_id=` — итог тройки
(ΣA, ΣB со знаком, расхождение, процент, `eliminated_amount` по правилу 5 %) и упаковка строк 1:1 / 1:N / N:1
(`matched`, `with_discrepancy`, `unpaired` с причиной); `GET …/elimination-totals` — итоги всех троек версии (правила ББ ×
упорядоченные пары периметра) со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`, сводная ОСВ строится один раз.
Каскада по остатку нет: строка общего счёта входит в несколько правил до разнесения (AURORA-1751). Ошибки: 404 (версия,
правило не из версии правил), 422 `INVALID_COMPANY_PAIR`, `INVALID_RULE`, `ACCOUNTS_REQUIRED`. Решения —
`openspec/changes/elimination-pairs-rule-totals/design.md`; контракт — `../docs/dev/frontend-api-guide.md` §5.7.2.
Поставка DBML v5.23: миграция `0010_dbml_v5_23` проставляет пустой `elimination_rules_version` по дате создания версии и
делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён
раньше. Откат снимает только NOT NULL.
## Ошибки API
Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом:
@ -966,6 +1043,7 @@ email без учёта регистра проверяет только сер
| `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID |
| `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов |
| `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) |
| `ELIMINATION_RULES_MISSING` | 409 | `POST /consolidations`: нет правил элиминации, действующих на дату создания (DBML v5.23: версия правил обязательна) |
| `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании без списка), в выбранных ЮЛ (`legal_entity_ids` при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется |
| `RELATED_GROUP_MISMATCH` | 422 | ГСК принадлежит другой ГК или имеет вид `gsz` (невидимая/неизвестная ГСК — 404 `NOT_FOUND`) |
| `ENTITY_NOT_IN_GROUP` | 422 | `POST /consolidations` с `legal_entity_ids` и `PUT …/members`: ЮЛ не из ГК консолидации, без ГК или несуществующее (`legal_entity_ids` — перечень); проверка раньше `PERIMETER_TOO_SMALL` |
@ -976,6 +1054,7 @@ email без учёта регистра проверяет только сер
| `ACCOUNT_KIND_MISMATCH` | 422 | MANUAL шахматки или её экспорта: вид счёта не подходит дебетовой/кредитовой стороне |
| `ACCOUNTS_REQUIRED` | 422 | сервис шахматки: не выбран дебетовый или кредитовый набор; через HTTP пустые массивы отклоняются раньше валидацией тела |
| `RESULT_ACCOUNTS_MISSING` | 422 | результирующая таблица ВГО: в плане счетов нет обязательных кодов статей |
| `INVALID_COMPANY_PAIR` | 422 | пары ВГО тройки: компании совпадают или не входят в периметр версии |
| `ACCOUNTS_NOT_ALLOWED` / `INVALID_MODE` | 422 | защита внутреннего вызова сервиса шахматки; маршруты ALL/MANUAL не дают вызвать эти ветки |
| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) |
| `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки) либо `CANCELLED` при «взять в работу» |
@ -1004,7 +1083,7 @@ email без учёта регистра проверяет только сер
| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно |
| `DB_SCHEMA` | `dkb` | схема PostgreSQL модели данных: MetaData ORM, `search_path` движка приложения, `CREATE SCHEMA` и таблица версий Alembic, схема в миграциях. Имя — строчный идентификатор `[a-z_][a-z0-9_]{0,62}`, не `pg_*` (подставляется в DDL без кавычек; иное — ошибка при старте). **Одно значение для приложения и для `alembic upgrade`:** сменить схему у уже мигрированной БД нельзя (таблицы остаются в старой); в контуре — `OPENBAO__APP__DB_SCHEMA`, в миграционном задании тоже. Читается при импорте моделей, поэтому скрипты и тесты видят его через те же `.env`/окружение |
| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) |
| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны) |
| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны). В обоих режимах фронту открыт заголовок `Content-Disposition` (`Access-Control-Expose-Headers`) — имя файла экспорта (#173) |
| `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) |
| `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) |
| `VERSION` | `1.0.0` | версия в `/healthcheck` и OpenAPI |
@ -1062,7 +1141,7 @@ email без учёта регистра проверяет только сер
| `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен |
| `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) |
| `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.19.dbml` |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.23.dbml` |
| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
| `make check` | быстрая проверка без Docker: `lint` + `test` |
| `make docker-build` | сборка образа: `docker build -t arm-core .` |
@ -1114,7 +1193,7 @@ queue group `arm-core`, подписчики группы получают со
### Проверка ORM и DBML
Модель данных — `../docs/model/data-model-v5.19.dbml`. ORM обязана совпадать с ней один в один:
Модель данных — `../docs/model/data-model-v5.23.dbml`. ORM обязана совпадать с ней один в один:
```bash
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
@ -1137,7 +1216,7 @@ make migration-cycle # проверить цикл upgr
```
api/
run_server.py точка входа (uvicorn; хост и порт — из настроек)
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19)
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21, 0010_dbml_v5_23.py — v5.23; v5.22 структуру не меняла)
rules/ statement_lines.yaml — справочник имён строк форм ББ/ОФР по редакциям (словарь проверок §7 — в пакете arm_statements)
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
seed/dev/ dev-данные: филиалы и пользователи по ролям
@ -1221,7 +1300,7 @@ Dockerfile только для локального compose
- [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы;
- [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3;
- [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и
[`../docs/model/data-model-v5.19.dbml`](../docs/model/data-model-v5.19.dbml) — модель данных;
[`../docs/model/data-model-v5.23.dbml`](../docs/model/data-model-v5.23.dbml) — модель данных;
- [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области;
- [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы;
- [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов;

View File

@ -0,0 +1,28 @@
"""DBML v5.20: исходная шапка ОСВ рядом с исправленными данными."""
from alembic import op
from src.models.base import SCHEMA
revision = "0008_dbml_v5_20"
down_revision = "0007_dbml_v5_19"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(f"ALTER TABLE {SCHEMA}.document ADD COLUMN is_modified boolean NOT NULL DEFAULT false")
op.execute(f"ALTER TABLE {SCHEMA}.document ADD COLUMN original_values jsonb")
op.execute(
f"ALTER TABLE {SCHEMA}.document ADD COLUMN modified_by uuid "
f"CONSTRAINT fk_document_modified_by_app_user REFERENCES {SCHEMA}.app_user(id)"
)
op.execute(f"ALTER TABLE {SCHEMA}.document ADD COLUMN modified_at timestamptz")
def downgrade() -> None:
op.execute(f"""DO $$ DECLARE n bigint; BEGIN
SELECT count(*) INTO n FROM {SCHEMA}.document WHERE is_modified;
IF n > 0 THEN RAISE EXCEPTION 'откат v5.20 невозможен: исправленные документы: %', n; END IF;
END $$""")
for name in ("modified_at", "modified_by", "original_values", "is_modified"):
op.execute(f"ALTER TABLE {SCHEMA}.document DROP COLUMN {name}")

View File

@ -0,0 +1,28 @@
"""DBML v5.21: FILE_REPROCESS; аудит не удаляется при откате."""
from alembic import op
from src.models.base import SCHEMA
revision = "0009_dbml_v5_21"
down_revision = "0008_dbml_v5_20"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE 'FILE_REPROCESS' AFTER 'FILE_REPLACE'")
def downgrade() -> None:
op.execute(f"""DO $$ DECLARE n bigint; labels text; BEGIN
SELECT count(*) INTO n FROM {SCHEMA}.audit_event WHERE action::text = 'FILE_REPROCESS';
IF n > 0 THEN RAISE EXCEPTION 'откат v5.21 невозможен: события FILE_REPROCESS: %', n; END IF;
SELECT string_agg(quote_literal(e.enumlabel), ',' ORDER BY e.enumsortorder) INTO labels
FROM pg_enum e JOIN pg_type t ON t.oid=e.enumtypid JOIN pg_namespace ns ON ns.oid=t.typnamespace
WHERE ns.nspname='{SCHEMA}' AND t.typname='audit_action' AND e.enumlabel <> 'FILE_REPROCESS';
ALTER TYPE {SCHEMA}.audit_action RENAME TO audit_action_v5_21;
EXECUTE 'CREATE TYPE {SCHEMA}.audit_action AS ENUM (' || labels || ')';
ALTER TABLE {SCHEMA}.audit_event ALTER COLUMN action TYPE {SCHEMA}.audit_action
USING action::text::{SCHEMA}.audit_action;
DROP TYPE {SCHEMA}.audit_action_v5_21;
END $$""")

View File

@ -0,0 +1,28 @@
"""DBML v5.23: consolidation_version.elimination_rules_version NOT NULL; пустые — по дате создания (§4, OQ-174)."""
from alembic import op
from src.models.base import SCHEMA
revision = "0010_dbml_v5_23"
down_revision = "0009_dbml_v5_21"
branch_labels = None
depends_on = None
def upgrade() -> None:
# действующая на дату создания (по Москве), иначе последняя открытая; без правил версия не заполнится — отказ ниже
created = "(COALESCE(v.created_at, now()) AT TIME ZONE 'Europe/Moscow')::date"
op.execute(f"""UPDATE {SCHEMA}.consolidation_version v SET elimination_rules_version = COALESCE(
(SELECT max(r.version_no) FROM {SCHEMA}.elimination_rule r
WHERE r.valid_from <= {created} AND (r.valid_to IS NULL OR r.valid_to >= {created})),
(SELECT max(r.version_no) FROM {SCHEMA}.elimination_rule r WHERE r.valid_to IS NULL))
WHERE v.elimination_rules_version IS NULL""")
op.execute(f"""DO $$ DECLARE n bigint; BEGIN
SELECT count(*) INTO n FROM {SCHEMA}.consolidation_version WHERE elimination_rules_version IS NULL;
IF n > 0 THEN RAISE EXCEPTION 'миграция v5.23 невозможна: нет правил элиминации для версий консолидации: %', n;
END IF; END $$""")
op.alter_column("consolidation_version", "elimination_rules_version", nullable=False, schema=SCHEMA)
def downgrade() -> None:
op.alter_column("consolidation_version", "elimination_rules_version", nullable=True, schema=SCHEMA)

View File

@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement
#: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением.
SCHEMA = "dkb"
DBML_FILE_NAME = "data-model-v5.19.dbml"
DBML_FILE_NAME = "data-model-v5.23.dbml"
ORM = "ORM"
DBML = "DBML"
ABSENT = "нет"

View File

@ -9,6 +9,8 @@ from src.api.dependencies.db import SessionDep
from src.api.dependencies.directories import SettingsDep, get_scope
from src.domain.policy import VisibilityScope
from src.services.documents import DocumentService
from src.services.file_retry import FileRetryService
from src.services.osv_header import OsvHeaderService
from src.services.primary_files import PrimaryFilesService
from src.services.statements import StatementService
from src.services.statements_engine import StatementsEngine, default_engine
@ -60,3 +62,26 @@ def get_statement_service(
StatementServiceDep = Annotated[StatementService, Depends(get_statement_service)]
def get_file_retry_service(
session: SessionDep,
app_settings: SettingsDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
scope: Annotated[VisibilityScope, Depends(get_scope)],
) -> FileRetryService:
return FileRetryService(session, audit, scope, app_settings)
FileRetryServiceDep = Annotated[FileRetryService, Depends(get_file_retry_service)]
def get_osv_header_service(
session: SessionDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
scope: Annotated[VisibilityScope, Depends(get_scope)],
) -> OsvHeaderService:
return OsvHeaderService(session, audit, scope)
OsvHeaderServiceDep = Annotated[OsvHeaderService, Depends(get_osv_header_service)]

View File

@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, status
from src.api.dependencies.auth import require
from src.api.dependencies.consolidations import EliminationRuleServiceDep, IntercompanyMatrixServiceDep
from src.schemas.common import ErrorDetail
from src.schemas.consolidations import EliminationPairsOut, EliminationRulesOut
from src.schemas.consolidations import EliminationPairsOut, EliminationRulesOut, EliminationTotalsOut
router = APIRouter(prefix="/consolidations/versions")
@ -37,6 +37,21 @@ async def list_rules(
return await service.list_rules(version_id)
@router.get(
"/{version_id}/elimination-totals",
response_model=EliminationTotalsOut,
dependencies=VIEW,
responses=PAIRS_RESPONSES,
summary="Пары ВГО версии: итоги всех троек",
)
async def build_elimination_totals(
version_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
) -> EliminationTotalsOut:
"""Правила ББ × упорядоченные пары компаний периметра: суммы сторон, расхождение, элиминация, статус (§5)."""
return await service.build_totals(version_id)
@router.get(
"/{version_id}/elimination-pairs",
response_model=EliminationPairsOut,

View File

@ -2,7 +2,7 @@
from fastapi import APIRouter
from src.api.endpoints.files import comments, consolidation_files, documents, read, statements, upload
from src.api.endpoints.files import comments, consolidation_files, documents, read, retry, statements, upload
router = APIRouter(tags=["files"])
router.include_router(upload.router)
@ -11,3 +11,5 @@ router.include_router(consolidation_files.router)
router.include_router(documents.router)
router.include_router(comments.router)
router.include_router(statements.router)
router.include_router(retry.router)

View File

@ -11,9 +11,9 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require
from src.api.dependencies.files import DocumentServiceDep
from src.api.dependencies.files import DocumentServiceDep, OsvHeaderServiceDep
from src.schemas.common import ErrorDetail, Page
from src.schemas.documents import DocumentOut, EntriesPageParams, EntryOut
from src.schemas.documents import DocumentOut, EntriesPageParams, EntryOut, OsvHeaderPatch
router = APIRouter(prefix="/documents")
@ -70,3 +70,22 @@ async def list_entries(
async def confirm_document(document_id: uuid.UUID, service: DocumentServiceDep) -> DocumentOut:
"""`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`; аудит `DOCUMENT_CONFIRM`."""
return await service.confirm(document_id)
@router.patch(
"/{document_id}/osv-header",
summary="Уточнить период и счёт ОСВ",
response_model=DocumentOut,
dependencies=EDIT,
responses={
404: NOT_FOUND[status.HTTP_404_NOT_FOUND],
409: {
"model": ErrorDetail,
"description": "DOCUMENT_NOT_OSV, FILE_SUPERSEDED, DUPLICATE_ACCOUNT, VERSION_NOT_EDITABLE",
},
422: {"model": ErrorDetail, "description": "INVALID_PERIOD, INVALID_ACCOUNT_CODE"},
},
)
async def patch_osv_header(document_id: uuid.UUID, body: OsvHeaderPatch, service: OsvHeaderServiceDep) -> DocumentOut:
"""Уточнить период/счёт ОСВ, сохранив исходные значения и сняв подтверждение."""
return await service.patch(document_id, body)

View File

@ -0,0 +1,40 @@
"""AURORA-1442: ручной повтор файла/пакета."""
import uuid
from typing import Any
from fastapi import APIRouter, Depends
from src.api.dependencies.auth import require
from src.api.dependencies.files import FileRetryServiceDep
from src.schemas.common import ErrorDetail
from src.schemas.files import FileOut, RetryBatchOut
router = APIRouter(dependencies=[Depends(require("DOCUMENT_EDIT"))])
FILE_RESPONSES: dict[int | str, dict[str, Any]] = {
404: {"model": ErrorDetail, "description": "Нет или вне видимости"},
409: {"model": ErrorDetail, "description": "FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED, VERSION_NOT_EDITABLE"},
}
BATCH_RESPONSES: dict[int | str, dict[str, Any]] = {
404: {"model": ErrorDetail, "description": "Нет или вне видимости"},
409: {"model": ErrorDetail, "description": "VERSION_NOT_EDITABLE"},
}
@router.post("/files/{file_id}/retry", response_model=FileOut, status_code=202, responses=FILE_RESPONSES)
async def retry_file(file_id: uuid.UUID, service: FileRetryServiceDep) -> FileOut:
"""Новая попытка tabular после TIMEOUT или NO_ACK, до max_attempts."""
return await service.retry(file_id)
@router.post(
"/consolidations/versions/{version_id}/files/retry",
response_model=RetryBatchOut,
status_code=202,
responses=BATCH_RESPONSES,
)
async def retry_version_files(version_id: uuid.UUID, service: FileRetryServiceDep) -> RetryBatchOut:
"""Повтор подходящих файлов консолидации по периметру выбранной версии."""
return await service.retry_version(version_id)

View File

@ -203,19 +203,24 @@ async def lifespan(app: FastAPI):
await _shutdown(app)
#: заголовки ответа, которые браузер отдаёт JS с другого источника: имя файла экспорта (xlsx, csv, скан)
EXPOSE_HEADERS = ["Content-Disposition"]
def cors_options(app_settings: Settings) -> dict[str, Any] | None:
"""Источники CORS (OQ-65): DEBUG — `*` без credentials (ревью R-11; токен — в заголовке, cookie не нужны);
иначе — `CORS_ORIGINS`.
Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит.
`allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials).
`Content-Disposition` открыт фронту: без него имя файла экспорта с другого источника недоступно (arm-core #173).
"""
if app_settings.DEBUG:
return {"allow_origins": ["*"], "allow_credentials": False}
return {"allow_origins": ["*"], "allow_credentials": False, "expose_headers": EXPOSE_HEADERS}
origins = list(app_settings.CORS_ORIGINS)
if not origins:
return None
return {"allow_origins": origins, "allow_credentials": "*" not in origins}
return {"allow_origins": origins, "allow_credentials": "*" not in origins, "expose_headers": EXPOSE_HEADERS}
#: пути документации API: при DEBUG=false маршрутов нет, а SPA-заглушка отвечает на них 404, а не index.html

View File

@ -57,9 +57,9 @@ class RuleTotals:
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_amount: Decimal
deviation_percent: Decimal | None
eliminated_amount: Decimal
eliminated_amount: Decimal
exceeds_threshold: bool

View File

@ -0,0 +1,34 @@
"""Проверка итоговой шапки ОСВ, §9.5 п.12; отсутствующее поле не меняется."""
from collections.abc import Mapping
from src.common.exceptions import DomainValidationError
from src.domain.osv_mapping import normalize_account_code
from src.models.enums import PeriodQuarter
HEADER_FIELDS = ("period_year", "period_quarter", "is_cumulative", "osv_account_code")
MIN_YEAR = 1900
MAX_YEAR = 2099
def corrected_header(current: Mapping[str, object], patch: Mapping[str, object]) -> dict[str, object]:
"""Проверить и нормализовать итог, не изменяя входные отображения."""
result = dict(current)
result.update(patch)
if any(key in patch for key in HEADER_FIELDS[:3]):
year, quarter, cumulative = (result[key] for key in HEADER_FIELDS[:3])
if (
type(year) is not int
or not MIN_YEAR <= year <= MAX_YEAR
or not isinstance(quarter, str)
or quarter not in {q.value for q in PeriodQuarter}
or type(cumulative) is not bool
or (quarter == "Q1" and cumulative is not True)
):
raise DomainValidationError("INVALID_PERIOD", "Укажите год 1900–2099, квартал и признак нарастающего итога")
if "osv_account_code" in patch:
account = patch["osv_account_code"]
if not isinstance(account, str) or not account.strip():
raise DomainValidationError("INVALID_ACCOUNT_CODE", "Счёт ОСВ не может быть пустым")
result["osv_account_code"] = normalize_account_code(account)
return result

View File

@ -1,4 +1,4 @@
"""ORM-модели. Источник истины — docs/model/data-model-v5.19.dbml; заполняется в Срезе 0.
"""ORM-модели. Источник истины — docs/model/data-model-v5.23.dbml; заполняется в Срезе 0.
Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic).
"""
@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k
from .base import Base, UUIDPrimaryMixin
#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm).
DBML_VERSION = "5.19"
DBML_VERSION = "5.23"
__all__ = [
"DBML_VERSION",

View File

@ -67,7 +67,7 @@ class ConsolidationVersion(UUIDPrimaryMixin, Base):
uob_decision_no: Mapped[str | None] = mapped_column(String)
uob_decision_date: Mapped[datetime.date | None] = mapped_column(Date)
methodology_deviations: Mapped[str | None] = mapped_column(Text)
elimination_rules_version: Mapped[int | None] = mapped_column(Integer)
elimination_rules_version: Mapped[int] = mapped_column(Integer, nullable=False)
is_in_work: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
previous_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),

View File

@ -328,6 +328,7 @@ class AuditAction(enum.StrEnum):
FILE_UPLOAD = "FILE_UPLOAD"
FILE_DELETE = "FILE_DELETE"
FILE_REPLACE = "FILE_REPLACE"
FILE_REPROCESS = "FILE_REPROCESS"
DOCUMENT_CONFIRM = "DOCUMENT_CONFIRM"
CONSOLIDATION_LOCK = "CONSOLIDATION_LOCK"
CONSOLIDATION_LOCK_TAKEOVER = "CONSOLIDATION_LOCK_TAKEOVER"

View File

@ -137,6 +137,11 @@ class Document(UUIDPrimaryMixin, Base):
# v5.19: индекс документа в documents[] результата (с 0); несозданный документ номер не занимает
file_ordinal: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("0"))
is_modified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
original_values: Mapped[Any | None] = mapped_column(nullable_jsonb())
modified_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))
modified_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
class RecognitionJob(UUIDPrimaryMixin, Base):
"""Единственная точка обмена с NATS: запись = outbox (queued -> published), результат — inbox (дедуп)."""

View File

@ -11,21 +11,13 @@ from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRu
async def list_rules_with_accounts(
session: AsyncSession, version_no: int | None
session: AsyncSession, version_no: int
) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]:
"""Правила §4 заданной либо последней открытой версии."""
condition = (
EliminationRule.version_no == version_no
if version_no is not None
else (
EliminationRule.valid_to.is_(None)
& (EliminationRule.version_no == _current_open_version_stmt().scalar_subquery())
)
)
"""Правила §4 заданной версии."""
stmt = (
select(EliminationRule, EliminationRuleAccount)
.outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id)
.where(condition)
.where(EliminationRule.version_no == version_no)
.order_by(
EliminationRule.statement_type,
EliminationRule.rule_no,
@ -35,16 +27,6 @@ async def list_rules_with_accounts(
return [(rule, account) for rule, account in (await session.execute(stmt)).all()]
def _current_open_version_stmt():
"""Запрос номера последней открытой версии правил §4."""
return select(func.max(EliminationRule.version_no)).where(EliminationRule.valid_to.is_(None))
async def current_open_version_no(session: AsyncSession) -> int | None:
"""Номер последней версии с открытыми правилами §4."""
return await session.scalar(_current_open_version_stmt())
async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int | None:
"""Максимальный номер версии правил, действующей на дату создания"""
return await session.scalar(
@ -55,9 +37,9 @@ async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int
)
def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool:
"""Проверить версию правила §4 либо отсутствие `valid_to`."""
return rule is not None and (rule.version_no == version_no if version_no is not None else rule.valid_to is None)
def is_valid_for_version(rule: EliminationRule | None, version_no: int) -> bool:
"""Правило §4 из зафиксированной версии правил версии консолидации."""
return rule is not None and rule.version_no == version_no
async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]:

View File

@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.policy import VisibilityScope, legal_entities_visible
from src.models.access import AppUser
from src.models.audit_ex import Comment
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
from src.models.consolidation import ConsolidationDocument, ConsolidationMember, ConsolidationVersion
from src.models.directories import LegalEntity
from src.models.enums import DocumentStatus, FileStatus, S3BucketRole
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
@ -298,3 +298,22 @@ async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация
)
files = list((await session.execute(page_stmt)).scalars().all())
return files, total
async def retry_candidates(
session: AsyncSession, scope: VisibilityScope, consolidation_id: uuid.UUID, version_id: uuid.UUID
) -> list[StoredFile]:
"""ERROR-файлы консолидации по видимому периметру версии; без блокировки нормальных файлов."""
members = select(ConsolidationMember.legal_entity_id).where(
ConsolidationMember.consolidation_version_id == version_id
)
stmt = (
_visible_files(select(StoredFile), scope)
.where(
StoredFile.consolidation_id == consolidation_id,
StoredFile.legal_entity_id.in_(members),
StoredFile.status == FileStatus.ERROR,
)
.order_by(StoredFile.id)
)
return list((await session.scalars(stmt)).all())

View File

@ -388,6 +388,41 @@ class EliminationPairsOut(BaseModel):
unpaired: list[EliminationUnpairedRowOut]
EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"]
class EliminationTotalItemOut(BaseModel):
"""Итог тройки (правило, компания A, компания B) в списке версии (§5 п. 3)."""
rule_id: uuid.UUID
rule_no: int
elimination_mode: str
company_a_id: uuid.UUID
company_b_id: uuid.UUID
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_percent: Decimal | None
eliminated_amount: Decimal
exceeds_threshold: bool
status: EliminationTotalStatus
osv_entry_ids_a: list[uuid.UUID]
osv_entry_ids_b: list[uuid.UUID]
@field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent", "eliminated_amount")
@classmethod
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
"""Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null."""
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
class EliminationTotalsOut(BaseModel):
"""Все тройки версии консолидации по правилам ББ зафиксированной версии правил."""
version_no: int
items: list[EliminationTotalItemOut]
class IntercompanyMatrixManualIn(BaseModel):
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""

View File

@ -9,8 +9,9 @@
import datetime
import uuid
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr
from src.models.enums import (
CounterpartyMatchKind,
@ -54,6 +55,10 @@ class DocumentOut(BaseModel):
confirmed_at: datetime.datetime | None
group_schema: list[GroupOut] | None
warnings: list[str]
is_modified: bool = False
original_values: dict[str, Any] | None = None
modified_by: uuid.UUID | None = None
modified_at: datetime.datetime | None = None
comments_count: int = 0 # число комментариев документа (D-11)
issues: list[IssueOut] = Field(default_factory=list) # v5.19: записи `document_issue` документа (любого типа)
issue_counts: IssueCountsOut = Field(default_factory=IssueCountsOut) # P3-23
@ -93,3 +98,14 @@ class EntriesPageParams(BaseModel):
limit: int = Field(DEFAULT_ENTRIES_LIMIT, ge=1, le=MAX_ENTRIES_LIMIT)
offset: int = Field(0, ge=0)
class OsvHeaderPatch(BaseModel):
"""Строгие типы проверяет Pydantic, допустимость значений — домен."""
model_config = ConfigDict(extra="forbid")
period_year: StrictInt | None = None
period_quarter: StrictStr | None = None
is_cumulative: StrictBool | None = None
osv_account_code: StrictStr | None = None

View File

@ -109,3 +109,19 @@ class FileOut(BaseModel):
consolidation_id: uuid.UUID | None # v5.18: консолидация, в контексте которой загружен файл; None — без контекста
job: JobBrief | None
documents: list[DocumentBrief]
class RetryAccepted(BaseModel):
file_id: uuid.UUID
job_id: uuid.UUID
attempt: int
class RetrySkipped(BaseModel):
file_id: uuid.UUID
code: str
class RetryBatchOut(BaseModel):
accepted: list[RetryAccepted]
skipped: list[RetrySkipped]

View File

@ -332,6 +332,10 @@ class ConsolidationService:
"""Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы."""
now = utc_now()
user = self._principal.user
# версия правил §4 — действующая на дату создания (OQ-174), с v5.23 обязательна
rules_version = await elimination_rules_repo.latest_effective_version_no(self._session, moscow_date(now))
if rules_version is None:
raise ConflictError("ELIMINATION_RULES_MISSING", "Нет правил элиминации, действующих на дату создания")
consolidation = Consolidation(
company_group_id=group_id,
year=data.year,
@ -348,9 +352,7 @@ class ConsolidationService:
scenario=ConsolidationScenario.CREATE,
approach=ConsolidationApproach.BASIC,
goodwill_enabled=False,
elimination_rules_version=await elimination_rules_repo.latest_effective_version_no(
self._session, moscow_date(now)
),
elimination_rules_version=rules_version,
initiator_id=user.id,
initiator_office_id=user.regional_office_id,
is_in_work=True,

View File

@ -177,6 +177,10 @@ def document_out( # noqa: PLR0913, PLR0917 - карточка собирает
group_schema=_group_schema(job, document.locator),
# ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла
warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)),
is_modified=document.is_modified,
original_values=document.original_values,
modified_by=document.modified_by,
modified_at=document.modified_at,
comments_count=comments_count,
issues=list(issues),
issue_counts=_counts_out(issue_counts),

View File

@ -26,11 +26,9 @@ class EliminationRuleService:
if found is None:
raise NotFoundError(VERSION_NOT_FOUND)
version, _ = found
is_version_fixed = version.elimination_rules_version is not None
# с v5.23 версия правил фиксируется при создании версии консолидации и не бывает пустой
version_no = version.elimination_rules_version
rows = await rules_repo.list_rules_with_accounts(self._session, version_no)
if not is_version_fixed and rows:
version_no = rows[0][0].version_no
by_id: dict[uuid.UUID, EliminationRuleOut] = {}
for rule, account in rows:
result = by_id.get(rule.id)
@ -56,6 +54,6 @@ class EliminationRuleService:
return EliminationRulesOut(
version_no=version_no,
is_version_fixed=is_version_fixed,
is_version_fixed=True,
rules=list(by_id.values()),
)

View File

@ -0,0 +1,187 @@
"""Ручные новые попытки tabular; очередь и исходный объект S3 переиспользуются."""
import uuid
from typing import Any
from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, NotFoundError
from src.contract import CONTRACT_VERSION, request_subject
from src.core.config import Settings, get_settings
from src.domain.policy import IN_WORK_STATUSES, VisibilityScope
from src.models.consolidation import ConsolidationMember, ConsolidationVersion
from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionPipeline, RecognitionStatus
from src.models.files import Document, RecognitionJob, StoredFile
from src.repositories import consolidations as versions_repo
from src.repositories import files as files_repo
from src.schemas.files import FileOut, RetryAccepted, RetryBatchOut, RetrySkipped
from src.services.documents import file_out
class FileRetryService:
def __init__(
self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, settings: Settings | None = None
) -> None:
self.session, self.audit, self.scope = session, audit, scope
self.settings = settings if settings is not None else get_settings()
async def _lock_files(self, files: list[StoredFile]) -> list[tuple[StoredFile, RecognitionJob | None]]:
expected = {file.id: file.current_recognition_job_id for file in files}
jobs = list(
(
await self.session.scalars(
select(RecognitionJob)
.where(RecognitionJob.id.in_([jid for jid in expected.values() if jid is not None]))
.order_by(RecognitionJob.id)
.with_for_update()
.execution_options(populate_existing=True)
)
).all()
)
by_id = {job.id: job for job in jobs}
locked = list(
(
await self.session.scalars(
select(StoredFile)
.where(StoredFile.id.in_(expected))
.order_by(StoredFile.id)
.with_for_update()
.execution_options(populate_existing=True)
)
).all()
)
return [
(
file,
by_id.get(file.current_recognition_job_id)
if file.current_recognition_job_id is not None and file.current_recognition_job_id == expected[file.id]
else None,
)
for file in locked
]
async def _reason(self, file: StoredFile, job: RecognitionJob | None) -> str | None:
if (
job is None
or file.status is not FileStatus.ERROR
or job.pipeline is not RecognitionPipeline.tabular
or (job.status, job.error_code)
not in {(RecognitionStatus.timeout, "TIMEOUT"), (RecognitionStatus.dlq, "NO_ACK")}
or await self.session.scalar(select(exists().where(Document.stored_file_id == file.id)))
):
return "FILE_NOT_RETRYABLE"
if job.attempt >= job.max_attempts:
return "MAX_ATTEMPTS_REACHED"
return None
async def _new(self, file: StoredFile, job: RecognitionJob, consolidation_id: uuid.UUID | None) -> RecognitionJob:
now = utc_now()
new = RecognitionJob(
file_id=file.id,
pipeline=job.pipeline,
request_subject=request_subject(job.pipeline.value, self.settings.OPENBAO.NATS.request_subjects()),
schema_version=CONTRACT_VERSION,
status=RecognitionStatus.queued,
attempt=job.attempt + 1,
max_attempts=job.max_attempts,
requested_at=now,
)
self.session.add(new)
await self.session.flush()
file.current_recognition_job_id = new.id
file.status, file.updated_at = FileStatus.UPLOADED, now
after: dict[str, Any] = {"job_id": str(new.id), "attempt": new.attempt}
if consolidation_id is not None:
after["consolidation_id"] = str(consolidation_id)
await self.audit.record(
action=AuditAction.FILE_REPROCESS,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type="stored_file",
object_id=file.id,
before_value={
"job_id": str(job.id),
"attempt": job.attempt,
"status": job.status.value,
"error_code": job.error_code,
},
after_value=after,
)
return new
async def retry(self, file_id: uuid.UUID) -> FileOut:
visible = await files_repo.get_file(self.session, self.scope, file_id)
if visible is None:
raise NotFoundError("файл не найден")
locked = await self._lock_files([visible])
if not locked:
raise NotFoundError("файл не найден")
file, job = locked[0]
reason = await self._reason(file, job)
if reason:
raise ConflictError(reason, "Повторная обработка файла недоступна")
if file.consolidation_id is not None:
versions = list(
(
await self.session.scalars(
select(ConsolidationVersion)
.where(
ConsolidationVersion.consolidation_id == file.consolidation_id,
ConsolidationVersion.status.in_(IN_WORK_STATUSES),
)
.order_by(ConsolidationVersion.id)
.with_for_update(read=True)
.execution_options(populate_existing=True)
)
).all()
)
allowed = [v.id for v in versions]
member = await self.session.scalar(
select(
exists().where(
ConsolidationMember.consolidation_version_id.in_(allowed),
ConsolidationMember.legal_entity_id == file.legal_entity_id,
)
)
)
if not member:
raise ConflictError("VERSION_NOT_EDITABLE", "Нет версии в работе с организацией файла")
assert job is not None
new = await self._new(file, job, None)
await self.session.flush()
return file_out(file, new, [])
async def retry_version(self, version_id: uuid.UUID) -> RetryBatchOut:
found = await versions_repo.get_version(self.session, self.scope, version_id)
if found is None:
raise NotFoundError("версия не найдена")
_, consolidation = found
members = select(ConsolidationMember.legal_entity_id).where(
ConsolidationMember.consolidation_version_id == version_id
)
candidates = await files_repo.retry_candidates(self.session, self.scope, consolidation.id, version_id)
locked = await self._lock_files(candidates)
version = await versions_repo.lock_version(self.session, version_id)
if version is None:
raise NotFoundError("версия не найдена")
if version.status not in IN_WORK_STATUSES:
raise ConflictError("VERSION_NOT_EDITABLE", "Версия не в работе")
current_members = set((await self.session.scalars(members)).all())
result = RetryBatchOut(accepted=[], skipped=[])
for file, job in locked:
if (
file.legal_entity_id not in current_members
or file.consolidation_id != consolidation.id
or file.status is not FileStatus.ERROR
):
continue
reason = await self._reason(file, job)
if reason:
result.skipped.append(RetrySkipped(file_id=file.id, code=reason))
continue
assert job is not None
new = await self._new(file, job, consolidation.id)
result.accepted.append(RetryAccepted(file_id=file.id, job_id=new.id, attempt=new.attempt))
return result

View File

@ -31,6 +31,9 @@ from src.schemas.consolidations import (
EliminationMatchedPairOut,
EliminationPairRowOut,
EliminationPairsOut,
EliminationTotalItemOut,
EliminationTotalsOut,
EliminationTotalStatus,
EliminationUnpairedRowOut,
IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut,
@ -402,12 +405,23 @@ def _matched_pair_out(
)
def _pack_preset_rows(
rows: Sequence[ConsolidatedRow],
selection: MatrixSelection,
context: PairContext,
) -> RulePacking:
"""Отобрать встречные строки одной направленной пары компаний и одного правила."""
@dataclass(frozen=True)
class RuleSides:
"""Встречные строки тройки: исходные строки свода и строки сторон для сумм и упаковки."""
sources: dict[RowKey, ConsolidatedRow]
debit_rows: list[PackRow]
credit_rows: list[PackRow]
def a_b(self, rule: EliminationRule) -> tuple[list[PackRow], list[PackRow]]:
"""Строки стороны A (side_1 правила) и стороны B."""
if rule.side_1 == "D":
return self.debit_rows, self.credit_rows
return self.credit_rows, self.debit_rows
def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelection, context: PairContext) -> RuleSides:
"""Отобрать встречные строки одной направленной пары компаний и одного правила (§5)."""
rule = context.rule
company_a_id = context.company_a_id
company_b_id = context.company_b_id
@ -439,12 +453,73 @@ def _pack_preset_rows(
debit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, debit_amount))
if in_credit and credit_amount > ZERO:
credit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, credit_amount))
return RuleSides(sources, debit_rows, credit_rows)
rows_a = debit_rows if rule.side_1 == "D" else credit_rows
rows_b = credit_rows if rule.side_1 == "D" else debit_rows
def _rule_totals(sides: RuleSides, rule: EliminationRule) -> RuleTotals:
"""Итог тройки по строкам сторон: ΣA, ΣB, расхождение и сумма элиминации (§5 п. 1)."""
rows_a, rows_b = sides.a_b(rule)
amount_a = sum((row.amount for row in rows_a), ZERO)
amount_b = sum((row.amount for row in rows_b), ZERO)
totals = calculate_rule_totals(amount_a, amount_b, manual=rule.elimination_mode == "manual")
return calculate_rule_totals(amount_a, amount_b, manual=rule.elimination_mode == "manual")
def _total_status(totals: RuleTotals, rule: EliminationRule) -> EliminationTotalStatus:
"""Статус тройки в списке версии (§5 п. 3)."""
if rule.elimination_mode == "manual":
return "MANUAL"
if ZERO in (totals.amount_a, totals.amount_b):
return "ONE_SIDED"
return "DISCREPANCY" if totals.exceeds_threshold else "AUTO"
def calculate_rule_total_items(
rows: Sequence[ConsolidatedRow],
rules: Sequence[tuple[EliminationRule, MatrixSelection]],
company_ids: Sequence[uuid.UUID],
) -> list[EliminationTotalItemOut]:
"""Итоги всех троек версии: правило × упорядоченная пара компаний; тройки без строк пропускаются (§5 п. 3)."""
items: list[EliminationTotalItemOut] = []
for rule, selection in rules:
for company_a_id in company_ids:
for company_b_id in company_ids:
if company_a_id == company_b_id:
continue
sides = _collect_rule_sides(rows, selection, PairContext(rule, company_a_id, company_b_id))
if not sides.debit_rows and not sides.credit_rows:
continue
totals = _rule_totals(sides, rule)
rows_a, rows_b = sides.a_b(rule)
items.append(
EliminationTotalItemOut(
rule_id=rule.id,
rule_no=rule.rule_no,
elimination_mode=rule.elimination_mode,
company_a_id=company_a_id,
company_b_id=company_b_id,
amount_a=totals.amount_a,
amount_b=totals.amount_b,
deviation_amount=totals.deviation_amount,
deviation_percent=totals.deviation_percent,
eliminated_amount=totals.eliminated_amount,
exceeds_threshold=totals.exceeds_threshold,
status=_total_status(totals, rule),
osv_entry_ids_a=[entry_id for row in rows_a for entry_id in row.key],
osv_entry_ids_b=[entry_id for row in rows_b for entry_id in row.key],
)
)
return items
def _pack_preset_rows(
rows: Sequence[ConsolidatedRow],
selection: MatrixSelection,
context: PairContext,
) -> RulePacking:
"""Итог тройки и упаковка её встречных строк (§5 п. 1–2)."""
sides = _collect_rule_sides(rows, selection, context)
sources, debit_rows, credit_rows = sides.sources, sides.debit_rows, sides.credit_rows
totals = _rule_totals(sides, context.rule)
packed = pack_rule_rows(debit_rows, credit_rows)
matched: list[EliminationMatchedPairOut] = []
with_discrepancy: list[EliminationMatchedPairOut] = []
@ -511,6 +586,22 @@ class IntercompanyMatrixService:
unpaired=packing.unpaired,
)
async def build_totals(self, version_id: uuid.UUID) -> EliminationTotalsOut:
"""Итоги всех троек версии по правилам ББ её версии правил; сводная ОСВ строится один раз (§5 п. 3)."""
osv_result = await self._osv_service.build_with_members(version_id)
version = osv_result.version
accounts = await directory_repo.list_accounts(self._session)
by_id, children = _make_account_tree(accounts)
rules: list[tuple[EliminationRule, MatrixSelection]] = []
for rule in await elimination_rules_repo.list_rules(self._session, version.elimination_rules_version):
# пары §5 — только правила ББ по сальдо; правила ОФР ещё не формализованы (§4)
if rule.statement_type != StatementType.balance or rule.measure != "closing":
continue
selection, _ = await self._resolve_preset_selection(rule.id, version, by_id, children)
rules.append((rule, selection))
items = calculate_rule_total_items(osv_result.consolidated.rows, rules, list(osv_result.members))
return EliminationTotalsOut(version_no=version.elimination_rules_version, items=items)
async def build(
self,
version_id: uuid.UUID,
@ -637,10 +728,6 @@ class IntercompanyMatrixService:
rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id)
if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version):
raise NotFoundError("правило-пресет не найдено для версии консолидации")
if version.elimination_rules_version is None:
open_version_no = await elimination_rules_repo.current_open_version_no(self._session)
if rule.version_no != open_version_no:
raise NotFoundError("правило-пресет не найдено для версии консолидации")
if rule.statement_type != StatementType.balance:
raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ")
if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"):

View File

@ -0,0 +1,104 @@
"""Уточнение ОСВ в транзакции запроса с сохранением первички и аудитом."""
import uuid
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, NotFoundError
from src.domain.osv_header import HEADER_FIELDS, corrected_header
from src.domain.policy import IN_WORK_STATUSES
from src.models.consolidation import ConsolidationDocument
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, DocumentStatus, DocumentType, FileStatus
from src.models.files import Document
from src.repositories import files as files_repo
from src.schemas.documents import DocumentOut, OsvHeaderPatch
from src.services import consolidation_documents
from src.services.documents import DocumentService
class OsvHeaderService(DocumentService):
"""Разделяет видимость, карточку и транзакцию с обычным сервисом документов."""
async def _twin(self, document: Document, account: object) -> uuid.UUID | None:
return await self._session.scalar(
select(Document.id).where(
Document.stored_file_id == document.stored_file_id,
Document.document_type == DocumentType.OSV,
Document.osv_account_code == account,
Document.id != document.id,
)
)
async def patch(self, document_id: uuid.UUID, body: OsvHeaderPatch) -> DocumentOut:
visible = await self._visible(document_id)
file = await files_repo.lock_file(self._session, visible.stored_file_id)
document = await files_repo.lock_document(self._session, document_id)
if file is None or document is None:
raise NotFoundError("документ не найден")
if document.document_type is not DocumentType.OSV:
raise ConflictError("DOCUMENT_NOT_OSV", "Документ не является ОСВ")
if file.status is FileStatus.SUPERSEDED:
raise ConflictError("FILE_SUPERSEDED", "Файл заменён новой версией")
current = {key: getattr(document, key) for key in HEADER_FIELDS}
result = corrected_header(current, body.model_dump(exclude_unset=True))
twin = await self._twin(document, result["osv_account_code"])
if twin is not None:
raise ConflictError("DUPLICATE_ACCOUNT", "Счёт уже есть в файле", document_id=str(twin))
versions = await consolidation_documents.bound_versions(self._session, documents=[document])
blocked = [
str(vid) for vid, status in versions if status not in IN_WORK_STATUSES | {ConsolidationStatus.CANCELLED}
]
if blocked:
raise ConflictError("VERSION_NOT_EDITABLE", "Документ используется в закрытой версии", version_ids=blocked)
changes = {key: value for key, value in result.items() if value != current[key]}
if not changes:
return await self._card(document, file)
before = {key: current[key] for key in changes}
after = dict(changes)
try:
async with self._session.begin_nested():
if document.original_values is None:
document.original_values = current
for key, value in changes.items():
setattr(document, key, value)
document.is_modified = result != document.original_values
document.modified_by = self._audit.user_id
document.modified_at = document.updated_at = utc_now()
if document.status is DocumentStatus.CONFIRMED:
before.update(
status=document.status.value,
confirmed_by=str(document.confirmed_by) if document.confirmed_by else None,
confirmed_at=document.confirmed_at.isoformat() if document.confirmed_at else None,
)
document.status = DocumentStatus.NEEDS_CHECK
document.confirmed_by = document.confirmed_at = None
after.update(status=document.status.value, confirmed_by=None, confirmed_at=None)
working = [vid for vid, status in versions if status in IN_WORK_STATUSES]
await self._session.execute(
delete(ConsolidationDocument).where(
ConsolidationDocument.document_id == document.id,
ConsolidationDocument.consolidation_version_id.in_(working),
)
)
after["unbound_version_ids"] = [str(vid) for vid in working]
await self._session.flush()
except IntegrityError:
# SAVEPOINT rollback expires the changed ORM object; reload asynchronously before accessing fields.
document = await files_repo.lock_document(self._session, document_id)
if document is None:
raise
twin = await self._twin(document, result["osv_account_code"])
if twin is None:
raise
raise ConflictError("DUPLICATE_ACCOUNT", "Счёт уже есть в файле", document_id=str(twin)) from None
await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION,
object_type="document",
object_id=document.id,
before_value=before,
after_value=after,
)
return await self._card(document, file)

View File

@ -415,6 +415,7 @@ async def make_version( # noqa: PLR0913 - сигнатура фабрики ф
scenario=ConsolidationScenario.CREATE,
approach=ConsolidationApproach.BASIC,
goodwill_enabled=False,
elimination_rules_version=1, # seed правил §4 — версия v1; с DBML v5.23 поле обязательно
initiator_id=initiator.id,
initiator_office_id=initiator.regional_office_id,
is_in_work=status not in FINISHED_STATUSES,

View File

@ -166,6 +166,23 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s
}
@pytest.mark.integration
async def test_create_without_effective_rules_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""DBML v5.23: версия правил обязательна — без правил, действующих на дату создания, консолидация не создаётся."""
group, gsk, _ = await _scenario(seeded)
tomorrow = moscow_date(utc_now()) + timedelta(days=1)
for rule in (await seeded.scalars(select(EliminationRule))).all():
rule.valid_from = tomorrow
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2024))
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "ELIMINATION_RULES_MISSING"
assert await _count(seeded, Consolidation) == 0
@pytest.mark.integration
async def test_create_fixes_rules_for_creation_date_not_reporting_period(
auth_client: AuthClientFactory, seeded: AsyncSession

View File

@ -140,6 +140,10 @@ async def test_document_card(seeded: AsyncSession, auth_client: AuthClientFactor
# R-6: предупреждения маппинга файла целиком, без фильтра по листу
"warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"],
"comments_count": 0,
"is_modified": False,
"original_values": None,
"modified_by": None,
"modified_at": None,
"issues": [],
"issue_counts": {"error": 0, "info": 0},
}

View File

@ -0,0 +1,421 @@
"""Регрессии AURORA-1442: новая попытка после сбоя обмена, без повторной загрузки."""
import asyncio
from datetime import timedelta
from http import HTTPStatus
import pytest
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.api.dependencies.directories import get_app_settings
from src.common.exceptions import ConflictError
from src.contract import CONTRACT_VERSION
from src.domain.policy import VisibilityScope
from src.models.audit_ex import AuditEvent
from src.models.consolidation import Consolidation, ConsolidationMember, ConsolidationVersion
from src.models.enums import ConsolidationStatus, FileStatus, RecognitionPipeline, RecognitionStatus
from src.models.files import RecognitionJob, StoredFile
from src.services.audit import AuditService
from src.services.file_retry import FileRetryService
from src.services.recognition_exchange import handle_message, publish_pending
from tests.conftest import AuthClientFactory
from tests.factories import make_document, make_stored_file, make_version
from tests.test_api_consolidation_members import _user, _version
from tests.test_api_files_read import KA_RF, _rostov_entity
from tests.test_recognition_exchange import FakeNats
from tests.test_recognition_inbox import envelope, fake_msg
pytestmark = pytest.mark.integration
@pytest.mark.parametrize("state,code", [(RecognitionStatus.timeout, "TIMEOUT"), (RecognitionStatus.dlq, "NO_ACK")])
async def test_retry_preserves_old_job_and_records_audit(
seeded: AsyncSession, auth_client: AuthClientFactory, state: RecognitionStatus, code: str
) -> None:
entity = await _rostov_entity(seeded)
file, old = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR, job_status=state)
old.max_attempts = 3
old.error_code = code
old.metrics = {"republish_count": 3}
source = file.s3_object_id
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(f"/files/{file.id}/retry")
assert response.status_code == HTTPStatus.ACCEPTED, response.text
await seeded.refresh(file)
await seeded.refresh(old)
new = await seeded.get(RecognitionJob, file.current_recognition_job_id)
assert new is not None and new.id != old.id
assert (new.status, new.attempt, new.max_attempts) == (RecognitionStatus.queued, 2, 3)
assert new.error_code is None and new.metrics is None and new.result_s3_object_id is None
assert file.s3_object_id == source and file.status == FileStatus.UPLOADED
assert old.status == state and old.error_code == code
event = (
await seeded.execute(
select(AuditEvent).where(AuditEvent.object_id == file.id, AuditEvent.action == "FILE_REPROCESS")
)
).scalar_one()
assert event.before_value == {"job_id": str(old.id), "attempt": 1, "status": state.value, "error_code": code}
assert event.after_value == {"job_id": str(new.id), "attempt": 2}
again = await client.post(f"/files/{file.id}/retry")
assert again.status_code == HTTPStatus.CONFLICT
@pytest.mark.parametrize("reason", ["limit", "ocr", "service_error", "document"])
async def test_retry_rejects_ineligible_file(seeded: AsyncSession, auth_client: AuthClientFactory, reason: str) -> None:
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
job.error_code = "TIMEOUT"
job.max_attempts = 3
if reason == "limit":
job.attempt = 3
elif reason == "ocr":
job.pipeline = RecognitionPipeline.image
elif reason == "service_error":
job.status = RecognitionStatus.error
else:
await make_document(seeded, file=file, job=job)
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(f"/files/{file.id}/retry")
assert response.status_code == HTTPStatus.CONFLICT, response.text
expected = "MAX_ATTEMPTS_REACHED" if reason == "limit" else "FILE_NOT_RETRYABLE"
assert response.json()["detail"]["code"] == expected
await seeded.refresh(file)
assert file.current_recognition_job_id == job.id
async def test_batch_filters_perimeter_and_skips_ineligible(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
version, inside, outside, _ = await _version(seeded, members=1, spare=1, locked_by=None)
good, job = await make_stored_file(
seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.dlq
)
job.error_code, job.max_attempts = "NO_ACK", 3
good.consolidation_id = version.consolidation_id
bad, _ = await make_stored_file(seeded, entity=inside[0])
bad.consolidation_id = version.consolidation_id
excluded, _ = await make_stored_file(seeded, entity=outside[0])
excluded.consolidation_id = version.consolidation_id
desk, _ = await make_stored_file(seeded, entity=inside[0])
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(f"/consolidations/versions/{version.id}/files/retry")
assert response.status_code == HTTPStatus.ACCEPTED, response.text
body = response.json()
assert [row["file_id"] for row in body["accepted"]] == [str(good.id)]
assert body["skipped"] == []
event = (
await seeded.execute(
select(AuditEvent).where(AuditEvent.object_id == good.id, AuditEvent.action == "FILE_REPROCESS")
)
).scalar_one()
assert event.after_value["consolidation_id"] == str(version.consolidation_id)
assert str(desk.id) not in str(body)
async def test_batch_rejects_verified_and_returns_empty_for_empty_perimeter(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
version, _, _, _ = await _version(seeded, members=1, locked_by=None)
client = await auth_client(KA_RF)
url = f"/consolidations/versions/{version.id}/files/retry"
response = await client.post(url)
assert response.status_code == HTTPStatus.ACCEPTED
assert response.json() == {"accepted": [], "skipped": []}
version.status = ConsolidationStatus.VERIFIED
await seeded.flush()
response = await client.post(url)
assert response.status_code == HTTPStatus.CONFLICT
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
async def test_concurrent_retries_create_only_one_attempt(committed_sessionmaker) -> None:
async with committed_sessionmaker() as session, session.begin():
entity = await _rostov_entity(session)
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
job.error_code, job.max_attempts = "TIMEOUT", 3
user = await _user(session, KA_RF)
file_id, user_id = file.id, user.id
async def retry():
async with committed_sessionmaker() as session:
try:
async with session.begin():
audit = RequestAudit(AuditService(session), user_id, None, None)
return await FileRetryService(session, audit, VisibilityScope(None)).retry(file_id)
except ConflictError as error:
return error.code
results = await asyncio.wait_for(asyncio.gather(retry(), retry()), timeout=10)
assert sum(isinstance(result, str) for result in results) == 1
assert "FILE_NOT_RETRYABLE" in results
async with committed_sessionmaker() as session:
jobs = list((await session.scalars(select(RecognitionJob).where(RecognitionJob.file_id == file_id))).all())
assert sorted(job.attempt for job in jobs) == [1, 2]
async def test_retry_outbox_and_late_old_result(committed_sessionmaker) -> None:
async with committed_sessionmaker() as session, session.begin():
entity = await _rostov_entity(session)
file, old = await make_stored_file(
session, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
old.error_code, old.max_attempts = "TIMEOUT", 3
user = await _user(session, KA_RF)
file_id, old_id = file.id, old.id
audit = RequestAudit(AuditService(session), user.id, None, None)
card = await FileRetryService(session, audit, VisibilityScope(None)).retry(file.id)
assert card.job is not None
new_id = card.job.id
for state in ("processing", "done"):
await handle_message(fake_msg(envelope(old_id, file_id, state), None), session_factory=committed_sessionmaker)
offline = FakeNats(connected=False)
await publish_pending(committed_sessionmaker, offline, ack_timeout=timedelta(seconds=30), max_republish=3)
async with committed_sessionmaker() as session:
new = await session.get(RecognitionJob, new_id)
assert new is not None and new.status is RecognitionStatus.queued
assert new.reply_deadline is None and new.result_s3_object_id is None
online = FakeNats()
await publish_pending(committed_sessionmaker, online, ack_timeout=timedelta(seconds=30), max_republish=3)
async with committed_sessionmaker() as session:
new = await session.get(RecognitionJob, new_id)
assert new is not None and new.status is RecognitionStatus.published
assert new.reply_deadline is not None and new.request_msg_id == f"{new_id}:2"
old = await session.get(RecognitionJob, old_id)
assert old is not None and old.status is RecognitionStatus.timeout
async def test_retry_rolls_back_job_pointer_and_audit(committed_sessionmaker) -> None:
async with committed_sessionmaker() as session, session.begin():
entity = await _rostov_entity(session)
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
job.error_code, job.max_attempts = "TIMEOUT", 3
user = await _user(session, KA_RF)
file_id, job_id, user_id = file.id, job.id, user.id
with pytest.raises(RuntimeError, match="rollback"):
async with committed_sessionmaker() as session, session.begin():
audit = RequestAudit(AuditService(session), user_id, None, None)
await FileRetryService(session, audit, VisibilityScope(None)).retry(file_id)
raise RuntimeError("rollback")
async with committed_sessionmaker() as session:
jobs = list((await session.scalars(select(RecognitionJob).where(RecognitionJob.file_id == file_id))).all())
assert [job.id for job in jobs] == [job_id]
events = list((await session.scalars(select(AuditEvent).where(AuditEvent.object_id == file_id))).all())
assert events == []
async def test_concurrent_batch_retry(committed_sessionmaker) -> None:
async with committed_sessionmaker() as session, session.begin():
version, members, _, _ = await _version(session, members=1, locked_by=None)
file, job = await make_stored_file(
session, entity=members[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
file.consolidation_id = version.consolidation_id
job.error_code, job.max_attempts = "TIMEOUT", 3
user = await _user(session, KA_RF)
version_id, user_id = version.id, user.id
async def retry():
async with committed_sessionmaker() as session, session.begin():
audit = RequestAudit(AuditService(session), user_id, None, None)
return await FileRetryService(session, audit, VisibilityScope(None)).retry_version(version_id)
results = await asyncio.wait_for(asyncio.gather(retry(), retry()), timeout=10)
assert sorted(len(result.accepted) for result in results) == [0, 1]
assert all(result.skipped == [] for result in results)
@pytest.mark.parametrize("change", ["status", "perimeter"])
async def test_batch_rechecks_version_after_waiting_for_job(
committed_sessionmaker, monkeypatch: pytest.MonkeyPatch, change: str
) -> None:
async with committed_sessionmaker() as session, session.begin():
version, members, _, _ = await _version(session, members=1, locked_by=None)
file, job = await make_stored_file(
session, entity=members[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
file.consolidation_id = version.consolidation_id
job.error_code, job.max_attempts = "TIMEOUT", 3
user = await _user(session, KA_RF)
version_id, job_id, user_id = version.id, job.id, user.id
reached = asyncio.Event()
original = FileRetryService._lock_files
async def signal(self, files):
reached.set()
return await original(self, files)
monkeypatch.setattr(FileRetryService, "_lock_files", signal)
async def retry():
async with committed_sessionmaker() as session:
try:
async with session.begin():
audit = RequestAudit(AuditService(session), user_id, None, None)
return await FileRetryService(session, audit, VisibilityScope(None)).retry_version(version_id)
except ConflictError as error:
return error.code
async with committed_sessionmaker() as holder, holder.begin():
await holder.execute(select(RecognitionJob).where(RecognitionJob.id == job_id).with_for_update())
pending = asyncio.create_task(retry())
await asyncio.wait_for(reached.wait(), timeout=10)
version = await holder.get(ConsolidationVersion, version_id)
assert version is not None
if change == "status":
version.status = ConsolidationStatus.VERIFIED
else:
await holder.execute(
delete(ConsolidationMember).where(ConsolidationMember.consolidation_version_id == version_id)
)
result = await asyncio.wait_for(pending, timeout=10)
if change == "status":
assert result == "VERSION_NOT_EDITABLE"
else:
assert result.accepted == [] and result.skipped == []
async def test_batch_mixed_package_skips_ocr_and_exhausted_files(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Смешанный пакет: подходящий файл принят, OCR в ERROR и исчерпанный лимит попыток — в skipped с кодами."""
version, inside, _, _ = await _version(seeded, members=1, locked_by=None)
good, good_job = await make_stored_file(
seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
good_job.error_code, good_job.max_attempts = "TIMEOUT", 3
ocr, ocr_job = await make_stored_file(
seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
ocr_job.pipeline, ocr_job.error_code, ocr_job.max_attempts = RecognitionPipeline.image, "TIMEOUT", 3
spent, spent_job = await make_stored_file(
seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
spent_job.error_code, spent_job.max_attempts, spent_job.attempt = "TIMEOUT", 3, 3
for item in (good, ocr, spent):
item.consolidation_id = version.consolidation_id
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(f"/consolidations/versions/{version.id}/files/retry")
assert response.status_code == HTTPStatus.ACCEPTED, response.text
body = response.json()
assert [row["file_id"] for row in body["accepted"]] == [str(good.id)]
skipped = {row["file_id"]: row["code"] for row in body["skipped"]}
assert skipped == {str(ocr.id): "FILE_NOT_RETRYABLE", str(spent.id): "MAX_ATTEMPTS_REACHED"}
for skipped_file, skipped_job in ((ocr, ocr_job), (spent, spent_job)):
await seeded.refresh(skipped_file)
assert skipped_file.current_recognition_job_id == skipped_job.id and skipped_file.status == FileStatus.ERROR
events = await seeded.scalars(
select(AuditEvent).where(AuditEvent.object_id == skipped_file.id, AuditEvent.action == "FILE_REPROCESS")
)
assert list(events.all()) == []
async def test_batch_no_ack_at_attempt_limit_is_max_attempts_reached(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""NO_ACK (dlq) на пределе попыток в пакете даёт MAX_ATTEMPTS_REACHED, новое задание не создаётся."""
version, inside, _, _ = await _version(seeded, members=1, locked_by=None)
file, job = await make_stored_file(
seeded, entity=inside[0], status=FileStatus.ERROR, job_status=RecognitionStatus.dlq
)
job.error_code, job.max_attempts, job.attempt = "NO_ACK", 3, 3
file.consolidation_id = version.consolidation_id
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(f"/consolidations/versions/{version.id}/files/retry")
assert response.status_code == HTTPStatus.ACCEPTED, response.text
assert response.json() == {"accepted": [], "skipped": [{"file_id": str(file.id), "code": "MAX_ATTEMPTS_REACHED"}]}
jobs = await seeded.scalars(select(RecognitionJob).where(RecognitionJob.file_id == file.id))
assert [item.id for item in jobs.all()] == [job.id]
async def test_retry_uses_current_contract_and_subject(
seeded: AsyncSession, auth_client: AuthClientFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
entity = await _rostov_entity(seeded)
file, old = await make_stored_file(
seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
old.error_code, old.max_attempts = "TIMEOUT", 3
old.schema_version, old.request_subject = "0.9", "old.tabular.subject"
monkeypatch.setattr(get_app_settings().OPENBAO.NATS, "NATS_TABULAR_REQUEST_SUBJECT", "current.tabular.subject")
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(f"/files/{file.id}/retry")
assert response.status_code == HTTPStatus.ACCEPTED, response.text
await seeded.refresh(file)
new = await seeded.get(RecognitionJob, file.current_recognition_job_id)
assert new is not None
assert new.schema_version == CONTRACT_VERSION and new.request_subject == "current.tabular.subject"
await seeded.refresh(old)
assert old.schema_version == "0.9" and old.request_subject == "old.tabular.subject"
@pytest.mark.parametrize(
"normal_status", [FileStatus.UPLOADED, FileStatus.PROCESSING, FileStatus.PROCESSED, FileStatus.SUPERSEDED]
)
async def test_batch_does_not_lock_normal_files(committed_sessionmaker, normal_status: FileStatus) -> None:
async with committed_sessionmaker() as session, session.begin():
version, members, _, _ = await _version(session, members=1, locked_by=None)
normal, job = await make_stored_file(
session, entity=members[0], status=normal_status, job_status=RecognitionStatus.processing
)
normal.consolidation_id = version.consolidation_id
user = await _user(session, KA_RF)
version_id, file_id, job_id, user_id = version.id, normal.id, job.id, user.id
async def retry():
async with committed_sessionmaker() as session, session.begin():
audit = RequestAudit(AuditService(session), user_id, None, None)
return await FileRetryService(session, audit, VisibilityScope(None)).retry_version(version_id)
async with committed_sessionmaker() as holder, holder.begin():
await holder.execute(select(RecognitionJob).where(RecognitionJob.id == job_id).with_for_update())
await holder.execute(select(StoredFile).where(StoredFile.id == file_id).with_for_update())
result = await asyncio.wait_for(retry(), timeout=5)
assert result.accepted == [] and result.skipped == []
@pytest.mark.parametrize("closed_status", [ConsolidationStatus.VERIFIED, ConsolidationStatus.CANCELLED])
async def test_single_retry_does_not_lock_closed_versions(committed_sessionmaker, closed_status) -> None:
async with committed_sessionmaker() as session, session.begin():
working, members, _, _ = await _version(session, members=1, locked_by=None)
consolidation = await session.get(Consolidation, working.consolidation_id)
user = await _user(session, KA_RF)
closed = await make_version(
session, consolidation=consolidation, version_no=2, members=members, initiator=user, status=closed_status
)
file, job = await make_stored_file(
session, entity=members[0], status=FileStatus.ERROR, job_status=RecognitionStatus.timeout
)
file.consolidation_id = working.consolidation_id
job.error_code, job.max_attempts = "TIMEOUT", 3
file_id, user_id, closed_id = file.id, user.id, closed.id
async def retry():
async with committed_sessionmaker() as session, session.begin():
audit = RequestAudit(AuditService(session), user_id, None, None)
return await FileRetryService(session, audit, VisibilityScope(None)).retry(file_id)
async with committed_sessionmaker() as holder, holder.begin():
await holder.execute(select(ConsolidationVersion).where(ConsolidationVersion.id == closed_id).with_for_update())
card = await asyncio.wait_for(retry(), timeout=5)
assert card.status == FileStatus.UPLOADED

View File

@ -202,18 +202,17 @@ async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded
assert response.json()["detail"]["code"] == "INVALID_RULE"
async def test_preset_uses_current_rules_when_version_unfixed(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
rule = (
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
).one()
assert world.version.elimination_rules_version == rule.version_no
client = await auth_client(KA_RF)
url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}"
listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
assert listed.status_code == OK, listed.text
assert listed.json()["is_version_fixed"] is False
assert listed.json()["is_version_fixed"] is True
assert listed.json()["version_no"] == rule.version_no
assert "as_of" not in listed.json()
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
@ -226,36 +225,31 @@ async def test_preset_uses_current_rules_when_version_unfixed(
},
)
assert pairs.status_code == OK, pairs.text
assert world.version.elimination_rules_version is None # GET не фиксирует номер правил
response = await client.get(url)
assert response.status_code == OK, response.text
assert (await client.get(f"{url}/export")).status_code == OK
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert audit.after_value["rule_no"] == rule.rule_no
assert audit.after_value["rule_version_no"] == rule.version_no
assert world.version.elimination_rules_version is None
assert world.version.elimination_rules_version == rule.version_no # GET номер правил не меняет
world.version.elimination_rules_version = rule.version_no + 1
await seeded.flush()
assert (await client.get(url)).status_code == NOT_FOUND
assert (await client.get(f"{url}/export")).status_code == NOT_FOUND
world.version.elimination_rules_version = rule.version_no
await seeded.flush()
fixed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
assert fixed.status_code == OK, fixed.text
assert fixed.json()["is_version_fixed"] is True
assert fixed.json()["version_no"] == rule.version_no
missing_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}"
missing = await client.get(missing_url)
assert missing.status_code == NOT_FOUND
assert (await client.get(f"{missing_url}/export")).status_code == NOT_FOUND
async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
async def test_fixed_rules_version_ignores_newer_rules(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Новая версия правил после создания версии консолидации на неё не влияет (§4, DBML v5.23)."""
world = await _world(seeded)
old_rule, new_rule = (
await seeded.scalars(select(EliminationRule).order_by(EliminationRule.rule_no).limit(2))
).all()
assert old_rule.valid_to is None and new_rule.valid_to is None
new_rule.rule_no = old_rule.rule_no
new_rule.version_no = old_rule.version_no + 1
await seeded.flush()
@ -265,13 +259,13 @@ async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientF
response = await client.get(f"{base}/elimination-rules")
assert response.status_code == OK, response.text
payload = response.json()
assert payload["is_version_fixed"] is False
assert payload["version_no"] == new_rule.version_no
assert [item["id"] for item in payload["rules"]] == [str(new_rule.id)]
assert payload["rules"][0]["rule_no"] == old_rule.rule_no
assert payload["is_version_fixed"] is True
assert payload["version_no"] == old_rule.version_no
assert str(new_rule.id) not in {item["id"] for item in payload["rules"]}
assert str(old_rule.id) in {item["id"] for item in payload["rules"]}
old_preset = await client.get(f"{base}/intercompany-matrix/preset/{old_rule.id}")
assert old_preset.status_code == NOT_FOUND
new_preset = await client.get(f"{base}/intercompany-matrix/preset/{new_rule.id}")
assert new_preset.status_code == NOT_FOUND
async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
@ -522,6 +516,32 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-pairs", params=params)
assert missing.status_code == NOT_FOUND
# список версии — те же итоги, что у тройки, одним запросом (§5 п. 3)
totals = await client.get(f"/consolidations/versions/{world.version.id}/elimination-totals")
assert totals.status_code == OK, totals.text
assert totals.json()["version_no"] == 1
by_triple = {(item["rule_no"], item["company_a_id"], item["company_b_id"]): item for item in totals.json()["items"]}
a, b, c = str(company_a_id), str(company_b_id), str(company_c_id)
main = by_triple[(6, a, b)]
assert (main["amount_a"], main["amount_b"], main["deviation_amount"]) == ("300.00", "100.00", "200.00")
assert (main["deviation_percent"], main["eliminated_amount"]) == ("200.00", "0.00")
assert (main["status"], main["exceeds_threshold"], main["elimination_mode"]) == ("DISCREPANCY", True, "min")
# у A по B три ненулевые строки: 62.01 40 и 200, 60.02 60; строка 0 в сумму не входит
assert (len(main["osv_entry_ids_a"]), len(main["osv_entry_ids_b"])) == (len(("40", "200", "60")), 1)
mirrored = by_triple[(6, b, a)]
assert (mirrored["status"], mirrored["eliminated_amount"]) == ("AUTO", "70.00")
assert by_triple[(6, a, c)]["status"] == "DISCREPANCY"
assert (by_triple[(1, a, b)]["status"], by_triple[(1, a, b)]["eliminated_amount"]) == ("MANUAL", "0.00")
# дебиторка № 6 входит и в № 7 (займы 67), встречной стороны нет — двойной учёт до AURORA-1751
one_sided = by_triple[(7, a, b)]
assert (one_sided["status"], one_sided["amount_b"], one_sided["deviation_percent"]) == ("ONE_SIDED", "0.00", None)
assert (3, a, b) not in by_triple # строк по счетам правила нет — тройки нет
assert [item["rule_no"] for item in totals.json()["items"]] == sorted(
item["rule_no"] for item in totals.json()["items"]
)
missing_totals = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-totals")
assert missing_totals.status_code == NOT_FOUND
async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(
auth_client: AuthClientFactory, seeded: AsyncSession

View File

@ -0,0 +1,228 @@
"""Правки шапки ОСВ с сохранением первичного снимка и повторным подтверждением."""
import asyncio
import json
import uuid
from http import HTTPStatus
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.contract.versions import parse_result
from src.core.config import get_settings
from src.domain.policy import VisibilityScope
from src.models.audit_ex import AuditEvent
from src.models.consolidation import Consolidation, ConsolidationDocument
from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter
from src.models.files import Document, StoredFile
from src.schemas.documents import OsvHeaderPatch
from src.services.audit import AuditService
from src.services.documents import DocumentService
from src.services.osv_header import OsvHeaderService
from tests.conftest import AuthClientFactory
from tests.factories import make_document, make_stored_file, make_version
from tests.fixtures_helpers import map_golden_snapshot
from tests.test_api_consolidation_members import _user, _version
from tests.test_api_files_read import KA_RF, _rostov_entity
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
def _result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> Any:
data = json.loads(path.read_text())
data.update(job_id=str(job_id), file_id=str(file_id))
data["service"]["version"] = "golden"
return parse_result(json.dumps(data).encode())
async def test_header_keeps_original_and_entries(
seeded: AsyncSession, auth_client: AuthClientFactory, fixtures_dir: Path
) -> None:
entity = await _rostov_entity(seeded)
snapshots = sorted((fixtures_dir / "golden/expected_osv_parse/rb").rglob("*.json"))
assert snapshots, "Нужны реальные golden-снимки ОСВ РБ"
documents = await map_golden_snapshot(
seeded, entity=entity, path=snapshots[0], uploaded_by=None, result_builder=_result
)
document = documents[0]
file = await seeded.get(StoredFile, document.stored_file_id)
assert file is not None
file.status = FileStatus.PROCESSED
document.status = DocumentStatus.CONFIRMED
original = {
key: getattr(document, key) for key in ("period_year", "period_quarter", "is_cumulative", "osv_account_code")
}
await seeded.flush()
client = await auth_client(KA_RF)
url = f"/documents/{document.id}"
entries = (await client.get(f"{url}/entries")).json()
ordinal = document.file_ordinal
response = await client.patch(f"{url}/osv-header", json={"osv_account_code": "99.1"})
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["osv_account_code"] == "99.01"
assert body["original_values"] == original
assert body["is_modified"] is True and body["status"] == "NEEDS_CHECK"
response = await client.patch(f"{url}/osv-header", json={"osv_account_code": original["osv_account_code"]})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["is_modified"] is False
assert response.json()["original_values"] == original
await seeded.refresh(document)
assert document.file_ordinal == ordinal
assert (await client.get(f"{url}/entries")).json() == entries
async def test_header_unbinds_working_version_but_keeps_cancelled(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
version, members, _, _ = await _version(seeded, members=1, locked_by=None)
file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED)
doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
consolidation = await seeded.get(Consolidation, version.consolidation_id)
assert consolidation is not None
cancelled = await make_version(
seeded,
consolidation=consolidation,
version_no=2,
members=members,
initiator=await _user(seeded, KA_RF),
status=ConsolidationStatus.CANCELLED,
)
seeded.add_all(
[ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, cancelled)]
)
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"})
assert response.status_code == HTTPStatus.OK, response.text
bound = list(
(
await seeded.scalars(
select(ConsolidationDocument.consolidation_version_id).where(
ConsolidationDocument.document_id == doc.id
)
)
).all()
)
assert bound == [cancelled.id]
assert response.json()["status"] == "NEEDS_CHECK"
async def test_header_validation_order_duplicate_and_noop(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED)
doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
twin = await make_document(seeded, file=file, job=job, account="62")
client = await auth_client(KA_RF)
url = f"/documents/{doc.id}/osv-header"
response = await client.patch(url, json={"period_quarter": None, "osv_account_code": "62"})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "INVALID_PERIOD"
response = await client.patch(url, json={"osv_account_code": "62"})
assert response.status_code == HTTPStatus.CONFLICT
assert response.json()["detail"]["document_id"] == str(twin.id)
response = await client.patch(url, json={"osv_account_code": "60"})
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "CONFIRMED"
assert response.json()["original_values"] is None
assert (
await seeded.scalar(
select(func.count())
.select_from(AuditEvent)
.where(AuditEvent.object_id == doc.id, AuditEvent.action == "RECOGNIZED_DATA_EDIT")
)
== 0
)
async def test_header_type_validation_precedes_domain_checks(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED)
doc = await make_document(seeded, file=file, job=job)
doc.document_type = DocumentType.BALANCE
doc.osv_account_code = None
await seeded.flush()
client = await auth_client(KA_RF)
url = f"/documents/{doc.id}/osv-header"
response = await client.patch(url, json={"period_year": "abc"})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert isinstance(response.json()["detail"], list)
assert response.json()["detail"][0]["loc"] == ["body", "period_year"]
response = await client.patch(url, json={"period_year": 2100})
assert response.status_code == HTTPStatus.CONFLICT
assert response.json()["detail"]["code"] == "DOCUMENT_NOT_OSV"
@pytest.mark.parametrize("period_from_version", [False, True])
async def test_corrected_account_in_version_card(
seeded: AsyncSession,
auth_client: AuthClientFactory,
monkeypatch: pytest.MonkeyPatch,
period_from_version: bool,
) -> None:
monkeypatch.setattr(get_settings(), "CONSOLIDATION_PERIOD_FROM_VERSION", period_from_version)
version, members, _, _ = await _version(seeded, members=1, locked_by=None)
file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED)
file.consolidation_id = version.consolidation_id
doc = await make_document(seeded, file=file, job=job, year=2024, quarter=PeriodQuarter.Q3)
old_checks = doc.period_check, doc.counterparty_check
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"})
assert response.status_code == HTTPStatus.OK, response.text
card = (await client.get(f"/consolidations/versions/{version.id}")).json()
assert card["members"][0]["osv_accounts"] == ["62"]
await seeded.refresh(doc)
assert (doc.period_check, doc.counterparty_check) == old_checks
response = await client.post(f"/documents/{doc.id}/confirm")
assert response.status_code == HTTPStatus.OK, response.text
assert (
await seeded.scalar(
select(ConsolidationDocument.document_id).where(
ConsolidationDocument.consolidation_version_id == version.id
)
)
== doc.id
)
async def test_concurrent_header_patch_and_confirm(committed_sessionmaker) -> None:
async with committed_sessionmaker() as session, session.begin():
version, members, _, _ = await _version(session, members=1, locked_by=None)
file, job = await make_stored_file(session, entity=members[0], status=FileStatus.PROCESSED)
doc = await make_document(session, file=file, job=job, year=2024, quarter=PeriodQuarter.Q3)
user = await _user(session, KA_RF)
doc_id, user_id, version_id = doc.id, user.id, version.id
async def change(confirm: bool):
async with committed_sessionmaker() as session, session.begin():
audit = RequestAudit(AuditService(session), user_id, None, None)
if confirm:
return await DocumentService(session, audit, VisibilityScope(None)).confirm(doc_id)
return await OsvHeaderService(session, audit, VisibilityScope(None)).patch(
doc_id, OsvHeaderPatch(osv_account_code="62")
)
await asyncio.wait_for(asyncio.gather(change(True), change(False)), timeout=10)
async with committed_sessionmaker() as session:
doc = await session.get(Document, doc_id)
assert doc is not None and doc.osv_account_code == "62"
bound = await session.scalar(
select(ConsolidationDocument.document_id).where(
ConsolidationDocument.consolidation_version_id == version_id
)
)
if doc.status is DocumentStatus.CONFIRMED:
assert bound == doc_id
else:
assert doc.status is DocumentStatus.NEEDS_CHECK and bound is None

View File

@ -0,0 +1,280 @@
"""Правка шапки ОСВ на синтетических документах из фабрик (без golden-файлов), §9.5 п.12–13."""
import datetime
from http import HTTPStatus
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.audit_ex import AuditEvent
from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationVersion
from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus
from src.models.files import Document, StoredFile
from src.models.osv import OsvEntry
from tests.conftest import AuthClientFactory
from tests.factories import make_document, make_osv_entry, make_stored_file, make_version
from tests.test_api_consolidation_members import _user, _version
from tests.test_api_files_read import KA_RF, _rostov_entity
pytestmark = pytest.mark.integration
ORIGINAL = {"period_year": 2025, "period_quarter": "Q1", "is_cumulative": True, "osv_account_code": "60"}
async def _events(session: AsyncSession, document: Document) -> list[AuditEvent]:
rows = await session.scalars(
select(AuditEvent).where(AuditEvent.object_id == document.id, AuditEvent.action == "RECOGNIZED_DATA_EDIT")
)
return list(rows.all())
async def _bound(session: AsyncSession, document: Document) -> set[object]:
rows = await session.scalars(
select(ConsolidationDocument.consolidation_version_id).where(ConsolidationDocument.document_id == document.id)
)
return set(rows.all())
async def _simple(session: AsyncSession, status: DocumentStatus = DocumentStatus.RECOGNIZED) -> Document:
entity = await _rostov_entity(session)
file, job = await make_stored_file(session, entity=entity, status=FileStatus.PROCESSED)
return await make_document(session, file=file, job=job, status=status)
async def test_conflict_order_type_then_superseded(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""DOCUMENT_NOT_OSV раньше FILE_SUPERSEDED, а тот раньше проверок периода и счёта."""
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED)
balance = await make_document(seeded, file=file, job=job)
balance.document_type = DocumentType.BALANCE
balance.osv_account_code = None
osv = await make_document(seeded, file=file, job=job, account="62")
await seeded.flush()
client = await auth_client(KA_RF)
bad_body = {"period_quarter": "Q9", "osv_account_code": " "}
response = await client.patch(f"/documents/{balance.id}/osv-header", json=bad_body)
assert response.status_code == HTTPStatus.CONFLICT
assert response.json()["detail"]["code"] == "DOCUMENT_NOT_OSV"
response = await client.patch(f"/documents/{osv.id}/osv-header", json=bad_body)
assert response.status_code == HTTPStatus.CONFLICT
assert response.json()["detail"]["code"] == "FILE_SUPERSEDED"
await seeded.refresh(osv)
assert osv.osv_account_code == "62" and osv.original_values is None
async def test_conflict_order_period_account_duplicate_version(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Порядок: INVALID_PERIOD, INVALID_ACCOUNT_CODE, DUPLICATE_ACCOUNT, VERSION_NOT_EDITABLE; запись после всех."""
version, members, _, _ = await _version(seeded, members=1, locked_by=None)
file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED)
doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
twin = await make_document(seeded, file=file, job=job, account="62.01")
consolidation = await seeded.get(Consolidation, version.consolidation_id)
assert consolidation is not None
verified = await make_version(
seeded,
consolidation=consolidation,
version_no=2,
members=members,
initiator=await _user(seeded, KA_RF),
status=ConsolidationStatus.VERIFIED,
)
seeded.add_all(
[ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, verified)]
)
await seeded.flush()
client = await auth_client(KA_RF)
url = f"/documents/{doc.id}/osv-header"
# период проверяется раньше счёта и коллизии
response = await client.patch(
url, json={"period_quarter": "Q1", "is_cumulative": False, "osv_account_code": "62.1"}
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "INVALID_PERIOD"
# пустой счёт — раньше коллизии и версий
response = await client.patch(url, json={"osv_account_code": " "})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "INVALID_ACCOUNT_CODE"
# коллизия по нормализованному счёту раньше VERSION_NOT_EDITABLE
response = await client.patch(url, json={"osv_account_code": "62.1"})
assert response.status_code == HTTPStatus.CONFLICT
assert response.json()["detail"]["code"] == "DUPLICATE_ACCOUNT"
assert response.json()["detail"]["document_id"] == str(twin.id)
# без коллизии — запрещающая версия
response = await client.patch(url, json={"osv_account_code": "70"})
assert response.status_code == HTTPStatus.CONFLICT
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
assert response.json()["detail"]["version_ids"] == [str(verified.id)]
await seeded.refresh(doc)
assert doc.osv_account_code == "60" and doc.status is DocumentStatus.CONFIRMED
assert doc.original_values is None and doc.is_modified is False
assert await _bound(seeded, doc) == {version.id, verified.id}
assert await _events(seeded, doc) == []
async def test_noop_patch_changes_nothing(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Совпадающая шапка: 200, без аудита, original_values, сброса подтверждения и modified_by/at."""
version, members, _, _ = await _version(seeded, members=1, locked_by=None)
file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED)
doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=doc.id))
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{doc.id}/osv-header", json=ORIGINAL)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["status"] == "CONFIRMED" and body["is_modified"] is False and body["original_values"] is None
await seeded.refresh(doc)
assert doc.modified_by is None and doc.modified_at is None
assert await _bound(seeded, doc) == {version.id}
assert await _events(seeded, doc) == []
async def test_original_saved_once_and_is_modified_resets(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Снимок original_values — при первой правке и больше не меняется; is_modified зависит от отличия от снимка."""
doc = await _simple(seeded)
client = await auth_client(KA_RF)
url = f"/documents/{doc.id}/osv-header"
user = await _user(seeded, KA_RF)
body = (await client.patch(url, json={"osv_account_code": "62"})).json()
assert body["osv_account_code"] == "62" and body["is_modified"] is True
assert body["original_values"] == ORIGINAL
body = (await client.patch(url, json={"osv_account_code": "76"})).json()
assert body["osv_account_code"] == "76" and body["is_modified"] is True
assert body["original_values"] == ORIGINAL
body = (await client.patch(url, json={"period_year": 2024, "period_quarter": "Q3", "is_cumulative": False})).json()
assert (body["period_year"], body["period_quarter"], body["is_cumulative"]) == (2024, "Q3", False)
assert body["original_values"] == ORIGINAL
# возврат всех четырёх полей к снимку
response = await client.patch(url, json=ORIGINAL)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["is_modified"] is False and body["original_values"] == ORIGINAL
await seeded.refresh(doc)
assert doc.modified_by == user.id and doc.modified_at is not None
# по одному событию на фактическую правку (4), старые не переписываются
events = await _events(seeded, doc)
assert len(events) == len(("62", "76", "период", "возврат"))
afters = [e.after_value or {} for e in events]
assert sorted(a["osv_account_code"] for a in afters if "osv_account_code" in a) == ["60", "62", "76"]
# частичный возврат: только счёт — снова изменён
body = (await client.patch(url, json={"period_year": 2023})).json()
assert body["is_modified"] is True and body["original_values"] == ORIGINAL
async def test_confirmed_reset_unbinds_only_working_versions_with_single_audit(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""CONFIRMED -> NEEDS_CHECK, снятие привязок «в работе», CANCELLED остаётся, одно событие аудита."""
version, members, _, _ = await _version(seeded, members=1, locked_by=None)
file, job = await make_stored_file(seeded, entity=members[0], status=FileStatus.PROCESSED)
doc = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
user = await _user(seeded, KA_RF)
confirmed_at = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
doc.confirmed_by, doc.confirmed_at = user.id, confirmed_at
consolidation = await seeded.get(Consolidation, version.consolidation_id)
assert consolidation is not None
second = await make_version(
seeded,
consolidation=consolidation,
version_no=2,
members=members,
initiator=user,
status=ConsolidationStatus.INITIATED,
)
cancelled = await make_version(
seeded,
consolidation=consolidation,
version_no=3,
members=members,
initiator=user,
status=ConsolidationStatus.CANCELLED,
)
seeded.add_all(
[ConsolidationDocument(consolidation_version_id=v.id, document_id=doc.id) for v in (version, second, cancelled)]
)
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "NEEDS_CHECK"
await seeded.refresh(doc)
assert doc.status is DocumentStatus.NEEDS_CHECK and doc.confirmed_by is None and doc.confirmed_at is None
assert await _bound(seeded, doc) == {cancelled.id}
statuses = {
v.id: v.status
for v in (
await seeded.scalars(
select(ConsolidationVersion).where(ConsolidationVersion.id.in_([version.id, second.id, cancelled.id]))
)
).all()
}
assert statuses == {
version.id: ConsolidationStatus.DATA_CORRECTION,
second.id: ConsolidationStatus.INITIATED,
cancelled.id: ConsolidationStatus.CANCELLED,
}
(event,) = await _events(seeded, doc)
assert event.user_id == user.id and event.object_type == "document"
assert event.before_value["osv_account_code"] == "60"
assert event.before_value["status"] == "CONFIRMED"
assert event.before_value["confirmed_by"] == str(user.id)
assert event.before_value["confirmed_at"] is not None
assert event.after_value["osv_account_code"] == "62"
assert event.after_value["status"] == "NEEDS_CHECK"
assert event.after_value["confirmed_by"] is None and event.after_value["confirmed_at"] is None
assert sorted(event.after_value["unbound_version_ids"]) == sorted([str(version.id), str(second.id)])
async def test_unconfirmed_document_keeps_status_and_audit_has_no_reset_fields(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Неподтверждённый документ сохраняет статус; в аудите нет status и unbound_version_ids."""
doc = await _simple(seeded, DocumentStatus.NEEDS_CHECK)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{doc.id}/osv-header", json={"osv_account_code": "62"})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "NEEDS_CHECK"
(event,) = await _events(seeded, doc)
assert event.before_value == {"osv_account_code": "60"}
assert event.after_value == {"osv_account_code": "62"}
async def test_duplicate_account_after_normalization_and_entries_untouched(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""DUPLICATE_ACCOUNT по нормализованному счёту; успешная правка не меняет osv_entry."""
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED)
doc = await make_document(seeded, file=file, job=job)
twin = await make_document(seeded, file=file, job=job, account="60.01")
entry = await make_osv_entry(
seeded, recognition_job_id=job.id, document_id=doc.id, owner_legal_entity_id=entity.id, row_number=1
)
before = {c.name: getattr(entry, c.name) for c in OsvEntry.__table__.columns}
count = await seeded.scalar(select(func.count()).select_from(OsvEntry))
client = await auth_client(KA_RF)
url = f"/documents/{doc.id}/osv-header"
response = await client.patch(url, json={"osv_account_code": "60.1"})
assert response.status_code == HTTPStatus.CONFLICT
assert response.json()["detail"]["code"] == "DUPLICATE_ACCOUNT"
assert response.json()["detail"]["document_id"] == str(twin.id)
await seeded.refresh(doc)
assert doc.osv_account_code == "60" and doc.original_values is None
response = await client.patch(url, json={"osv_account_code": "61"})
assert response.status_code == HTTPStatus.OK, response.text
await seeded.refresh(entry)
assert {c.name: getattr(entry, c.name) for c in OsvEntry.__table__.columns} == before
assert await seeded.scalar(select(func.count()).select_from(OsvEntry)) == count
assert (await seeded.get(StoredFile, file.id)) is not None

View File

@ -642,20 +642,31 @@ async def test_cors_prod_wildcard_without_credentials(tmp_path: Any) -> None:
assert "access-control-allow-credentials" not in response.headers
@pytest.mark.parametrize("update", [{"DEBUG": True}, {"DEBUG": False, "CORS_ORIGINS": [ORIGIN]}])
async def test_cors_exposes_content_disposition(tmp_path: Any, update: dict[str, Any]) -> None:
"""Фронт на другом источнике читает имя файла экспорта из Content-Disposition (qa-guide W35, arm-core #173)."""
response = await _request(_app_with(tmp_path, **update), "GET", "/healthcheck", headers={"Origin": ORIGIN})
assert response.headers["access-control-expose-headers"] == "Content-Disposition"
def test_cors_options_rules() -> None:
base = app_module.settings
assert app_module.cors_options(base.model_copy(update={"DEBUG": True, "CORS_ORIGINS": []})) == {
"allow_origins": ["*"],
"allow_credentials": False,
"expose_headers": ["Content-Disposition"],
}
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": []})) is None
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": [ORIGIN]})) == {
"allow_origins": [ORIGIN],
"allow_credentials": True,
"expose_headers": ["Content-Disposition"],
}
assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": ["*", ORIGIN]})) == {
"allow_origins": ["*", ORIGIN],
"allow_credentials": False,
"expose_headers": ["Content-Disposition"],
}

View File

@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None:
def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None:
lines = dbml_file.read_text(encoding="utf-8").splitlines()
newer = tmp_path / "data-model-v5.20.dbml"
# шапка 5.19 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.19", "v5.20") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.20 не совпадает с версией ORM 5.19: обновите модели"])
newer = tmp_path / "data-model-v5.24.dbml"
# шапка 5.23 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.23", "v5.24") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели"])
def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None:
newer = tmp_path / "data-model-v5.20.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.19", "v5.20", 1), encoding="utf-8")
newer = tmp_path / "data-model-v5.24.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.23", "v5.24", 1), encoding="utf-8")
result = _run_cli(str(newer))
assert result.returncode == 1
assert result.stdout == "версия DBML 5.20 не совпадает с версией ORM 5.19: обновите модели\n"
assert result.stdout == "версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели\n"
def test_header_without_version_is_usage_error(tmp_path: Path) -> None:

View File

@ -0,0 +1,47 @@
"""Частичные правки шапки ОСВ и доменные ошибки до записи в БД."""
import pytest
from src.common.exceptions import DomainValidationError
from src.domain.osv_header import corrected_header
CURRENT = {"period_year": 2025, "period_quarter": "Q3", "is_cumulative": True, "osv_account_code": "60"}
@pytest.mark.parametrize(
"patch",
[
{"period_year": 1899},
{"period_year": 2100},
{"period_year": True},
{"period_year": "2025"},
{"period_quarter": "Q0"},
{"period_quarter": []},
{"period_quarter": None},
{"is_cumulative": None},
{"is_cumulative": "true"},
{"period_quarter": "Q1", "is_cumulative": False},
],
)
def test_invalid_period(patch: dict[str, object]) -> None:
with pytest.raises(DomainValidationError) as error:
corrected_header(CURRENT, patch)
assert error.value.code == "INVALID_PERIOD"
@pytest.mark.parametrize("year", [1900, 2099])
def test_valid_year_boundaries(year: int) -> None:
assert corrected_header(CURRENT, {"period_year": year})["period_year"] == year
def test_account_only_does_not_require_period() -> None:
current = dict(CURRENT, period_quarter=None)
assert corrected_header(current, {"osv_account_code": "60.1"}) == dict(current, osv_account_code="60.01")
assert corrected_header(CURRENT, {}) == CURRENT
@pytest.mark.parametrize("account", [None, "", " ", 60, []])
def test_empty_or_invalid_account(account: object) -> None:
with pytest.raises(DomainValidationError) as error:
corrected_header(CURRENT, {"osv_account_code": account})
assert error.value.code == "INVALID_ACCOUNT_CODE"

View File

@ -26,27 +26,19 @@ def _rule() -> SimpleNamespace:
("rule", "version_no", "expected"),
[
(None, 3, False),
(SimpleNamespace(version_no=3, valid_to=None), None, True),
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), None, False),
(SimpleNamespace(version_no=3), 2, False),
(SimpleNamespace(version_no=3), 3, True),
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), 3, True),
],
)
def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int | None, expected: bool) -> None:
def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int, expected: bool) -> None:
assert rules_repo.is_valid_for_version(rule, version_no) is expected
async def test_rules_filter_by_version_or_open_valid_to() -> None:
async def test_rules_filter_by_fixed_version() -> None:
version_no = 3
session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: [])))
await rules_repo.list_rules_with_accounts(session, None)
query = str(session.execute.await_args.args[0])
assert "valid_to IS NULL" in query
assert "max(" in query
assert "version_no = (SELECT" in query
await rules_repo.list_rules_with_accounts(session, version_no)
assert "valid_to IS NULL" not in str(session.execute.await_args.args[0])
assert "version_no =" in str(session.execute.await_args.args[0])
@ -73,41 +65,3 @@ async def test_fixed_rules_use_version_number(monkeypatch: pytest.MonkeyPatch) -
assert [item.id for item in result.rules] == [rule.id]
assert result.rules[0].company_1.accounts == [account_id]
listed.assert_awaited_once_with(session, rule.version_no)
async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch) -> None:
session, scope = object(), object()
version_id = uuid.uuid4()
rule = _rule()
monkeypatch.setattr(
service_module.consolidations_repo,
"get_version",
AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)),
)
listed = AsyncMock(return_value=[(rule, None)])
monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed)
result = await service_module.EliminationRuleService(session, scope).list_rules(version_id)
assert result.is_version_fixed is False
assert result.version_no == rule.version_no
assert [item.id for item in result.rules] == [rule.id]
listed.assert_awaited_once_with(session, None)
async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pytest.MonkeyPatch) -> None:
session, scope = object(), object()
monkeypatch.setattr(
service_module.consolidations_repo,
"get_version",
AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)),
)
listed = AsyncMock(return_value=[])
monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed)
result = await service_module.EliminationRuleService(session, scope).list_rules(uuid.uuid4())
assert result.is_version_fixed is False
assert result.version_no is None
assert result.rules == []
listed.assert_awaited_once_with(session, None)

View File

@ -1,4 +1,4 @@
"""Enum'ы ORM против блоков `Enum {}` DBML v5.19: имена, значения и их порядок (без БД)."""
"""Enum'ы ORM против блоков `Enum {}` DBML v5.23: имена, значения и их порядок (без БД)."""
import enum
import re
@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 33
def test_dbml_version_format() -> None:
assert re.fullmatch(r"\d+\.\d+", DBML_VERSION)
assert DBML_VERSION == "5.19"
assert DBML_VERSION == "5.23"
assert f"v{DBML_VERSION}" in dbml_path().name

View File

@ -136,10 +136,6 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
assert captured[0].company_2_account_ids == {parent.id, child.id}
assert captured[0].measure == "turnover"
version.elimination_rules_version = None
monkeypatch.setattr(
matrix.elimination_rules_repo, "current_open_version_no", AsyncMock(return_value=rule.version_no)
)
_, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4())
assert info.mode == "PRESET"
assert info.debit_account_codes == ("76",)

View File

@ -37,7 +37,10 @@ from tests.test_models_part_07_09 import (
_check_active_lock_is_unique,
)
HEAD = "0007_dbml_v5_19"
V5_19 = "0007_dbml_v5_19"
V5_20 = "0008_dbml_v5_20"
V5_21 = "0009_dbml_v5_21"
HEAD = "0010_dbml_v5_23"
V5_18 = "0006_dbml_v5_18"
V5_17 = "0005_dbml_v5_17"
V5_16 = "0004_dbml_v5_16"
@ -584,6 +587,9 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N
),
ids,
)
await _insert_rule(
conn, version_no=1, valid_from=datetime.date(2020, 1, 1)
) # v5.23: версиям нужна версия правил
await alembic(scratch_db_url, "upgrade", "head")
async with engine.begin() as conn:
@ -911,7 +917,7 @@ async def _expect_downgrade_0007_refused(scratch_db_url: str, mutate_sql: str, r
await alembic(scratch_db_url, "downgrade", V5_18)
assert f"откат v5.19 невозможен: {reason}: 1" in str(error.value)
async with engine.connect() as conn:
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == ["0007_dbml_v5_19"]
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [HEAD]
assert await _count(conn, "financial_statement_line") == lines_before # данные не изменены
assert await _count(conn, "document") == documents_before
assert await _scalars(
@ -1295,7 +1301,10 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
(V5_16, V5_15),
(V5_17, V5_16),
(V5_18, V5_17),
(HEAD, V5_18),
(V5_19, V5_18),
(V5_20, V5_19),
(V5_21, V5_20),
(HEAD, V5_21),
):
assert (
f"UPDATE dkb.alembic_version SET version_num='{new_version}' "
@ -1308,3 +1317,175 @@ def test_cluster_commands_use_service_database() -> None:
assert service_url().database == "postgres"
base = base_url()
assert (service_url().host, service_url().port, service_url().username) == (base.host, base.port, base.username)
@pytest.mark.integration
async def test_v5_21_downgrade_preserves_document_fields(scratch_db_url: str) -> None:
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", "head")
await alembic(scratch_db_url, "downgrade", V5_20)
async with engine.connect() as conn:
names = await _scalars(
conn,
"SELECT column_name FROM information_schema.columns WHERE table_schema='dkb' AND table_name='document'",
)
assert {"original_values", "is_modified", "modified_by", "modified_at"} <= set(names)
labels = await _scalars(conn, "SELECT unnest(enum_range(NULL::dkb.audit_action))::text")
assert "FILE_REPROCESS" not in labels
await engine.dispose()
await alembic(scratch_db_url, "upgrade", "head")
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO dkb.audit_event "
"(id, occurred_at, action, section, object_type) "
"VALUES (:id, now(), 'FILE_REPROCESS', 'PRIMARY_DOCUMENTS', 'stored_file')"
),
{"id": uuid.uuid4()},
)
with pytest.raises(AssertionError, match="откат v5.21 невозможен"):
await alembic(scratch_db_url, "downgrade", V5_20)
async with engine.connect() as conn:
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [HEAD]
assert await _count(conn, "audit_event") == 1
finally:
await engine.dispose()
@pytest.mark.integration
async def test_v5_20_downgrade_refuses_modified_document(scratch_db_url: str) -> None:
engine = create_async_engine(scratch_db_url)
ids = {k: uuid.uuid4() for k in _SEED_KEYS}
try:
await alembic(scratch_db_url, "upgrade", V5_18)
async with engine.begin() as conn:
await _seed_v5_18_statements(conn, ids)
await alembic(scratch_db_url, "upgrade", V5_20)
async with engine.begin() as conn:
await conn.execute(text("UPDATE dkb.document SET is_modified=true"))
with pytest.raises(AssertionError, match="откат v5.20 невозможен"):
await alembic(scratch_db_url, "downgrade", V5_19)
async with engine.connect() as conn:
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [V5_20]
assert await conn.scalar(text("SELECT count(*) FROM dkb.document WHERE is_modified"))
finally:
await engine.dispose()
async def _insert_rule(
conn: AsyncConnection, *, version_no: int, valid_from: datetime.date, valid_to: datetime.date | None = None
) -> None:
await conn.execute(
text(
"INSERT INTO dkb.elimination_rule (id, statement_type, rule_no, article_1_line, article_2_line, side_1, "
"measure, elimination_mode, excise_adjust, version_no, valid_from, valid_to) "
"VALUES (gen_random_uuid(), 'balance', 6, '1230', '1520', 'D', 'closing', 'min', false, :no, "
":start, :end)"
),
{"no": version_no, "start": valid_from, "end": valid_to},
)
async def _insert_versions(conn: AsyncConnection, created: dict[str, datetime.datetime | None]) -> dict[str, uuid.UUID]:
"""Версии консолидации v5.21 без номера правил; ключ — метка, значение — `created_at` (None — пусто)."""
ids = {k: uuid.uuid4() for k in ("office", "group", "user", "c")}
await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:office, 'ГО')"), ids)
await conn.execute(
text(
"INSERT INTO dkb.company_group (id, code, name, base_regional_office_id, valid_from) "
"VALUES (:group, 'G-1', 'ГК', :office, now())"
),
ids,
)
await conn.execute(
text(
"INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) VALUES (:user, 'm@x.ru', 'Т', :office)"
),
ids,
)
await conn.execute(
text(
"INSERT INTO dkb.consolidation (id, company_group_id, year, quarter, reporting_date) "
"VALUES (:c, :group, 2026, 'Q1', '2026-04-01')"
),
ids,
)
versions: dict[str, uuid.UUID] = {}
for no, (label, created_at) in enumerate(created.items(), start=1):
versions[label] = uuid.uuid4()
await conn.execute(
text(
"INSERT INTO dkb.consolidation_version (id, consolidation_id, version_no, status, scenario, approach, "
"goodwill_enabled, initiator_id, initiator_office_id, is_in_work, created_at) "
"VALUES (:id, :c, :no, 'INITIATED', 'CREATE', 'BASIC', false, :user, :office, true, "
":created)"
),
ids | {"id": versions[label], "no": no, "created": created_at},
)
return versions
@pytest.mark.integration
async def test_upgrade_0010_fills_rules_version_by_creation_date(scratch_db_url: str) -> None:
"""v5.23: номер правил — действующих на дату создания версии (по Москве), без даты — на сегодня; затем NOT NULL."""
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", V5_21)
async with engine.begin() as conn:
await _insert_rule(
conn, version_no=1, valid_from=datetime.date(2020, 1, 1), valid_to=datetime.date(2026, 6, 30)
)
await _insert_rule(conn, version_no=2, valid_from=datetime.date(2026, 7, 1))
versions = await _insert_versions(
conn,
{
"spring": datetime.datetime(2026, 3, 1, 9, tzinfo=datetime.UTC),
# 30.06 21:30 UTC — уже 01.07 по Москве
"edge": datetime.datetime(2026, 6, 30, 21, 30, tzinfo=datetime.UTC),
"undated": None,
},
)
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
rows = dict(
(await conn.execute(text("SELECT id, elimination_rules_version FROM dkb.consolidation_version"))).all()
)
assert rows == {versions["spring"]: 1, versions["edge"]: 2, versions["undated"]: 2}
nullable = await _scalars(
conn,
"SELECT is_nullable FROM information_schema.columns WHERE table_schema='dkb' "
"AND table_name='consolidation_version' AND column_name='elimination_rules_version'",
)
assert nullable == ["NO"]
await alembic(scratch_db_url, "downgrade", V5_21)
async with engine.connect() as conn:
nullable = await _scalars(
conn,
"SELECT is_nullable FROM information_schema.columns WHERE table_schema='dkb' "
"AND table_name='consolidation_version' AND column_name='elimination_rules_version'",
)
assert nullable == ["YES"]
assert (
await conn.scalar(
text("SELECT count(*) FROM dkb.consolidation_version WHERE elimination_rules_version IS NULL")
)
== 0
)
finally:
await engine.dispose()
@pytest.mark.integration
async def test_upgrade_0010_refuses_versions_without_rules(scratch_db_url: str) -> None:
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", V5_21)
async with engine.begin() as conn:
await _insert_versions(conn, {"lonely": datetime.datetime(2026, 3, 1, 9, tzinfo=datetime.UTC)})
with pytest.raises(AssertionError, match="миграция v5.23 невозможна"):
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [V5_21]
finally:
await engine.dispose()

View File

@ -210,6 +210,7 @@ async def _check_active_lock_is_unique(session: AsyncSession) -> None:
version_no=1,
status=ConsolidationStatus.INITIATED,
scenario=ConsolidationScenario.CREATE,
elimination_rules_version=1,
initiator_id=user.id,
initiator_office_id=office.id,
)
@ -277,6 +278,7 @@ def test_all_nullable_jsonb_columns_are_listed() -> None:
"recognition_job.group_schema",
"recognition_job.metrics",
"osv_entry.original_values",
"document.original_values",
"financial_statement_meta.needs_review_fields",
"financial_statement_meta.header_source",
"document_issue.line_ids",

View File

@ -53,6 +53,9 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files": {"get", "post"}, # v5.18
"/api/v1/files/{file_id}": {"get", "delete"},
"/api/v1/files/{file_id}/content": {"get"},
"/api/v1/files/{file_id}/retry": {"post"},
"/api/v1/consolidations/versions/{version_id}/files/retry": {"post"},
"/api/v1/documents/{document_id}/osv-header": {"patch"},
"/api/v1/documents/{document_id}": {"get"},
"/api/v1/documents/{document_id}/entries": {"get"},
"/api/v1/documents/{document_id}/confirm": {"post"},
@ -76,6 +79,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
# шахматка ВГО и правила элиминации
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-totals": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},
@ -287,3 +291,12 @@ def test_version_member_declares_type_and_osv_accounts(schema: dict[str, Any]) -
"""front-api-gaps D-3: участник периметра — тип ЮЛ и счета ОСВ к периоду версии."""
member = schema["components"]["schemas"]["MemberOut"]
assert {"entity_type", "osv_accounts", "checklist"} <= set(member["required"])
def test_retry_routes_declare_different_conflicts(schema: dict[str, Any]) -> None:
single = schema["paths"]["/api/v1/files/{file_id}/retry"]["post"]["responses"]["409"]["description"]
batch = schema["paths"]["/api/v1/consolidations/versions/{version_id}/files/retry"]["post"]["responses"]["409"][
"description"
]
assert "FILE_NOT_RETRYABLE" in single and "MAX_ATTEMPTS_REACHED" in single
assert batch == "VERSION_NOT_EDITABLE"

View File

@ -0,0 +1,52 @@
"""Типы PATCH проверяются до домена, значения и частичная шапка — в домене."""
import pytest
from pydantic import ValidationError
from src.schemas.documents import OsvHeaderPatch
@pytest.mark.parametrize("year", ["abc", "2025", 2025.5, 2025.0, True, [], {}])
def test_year_type_is_strict(year: object) -> None:
with pytest.raises(ValidationError) as error:
OsvHeaderPatch.model_validate({"period_year": year})
assert error.value.errors()[0]["loc"] == ("period_year",)
@pytest.mark.parametrize(
"field,value",
[
("period_quarter", 3),
("period_quarter", []),
("osv_account_code", 60),
("osv_account_code", {}),
("is_cumulative", "true"),
("is_cumulative", 1),
("is_cumulative", 0),
],
)
def test_other_types_are_strict(field: str, value: object) -> None:
with pytest.raises(ValidationError):
OsvHeaderPatch.model_validate({field: value})
def test_domain_values_are_not_rejected_by_schema() -> None:
patch = {"period_year": 2100, "period_quarter": "Q0", "is_cumulative": False, "osv_account_code": ""}
assert OsvHeaderPatch.model_validate(patch).model_dump(exclude_unset=True) == patch
def test_null_and_absent_fields_remain_distinct() -> None:
assert OsvHeaderPatch().model_dump(exclude_unset=True) == {}
assert OsvHeaderPatch(period_year=None).model_dump(exclude_unset=True) == {"period_year": None}
def test_openapi_schema_describes_field_types() -> None:
properties = OsvHeaderPatch.model_json_schema()["properties"]
expected = {
"period_year": "integer",
"period_quarter": "string",
"is_cumulative": "boolean",
"osv_account_code": "string",
}
for name, kind in expected.items():
assert properties[name]["anyOf"] == [{"type": kind}, {"type": "null"}]

View File

@ -73,6 +73,7 @@ async def test_list_periods_sorts_periods_and_versions_descending(db_session: As
version_no=2,
status=ConsolidationStatus.INITIATED,
scenario=first.scenario,
elimination_rules_version=first.elimination_rules_version,
initiator_id=user.id,
initiator_office_id=user.regional_office_id,
)

View File

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

View File

@ -0,0 +1,35 @@
## Why
AURORA-1692: на экране документа аналитик перебирает документы организации через выпадающий список «Документ» в шапке,
каждый выбор добавляет запись в историю браузера, и «Назад» ведёт к предыдущему выбранному документу, а не к экрану
консолидации, откуда документ открыли. В ветке `AURORA-1692` (PR RSHB/arm-core#171, автор — Степанов) выбор документа
открывается заменой текущей записи истории. Переключателя нет в спеке `document-review-ui`; спеку ведёт ревьюер
коммитом в ветку автора (правило владельца).
## What Changes
Репозиторий — только arm-core (`web/`), эндпоинты без изменений.
- Выбор в списке «Документ» шапки экрана документа открывает `/documents/:document_id` выбранного документа с заменой
текущей записи истории (`navigate(…, { replace: true })`), без новой записи.
- Дельта `document-review-ui`: ADDED требование «Переключатель документа» — состав списка (как в коде main) и поведение
истории.
## Non-goals
- Переход «Открыть подтверждённую форму» с дубля формы (`StatementReview`) и открытие документа из списка файлов
(`FilesList`) — остаются обычными переходами с новой записью истории.
- Изменения бэкенда и DBML.
## Capabilities
### New Capabilities
### Modified Capabilities
- `document-review-ui`: ADDED требование «Переключатель документа».
## Impact
- `web/src/layouts/DocumentPage.tsx` — одна строка, `onChange` списка «Документ».
- Тестов экрана `DocumentPage` нет; поведение проверяется вручную по сценариям спеки.

View File

@ -0,0 +1,18 @@
## ADDED Requirements
### Requirement: Переключатель документа
В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации,
что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат ·
дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id`
выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера
возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному.
#### Scenario: Назад после перебора документов
- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два
документа и нажал «Назад»
- **THEN** открыт экран консолидации, с которого был открыт первый документ
#### Scenario: Документ открыт прямой ссылкой
- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал
«Назад» экрана
- **THEN** открыт список групп компаний `/groups`

View File

@ -0,0 +1,8 @@
## 1. Автор (RSHB/arm-core#171, AURORA-1692)
- [x] 1.1 `web/src/layouts/DocumentPage.tsx`: выбор в списке «Документ» — `navigate(…, { replace: true })`.
## 2. Ревьюер
- [x] 2.1 Change `document-switcher-history`: proposal, дельта `document-review-ui` (ADDED «Переключатель документа»).
- [x] 2.2 Проверки фронта на ветке: `tsc`, `eslint`, `prettier --check`, `vitest`.

View File

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

View File

@ -0,0 +1,138 @@
# Design
## Context
Мотивация и объём — proposal.md. Сейчас upload создаёт recognition_job в queued, а recognition_exchange.py
публикует outbox, принимает ack/результаты и закрывает просроченные задания. attempt при повторной публикации
не растёт. Механизма ручной новой попытки нет. file_status.py уже защищает файл от событий нетекущего задания.
DocumentService умеет confirm; привязки централизованы в consolidation_documents.py. Модель и миграции — v5.19.
Основание для ОСВ — DBML v5.20 и §9.5 п.12; для повтора — AURORA-1442, DBML v5.21,
§9.4 и §9.5 п.13. FILE_REPROCESS уже опубликован в модели v5.21; изменение enum в v5.20 не требуется.
Пакет по consolidation_id и периметру выбранной версии дополнительно согласован пользователем.
Этот change содержит только план: соседний docs не изменён.
## Goals / Non-Goals
**Goals:** атомарные правки и повтор, сохранение первичных данных и истории, отсутствие дублей при гонках,
единая проверка допустимости для одиночного и пакетного повтора, совместимость с действующим outbox.
**Non-Goals:** новые фоновые воркеры, доставка через JetStream, вычисляемые ORM-таблицы, изменение сумм и
субсчетов ОСВ. Денежные значения сохраняют Decimal/numeric(18,2), рубли; правка шапки их не пересчитывает.
## Decisions
### D1. Сервис ручного повтора и API
Новый services/file_retry.py, фабрика DI и тонкие маршруты:
- POST /api/v1/files/{file_id}/retry → 202 FileOut.
- POST /api/v1/consolidations/versions/{version_id}/files/retry → 202 RetryBatchOut.
- PATCH /api/v1/documents/{id}/osv-header → 200 DocumentOut (endpoints/files/documents.py).
Первые два маршрута не требуют чтения/записи S3 и синхронной публикации NATS: достаточно транзакции outbox.
Для файла повторно используется тот же s3_object; path загрузки содержит id первой попытки, это допустимо:
ключ исходника берётся из реестра, не конструируется из id новой попытки.
Ответы и коды отказов определены в дельтах primary-files/documents-api; новые типы DTO — в schemas/files.py
и schemas/documents.py. Названия retry-маршрутов и 202 — проектное решение, в исходной задаче они не заданы.
### D2. Новая попытка вместо обновления прежнего job
Создать новый UUID job, queued, attempt + 1; перенести file_id, pipeline, max_attempts.
schema_version брать из CONTRACT_VERSION, request_subject — из текущих настроек NATS, как при загрузке;
версию контракта и subject прежнего задания не копировать. Ошибки, метрики, result_s3_object_id, service metadata, message ids
и времена старой попытки не копируются. requested_at — сейчас, reply_deadline формируется публикатором.
Переключить current_recognition_job_id и вернуть файл из ERROR в UPLOADED. Прежний job неизменен.
Это соответствует уточнённой задаче и не требует изменения дедупа парсера по старому job_id (OQ-73).
Автоматический republish остаётся прежним job/attempt; это другая операция.
### D3. Пакет, частичные отказы и версии
Отбор repositories/files.py::retry_candidates выбирает только ERROR-файлы выбранной консолидации
по stored_file.consolidation_id, с ЮЛ из периметра версии и policy ЮЛ, до любых блокировок.
Остальные статусы не блокируются и не попадают в skipped; после блокировки статус ERROR перепроверяется.
Файл без документов не отбирается через consolidation_document. Дедуп по file_id.
Согласованный пользователем общий набор у нескольких версий сохраняется; повтор не создаёт версионной копии файла.
Проектное решение для смешанного пакета: общие ошибки прав/видимости/статуса версии отклоняют запрос целиком;
непригодность конкретного файла возвращается в skipped, остальные принимаются. accepted и skipped стабильны
по file_id. Пустой пакет — 202. Неожиданная ошибка БД откатывает весь запрос, а не маскируется как skipped.
Одна транзакция на запрос, сервис не вызывает commit. Новых переходов статуса версии от ручного retry нет.
Допустимые версии — IN_WORK_STATUSES; брать пользовательскую consolidation_lock не требуется.
Для одиночного файла с consolidation_id проверить наличие такой версии с его ЮЛ; файл без контекста — по policy ЮЛ.
Это консервативное проектное правило для отсутствующего version_id, его нужно явно перенести в §9.4.
### D4. Блокировки и гонки
Retry следует порядку работающего обмена: текущее задание FOR UPDATE → файл FOR UPDATE → версии по id.
В одиночном повторе только рабочие версии берутся FOR SHARE: статус и периметр защищены от изменений,
обычное чтение не блокируется, VERIFIED/CANCELLED вообще не блокируются.
После блокировки файла перечитать current_recognition_job_id; если изменился, одиночный запрос получает
FILE_NOT_RETRYABLE, пакет пропускает файл. Не захватывать новое текущее задание, уже удерживая файл:
это инвертирует порядок обмена. Лимит и отсутствие документов проверять заново под блокировкой.
Для пакета сначала блокировать все отобранные прежние jobs по id, затем файлы по id, затем версии по id;
после захвата версий перечитать статус и состав периметра. Не начинать пакет с FOR UPDATE версии.
OpenAPI одиночного повтора описывает FILE_NOT_RETRYABLE/MAX_ATTEMPTS_REACHED/VERSION_NOT_EDITABLE;
для пакетного 409 только VERSION_NOT_EDITABLE, остальные причины возвращаются в skipped.
Для PATCH — файл → документ FOR NO KEY UPDATE → существующая advisory-блокировка привязок ГК → версии
по id, как при confirm/delete. Проверить связанные статусы под блокировками и не использовать
lock_editable_version, требующий пользовательскую consolidation_lock. Сериализация на файле защищает
счёт от параллельного PATCH; уникальный индекс остаётся последней защитой, IntegrityError переводится
в DUPLICATE_ACCOUNT через SAVEPOINT с повторным чтением конфликтующего document_id.
### D5. Шапка и подтверждение
OsvHeaderPatch использует StrictInt для period_year, StrictStr для period_quarter/osv_account_code,
StrictBool для is_cumulative; каждое поле допускает null, отсутствие определяется exclude_unset.
Pydantic возвращает стандартный 422 для неверного типа до вызова обработчика. Порядок доменных
ошибок §9.5 п.12 применяется к телу с корректными типами; границы года и набор кварталов проверяет домен.
Чистая функция domain/osv_header.py строит итоговую шапку, нормализует счёт существующей
normalize_account_code, различает отсутствующие и переданные поля, проверяет целостность периода.
ORM-операции — отдельный services/osv_header.py с доступом из DocumentService/DI.
Пустое тело или нормализованное равенство текущему — no-op; проверки доступа и конфликтов сохраняются.
Перед первой фактической правкой сохранить все четыре исходных поля JSON, используя сериализуемые
строки enum. На каждой правке сравнивать итог с неизменным original_values. При возврате исходного
is_modified снимается, original_values и история остаются. Не создавать новый document/file_ordinal.
У CONFIRMED снять подтверждение и только рабочие привязки; CANCELLED сохранить. Другие статусы не менять.
period_check/counterparty_check и document_issue не пересчитываются этим PATCH.
Повторный confirm использует существующие правила привязки по исправленным полям, автозамена файлов не вызывается.
### D6. Аудит и согласование модели
FILE_REPROCESS — значение audit_action из DBML v5.21; раздел PRIMARY_DOCUMENTS, объект stored_file,
одно событие на принятый файл. before_value — прежнее задание: job_id, attempt, status задания, error_code;
after_value — новое задание: job_id, attempt. Для пакета в after_value добавить consolidation_id.
Сохранить автора, IP и User-Agent. Статус файла в этот payload не подставляется.
Автоматический обмен по OQ-55 по-прежнему без такого аудита. PATCH — RECOGNIZED_DATA_EDIT, RECOGNITION,
объект document, изменённые поля и сброшенное подтверждение, unbound_version_ids. Всё в транзакции мутации.
Переносить аудит повтора на FILE_UPLOAD отвергнуто: физической загрузки нет.
## Risks / Trade-offs
- Поздний ответ старого job → существующая защита финального статуса и current job, регрессия inbox и маппера.
- Взаимоблокировка с confirm/delete/status → единый порядок и интеграционные тесты на отдельных соединениях.
- Пакет охватывает общие файлы консолидации → явно описанный состав и повторная проверка текущего job.
- Расхождение с источником истины → сверка ORM с опубликованной DBML v5.21; исходная v5.20 остаётся неизменной.
- Папка ../fixtures сейчас недоступна → до golden/e2e обеспечить доступ к репозиторию реальных ОСВ РБ;
отсутствие данных не считать успешной проверкой. Гонки и статусы проверяются фабриками БД без поддельных форматов.
- Временный флаг периода может маскировать исправление периода → тесты с флагом false и true, сам флаг не удалять.
- Откат после возврата к исходному удаляет original_values при is_modified=false → следовать утверждённому
условию v5.20; историю правок сохраняет audit_event, исходник/JSON остаются в S3; ограничение описать в README.
## Migration Plan
1. Использовать опубликованные DBML v5.20/v5.21 и §9.5 п.12–13. В docs уточнить согласованный отбор пакета
и описание API; повторно вводить FILE_REPROCESS в модель не требуется.
2. Миграция 0008_dbml_v5_20 после 0007: только четыре колонки document; старые строки получают
is_modified=false, остальные NULL. Существующие документы и привязки не меняются.
3. Отдельная миграция 0009_dbml_v5_21 после 0008: только FILE_REPROCESS в audit_action,
после FILE_REPLACE как в DBML. Итоговые DBML_VERSION, путь Makefile и сверка схемы — v5.21.
4. Сначала схема, затем приложение; перезапуск фоновых задач обычным порядком. Старые job и файлы не мигрировать.
5. Downgrade 0009 → 0008 запрещён до DDL при наличии FILE_REPROCESS в аудите, события не переписываются.
На совместимых данных восстанавливается enum v5.20 без FILE_REPROCESS. Downgrade 0008 → 0007
отдельно запрещён при document.is_modified=true; иначе удаляются только четыре колонки.
Проверить оба перехода по отдельности, полный цикл на чистой/непустой БД и нестандартный DB_SCHEMA.
6. Обновить frontend-api-guide и README; UI не выкладывается этой задачей.

View File

@ -0,0 +1,60 @@
# Proposal
## Why
AURORA-1442: после TIMEOUT/NO_ACK аналитик не может повторить обработку уже загруженного файла;
ошибочный период или счёт ОСВ нельзя уточнить через API. DBML v5.20 и решение OQ-161 от 21.10.2026 уже задают
хранение исходных значений рядом с исправленными и правила снятия подтверждения.
## What Changes
- Завершение остатка Среза 2c (§9.4–9.5 правил), с интеграцией с версиями Среза 4: ручной повтор одного файла
и подходящих файлов пакета версии. Новое recognition_job с новым id и attempt + 1, прежнее остаётся в истории.
- Повтор только tabular после timeout/TIMEOUT или dlq/NO_ACK, до max_attempts; OCR и другие причины исключены.
- PATCH /api/v1/documents/{id}/osv-header по §9.5 п.12: период и счёт меняются на месте, исходные четыре поля
сохраняются в document.original_values; копий document и правок file_ordinal нет.
- DBML v5.20: четыре колонки document, одна добавочная миграция; сброс подтверждения и снятие привязок,
ручное переподтверждение, аудит RECOGNIZED_DATA_EDIT. Строки osv_entry и первичка в S3 неизменны.
- DBML v5.21: отдельная миграция enum audit_action с FILE_REPROCESS по §9.5 п.13.
- Документировать новые API, ошибки и ограничения; временный CONSOLIDATION_PERIOD_FROM_VERSION сохраняется.
## Capabilities
### New Capabilities
Нет: расширяются существующие возможности.
### Modified Capabilities
- `primary-files`: API ручной повторной обработки одного файла и пакета версии, права и ограничения.
- `recognition-job-exchange`: новая попытка через существующий outbox, сохранение истории и защита от старых ответов.
- `documents-api`: уточнение шапки ОСВ, сохранение исходных значений, сброс подтверждения и привязок.
- `data-model`: переход к DBML v5.21 через отдельные миграции v5.20 (document) и v5.21 (audit_action).
- `audit-trail`: RECOGNIZED_DATA_EDIT для шапки ОСВ и согласованный FILE_REPROCESS для каждого ручного повтора файла.
## Impact
Реализация — arm-core; документация — arm-ka-docs (`../docs`), отдельный согласованный docs change/PR для уточнения
§9.4–9.5 (п.12–13) и frontend-api-guide. Схема для ОСВ утверждена в v5.20, аудит повтора — в v5.21;
целевой источник истины — model/data-model-v5.21.dbml и entities-v5.21.md;
упоминания v5.17 в конфигурации OpenSpec устарели и не отменяют явно запрошенные пользователем v5.20 и v5.21.
Таблицы: document (четыре новых поля), recognition_job, stored_file, consolidation_document, audit_event;
consolidation_version/member читаются для статусов и периметра. Новых таблиц не требуется.
Схема и аудит реализуются по опубликованным версиям: v5.20 — четыре поля document,
v5.21 — FILE_REPROCESS в audit_action. Дополнять старую v5.20 новым enum не требуется.
Отдельная миграция v5.21 добавляет только значение enum; откат запрещён при наличии событий FILE_REPROCESS.
Согласованный пакет версии: файлы с stored_file.consolidation_id консолидации выбранной версии и ЮЛ из её
периметра, подходящие по текущему заданию и лимиту попыток. Версии одной консолидации могут видеть те же файлы.
Код: api/src/{models,schemas,domain,services,repositories,api/endpoints/files,api/dependencies/files.py},
api/alembic/versions, api/tests, Makefile, README. Контракты парсеров и фронтенд не меняются.
Основание: §9.4, §9.5 п.6–7,12–13; правила привязок §3; OQ-161 закрыт вариантом 2, OQ-55 относится к автоматическому обмену.
## Non-goals
- UI повторной обработки и правки ОСВ (отдельные задачи фронта, в том числе AURORA-1679).
- Повтор OCR, error результата сервиса, ошибок маппинга, автоматический retry с увеличением attempt.
- Копирование документов, перенос ручных правок на повторное распознавание, изменение строк ОСВ при смене счёта шапки.
- Отмена временного флага периода, изменение §7 ББ/ОФР, новые механизмы доставки NATS.

View File

@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: Аудит ручной повторной обработки файла
Каждая принятая ручная попытка SHALL писать одно audit_event с action = FILE_REPROCESS,
section = PRIMARY_DOCUMENTS, object_type = stored_file, object_id = file_id, user_id инициатора,
IP и User-Agent запроса. before_value SHALL содержать прежние job_id, attempt, status задания, error_code;
after_value — новые job_id, attempt, а при пакетном повторе также consolidation_id. Событие SHALL фиксироваться в транзакции создания попытки.
#### Scenario: Пакетный повтор
- **WHEN** пакет принял два файла ОСВ РБ и пропустил третий
- **THEN** два FILE_REPROCESS с разными object_id и прежними/новыми job_id, с consolidation_id в after_value;
before_value.status — статус прежнего задания (timeout или dlq), error_code — TIMEOUT или NO_ACK; для skipped события нет
#### Scenario: Отказ или откат
- **WHEN** повтор отклонён либо транзакция создания задания откатывается
- **THEN** FILE_REPROCESS для него отсутствует
#### Scenario: Автоматическая повторная публикация
- **WHEN** outbox повторно публикует прежнее задание без ack, не создавая новую попытку
- **THEN** FILE_REPROCESS не пишется
### Requirement: Аудит уточнения шапки ОСВ
Фактический PATCH шапки ОСВ SHALL писать одно RECOGNIZED_DATA_EDIT раздела RECOGNITION,
object_type = document, object_id документа, user_id инициатора. before/after SHALL содержать изменённые
поля и при сбросе подтверждения status, confirmed_by/at; after_value.unbound_version_ids SHALL содержать
версии снятых привязок. Запрос без изменений MUST NOT писать аудит.
#### Scenario: Подтверждённая ОСВ исправлена
- **WHEN** меняется счёт CONFIRMED ОСВ РБ, привязанной к версии DATA_CORRECTION
- **THEN** одно RECOGNIZED_DATA_EDIT с прежним/новым счётом и подтверждением, after_value содержит
unbound_version_ids, пользователь, IP и User-Agent сохранены
#### Scenario: Возврат к исходному
- **WHEN** аналитик возвращает исходные значения после предыдущей правки
- **THEN** новое RECOGNIZED_DATA_EDIT отражает эту правку; старые события не меняются

View File

@ -0,0 +1,111 @@
## RENAMED Requirements
- FROM: `### Requirement: Схема БД равна DBML v5.19`
- TO: `### Requirement: Схема БД равна DBML v5.21`
## MODIFIED Requirements
### Requirement: Схема БД равна DBML v5.21
ORM-метаданные SHALL содержать ровно 44 таблицы и 33 enum'а из `../docs/model/data-model-v5.21.dbml` в схеме PostgreSQL
из настройки `DB_SCHEMA` (по умолчанию `dkb`; в контуре — `OPENBAO__APP__DB_SCHEMA`; простой идентификатор
`[a-z_][a-z0-9_]{0,62}`, не начинающийся с `pg_`, иначе приложение не стартует); сверка с 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`).
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. Колонка `kpp` (`varchar`, nullable, без индексов и уникальности; уникален только `inn`) SHALL быть
последней в `legal_entity` и `legal_entity_history`; формат и запрет КПП у ИП проверяет ядро. Миграция `0005_dbml_v5_17`
SHALL добавлять обе колонки (существующие записи — `kpp IS NULL`), откат — удалять.
Колонка `stored_file.consolidation_id` (`uuid`, nullable, FK → `consolidation.id`; v5.18) SHALL быть последней в
`stored_file`; индекс `ix_stored_file_legal_entity_id_consolidation_id (legal_entity_id, consolidation_id)`. Миграция
`0006_dbml_v5_18` SHALL добавлять колонку, FK и индекс и заполнять колонку по `consolidation_document`: файлу с документом,
привязанным к версии, — консолидация этой версии; при привязках к версиям разных консолидаций — самый ранний период
(год, затем квартал); файлы без привязок остаются `NULL`; откат SHALL удалять индекс и колонку. Поля, отсутствующие в
DBML, MUST NOT существовать.
Схема v5.19 (RSHB/arm-ka-docs#116) SHALL добавлять: таблицу `document_issue` и enum'ы `statement_line_status`, `statement_form_variant`, `document_issue_kind` и прочие enum'ы DBML v5.19; колонки `document.file_ordinal` (уникальна в паре `(stored_file_id, file_ordinal)`), `recognition_job.statements_version`, `related_group.crm_id` (`varchar`, nullable, уникальна), в `financial_statement_meta` — `form_variant`, `needs_review_fields`, `header_source`, `organization_name_original_value`, `organization_name_modified`; в `financial_statement_column` — `period_kind`, `label_confidence`; в `financial_statement_line` — `row_index`, `source_ref`, `line_status`, `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed`; в `financial_statement_value` — `source_text`, `needs_review`, `is_confirmed` (с `confirmed_by/at`). Уникальность `(meta_id, line_code)` снимается, добавляется частичная уникальность `(meta_id, source_ref)` WHERE `source_ref IS NOT NULL`; уникальность документа `(stored_file_id, document_type, osv_account_code)` становится частичной (только для типов, кроме `BALANCE` / `INCOME`). Состав и порядок колонок — буквально по DBML v5.19.
Схема v5.20 SHALL добавлять в конец `document` колонки `is_modified` (`boolean NOT NULL DEFAULT false`), `original_values`
(`jsonb`, nullable), `modified_by` (`uuid`, nullable, FK → `app_user.id`) и `modified_at` (`timestamptz`, nullable); схема v5.21 SHALL
добавлять значение `FILE_REPROCESS` в enum `audit_action`. Состав и порядок колонок — буквально по DBML v5.21.
#### Scenario: Сверка ORM с DBML
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.21.dbml` (`make dbml-check`)
- **THEN** вывод пуст и код возврата 0
#### Scenario: Лишняя колонка в ORM
- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая)
- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1
#### Scenario: Расхождение типа или nullability
- **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL`
- **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1
#### Scenario: ЮЛ одновременно в ГСК и ГСЗ
- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz`
- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс
#### Scenario: Строка налогового учёта
- **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
#### Scenario: Backfill консолидации файла при миграции 0006
- **WHEN** до миграции 0006 есть файл `A` с документом, привязанным к версии консолидации 2025 Q2 и к версии консолидации
2025 Q1, и файл `B` без привязок
- **THEN** после `alembic upgrade head` у `A` `consolidation_id` = консолидация 2025 Q1, у `B` — NULL; колонка последняя в
`stored_file`; `alembic downgrade 0005_dbml_v5_17` удаляет индекс и колонку, повторный `upgrade` их возвращает
#### Scenario: Сверка с DBML v5.19
- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.21.dbml`
- **THEN** таблицы, колонки, enum'ы, ключи и индексы ORM совпадают с DBML без расхождений
### Requirement: Версия DBML зафиксирована в коде
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.21"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.22.dbml`, а константа в ORM — `5.21`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
## ADDED Requirements
### Requirement: Миграция DBML v5.20
Одна добавочная миграция после 0007_dbml_v5_19 SHALL добавить четыре колонки document по DBML v5.20; существующие записи получают is_modified = false и остальные поля NULL. Миграция MUST NOT менять документы, их строки, подтверждения и привязки. Downgrade SHALL до любых изменений проверить отсутствие document.is_modified = true и при наличии остановиться с ошибкой и числом записей.
#### Scenario: Обновление существующих ОСВ
- **WHEN** миграция применяется к базе с подтверждёнными ОСВ РБ
- **THEN** добавлены четыре поля, данные ОСВ, id, file_ordinal, подтверждения и привязки сохранены; сверка с DBML v5.20 проходит
#### Scenario: Защита от потери исправлений
- **WHEN** хотя бы один документ имеет is_modified = true и запрошен downgrade до v5.19
- **THEN** откат прекращается до DDL с причиной и числом записей, схема и данные не меняются
#### Scenario: Пустая база и нестандартная схема
- **WHEN** выполняется upgrade head, downgrade base, upgrade head на пустой базе с настроенным DB_SCHEMA
- **THEN** цикл успешен, ORM соответствует DBML v5.20, autogenerate пуст
### Requirement: Миграция DBML v5.21 и действие аудита повторной обработки
Отдельная миграция v5.21 после миграции v5.20 SHALL добавить только FILE_REPROCESS в enum audit_action
по DBML v5.21. Миграция SHALL сохранять все существующие значения и события. Downgrade MUST NOT удалять либо
переписывать audit_event; при наличии FILE_REPROCESS он SHALL завершаться ошибкой до изменения схемы.
#### Scenario: Схема после дополнения модели
- **WHEN** миграция v5.21 применена к БД v5.20
- **THEN** событие нового типа записывается, прежние события читаются без изменений, сверка DBML успешна
#### Scenario: Откат после ручного повтора
- **WHEN** существует audit_event с FILE_REPROCESS и выполняется downgrade с v5.21 к v5.20
- **THEN** ошибка с причиной и количеством несовместимых событий до DDL, события и схема сохранены
#### Scenario: Независимый откат enum
- **WHEN** в БД v5.21 нет FILE_REPROCESS и выполняется downgrade к v5.20
- **THEN** удалено только новое значение enum; четыре поля document, исходные значения и привязки сохранены

View File

@ -0,0 +1,110 @@
## ADDED Requirements
### Requirement: Уточнение шапки ОСВ
`PATCH /api/v1/documents/{id}/osv-header` SHALL требовать `DOCUMENT_EDIT` и видимость ЮЛ, принимать частичный набор
`period_year`, `period_quarter`, `is_cumulative`, `osv_account_code` и возвращать карточку документа с
`is_modified`, `original_values`, `modified_by`, `modified_at`. Отсутствующие поля SHALL сохраняться.
Правила — §9.5 п.12. Чужой и несуществующий документ SHALL возвращать одинаковый 404.
#### Scenario: Изменение только счёта
- **WHEN** аналитик меняет счёт ОСВ РБ с 60 на 62, не передавая период
- **THEN** меняется только счёт; даже неопределённый квартал остаётся прежним
#### Scenario: Нет доступа
- **WHEN** пользователь без DOCUMENT_EDIT правит шапку либо документ находится вне видимости ЮЛ
- **THEN** без права возвращается 403, при наличии права и отсутствии видимости — 404; данные не меняются
### Requirement: Проверки правки шапки ОСВ
После проверки типов тела запроса Pydantic PATCH SHALL проверять последовательно: тип OSV (иначе 409 DOCUMENT_NOT_OSV), файл не SUPERSEDED (иначе 409
FILE_SUPERSEDED), корректность периода (422 INVALID_PERIOD), счёта (422 INVALID_ACCOUNT_CODE), отсутствие
другой ОСВ с тем же нормализованным счётом в файле (409 DUPLICATE_ACCOUNT с detail.document_id),
редактируемость привязанных версий (409 VERSION_NOT_EDITABLE с detail.version_ids). Все проверки SHALL
предшествовать записи.
#### Scenario: Частичная правка периода
- **WHEN** передано хотя бы одно поле периода
- **THEN** итоговые год, квартал и is_cumulative обязательны: год 1900–2099, квартал Q1–Q4, признак boolean;
для Q1 признак true; иначе 422 INVALID_PERIOD без изменений
#### Scenario: Нормализация и коллизия
- **WHEN** передано 60.1, а у другой ОСВ этого файла уже счёт 60.01
- **THEN** нормализация даёт 60.01, возвращается 409 DUPLICATE_ACCOUNT с id другого документа
#### Scenario: Пустой счёт
- **WHEN** передан пустой счёт
- **THEN** 422 INVALID_ACCOUNT_CODE, прежний счёт сохранён
#### Scenario: Порядок конфликтов
- **WHEN** у заменённого файла документ BALANCE и некорректный период в теле
- **THEN** возвращается DOCUMENT_NOT_OSV; у OSV того же файла — FILE_SUPERSEDED
### Requirement: Исходная шапка и признак исправления
При первой фактической правке ядро SHALL сохранить прежние значения всех четырёх полей в document.original_values;
при последующих правках снимок MUST NOT меняться. Новые значения SHALL записываться в ту же строку document,
is_modified SHALL отражать отличие от снимка, modified_by/at — последнюю правку. document.id, file_ordinal,
строки osv_entry, файл и JSON результата в S3 MUST NOT изменяться.
#### Scenario: Две правки
- **WHEN** счёт ОСВ РБ меняется 60 → 62 → 76
- **THEN** текущий счёт 76, original_values.osv_account_code = 60, в original_values также исходный период;
is_modified = true, id и количество документов и строк ОСВ прежние
#### Scenario: Возврат к исходному
- **WHEN** все четыре поля возвращены к значениям original_values
- **THEN** is_modified = false, original_values остаётся заполненным, последняя правка атрибутирована
#### Scenario: Запрос без изменений
- **WHEN** нормализованная итоговая шапка совпадает с текущей
- **THEN** 200 с карточкой без аудита, создания original_values, сброса подтверждения и изменения modified_by/at
### Requirement: Снятие подтверждения ОСВ после уточнения
Фактическая правка CONFIRMED ОСВ SHALL установить NEEDS_CHECK, очистить confirmed_by/at и снять привязки
consolidation_document из всех версий «в работе», сохранив привязки CANCELLED и статусы версий.
Привязка к иной версии SHALL запретить правку. Неподтверждённый документ SHALL сохранить статус.
consolidation_lock MUST NOT требоваться; автоподтверждение после PATCH MUST NOT запускаться.
#### Scenario: Исправление автоподтверждённой ОСВ
- **WHEN** ОСВ РБ привязана к версии DATA_CORRECTION, аналитик исправляет период
- **THEN** NEEDS_CHECK, confirmed_by/at = null, привязка снята, версия остаётся DATA_CORRECTION;
повторное ручное подтверждение привязывает документ по новому периоду и счёту
#### Scenario: Завершённая версия
- **WHEN** документ привязан к VERIFIED, даже если одновременно привязан к версии «в работе»
- **THEN** 409 VERSION_NOT_EDITABLE со списком запрещающих version_ids, ни одна привязка и шапка не меняются
#### Scenario: Отменённая версия
- **WHEN** подтверждённый документ связан только с CANCELLED и версиями «в работе»
- **THEN** правка разрешена без consolidation_lock, привязки CANCELLED остаются, остальные снимаются
### Requirement: Производные данные после уточнения ОСВ
Подходящесть версиям, чек-лист и колонка «ОСВ» SHALL отражать новую шапку при следующем чтении;
period_check, counterparty_check и строки osv_entry SHALL сохраняться. Правка MUST NOT запускать автозамену
другого файла; замещение привязки с тем же ключом происходит при повторном подтверждении по §3 п.1.
Перезагрузка файла MUST NOT переносить уточнения старых документов. Флаг CONSOLIDATION_PERIOD_FROM_VERSION сохраняется.
#### Scenario: Смена счёта шапки
- **WHEN** счёт ОСВ РБ исправлен с 60 на 62
- **THEN** колонка «ОСВ» и подходящие слоты чек-листа учитывают 62; account_code/account_id/account_root
строк ОСВ, period_check и counterparty_check не изменились, другой файл ОСВ 62 не заменён
#### Scenario: Перезагрузка
- **WHEN** новый загруженный файл распознан с исходным счётом 60
- **THEN** новые документы созданы из нового результата, исправление 62 предыдущего документа не переносится
### Requirement: Строгие типы шапки ОСВ
PATCH SHALL принимать period_year как integer, period_quarter и osv_account_code как string,
is_cumulative как boolean, без приведения типов. Отсутствующие поля не меняются; null SHALL передаваться
в доменную проверку явно. Неверные типы SHALL отклоняться стандартным 422 валидации тела до доменных
проверок. Границы года, допустимые кварталы и совместимость полей SHALL проверяться доменом.
#### Scenario: Неверный тип года
- **WHEN** передан period_year как строка, дробное число, boolean, массив или объект
- **THEN** стандартный 422 с detail-массивом ошибок валидации, доменная обработка не запускается
#### Scenario: Год вне диапазона
- **WHEN** у доступного незаменённого документа OSV передан period_year = 2100
- **THEN** тип integer принят, домен возвращает 422 INVALID_PERIOD
#### Scenario: Неизвестный квартал
- **WHEN** у доступного незаменённого документа OSV передан period_quarter = "Q0"
- **THEN** тип string принят, домен возвращает 422 INVALID_PERIOD

View File

@ -0,0 +1,77 @@
## ADDED Requirements
### Requirement: Ручной повтор обработки файла
`POST /api/v1/files/{file_id}/retry` SHALL требовать DOCUMENT_EDIT и видимость ЮЛ. Повтор разрешён для
текущего задания tabular в timeout/TIMEOUT или dlq/NO_ACK при attempt < max_attempts, отсутствии документов
и файле ERROR. Успех SHALL вернуть 202 с карточкой файла и новым текущим заданием. Несуществующий или чужой файл — 404.
#### Scenario: После таймаута
- **WHEN** табличный файл ОСВ РБ завершился timeout/TIMEOUT, attempt = 1, max_attempts = 3
- **THEN** 202, создано новое задание attempt = 2, прежнее осталось, в карточке новое текущее задание
#### Scenario: Неподходящее состояние
- **WHEN** повтор запрошен для OCR, done, needs_check, error сервиса, ошибки маппинга либо файла с документами
- **THEN** 409 FILE_NOT_RETRYABLE без нового задания и аудита
#### Scenario: Лимит исчерпан
- **WHEN** подходящее задание имеет attempt = max_attempts
- **THEN** 409 MAX_ATTEMPTS_REACHED, состояние не меняется
### Requirement: Пакет повторной обработки версии
`POST /api/v1/consolidations/versions/{version_id}/files/retry` SHALL выбирать видимые ERROR-файлы
консолидации по периметру версии. Другие статусы не входят в пакет и не блокируются.
Ответ — 202 с accepted[{file_id, job_id, attempt}] и skipped[{file_id, code}]. В skipped SHALL входить
только ERROR-файлы, которые нельзя повторить (FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED).
Файлы без контекста консолидации MUST NOT включаться по совпадению ЮЛ.
#### Scenario: Смешанный пакет
- **WHEN** у версии есть файл timeout/TIMEOUT, OCR, успешный файл и файл с исчерпанным лимитом
- **THEN** новый job создаётся только для первого; OCR-файл в skipped с `FILE_NOT_RETRYABLE`, файл с исчерпанным
лимитом — с `MAX_ATTEMPTS_REACHED`; успешный (`PROCESSED`) файл не попадает ни в accepted, ни в skipped
#### Scenario: Периметр и контекст
- **WHEN** файл загружен в другую консолидацию либо его ЮЛ не входит в выбранную версию
- **THEN** файл не участвует ни в accepted, ни в skipped этой версии
#### Scenario: Общие файлы разных версий
- **WHEN** две версии одной консолидации содержат одно ЮЛ и вызывают пакетный повтор
- **THEN** они используют общий набор файлов консолидации; уже повторяемый файл второй раз не запускается
#### Scenario: Пустой пакет
- **WHEN** подходящих файлов нет
- **THEN** 202 с пустым accepted; в skipped — только файлы в `ERROR`, которые повторить нельзя (без них список пуст);
аудита повторов нет
### Requirement: Ограничения версии при повторе
Повтор в контексте версии SHALL разрешаться только для версий «в работе» (INITIATED, DOCUMENTS_PROCESSING,
DATA_CORRECTION). Иначе SHALL возвращаться 409 VERSION_NOT_EDITABLE. consolidation_lock MUST NOT требоваться.
Одиночный повтор файла с consolidation_id SHALL требовать наличия версии «в работе» с ЮЛ файла в периметре;
без consolidation_id SHALL применяться только policy ЮЛ. Повтор MUST NOT изменять статусы версий.
#### Scenario: Проверенная версия
- **WHEN** пакет запрошен для VERIFIED
- **THEN** 409 VERSION_NOT_EDITABLE, ни один файл не изменён
#### Scenario: Консолидация уже закрыта для обработки
- **WHEN** одиночный повтор файла консолидации запрошен без версии «в работе» с его ЮЛ
- **THEN** 409 VERSION_NOT_EDITABLE; другая версия консолидации не открывает недоступный пакет версии
#### Scenario: Сохранение статуса
- **WHEN** пакет допустимой версии INITIATED принят без пользовательской блокировки
- **THEN** попытки созданы, INITIATED сохраняется; дальнейшие переходы происходят по существующим событиям
## MODIFIED Requirements
### 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`

View File

@ -0,0 +1,47 @@
## MODIFIED Requirements
### Requirement: Таймаут ожидания результата
Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в
`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в
`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Ручной повтор после TIMEOUT/NO_ACK SHALL создавать новое задание по требованию «Новая попытка обработки файла».
#### Scenario: Сервис взял, но не ответил
- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата
- **THEN** не позднее чем через 30 с задание имеет `status = timeout`, файл — `ERROR` с `error_code = TIMEOUT`
## ADDED Requirements
### Requirement: Новая попытка обработки файла
Ручной повтор SHALL создать recognition_job с новым id, attempt = прежний attempt + 1, тем же file_id,
pipeline и max_attempts, статусом queued. Предыдущая запись SHALL сохраняться без изменений.
Файл SHALL ссылаться на новое задание и получить UPLOADED. Ссылка S3 на исходник и consolidation_id сохраняются;
новых документов, файлов и копий исходника SHALL NOT создаваться.
#### Scenario: Повтор после NO_ACK
- **WHEN** tabular-задание ОСВ РБ завершилось dlq/NO_ACK с attempt = 1
- **THEN** новое задание queued имеет attempt = 2, файл UPLOADED с прежним id и объектом S3,
current_recognition_job_id указывает на новый id
### Requirement: Изоляция новой попытки
Новое задание SHALL начинаться без ack, результата, ошибок, метрик и времён старого задания, со свежим
requested_at. Публикатор SHALL вычислить новый reply_deadline и Nats-Msg-Id для нового job_id/attempt.
Запоздавшие ack/результаты прежней финальной попытки MUST NOT менять файл, создавать документы или
мешать обработке нового задания.
#### Scenario: Старый ответ
- **WHEN** после создания попытки 2 пришли ack и done для попытки 1 в timeout
- **THEN** текущее задание, статус файла и документы не меняются; ответ логируется и игнорируется
#### Scenario: Публикация после недоступности NATS
- **WHEN** ручной повтор принят, а NATS временно недоступен
- **THEN** задание остаётся queued; после восстановления публикуется со свежим сроком и новым Nats-Msg-Id
### Requirement: Конкурентные запросы повтора
Проверка текущего задания и лимита, создание новой попытки и переключение файла SHALL быть атомарны.
Два запроса к одной неуспешной попытке MUST NOT создавать два новых задания. При rollback новое задание,
переключение файла и аудит SHALL откатываться вместе.
#### Scenario: Двойное нажатие
- **WHEN** два параллельных запроса повторяют attempt = 1
- **THEN** создан ровно один job attempt = 2; второй одиночный запрос получает 409 FILE_NOT_RETRYABLE,
а пакетный отмечает файл skipped; attempt = 3 не создаётся

View File

@ -0,0 +1,89 @@
# Tasks
Задачи по отдельности рассчитаны не более чем на половину рабочего дня. Репозиторий указан в каждой задаче.
Не отмечать пропущенные из-за инфраструктуры проверки выполненными.
## 1. Регрессионные сценарии и источник истины
- [x] 1.1 arm-core: в api/tests/test_recognition_exchange.py и новом test_api_file_retry.py подготовить сценарии TIMEOUT/NO_ACK, лимита, OCR и поздних ответов; проверить, что регрессии воспроизводятся до реализации. Форматы файлов не выдумывать; для статусов использовать существующие фабрики БД.
- [x] 1.2 arm-core: в api/tests/test_api_osv_header.py подготовить реальные ОСВ РБ через доступный fixtures, сценарии первой/повторной правки, возврата к исходному, сброса подтверждения и неизменности строк; проверка — тесты описывают дельту documents-api. Если ../fixtures недоступен, восстановить доступ до golden/e2e и явно отметить ограничение.
- [x] 1.3 arm-core и arm-ka-docs: сверить план с опубликованными model/data-model-v5.20.dbml, data-model-v5.21.dbml, entities-v5.21.md и context/05-domain-rules.md §9.5 п.12–13; отдельно уточнить в docs согласованный отбор пакета consolidation_id + периметр. Проверка — FILE_REPROCESS и его payload соответствуют v5.21, изменения схемы docs не требуются.
## 2. Модель и отдельные миграции v5.20 и v5.21
- [x] 2.1 arm-core: расширить api/tests/test_migrations.py и test_dbml_parity.py сценариями v5.20/v5.21, непустой базы и независимых запретов отката v5.20 при is_modified и v5.21 при FILE_REPROCESS; проверка — регрессии падают по отсутствующей схеме.
- [x] 2.2 arm-core: api/src/models/files.py, enums.py и константа DBML_VERSION, Makefile — четыре поля document и FILE_REPROCESS строго по опубликованному DBML v5.21, итоговый DBML_VERSION = 5.21; проверить make dbml-check и тесты моделей.
- [x] 2.3 arm-core: api/alembic/versions/0008_dbml_v5_20.py после 0007 — только четыре поля document и запрет downgrade при is_modified=true; проверить upgrade/downgrade на пустой и непустой БД, сохранение документов и привязок.
- [x] 2.4 arm-core: отдельная api/alembic/versions/0009_dbml_v5_21.py после 0008 — только FILE_REPROCESS в audit_action и запрет downgrade при наличии таких событий; проверить переходы 5.20 ↔ 5.21 без удаления полей document, полный цикл, нестандартный DB_SCHEMA и пустой autogenerate.
- [x] 2.5 arm-core: README — схема v5.21, две отдельные миграции и независимые условия отката; проверка — инструкции соответствуют выполненным миграционным сценариям.
## 3. Одиночный повтор файла
- [x] 3.1 arm-core: api/src/services/file_retry.py и repositories/files.py — допустимость текущего job, блокировки job → файл → версии, новая попытка и история; проверить TIMEOUT/NO_ACK, лимит, отсутствие документов, неизменность исходника и старого job.
- [x] 3.2 arm-core: services/file_retry.py, file_status.py и audit.py — атомарное переключение current job, UPLOADED и FILE_REPROCESS; проверить откат всей операции и отсутствие копирования ошибок, метрик, результата и времён.
- [x] 3.3 arm-core: schemas/files.py, api/dependencies/files.py и новый endpoints/files/retry.py, регистрация router — POST /files/{id}/retry, 202 FileOut и коды ошибок; проверить api/tests/test_api_file_retry.py, права, одинаковый 404, OpenAPI и единственную транзакцию запроса.
- [x] 3.4 arm-core: api/tests/test_recognition_exchange.py — новый job проходит существующий outbox/inbox, поздние ack/done прежнего job игнорируются; проверить свежие deadline/message-id и восстановление после недоступности NATS.
- [x] 3.5 arm-core и arm-ka-docs (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — одиночный retry, разделение ручной попытки и republish; проверить примеры запросов, ответы 202/409 и описание FILE_REPROCESS по OpenAPI.
## 4. Пакет версии
- [x] 4.1 arm-core: api/tests/test_api_file_retry.py — сценарии смешанного/пустого пакета, общей консолидации нескольких версий, чужих ЮЛ и файла вне периметра; проверить отбор ровно по согласованному правилу.
- [x] 4.2 arm-core: repositories/files.py и services/file_retry.py — пакетный отбор, общие проверки версии, повторная проверка периметра под блокировкой, accepted/skipped; проверить отсутствие повторного запуска и аудита skipped, откат на неожиданной ошибке БД.
- [x] 4.3 arm-core: schemas/files.py и endpoints/files/retry.py — POST /consolidations/versions/{id}/files/retry, 202 RetryBatchOut без consolidation_lock; проверить OpenAPI, стабильный порядок file_id и VERSION_NOT_EDITABLE без мутаций.
- [x] 4.4 arm-core: api/tests/test_api_constraint_races.py — две сессии для одиночного/пакетного retry и смены статуса/периметра; проверить ровно одну новую попытку, отсутствие дедлока и запрещённых операций после смены статуса.
- [x] 4.5 arm-core и arm-ka-docs (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — состав пакета, частичные пропуски и правила версий; проверить примеры смешанного и пустого ответа по интеграционным тестам.
## 5. Уточнение шапки ОСВ
- [x] 5.1 arm-core: domain/osv_header.py и api/tests/test_domain_osv_header.py — итоговая частичная шапка, нормализация, периоды, no-op и сравнение с оригиналом; проверить границы 1900/2099, Q1, null и отсутствие полей.
- [x] 5.2 arm-core: schemas/documents.py, services/documents.py — DTO PATCH и выдача is_modified/original_values/modified_by/modified_at в карточке; проверить старые документы с false/null и отсутствие регрессий чтения.
- [x] 5.3 arm-core: services/osv_header.py, repositories/files.py — изменение на месте, первичный снимок, метаданные правки и DUPLICATE_ACCOUNT; проверить api/tests/test_api_osv_header.py, неизменность document.id/file_ordinal и всех osv_entry.
- [x] 5.4 arm-core: services/osv_header.py и consolidation_documents.py — проверка привязанных версий, сброс подтверждения, выборочная отвязка и аудит; проверить CONFIRMED/автоподтверждённый/CANCELLED/VERIFIED, rollback, no-op и отсутствие автоподтверждения.
- [x] 5.5 arm-core: endpoints/files/documents.py и api/dependencies/files.py — PATCH /documents/{id}/osv-header, 200 DocumentOut, доменные 409/422 в требуемом порядке; проверить OpenAPI, права, policy и ограничения тела без изменения отсутствующих полей.
- [x] 5.6 arm-core: api/tests/test_api_osv_header.py, test_api_constraint_races.py и test_api_consolidation_checklist.py — конкурентные PATCH/confirm, коллизия счёта, новое подтверждение и производные данные; проверить колонку ОСВ/чек-лист с флагом периода false/true и отсутствие автозамены файлов.
- [x] 5.7 arm-core и arm-ka-docs (docs — ревьюер, RSHB/arm-ka-docs#157): README и dev/frontend-api-guide.md — PATCH, original_values, no-op, снятие подтверждения и флаг периода; проверить примеры по сценариям тестов и OpenAPI, UI оставить отдельной задачей.
## 6. Общая проверка и документация поставки
- [ ] 6.1 arm-core: выполнить make check, make dbml-check, make test-integration, затем make check-full по требованиям репозитория; сохранить результаты и явно указать skips/недоступность инфраструктуры, проверить git diff на отсутствие посторонних изменений.
- [x] 6.2 arm-core и arm-ka-docs (ревьюер: README/CLAUDE.md v5.21, 05-domain-rules §9.4–9.5 и гайд фронта — RSHB/arm-ka-docs#157): сверить README, context/03-architecture.md, context/07-open-questions.md и dev/frontend-api-guide.md с реализацией, закрытым OQ-161 и согласованными решениями FILE_REPROCESS/пакета; проверить ссылки, порядок выкладки и согласованность core/docs PR. Это финальная задача документации среза, основные API-документы обновлены в своих группах.
## Workflow follow-up
- После реализации и проверки оформить отдельные PR для arm-core и arm-ka-docs; архивация — отдельным запросом.
- Не удалять CONSOLIDATION_PERIOD_FROM_VERSION до отдельной поставки UI правки ОСВ.
## Результаты проверки реализации
- Линтеры ruff, форматирование и mypy: пройдены; ORM соответствует DBML v5.21.
- 127 проверок API/домена/OpenAPI/миграций пройдены, включая защитные откаты.
- Расширенный набор с гонками, rollback и флагом периода: 108 passed до переноса маршрута шапки в documents.py.
- После переноса: 70 тестов OpenAPI passed; DOCUMENT_EDIT сохранён.
- Полный интеграционный набор без старых fixtures: 956 passed, 1 failed — ожидание test_document_card
не включало новые поля DocumentOut; ожидание обновлено, отдельный повторный test_document_card прошёл.
- Docker-сборка и web-check пройдены.
- Общий unit-прогон с текущими fixtures: первоначально 1835 passed, 6 failed, 1 skipped.
Четыре отказа относились к обновлённой версии модели/JSON-колонкам, ожидания исправлены;
два старых OCR-теста несовместимы с текущими fixtures v2.0 (помощник ожидает v1.2), вне реализации retry/ОСВ.
- statements-check: отсутствует соседний репозиторий arm-statements; e2e ещё не выполнен.
- Лишняя пустая строка в конце docs/dev/frontend-api-guide.md удалена; git -C ../docs diff --check проходит.
- Задача 6.1 остаётся открытой: общий зелёный check-full пока не подтверждён.
- Уточнение строгих типов OsvHeaderPatch: StrictInt/StrictStr/StrictBool без приведения; порядок доменных
ошибок действует после проверки типов. Схема/домен/OpenAPI — 105 passed; API шапки и карточка — 8 passed;
mypy — без ошибок. Глобальный format-check отдельно указывает на пользовательский комментарий
в services/file_retry.py, не относящийся к этой правке; комментарий оставлен без изменений.
## Ревью (RSHB/arm-core#161)
- [x] 7.1 Ревьюер: `api/.env.test` — порт PostgreSQL 5432 по compose (свой порт — переменной `OPENBAO__DATABASE__DATABASE_URL`).
- [x] 7.2 Ревьюер: дельта data-model переименована на v5.21, `openspec/config.yaml` — DBML v5.21, README/CLAUDE.md — миграции 0008/0009.
- [x] 7.3 Ревьюер: синтетические тесты PATCH шапки ОСВ без `fixtures`, смешанный пакет и NO_ACK на пределе.
- [x] 7.4 Автор: замечания комментария ревью (пакет — только файлы в `ERROR`, блокировки версий, `CONTRACT_VERSION`, репозиторий, OpenAPI).
Раунд 1: все шесть замечаний к коду исправлены. Пакет отбирается репозиторием только по ERROR до блокировок,
статус перепроверяется под блокировкой; одиночный повтор берёт FOR SHARE только рабочих версий.
Новая попытка использует CONTRACT_VERSION и subject из SettingsDep. OpenAPI-конфликты разделены;
проверка пары status/error_code сохранена, вопросительный комментарий удалён.
Проверки: 101 passed (file_retry, синтетическая шапка ОСВ, OpenAPI), make lint,
openspec validate --strict, git diff --check — успешно. .env.test не менялся.

View File

@ -1,3 +1,4 @@
# elimination-pairs-rule-totals
Тесты упаковки и итог элиминации для тройки правила и компаний
Пары ВГО §5 (AURORA-1536, PR #175): фиксация версии правил при создании версии консолидации (DBML v5.23, NOT NULL),
итог и упаковка тройки «правило × A × B», список троек версии.

View File

@ -1,10 +1,33 @@
## Решение
Суммы выбранных строк считаются один раз до упаковки внутри тройки `(rule_id, A, B)` как чистая функция.
`elimination_amount` сохраняет ранее согласованное значение абсолютного расхождения; новое `eliminated_amount`
содержит `min(ΣA, ΣB)` при пороге не более 5 %, иначе 0, и всегда 0 для `manual`.
Нулевая меньшая сторона даёт `deviation_percent = null` и нулевую элиминацию.
Итог тройки `(rule_id, A, B)` считается чистой функцией по строкам обеих сторон до упаковки: суммы сторон со знаком,
`deviation_amount = |ΣA − ΣB|`, процент к меньшей стороне, `eliminated_amount = min(ΣA, ΣB)` при расхождении ≤ 5 %,
иначе и для `manual` — 0. Упаковка итог не меняет. Каждое правило считается отдельно (каскад по остатку отменён
владельцем 21.10.2026): строка общего счёта входит в несколько правил, двойной учёт снимает AURORA-1751.
Сбор встречных строк тройки — одна функция `_collect_rule_sides` для итога, упаковки и списка версии: список версии
(`build_totals`) строит сводную ОСВ один раз и проходит правила ББ × упорядоченные пары периметра без упаковки.
Версия правил фиксируется при создании версии консолидации (`latest_effective_version_no` на московскую дату) и дальше
не меняется; ветки «правила не зафиксированы» в чтении правил, пресете шахматки и парах убраны.
Упаковка не влияет на итог. Для обратной направленной пары клиент переставляет `company_a_id` и `company_b_id`.
Эталонный Excel используется только для сверки точных встречных строк: он списывает полную сумму каждой стороны,
поэтому его общий итог не равен системному правилу минимума.
поэтому его общий итог не равен системному правилу минимума (OQ-155).
## Решения владельца 23.10.2026 (ревью PR #175)
1. Суммы сторон — со знаком, как в шахматке; строки с отрицательной суммой в упаковку не идут (`NEGATIVE_AMOUNT`).
2. Группы одного размера сортируются по проценту расхождения, а не по сумме.
3. Кроме тройки — список пар всей версии одним запросом (делаем сами в ветке PR).
4. `elimination_rules_version` — NOT NULL с миграцией существующих версий (DBML v5.23).
## Decisions made without discussion
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-1 (design #1) | 23.10.2026 | При `min(ΣA, ΣB) ≤ 0` процент `null`, элиминация 0, тройка подсвечивается при любом расхождении | Минимум со знаком дал бы элиминацию отрицательной суммы или отрицательный процент, который не пробивает порог; аналитик должен разобрать такой случай | Поменять одну функцию `calculate_rule_totals` и §5 |
| R-2 (design #2) | 23.10.2026 | Список версии — `GET …/elimination-totals`, статусы `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`; тройки без строк не возвращаются; только правила ББ с `measure = closing` | Повторяет три категории задачи («сопоставленные, с расхождением, без пары») плюс ручные правила; ОФР в §4 не формализован | Переименование маршрута и статусов — правка фронта до его разработки |
| R-3 (design #3) | 23.10.2026 | Создание версии без правил, действующих на дату, — 409 `ELIMINATION_RULES_MISSING` | Поле NOT NULL; подставлять «последние открытые» значило бы взять правила, ещё не вступившие в силу | При пустом справочнике правил консолидацию не создать до seed — на практике seed всегда заводит v1 |
| R-4 (design #4) | 23.10.2026 | Миграция: дата создания по Москве; пустой `created_at` — текущая дата; нет действующих на дату — последняя открытая; нет правил вовсе — отказ миграции | Совпадает с правилом создания; запасной вариант нужен только для данных, созданных до появления правил | Неверный номер у старых версий — исправляется UPDATE до верификации |
| R-5 (design #5) | 23.10.2026 | `is_version_fixed` в ответе `elimination-rules` оставлен и всегда `true` | Фронт шахматки уже читает поле (PR #141, #158) | Удалить поле позже вместе с фронтом |
| R-6 (design #6) | 23.10.2026 | Группа упаковки — `deviation_amount` вместо `elimination_amount`; отдельной суммы элиминации у группы нет | В КФО идёт итог тройки; упаковка его не меняет (§5), а сумма у группы читалась бы как вклад в элиминацию | Добавить поле у группы, если корректировкам понадобится |

View File

@ -1,16 +1,21 @@
## Why
Срез 5: упаковка ОСВ показывает группы строк, но итог элиминации тройки должен учитывать все выбранные строки,
включая оставшиеся без пары. Это нужно закрепить тестами на реальных ОСВ группы «Русский бисквит» и отдельным
контрактом для фронта.
Срез 5, AURORA-1536: автоматическое сопоставление ВГО по `05-domain-rules.md` §5. Нужны итог элиминации по тройке
«правило, компания A, компания B» (идёт в КФО), детализация строк ОСВ по группам и список всех троек версии для
таблицы корректировок (AURORA-1540, AURORA-1541) и разнесения строк (AURORA-1751). Версия правил, по которой считается
версия консолидации, должна быть зафиксирована всегда, иначе после публикации новых правил старые консолидации молча
пересчитаются по ним.
## What Changes
- Для запроса `(правило, A, B)` добавить общий итог ΣA/ΣB, расхождение, процент, признак порога и сумму
элиминации по правилу 5 %; при `manual` сумма элиминации равна нулю.
- Проверить упаковку 1:1, 1:N, N:1, ничьи и оба направления пары на реальных и модельных строках.
- Проверить неизменность зафиксированной версии правил при повторных GET и сопоставить точные строки с эталонным Excel.
- Описать новый ответ в гайде фронта.
- При создании версии консолидации фиксировать `elimination_rules_version` — версию правил, действующую на дату
создания (OQ-174); DBML v5.23 делает поле NOT NULL, миграция `0010_dbml_v5_23` заполняет существующие версии;
без действующих правил версия не создаётся (409 `ELIMINATION_RULES_MISSING`).
- `GET …/elimination-pairs` — итог тройки (суммы сторон со знаком, расхождение, процент, сумма элиминации по правилу 5 %)
и упаковка 1:1 / 1:N / N:1 с причинами строк без пары.
- `GET …/elimination-totals` — итоги всех троек версии одним запросом со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` /
`MANUAL`.
- `GET …/elimination-rules` — всегда правила зафиксированной версии.
## Capabilities
@ -20,15 +25,17 @@
### Modified Capabilities
- `consolidations-api`: общий итог элиминации тройки независимо от упаковки строк.
- `consolidations-api`: создание консолидации фиксирует версию правил; правила зафиксированной версии; пары ВГО тройки и
версии.
- `data-model`: DBML v5.23, миграция `0010_dbml_v5_23`.
## Impact
`arm-core`: чистый расчёт, сервис и схема ответа, тесты, OpenSpec. `arm-ka-docs`: гайд фронта.
Читаются таблицы `consolidation_version`, `consolidation_member`, `consolidation_document`, `osv_entry`,
`elimination_rule`, `elimination_rule_account`, `chart_of_account`; DBML и хранимые данные не меняются.
Основание — `context/05-domain-rules.md` §4–§5, решение владельца 08.10.2026 о минимуме пары.
`arm-core`: миграция, ORM, чистый расчёт, сервис, схемы, маршрут, тесты, OpenSpec. `arm-ka-docs` (#164): DBML v5.23,
§4–§5 без каскада, OQ-173/174/175, гайд фронта §5.7.2. Основание — `context/05-domain-rules.md` §4–§5, решения
владельца 08.10, 19.10, 21.10 и 23.10.2026.
## Non-goals
Пары всех правил версии в одном запросе, сохранение результатов в БД, корректировки, расчёт ОФР.
Сохранение пар в БД (DECISION-5), разнесение строк по правилам и снятие двойного учёта (AURORA-1751), корректировки
(AURORA-1540), правила и пары ОФР.

View File

@ -1,21 +1,168 @@
## MODIFIED Requirements
### Requirement: Создание консолидации
`POST /consolidations` (`CONSOLIDATION_EDIT`, policy по ГК) с телом `{company_group_id, related_group_id, year, quarter,
legal_entity_ids?}` 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)`, у которого
`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии. Периметр SHALL быть: без
`legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после
схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации
(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке
запроса; состав ГСК при явном списке не проверяется и не меняется. ГСК SHALL быть обязательной, принадлежать ГК и иметь
`kind = gsk`, иначе 422 `RELATED_GROUP_MISMATCH`. Периметр меньше двух ЮЛ (членов ГСК без списка либо выбранных ЮЛ после
схлопывания) → 422 `PERIMETER_TOO_SMALL`; пара (ГК, год, квартал) уже есть → 409 `CONSOLIDATION_EXISTS` (в том числе при
гонке двух запросов — UNIQUE `(company_group_id, year, quarter)`); `year` вне 2000..2100 → 422. Порядок проверок: ГК
видима (404) → ГСК видима (404) и соответствует ГК (422) → периметр (422) → период свободен (409). Блокировка на
создателя MUST NOT ставиться; `MEMBERSHIP_CHANGE` при создании MUST NOT писаться. `related_group_id` в `consolidation`
SHALL оставаться NULL (фиксируется при верификации, Срез 7); `related_group_member` MUST NOT изменяться.
В `consolidation_version.elimination_rules_version` SHALL записываться номер версии правил элиминации, действующей на
московскую календарную дату создания: максимальный `version_no` среди правил с `valid_from <= дата` и `valid_to IS NULL`
либо `valid_to >= дата` (OQ-174; DBML v5.23, поле NOT NULL). Пользователь версию правил не выбирает; год, квартал и
`reporting_date` на номер не влияют; позже номер не меняется. Нет правил, действующих на дату, → 409
`ELIMINATION_RULES_MISSING`, консолидация не создаётся.
#### Scenario: Консолидация РБ за Q3 2024
- **WHEN** `ka_rf` Санкт-Петербургского РФ создаёт консолидацию ГК «Русский бисквит», ГСК из двух ЮЛ, 2024, Q3, без
`legal_entity_ids`
- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью,
`audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет
#### Scenario: Выбранный периметр шире ГСК
- **WHEN** в ГК три ЮЛ, в ГСК два из них, создаётся консолидация с `legal_entity_ids` из всех трёх ЮЛ ГК
- **THEN** 201; в `members` три ЮЛ; `after_value.members` события `CONSOLIDATION_CREATE` — три ЮЛ; состав ГСК не изменился
#### Scenario: ГСК из одного ЮЛ, второе отмечено вручную
- **WHEN** в ГСК один текущий член, в `legal_entity_ids` он и ещё одно ЮЛ той же ГК
- **THEN** 201; в `members` два ЮЛ
#### Scenario: ГСК из одного ЮЛ
- **WHEN** в ГСК один текущий член, `legal_entity_ids` не передан
- **THEN** 422 `PERIMETER_TOO_SMALL`; `consolidation` и версия не созданы
#### Scenario: ЮЛ другой ГК в списке
- **WHEN** в `legal_entity_ids` попало ЮЛ, `company_group_id` которого не равен ГК консолидации
- **THEN** 422 `ENTITY_NOT_IN_GROUP` с этим ЮЛ в `legal_entity_ids`; `consolidation` не создана
#### Scenario: Дубли схлопываются до одного ЮЛ
- **WHEN** `legal_entity_ids` — один и тот же идентификатор дважды
- **THEN** 422 `PERIMETER_TOO_SMALL`
#### Scenario: Повтор периода
- **WHEN** консолидация ГК за 2024 Q3 уже есть, создаётся ещё одна
- **THEN** 409 `CONSOLIDATION_EXISTS`
#### Scenario: Правила действуют на дату создания версии
- **WHEN** в день создания консолидации за 2024 год действует версия правил 2, а версия 1 действовала в отчётном периоде
- **THEN** `consolidation_version.elimination_rules_version = 2`; номер не меняется при последующей публикации правил
#### Scenario: Нет действующих правил
- **WHEN** у всех правил `valid_from` позже даты создания
- **THEN** 409 `ELIMINATION_RULES_MISSING`; `consolidation` и версия не созданы
## ADDED Requirements
### Requirement: Итог элиминации тройки независимо от упаковки
### Requirement: Правила элиминации зафиксированной версии
`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК)
SHALL возвращать объект `{version_no, is_version_fixed, rules}`: `version_no` — `consolidation_version.elimination_rules_version`
(с DBML v5.23 задан у каждой версии), `is_version_fixed = true` (поле оставлено для совместимости клиента), `rules` —
правила этой версии; правила других версий, в том числе более новых, MUST NOT попадать в ответ. Каждое правило SHALL
содержать `id`, `rule_no`, `version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`),
`company_1`, `company_2`; каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии
счетов. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
Для `(rule_id, company_a_id, company_b_id)` API SHALL возвращать суммы всех выбранных строк ОСВ обеих сторон,
абсолютное расхождение `elimination_amount`, процент расхождения к меньшей сумме, `exceeds_threshold` и отдельное
`eliminated_amount`. При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`;
при превышении порога, нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк
не меняет этот итог.
#### Scenario: Правило с исключением
- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5
- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded`
#### Scenario: Версия правил зафиксирована
- **WHEN** у версии консолидации `elimination_rules_version = 1`
- **THEN** ответ содержит `is_version_fixed = true`, `version_no = 1` и только правила с `version_no = 1`
#### Scenario: Опубликована новая версия правил
- **WHEN** после создания версии консолидации с `elimination_rules_version = 1` открыта версия правил 2
- **THEN** ответ по-прежнему содержит `version_no = 1` и правила версии 1; пресет правила версии 2 для этой версии — 404
### Requirement: Пары ВГО тройки
`GET /api/v1/consolidations/versions/{version_id}/elimination-pairs?rule_id=&company_a_id=&company_b_id=` SHALL требовать
`CONSOLIDATION_VIEW` и видимость версии по ГК; три параметра обязательны. Компании SHALL быть разными участниками
периметра версии (иначе 422 `INVALID_COMPANY_PAIR`), правило — правилом ББ версии правил версии консолидации с
`measure = closing` (иначе 404 `NOT_FOUND` или 422 `INVALID_RULE`). Отбор счетов, раскрытие счетов-предков и исключения
SHALL совпадать с шахматкой PRESET. Расчёт — по `05-domain-rules.md` §5, на лету, без сохранения.
Итог тройки: `amount_a` — сумма строк A с контрагентом B по счетам стороны 1, `amount_b` — строк B с контрагентом A по
счетам стороны 2, **со знаком** (как в шахматке); `deviation_amount = |amount_a − amount_b|`; `deviation_percent` — к
`min(amount_a, amount_b)`, `null`, если он ≤ 0; `exceeds_threshold` — процент > 5 % или меньшая сторона ≤ 0 при ненулевом
расхождении; `eliminated_amount = min(amount_a, amount_b)` при расхождении ≤ 5 %, иначе, при `elimination_mode = manual` и
при меньшей стороне ≤ 0 — 0. Упаковка итог не меняет. Каждое правило считается отдельно: строка общего счёта может войти
в тройки нескольких правил (каскад отменён, AURORA-1751).
Упаковка: строка свода неделима; в группы идут только строки с суммой > 0; группы 1:1, 1:N, N:1 при N ≤ 5, N:M нет;
сначала меньшее число строк, внутри размера — точные, затем с расхождением ≤ 5 % к меньшей сумме по возрастанию
**процента** расхождения; строка — не более чем в одной группе правила; группы одного размера с одинаковым процентом,
делящие строку, не сопоставляются. Ответ SHALL содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`,
`company_b_id`, итог тройки, `matched[]` (точные группы), `with_discrepancy[]` (расхождение ≤ 5 %) и `unpaired[]`. Группа —
`{rule_id, company_a_id, company_b_id, amount_a, amount_b, deviation_amount, deviation_percent, osv_entry_ids_a[],
osv_entry_ids_b[]}`; строка без пары — `{row, reason}`, `reason`: `NO_MATCH`, `AMBIGUOUS` (ничья), `ZERO_AMOUNT`,
`NEGATIVE_AMOUNT` (сумма < 0, в упаковку не идёт). Суммы и проценты в JSON SHALL быть строками с двумя знаками.
#### Scenario: Точная строка среди несопоставленных остатков
- **WHEN** правило № 6 для Счастливочной и АО «Русский бисквит» даёт суммы 110 429 113,15 и 106 311 116,46 руб.,
а точная группа 62.01 ↔ 60.01 составляет 106 311 116,46 руб.
- **THEN** точная группа присутствует в `matched`, остальные три строки стороны A — в `unpaired`,
`eliminated_amount = 106311116.46`, расхождение округляется до 3,87 %.
- **THEN** точная группа в `matched`, три остальные строки стороны A — в `unpaired`, `eliminated_amount = 106311116.46`,
`deviation_percent = 3.87`
#### Scenario: Отрицательная строка
- **WHEN** у A по B строки 150 и −50, у B по A строка 100
- **THEN** `amount_a = 100.00`, `amount_b = 100.00`, `eliminated_amount = 100.00`; строка −50 — в `unpaired` с причиной
`NEGATIVE_AMOUNT`
#### Scenario: Две строки свода упакованы с одной встречной
- **WHEN** по одному правилу есть дебетовые строки 40 и 60 и встречная кредитовая строка 100
- **THEN** `matched[]` содержит группу с суммами 100.00 и 100.00, `deviation_amount = 0.00` и исходными `osv_entry_ids_a/b`
#### Scenario: Ручное правило и превышение порога
- **WHEN** правило ручное либо расхождение больше 5 %
- **THEN** `eliminated_amount = 0`, независимо от результата упаковки.
- **THEN** `eliminated_amount = 0.00` независимо от упаковки
#### Scenario: Исключённый счёт не входит в пары
- **WHEN** правило включает родительский счёт 76 и исключает 76.07.5
- **THEN** строки счёта 76.07.5 и его потомков не входят ни в группы, ни в `unpaired`
#### Scenario: Другая пара компаний не смешивается с выбранной
- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б`
- **THEN** ответ содержит только строки A→Б и Б→A
### Requirement: Пары ВГО версии
`GET /api/v1/consolidations/versions/{version_id}/elimination-totals` (`CONSOLIDATION_VIEW`, policy по ГК) SHALL
возвращать `{version_no, items[]}` — итоги всех троек версии: правила ББ с `measure = closing` версии правил ×
упорядоченные пары разных компаний периметра, у которых есть строки хотя бы на одной стороне. Сводная ОСВ SHALL строиться
один раз на запрос. Элемент — `{rule_id, rule_no, elimination_mode, company_a_id, company_b_id, amount_a, amount_b,
deviation_amount, deviation_percent, eliminated_amount, exceeds_threshold, status, osv_entry_ids_a[], osv_entry_ids_b[]}`
с теми же суммами, что у тройки («Пары ВГО тройки»); `status`: `MANUAL` — `elimination_mode = manual`; `ONE_SIDED` — одна из
сумм нулевая; `DISCREPANCY` — `exceeds_threshold`; иначе `AUTO`. Порядок — `rule_no`, затем порядок компаний периметра.
Суммы и проценты — строки с двумя знаками. Невидимая и несуществующая версия — 404 `NOT_FOUND`.
#### Scenario: Список совпадает с тройкой
- **WHEN** по правилу № 6 у A на B 300, у B на A 100, у B на A дебиторка 70 и у A на B кредиторка 70
- **THEN** в `items` тройка (6, A, B) — `DISCREPANCY`, `eliminated_amount = 0.00`; тройка (6, B, A) — `AUTO`,
`eliminated_amount = 70.00`; те же суммы возвращает `elimination-pairs` для каждой тройки
#### Scenario: Пересечение правил
- **WHEN** дебиторка A на B по счетам № 6 есть, а займов B у A (67) нет
- **THEN** в `items` есть и тройка (6, A, B), и тройка (7, A, B) со статусом `ONE_SIDED` — двойной учёт до разнесения
строк (AURORA-1751)
#### Scenario: Правило без строк
- **WHEN** по счетам правила № 3 строк ни у A, ни у B нет
- **THEN** тройки (3, A, B) в `items` нет
## REMOVED Requirements
### Requirement: Правила элиминации версии
**Reason**: с DBML v5.23 `elimination_rules_version` задан у каждой версии консолидации; ветка «версия правил не
зафиксирована — последние открытые правила» больше не существует.
**Migration**: требование «Правила элиминации зафиксированной версии»; клиенту `is_version_fixed` всегда `true`.

View File

@ -0,0 +1,26 @@
## MODIFIED Requirements
### Requirement: Версия DBML зафиксирована в коде
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.23"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.24.dbml`, а константа в ORM — `5.23`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
## ADDED Requirements
### Requirement: Миграция DBML v5.23 — версия правил элиминации обязательна
Миграция `0010_dbml_v5_23` после `0009_dbml_v5_21` SHALL заполнить пустой `consolidation_version.elimination_rules_version`
номером версии правил, действовавшей на московскую дату `created_at` версии (пустой `created_at` — текущая дата), а если
такой нет — последней открытой версии (`valid_to IS NULL`), затем сделать колонку NOT NULL. Если после заполнения
остаются пустые значения (правил нет вовсе), миграция SHALL завершиться ошибкой с числом таких версий до изменения схемы.
Откат SHALL снимать только NOT NULL, значения сохраняются. v5.22 структуру БД не меняла, отдельной миграции у неё нет.
#### Scenario: Заполнение по дате создания
- **WHEN** правила v1 действуют до 30.06.2026, v2 — с 01.07.2026, версии созданы 01.03.2026, 30.06.2026 21:30 UTC и без даты
- **THEN** после `upgrade head` у них 1, 2 и 2; колонка NOT NULL; `downgrade 0009_dbml_v5_21` снимает NOT NULL, значения остаются
#### Scenario: Версии без правил
- **WHEN** в БД есть версия консолидации и нет ни одного правила элиминации
- **THEN** `upgrade head` падает с ошибкой «миграция v5.23 невозможна…», ревизия остаётся `0009_dbml_v5_21`

View File

@ -1,7 +1,12 @@
## Реализация
- [x] Добавить чистый расчёт общего итога и тесты порога, ручного режима и нулевой стороны.
- [x] Проверить реальные строки правила № 6, точную пару и оставшиеся без пары по golden ОСВ.
- [x] Проверить 42 млн по правилу № 4, ничью упаковки, обратную пару и фиксацию версии правил.
- [x] Дополнить основной OpenSpec и гайд фронта.
- [ ] Запустить `make check-full` и заархивировать change.
- [x] Чистый расчёт итога тройки и тесты порога, ручного режима и нулевой стороны.
- [x] Реальные строки правила № 6 (точная пара и строки без пары) по golden ОСВ; 42 млн по правилу № 4; ничья упаковки;
обратная пара.
- [x] Фиксация версии правил при создании версии; DBML v5.23, миграция `0010_dbml_v5_23` и её тесты; 409
`ELIMINATION_RULES_MISSING`.
- [x] Список троек версии `GET …/elimination-totals` и тест на тех же данных, что и тройка.
- [x] OpenSpec-дельта, README, `05-domain-rules.md` §5 и гайд фронта (arm-ka-docs #164).
- [ ] Суммы сторон со знаком, `NEGATIVE_AMOUNT`, сортировка групп по проценту, `deviation_amount` у группы и тройки, суммы
с двумя знаками, правило с `measure ≠ closing` — 422 `INVALID_RULE` (раунд ревью 1, автор).
- [ ] `make check-full` и архив change.

View File

@ -3,7 +3,7 @@ schema: spec-driven
context: |
Репозиторий arm-core — часть системы АРМ КА (ДКБ, Россельхозбанк): ядро (FastAPI + React), единственный писатель в PostgreSQL: справочники, файлы/документы, маппинг результатов распознавания, вычисления консолидации, согласование, ЕХ, аудит, права.
Workspace из нескольких репозиториев; общий контекст — ../CLAUDE.md и ../docs/ (репозиторий arm-ka-docs).
Источники истины по приоритету: ../docs/model/data-model-v5.17.dbml → ../docs/model/entities-v5.17.md →
Источники истины по приоритету: ../docs/model/data-model-v5.21.dbml → ../docs/model/entities-v5.21.md →
../docs/context/05-domain-rules.md → страницы ТЗ в ../docs/source/tz (черновик, старые имена v4).
Открытые вопросы — ../docs/context/07-open-questions.md; план срезов — ../docs/context/09-delivery-plan.md.
Фикстуры — ../fixtures (реальные данные, не спецификация формата).
@ -14,7 +14,7 @@ context: |
rules:
proposal:
- Указывать номер среза из 09-delivery-plan.md и затронутые репозитории; сквозные изменения — отдельный change в ../docs
- Перечислять таблицы DBML v5.17, которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML
- Перечислять таблицы текущей DBML (сейчас v5.21), которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML
- Ссылаться на параграфы 05-domain-rules.md для каждого алгоритма; правило, которого там нет, — в 07-open-questions.md
- Раздел «Non-goals» обязателен
- Любое допущение по открытому вопросу помечать ASSUMPTION(OQ-nn)

View File

@ -179,3 +179,37 @@ credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть д
#### Scenario: Нет счёта статьи
- **WHEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING`
- **THEN** событие `EXPORT` не записано
### Requirement: Аудит ручной повторной обработки файла
Каждая принятая ручная попытка SHALL писать одно audit_event с action = FILE_REPROCESS,
section = PRIMARY_DOCUMENTS, object_type = stored_file, object_id = file_id, user_id инициатора,
IP и User-Agent запроса. before_value SHALL содержать прежние job_id, attempt, status задания, error_code;
after_value — новые job_id, attempt, а при пакетном повторе также consolidation_id. Событие SHALL фиксироваться в транзакции создания попытки.
#### Scenario: Пакетный повтор
- **WHEN** пакет принял два файла ОСВ РБ и пропустил третий
- **THEN** два FILE_REPROCESS с разными object_id и прежними/новыми job_id, с consolidation_id в after_value;
before_value.status — статус прежнего задания (timeout или dlq), error_code — TIMEOUT или NO_ACK; для skipped события нет
#### Scenario: Отказ или откат
- **WHEN** повтор отклонён либо транзакция создания задания откатывается
- **THEN** FILE_REPROCESS для него отсутствует
#### Scenario: Автоматическая повторная публикация
- **WHEN** outbox повторно публикует прежнее задание без ack, не создавая новую попытку
- **THEN** FILE_REPROCESS не пишется
### Requirement: Аудит уточнения шапки ОСВ
Фактический PATCH шапки ОСВ SHALL писать одно RECOGNIZED_DATA_EDIT раздела RECOGNITION,
object_type = document, object_id документа, user_id инициатора. before/after SHALL содержать изменённые
поля и при сбросе подтверждения status, confirmed_by/at; after_value.unbound_version_ids SHALL содержать
версии снятых привязок. Запрос без изменений MUST NOT писать аудит.
#### Scenario: Подтверждённая ОСВ исправлена
- **WHEN** меняется счёт CONFIRMED ОСВ РБ, привязанной к версии DATA_CORRECTION
- **THEN** одно RECOGNIZED_DATA_EDIT с прежним/новым счётом и подтверждением, after_value содержит
unbound_version_ids, пользователь, IP и User-Agent сохранены
#### Scenario: Возврат к исходному
- **WHEN** аналитик возвращает исходные значения после предыдущей правки
- **THEN** новое RECOGNIZED_DATA_EDIT отражает эту правку; старые события не меняются

View File

@ -15,13 +15,7 @@ Q3 → 01.10.year, Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_versi
`is_in_work = true`), `consolidation_member` периметра (`is_head_entity = false`), первую запись
`consolidation_status_history` (`from_status` NULL → `INITIATED`), подхват подтверждённых документов ЮЛ периметра (см.
`documents-api` «Подхват документов») и одно событие `audit_event(CONSOLIDATION_CREATE)`, у которого
`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии.
При создании версии система SHALL автоматически записать в `consolidation_version.elimination_rules_version` номер
самой актуальной версии правил: максимальный `version_no` среди правил, действующих на московскую календарную дату
создания версии (`valid_from <= дата` и `valid_to IS NULL` либо `valid_to >= дата`; OQ-174, docs PR #146).
Пользователь не выбирает версию правил. Отчётные год, квартал и `reporting_date` не влияют на определение номера.
Если на дату создания нет действующих правил, поле остаётся NULL.
Периметр SHALL быть: без
`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии. Периметр SHALL быть: без
`legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после
схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации
(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке
@ -39,10 +33,6 @@ SHALL оставаться NULL (фиксируется при верифика
- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью,
`audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет
#### Scenario: Правила действуют на дату создания версии
- **WHEN** в день создания консолидации за 2024 год действует версия правил 2, а версия 1 действовала в отчётном периоде
- **THEN** `consolidation_version.elimination_rules_version = 2`; номер не меняется при последующей публикации правил
#### Scenario: Выбранный периметр шире ГСК
- **WHEN** в ГК три ЮЛ, в ГСК два из них, создаётся консолидация с `legal_entity_ids` из всех трёх ЮЛ ГК
- **THEN** 201; в `members` три ЮЛ; `after_value.members` события `CONSOLIDATION_CREATE` — три ЮЛ; состав ГСК не изменился
@ -261,56 +251,6 @@ SHALL возвращать объект `{version_no, is_version_fixed, rules}`.
- **WHEN** открыты правила с `version_no = 1` и `version_no = 2`, в том числе с одинаковым `rule_no`
- **THEN** ответ содержит только правила версии 2 и `version_no = 2`
### Requirement: Сопоставленные строки сводной ОСВ по правилу и паре компаний
`GET /api/v1/consolidations/versions/{version_id}/elimination-pairs` SHALL требовать `CONSOLIDATION_VIEW` и проверять
видимость версии по ГК. Запрос SHALL принимать обязательные query-параметры `rule_id`, `company_a_id`, `company_b_id`.
Компании SHALL быть разными участниками периметра версии, а правило — пресетом ББ её версии правил. Для этой тройки
SHALL возвращаться только её результат; строки других правил и направленных пар компаний в расчёт не входят.
Отбор счетов, раскрытие счетов-предков и их исключений SHALL совпадать с расчётом шахматки PRESET. Ответ SHALL
содержать `version_no`, `rule_id`, `rule_no`, `company_a_id`, `company_b_id`, `matched[]` (точные группы),
`with_discrepancy[]` (группы с допустимым ненулевым расхождением) и `unpaired[]` (строки без группы), без полного
перечня допустимых пар счетов. На уровне тройки ответ SHALL содержать `amount_a`, `amount_b` (суммы всех выбранных
строк, включая оставшиеся без пары), `elimination_amount` (абсолютное расхождение), `deviation_percent` (от меньшей
суммы, `null` при нуле), `eliminated_amount` (сумма фактической элиминации) и `exceeds_threshold`.
При расхождении не более 5 % `eliminated_amount` SHALL равняться `min(amount_a, amount_b)`; при превышении порога,
нулевой стороне или `elimination_mode = manual` SHALL равняться нулю. Упаковка строк SHALL не менять этот итог.
Каждая сопоставленная группа SHALL содержать `rule_id`, `company_a_id`,
`company_b_id`, `amount_a`, `amount_b`, `elimination_amount`, `deviation_percent` и исходные `osv_entry_ids_a/b`.
Суммы `amount_a/b` SHALL рассчитываться по ОСВ; `elimination_amount` SHALL равняться
`abs(amount_a - amount_b)`.
Невидимая и несуществующая версия или отсутствующее правило SHALL отвечать 404 `NOT_FOUND`;
недопустимая пара компаний — 422 `INVALID_COMPANY_PAIR`. Пары и группы не сохраняются.
Строка сводной ОСВ неделима, даже если содержит несколько `entry_ids`. Внутри выбранной тройки строки с распознанным
встречным ЮЛ и допустимыми счетами группируются как 1:1, 1:N или N:1 при N ≤ 5; N:M не допускается. Комбинации
строятся из всех строк выбранных счетов соответствующей стороны правила, без дополнительного требования совпадения
конкретных счетов внутри группы.
Сравниваются модули остатков по соответствующей стороне. Нулевые строки остаются в `unpaired` с причиной
`ZERO_AMOUNT`. Сначала выбираются группы с наименьшим общим числом строк: 1:1, затем 1:2 и 2:1, и так до 1:5 и 5:1.
Внутри размера сначала точные, затем с расхождением не более 5 % относительно меньшей из двух сумм, по возрастанию
расхождения. Строка входит не более чем в одну группу данного правила. Если группы одного размера с одинаковым
расхождением пересекаются, все строки конкурирующих групп исключаются из автоматического поиска и возвращаются в
`unpaired` с причиной `AMBIGUOUS` для ручного разбора. Остальные строки без группы возвращаются в `unpaired` с
причиной `NO_MATCH` независимо от того, отсутствуют ли встречные суммы, превышен ли порог или подходящие строки уже
заняты другой группой.
Упаковка не меняет сумму элиминации.
#### Scenario: Исключённый счёт не входит в пары
- **WHEN** правило включает родительский счёт 76 и исключает 76.07.5
- **THEN** строки счёта 76.07.5 и его потомков не входят в сопоставленные группы и `unpaired`
#### Scenario: Две строки свода упакованы с одной встречной
- **WHEN** по одному правилу есть дебетовые строки 40 и 60 и встречная кредитовая строка 100
- **THEN** `matched[]` содержит пару с суммами 100 и 100, нулевым расхождением и исходными `osv_entry_ids_a/b`
#### Scenario: Пара с допустимым расхождением
- **WHEN** группа имеет суммы 103 и 100 при пороге 5 %
- **THEN** она входит в `with_discrepancy[]`, а не в `matched[]`
#### Scenario: Другая пара компаний не смешивается с выбранной
- **WHEN** по одному правилу есть встречные строки для A→Б и A→В, а запрошены `company_a_id=A`, `company_b_id=Б`
- **THEN** ответ содержит только строки A→Б и Б→A
### Requirement: Результирующая таблица ВГО
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table`
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра

View File

@ -6,8 +6,8 @@
## Requirements
### Requirement: Схема БД равна DBML v5.19
ORM-метаданные SHALL содержать ровно 44 таблицы и 33 enum'а из `../docs/model/data-model-v5.19.dbml` в схеме PostgreSQL
### Requirement: Схема БД равна DBML v5.21
ORM-метаданные SHALL содержать ровно 44 таблицы и 33 enum'а из `../docs/model/data-model-v5.21.dbml` в схеме PostgreSQL
из настройки `DB_SCHEMA` (по умолчанию `dkb`; в контуре — `OPENBAO__APP__DB_SCHEMA`; простой идентификатор
`[a-z_][a-z0-9_]{0,62}`, не начинающийся с `pg_`, иначе приложение не стартует); сверка с DBML идёт в эталонной схеме `dkb`;
имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения
@ -31,9 +31,12 @@ SHALL добавлять обе колонки (существующие зап
(год, затем квартал); файлы без привязок остаются `NULL`; откат SHALL удалять индекс и колонку. Поля, отсутствующие в
DBML, MUST NOT существовать.
Схема v5.19 (RSHB/arm-ka-docs#116) SHALL добавлять: таблицу `document_issue` и enum'ы `statement_line_status`, `statement_form_variant`, `document_issue_kind` и прочие enum'ы DBML v5.19; колонки `document.file_ordinal` (уникальна в паре `(stored_file_id, file_ordinal)`), `recognition_job.statements_version`, `related_group.crm_id` (`varchar`, nullable, уникальна), в `financial_statement_meta` — `form_variant`, `needs_review_fields`, `header_source`, `organization_name_original_value`, `organization_name_modified`; в `financial_statement_column` — `period_kind`, `label_confidence`; в `financial_statement_line` — `row_index`, `source_ref`, `line_status`, `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed`; в `financial_statement_value` — `source_text`, `needs_review`, `is_confirmed` (с `confirmed_by/at`). Уникальность `(meta_id, line_code)` снимается, добавляется частичная уникальность `(meta_id, source_ref)` WHERE `source_ref IS NOT NULL`; уникальность документа `(stored_file_id, document_type, osv_account_code)` становится частичной (только для типов, кроме `BALANCE` / `INCOME`). Состав и порядок колонок — буквально по DBML v5.19.
Схема v5.20 SHALL добавлять в конец `document` колонки `is_modified` (`boolean NOT NULL DEFAULT false`), `original_values`
(`jsonb`, nullable), `modified_by` (`uuid`, nullable, FK → `app_user.id`) и `modified_at` (`timestamptz`, nullable); схема v5.21 SHALL
добавлять значение `FILE_REPROCESS` в enum `audit_action`. Состав и порядок колонок — буквально по DBML v5.21.
#### Scenario: Сверка ORM с DBML
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.19.dbml` (`make dbml-check`)
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.21.dbml` (`make dbml-check`)
- **THEN** вывод пуст и код возврата 0
#### Scenario: Лишняя колонка в ORM
@ -64,7 +67,7 @@ DBML, MUST NOT существовать.
`stored_file`; `alembic downgrade 0005_dbml_v5_17` удаляет индекс и колонку, повторный `upgrade` их возвращает
#### Scenario: Сверка с DBML v5.19
- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.19.dbml`
- **WHEN** выполняется `make dbml-check` по `../docs/model/data-model-v5.21.dbml`
- **THEN** таблицы, колонки, enum'ы, ключи и индексы ORM совпадают с DBML без расхождений
### Requirement: Миграции применяются с нуля и откатываются
@ -99,11 +102,11 @@ enum'ы, таблицы, ограничения, индексы и послед
- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`)
### Requirement: Версия DBML зафиксирована в коде
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.19"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.21"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
#### Scenario: Новая версия DBML без обновления ORM
- **WHEN** в `docs/model/` появляется `data-model-v5.20.dbml`, а константа в ORM — `5.19`
- **WHEN** в `docs/model/` появляется `data-model-v5.22.dbml`, а константа в ORM — `5.21`
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
### Requirement: Деньги и проценты — точные типы
@ -161,3 +164,35 @@ organization_name`, `document.file_ordinal` — по `(created_at, id)` внут
#### Scenario: Откат после подтверждений
- **WHEN** в БД нет несовместимых данных, но у ячеек есть `is_confirmed = true` и записи `document_issue`
- **THEN** `downgrade` проходит, удаляет колонки и таблицу и пишет в журнал число удалённых подтверждений и записей
### Requirement: Миграция DBML v5.20
Одна добавочная миграция после 0007_dbml_v5_19 SHALL добавить четыре колонки document по DBML v5.20; существующие записи получают is_modified = false и остальные поля NULL. Миграция MUST NOT менять документы, их строки, подтверждения и привязки. Downgrade SHALL до любых изменений проверить отсутствие document.is_modified = true и при наличии остановиться с ошибкой и числом записей.
#### Scenario: Обновление существующих ОСВ
- **WHEN** миграция применяется к базе с подтверждёнными ОСВ РБ
- **THEN** добавлены четыре поля, данные ОСВ, id, file_ordinal, подтверждения и привязки сохранены; сверка с DBML v5.20 проходит
#### Scenario: Защита от потери исправлений
- **WHEN** хотя бы один документ имеет is_modified = true и запрошен downgrade до v5.19
- **THEN** откат прекращается до DDL с причиной и числом записей, схема и данные не меняются
#### Scenario: Пустая база и нестандартная схема
- **WHEN** выполняется upgrade head, downgrade base, upgrade head на пустой базе с настроенным DB_SCHEMA
- **THEN** цикл успешен, ORM соответствует DBML v5.20, autogenerate пуст
### Requirement: Миграция DBML v5.21 и действие аудита повторной обработки
Отдельная миграция v5.21 после миграции v5.20 SHALL добавить только FILE_REPROCESS в enum audit_action
по DBML v5.21. Миграция SHALL сохранять все существующие значения и события. Downgrade MUST NOT удалять либо
переписывать audit_event; при наличии FILE_REPROCESS он SHALL завершаться ошибкой до изменения схемы.
#### Scenario: Схема после дополнения модели
- **WHEN** миграция v5.21 применена к БД v5.20
- **THEN** событие нового типа записывается, прежние события читаются без изменений, сверка DBML успешна
#### Scenario: Откат после ручного повтора
- **WHEN** существует audit_event с FILE_REPROCESS и выполняется downgrade с v5.21 к v5.20
- **THEN** ошибка с причиной и количеством несовместимых событий до DDL, события и схема сохранены
#### Scenario: Независимый откат enum
- **WHEN** в БД v5.21 нет FILE_REPROCESS и выполняется downgrade к v5.20
- **THEN** удалено только новое значение enum; четыре поля document, исходные значения и привязки сохранены

View File

@ -183,3 +183,20 @@ SHALL быть отключена с подсказкой «Разберите
#### Scenario: Предупреждение листа ОСВ
- **WHEN** у ОСВ-документа в `issues` есть запись с кодом `OSV_*`
- **THEN** над таблицей показан блок предупреждений с текстом записи и уровнем
### Requirement: Переключатель документа
В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации,
что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат ·
дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id`
выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера
возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному.
#### Scenario: Назад после перебора документов
- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два
документа и нажал «Назад»
- **THEN** открыт экран консолидации, с которого был открыт первый документ
#### Scenario: Документ открыт прямой ссылкой
- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал
«Назад» экрана
- **THEN** открыт список групп компаний `/groups`

View File

@ -568,3 +568,112 @@ locator?}` (`fileWarning` контракта): у `2.0` — как пришли,
#### Scenario: Нарушение с пустой целью в ответе
- **WHEN** у формы запись `relation` по `1700 = 1300 + 1400 + 1500` с `expected = NULL`
- **THEN** элемент `checks.failed` имеет `expected = null`, `actual` — сумма членов
### Requirement: Уточнение шапки ОСВ
`PATCH /api/v1/documents/{id}/osv-header` SHALL требовать `DOCUMENT_EDIT` и видимость ЮЛ, принимать частичный набор
`period_year`, `period_quarter`, `is_cumulative`, `osv_account_code` и возвращать карточку документа с
`is_modified`, `original_values`, `modified_by`, `modified_at`. Отсутствующие поля SHALL сохраняться.
Правила — §9.5 п.12. Чужой и несуществующий документ SHALL возвращать одинаковый 404.
#### Scenario: Изменение только счёта
- **WHEN** аналитик меняет счёт ОСВ РБ с 60 на 62, не передавая период
- **THEN** меняется только счёт; даже неопределённый квартал остаётся прежним
#### Scenario: Нет доступа
- **WHEN** пользователь без DOCUMENT_EDIT правит шапку либо документ находится вне видимости ЮЛ
- **THEN** без права возвращается 403, при наличии права и отсутствии видимости — 404; данные не меняются
### Requirement: Проверки правки шапки ОСВ
После проверки типов тела запроса Pydantic PATCH SHALL проверять последовательно: тип OSV (иначе 409 DOCUMENT_NOT_OSV), файл не SUPERSEDED (иначе 409
FILE_SUPERSEDED), корректность периода (422 INVALID_PERIOD), счёта (422 INVALID_ACCOUNT_CODE), отсутствие
другой ОСВ с тем же нормализованным счётом в файле (409 DUPLICATE_ACCOUNT с detail.document_id),
редактируемость привязанных версий (409 VERSION_NOT_EDITABLE с detail.version_ids). Все проверки SHALL
предшествовать записи.
#### Scenario: Частичная правка периода
- **WHEN** передано хотя бы одно поле периода
- **THEN** итоговые год, квартал и is_cumulative обязательны: год 1900–2099, квартал Q1–Q4, признак boolean;
для Q1 признак true; иначе 422 INVALID_PERIOD без изменений
#### Scenario: Нормализация и коллизия
- **WHEN** передано 60.1, а у другой ОСВ этого файла уже счёт 60.01
- **THEN** нормализация даёт 60.01, возвращается 409 DUPLICATE_ACCOUNT с id другого документа
#### Scenario: Пустой счёт
- **WHEN** передан пустой счёт
- **THEN** 422 INVALID_ACCOUNT_CODE, прежний счёт сохранён
#### Scenario: Порядок конфликтов
- **WHEN** у заменённого файла документ BALANCE и некорректный период в теле
- **THEN** возвращается DOCUMENT_NOT_OSV; у OSV того же файла — FILE_SUPERSEDED
### Requirement: Исходная шапка и признак исправления
При первой фактической правке ядро SHALL сохранить прежние значения всех четырёх полей в document.original_values;
при последующих правках снимок MUST NOT меняться. Новые значения SHALL записываться в ту же строку document,
is_modified SHALL отражать отличие от снимка, modified_by/at — последнюю правку. document.id, file_ordinal,
строки osv_entry, файл и JSON результата в S3 MUST NOT изменяться.
#### Scenario: Две правки
- **WHEN** счёт ОСВ РБ меняется 60 → 62 → 76
- **THEN** текущий счёт 76, original_values.osv_account_code = 60, в original_values также исходный период;
is_modified = true, id и количество документов и строк ОСВ прежние
#### Scenario: Возврат к исходному
- **WHEN** все четыре поля возвращены к значениям original_values
- **THEN** is_modified = false, original_values остаётся заполненным, последняя правка атрибутирована
#### Scenario: Запрос без изменений
- **WHEN** нормализованная итоговая шапка совпадает с текущей
- **THEN** 200 с карточкой без аудита, создания original_values, сброса подтверждения и изменения modified_by/at
### Requirement: Снятие подтверждения ОСВ после уточнения
Фактическая правка CONFIRMED ОСВ SHALL установить NEEDS_CHECK, очистить confirmed_by/at и снять привязки
consolidation_document из всех версий «в работе», сохранив привязки CANCELLED и статусы версий.
Привязка к иной версии SHALL запретить правку. Неподтверждённый документ SHALL сохранить статус.
consolidation_lock MUST NOT требоваться; автоподтверждение после PATCH MUST NOT запускаться.
#### Scenario: Исправление автоподтверждённой ОСВ
- **WHEN** ОСВ РБ привязана к версии DATA_CORRECTION, аналитик исправляет период
- **THEN** NEEDS_CHECK, confirmed_by/at = null, привязка снята, версия остаётся DATA_CORRECTION;
повторное ручное подтверждение привязывает документ по новому периоду и счёту
#### Scenario: Завершённая версия
- **WHEN** документ привязан к VERIFIED, даже если одновременно привязан к версии «в работе»
- **THEN** 409 VERSION_NOT_EDITABLE со списком запрещающих version_ids, ни одна привязка и шапка не меняются
#### Scenario: Отменённая версия
- **WHEN** подтверждённый документ связан только с CANCELLED и версиями «в работе»
- **THEN** правка разрешена без consolidation_lock, привязки CANCELLED остаются, остальные снимаются
### Requirement: Производные данные после уточнения ОСВ
Подходящесть версиям, чек-лист и колонка «ОСВ» SHALL отражать новую шапку при следующем чтении;
period_check, counterparty_check и строки osv_entry SHALL сохраняться. Правка MUST NOT запускать автозамену
другого файла; замещение привязки с тем же ключом происходит при повторном подтверждении по §3 п.1.
Перезагрузка файла MUST NOT переносить уточнения старых документов. Флаг CONSOLIDATION_PERIOD_FROM_VERSION сохраняется.
#### Scenario: Смена счёта шапки
- **WHEN** счёт ОСВ РБ исправлен с 60 на 62
- **THEN** колонка «ОСВ» и подходящие слоты чек-листа учитывают 62; account_code/account_id/account_root
строк ОСВ, period_check и counterparty_check не изменились, другой файл ОСВ 62 не заменён
#### Scenario: Перезагрузка
- **WHEN** новый загруженный файл распознан с исходным счётом 60
- **THEN** новые документы созданы из нового результата, исправление 62 предыдущего документа не переносится
### Requirement: Строгие типы шапки ОСВ
PATCH SHALL принимать period_year как integer, period_quarter и osv_account_code как string,
is_cumulative как boolean, без приведения типов. Отсутствующие поля не меняются; null SHALL передаваться
в доменную проверку явно. Неверные типы SHALL отклоняться стандартным 422 валидации тела до доменных
проверок. Границы года, допустимые кварталы и совместимость полей SHALL проверяться доменом.
#### Scenario: Неверный тип года
- **WHEN** передан period_year как строка, дробное число, boolean, массив или объект
- **THEN** стандартный 422 с detail-массивом ошибок валидации, доменная обработка не запускается
#### Scenario: Год вне диапазона
- **WHEN** у доступного незаменённого документа OSV передан period_year = 2100
- **THEN** тип integer принят, домен возвращает 422 INVALID_PERIOD
#### Scenario: Неизвестный квартал
- **WHEN** у доступного незаменённого документа OSV передан period_quarter = "Q0"
- **THEN** тип string принят, домен возвращает 422 INVALID_PERIOD

View File

@ -60,7 +60,7 @@ Query-параметр `consolidation_version_id` MUST NOT приниматьс
- **THEN** `rejected[0].code = DUPLICATE_FILE`, `rejected[0].file_id` — прежний файл; новых записей нет
### Requirement: Жизненный цикл статуса файла
`stored_file.status` SHALL меняться только по заданию `current_recognition_job_id`: `UPLOADED` при загрузке;
`stored_file.status` SHALL меняться только по заданию `current_recognition_job_id`: `UPLOADED` при загрузке и при принятом ручном повторе с новым текущим заданием;
`PROCESSING` при ack сервиса; `ERROR` при финальной ошибке задания (`error`, `timeout`, `dlq`) и при невозможности
маппинга; `PROCESSED` после успешного маппинга; `SUPERSEDED` при автозамене. Карточка файла SHALL показывать причину
`ERROR` (`error_code`/`error_message` задания или ошибку маппинга).
@ -238,3 +238,63 @@ advisory-блокировка привязок ГК → версии по `id`.
#### Scenario: Очистка и повторная загрузка
- **WHEN** очистка выполнена, ядро v5.19 выложено, аналитик загружает тот же PDF с ББ и ОФР
- **THEN** дедуп не мешает (файла больше нет), формы создаются по мапперу 1.x или 2.0 — смотря что шлёт сервис
### Requirement: Ручной повтор обработки файла
`POST /api/v1/files/{file_id}/retry` SHALL требовать DOCUMENT_EDIT и видимость ЮЛ. Повтор разрешён для
текущего задания tabular в timeout/TIMEOUT или dlq/NO_ACK при attempt < max_attempts, отсутствии документов
и файле ERROR. Успех SHALL вернуть 202 с карточкой файла и новым текущим заданием. Несуществующий или чужой файл — 404.
#### Scenario: После таймаута
- **WHEN** табличный файл ОСВ РБ завершился timeout/TIMEOUT, attempt = 1, max_attempts = 3
- **THEN** 202, создано новое задание attempt = 2, прежнее осталось, в карточке новое текущее задание
#### Scenario: Неподходящее состояние
- **WHEN** повтор запрошен для OCR, done, needs_check, error сервиса, ошибки маппинга либо файла с документами
- **THEN** 409 FILE_NOT_RETRYABLE без нового задания и аудита
#### Scenario: Лимит исчерпан
- **WHEN** подходящее задание имеет attempt = max_attempts
- **THEN** 409 MAX_ATTEMPTS_REACHED, состояние не меняется
### Requirement: Пакет повторной обработки версии
`POST /api/v1/consolidations/versions/{version_id}/files/retry` SHALL выбирать видимые ERROR-файлы
консолидации по периметру версии. Другие статусы не входят в пакет и не блокируются.
Ответ — 202 с accepted[{file_id, job_id, attempt}] и skipped[{file_id, code}]. В skipped SHALL входить
только ERROR-файлы, которые нельзя повторить (FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED).
Файлы без контекста консолидации MUST NOT включаться по совпадению ЮЛ.
#### Scenario: Смешанный пакет
- **WHEN** у версии есть файл timeout/TIMEOUT, OCR, успешный файл и файл с исчерпанным лимитом
- **THEN** новый job создаётся только для первого; OCR-файл в skipped с `FILE_NOT_RETRYABLE`, файл с исчерпанным
лимитом — с `MAX_ATTEMPTS_REACHED`; успешный (`PROCESSED`) файл не попадает ни в accepted, ни в skipped
#### Scenario: Периметр и контекст
- **WHEN** файл загружен в другую консолидацию либо его ЮЛ не входит в выбранную версию
- **THEN** файл не участвует ни в accepted, ни в skipped этой версии
#### Scenario: Общие файлы разных версий
- **WHEN** две версии одной консолидации содержат одно ЮЛ и вызывают пакетный повтор
- **THEN** они используют общий набор файлов консолидации; уже повторяемый файл второй раз не запускается
#### Scenario: Пустой пакет
- **WHEN** подходящих файлов нет
- **THEN** 202 с пустым accepted; в skipped — только файлы в `ERROR`, которые повторить нельзя (без них список пуст);
аудита повторов нет
### Requirement: Ограничения версии при повторе
Повтор в контексте версии SHALL разрешаться только для версий «в работе» (INITIATED, DOCUMENTS_PROCESSING,
DATA_CORRECTION). Иначе SHALL возвращаться 409 VERSION_NOT_EDITABLE. consolidation_lock MUST NOT требоваться.
Одиночный повтор файла с consolidation_id SHALL требовать наличия версии «в работе» с ЮЛ файла в периметре;
без consolidation_id SHALL применяться только policy ЮЛ. Повтор MUST NOT изменять статусы версий.
#### Scenario: Проверенная версия
- **WHEN** пакет запрошен для VERIFIED
- **THEN** 409 VERSION_NOT_EDITABLE, ни один файл не изменён
#### Scenario: Консолидация уже закрыта для обработки
- **WHEN** одиночный повтор файла консолидации запрошен без версии «в работе» с его ЮЛ
- **THEN** 409 VERSION_NOT_EDITABLE; другая версия консолидации не открывает недоступный пакет версии
#### Scenario: Сохранение статуса
- **WHEN** пакет допустимой версии INITIATED принят без пользовательской блокировки
- **THEN** попытки созданы, INITIATED сохраняется; дальнейшие переходы происходят по существующим событиям

View File

@ -82,8 +82,7 @@
### Requirement: Таймаут ожидания результата
Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в
`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в
`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Replay
задания (новая попытка, `attempt + 1`) — вне этого среза.
`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Ручной повтор после TIMEOUT/NO_ACK SHALL создавать новое задание по требованию «Новая попытка обработки файла».
#### Scenario: Сервис взял, но не ответил
- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата
@ -162,3 +161,38 @@ Inbox SHALL принимать конверты (включая ack `processing`
#### Scenario: Результат 2.0 разбирает маппер 2.0
- **WHEN** inbox принял файл результата со `schema_version = "2.0"`
- **THEN** документы создаёт маппер результата `2.0`; результат `1.x` по-прежнему разбирает маппер `1.x`
### Requirement: Новая попытка обработки файла
Ручной повтор SHALL создать recognition_job с новым id, attempt = прежний attempt + 1, тем же file_id,
pipeline и max_attempts, статусом queued. Предыдущая запись SHALL сохраняться без изменений.
Файл SHALL ссылаться на новое задание и получить UPLOADED. Ссылка S3 на исходник и consolidation_id сохраняются;
новых документов, файлов и копий исходника SHALL NOT создаваться.
#### Scenario: Повтор после NO_ACK
- **WHEN** tabular-задание ОСВ РБ завершилось dlq/NO_ACK с attempt = 1
- **THEN** новое задание queued имеет attempt = 2, файл UPLOADED с прежним id и объектом S3,
current_recognition_job_id указывает на новый id
### Requirement: Изоляция новой попытки
Новое задание SHALL начинаться без ack, результата, ошибок, метрик и времён старого задания, со свежим
requested_at. Публикатор SHALL вычислить новый reply_deadline и Nats-Msg-Id для нового job_id/attempt.
Запоздавшие ack/результаты прежней финальной попытки MUST NOT менять файл, создавать документы или
мешать обработке нового задания.
#### Scenario: Старый ответ
- **WHEN** после создания попытки 2 пришли ack и done для попытки 1 в timeout
- **THEN** текущее задание, статус файла и документы не меняются; ответ логируется и игнорируется
#### Scenario: Публикация после недоступности NATS
- **WHEN** ручной повтор принят, а NATS временно недоступен
- **THEN** задание остаётся queued; после восстановления публикуется со свежим сроком и новым Nats-Msg-Id
### Requirement: Конкурентные запросы повтора
Проверка текущего задания и лимита, создание новой попытки и переключение файла SHALL быть атомарны.
Два запроса к одной неуспешной попытке MUST NOT создавать два новых задания. При rollback новое задание,
переключение файла и аудит SHALL откатываться вместе.
#### Scenario: Двойное нажатие
- **WHEN** два параллельных запроса повторяют attempt = 1
- **THEN** создан ровно один job attempt = 2; второй одиночный запрос получает 409 FILE_NOT_RETRYABLE,
а пакетный отмечает файл skipped; attempt = 3 не создаётся

View File

@ -94,7 +94,7 @@ export function DocumentPage() {
style={{ width: '100%' }}
value={doc.id}
options={documentOptions}
onChange={(value) => navigate(`/documents/${value}`)}
onChange={(value) => navigate(`/documents/${value}`, { replace: true })}
optionRender={(option) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
{/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */}