fix-волна ревью: delta статусов, блокировка версий консолидации, тесты (#96)

I-2: delta MODIFIED consolidations-api «Статусы Среза 4» (загрузка в консолидацию).
T1.1: шапки моделей — DBML v5.18.
T3.1: lock_versions_of_consolidation — двухшаговая блокировка (FOR UPDATE без членства, затем выборка членов);
тесты гонки и выборки.
M-1: тест миграции 0006 — порядок кварталов внутри года.
M-2: тест 404 для ЮЛ чужого филиала на столе своей консолидации.
T4.1: docstring supersede_previous — перенос строк.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-10 02:23:09 +04:00
parent 5899525ca5
commit 4abfa03539
15 changed files with 159 additions and 33 deletions

View File

@ -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`

View File

@ -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

View File

@ -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).

View File

@ -1,4 +1,4 @@
"""Часть 01 DBML v5.17 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
"""Часть 01 DBML v5.18 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
import datetime
import uuid

View File

@ -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 значениями не являются.

View File

@ -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` создаёт

View File

@ -1,4 +1,4 @@
"""Часть 08 DBML v5.17 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
"""Часть 08 DBML v5.18 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО
замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`.

View File

@ -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)).

View File

@ -1,4 +1,4 @@
"""Часть 06 DBML v5.17 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
"""Часть 06 DBML v5.18 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением.
Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету.

View File

@ -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:

View File

@ -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` события

View File

@ -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:

View File

@ -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],

View File

@ -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,10 +574,11 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N
),
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_a)"
f"VALUES (gen_random_uuid(), :{v}, :{doc})"
),
ids,
)
@ -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' "

View File

@ -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`