diff --git a/api/src/models/audit_ex.py b/api/src/models/audit_ex.py index f9de8a7..4bf7804 100644 --- a/api/src/models/audit_ex.py +++ b/api/src/models/audit_ex.py @@ -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` diff --git a/api/src/models/base.py b/api/src/models/base.py index 762e0f9..75a8b49 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -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 diff --git a/api/src/models/consolidation.py b/api/src/models/consolidation.py index 45941ac..79c99a6 100644 --- a/api/src/models/consolidation.py +++ b/api/src/models/consolidation.py @@ -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). diff --git a/api/src/models/directories.py b/api/src/models/directories.py index 64e18a1..7c64d69 100644 --- a/api/src/models/directories.py +++ b/api/src/models/directories.py @@ -1,4 +1,4 @@ -"""Часть 01 DBML v5.17 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" +"""Часть 01 DBML v5.18 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" import datetime import uuid diff --git a/api/src/models/enums.py b/api/src/models/enums.py index c533d8f..7279617 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -1,4 +1,4 @@ -"""Enum'ы модели данных DBML v5.17 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. +"""Enum'ы модели данных DBML v5.18 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. Источник истины — docs/model/data-model-v5.18.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. diff --git a/api/src/models/files.py b/api/src/models/files.py index 13cd93e..42a2e25 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -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` создаёт diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index a425e66..5878668 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -1,4 +1,4 @@ -"""Часть 08 DBML v5.17 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. +"""Часть 08 DBML v5.18 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`. diff --git a/api/src/models/osv.py b/api/src/models/osv.py index 2fc38f0..1352085 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -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)). diff --git a/api/src/models/statements.py b/api/src/models/statements.py index b82870f..2a1d04d 100644 --- a/api/src/models/statements.py +++ b/api/src/models/statements.py @@ -1,4 +1,4 @@ -"""Часть 06 DBML v5.17 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). +"""Часть 06 DBML v5.18 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением. Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету. diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 75fb746..8ddc373 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -196,23 +196,36 @@ async def lock_versions_of_consolidation( statuses: Collection[ConsolidationStatus], ) -> list[ConsolidationVersion]: """Версии консолидации со статусом из `statuses`, где ЮЛ — член периметра, под `FOR UPDATE ORDER BY id` (R-2; - design D-4: переход после загрузки в консолидацию). `populate_existing` — свежий статус из БД.""" + 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, ) - stmt = ( + members_only = ( select(ConsolidationVersion) - .where( - ConsolidationVersion.consolidation_id == consolidation_id, - ConsolidationVersion.status.in_(statuses), - is_member, - ) + .where(ConsolidationVersion.id.in_(locked_ids), is_member) .order_by(ConsolidationVersion.id) - .with_for_update() .execution_options(populate_existing=True) ) - return list((await session.execute(stmt)).scalars()) + return list((await session.execute(members_only)).scalars()) async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationLock | None: diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 6b41290..61bdf40 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -663,9 +663,9 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена `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. + загрузки — в `description`, `confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение + владельца 05.10.2026). Документы и строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; + в транзакции вызывающего, без commit. Привязки документов заменённых файлов к версиям консолидации «в работе» снимаются (`consolidation_documents.on_documents_superseded`); id версий — в `after_value.unbound_version_ids` события diff --git a/api/tests/test_api_consolidation_files.py b/api/tests/test_api_consolidation_files.py index 8abe4a4..d7e7487 100644 --- a/api/tests/test_api_consolidation_files.py +++ b/api/tests/test_api_consolidation_files.py @@ -160,15 +160,18 @@ async def test_desk_foreign_and_missing_consolidation_are_same_404( """Сценарий «Чужая консолидация»: ГК другого филиала и несуществующий id — одинаковый 404; чужое ЮЛ — тоже 404.""" group, entity, initiator = await _group(seeded, office_name=KRASNODAR, name="ГК Чужая") c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1) - _, own_entity, _ = await _group(seeded, name="ГК Своя") + 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 == HTTP_NOT_FOUND + 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: diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index 68d45bb..ce17c20 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -1155,6 +1155,70 @@ 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], diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 882f681..020b0fb 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -467,7 +467,8 @@ async def _consolidation_id_column(conn: AsyncConnection) -> list[Any]: 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; `downgrade 0005` + 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) @@ -480,12 +481,18 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N "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", ) } @@ -515,7 +522,7 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N ), ids, ) - for s3, file in (("s3a", "file_a"), ("s3b", "file_b")): + 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) " @@ -538,8 +545,18 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N ), 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( @@ -557,13 +574,14 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N ), ids, ) - await conn.execute( - text( - "INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) " - f"VALUES (gen_random_uuid(), :{v}, :doc_a)" - ), - 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: @@ -575,7 +593,7 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N ) 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} + 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' " diff --git a/openspec/changes/console-files/specs/consolidations-api/spec.md b/openspec/changes/console-files/specs/consolidations-api/spec.md new file mode 100644 index 0000000..f703299 --- /dev/null +++ b/openspec/changes/console-files/specs/consolidations-api/spec.md @@ -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`