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:
parent
5899525ca5
commit
4abfa03539
@ -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,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 значениями не являются.
|
||||
|
||||
@ -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` создаёт
|
||||
|
||||
@ -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) и «требует проверки» не хранятся — считаются на лету.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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` события
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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' "
|
||||
|
||||
@ -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`
|
||||
Loading…
x
Reference in New Issue
Block a user