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 на
|
`audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на
|
||||||
консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id`
|
консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `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
|
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).
|
#: Читается при импорте: MetaData, enum и sequence привязываются к схеме сразу (см. R-1 PR #38).
|
||||||
SCHEMA = settings.DB_SCHEMA
|
SCHEMA = settings.DB_SCHEMA
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Часть 07 DBML v5.17 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы.
|
"""Часть 07 DBML v5.18 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы.
|
||||||
|
|
||||||
Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из
|
Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из
|
||||||
`osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10).
|
`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 datetime
|
||||||
import uuid
|
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 буквально, в порядке
|
Источник истины — docs/model/data-model-v5.18.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
|
||||||
DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` 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`
|
Цикл 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` создаёт
|
разорван через `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). Результат КФО
|
Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО
|
||||||
замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`.
|
замораживается при 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)).
|
(`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` — рядом с распознанным значением.
|
Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением.
|
||||||
Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету.
|
Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету.
|
||||||
|
|||||||
@ -196,23 +196,36 @@ async def lock_versions_of_consolidation(
|
|||||||
statuses: Collection[ConsolidationStatus],
|
statuses: Collection[ConsolidationStatus],
|
||||||
) -> list[ConsolidationVersion]:
|
) -> list[ConsolidationVersion]:
|
||||||
"""Версии консолидации со статусом из `statuses`, где ЮЛ — член периметра, под `FOR UPDATE ORDER BY id` (R-2;
|
"""Версии консолидации со статусом из `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(
|
is_member = exists().where(
|
||||||
ConsolidationMember.consolidation_version_id == ConsolidationVersion.id,
|
ConsolidationMember.consolidation_version_id == ConsolidationVersion.id,
|
||||||
ConsolidationMember.legal_entity_id == entity_id,
|
ConsolidationMember.legal_entity_id == entity_id,
|
||||||
)
|
)
|
||||||
stmt = (
|
members_only = (
|
||||||
select(ConsolidationVersion)
|
select(ConsolidationVersion)
|
||||||
.where(
|
.where(ConsolidationVersion.id.in_(locked_ids), is_member)
|
||||||
ConsolidationVersion.consolidation_id == consolidation_id,
|
|
||||||
ConsolidationVersion.status.in_(statuses),
|
|
||||||
is_member,
|
|
||||||
)
|
|
||||||
.order_by(ConsolidationVersion.id)
|
.order_by(ConsolidationVersion.id)
|
||||||
.with_for_update()
|
|
||||||
.execution_options(populate_existing=True)
|
.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:
|
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; файл без
|
`superseded_by_file_id = new_file.id`; у нового файла `version_no` = максимум заменённых + 1; файл без
|
||||||
консолидации (`consolidation_id` пуст) наследует её у первого по `id` заменённого файла, где она задана (v5.18,
|
консолидации (`consolidation_id` пуст) наследует её у первого по `id` заменённого файла, где она задана (v5.18,
|
||||||
§9.5 п. 3). На каждый заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор
|
§9.5 п. 3). На каждый заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор
|
||||||
загрузки — в `description`,
|
загрузки — в `description`, `confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение
|
||||||
`confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение владельца 05.10.2026). Документы и
|
владельца 05.10.2026). Документы и строки заменённых файлов не трогаются (история). Возвращает заменённые файлы;
|
||||||
строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; в транзакции вызывающего, без commit.
|
в транзакции вызывающего, без commit.
|
||||||
|
|
||||||
Привязки документов заменённых файлов к версиям консолидации «в работе» снимаются
|
Привязки документов заменённых файлов к версиям консолидации «в работе» снимаются
|
||||||
(`consolidation_documents.on_documents_superseded`); id версий — в `after_value.unbound_version_ids` события
|
(`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."""
|
"""Сценарий «Чужая консолидация»: ГК другого филиала и несуществующий id — одинаковый 404; чужое ЮЛ — тоже 404."""
|
||||||
group, entity, initiator = await _group(seeded, office_name=KRASNODAR, name="ГК Чужая")
|
group, entity, initiator = await _group(seeded, office_name=KRASNODAR, name="ГК Чужая")
|
||||||
c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
|
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)
|
client = await auth_client(KA_RF)
|
||||||
|
|
||||||
foreign = await client.get(_desk_url(c.id, own_entity.id))
|
foreign = await client.get(_desk_url(c.id, own_entity.id))
|
||||||
missing = await client.get(_desk_url(uuid.uuid4(), 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() == missing.json()
|
||||||
assert foreign.json()["detail"]["code"] == "NOT_FOUND"
|
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:
|
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
|
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
|
@pytest.mark.integration
|
||||||
async def test_on_documents_mapped_waits_for_transition_to_documents_processing(
|
async def test_on_documents_mapped_waits_for_transition_to_documents_processing(
|
||||||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
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:
|
async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> None:
|
||||||
"""0006: `stored_file.consolidation_id uuid` nullable, последняя колонка, индекс
|
"""0006: `stored_file.consolidation_id uuid` nullable, последняя колонка, индекс
|
||||||
`(legal_entity_id, consolidation_id)`; backfill по `consolidation_document`: файл A привязан к версиям 2025 Q1 и
|
`(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 возвращает."""
|
убирает индекс и колонку, повторный upgrade возвращает."""
|
||||||
engine = create_async_engine(scratch_db_url)
|
engine = create_async_engine(scratch_db_url)
|
||||||
now = datetime.datetime.now(datetime.UTC)
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
@ -480,12 +481,18 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N
|
|||||||
"user",
|
"user",
|
||||||
"s3a",
|
"s3a",
|
||||||
"s3b",
|
"s3b",
|
||||||
|
"s3c",
|
||||||
"file_a",
|
"file_a",
|
||||||
"file_b",
|
"file_b",
|
||||||
|
"file_c",
|
||||||
"doc_a",
|
"doc_a",
|
||||||
|
"doc_c1",
|
||||||
|
"doc_c2",
|
||||||
"c_2025q1",
|
"c_2025q1",
|
||||||
|
"c_2025q2",
|
||||||
"c_2024q4",
|
"c_2024q4",
|
||||||
"v_2025q1",
|
"v_2025q1",
|
||||||
|
"v_2025q2",
|
||||||
"v_2024q4",
|
"v_2024q4",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -515,7 +522,7 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N
|
|||||||
),
|
),
|
||||||
ids,
|
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(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO dkb.s3_object (id, bucket_role, bucket, key, created_at) "
|
"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,
|
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 (
|
for c, v, year, quarter, rd in (
|
||||||
("c_2025q1", "v_2025q1", 2025, "Q1", "2025-04-01"),
|
("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"),
|
("c_2024q4", "v_2024q4", 2024, "Q4", "2025-01-01"),
|
||||||
):
|
):
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@ -557,13 +574,14 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N
|
|||||||
),
|
),
|
||||||
ids,
|
ids,
|
||||||
)
|
)
|
||||||
await conn.execute(
|
for doc in {"v_2025q1": ("doc_a", "doc_c2"), "v_2025q2": ("doc_c1",), "v_2024q4": ("doc_a",)}[v]:
|
||||||
text(
|
await conn.execute(
|
||||||
"INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) "
|
text(
|
||||||
f"VALUES (gen_random_uuid(), :{v}, :doc_a)"
|
"INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) "
|
||||||
),
|
f"VALUES (gen_random_uuid(), :{v}, :{doc})"
|
||||||
ids,
|
),
|
||||||
)
|
ids,
|
||||||
|
)
|
||||||
|
|
||||||
await alembic(scratch_db_url, "upgrade", "head")
|
await alembic(scratch_db_url, "upgrade", "head")
|
||||||
async with engine.begin() as conn:
|
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"]
|
assert last == ["consolidation_id"]
|
||||||
rows = (await conn.execute(text("SELECT id, consolidation_id FROM dkb.stored_file ORDER BY id"))).all()
|
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(
|
assert await _scalars(
|
||||||
conn,
|
conn,
|
||||||
"SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' "
|
"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