Compare commits
22 Commits
827b490137
...
fdb1f179cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb1f179cc | ||
|
|
b4522addc0 | ||
|
|
812e530fe5 | ||
|
|
888ad4b401 | ||
| 1b3f85c88c | |||
|
|
70c5743c77 | ||
| 4d3e596337 | |||
|
|
3bc71a912d | ||
|
|
b5520696ef | ||
|
|
e13cd88fea | ||
| f2c70935b2 | |||
|
|
f3abc8c01c | ||
| 5ba3bc9971 | |||
|
|
013b04d4c3 | ||
| 8dfa068c6d | |||
|
|
21f40bc9bf | ||
|
|
1197a94f3c | ||
| 622a60e4e0 | |||
|
|
98f00c0faa | ||
|
|
b32383eff7 | ||
|
|
5c70fb5387 | ||
| 4f44d34d59 |
@ -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`, как и
|
||||
|
||||
2
Makefile
2
Makefile
@ -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
|
||||
|
||||
|
||||
93
README.md
93
README.md
@ -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) — план срезов;
|
||||
|
||||
28
api/alembic/versions/0008_dbml_v5_20.py
Normal file
28
api/alembic/versions/0008_dbml_v5_20.py
Normal 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}")
|
||||
28
api/alembic/versions/0009_dbml_v5_21.py
Normal file
28
api/alembic/versions/0009_dbml_v5_21.py
Normal 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 $$""")
|
||||
28
api/alembic/versions/0010_dbml_v5_23.py
Normal file
28
api/alembic/versions/0010_dbml_v5_23.py
Normal 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)
|
||||
@ -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 = "нет"
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
40
api/src/api/endpoints/files/retry.py
Normal file
40
api/src/api/endpoints/files/retry.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
34
api/src/domain/osv_header.py
Normal file
34
api/src/domain/osv_header.py
Normal 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
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 (дедуп)."""
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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):
|
||||
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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()),
|
||||
)
|
||||
|
||||
187
api/src/services/file_retry.py
Normal file
187
api/src/services/file_retry.py
Normal 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
|
||||
@ -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"):
|
||||
|
||||
104
api/src/services/osv_header.py
Normal file
104
api/src/services/osv_header.py
Normal 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)
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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},
|
||||
}
|
||||
|
||||
421
api/tests/test_api_file_retry.py
Normal file
421
api/tests/test_api_file_retry.py
Normal 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
|
||||
@ -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
|
||||
|
||||
228
api/tests/test_api_osv_header.py
Normal file
228
api/tests/test_api_osv_header.py
Normal 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
|
||||
280
api/tests/test_api_osv_header_synthetic.py
Normal file
280
api/tests/test_api_osv_header_synthetic.py
Normal 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
|
||||
@ -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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
47
api/tests/test_domain_osv_header.py
Normal file
47
api/tests/test_domain_osv_header.py
Normal 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"
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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",)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
52
api/tests/test_osv_header_schema.py
Normal file
52
api/tests/test_osv_header_schema.py
Normal 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"}]
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-23
|
||||
@ -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` нет; поведение проверяется вручную по сценариям спеки.
|
||||
@ -0,0 +1,18 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Переключатель документа
|
||||
В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации,
|
||||
что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат ·
|
||||
дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id`
|
||||
выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера
|
||||
возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному.
|
||||
|
||||
#### Scenario: Назад после перебора документов
|
||||
- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два
|
||||
документа и нажал «Назад»
|
||||
- **THEN** открыт экран консолидации, с которого был открыт первый документ
|
||||
|
||||
#### Scenario: Документ открыт прямой ссылкой
|
||||
- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал
|
||||
«Назад» экрана
|
||||
- **THEN** открыт список групп компаний `/groups`
|
||||
@ -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`.
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-22
|
||||
@ -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 не выкладывается этой задачей.
|
||||
@ -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.
|
||||
@ -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 отражает эту правку; старые события не меняются
|
||||
@ -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, исходные значения и привязки сохранены
|
||||
@ -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
|
||||
@ -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`
|
||||
@ -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 не создаётся
|
||||
@ -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 не менялся.
|
||||
@ -1,3 +1,4 @@
|
||||
# elimination-pairs-rule-totals
|
||||
|
||||
Тесты упаковки и итог элиминации для тройки правила и компаний
|
||||
Пары ВГО §5 (AURORA-1536, PR #175): фиксация версии правил при создании версии консолидации (DBML v5.23, NOT NULL),
|
||||
итог и упаковка тройки «правило × A × B», список троек версии.
|
||||
|
||||
@ -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), а сумма у группы читалась бы как вклад в элиминацию | Добавить поле у группы, если корректировкам понадобится |
|
||||
|
||||
@ -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), правила и пары ОФР.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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`
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 отражает эту правку; старые события не меняются
|
||||
|
||||
@ -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[]}`. Компании — все члены периметра
|
||||
|
||||
@ -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, исходные значения и привязки сохранены
|
||||
|
||||
@ -183,3 +183,20 @@ SHALL быть отключена с подсказкой «Разберите
|
||||
#### Scenario: Предупреждение листа ОСВ
|
||||
- **WHEN** у ОСВ-документа в `issues` есть запись с кодом `OSV_*`
|
||||
- **THEN** над таблицей показан блок предупреждений с текстом записи и уровнем
|
||||
|
||||
### Requirement: Переключатель документа
|
||||
В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации,
|
||||
что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат ·
|
||||
дата) и статус; пункты, которые нельзя открыть, неактивны. Выбор пункта SHALL открывать `/documents/:document_id`
|
||||
выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера
|
||||
возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному.
|
||||
|
||||
#### Scenario: Назад после перебора документов
|
||||
- **WHEN** аналитик открыл документ из панели «Организации» консолидации, выбрал в списке «Документ» подряд ещё два
|
||||
документа и нажал «Назад»
|
||||
- **THEN** открыт экран консолидации, с которого был открыт первый документ
|
||||
|
||||
#### Scenario: Документ открыт прямой ссылкой
|
||||
- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал
|
||||
«Назад» экрана
|
||||
- **THEN** открыт список групп компаний `/groups`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 сохраняется; дальнейшие переходы происходят по существующим событиям
|
||||
|
||||
@ -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 не создаётся
|
||||
|
||||
@ -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 имя файла одно — различаются второй строкой (тип · формат · дата) */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user