diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index b890f93..6b41290 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -660,8 +660,10 @@ 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`, + `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. @@ -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), а не только у diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 48ac9bb..8c25454 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -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` — оба документа."""