Merge pull request 'Файлы в контексте консолидации: stored_file.consolidation_id, маршруты /consolidations/{id}/legal-entities/{le}/files (DBML v5.18, #96)' (#97) from feat/96-console-files into main
Reviewed-on: #97
This commit is contained in:
commit
9be7d7508b
@ -73,7 +73,7 @@
|
||||
`OUTBOX_MAX_REPUBLISH`=3, счётчик — `recognition_job.metrics.republish_count`) → `dlq`; дедуп — `request_msg_id` /
|
||||
`result_msg_id`. Статусы задания в Срезе 0 `audit_event` не пишут.
|
||||
- **Первичные документы (Срез 2c)** — `services/primary_files.py` (загрузка `upload_one`: дедуп по `(ЮЛ, sha256)` под
|
||||
advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status`
|
||||
advisory-lock, SAVEPOINT на файл, `delete`), v5.18 (#96): загрузка в консолидацию — `check_consolidation_for_upload` → `accept_uploads` (`endpoints/files/upload.py`, общий для обоих маршрутов) → `after_upload_to_consolidation` (отдельная транзакция, версии консолидации `INITIATED` с ЮЛ в периметре под `FOR UPDATE ORDER BY id`); рабочий стол — `page_desk` / `files_repo.list_desk_files` (`OR EXISTS` по `consolidation_document`); автозамена наследует `consolidation_id`; `services/file_status.py` (единственное место смены `stored_file.status`
|
||||
по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга:
|
||||
`pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции →
|
||||
автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `services/auto_confirm.py` (автоподтверждение чистых документов в транзакции маппинга: `_map_candidate`: `map_file` → автозамена → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157), `domain/osv_mapping.py` (чистые функции:
|
||||
@ -129,7 +129,7 @@
|
||||
(`counterparty_id` + `counterparty_match_kind` + алиас); (5) ключ замены привязки — «тип, счёт, квартал, нарастающий»
|
||||
(R-1); ОСВ за Q1 равнозначна нарастающей и квартальной (R-14, Q1-эквивалентность — только для ОСВ, ББ/ОФР за Q1 версиям Q2–Q4
|
||||
не подходят, R-14a); (6) «строка версии» для ручного сопоставления/автоприменения — только строки свода (R-11); (7) 404 —
|
||||
код `NOT_FOUND`, различие по тексту; правки — `ensure_version_editable` (`VERSION_NOT_EDITABLE`/`LOCK_REQUIRED`/
|
||||
код `NOT_FOUND`, различие по тексту (в т. ч. «консолидация не найдена»); правки — `ensure_version_editable` (`VERSION_NOT_EDITABLE`/`LOCK_REQUIRED`/
|
||||
`LOCKED_BY_OTHER` без `locked_by_name`). Деньги в ответах — строки с двумя знаками. Аудит: `CONSOLIDATION_CREATE`,
|
||||
`CONSOLIDATION_STATUS_CHANGE`, `CONSOLIDATION_LOCK`, `CONSOLIDATION_LOCK_TAKEOVER`, `MEMBERSHIP_CHANGE`,
|
||||
`OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`, `EXPORT`; `FILE_REPLACE.unbound_version_ids`. Контракт для фронта —
|
||||
@ -159,7 +159,7 @@
|
||||
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
|
||||
|
||||
## Правила репозитория
|
||||
- ORM 1:1 с `../docs/model/data-model-v5.17.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
|
||||
- ORM 1:1 с `../docs/model/data-model-v5.18.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`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
|
||||
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
|
||||
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
|
||||
- Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR.
|
||||
|
||||
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.17.dbml
|
||||
DBML ?= ../../docs/model/data-model-v5.18.dbml
|
||||
|
||||
.PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev
|
||||
|
||||
|
||||
24
README.md
24
README.md
@ -420,6 +420,7 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о
|
||||
|
||||
```
|
||||
POST /legal-entities/{id}/files → S3 uploads/… + stored_file(UPLOADED) + recognition_job(queued) + audit FILE_UPLOAD
|
||||
(`consolidation_id` NULL; загрузка в консолидацию — маршрут консолидации)
|
||||
→ публикатор (outbox) → NATS files.tabular_uploaded → tabular-parser (читает S3, пишет results/…)
|
||||
→ files.tabular_parsed → inbox (задание done, файл PROCESSING) → цикл маппинга (document + osv_entry, файл PROCESSED)
|
||||
→ автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm (только для NEEDS_CHECK и форм после правки)
|
||||
@ -526,6 +527,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл
|
||||
|---|---|---|
|
||||
| `POST /legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка файлов (multipart `files`), ответ `{accepted[], rejected[]}` |
|
||||
| `GET /legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | файлы ЮЛ, новые первыми (`limit`/`offset`; `include_superseded=true` — с заменёнными) |
|
||||
| `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка в консолидацию (v5.18): файл получает `consolidation_id`; версии консолидации «в работе» с ЮЛ в периметре `INITIATED` → `DOCUMENTS_PROCESSING` |
|
||||
| `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | рабочий стол консолидации для ЮЛ: файлы с её `consolidation_id` + файлы с документами, привязанными к её версиям (`limit`/`offset`/`include_superseded`) |
|
||||
| `GET /files/{file_id}` | `DOCUMENT_VIEW` | карточка файла: статус, `job` (`error_code`, `error_message`, `mapping_error`, `warnings`, `mapping_warnings`), документы |
|
||||
| `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 |
|
||||
| `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` |
|
||||
@ -616,7 +619,7 @@ advisory-блокировкой.
|
||||
- Аудит обмена (смена статусов задания/файла в inbox, вахтёре, цикле маппинга) не пишется — `ASSUMPTION(OQ-55)`;
|
||||
`audit_event` пишут только загрузка, автозамена, удаление, подтверждение.
|
||||
- Объекты S3 при удалении не стираются (OQ-22, OQ-111): растёт бакет; e2e оставляет ~2 объекта за прогон.
|
||||
- Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены
|
||||
- Общий размер multipart-запроса `POST /legal-entities/{id}/files` (и маршрута консолидации) и число файлов в нём приложением не ограничены
|
||||
(Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы.
|
||||
- Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»).
|
||||
Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED` при маппинге, затем автоподтверждение;
|
||||
@ -663,7 +666,7 @@ advisory-блокировкой.
|
||||
| `GET /consolidations/versions/{id}/osv/export` | VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» |
|
||||
| `GET /consolidations/versions/{id}/counterparty-candidates` | VIEW | кандидаты для сопоставления: `scope=group\|all`, `q`, `entry_id` |
|
||||
| `POST /consolidations/versions/{id}/osv/entries/{entry_id}/counterparty` | EDIT | ручное сопоставление контрагента строки свода |
|
||||
| `POST /legal-entities/{id}/files?consolidation_version_id=` | `DOCUMENT_EDIT` | загрузка в контексте версии (необязательный параметр, Срез 2c) |
|
||||
| `POST /consolidations/{id}/legal-entities/{le}/files` | `DOCUMENT_EDIT` | загрузка в контексте консолидации (v5.18); query `consolidation_version_id` у маршрута ЮЛ удалён (v5.18) |
|
||||
|
||||
Деньги в ответах — строки JSON с двумя знаками (`"100.00"`); версия любого статуса (включая `CANCELLED`) читается.
|
||||
|
||||
@ -678,7 +681,7 @@ advisory-блокировкой.
|
||||
|
||||
| Событие | Переход | `changed_by` |
|
||||
|---|---|---|
|
||||
| загрузка с `consolidation_version_id`, принят ≥ 1 файл | `INITIATED` → `DOCUMENTS_PROCESSING` | загрузивший |
|
||||
| загрузка в консолидацию, принят ≥ 1 файл | `INITIATED` → `DOCUMENTS_PROCESSING` у каждой версии консолидации «в работе» с ЮЛ в периметре | загрузивший |
|
||||
| маппинг создал документ `NEEDS_CHECK`, подходящий версии | `DOCUMENTS_PROCESSING` → `DATA_CORRECTION` | `uploaded_by` файла (нет — переход пропускается, лог) |
|
||||
| подтверждение документа, привязанного к версии | `INITIATED`/`DOCUMENTS_PROCESSING` → `DATA_CORRECTION` | подтвердивший |
|
||||
| `POST …/status {CANCELLED, comment}` | любой «в работе» → `CANCELLED` (снимает блокировку) | пользователь |
|
||||
@ -822,7 +825,7 @@ email без учёта регистра проверяет только сер
|
||||
|
||||
| Код | HTTP | Где |
|
||||
|---|---|---|
|
||||
| `NOT_FOUND` | 404 | объекта нет или он вне видимости; в консолидациях различие — по тексту: «версия консолидации не найдена», «ГК не найдена», «ГСК/ГСЗ не найдена», «строка ОСВ не найдена в версии», «ЮЛ не найдено» |
|
||||
| `NOT_FOUND` | 404 | объекта нет или он вне видимости; в консолидациях различие — по тексту: «версия консолидации не найдена», «консолидация не найдена», «ГК не найдена», «ГСК/ГСЗ не найдена», «строка ОСВ не найдена в версии», «ЮЛ не найдено» |
|
||||
| `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) |
|
||||
| `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом |
|
||||
| `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр; в правке формы (`PATCH …/statement`) — не из 10 или 12 цифр |
|
||||
@ -869,10 +872,11 @@ email без учёта регистра проверяет только сер
|
||||
| `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` |
|
||||
| `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка с `consolidation_version_id`: ЮЛ файла не входит в периметр версии |
|
||||
| `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка в консолидацию: ЮЛ не входит в периметр ни одной версии «в работе» |
|
||||
| `CONSOLIDATION_NOT_EDITABLE` | 409 | загрузка в консолидацию без версии «в работе» |
|
||||
| `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all`: `q` короче 3 символов после обрезки |
|
||||
| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) |
|
||||
| `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки, загрузка с версией) либо `CANCELLED` при «взять в работу» |
|
||||
| `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки) либо `CANCELLED` при «взять в работу» |
|
||||
| `LOCK_REQUIRED` | 409 | правка версии без своей активной блокировки |
|
||||
| `LOCKED_BY_OTHER` | 409 | версию держит другой пользователь: из `POST …/lock` — `locked_by`, `locked_by_name`, `locked_at`; из правок (`ensure_version_editable`) — только `locked_by`, `locked_at`; гонка двух взятий (частичный UNIQUE) — то же 409 |
|
||||
| `NOT_LOCK_OWNER` | 409 | `DELETE …/lock`: блокировка чужая (`locked_by`, `locked_at`) |
|
||||
@ -952,7 +956,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.17.dbml` |
|
||||
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.18.dbml` |
|
||||
| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
|
||||
| `make check` | быстрая проверка без Docker: `lint` + `test` |
|
||||
| `make docker-build` | сборка образа: `docker build -t arm-core .` |
|
||||
@ -1004,7 +1008,7 @@ queue group `arm-core`, подписчики группы получают со
|
||||
|
||||
### Проверка ORM и DBML
|
||||
|
||||
Модель данных — `../docs/model/data-model-v5.17.dbml`. ORM обязана совпадать с ней один в один:
|
||||
Модель данных — `../docs/model/data-model-v5.18.dbml`. ORM обязана совпадать с ней один в один:
|
||||
|
||||
```bash
|
||||
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
|
||||
@ -1027,7 +1031,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)
|
||||
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)
|
||||
rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях)
|
||||
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
|
||||
seed/dev/ dev-данные: филиалы и пользователи по ролям
|
||||
@ -1111,7 +1115,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.17.dbml`](../docs/model/data-model-v5.17.dbml) — модель данных;
|
||||
[`../docs/model/data-model-v5.18.dbml`](../docs/model/data-model-v5.18.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) — план срезов;
|
||||
|
||||
52
api/alembic/versions/0006_dbml_v5_18.py
Normal file
52
api/alembic/versions/0006_dbml_v5_18.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""DBML v5.18: stored_file.consolidation_id — консолидация (период ГК), в контексте которой загружен файл
|
||||
(RSHB/arm-ka-docs#90, RSHB/arm-core#96; design D-1, D-2).
|
||||
|
||||
Колонка nullable, FK → consolidation, последняя в таблице; индекс (legal_entity_id, consolidation_id) — рабочий стол
|
||||
консоли для ЮЛ. Backfill: файлу с документом, привязанным к версии (consolidation_document), — консолидация этой
|
||||
версии; при привязках к версиям разных консолидаций — самый ранний период (год, затем квартал; enum period_quarter
|
||||
упорядочен Q1 < Q2 < Q3 < Q4 по объявлению); файлы без привязок остаются NULL.
|
||||
|
||||
Revision ID: 0006_dbml_v5_18
|
||||
Revises: 0005_dbml_v5_17
|
||||
Create Date: 2026-10-09
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
from src.models.base import SCHEMA
|
||||
|
||||
revision: str = "0006_dbml_v5_18"
|
||||
down_revision: str | Sequence[str] | None = "0005_dbml_v5_17"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
INDEX = "ix_stored_file_legal_entity_id_consolidation_id"
|
||||
FK = "fk_stored_file_consolidation_id_consolidation"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
f"ALTER TABLE {SCHEMA}.stored_file ADD COLUMN consolidation_id uuid "
|
||||
f"CONSTRAINT {FK} REFERENCES {SCHEMA}.consolidation (id)"
|
||||
)
|
||||
op.execute(f"CREATE INDEX {INDEX} ON {SCHEMA}.stored_file (legal_entity_id, consolidation_id)")
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {SCHEMA}.stored_file AS sf SET consolidation_id = pick.consolidation_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (d.stored_file_id) d.stored_file_id, c.id AS consolidation_id
|
||||
FROM {SCHEMA}.document d
|
||||
JOIN {SCHEMA}.consolidation_document cd ON cd.document_id = d.id
|
||||
JOIN {SCHEMA}.consolidation_version v ON v.id = cd.consolidation_version_id
|
||||
JOIN {SCHEMA}.consolidation c ON c.id = v.consolidation_id
|
||||
ORDER BY d.stored_file_id, c.year, c.quarter, c.id
|
||||
) AS pick
|
||||
WHERE sf.id = pick.stored_file_id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(f"DROP INDEX {SCHEMA}.{INDEX}")
|
||||
op.execute(f"ALTER TABLE {SCHEMA}.stored_file DROP COLUMN consolidation_id")
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
Запуск из `api/`:
|
||||
|
||||
python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.17.dbml
|
||||
python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.18.dbml
|
||||
|
||||
Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`.
|
||||
|
||||
|
||||
@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement
|
||||
|
||||
#: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением.
|
||||
SCHEMA = "dkb"
|
||||
DBML_FILE_NAME = "data-model-v5.17.dbml"
|
||||
DBML_FILE_NAME = "data-model-v5.18.dbml"
|
||||
ORM = "ORM"
|
||||
DBML = "DBML"
|
||||
ABSENT = "нет"
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.endpoints.files import comments, documents, read, statements, upload
|
||||
from src.api.endpoints.files import comments, consolidation_files, documents, read, statements, upload
|
||||
|
||||
router = APIRouter(tags=["files"])
|
||||
router.include_router(upload.router)
|
||||
router.include_router(read.router)
|
||||
router.include_router(consolidation_files.router)
|
||||
router.include_router(documents.router)
|
||||
router.include_router(comments.router)
|
||||
router.include_router(statements.router)
|
||||
|
||||
84
api/src/api/endpoints/files/consolidation_files.py
Normal file
84
api/src/api/endpoints/files/consolidation_files.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Файлы ЮЛ в контексте консолидации (v5.18, RSHB/arm-ka-docs#90): `GET /consolidations/{consolidation_id}/
|
||||
legal-entities/{legal_entity_id}/files` — рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол
|
||||
консолидации для ЮЛ»);
|
||||
`POST …/files` — загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию»). Права — как у маршрутов
|
||||
ЮЛ (`DOCUMENT_VIEW` / `DOCUMENT_EDIT`, design R-1); консолидация или ЮЛ вне видимости — 404, как несуществующие."""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status
|
||||
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.files import PrimaryFilesServiceDep
|
||||
from src.api.endpoints.files.read import FileListQuery
|
||||
from src.api.endpoints.files.upload import accept_uploads
|
||||
from src.schemas.common import ErrorDetail, Page, PageParams
|
||||
from src.schemas.files import FileOut, UploadResult
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VIEW = [Depends(require("DOCUMENT_VIEW"))]
|
||||
EDIT = [Depends(require("DOCUMENT_EDIT"))]
|
||||
NOT_FOUND: dict[int | str, dict[str, Any]] = {
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "консолидация или ЮЛ: нет или вне видимости"}
|
||||
}
|
||||
DESK_PATH = "/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files"
|
||||
|
||||
|
||||
@router.get(
|
||||
DESK_PATH,
|
||||
response_model=Page[FileOut],
|
||||
dependencies=VIEW,
|
||||
responses=NOT_FOUND,
|
||||
summary="Рабочий стол консолидации для ЮЛ",
|
||||
)
|
||||
async def list_desk_files(
|
||||
consolidation_id: uuid.UUID,
|
||||
legal_entity_id: uuid.UUID,
|
||||
service: PrimaryFilesServiceDep,
|
||||
query: Annotated[FileListQuery, Query()],
|
||||
) -> Page[FileOut]:
|
||||
"""Файлы ЮЛ, загруженные в эту консолидацию, плюс файлы с документами, привязанными к её версиям (новые первыми)."""
|
||||
page = PageParams(limit=query.limit, offset=query.offset)
|
||||
items, total = await service.page_desk(
|
||||
consolidation_id, legal_entity_id, include_superseded=query.include_superseded, page=page
|
||||
)
|
||||
return Page[FileOut](items=items, total=total, limit=page.limit, offset=page.offset)
|
||||
|
||||
|
||||
UPLOAD_RESPONSES: dict[int | str, dict[str, Any]] = {
|
||||
**NOT_FOUND,
|
||||
status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "CONSOLIDATION_NOT_EDITABLE — нет версии в работе"},
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
||||
"model": UploadResult,
|
||||
"description": "ни один файл не принят; ЮЛ вне периметра версий в работе (ENTITY_NOT_IN_PERIMETER)",
|
||||
},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"},
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
DESK_PATH,
|
||||
operation_id="upload_consolidation_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes)
|
||||
response_model=UploadResult,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=EDIT,
|
||||
responses=UPLOAD_RESPONSES,
|
||||
summary="Загрузка файлов ЮЛ в консолидацию",
|
||||
)
|
||||
async def upload_consolidation_files(
|
||||
consolidation_id: uuid.UUID,
|
||||
legal_entity_id: uuid.UUID,
|
||||
files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")],
|
||||
service: PrimaryFilesServiceDep,
|
||||
response: Response,
|
||||
) -> UploadResult:
|
||||
"""До приёма: консолидация и ЮЛ видимы, есть версия «в работе», ЮЛ в её периметре. Принятые файлы получают
|
||||
`consolidation_id`; после приёма хотя бы одного версии `INITIATED` с этим ЮЛ → `DOCUMENTS_PROCESSING`."""
|
||||
entity = await service.get_entity(legal_entity_id)
|
||||
consolidation = await service.check_consolidation_for_upload(consolidation_id, entity)
|
||||
result = await accept_uploads(service, entity, files, response, consolidation_id=consolidation.id)
|
||||
if result.accepted:
|
||||
await service.after_upload_to_consolidation(consolidation.id, entity.id)
|
||||
return result
|
||||
@ -1,5 +1,6 @@
|
||||
"""`POST /legal-entities/{legal_entity_id}/files` — загрузка файлов организации (spec primary-files «Загрузка файлов
|
||||
организации», «Дедуп по содержимому»; design D-4).
|
||||
организации», «Дедуп по содержимому»; design D-4). Загрузка в консолидацию — `POST /consolidations/{id}/legal-entities/
|
||||
{id}/files` (`consolidation_files.py`), общий приём файлов — `accept_uploads`.
|
||||
|
||||
multipart: одно или несколько полей `files`; право `DOCUMENT_EDIT`; ЮЛ — в области видимости пользователя, иначе 404.
|
||||
Тип документа и период не запрашиваются (макет `40`). Каждый файл принимается или отклоняется отдельно (частичный
|
||||
@ -10,59 +11,30 @@ multipart: одно или несколько полей `files`; право `DO
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.files import PrimaryFilesServiceDep
|
||||
from src.common.exceptions import DomainError
|
||||
from src.common.filenames import read_limited, safe_filename
|
||||
from src.models.directories import LegalEntity
|
||||
from src.schemas.common import ErrorDetail
|
||||
from src.schemas.files import AcceptedOut, RejectedOut, UploadResult
|
||||
from src.services.primary_files import FILE_TOO_LARGE, RejectedFile
|
||||
from src.services.primary_files import FILE_TOO_LARGE, PrimaryFilesService, RejectedFile
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/legal-entities/{legal_entity_id}/files",
|
||||
operation_id="upload_legal_entity_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes)
|
||||
response_model=UploadResult,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require("DOCUMENT_EDIT"))],
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorDetail,
|
||||
"description": "ЮЛ нет или оно вне видимости; версия консолидации не найдена или чужая (VERSION_NOT_FOUND)",
|
||||
},
|
||||
status.HTTP_409_CONFLICT: {
|
||||
"model": ErrorDetail,
|
||||
"description": "версия консолидации не в работе (VERSION_NOT_EDITABLE)",
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
||||
"model": UploadResult,
|
||||
"description": "ни один файл не принят; ЮЛ вне периметра версии (ENTITY_NOT_IN_PERIMETER)",
|
||||
},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"},
|
||||
},
|
||||
summary="Загрузка файлов организации",
|
||||
)
|
||||
async def upload_files(
|
||||
legal_entity_id: uuid.UUID,
|
||||
files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")],
|
||||
service: PrimaryFilesServiceDep,
|
||||
async def accept_uploads(
|
||||
service: PrimaryFilesService,
|
||||
entity: LegalEntity,
|
||||
files: list[UploadFile],
|
||||
response: Response,
|
||||
consolidation_version_id: Annotated[
|
||||
uuid.UUID | None,
|
||||
Query(description="Версия консолидации, в контексте которой загружаются файлы (необязательно)"),
|
||||
] = None,
|
||||
*,
|
||||
consolidation_id: uuid.UUID | None,
|
||||
) -> UploadResult:
|
||||
"""Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля).
|
||||
|
||||
С `consolidation_version_id`: до приёма — версия видима, «в работе», ЮЛ в периметре; после приёма хотя бы одного
|
||||
файла версия `INITIATED` → `DOCUMENTS_PROCESSING` (отдельная транзакция, блокировка версии не нужна)."""
|
||||
entity = await service.get_entity(legal_entity_id)
|
||||
if consolidation_version_id is not None:
|
||||
await service.check_version_for_upload(consolidation_version_id, entity)
|
||||
"""Общий приём файлов двух маршрутов загрузки: каждый файл принимается или отклоняется отдельно; 422 — если не
|
||||
принят ни один. `consolidation_id` пишется в `stored_file` (загрузка в консолидацию) или `None`."""
|
||||
service.require_storage()
|
||||
accepted: list[AcceptedOut] = []
|
||||
rejected: list[RejectedOut] = []
|
||||
@ -78,7 +50,7 @@ async def upload_files(
|
||||
)
|
||||
continue
|
||||
try:
|
||||
result = await service.upload_one(entity, name, content)
|
||||
result = await service.upload_one(entity, name, content, consolidation_id=consolidation_id)
|
||||
except DomainError as error:
|
||||
failure = RejectedFile.from_error(safe_filename(name, default=""), error)
|
||||
rejected.append(
|
||||
@ -88,6 +60,28 @@ async def upload_files(
|
||||
accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name))
|
||||
if not accepted:
|
||||
response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
elif consolidation_version_id is not None:
|
||||
await service.after_upload_to_version(consolidation_version_id)
|
||||
return UploadResult(accepted=accepted, rejected=rejected)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/legal-entities/{legal_entity_id}/files",
|
||||
operation_id="upload_legal_entity_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes)
|
||||
response_model=UploadResult,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require("DOCUMENT_EDIT"))],
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ЮЛ нет или оно вне видимости"},
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": UploadResult, "description": "ни один файл не принят"},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"},
|
||||
},
|
||||
summary="Загрузка файлов организации",
|
||||
)
|
||||
async def upload_files(
|
||||
legal_entity_id: uuid.UUID,
|
||||
files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")],
|
||||
service: PrimaryFilesServiceDep,
|
||||
response: Response,
|
||||
) -> UploadResult:
|
||||
"""Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля)."""
|
||||
entity = await service.get_entity(legal_entity_id)
|
||||
return await accept_uploads(service, entity, files, response, consolidation_id=None)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""ORM-модели. Источник истины — docs/model/data-model-v5.17.dbml; заполняется в Срезе 0.
|
||||
"""ORM-модели. Источник истины — docs/model/data-model-v5.18.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.17"
|
||||
DBML_VERSION = "5.18"
|
||||
|
||||
__all__ = [
|
||||
"DBML_VERSION",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 09 DBML v5.17 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов.
|
||||
"""Часть 09 DBML v5.18 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов.
|
||||
|
||||
`audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на
|
||||
консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id`
|
||||
|
||||
@ -8,7 +8,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_co
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.17); единственный источник — `settings.DB_SCHEMA`.
|
||||
#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.18); единственный источник — `settings.DB_SCHEMA`.
|
||||
#: Читается при импорте: MetaData, enum и sequence привязываются к схеме сразу (см. R-1 PR #38).
|
||||
SCHEMA = settings.DB_SCHEMA
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 07 DBML v5.17 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы.
|
||||
"""Часть 07 DBML v5.18 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы.
|
||||
|
||||
Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из
|
||||
`osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10).
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 01 DBML v5.17 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
|
||||
"""Часть 01 DBML v5.18 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""Enum'ы модели данных DBML v5.17 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок.
|
||||
"""Enum'ы модели данных DBML v5.18 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок.
|
||||
|
||||
Источник истины — docs/model/data-model-v5.17.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
|
||||
Источник истины — docs/model/data-model-v5.18.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
|
||||
DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются.
|
||||
Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима).
|
||||
Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 04 DBML v5.17 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания.
|
||||
"""Часть 04 DBML v5.18 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания.
|
||||
|
||||
Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file`
|
||||
разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт
|
||||
@ -55,6 +55,8 @@ class StoredFile(UUIDPrimaryMixin, Base):
|
||||
Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"),
|
||||
# v5.15 п.4 (OQ-117): проверка FK при удалении recognition_job / файла
|
||||
Index("ix_stored_file_current_recognition_job_id", "current_recognition_job_id"),
|
||||
# v5.18: рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»)
|
||||
Index("ix_stored_file_legal_entity_id_consolidation_id", "legal_entity_id", "consolidation_id"),
|
||||
)
|
||||
|
||||
legal_entity_id: Mapped[uuid.UUID] = mapped_column(
|
||||
@ -79,6 +81,9 @@ class StoredFile(UUIDPrimaryMixin, Base):
|
||||
uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))
|
||||
uploaded_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). Последняя
|
||||
# колонка: миграция 0006 добавляет её ALTER TABLE, а check_dbml сверяет порядок
|
||||
consolidation_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("consolidation.id"))
|
||||
|
||||
|
||||
class Document(UUIDPrimaryMixin, Base):
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 08 DBML v5.17 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
|
||||
"""Часть 08 DBML v5.18 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
|
||||
|
||||
Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО
|
||||
замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 05 DBML v5.17 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5).
|
||||
"""Часть 05 DBML v5.18 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5).
|
||||
|
||||
Первичка не мутируется: сырые значения хранятся как пришли, правка аналитика — рядом
|
||||
(`is_modified`, `original_values`, `modified_by/at` + audit_event(RECOGNIZED_DATA_EDIT)).
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 06 DBML v5.17 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
|
||||
"""Часть 06 DBML v5.18 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
|
||||
|
||||
Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением.
|
||||
Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету.
|
||||
|
||||
@ -71,6 +71,14 @@ async def get_version(
|
||||
return None if row is None else (row[0], row[1])
|
||||
|
||||
|
||||
async def get_consolidation(
|
||||
session: AsyncSession, scope: VisibilityScope, consolidation_id: uuid.UUID
|
||||
) -> Consolidation | None:
|
||||
"""Консолидация по id, если её ГК видна пользователю; чужая и несуществующая — `None` (v5.18, design D-3)."""
|
||||
stmt = _visible_consolidations(scope).where(Consolidation.id == consolidation_id)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_consolidation_for_group(
|
||||
session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID, year: int, quarter: PeriodQuarter
|
||||
) -> Consolidation | None:
|
||||
@ -171,6 +179,55 @@ async def lock_versions_for_entity(
|
||||
return list((await session.execute(members_only)).scalars())
|
||||
|
||||
|
||||
async def versions_of(session: AsyncSession, consolidation_id: uuid.UUID) -> list[ConsolidationVersion]:
|
||||
"""Все версии консолидации по возрастанию `version_no` (без блокировки; проверки до приёма файлов, D-3)."""
|
||||
stmt = (
|
||||
select(ConsolidationVersion)
|
||||
.where(ConsolidationVersion.consolidation_id == consolidation_id)
|
||||
.order_by(ConsolidationVersion.version_no)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars())
|
||||
|
||||
|
||||
async def lock_versions_of_consolidation(
|
||||
session: AsyncSession,
|
||||
consolidation_id: uuid.UUID,
|
||||
entity_id: uuid.UUID,
|
||||
statuses: Collection[ConsolidationStatus],
|
||||
) -> list[ConsolidationVersion]:
|
||||
"""Версии консолидации со статусом из `statuses`, где ЮЛ — член периметра, под `FOR UPDATE ORDER BY id` (R-2;
|
||||
design D-4: переход после загрузки в консолидацию).
|
||||
|
||||
Двухшаговая схема, как в `lock_versions_for_entity`: сначала блокируются ВСЕ версии консолидации с нужными
|
||||
статусами (`ORDER BY id FOR UPDATE`) — без условия членства: в READ COMMITTED после ожидания блокировки `EXISTS`
|
||||
в том же операторе не пересчитывается и пропустил бы члена, добавленного ожидавшейся правкой периметра. Затем
|
||||
вторым запросом, без блокировки (строки уже заблокированы), выбираются версии, где ЮЛ — член; он получает свежий
|
||||
снимок и видит закоммиченные правки. `populate_existing` — состояние версий в сессии заменяется свежим."""
|
||||
locked = (
|
||||
select(ConsolidationVersion.id)
|
||||
.where(
|
||||
ConsolidationVersion.consolidation_id == consolidation_id,
|
||||
ConsolidationVersion.status.in_(statuses),
|
||||
)
|
||||
.order_by(ConsolidationVersion.id)
|
||||
.with_for_update()
|
||||
)
|
||||
locked_ids = list((await session.execute(locked)).scalars())
|
||||
if not locked_ids:
|
||||
return []
|
||||
is_member = exists().where(
|
||||
ConsolidationMember.consolidation_version_id == ConsolidationVersion.id,
|
||||
ConsolidationMember.legal_entity_id == entity_id,
|
||||
)
|
||||
members_only = (
|
||||
select(ConsolidationVersion)
|
||||
.where(ConsolidationVersion.id.in_(locked_ids), is_member)
|
||||
.order_by(ConsolidationVersion.id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return list((await session.execute(members_only)).scalars())
|
||||
|
||||
|
||||
async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationLock | None:
|
||||
"""Активная блокировка версии (`released_at IS NULL`; не более одной — partial UNIQUE)."""
|
||||
stmt = select(ConsolidationLock).where(
|
||||
|
||||
@ -12,12 +12,13 @@ import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, and_, delete, func, or_, select, update
|
||||
from sqlalchemy import Select, and_, delete, exists, func, or_, select, update
|
||||
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.directories import LegalEntity
|
||||
from src.models.enums import FileStatus, S3BucketRole
|
||||
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
||||
@ -237,3 +238,43 @@ async def expire_s3_objects(
|
||||
)
|
||||
)
|
||||
await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now))
|
||||
|
||||
|
||||
async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация списка передаются явными именованными аргументами
|
||||
session: AsyncSession,
|
||||
scope: VisibilityScope,
|
||||
entity_id: uuid.UUID,
|
||||
consolidation_id: uuid.UUID,
|
||||
*,
|
||||
include_superseded: bool,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[list[StoredFile], int]:
|
||||
"""Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6):
|
||||
файлы ЮЛ с `consolidation_id` этой консолидации ИЛИ с документом, привязанным к любой её версии. Порядок и
|
||||
`include_superseded` — как в `list_files`; страница и общее число тем же фильтром."""
|
||||
bound = exists().where(
|
||||
Document.stored_file_id == StoredFile.id,
|
||||
ConsolidationDocument.document_id == Document.id,
|
||||
ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id,
|
||||
ConsolidationVersion.consolidation_id == consolidation_id,
|
||||
)
|
||||
|
||||
def filtered(stmt: Select[Any]) -> Select[Any]:
|
||||
stmt = _visible_files(stmt, scope).where(
|
||||
StoredFile.legal_entity_id == entity_id,
|
||||
or_(StoredFile.consolidation_id == consolidation_id, bound),
|
||||
)
|
||||
if not include_superseded:
|
||||
stmt = stmt.where(StoredFile.status != FileStatus.SUPERSEDED)
|
||||
return stmt
|
||||
|
||||
total = (await session.execute(filtered(select(func.count(StoredFile.id))))).scalar_one()
|
||||
page_stmt = (
|
||||
filtered(select(StoredFile))
|
||||
.order_by(StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
files = list((await session.execute(page_stmt)).scalars().all())
|
||||
return files, total
|
||||
|
||||
@ -93,5 +93,6 @@ class FileOut(BaseModel):
|
||||
uploaded_at: datetime.datetime | None
|
||||
uploaded_by: uuid.UUID | None
|
||||
superseded_by_file_id: uuid.UUID | None
|
||||
consolidation_id: uuid.UUID | None # v5.18: консолидация, в контексте которой загружен файл; None — без контекста
|
||||
job: JobBrief | None
|
||||
documents: list[DocumentBrief]
|
||||
|
||||
@ -18,6 +18,7 @@ from src.models.consolidation import Consolidation, ConsolidationVersion
|
||||
from src.repositories import consolidations as repo
|
||||
|
||||
VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей
|
||||
CONSOLIDATION_NOT_FOUND = "консолидация не найдена" # одинаково для чужой и несуществующей
|
||||
|
||||
|
||||
async def lock_visible_version(
|
||||
|
||||
@ -93,6 +93,7 @@ def file_out(
|
||||
uploaded_at=file.uploaded_at,
|
||||
uploaded_by=file.uploaded_by,
|
||||
superseded_by_file_id=file.superseded_by_file_id,
|
||||
consolidation_id=file.consolidation_id,
|
||||
job=job_brief(job) if job is not None else None,
|
||||
documents=[
|
||||
DocumentBrief.model_validate(document).model_copy(update={"comments_count": counts.get(document.id, 0)})
|
||||
|
||||
@ -8,8 +8,8 @@ audit-trail «События файлов и документов»; design D-4)
|
||||
1. рекомендательная блокировка `(ЮЛ, sha256)` до конца транзакции запроса и проверка дубля — до записи в S3;
|
||||
2. `s3_object` + `flush` (конфликт ключа или ошибка БД — до загрузки объекта, бакет не засоряется);
|
||||
3. `put_object`; ошибка S3 — `STORAGE_UNAVAILABLE`, SAVEPOINT откатывается (строки `s3_object` не остаётся);
|
||||
4. `stored_file` (`UPLOADED`, `version_no = 1`) и `recognition_job` (`queued`) + `flush`,
|
||||
`stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD`.
|
||||
4. `stored_file` (`UPLOADED`, `version_no = 1`, `consolidation_id` при загрузке в консолидацию) и `recognition_job`
|
||||
(`queued`) + `flush`, `stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD` (с `consolidation_id`).
|
||||
|
||||
Если после шага 3 упадёт БД (вставка файла/задания, аудит или commit запроса), объект в S3 останется без записи
|
||||
`s3_object` — допустимо: ключ содержит свежие `file_id`/`job_id`, ни на что не ссылается и не будет переиспользован;
|
||||
@ -37,7 +37,7 @@ from src.core.config import Settings
|
||||
from src.core.storage import ObjectNotFoundError, ObjectStorage
|
||||
from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file
|
||||
from src.domain.policy import IN_WORK_STATUSES, VisibilityScope
|
||||
from src.models.consolidation import ConsolidationVersion
|
||||
from src.models.consolidation import Consolidation
|
||||
from src.models.directories import LegalEntity
|
||||
from src.models.enums import (
|
||||
AuditAction,
|
||||
@ -56,7 +56,7 @@ from src.schemas.common import PageParams
|
||||
from src.schemas.files import FileOut
|
||||
from src.services import consolidation_documents
|
||||
from src.services.audit import AuditService
|
||||
from src.services.consolidation_guard import VERSION_NOT_FOUND
|
||||
from src.services.consolidation_guard import CONSOLIDATION_NOT_FOUND
|
||||
from src.services.documents import file_out
|
||||
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
|
||||
from src.services.result_mapping import RESULTS_PREFIX
|
||||
@ -160,34 +160,35 @@ class PrimaryFilesService:
|
||||
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
|
||||
return entity
|
||||
|
||||
async def check_version_for_upload(self, version_id: uuid.UUID, entity: LegalEntity) -> ConsolidationVersion:
|
||||
"""Загрузка в контексте версии консолидации (spec primary-files «Загрузка файлов организации», D-6): ДО приёма
|
||||
файлов версия видима (иначе 404 `VERSION_NOT_FOUND`) → статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`) →
|
||||
ЮЛ — член периметра (иначе 422 `ENTITY_NOT_IN_PERIMETER`). Блокировка версии (`consolidation_lock`) и
|
||||
`FOR UPDATE` не нужны: статус перепроверяется под блокировкой в `after_upload_to_version`."""
|
||||
found = await consolidations_repo.get_version(self._session, self._scope, version_id)
|
||||
if found is None:
|
||||
raise NotFoundError(VERSION_NOT_FOUND)
|
||||
version = found[0]
|
||||
if version.status not in IN_WORK_STATUSES:
|
||||
raise ConflictError("VERSION_NOT_EDITABLE", f"Версия в статусе {version.status.value} не редактируется")
|
||||
members = await consolidations_repo.members(self._session, version.id)
|
||||
if all(member.legal_entity_id != entity.id for member, _ in members):
|
||||
raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версии консолидации")
|
||||
return version
|
||||
async def check_consolidation_for_upload(self, consolidation_id: uuid.UUID, entity: LegalEntity) -> Consolidation:
|
||||
"""Загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию», §9.5 п. 8, D-3): ДО приёма —
|
||||
консолидация видима (404 `CONSOLIDATION_NOT_FOUND`) → есть версия «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`)
|
||||
→ ЮЛ в периметре хотя бы одной из них (422 `ENTITY_NOT_IN_PERIMETER`). Блокировки не нужны: статус
|
||||
перечитывается под `FOR UPDATE` в `after_upload_to_consolidation`."""
|
||||
consolidation = await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id)
|
||||
if consolidation is None:
|
||||
raise NotFoundError(CONSOLIDATION_NOT_FOUND)
|
||||
in_work = [
|
||||
version
|
||||
for version in await consolidations_repo.versions_of(self._session, consolidation.id)
|
||||
if version.status in IN_WORK_STATUSES
|
||||
]
|
||||
if not in_work:
|
||||
raise ConflictError("CONSOLIDATION_NOT_EDITABLE", "У консолидации нет версии в работе")
|
||||
for version in in_work:
|
||||
members = await consolidations_repo.members(self._session, version.id)
|
||||
if any(member.legal_entity_id == entity.id for member, _ in members):
|
||||
return consolidation
|
||||
raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версий консолидации в работе")
|
||||
|
||||
async def after_upload_to_version(self, version_id: uuid.UUID) -> None:
|
||||
"""После приёма хотя бы одного файла: версия `INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший).
|
||||
async def after_upload_to_consolidation(self, consolidation_id: uuid.UUID, entity_id: uuid.UUID) -> None:
|
||||
"""После приёма хотя бы одного файла: каждая версия консолидации «в работе» с ЮЛ в периметре и статусом
|
||||
`INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший; §9.1, D-4).
|
||||
|
||||
В ОТДЕЛЬНОЙ транзакции (`session_transaction`, как аудит входа): `lock_version` (`FOR UPDATE`) → статус
|
||||
перечитывается — если уже не `INITIATED` (параллельный переход, аннулирование), ничего не меняется. Версия
|
||||
блокируется на короткое время и вне транзакции запроса, чтобы загрузка не держала её на время приёма файлов
|
||||
(D-9). Транзакция запроса к этому моменту версию не трогала, взаимных ожиданий нет.
|
||||
|
||||
Приём файлов важнее побочного статуса (R-10, design №43): любая ошибка перехода (`Exception`) логируется и
|
||||
НЕ меняет ответ — принятые файлы остаются, версия остаётся `INITIATED` до следующей загрузки или
|
||||
подтверждения. `BaseException` (`asyncio.CancelledError`) не перехватывается."""
|
||||
# импорт внутри функции — как в consolidation_documents (design №36): модули консолидации импортируют друг друга
|
||||
В ОТДЕЛЬНОЙ транзакции (`session_transaction`): версии под `FOR UPDATE ORDER BY id`, статус перечитан —
|
||||
попадают только ещё `INITIATED`. Приём файлов важнее побочного статуса (R-10 Среза 4): любая ошибка
|
||||
перехода логируется и НЕ меняет ответ. `BaseException` не перехватывается."""
|
||||
# импорт внутри функции — модули консолидации импортируют друг друга
|
||||
from src.services.consolidation_status import transition # noqa: PLC0415
|
||||
|
||||
factory = self._transaction
|
||||
@ -197,25 +198,28 @@ class PrimaryFilesService:
|
||||
factory = session_transaction
|
||||
try:
|
||||
async with factory() as session:
|
||||
version = await consolidations_repo.lock_version(session, version_id)
|
||||
if version is None or version.status is not ConsolidationStatus.INITIATED:
|
||||
return
|
||||
await transition(
|
||||
session,
|
||||
AuditService(session),
|
||||
version=version,
|
||||
to=ConsolidationStatus.DOCUMENTS_PROCESSING,
|
||||
changed_by=self._audit.user_id,
|
||||
comment=None,
|
||||
ip_address=self._audit.ip_address,
|
||||
user_agent=self._audit.user_agent,
|
||||
now=utc_now(),
|
||||
versions = await consolidations_repo.lock_versions_of_consolidation(
|
||||
session, consolidation_id, entity_id, {ConsolidationStatus.INITIATED}
|
||||
)
|
||||
audit = AuditService(session)
|
||||
now = utc_now()
|
||||
for version in versions:
|
||||
await transition(
|
||||
session,
|
||||
audit,
|
||||
version=version,
|
||||
to=ConsolidationStatus.DOCUMENTS_PROCESSING,
|
||||
changed_by=self._audit.user_id,
|
||||
comment=None,
|
||||
ip_address=self._audit.ip_address,
|
||||
user_agent=self._audit.user_agent,
|
||||
now=now,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Не удалось перевести версию в DOCUMENTS_PROCESSING после загрузки, файлы приняты. "
|
||||
"error_category=version_transition_error, version_id={version_id}",
|
||||
version_id=version_id,
|
||||
"Не удалось перевести версии консолидации в DOCUMENTS_PROCESSING после загрузки, файлы приняты. "
|
||||
"error_category=version_transition_error, consolidation_id={consolidation_id}",
|
||||
consolidation_id=consolidation_id,
|
||||
)
|
||||
|
||||
def require_storage(self) -> ObjectStorage:
|
||||
@ -241,6 +245,26 @@ class PrimaryFilesService:
|
||||
)
|
||||
return await self._cards(files), total
|
||||
|
||||
async def page_desk(
|
||||
self, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *, include_superseded: bool, page: PageParams
|
||||
) -> tuple[list[FileOut], int]:
|
||||
"""Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6):
|
||||
консолидация вне видимости — 404 `CONSOLIDATION_NOT_FOUND`, ЮЛ вне видимости — 404; принадлежность ЮЛ к ГК
|
||||
консолидации не проверяется (чужому ЮЛ — пустой стол). Карточки — как в `page_files`."""
|
||||
if await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id) is None:
|
||||
raise NotFoundError(CONSOLIDATION_NOT_FOUND)
|
||||
entity = await self.get_entity(entity_id)
|
||||
files, total = await files_repo.list_desk_files(
|
||||
self._session,
|
||||
self._scope,
|
||||
entity.id,
|
||||
consolidation_id,
|
||||
include_superseded=include_superseded,
|
||||
limit=page.limit,
|
||||
offset=page.offset,
|
||||
)
|
||||
return await self._cards(files), total
|
||||
|
||||
async def get_file(self, file_id: uuid.UUID) -> FileOut:
|
||||
"""Карточка видимого файла; чужой и несуществующий — одинаковый 404."""
|
||||
file = await files_repo.get_file(self._session, self._scope, file_id)
|
||||
@ -366,7 +390,9 @@ class PrimaryFilesService:
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def upload_one(self, entity: LegalEntity, name: str, content: bytes) -> AcceptedFile:
|
||||
async def upload_one(
|
||||
self, entity: LegalEntity, name: str, content: bytes, *, consolidation_id: uuid.UUID | None = None
|
||||
) -> AcceptedFile:
|
||||
"""Принять один файл (см. docstring модуля); отказ — `DomainError` (`UNSUPPORTED_FILE`, `DUPLICATE_FILE`,
|
||||
`STORAGE_UNAVAILABLE`), записи этого файла откатываются вместе с его SAVEPOINT."""
|
||||
storage = self.require_storage()
|
||||
@ -383,7 +409,13 @@ class PrimaryFilesService:
|
||||
file_id=str(duplicate.id),
|
||||
)
|
||||
return await self._store(
|
||||
entity, name=safe_name, content=content, sha256=sha256, storage=storage, detected=detected
|
||||
entity,
|
||||
name=safe_name,
|
||||
content=content,
|
||||
sha256=sha256,
|
||||
storage=storage,
|
||||
detected=detected,
|
||||
consolidation_id=consolidation_id,
|
||||
)
|
||||
|
||||
async def _lock_content(self, entity_id: uuid.UUID, sha256: str) -> None:
|
||||
@ -401,6 +433,7 @@ class PrimaryFilesService:
|
||||
sha256: str,
|
||||
storage: ObjectStorage,
|
||||
detected: DetectedFile,
|
||||
consolidation_id: uuid.UUID | None,
|
||||
) -> AcceptedFile:
|
||||
now = utc_now()
|
||||
user_id = self._audit.user_id
|
||||
@ -432,6 +465,7 @@ class PrimaryFilesService:
|
||||
stored_file = StoredFile(
|
||||
id=file_id,
|
||||
legal_entity_id=entity.id,
|
||||
consolidation_id=consolidation_id,
|
||||
file_type=detected.file_type,
|
||||
original_name=name,
|
||||
s3_object_id=s3_object.id,
|
||||
@ -471,6 +505,7 @@ class PrimaryFilesService:
|
||||
"content_hash": sha256,
|
||||
"job_id": str(job_id),
|
||||
"file_type": detected.file_type.value,
|
||||
"consolidation_id": str(consolidation_id) if consolidation_id is not None else None,
|
||||
},
|
||||
)
|
||||
return AcceptedFile(file_id=file_id, job_id=job_id, name=name)
|
||||
|
||||
@ -660,10 +660,12 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена
|
||||
) -> list[StoredFile]:
|
||||
"""D-10, spec primary-files «Автозамена прежних версий»: файлы того же ЮЛ (не `SUPERSEDED`, не сам новый) с
|
||||
документом тех же `document_type`, `osv_account_code`, `period_year`, `period_quarter` → `SUPERSEDED`,
|
||||
`superseded_by_file_id = new_file.id`; у нового файла `version_no` = максимум заменённых + 1. На каждый
|
||||
заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор загрузки — в `description`,
|
||||
`confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение владельца 05.10.2026). Документы и
|
||||
строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; в транзакции вызывающего, без commit.
|
||||
`superseded_by_file_id = new_file.id`; у нового файла `version_no` = максимум заменённых + 1; файл без
|
||||
консолидации (`consolidation_id` пуст) наследует её у первого по `id` заменённого файла, где она задана (v5.18,
|
||||
§9.5 п. 3). На каждый заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор
|
||||
загрузки — в `description`, `confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение
|
||||
владельца 05.10.2026). Документы и строки заменённых файлов не трогаются (история). Возвращает заменённые файлы;
|
||||
в транзакции вызывающего, без commit.
|
||||
|
||||
Привязки документов заменённых файлов к версиям консолидации «в работе» снимаются
|
||||
(`consolidation_documents.on_documents_superseded`); id версий — в `after_value.unbound_version_ids` события
|
||||
@ -729,6 +731,10 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
new_file.version_no = max(old.version_no for old in locked) + 1
|
||||
if new_file.consolidation_id is None: # v5.18, §9.5 п. 3: файл без контекста наследует консоль заменённого
|
||||
new_file.consolidation_id = next(
|
||||
(old.consolidation_id for old in locked if old.consolidation_id is not None), None
|
||||
)
|
||||
new_file.updated_at = now
|
||||
await session.flush()
|
||||
# привязки к версиям «в работе» снимаются у ВСЕХ документов заменённого файла (spec primary-files), а не только у
|
||||
|
||||
197
api/tests/test_api_consolidation_files.py
Normal file
197
api/tests/test_api_consolidation_files.py
Normal file
@ -0,0 +1,197 @@
|
||||
"""Рабочий стол консолидации для ЮЛ и `consolidation_id` в карточке файла (spec documents-api «Рабочий стол консолидации
|
||||
для ЮЛ», «Файлы организации и карточка файла»; design D-6, D-7). Поверх фикстуры `app`: данные — фабрики в транзакции
|
||||
теста."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.access import AppUser
|
||||
from src.models.consolidation import ConsolidationDocument
|
||||
from src.models.directories import LegalEntity, RegionalOffice
|
||||
from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file
|
||||
|
||||
HTTP_OK = 200
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_FORBIDDEN = 403
|
||||
KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT
|
||||
CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал
|
||||
CURATOR_GO = "curator_go@armka.local" # головной офис, права DOCUMENT_VIEW нет
|
||||
ROSTOV = "Ростовский региональный филиал"
|
||||
KRASNODAR = "Краснодарский региональный филиал"
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _group(session: AsyncSession, office_name: str = ROSTOV, name: str = "ГК Стол"):
|
||||
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one()
|
||||
group = await make_company_group(session, name=name, office=office)
|
||||
entity = await make_legal_entity(session, name=f"ООО {name}", group=group)
|
||||
initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
|
||||
return group, entity, initiator
|
||||
|
||||
|
||||
async def _consolidation( # noqa: PLR0913, PLR0917 - тестовый помощник
|
||||
session: AsyncSession, group, entity, initiator, year: int, quarter: PeriodQuarter
|
||||
):
|
||||
consolidation, version = await make_consolidation(
|
||||
session, group=group, year=year, quarter=quarter, members=[entity], initiator=initiator
|
||||
)
|
||||
return consolidation, version
|
||||
|
||||
|
||||
async def _file(
|
||||
session: AsyncSession, entity: LegalEntity, name: str, *, consolidation_id=None, status=FileStatus.PROCESSED
|
||||
):
|
||||
file, job = await make_stored_file(
|
||||
session, entity=entity, status=status, job_status=RecognitionStatus.done, original_name=name
|
||||
)
|
||||
file.consolidation_id = consolidation_id
|
||||
await session.flush()
|
||||
return file, job
|
||||
|
||||
|
||||
def _desk_url(consolidation_id: uuid.UUID, entity_id: uuid.UUID, **query: str) -> str:
|
||||
base = f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files"
|
||||
return base + ("?" + "&".join(f"{k}={v}" for k, v in query.items()) if query else "")
|
||||
|
||||
|
||||
async def test_desk_shows_only_files_of_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Сценарий «Стол показывает только файлы консоли»: три файла ЮЛ — 2025 Q1, 2025 Q2, без контекста."""
|
||||
group, entity, initiator = await _group(seeded)
|
||||
c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
|
||||
c2, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2)
|
||||
f1, _ = await _file(seeded, entity, "q1.xls", consolidation_id=c1.id)
|
||||
f2, _ = await _file(seeded, entity, "q2.xls", consolidation_id=c2.id)
|
||||
f0, _ = await _file(seeded, entity, "none.xls")
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
desk1 = await client.get(_desk_url(c1.id, entity.id))
|
||||
desk2 = await client.get(_desk_url(c2.id, entity.id))
|
||||
all_files = await client.get(f"/legal-entities/{entity.id}/files")
|
||||
|
||||
assert desk1.status_code == HTTP_OK, desk1.text
|
||||
assert [item["id"] for item in desk1.json()["items"]] == [str(f1.id)]
|
||||
assert desk1.json()["total"] == 1
|
||||
assert desk1.json()["items"][0]["consolidation_id"] == str(c1.id)
|
||||
assert [item["id"] for item in desk2.json()["items"]] == [str(f2.id)]
|
||||
assert {item["id"] for item in all_files.json()["items"]} == {str(f1.id), str(f2.id), str(f0.id)}
|
||||
by_id = {item["id"]: item["consolidation_id"] for item in all_files.json()["items"]}
|
||||
assert by_id[str(f0.id)] is None
|
||||
|
||||
|
||||
async def test_desk_includes_file_bound_to_version(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Сценарий «Подхваченный файл виден через привязку»: файл без контекста, документ привязан к версии консоли."""
|
||||
group, entity, initiator = await _group(seeded)
|
||||
c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2)
|
||||
file, job = await _file(seeded, entity, "q1-confirmed.xls")
|
||||
document = await make_document(
|
||||
seeded,
|
||||
file=file,
|
||||
job=job,
|
||||
account="60",
|
||||
year=2025,
|
||||
quarter=PeriodQuarter.Q1,
|
||||
status=DocumentStatus.CONFIRMED,
|
||||
is_cumulative=False,
|
||||
)
|
||||
seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id))
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.get(_desk_url(c2.id, entity.id))
|
||||
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert [item["id"] for item in response.json()["items"]] == [str(file.id)]
|
||||
assert response.json()["items"][0]["consolidation_id"] is None
|
||||
|
||||
|
||||
async def test_desk_excludes_other_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Review Focus 2: документ привязан к версии ДРУГОЙ консолидации — файл в этом столе не показывается."""
|
||||
group, entity, initiator = await _group(seeded)
|
||||
c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
|
||||
c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2)
|
||||
file, job = await _file(seeded, entity, "bound-elsewhere.xls", consolidation_id=c2.id)
|
||||
document = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
|
||||
seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id))
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.get(_desk_url(c1.id, entity.id))
|
||||
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert response.json()["items"] == []
|
||||
assert response.json()["total"] == 0
|
||||
|
||||
|
||||
async def test_desk_superseded_hidden_by_default(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Сценарий «Заменённый файл скрыт по умолчанию» для DESK и BOUND; `include_superseded=true` — оба."""
|
||||
group, entity, initiator = await _group(seeded)
|
||||
c1, v1 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
|
||||
new, _ = await _file(seeded, entity, "new.xls", consolidation_id=c1.id)
|
||||
old_desk, _ = await _file(seeded, entity, "old-desk.xls", consolidation_id=c1.id, status=FileStatus.SUPERSEDED)
|
||||
old_desk.superseded_by_file_id = new.id
|
||||
old_bound, job = await _file(seeded, entity, "old-bound.xls", status=FileStatus.SUPERSEDED)
|
||||
old_bound.superseded_by_file_id = new.id
|
||||
document = await make_document(seeded, file=old_bound, job=job, status=DocumentStatus.CONFIRMED)
|
||||
seeded.add(ConsolidationDocument(consolidation_version_id=v1.id, document_id=document.id))
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
default = await client.get(_desk_url(c1.id, entity.id))
|
||||
with_superseded = await client.get(_desk_url(c1.id, entity.id, include_superseded="true"))
|
||||
|
||||
assert [item["id"] for item in default.json()["items"]] == [str(new.id)]
|
||||
assert {item["id"] for item in with_superseded.json()["items"]} == {
|
||||
str(new.id),
|
||||
str(old_desk.id),
|
||||
str(old_bound.id),
|
||||
}
|
||||
assert with_superseded.json()["total"] == 3 # noqa: PLR2004
|
||||
|
||||
|
||||
async def test_desk_foreign_and_missing_consolidation_are_same_404(
|
||||
seeded: AsyncSession, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
"""Сценарий «Чужая консолидация»: ГК другого филиала и несуществующий id — одинаковый 404; чужое ЮЛ — тоже 404."""
|
||||
group, entity, initiator = await _group(seeded, office_name=KRASNODAR, name="ГК Чужая")
|
||||
c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
|
||||
own_group, own_entity, own_initiator = await _group(seeded, name="ГК Своя")
|
||||
own_c, _ = await _consolidation(seeded, own_group, own_entity, own_initiator, 2025, PeriodQuarter.Q1)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
foreign = await client.get(_desk_url(c.id, own_entity.id))
|
||||
missing = await client.get(_desk_url(uuid.uuid4(), own_entity.id))
|
||||
foreign_entity = await client.get(_desk_url(own_c.id, entity.id)) # своя консолидация, ЮЛ чужого филиала
|
||||
|
||||
assert foreign.status_code == missing.status_code == foreign_entity.status_code == HTTP_NOT_FOUND
|
||||
assert foreign.json() == missing.json()
|
||||
assert foreign.json()["detail"]["code"] == "NOT_FOUND"
|
||||
assert foreign_entity.json()["detail"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
async def test_desk_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
group, entity, initiator = await _group(seeded)
|
||||
c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
|
||||
client = await auth_client(CURATOR_GO)
|
||||
|
||||
response = await client.get(_desk_url(c.id, entity.id))
|
||||
|
||||
assert response.status_code == HTTP_FORBIDDEN
|
||||
|
||||
|
||||
async def test_file_card_has_consolidation_id(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Сценарий «Консолидация в карточке»: `GET /files/{id}` отдаёт `consolidation_id`."""
|
||||
group, entity, initiator = await _group(seeded)
|
||||
c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
|
||||
file, _ = await _file(seeded, entity, "card.xls", consolidation_id=c.id)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.get(f"/files/{file.id}")
|
||||
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert response.json()["consolidation_id"] == str(c.id)
|
||||
@ -25,7 +25,7 @@ from src.api.dependencies.directories import get_app_settings
|
||||
from src.core.config import get_settings
|
||||
from src.models.access import AppUser
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion
|
||||
from src.models.consolidation import Consolidation, ConsolidationStatusHistory, ConsolidationVersion
|
||||
from src.models.directories import LegalEntity, RegionalOffice
|
||||
from src.models.enums import (
|
||||
AuditAction,
|
||||
@ -41,7 +41,7 @@ from src.models.enums import (
|
||||
from src.models.files import RecognitionJob, S3Object, StoredFile
|
||||
from src.services.recognition_exchange import publish_pending
|
||||
from tests.conftest import AuthClientFactory, FakeStorage
|
||||
from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock
|
||||
from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock, make_version
|
||||
from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats
|
||||
|
||||
HTTP_CREATED = 201
|
||||
@ -311,6 +311,7 @@ async def test_two_files_write_two_upload_events(
|
||||
"content_hash": hashlib.sha256(content).hexdigest(),
|
||||
"job_id": item["job_id"],
|
||||
"file_type": file_type,
|
||||
"consolidation_id": None,
|
||||
}
|
||||
assert len(storage.objects) == len(accepted)
|
||||
|
||||
@ -578,26 +579,33 @@ async def _version_state(session: AsyncSession, version_id: uuid.UUID) -> Consol
|
||||
return (await session.execute(stmt.execution_options(populate_existing=True))).scalar_one()
|
||||
|
||||
|
||||
async def _post_in_version(
|
||||
client: httpx.AsyncClient, entity_id: uuid.UUID, version_id: uuid.UUID, *uploads: Upload
|
||||
async def _post_in_consolidation(
|
||||
client: httpx.AsyncClient, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *uploads: Upload
|
||||
) -> httpx.Response:
|
||||
files = [("files", (name, content, "application/octet-stream")) for name, content in uploads]
|
||||
return await client.post(_files_url(entity_id), params={"consolidation_version_id": str(version_id)}, files=files)
|
||||
return await client.post(f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files", files=files)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_upload_in_version_moves_initiated_to_documents_processing(
|
||||
async def test_upload_in_consolidation_moves_initiated_to_documents_processing(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
version, (member, *_), _ = await _version(seeded)
|
||||
user_id = await _user_id(seeded, KA_RF)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("in-version")))
|
||||
response = await _post_in_consolidation(
|
||||
client, version.consolidation_id, member.id, ("osv.xls", _xls("in-version"))
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
assert len(response.json()["accepted"]) == 1
|
||||
assert len(storage.objects) == 1
|
||||
file = await seeded.get(StoredFile, uuid.UUID(response.json()["accepted"][0]["file_id"]))
|
||||
assert file is not None
|
||||
assert file.consolidation_id == version.consolidation_id
|
||||
[upload_event] = await _upload_events(seeded)
|
||||
assert upload_event.after_value["consolidation_id"] == str(version.consolidation_id)
|
||||
assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
|
||||
history = (
|
||||
(
|
||||
@ -627,13 +635,15 @@ async def test_upload_in_version_moves_initiated_to_documents_processing(
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("status", [ConsolidationStatus.DOCUMENTS_PROCESSING, ConsolidationStatus.DATA_CORRECTION])
|
||||
async def test_upload_in_version_keeps_other_in_work_status(
|
||||
async def test_upload_in_consolidation_keeps_other_in_work_status(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, status: ConsolidationStatus
|
||||
) -> None:
|
||||
version, (member, *_), _ = await _version(seeded, status=status)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls(f"keep-{status.value}")))
|
||||
response = await _post_in_consolidation(
|
||||
client, version.consolidation_id, member.id, ("osv.xls", _xls(f"keep-{status.value}"))
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
assert await _version_state(seeded, version.id) is status
|
||||
@ -647,7 +657,7 @@ async def test_upload_with_all_files_rejected_does_not_move_version(
|
||||
version, (member, *_), _ = await _version(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await _post_in_version(client, member.id, version.id, ("notes.docx", ZIP + b"x"))
|
||||
response = await _post_in_consolidation(client, version.consolidation_id, member.id, ("notes.docx", ZIP + b"x"))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
assert response.json()["accepted"] == []
|
||||
@ -664,7 +674,9 @@ async def test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing(
|
||||
client = await auth_client(KA_RF)
|
||||
before = await _counts(seeded)
|
||||
|
||||
response = await _post_in_version(client, outsider.id, version.id, ("osv.xls", _xls("outsider")))
|
||||
response = await _post_in_consolidation(
|
||||
client, version.consolidation_id, outsider.id, ("osv.xls", _xls("outsider"))
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_PERIMETER"
|
||||
@ -674,23 +686,23 @@ async def test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing(
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_upload_in_cancelled_version_is_409(
|
||||
async def test_upload_without_in_work_version_is_409(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
version, (member, *_), _ = await _version(seeded, status=ConsolidationStatus.CANCELLED)
|
||||
client = await auth_client(KA_RF)
|
||||
before = await _counts(seeded)
|
||||
|
||||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("cancelled")))
|
||||
response = await _post_in_consolidation(client, version.consolidation_id, member.id, ("osv.xls", _xls("cancelled")))
|
||||
|
||||
assert response.status_code == HTTP_CONFLICT, response.text
|
||||
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
|
||||
assert response.json()["detail"]["code"] == "CONSOLIDATION_NOT_EDITABLE"
|
||||
assert await _counts(seeded) == before
|
||||
assert storage.objects == {}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_upload_in_foreign_version_is_404_like_missing(
|
||||
async def test_upload_in_foreign_consolidation_is_404_like_missing(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
foreign, _, _ = await _version(seeded, office_name=KRASNODAR)
|
||||
@ -698,8 +710,12 @@ async def test_upload_in_foreign_version_is_404_like_missing(
|
||||
client = await auth_client(KA_RF)
|
||||
before = await _counts(seeded)
|
||||
|
||||
foreign_response = await _post_in_version(client, own_entity.id, foreign.id, ("osv.xls", _xls("foreign-version")))
|
||||
missing_response = await _post_in_version(client, own_entity.id, uuid.uuid4(), ("osv.xls", _xls("missing-version")))
|
||||
foreign_response = await _post_in_consolidation(
|
||||
client, foreign.consolidation_id, own_entity.id, ("osv.xls", _xls("foreign-version"))
|
||||
)
|
||||
missing_response = await _post_in_consolidation(
|
||||
client, uuid.uuid4(), own_entity.id, ("osv.xls", _xls("missing-version"))
|
||||
)
|
||||
|
||||
assert foreign_response.status_code == missing_response.status_code == HTTP_NOT_FOUND, foreign_response.text
|
||||
assert foreign_response.json() == missing_response.json()
|
||||
@ -718,7 +734,9 @@ async def test_upload_does_not_require_version_lock(
|
||||
await make_lock(seeded, version=version, user=holder)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("under-lock")))
|
||||
response = await _post_in_consolidation(
|
||||
client, version.consolidation_id, member.id, ("osv.xls", _xls("under-lock"))
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
|
||||
@ -726,17 +744,28 @@ async def test_upload_does_not_require_version_lock(
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_upload_without_version_does_not_touch_versions(
|
||||
async def test_upload_without_consolidation_does_not_touch_versions(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
version, (member, *_), _ = await _version(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await _post(client, member.id, ("osv.xls", _xls("no-version")))
|
||||
ignored = await client.post(
|
||||
_files_url(member.id),
|
||||
params={"consolidation_version_id": str(version.id)},
|
||||
files=[("files", ("osv.xls", _xls("ignored-query"), "application/octet-stream"))],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
assert ignored.status_code == HTTP_CREATED, ignored.text
|
||||
bodies = (response.json(), ignored.json())
|
||||
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
|
||||
assert len(storage.objects) == 1
|
||||
assert len(storage.objects) == len(bodies)
|
||||
for body in bodies:
|
||||
file = await seeded.get(StoredFile, uuid.UUID(body["accepted"][0]["file_id"]))
|
||||
assert file is not None
|
||||
assert file.consolidation_id is None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@ -755,7 +784,9 @@ async def test_upload_keeps_accepted_files_when_version_transition_fails(
|
||||
messages: list[str] = []
|
||||
sink = logger.add(lambda message: messages.append(str(message)), level="ERROR")
|
||||
try:
|
||||
response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("transition-fails")))
|
||||
response = await _post_in_consolidation(
|
||||
client, version.consolidation_id, member.id, ("osv.xls", _xls("transition-fails"))
|
||||
)
|
||||
finally:
|
||||
logger.remove(sink)
|
||||
|
||||
@ -764,4 +795,26 @@ async def test_upload_keeps_accepted_files_when_version_transition_fails(
|
||||
assert (await seeded.get(StoredFile, uuid.UUID(accepted["file_id"]))) is not None
|
||||
assert len(storage.objects) == 1
|
||||
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
|
||||
assert any(str(version.id) in message and "version_transition_error" in message for message in messages)
|
||||
assert any(
|
||||
str(version.consolidation_id) in message and "version_transition_error" in message for message in messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_upload_moves_only_versions_with_entity(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
"""Две версии консолидации в INITIATED: ЮЛ в периметре только второй — переходит только она (D-4)."""
|
||||
version, entities, initiator = await _version(seeded)
|
||||
consolidation = await seeded.get(Consolidation, version.consolidation_id)
|
||||
assert consolidation is not None
|
||||
other = await make_version(
|
||||
seeded, consolidation=consolidation, version_no=2, members=[entities[2]], initiator=initiator
|
||||
)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await _post_in_consolidation(client, consolidation.id, entities[2].id, ("osv.xls", _xls("only-v2")))
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED
|
||||
assert await _version_state(seeded, other.id) is ConsolidationStatus.DOCUMENTS_PROCESSING
|
||||
|
||||
@ -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.18.dbml"
|
||||
# шапка 5.17 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
|
||||
newer.write_text(lines[0].replace("v5.17", "v5.18") + "\nTable broken {{{\n", encoding="utf-8")
|
||||
assert run_check(newer) == (1, ["версия DBML 5.18 не совпадает с версией ORM 5.17: обновите модели"])
|
||||
newer = tmp_path / "data-model-v5.19.dbml"
|
||||
# шапка 5.18 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
|
||||
newer.write_text(lines[0].replace("v5.18", "v5.19") + "\nTable broken {{{\n", encoding="utf-8")
|
||||
assert run_check(newer) == (1, ["версия DBML 5.19 не совпадает с версией ORM 5.18: обновите модели"])
|
||||
|
||||
|
||||
def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None:
|
||||
newer = tmp_path / "data-model-v5.18.dbml"
|
||||
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.17", "v5.18", 1), encoding="utf-8")
|
||||
newer = tmp_path / "data-model-v5.19.dbml"
|
||||
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.18", "v5.19", 1), encoding="utf-8")
|
||||
result = _run_cli(str(newer))
|
||||
assert result.returncode == 1
|
||||
assert result.stdout == "версия DBML 5.18 не совпадает с версией ORM 5.17: обновите модели\n"
|
||||
assert result.stdout == "версия DBML 5.19 не совпадает с версией ORM 5.18: обновите модели\n"
|
||||
|
||||
|
||||
def test_header_without_version_is_usage_error(tmp_path: Path) -> None:
|
||||
|
||||
@ -1155,11 +1155,75 @@ async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member(
|
||||
assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION
|
||||
|
||||
|
||||
async def _lock_of_consolidation_in_own_transaction(
|
||||
maker: async_sessionmaker[AsyncSession], consolidation_id: uuid.UUID, entity_id: uuid.UUID
|
||||
) -> list[tuple[uuid.UUID, ConsolidationStatus]]:
|
||||
async with maker() as session, session.begin():
|
||||
versions = await repo.lock_versions_of_consolidation(
|
||||
session, consolidation_id, entity_id, (ConsolidationStatus.INITIATED,)
|
||||
)
|
||||
return [(version.id, version.status) for version in versions]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_lock_versions_of_consolidation_waits_for_member_edit_and_sees_new_member(
|
||||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""T3.1: правка периметра держит версию (`FOR UPDATE`) и добавила ЮЛ (не зафиксировано); блокировка версий
|
||||
консолидации ждёт правку и после commit видит нового члена — `EXISTS` не пересчитывается в одном операторе с
|
||||
`FOR UPDATE`, поэтому членство выбирается вторым запросом."""
|
||||
maker = committed_sessionmaker
|
||||
data = await _committed_setup(maker)
|
||||
async with maker() as session:
|
||||
version = await session.get(ConsolidationVersion, data["version_id"])
|
||||
assert version is not None
|
||||
consolidation_id = version.consolidation_id
|
||||
|
||||
async with maker() as editor, editor.begin():
|
||||
assert await repo.lock_version(editor, data["version_id"]) is not None
|
||||
editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"]))
|
||||
await editor.flush()
|
||||
waiter = asyncio.create_task(
|
||||
_lock_of_consolidation_in_own_transaction(maker, consolidation_id, data["owner_id"])
|
||||
)
|
||||
await _wait_for_lock_waiter(maker)
|
||||
assert not waiter.done()
|
||||
locked = await asyncio.wait_for(waiter, LOCK_WAIT_SECONDS)
|
||||
|
||||
assert locked == [(data["version_id"], ConsolidationStatus.INITIATED)]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_lock_versions_of_consolidation_returns_only_member_versions(
|
||||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""T3.1: ЮЛ вне периметра — пусто; член периметра — версия со свежим статусом; статус не из набора — пусто."""
|
||||
maker = committed_sessionmaker
|
||||
data = await _committed_setup(maker)
|
||||
async with maker() as session:
|
||||
version = await session.get(ConsolidationVersion, data["version_id"])
|
||||
assert version is not None
|
||||
consolidation_id = version.consolidation_id
|
||||
member_id, outsider_id = data["entity_ids"][0], data["owner_id"]
|
||||
|
||||
assert await _lock_of_consolidation_in_own_transaction(maker, consolidation_id, outsider_id) == []
|
||||
assert await _lock_of_consolidation_in_own_transaction(maker, consolidation_id, member_id) == [
|
||||
(data["version_id"], ConsolidationStatus.INITIATED)
|
||||
]
|
||||
async with maker() as session, session.begin():
|
||||
assert (
|
||||
await repo.lock_versions_of_consolidation(
|
||||
session, consolidation_id, member_id, (ConsolidationStatus.DATA_CORRECTION,)
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_on_documents_mapped_waits_for_transition_to_documents_processing(
|
||||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""R-2/№40, маппинг ∥ загрузка (`after_upload_to_version`): версия `INITIATED` держится транзакцией, которая
|
||||
"""R-2/№40, маппинг ∥ загрузка (`after_upload_to_consolidation`): версия `INITIATED` держится транзакцией, которая
|
||||
переводит её в `DOCUMENTS_PROCESSING` (не зафиксировано). Хук блокирует надмножество версий «в работе» (а не только
|
||||
`DOCUMENTS_PROCESSING`), поэтому встаёт в ожидание строки версии и после commit видит `DOCUMENTS_PROCESSING` —
|
||||
переход в `DATA_CORRECTION` не теряется. Хук, ищущий версии только в `DOCUMENTS_PROCESSING`, снимок `INITIATED`
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Enum'ы ORM против блоков `Enum {}` DBML v5.17: имена, значения и их порядок (без БД)."""
|
||||
"""Enum'ы ORM против блоков `Enum {}` DBML v5.18: имена, значения и их порядок (без БД)."""
|
||||
|
||||
import enum
|
||||
import re
|
||||
@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 28
|
||||
|
||||
def test_dbml_version_format() -> None:
|
||||
assert re.fullmatch(r"\d+\.\d+", DBML_VERSION)
|
||||
assert DBML_VERSION == "5.17"
|
||||
assert DBML_VERSION == "5.18"
|
||||
assert f"v{DBML_VERSION}" in dbml_path().name
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16), 0005 (v5.17) на PostgreSQL 16: полный цикл,
|
||||
откат на шаг, пустой autogenerate, схема = схеме ORM.
|
||||
"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16), 0005 (v5.17), 0006 (v5.18) на PostgreSQL 16:
|
||||
полный цикл, откат на шаг, пустой autogenerate, схема = схеме ORM.
|
||||
|
||||
Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация
|
||||
ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из
|
||||
@ -36,7 +36,8 @@ from tests.test_models_part_07_09 import (
|
||||
_check_active_lock_is_unique,
|
||||
)
|
||||
|
||||
HEAD = "0005_dbml_v5_17"
|
||||
HEAD = "0006_dbml_v5_18"
|
||||
V5_17 = "0005_dbml_v5_17"
|
||||
V5_16 = "0004_dbml_v5_16"
|
||||
V5_15 = "0003_dbml_v5_15"
|
||||
V5_14 = "0002_dbml_v5_14"
|
||||
@ -451,6 +452,174 @@ async def _kpp_columns(conn: AsyncConnection) -> list[Any]:
|
||||
)
|
||||
|
||||
|
||||
async def _consolidation_id_column(conn: AsyncConnection) -> list[Any]:
|
||||
return (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT is_nullable, data_type FROM information_schema.columns "
|
||||
"WHERE table_schema = 'dkb' AND table_name = 'stored_file' AND column_name = 'consolidation_id'"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> None:
|
||||
"""0006: `stored_file.consolidation_id uuid` nullable, последняя колонка, индекс
|
||||
`(legal_entity_id, consolidation_id)`; backfill по `consolidation_document`: файл A привязан к версиям 2025 Q1 и
|
||||
2024 Q4 → 2024 Q4 (самый ранний период, год важнее квартала); файл B без привязок → NULL; файл C привязан к
|
||||
версиям 2025 Q2 и 2025 Q1 → 2025 Q1 (квартал внутри года по возрастанию); `downgrade 0005`
|
||||
убирает индекс и колонку, повторный upgrade возвращает."""
|
||||
engine = create_async_engine(scratch_db_url)
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
ids = {
|
||||
name: uuid.uuid4()
|
||||
for name in (
|
||||
"office",
|
||||
"group",
|
||||
"entity",
|
||||
"user",
|
||||
"s3a",
|
||||
"s3b",
|
||||
"s3c",
|
||||
"file_a",
|
||||
"file_b",
|
||||
"file_c",
|
||||
"doc_a",
|
||||
"doc_c1",
|
||||
"doc_c2",
|
||||
"c_2025q1",
|
||||
"c_2025q2",
|
||||
"c_2024q4",
|
||||
"v_2025q1",
|
||||
"v_2025q2",
|
||||
"v_2024q4",
|
||||
)
|
||||
}
|
||||
try:
|
||||
await alembic(scratch_db_url, "upgrade", V5_17)
|
||||
async with engine.begin() as conn:
|
||||
assert await _consolidation_id_column(conn) == []
|
||||
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 | {"now": now},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO dkb.legal_entity (id, inn, name, company_group_id, valid_from) "
|
||||
"VALUES (:entity, '7701000001', 'ООО', :group, :now)"
|
||||
),
|
||||
ids | {"now": now},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) "
|
||||
"VALUES (:user, 'm@x.ru', 'Тест', :office)"
|
||||
),
|
||||
ids,
|
||||
)
|
||||
for s3, file in (("s3a", "file_a"), ("s3b", "file_b"), ("s3c", "file_c")):
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO dkb.s3_object (id, bucket_role, bucket, key, created_at) "
|
||||
f"VALUES (:{s3}, 'uploads', 'b', 'k-{s3}', :now)"
|
||||
),
|
||||
ids | {"now": now},
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO dkb.stored_file (id, legal_entity_id, file_type, s3_object_id, status, "
|
||||
f"version_no) VALUES (:{file}, :entity, 'XLS', :{s3}, 'PROCESSED', 1)"
|
||||
),
|
||||
ids,
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, osv_account_code, "
|
||||
"period_year, period_quarter, status) "
|
||||
"VALUES (:doc_a, :file_a, :entity, 'OSV', '60', 2024, 'Q4', 'CONFIRMED')"
|
||||
),
|
||||
ids,
|
||||
)
|
||||
for doc, account in (("doc_c1", "62"), ("doc_c2", "66")):
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, "
|
||||
"osv_account_code, period_year, period_quarter, status) "
|
||||
f"VALUES (:{doc}, :file_c, :entity, 'OSV', '{account}', 2025, 'Q2', 'CONFIRMED')"
|
||||
),
|
||||
ids,
|
||||
)
|
||||
for c, v, year, quarter, rd in (
|
||||
("c_2025q1", "v_2025q1", 2025, "Q1", "2025-04-01"),
|
||||
("c_2025q2", "v_2025q2", 2025, "Q2", "2025-07-01"),
|
||||
("c_2024q4", "v_2024q4", 2024, "Q4", "2025-01-01"),
|
||||
):
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO dkb.consolidation (id, company_group_id, year, quarter, reporting_date) "
|
||||
f"VALUES (:{c}, :group, {year}, '{quarter}', '{rd}')"
|
||||
),
|
||||
ids,
|
||||
)
|
||||
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) "
|
||||
f"VALUES (:{v}, :{c}, 1, 'INITIATED', 'CREATE', 'BASIC', false, :user, :office, true)"
|
||||
),
|
||||
ids,
|
||||
)
|
||||
for doc in {"v_2025q1": ("doc_a", "doc_c2"), "v_2025q2": ("doc_c1",), "v_2024q4": ("doc_a",)}[v]:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) "
|
||||
f"VALUES (gen_random_uuid(), :{v}, :{doc})"
|
||||
),
|
||||
ids,
|
||||
)
|
||||
|
||||
await alembic(scratch_db_url, "upgrade", "head")
|
||||
async with engine.begin() as conn:
|
||||
assert await _consolidation_id_column(conn) == [("YES", "uuid")]
|
||||
last = await _scalars(
|
||||
conn,
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'dkb' "
|
||||
"AND table_name = 'stored_file' ORDER BY ordinal_position DESC LIMIT 1",
|
||||
)
|
||||
assert last == ["consolidation_id"]
|
||||
rows = (await conn.execute(text("SELECT id, consolidation_id FROM dkb.stored_file ORDER BY id"))).all()
|
||||
assert dict(rows) == {ids["file_a"]: ids["c_2024q4"], ids["file_b"]: None, ids["file_c"]: ids["c_2025q1"]}
|
||||
assert await _scalars(
|
||||
conn,
|
||||
"SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' "
|
||||
"AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'",
|
||||
) == ["ix_stored_file_legal_entity_id_consolidation_id"]
|
||||
await engine.dispose()
|
||||
|
||||
await alembic(scratch_db_url, "downgrade", V5_17)
|
||||
async with engine.connect() as conn:
|
||||
assert await _consolidation_id_column(conn) == []
|
||||
assert (
|
||||
await _scalars(
|
||||
conn,
|
||||
"SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' "
|
||||
"AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'",
|
||||
)
|
||||
== []
|
||||
)
|
||||
await engine.dispose()
|
||||
await alembic(scratch_db_url, "upgrade", "head")
|
||||
async with engine.connect() as conn:
|
||||
assert await _consolidation_id_column(conn) == [("YES", "uuid")]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_upgrade_0005_adds_nullable_kpp_columns(scratch_db_url: str) -> None:
|
||||
"""0005: `kpp varchar` nullable у `legal_entity` и `legal_entity_history`, существующие ЮЛ — с `kpp IS NULL`;
|
||||
@ -714,8 +883,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
|
||||
assert schema_at < output.index("CREATE TABLE dkb.alembic_version")
|
||||
assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')")
|
||||
assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output
|
||||
# версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 → 0004 → 0005 — офлайн-SQL доходит до head
|
||||
for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (V5_16, V5_15), (HEAD, V5_16)):
|
||||
# версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 → 0004 → 0005 → 0006 — офлайн-SQL доходит до head
|
||||
for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (V5_16, V5_15), (V5_17, V5_16), (HEAD, V5_17)):
|
||||
assert (
|
||||
f"UPDATE dkb.alembic_version SET version_num='{new_version}' "
|
||||
f"WHERE dkb.alembic_version.version_num = '{old_version}'" in output
|
||||
|
||||
@ -49,6 +49,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
||||
f"{A}/roles/{{code}}/permissions": {"put"},
|
||||
# primary-files, documents-api (Срез 2c)
|
||||
"/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"},
|
||||
"/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/documents/{document_id}": {"get"},
|
||||
|
||||
@ -20,17 +20,23 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.contract import ack_msg_id, result_msg_id
|
||||
from src.contract.result import RecognitionResult
|
||||
from src.core.config import Settings, get_settings
|
||||
from src.domain.consolidation_periods import reporting_date
|
||||
from src.models.access import AppUser
|
||||
from src.models.accounting import ChartOfAccount
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.consolidation import ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion
|
||||
from src.models.consolidation import (
|
||||
Consolidation,
|
||||
ConsolidationDocument,
|
||||
ConsolidationStatusHistory,
|
||||
ConsolidationVersion,
|
||||
)
|
||||
from src.models.directories import LegalEntity
|
||||
from src.models.enums import (
|
||||
AuditAction,
|
||||
@ -1044,6 +1050,81 @@ async def test_version_chain(committed_sessionmaker: async_sessionmaker[AsyncSes
|
||||
assert event.after_value == {"file_id": str(v3_id), "version_no": 3, "unbound_version_ids": []}
|
||||
|
||||
|
||||
async def _consolidation_id(
|
||||
maker: async_sessionmaker[AsyncSession], entity_id: uuid.UUID, quarter: PeriodQuarter
|
||||
) -> uuid.UUID:
|
||||
"""Консолидация ГК этого ЮЛ за 2025/quarter (без версий: для наследования они не нужны).
|
||||
|
||||
ЮЛ без ГК получает ГК (консолидация требует группу) и привязывается к ней."""
|
||||
async with maker() as session, session.begin():
|
||||
entity = await session.get(LegalEntity, entity_id)
|
||||
assert entity is not None
|
||||
if entity.company_group_id is None:
|
||||
group = await make_company_group(session, name="ГК Наследование")
|
||||
entity.company_group_id = group.id
|
||||
consolidation = Consolidation(
|
||||
company_group_id=entity.company_group_id,
|
||||
year=2025,
|
||||
quarter=quarter,
|
||||
reporting_date=reporting_date(2025, quarter),
|
||||
created_at=_now(),
|
||||
)
|
||||
session.add(consolidation)
|
||||
await session.flush()
|
||||
return consolidation.id
|
||||
|
||||
|
||||
async def _set_consolidation(
|
||||
maker: async_sessionmaker[AsyncSession], file_id: uuid.UUID, consolidation_id: uuid.UUID | None
|
||||
) -> None:
|
||||
async with maker() as session, session.begin():
|
||||
await session.execute(
|
||||
update(StoredFile).where(StoredFile.id == file_id).values(consolidation_id=consolidation_id)
|
||||
)
|
||||
|
||||
|
||||
async def test_supersede_inherits_consolidation_from_first_with_value(
|
||||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""Спека «Новый файл без контекста наследует консолидацию» + Review Focus 5: два заменяемых файла, у первого по `id`
|
||||
`consolidation_id` пуст, у второго задан — новый получает значение второго."""
|
||||
maker, storage = committed_sessionmaker, FakeStorage()
|
||||
entity_id = await _entity(maker)
|
||||
c1 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q1)
|
||||
first_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1])
|
||||
second_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1])
|
||||
empty_id, filled_id = sorted([first_id, second_id])
|
||||
await _set_consolidation(maker, filled_id, c1)
|
||||
async with maker() as session:
|
||||
entity = await session.get(LegalEntity, entity_id)
|
||||
new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity)
|
||||
|
||||
assert await process_once(maker, storage, _settings()) is True
|
||||
|
||||
assert (await _file(maker, new_id)).consolidation_id == c1
|
||||
assert (await _file(maker, empty_id)).status is FileStatus.SUPERSEDED
|
||||
assert (await _file(maker, filled_id)).status is FileStatus.SUPERSEDED
|
||||
|
||||
|
||||
async def test_supersede_keeps_own_consolidation(committed_sessionmaker: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""Спека «Новый файл с контекстом сохраняет свой»: старый в C1, новый загружен в C2 — остаётся C2."""
|
||||
maker, storage = committed_sessionmaker, FakeStorage()
|
||||
entity_id = await _entity(maker)
|
||||
c1 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q1)
|
||||
c2 = await _consolidation_id(maker, entity_id, PeriodQuarter.Q2)
|
||||
old_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1])
|
||||
await _set_consolidation(maker, old_id, c1)
|
||||
async with maker() as session:
|
||||
entity = await session.get(LegalEntity, entity_id)
|
||||
new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity)
|
||||
await _set_consolidation(maker, new_id, c2)
|
||||
|
||||
assert await process_once(maker, storage, _settings()) is True
|
||||
|
||||
assert (await _file(maker, new_id)).consolidation_id == c2
|
||||
assert (await _file(maker, old_id)).status is FileStatus.SUPERSEDED
|
||||
|
||||
|
||||
async def test_one_event_per_replaced_file(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||||
"""Новый файл с ОСВ 60 и 62 заменяет прежний файл с теми же двумя документами одним событием `FILE_REPLACE`,
|
||||
в `description` — оба документа."""
|
||||
|
||||
1110
docs/superpowers/plans/2026-10-09-console-files-core.md
Normal file
1110
docs/superpowers/plans/2026-10-09-console-files-core.md
Normal file
File diff suppressed because it is too large
Load Diff
2
openspec/changes/console-files/.openspec.yaml
Normal file
2
openspec/changes/console-files/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-09
|
||||
105
openspec/changes/console-files/design.md
Normal file
105
openspec/changes/console-files/design.md
Normal file
@ -0,0 +1,105 @@
|
||||
## Context
|
||||
|
||||
Сквозной дизайн — `../docs/openspec/changes/console-files/design.md` (модель, алгоритмы, API, сравнение вариантов
|
||||
привязки). Здесь — только решения уровня ядра: где лежит код, как считается стол, как переводятся версии, миграция.
|
||||
|
||||
Точки опоры в коде: единственная точка создания `stored_file` — `services/primary_files._store`; переход версии после
|
||||
загрузки — `after_upload_to_version` (отдельная транзакция `session_transaction`, `lock_version FOR UPDATE`, затем
|
||||
`consolidation_status.transition`); автозамена — `services/result_mapping.supersede_previous` (старые файлы под
|
||||
`FOR UPDATE ORDER BY id`); карточка файла — `services/documents.file_out`; видимость консолидации —
|
||||
`repositories/consolidations._visible_consolidations` (ГК в области пользователя).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
Goals: см. proposal. Non-goals: см. proposal.
|
||||
|
||||
## Decisions
|
||||
|
||||
**D-1. Колонка последней.** `StoredFile.consolidation_id` объявляется после `updated_at` в ORM и в DBML v5.18:
|
||||
`ALTER TABLE … ADD COLUMN` ставит её в конец, а `check_dbml_vs_orm` сверяет порядок (как `kpp` в v5.17). FK —
|
||||
`fk_stored_file_consolidation_id_consolidation` по соглашению имён 0001; индекс `ix_stored_file_legal_entity_id_consolidation_id`.
|
||||
|
||||
**D-2. Backfill в миграции 0006 — одним UPDATE.** Для каждого файла с документом в `consolidation_document` берётся
|
||||
консолидация версии; при нескольких — самый ранний период (`ORDER BY c.year, c.quarter, c.id`, enum `period_quarter`
|
||||
упорядочен Q1 < … < Q4 по объявлению):
|
||||
|
||||
```sql
|
||||
UPDATE {S}.stored_file AS sf SET consolidation_id = pick.consolidation_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (d.stored_file_id) d.stored_file_id, c.id AS consolidation_id
|
||||
FROM {S}.document d
|
||||
JOIN {S}.consolidation_document cd ON cd.document_id = d.id
|
||||
JOIN {S}.consolidation_version v ON v.id = cd.consolidation_version_id
|
||||
JOIN {S}.consolidation c ON c.id = v.consolidation_id
|
||||
ORDER BY d.stored_file_id, c.year, c.quarter, c.id
|
||||
) AS pick
|
||||
WHERE sf.id = pick.stored_file_id
|
||||
```
|
||||
|
||||
Откат — `DROP INDEX`, `DROP COLUMN` (FK уходит вместе с колонкой).
|
||||
|
||||
**D-3. Проверки загрузки в консолидацию — до приёма, без блокировок.** `PrimaryFilesService.check_consolidation_for_upload
|
||||
(consolidation_id, entity) -> Consolidation`: `consolidations_repo.get_consolidation(session, scope, id)` (видимость по ГК,
|
||||
`None` → 404 `NOT_FOUND` «консолидация не найдена»); `consolidations_repo.versions_of(session, consolidation_id)`; версии
|
||||
со статусом из `IN_WORK_STATUSES` — пусто → 409 `CONSOLIDATION_NOT_EDITABLE`; `consolidations_repo.members` по каждой из
|
||||
них — ЮЛ ни в одной → 422 `ENTITY_NOT_IN_PERIMETER`. Как и раньше, `FOR UPDATE` не нужен: статус перечитывается под
|
||||
блокировкой при переходе.
|
||||
|
||||
**D-4. Переход N версий одной отдельной транзакцией.** `after_upload_to_consolidation(consolidation_id, entity_id)`:
|
||||
в `session_transaction` новый репозиторий `consolidations_repo.lock_versions_of_consolidation(session, consolidation_id,
|
||||
entity_id, statuses={INITIATED})` — `SELECT … FOR UPDATE ORDER BY id` версий этой консолидации с ЮЛ в периметре;
|
||||
по каждой `transition(INITIATED → DOCUMENTS_PROCESSING, changed_by = загрузивший)`. Любое исключение логируется и не
|
||||
меняет ответ (R-10 Среза 4 сохраняется). Advisory-lock ГК не берётся: версии создаются только через `POST
|
||||
/consolidations`, которое берёт его само, а новая версия не видна до commit — она просто не попадёт в выборку, и её
|
||||
статус `INITIATED` переведёт следующая загрузка или подтверждение (то же, что сейчас для одной версии).
|
||||
|
||||
**D-5. Один цикл приёма для двух маршрутов.** Логика приёма файлов (чтение с лимитом, `upload_one`, сбор
|
||||
`accepted`/`rejected`, 422 при пустом `accepted`) выносится из `upload.py` в функцию `accept_uploads(service, entity,
|
||||
files, response, *, consolidation_id) -> UploadResult`; маршрут ЮЛ вызывает её с `None`, маршрут консолидации — с id
|
||||
после проверок D-3 и затем `after_upload_to_consolidation`. Новый модуль `api/endpoints/files/consolidation_files.py`
|
||||
(оба маршрута консолидации), подключается в `files/__init__.py`. Проверка права — `DOCUMENT_EDIT`/`DOCUMENT_VIEW`, как
|
||||
у маршрутов ЮЛ (загружает и смотрит первичку тот же аналитик; `CONSOLIDATION_*` права не требуются).
|
||||
|
||||
**D-6. Рабочий стол — один запрос с `OR EXISTS`.** `files_repo.list_desk_files(session, scope, entity_id,
|
||||
consolidation_id, *, include_superseded, limit, offset)` повторяет `list_files` с условием
|
||||
`StoredFile.consolidation_id == cid OR EXISTS (document d JOIN consolidation_document cd JOIN consolidation_version v
|
||||
WHERE d.stored_file_id = StoredFile.id AND v.consolidation_id = cid)`; `count` тем же фильтром; порядок прежний. Сервис
|
||||
`page_desk(consolidation_id, entity_id, *, include_superseded, page)`: консолидация видима (404), ЮЛ видимо (404),
|
||||
затем `_cards`. Привязка ЮЛ к ГК консолидации не проверяется: стол ЮЛ чужой ГК просто пуст.
|
||||
|
||||
**D-7. `FileOut.consolidation_id`** — из `file.consolidation_id` в `file_out`; `DocumentBrief` не меняется.
|
||||
|
||||
**D-8. Наследование при автозамене** — в `supersede_previous` сразу после `new_file.version_no = …`:
|
||||
`if new_file.consolidation_id is None: new_file.consolidation_id = next((old.consolidation_id for old in locked if
|
||||
old.consolidation_id is not None), None)`; `locked` уже упорядочен по `id`.
|
||||
|
||||
**D-9. Удаление старого контекста версии.** `check_version_for_upload`, `after_upload_to_version` и query
|
||||
`consolidation_version_id` удаляются вместе с тестами на них (переписываются под консолидацию); код
|
||||
`VERSION_NOT_EDITABLE` остаётся для правок версии, `VERSION_NOT_FOUND` — для маршрутов версии.
|
||||
|
||||
**D-10. Аудит.** `FILE_UPLOAD.after_value` получает `consolidation_id` (строка uuid или `null`); отдельного события
|
||||
«загрузка в консолидацию» нет; переходы версий пишут `CONSOLIDATION_STATUS_CHANGE` как раньше.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- `OR EXISTS` по `consolidation_document` на большом ЮЛ — подзапрос по индексу `(consolidation_version_id, document_id)`
|
||||
и `document (stored_file_id)`; объёмы — сотни файлов на ЮЛ, не тысячи.
|
||||
- Переход нескольких версий в одной транзакции — пока версия одна; порядок `ORDER BY id` совпадает с R-2 Среза 4.
|
||||
- Backfill «самый ранний период» — эвристика для тестовых стендов; на проде данных до миграции нет.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
1. 09.10.2026 — **R-1:** права маршрутов консолидации — `DOCUMENT_EDIT`/`DOCUMENT_VIEW`, а не `CONSOLIDATION_*`:
|
||||
первичку загружает и смотрит тот же пользователь, что и на странице ЮЛ. Цена ошибки: низкая — одна константа в
|
||||
`dependencies` маршрута.
|
||||
2. 09.10.2026 — **R-2:** 404 загрузки/стола по консолидации — код `NOT_FOUND` с текстом «консолидация не найдена»
|
||||
(константа `CONSOLIDATION_NOT_FOUND` в `services/consolidation_guard.py`), в духе правила (7) README. Цена ошибки:
|
||||
низкая.
|
||||
3. 09.10.2026 — **R-3:** стол не проверяет принадлежность ЮЛ к ГК консолидации (D-6). Цена ошибки: низкая — пустой
|
||||
список вместо 422.
|
||||
4. 09.10.2026 — **R-4:** advisory-lock ГК при переходе версий после загрузки не берётся (D-4). Цена ошибки: низкая —
|
||||
версия, созданная параллельно с загрузкой, останется `INITIATED` до следующего события, как и сейчас.
|
||||
5. 09.10.2026 — **R-5:** общий приёмный цикл `accept_uploads` живёт в `endpoints/files/upload.py` и импортируется
|
||||
`consolidation_files.py` (а не в сервисе): он работает с `UploadFile`/`Response` FastAPI. Цена ошибки: низкая.
|
||||
6. 09.10.2026 — **R-6:** backfill миграции тестируется сценарием «файл с двумя привязками к версиям разных периодов →
|
||||
самый ранний»; файл без привязок остаётся `NULL`. Цена ошибки: низкая.
|
||||
71
openspec/changes/console-files/proposal.md
Normal file
71
openspec/changes/console-files/proposal.md
Normal file
@ -0,0 +1,71 @@
|
||||
## Why
|
||||
|
||||
Панель «Первичные документы» ЮЛ, открытая из версии консолидации, показывает все файлы ЮЛ: `stored_file` не знает,
|
||||
в какую консолидацию загружен, `GET /legal-entities/{id}/files` не фильтруется, а `consolidation_version_id` при
|
||||
загрузке служит только переходу статуса версии. Решения владельца 09.10.2026 (RSHB/arm-ka-docs#90, сквозной change
|
||||
`../docs/openspec/changes/console-files/`, DBML v5.18): файл загружается **в консолидацию** (ГК + период) и образует
|
||||
её «рабочий стол», общий для всех версий периода; что использует конкретная версия, по-прежнему определяет
|
||||
`consolidation_document`; загрузка и список в контексте консоли — маршрутами от консолидации.
|
||||
|
||||
Срез: доработка Срезов 2c/4 вне плана `09-delivery-plan.md` (запись в разделе «Срез 2d»). Репозиторий — только
|
||||
`arm-core`; фронт — RSHB/arm-web (свой change), документы — RSHB/arm-ka-docs#90. Issue — RSHB/arm-core#96.
|
||||
|
||||
Правила домена: `05-domain-rules.md` §9.1 (переход при загрузке), §9.5 п. 1, 3, 8 и 11 (редакция v5.18 из docs #90).
|
||||
Таблицы DBML v5.18: `stored_file` (новая колонка `consolidation_id`, индекс `(legal_entity_id, consolidation_id)`),
|
||||
читаются `consolidation`, `consolidation_version`, `consolidation_member`, `consolidation_document`, `document`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Модель и миграция.** `StoredFile.consolidation_id` (nullable FK → `consolidation`, последняя колонка), индекс
|
||||
`ix_stored_file_legal_entity_id_consolidation_id`; `DBML_VERSION = "5.18"`, `make dbml-check` по
|
||||
`../docs/model/data-model-v5.18.dbml`; миграция `0006_dbml_v5_18`: колонка, FK, индекс, backfill по
|
||||
`consolidation_document` (при привязках к версиям разных периодов — самый ранний), откат — удаление.
|
||||
- **Загрузка в консолидацию.** `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files`
|
||||
(`DOCUMENT_EDIT`): консолидация и ЮЛ видимы (404), есть версия «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`), ЮЛ в
|
||||
периметре хотя бы одной из них (422 `ENTITY_NOT_IN_PERIMETER`); файл получает `consolidation_id`,
|
||||
`FILE_UPLOAD.after_value.consolidation_id`; после приёма ≥ 1 файла каждая версия консолидации «в работе» с ЮЛ в
|
||||
периметре и статусом `INITIATED` переходит в `DOCUMENTS_PROCESSING` отдельной транзакцией.
|
||||
- **Загрузка без контекста.** `POST /legal-entities/{id}/files`: query `consolidation_version_id` удалён, коды
|
||||
`VERSION_NOT_FOUND`/`VERSION_NOT_EDITABLE` у загрузки больше не возникают; `consolidation_id = NULL`.
|
||||
- **Рабочий стол.** `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`):
|
||||
файлы ЮЛ с этим `consolidation_id` ∪ файлы ЮЛ с документом, привязанным к любой версии консолидации; `limit`,
|
||||
`offset`, `include_superseded`; та же карточка `FileOut`.
|
||||
- **Карточка файла.** `FileOut.consolidation_id: uuid | null` в списках и `GET /files/{id}`.
|
||||
- **Автозамена.** Новый файл без контекста наследует `consolidation_id` первого (по `id`) заменённого файла, у которого
|
||||
он задан.
|
||||
- Спеки `primary-files`, `documents-api`, `data-model`; README («Ошибки API», таблицы маршрутов, §9.1), `CLAUDE.md`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Несколько рабочих версий одного периода и «новая версия после `VERIFIED`» не реализуются (OQ-160, отдельный срез);
|
||||
переход статуса после загрузки написан для N версий, но создаётся по-прежнему одна.
|
||||
- Замороженный список первички верифицированной версии (ЕХ, Срез 7) не строится; стол показывается для версии любого
|
||||
статуса, загрузка проверяет только наличие версии «в работе».
|
||||
- Признак «документ привязан / подходит версии» в `FileOut` не вводится (чек-лист, OQ-120).
|
||||
- `consolidation_document`, `fits`, подхват, удаление файла — без изменений.
|
||||
- Перенос файла между консолидациями, правка `consolidation_id` через API — нет.
|
||||
- Контракт NATS и S3 не меняется.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
- `primary-files`: загрузка в консолидацию (новый маршрут, коды ошибок, переход N версий), удаление query у загрузки ЮЛ,
|
||||
наследование `consolidation_id` при автозамене.
|
||||
- `documents-api`: рабочий стол консоли, `consolidation_id` в карточке файла.
|
||||
- `data-model`: DBML v5.18, миграция 0006 с backfill.
|
||||
|
||||
## Impact
|
||||
|
||||
- `api/src/models/files.py`, `api/src/models/__init__.py`, `api/alembic/versions/0006_dbml_v5_18.py`, `Makefile`
|
||||
(`DBML`), `api/scripts/dbml_parity.py` (путь по умолчанию), `api/tests/dbml_helpers.py`.
|
||||
- `api/src/repositories/files.py` (стол), `api/src/repositories/consolidations.py` (консолидация по id, версии
|
||||
консолидации под `FOR UPDATE`), `api/src/services/primary_files.py` (загрузка в консолидацию, стол),
|
||||
`api/src/services/result_mapping.py` (наследование), `api/src/services/documents.py` (`file_out`),
|
||||
`api/src/schemas/files.py`, `api/src/api/endpoints/files/upload.py`, новый `consolidation_files.py`,
|
||||
`api/src/api/endpoints/files/__init__.py`.
|
||||
- Тесты: `test_api_files_upload.py` (раздел «в контексте версии» переписывается под консолидацию),
|
||||
`test_api_files_read.py`/новый `test_api_consolidation_files.py`, `test_migrations.py`, `test_models_part_04_06.py`,
|
||||
тесты автозамены.
|
||||
- Клиент arm-web меняется одновременно (query удалён); стенд обновляется после слияния обоих PR.
|
||||
@ -0,0 +1,28 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Статусы Среза 4
|
||||
Версия SHALL менять статус только через сервис статусов, который пишет `consolidation_status_history(from, to,
|
||||
changed_by, changed_at, comment)` и `audit_event(CONSOLIDATION_STATUS_CHANGE, before = from, after = to, comment)`.
|
||||
Автоматические переходы: загрузка в консолидацию (`POST /consolidations/{id}/legal-entities/{le}/files`, см. `primary-files`) —
|
||||
`INITIATED → DOCUMENTS_PROCESSING` у каждой версии консолидации «в работе» с ЮЛ в периметре; маппинг документа `NEEDS_CHECK`, подходящего версии (см. `osv-result-mapping`) —
|
||||
`DOCUMENTS_PROCESSING → DATA_CORRECTION`; подтверждение документа, привязанного к версии (см. `documents-api`) —
|
||||
`INITIATED` или `DOCUMENTS_PROCESSING → DATA_CORRECTION`. Ручной `POST /consolidations/versions/{id}/status`
|
||||
(`CONSOLIDATION_EDIT`, policy, своя блокировка) с телом `{to_status, comment}` SHALL допускать только: `CANCELLED` из
|
||||
любого статуса «в работе» (`comment` обязателен, иначе 422 `COMMENT_REQUIRED`; `cancelled_reason = comment`,
|
||||
`is_in_work = false`, активная блокировка снимается) и `INITIATED` из `DOCUMENTS_PROCESSING` / `DATA_CORRECTION`
|
||||
(`comment` необязателен; `consolidation_document` сохраняются). Любой другой `to_status` → 409 `TRANSITION_NOT_ALLOWED`
|
||||
с текущим и запрошенным статусами. Повтор того же статуса → 409 `TRANSITION_NOT_ALLOWED`. Статус «в работе» =
|
||||
{`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`}.
|
||||
|
||||
#### Scenario: Аннулирование
|
||||
- **WHEN** владелец блокировки переводит версию `DATA_CORRECTION` в `CANCELLED` с комментарием
|
||||
- **THEN** 200; `status = CANCELLED`, `cancelled_reason` = комментарий, `is_in_work = false`, блокировка снята; запись
|
||||
истории `DATA_CORRECTION → CANCELLED` с комментарием; `audit_event(CONSOLIDATION_STATUS_CHANGE)`
|
||||
|
||||
#### Scenario: Переход Среза 7
|
||||
- **WHEN** запрошен `to_status = CONSOLIDATION_RF`
|
||||
- **THEN** 409 `TRANSITION_NOT_ALLOWED`, статус не изменился
|
||||
|
||||
#### Scenario: Аннулирование без комментария
|
||||
- **WHEN** `to_status = CANCELLED`, `comment` пуст
|
||||
- **THEN** 422 `COMMENT_REQUIRED`
|
||||
100
openspec/changes/console-files/specs/data-model/spec.md
Normal file
100
openspec/changes/console-files/specs/data-model/spec.md
Normal file
@ -0,0 +1,100 @@
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Схема БД равна DBML v5.17`
|
||||
- TO: `### Requirement: Схема БД равна DBML v5.18`
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Схема БД равна DBML v5.18
|
||||
ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.18.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 существовать.
|
||||
|
||||
#### Scenario: Сверка ORM с DBML
|
||||
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.18.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` их возвращает
|
||||
|
||||
### Requirement: Миграции применяются с нуля и откатываются
|
||||
Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` → `0004_dbml_v5_16` → `0005_dbml_v5_17` →
|
||||
`0006_dbml_v5_18` SHALL создавать схему из `DB_SCHEMA` (если нет), все
|
||||
enum'ы, таблицы, ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`)
|
||||
на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type`, приводить
|
||||
`app_user.email` к нижнему регистру и создавать индексы `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`,
|
||||
`ix_stored_file_current_recognition_job_id` (DBML v5.15 п. 4); `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через
|
||||
пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок.
|
||||
|
||||
#### Scenario: Полный цикл
|
||||
- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head`
|
||||
- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `DB_SCHEMA` 43 таблицы модели (+ служебная `alembic_version`)
|
||||
и в `pg_type` 28 enum-типов
|
||||
|
||||
#### Scenario: Откат до v5.14
|
||||
- **WHEN** после `upgrade head` выполняется `alembic downgrade 0002_dbml_v5_14`
|
||||
- **THEN** `osv_data_type` не содержит `NU`, индексов v5.15 п. 4 нет; регистр `app_user.email` не возвращается (данные)
|
||||
|
||||
#### Scenario: КПП добавляется и откатывается
|
||||
- **WHEN** на базе с ЮЛ выполняется `alembic upgrade head`, затем `alembic downgrade 0004_dbml_v5_16`
|
||||
- **THEN** после `upgrade` у `legal_entity` и `legal_entity_history` есть последняя колонка `kpp varchar NULL`, у существующих ЮЛ
|
||||
`kpp IS NULL`; после `downgrade` колонок нет, повторный `upgrade` их возвращает
|
||||
|
||||
#### Scenario: Нестандартная схема
|
||||
- **WHEN** `DB_SCHEMA = dkb_alt` и на пустой БД выполняется `alembic upgrade head`
|
||||
- **THEN** таблицы и enum'ы модели создаются в схеме `dkb_alt`, приложение работает с ней; схема `dkb` не создаётся
|
||||
|
||||
#### Scenario: Автогенерация ничего не находит
|
||||
- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД
|
||||
- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`)
|
||||
|
||||
### Requirement: Версия DBML зафиксирована в коде
|
||||
Пакет моделей SHALL объявлять константу версии DBML (`DBML_VERSION = "5.18"`); скрипт сверки SHALL сравнивать её с версией в шапке файла
|
||||
DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна.
|
||||
|
||||
#### Scenario: Новая версия DBML без обновления ORM
|
||||
- **WHEN** в `docs/model/` появляется `data-model-v5.19.dbml`, а константа в ORM — `5.18`
|
||||
- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц
|
||||
50
openspec/changes/console-files/specs/documents-api/spec.md
Normal file
50
openspec/changes/console-files/specs/documents-api/spec.md
Normal file
@ -0,0 +1,50 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Файлы организации и карточка файла
|
||||
`GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL
|
||||
возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file` (в том числе `consolidation_id: uuid | null`,
|
||||
v5.18 — консолидация, в контексте которой файл загружен), текущее задание (`status`, `error_code`,
|
||||
`error_message`, `mapping_error` (`metrics.mapping_error`: причина `ERROR` файла после неудачного маппинга, когда у задания
|
||||
`error_code` пуст), `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`,
|
||||
`osv_account_code`, `period_year`, `period_quarter`, `is_cumulative`, `status`, `period_check`, `counterparty_check`,
|
||||
`comments_count`). `GET /api/v1/files/{file_id}` SHALL отдавать ту же карточку; чужой или несуществующий файл — 404.
|
||||
|
||||
#### Scenario: Список после обработки
|
||||
- **WHEN** у ЮЛ два файла: `PROCESSED` с документом и `ERROR`
|
||||
- **THEN** оба в списке; у первого `documents` непустой, у второго `job.error_code` заполнен
|
||||
|
||||
#### Scenario: Счётчик комментариев
|
||||
- **WHEN** к документу файла добавлены два комментария
|
||||
- **THEN** в карточке файла у документа `comments_count = 2`
|
||||
|
||||
#### Scenario: Консолидация в карточке
|
||||
- **WHEN** один файл ЮЛ загружен в консолидацию `C`, другой — без контекста
|
||||
- **THEN** в списке ЮЛ и в `GET /files/{id}` у первого `consolidation_id = C`, у второго `consolidation_id = null`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Рабочий стол консолидации для ЮЛ
|
||||
`GET /api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (`DOCUMENT_VIEW`; `limit`,
|
||||
`offset`, `include_superseded=false` по умолчанию; §9.5 п. 11 v5.18) SHALL отвечать 404 `NOT_FOUND` на консолидацию вне
|
||||
видимости или несуществующую («консолидация не найдена») и на ЮЛ вне видимости, иначе возвращать `Page[FileOut]` —
|
||||
объединение файлов ЮЛ, у которых `stored_file.consolidation_id = consolidation_id`, и файлов ЮЛ, у которых есть документ
|
||||
с записью `consolidation_document` к любой версии этой консолидации; порядок и карточка — как у списка файлов ЮЛ;
|
||||
`SUPERSEDED` — только при `include_superseded=true` (в обоих множествах). Принадлежность ЮЛ к ГК консолидации не
|
||||
проверяется: чужому ЮЛ соответствует пустой список.
|
||||
|
||||
#### Scenario: Стол показывает только файлы консоли
|
||||
- **WHEN** у ЮЛ три файла: загружен в консолидацию 2025 Q1, загружен в консолидацию 2025 Q2, без контекста
|
||||
- **THEN** стол 2025 Q1 содержит только первый; стол 2025 Q2 — только второй; `GET /legal-entities/{id}/files` — все три
|
||||
|
||||
#### Scenario: Подхваченный файл виден через привязку
|
||||
- **WHEN** файл с `CONFIRMED` ОСВ за 2025 Q1 загружен без контекста и его документ привязан к версии консолидации 2025 Q2
|
||||
(подхват при создании версии)
|
||||
- **THEN** файл есть в столе 2025 Q2, хотя `consolidation_id IS NULL`
|
||||
|
||||
#### Scenario: Заменённый файл скрыт по умолчанию
|
||||
- **WHEN** файл консолидации заменён (`SUPERSEDED`) новым файлом той же консолидации
|
||||
- **THEN** по умолчанию в столе только новый; с `include_superseded=true` — оба
|
||||
|
||||
#### Scenario: Чужая консолидация
|
||||
- **WHEN** `ka_rf` запрашивает стол консолидации ГК другого филиала и несуществующий id
|
||||
- **THEN** оба ответа 404 с одинаковым телом
|
||||
134
openspec/changes/console-files/specs/primary-files/spec.md
Normal file
134
openspec/changes/console-files/specs/primary-files/spec.md
Normal file
@ -0,0 +1,134 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Загрузка файлов организации
|
||||
`POST /api/v1/legal-entities/{legal_entity_id}/files` (multipart, одно или несколько полей `files`; право
|
||||
`DOCUMENT_EDIT`; ЮЛ в области видимости пользователя, иначе 404) SHALL для каждого файла в отдельной транзакции:
|
||||
определить тип по расширению и сверить с сигнатурой содержимого (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`,
|
||||
`jpg`); отвергнуть файл больше `UPLOAD_MAX_BYTES` (50 МиБ) или неподдерживаемого типа; вычислить sha256; создать
|
||||
`s3_object` (`bucket_role = uploads`, ключ `uploads/{file_id}/{job_id}/{безопасное имя}`, `sha256`, `size_bytes`,
|
||||
`content_type`, `created_by = user:{id}`), положить объект в S3, создать `stored_file` (`status = UPLOADED`,
|
||||
`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`, `consolidation_id = NULL`) и
|
||||
`recognition_job` (`status = queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`;
|
||||
`max_attempts` 3 / 1), установить `stored_file.current_recognition_job_id`, записать
|
||||
`audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)` с `after_value.consolidation_id = null`. Ответ SHALL
|
||||
перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`,
|
||||
`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT
|
||||
запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`.
|
||||
Query-параметр `consolidation_version_id` MUST NOT приниматься (удалён в v5.18): загрузка этим маршрутом — без
|
||||
контекста консолидации и не меняет статусы версий; загрузка в консолидацию — требование «Загрузка файлов в
|
||||
консолидацию».
|
||||
|
||||
#### Scenario: Реальная ОСВ принята и ушла в outbox
|
||||
- **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала
|
||||
- **THEN** 201, `accepted[0].file_id` существует; `stored_file.status = UPLOADED`, `file_type = XLS`, `content_hash` =
|
||||
sha256 файла, `consolidation_id IS NULL`; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу
|
||||
`uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`; публикатор outbox публикует запрос с этим ключом
|
||||
|
||||
#### Scenario: Смесь принятых и отклонённых
|
||||
- **WHEN** в одном запросе xlsx, файл `.docx` и файл 51 МиБ
|
||||
- **THEN** 201, `accepted` — один, `rejected` — два с кодами `UNSUPPORTED_FILE` и `FILE_TOO_LARGE`
|
||||
|
||||
#### Scenario: Расширение не совпадает с содержимым
|
||||
- **WHEN** загружается файл `osv.xlsx`, байты которого — BIFF8
|
||||
- **THEN** файл отклонён с кодом `UNSUPPORTED_FILE`
|
||||
|
||||
#### Scenario: Чужое ЮЛ
|
||||
- **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала
|
||||
- **THEN** 404, объектов и записей нет
|
||||
|
||||
#### Scenario: Загрузка в контексте версии
|
||||
- **WHEN** у ГК ЮЛ есть версия `INITIATED` с этим ЮЛ в периметре, файл загружен маршрутом ЮЛ с query
|
||||
`consolidation_version_id` (как делал клиент до v5.18)
|
||||
- **THEN** 201, параметр игнорируется; `stored_file.consolidation_id IS NULL`; версия остаётся `INITIATED`
|
||||
|
||||
#### Scenario: ЮЛ вне периметра
|
||||
- **WHEN** ЮЛ не входит в периметр ни одной версии, файл загружен маршрутом ЮЛ
|
||||
- **THEN** 201 — маршрут ЮЛ периметр не проверяет; проверка периметра — только у загрузки в консолидацию
|
||||
|
||||
### Requirement: Автозамена прежних версий
|
||||
После успешного маппинга файла ядро SHALL найти другие файлы того же ЮЛ со статусом не `SUPERSEDED`, у которых есть
|
||||
документ с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и перевести их в
|
||||
`SUPERSEDED` (`superseded_by_file_id` = новый файл, `updated_at`); новый файл получает `version_no` = максимум среди
|
||||
заменённых + 1. Если у нового файла `consolidation_id IS NULL`, он SHALL получить `consolidation_id` первого по `id`
|
||||
заменённого файла, у которого он задан (v5.18); файл, загруженный в консолидацию, сохраняет свой `consolidation_id`.
|
||||
Документы и строки заменённых файлов SHALL сохраняться (история), но по умолчанию не показываться.
|
||||
Привязки `consolidation_document` документов заменённых файлов к версиям «в работе» SHALL удаляться в той же
|
||||
транзакции (версии блокируются `FOR UPDATE` по возрастанию `id` после старых файлов); привязки к версиям в других
|
||||
статусах не трогаются; статус версии не меняется. Аудит `FILE_REPLACE` (`object_id` заменённого, `after_value` с
|
||||
новым файлом и `unbound_version_ids`, в `description` — перечень заменённых документов и признак `confirmed`, если
|
||||
заменён документ `CONFIRMED`). Решение владельца 05.10.2026: замена происходит и для `CONFIRMED`.
|
||||
Замена остаётся на уровне файла (OQ-115), но если у заменённого файла есть документ, ключа которого
|
||||
(`document_type`, `osv_account_code`, `period_year`, `period_quarter`) нет среди документов нового файла (в т. ч. документ
|
||||
без года или квартала), ядро SHALL добавить в `metrics.mapping_warnings` задания нового файла (карточка файла,
|
||||
`JobBrief.mapping_warnings`) по одному предупреждению на заменённый файл (OQ-154): имя и версия заменённого файла,
|
||||
выпавшие документы (тип, счёт, период, статус — в т. ч. `CONFIRMED`) и версии консолидации «в работе», с которых сняты
|
||||
привязки этих документов. Поведение замены и статусы документов нового файла от предупреждения не меняются.
|
||||
|
||||
#### Scenario: Новая ОСВ 60 за 1 кв. 2025
|
||||
- **WHEN** у ЮЛ есть `PROCESSED` файл с документом OSV 60 / 2025 / Q1 (`CONFIRMED`), загружен и смапплен новый файл с таким же документом
|
||||
- **THEN** прежний файл — `SUPERSEDED`, `superseded_by_file_id` = новый, у нового `version_no = 2`; `audit_event(FILE_REPLACE)`
|
||||
с `description`, содержащим `confirmed=true`; список файлов ЮЛ по умолчанию показывает только новый
|
||||
|
||||
#### Scenario: Заменённый документ был в версии
|
||||
- **WHEN** заменённый `CONFIRMED` документ ОСВ 60 за 2024 Q2 был привязан к версии 2024 Q3 в `DATA_CORRECTION`
|
||||
- **THEN** привязка удалена; в чек-листе ячейка ОСВ 60 показывает `missing_quarters = ["Q2"]` (или `NEEDS_CHECK`
|
||||
по новому документу), пока новый документ не подтверждён; статус версии прежний
|
||||
|
||||
#### Scenario: Отдельный ОФР заменил книгу «ББ + ОФР»
|
||||
- **WHEN** у ЮЛ есть файл с `CONFIRMED` ББ и ОФР за Q3 2025, ББ привязан к версии 2025 Q3 в `DATA_CORRECTION`; смаплен
|
||||
новый файл только с ОФР за Q3 2025
|
||||
- **THEN** прежний файл — `SUPERSEDED`, привязка ББ снята; в `metrics.mapping_warnings` нового задания одно предупреждение:
|
||||
имя прежнего файла, `BALANCE 2025 Q3 (CONFIRMED)` и версия «ГК» 2025 Q3, версия 1; ОФР в предупреждении нет
|
||||
|
||||
#### Scenario: Новый файл без контекста наследует консолидацию
|
||||
- **WHEN** прежний файл загружен в консолидацию 2025 Q1 (`consolidation_id = C`), новый файл с тем же документом загружен
|
||||
маршрутом ЮЛ (`consolidation_id IS NULL`) и смаплен
|
||||
- **THEN** у нового файла `consolidation_id = C`
|
||||
|
||||
#### Scenario: Новый файл с контекстом сохраняет свой
|
||||
- **WHEN** прежний файл в консолидации 2025 Q1 (`C1`), новый с тем же документом загружен в консолидацию 2025 Q2 (`C2`)
|
||||
- **THEN** у нового файла `consolidation_id = C2`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Загрузка файлов в консолидацию
|
||||
`POST /api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` (multipart `files`, право
|
||||
`DOCUMENT_EDIT`; §9.5 п. 8 v5.18) SHALL до приёма файлов проверить: консолидация видима (ГК в области пользователя;
|
||||
чужая и несуществующая — одинаковый 404 `NOT_FOUND` «консолидация не найдена»), ЮЛ видимо (404), у консолидации есть
|
||||
хотя бы одна версия со статусом «в работе» (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`; иначе 409
|
||||
`CONSOLIDATION_NOT_EDITABLE`), ЮЛ — член периметра хотя бы одной такой версии (иначе 422 `ENTITY_NOT_IN_PERIMETER`
|
||||
объектом `detail`). Приём файлов, дедуп, ответ и коды `rejected[]` — как у маршрута ЮЛ; принятый файл SHALL получить
|
||||
`stored_file.consolidation_id = consolidation_id`, `FILE_UPLOAD.after_value.consolidation_id` = его строка. Если принят
|
||||
хотя бы один файл, ядро SHALL в отдельной транзакции после приёма заблокировать `FOR UPDATE ORDER BY id` версии этой
|
||||
консолидации со статусом `INITIATED` и ЮЛ в периметре и перевести каждую в `DOCUMENTS_PROCESSING` (`changed_by` =
|
||||
загрузивший, запись `consolidation_status_history` и `CONSOLIDATION_STATUS_CHANGE`); сбой перехода логируется и не
|
||||
меняет ответ. Блокировка версии (`consolidation_lock`) для загрузки MUST NOT требоваться.
|
||||
|
||||
#### Scenario: Загрузка в консолидацию переводит версию
|
||||
- **WHEN** у консолидации 2024 Q3 версия 1 в `INITIATED`, ЮЛ в периметре, загружен один принятый файл
|
||||
- **THEN** 201; `stored_file.consolidation_id` = консолидация; версия в `DOCUMENTS_PROCESSING`, история с `changed_by`
|
||||
= пользователь; `FILE_UPLOAD.after_value.consolidation_id` = id консолидации
|
||||
|
||||
#### Scenario: Версия в другом рабочем статусе
|
||||
- **WHEN** единственная версия консолидации в `DATA_CORRECTION`, загружен принятый файл
|
||||
- **THEN** 201; статус версии не меняется; файл получил `consolidation_id`
|
||||
|
||||
#### Scenario: Нет версии в работе
|
||||
- **WHEN** единственная версия консолидации `CANCELLED`
|
||||
- **THEN** 409 `CONSOLIDATION_NOT_EDITABLE`, файлы не приняты, объектов в S3 нет
|
||||
|
||||
#### Scenario: ЮЛ вне периметра
|
||||
- **WHEN** ЮЛ той же ГК не входит в периметр ни одной версии «в работе»
|
||||
- **THEN** 422 `ENTITY_NOT_IN_PERIMETER`, файлы не приняты
|
||||
|
||||
#### Scenario: Чужая и несуществующая консолидация
|
||||
- **WHEN** `ka_rf` загружает файл в консолидацию ГК другого филиала и в несуществующий id
|
||||
- **THEN** оба ответа 404 с одинаковым телом (`code = NOT_FOUND`), записей нет
|
||||
|
||||
#### Scenario: Все файлы отклонены
|
||||
- **WHEN** в консолидацию с версией `INITIATED` загружен только `.docx`
|
||||
- **THEN** 422 телом `UploadResult`, версия остаётся `INITIATED`
|
||||
|
||||
#### Scenario: Загрузка не требует блокировки версии
|
||||
- **WHEN** версию держит другой пользователь (`consolidation_lock`), ЮЛ в периметре
|
||||
- **THEN** 201; версия `INITIATED` → `DOCUMENTS_PROCESSING`
|
||||
Loading…
x
Reference in New Issue
Block a user