mapping: новый файл без контекста наследует consolidation_id заменённого (#96)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-10 01:45:31 +04:00
parent 1e888d4670
commit 25307edee8
2 changed files with 91 additions and 4 deletions

View File

@ -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), а не только у

View File

@ -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` — оба документа."""