"""Сервис консолидаций ГК: создание, список периодов, карточка версии (spec consolidations-api «Создание консолидации», «Список консолидаций ГК и карточка версии»; directory-policy «Видимость консолидаций»; audit-trail `CONSOLIDATION_CREATE`; design D-15, D-16). Порядок операции: право (`require` на маршруте) → видимость (ГК/версия — через ГК, невидимое = несуществующее, 404 с тем же телом) → проверки → запись. Перимeтр версии при создании — текущие члены ГСК или выбранные ЮЛ ГК (`legal_entity_ids`, front-api-gaps D-1), `is_head_entity = false`; ГСК в `consolidation.related_group_id` не сохраняется (фиксируется при верификации, Срез 7). Блокировка на создателя не ставится. Параллельное создание того же периода ловит UNIQUE `uq_consolidation_group_year_quarter` → `IntegrityError` → 409 `CONSOLIDATION_EXISTS` (`common/constraint_conflicts.py`). """ import uuid from collections.abc import Sequence from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError from src.domain import checklist as checklist_domain from src.domain.consolidation_periods import quarters_through, reporting_date from src.domain.policy import FINISHED_STATUSES, VisibilityScope from src.models.access import AppUser from src.models.consolidation import ( Consolidation, ConsolidationLock, ConsolidationMember, ConsolidationStatusHistory, ConsolidationVersion, ) from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, AuditSection, ConsolidationApproach, ConsolidationScenario, ConsolidationStatus, RelatedGroupKind, ) from src.repositories import consolidations as repo from src.repositories import directories as directories_repo from src.repositories.users import UserWithAccess from src.schemas.consolidations import ( ChecklistCellOut, ChecklistOut, ChecklistRowOut, ChecklistRuleOut, ConsolidationCreate, ConsolidationPeriodOut, HistoryOut, LockOut, MemberOut, MembersIn, RefOut, VersionBrief, VersionCard, ) from src.services import consolidation_documents from src.services.company_groups import COMPANY_GROUP_NOT_FOUND from src.services.consolidation_guard import VERSION_NOT_FOUND, lock_editable_version from src.services.related_groups import RELATED_GROUP_NOT_FOUND OBJECT_TYPE = "consolidation_version" MIN_PERIMETER = 2 # в периметре консолидации не меньше двух ЮЛ (иначе нечего сопоставлять) FIRST_VERSION_NO = 1 def lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut: """Активная блокировка в ответе API (список, карточка, `POST …/lock`).""" return LockOut( locked_by=lock.locked_by, locked_by_name=locked_by_name, locked_at=lock.locked_at, taken_over_from=lock.taken_over_from, takeover_reason=lock.takeover_reason, ) class ConsolidationService: """Консолидации ГК от имени текущего пользователя в его области видимости.""" def __init__( self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, principal: UserWithAccess ) -> None: self._session = session self._audit = audit self._scope = scope self._principal = principal async def create(self, data: ConsolidationCreate) -> VersionCard: """Консолидация ГК за период с версией №1 (`INITIATED`), периметром (ГСК или выбранные ЮЛ), историей и аудитом. Проверки по порядку: ГК видима (404) → ГСК видима (404), принадлежит ГК и `kind = gsk` (422 `RELATED_GROUP_MISMATCH`) → периметр: без `legal_entity_ids` — текущие члены ГСК, не меньше двух (422 `PERIMETER_TOO_SMALL`); со списком — `_validate_perimeter` (422 `ENTITY_NOT_IN_GROUP` / `PERIMETER_TOO_SMALL`) → периода ещё нет (409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка откатывает всё. После проверки ГК берётся advisory-блокировка привязок ГК (R-2): подхват документов (`bind_confirmed`) не разминётся с параллельным подтверждением или автозаменой документа ЮЛ этой ГК. """ group = await directories_repo.get_company_group(self._session, self._scope, data.company_group_id) if group is None: raise NotFoundError(COMPANY_GROUP_NOT_FOUND) # R-2 (design №30): до чтения подтверждённых документов — подтверждение и автозамена документов ЮЛ ГК ждут нас # (новая версия им невидима до commit), а мы — их await repo.lock_group_bindings(self._session, group.id) related = await directories_repo.get_related_group(self._session, self._scope, data.related_group_id) if related is None: raise NotFoundError(RELATED_GROUP_NOT_FOUND) if related.kind != RelatedGroupKind.gsk or related.company_group_id != group.id: raise DomainValidationError("RELATED_GROUP_MISMATCH", "ГСК должна принадлежать выбранной ГК") if data.legal_entity_ids is None: entity_ids = [entity.id for _, entity in await directories_repo.current_members(self._session, related.id)] if len(entity_ids) < MIN_PERIMETER: raise DomainValidationError( "PERIMETER_TOO_SMALL", f"В ГСК должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(entity_ids)}" ) else: entity_ids = await self._validate_perimeter(group.id, data.legal_entity_ids) if await repo.get_consolidation_for_group(self._session, self._scope, group.id, data.year, data.quarter): raise ConflictError("CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана") consolidation, version = await self._insert(group.id, data, entity_ids) await self._audit.record( action=AuditAction.CONSOLIDATION_CREATE, section=AuditSection.CONSOLIDATION, object_type=OBJECT_TYPE, object_id=version.id, consolidation_version_id=version.id, after_value={ "consolidation_id": consolidation.id, "year": consolidation.year, "quarter": consolidation.quarter, "members": sorted(entity_ids), }, ) return await self._card(version, consolidation) async def list_periods(self, group_id: uuid.UUID, *, in_work_only: bool) -> list[ConsolidationPeriodOut]: """Периоды ГК по убыванию года и квартала, в каждом — версии по убыванию `version_no`. `in_work_only` оставляет версии со статусом не из {`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`}; период без оставшихся версий в ответ не попадает. ГК вне видимости и несуществующая — один и тот же 404. """ if await directories_repo.get_company_group(self._session, self._scope, group_id) is None: raise NotFoundError(COMPANY_GROUP_NOT_FOUND) periods = await repo.list_periods(self._session, self._scope, group_id) if in_work_only: periods = [ (consolidation, [v for v in versions if v.status not in FINISHED_STATUSES]) for consolidation, versions in periods ] periods = [(consolidation, versions) for consolidation, versions in periods if versions] version_ids = [version.id for _, versions in periods for version in versions] locks = await repo.active_locks(self._session, version_ids) counts = await repo.members_counts(self._session, version_ids) return [ ConsolidationPeriodOut( consolidation_id=consolidation.id, year=consolidation.year, quarter=consolidation.quarter, reporting_date=consolidation.reporting_date, versions=[self._brief(version, locks.get(version.id), counts[version.id]) for version in versions], ) for consolidation, versions in periods ] async def card(self, version_id: uuid.UUID) -> VersionCard: """Карточка версии; версия ГК вне видимости и несуществующая — один и тот же 404.""" found = await repo.get_version(self._session, self._scope, version_id) if found is None: raise NotFoundError(VERSION_NOT_FOUND) return await self._card(*found) async def checklist(self, version_id: uuid.UUID) -> ChecklistOut: """Чек-лист комплекта версии: правила подхода (`BALANCE`, `INCOME`, ОСВ по коду счёта) и строки по ЮЛ периметра (по имени) с ячейками в порядке правил; версия вне видимости и несуществующая — один и тот же 404.""" found = await repo.get_version(self._session, self._scope, version_id) if found is None: raise NotFoundError(VERSION_NOT_FOUND) version, consolidation = found entities = [entity for _, entity in await repo.members(self._session, version.id)] rules, rows = await self._checklist_cells(version, consolidation, entities) return ChecklistOut( rules=[ ChecklistRuleOut(document_type=rule.document_type, account=rule.account, is_mandatory=rule.is_mandatory) for rule in rules ], rows=[ ChecklistRowOut( legal_entity_id=entity.id, legal_entity_name=entity.name, legal_entity_inn=entity.inn, cells=[self._cell_out(rule, cell) for rule, cell in zip(rules, cells, strict=True)], ) for entity, cells in rows ], ) async def version_title(self, version_id: uuid.UUID) -> tuple[str, int, str]: """Название ГК, год и квартал версии для имени файла экспорта (CSV чек-листа). Версия вне видимости и несуществующая — один и тот же 404 как в `checklist`. """ found = await repo.get_version(self._session, self._scope, version_id) if found is None: raise NotFoundError(VERSION_NOT_FOUND) version, consolidation = found group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id) if group is None: raise NotFoundError(VERSION_NOT_FOUND) return group.name, consolidation.year, consolidation.quarter.value async def _checklist_cells( self, version: ConsolidationVersion, consolidation: Consolidation, entities: list[LegalEntity] ) -> tuple[list[checklist_domain.Rule], list[tuple[LegalEntity, list[checklist_domain.Cell]]]]: """Правила подхода версии и ячейки по каждому ЮЛ `entities` (D-13): привязанные документы версии, неподтверждённые подходящие (`fits`) и состояние файлов ЮЛ. Кандидаты — `repo.unconfirmed_documents`, фильтр `fits` — здесь.""" rules = checklist_domain.order_rules( checklist_domain.Rule(rule.document_type, account, rule.is_mandatory) for rule, account in await repo.rules_for(self._session, version.approach) ) entity_ids = [entity.id for entity in entities] candidates = await consolidation_documents.for_consolidation( self._session, await repo.unconfirmed_documents( self._session, entity_ids, consolidation.year, self._period_from_version(consolidation) ), consolidation, ) unbound = [doc for doc in candidates if consolidation_documents.fits(doc, version, consolidation, entity_ids)] rows = checklist_domain.build( rules, version_quarter=consolidation.quarter, members=entities, bound=await consolidation_documents.for_consolidation( self._session, await repo.bound_documents(self._session, version.id), consolidation ), unbound=unbound, files_state=await repo.entity_files_state(self._session, entity_ids), ) return rules, rows @staticmethod def _period_from_version(consolidation: Consolidation) -> uuid.UUID | None: """`consolidation_id` для выборок кандидатов в режиме «период из версии» (arm-core#103); иначе `None`.""" return consolidation.id if consolidation_documents.period_from_version() else None @staticmethod def _cell_out(rule: checklist_domain.Rule, cell: checklist_domain.Cell) -> ChecklistCellOut: """Ячейка ответа с правилом (D-2); `rule` — правило того же столбца, что и `cell`.""" return ChecklistCellOut( document_type=rule.document_type, account=rule.account, status=cell.status, missing_quarters=list(cell.missing_quarters), document_ids=list(cell.document_ids), ) async def set_members(self, version_id: uuid.UUID, data: MembersIn) -> VersionCard: """Заменяет периметр версии списком `data.legal_entity_ids`; ответ — карточка версии. Порядок: `FOR UPDATE` версии → видимость (404) → редактируемость и своя блокировка (409, D-8, D-9) → дубли схлопываются → все ЮЛ существуют и принадлежат ГК консолидации (422 `ENTITY_NOT_IN_GROUP`, `legal_entity_ids` — в порядке запроса) → не меньше двух (422 `PERIMETER_TOO_SMALL`) → diff: ушедшие теряют `consolidation_member` и привязки документов (`unbind_entity`), пришедшие получают член (`is_head_entity = false`) и подхват подтверждённых документов (`bind_confirmed`); оставшиеся не трогаются → `MEMBERSHIP_CHANGE` с полными отсортированными списками ЮЛ до/после. Состав не изменился — 200 без записи и без события. `related_group_member` не меняется никогда. """ user = self._principal.user version, consolidation = await lock_editable_version(self._session, self._scope, version_id, user.id) requested = await self._validate_perimeter(consolidation.company_group_id, data.legal_entity_ids) current = {member.legal_entity_id: member for member, _ in await repo.members(self._session, version.id)} added = [entity_id for entity_id in requested if entity_id not in current] removed = [entity_id for entity_id in current if entity_id not in set(requested)] if not added and not removed: return await self._card(version, consolidation) for entity_id in removed: await self._session.delete(current[entity_id]) self._session.add_all( ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity_id, is_head_entity=False) for entity_id in added ) version.updated_by = user.id version.updated_at = utc_now() await self._session.flush() for entity_id in removed: await consolidation_documents.unbind_entity(self._session, version_id=version.id, entity_id=entity_id) if added: await consolidation_documents.bind_confirmed( self._session, version=version, consolidation=consolidation, entity_ids=added ) await self._audit.record( action=AuditAction.MEMBERSHIP_CHANGE, section=AuditSection.CONSOLIDATION, object_type=OBJECT_TYPE, object_id=version.id, consolidation_version_id=version.id, before_value={"legal_entity_ids": sorted(current)}, after_value={"legal_entity_ids": sorted(requested)}, ) return await self._card(version, consolidation) async def _validate_perimeter( self, company_group_id: uuid.UUID, entity_ids: Sequence[uuid.UUID] ) -> list[uuid.UUID]: """Периметр консолидации — одно правило для создания и `PUT …/members` (front-api-gaps D-1): дубли схлопываются в порядке первого вхождения → все ЮЛ существуют и принадлежат ГК (422 `ENTITY_NOT_IN_GROUP`, `legal_entity_ids` — чужие в порядке запроса) → не меньше двух (422 `PERIMETER_TOO_SMALL`).""" requested = list(dict.fromkeys(entity_ids)) valid = await repo.entities_in_group(self._session, company_group_id, requested) foreign = [entity_id for entity_id in requested if entity_id not in valid] if foreign: raise DomainValidationError( "ENTITY_NOT_IN_GROUP", "ЮЛ не принадлежат ГК консолидации", legal_entity_ids=foreign ) if len(requested) < MIN_PERIMETER: raise DomainValidationError( "PERIMETER_TOO_SMALL", f"В периметре должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(requested)}" ) return requested async def _insert( self, group_id: uuid.UUID, data: ConsolidationCreate, entity_ids: list[uuid.UUID] ) -> tuple[Consolidation, ConsolidationVersion]: """Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы.""" now = utc_now() user = self._principal.user consolidation = Consolidation( company_group_id=group_id, year=data.year, quarter=data.quarter, reporting_date=reporting_date(data.year, data.quarter), created_at=now, ) self._session.add(consolidation) await self._session.flush() # гонку за (ГК, год, квартал) ловит UNIQUE → IntegrityError → 409 version = ConsolidationVersion( consolidation_id=consolidation.id, version_no=FIRST_VERSION_NO, status=ConsolidationStatus.INITIATED, scenario=ConsolidationScenario.CREATE, approach=ConsolidationApproach.BASIC, goodwill_enabled=False, initiator_id=user.id, initiator_office_id=user.regional_office_id, is_in_work=True, created_at=now, created_by=user.id, ) self._session.add(version) await self._session.flush() self._session.add_all( ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity_id, is_head_entity=False) for entity_id in entity_ids ) self._session.add( ConsolidationStatusHistory( version_id=version.id, from_status=None, to_status=ConsolidationStatus.INITIATED, changed_by=user.id, changed_at=now, ) ) await self._session.flush() await consolidation_documents.bind_confirmed( self._session, version=version, consolidation=consolidation, entity_ids=entity_ids ) return consolidation, version @staticmethod def _brief( version: ConsolidationVersion, lock: tuple[ConsolidationLock, str] | None, members_count: int ) -> VersionBrief: return VersionBrief( id=version.id, version_no=version.version_no, status=version.status, scenario=version.scenario, approach=version.approach, lock=lock_out(*lock) if lock is not None else None, created_at=version.created_at, members_count=members_count, ) async def _card(self, version: ConsolidationVersion, consolidation: Consolidation) -> VersionCard: """Карточка версии: версия, консолидация, ГК, инициатор, периметр, история, активная блокировка. Чек-лист у членов — те же ячейки, что у `GET …/checklist`, в порядке правил подхода; у членов — тип ЮЛ и счета ОСВ к периоду версии (front-api-gaps D-3). """ group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id) initiator = await self._session.get(AppUser, version.initiator_id) office = await directories_repo.get_regional_office(self._session, version.initiator_office_id) if group is None or initiator is None or office is None: # FK гарантируют наличие; группа видна по построению raise NotFoundError(VERSION_NOT_FOUND) members = await repo.members(self._session, version.id) locks = await repo.active_locks(self._session, [version.id]) history = await repo.history(self._session, version.id) brief = self._brief(version, locks.get(version.id), len(members)) rules, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in members]) accounts = await repo.osv_accounts( self._session, [entity.id for _, entity in members], consolidation.year, quarters_through(consolidation.quarter), self._period_from_version(consolidation), ) return VersionCard( **brief.model_dump(), consolidation_id=consolidation.id, year=consolidation.year, quarter=consolidation.quarter, reporting_date=consolidation.reporting_date, company_group=RefOut(id=group.id, name=group.name), initiator=RefOut(id=initiator.id, name=initiator.full_name), initiator_office=RefOut(id=office.id, name=office.name), goodwill_enabled=version.goodwill_enabled, is_in_work=version.is_in_work, previous_version_id=version.previous_version_id, members=[ MemberOut( id=entity.id, name=entity.name, inn=entity.inn, entity_type=entity.entity_type, is_head_entity=member.is_head_entity, checklist=[ self._cell_out(rule, cell) for rule, cell in zip(rules, cells[position][1], strict=True) ], osv_accounts=accounts.get(entity.id, []), ) for position, (member, entity) in enumerate(members) ], history=[ HistoryOut( from_status=record.from_status, to_status=record.to_status, changed_by=record.changed_by, changed_at=record.changed_at, comment=record.comment, ) for record in history ], )