"""Сводная ОСВ версии: страница и сводка (spec consolidated-osv «Страница свода и сводка»; design D-1, D-16). Свод — чистая функция версии (DECISION-5), в БД не хранится: каждый запрос заново собирает его из периметра, привязанных ОСВ-документов и их строк (`domain.consolidated_osv.build`) и отдаёт срез. Сервис только читает: без блокировок и аудита, версия в любом статусе (включая `CANCELLED`) читается; видимость — через ГК консолидации (чужая и несуществующая версии — один 404 `VERSION_NOT_FOUND`). Запросов к БД — константа, не зависящая от числа строк и контрагентов (без N+1): `get_version`, периметр, документы версии, строки, два запроса индекса контрагентов (`CounterpartyIndex.load`) и один `SELECT legal_entity` по собранным идентификаторам — контрагенты строк и кандидаты дорезолва. Дорезолв на лету кормит `build` тем же `CounterpartyIndex`, поэтому идентификаторы кандидатов собираются заранее тем же `resolve` по словарям в памяти. """ import uuid from collections.abc import Collection, Sequence from typing import Literal from fastapi.concurrency import run_in_threadpool from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.exceptions import DomainValidationError, NotFoundError from src.domain import consolidated_osv as domain from src.domain.aliases import normalize_alias from src.domain.policy import VisibilityScope from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity, RelatedGroup from src.models.enums import AuditAction, AuditSection, DocumentType, RelatedGroupKind from src.models.files import Document from src.models.osv import OsvEntry from src.repositories import consolidations as repo from src.repositories.counterparties import CounterpartyIndex from src.schemas.common import Page from src.schemas.consolidations import ( CandidateOut, ConsolidatedRowOut, GapOut, OsvFilters, OsvPageParams, OsvSummaryOut, RefOut, ) from src.services.consolidation_guard import VERSION_NOT_FOUND from src.services.osv_export import build_workbook __all__ = ["MAX_CANDIDATES", "MIN_QUERY_ALL", "CandidateScope", "ConsolidatedOsvService", "domain_filter", "row_out"] CandidateScope = Literal["group", "all"] MAX_CANDIDATES = 100 # кандидатов в ответе не больше (D-11) MIN_QUERY_ALL = 3 # `scope=all`: поиск по всему справочнику — от трёх символов (решение №5) def row_out(row: domain.ConsolidatedRow) -> ConsolidatedRowOut: """Строка свода → схема API: кортежи домена (`entry_ids`, `warnings`) — списки, суммы — `Decimal` (JSON-строка).""" return ConsolidatedRowOut.model_validate(row, from_attributes=True) def domain_filter(f: OsvFilters) -> domain.OsvFilter: """Query-модель фильтров → доменный `OsvFilter` (повторяемый `match_kind` → множество).""" return domain.OsvFilter( legal_entity_id=f.legal_entity_id, account_root=f.account_root, counterparty_type=f.counterparty_type, unmapped=f.unmapped, match_kinds=frozenset(f.match_kind or ()), q=f.q, ) def _entity(entity: LegalEntity) -> domain.Entity: return domain.Entity(id=entity.id, name=entity.name, inn=entity.inn, company_group_id=entity.company_group_id) def _ref(item: CompanyGroup | RelatedGroup | None) -> RefOut | None: return None if item is None else RefOut(id=item.id, name=item.name) def _source_document(document: Document) -> domain.SourceDocument: return domain.SourceDocument( id=document.id, legal_entity_id=document.legal_entity_id, osv_account_code=document.osv_account_code, period_quarter=document.period_quarter, is_cumulative=document.is_cumulative, ) def _source_row(entry: OsvEntry) -> domain.SourceRow: return domain.SourceRow( id=entry.id, document_id=entry.document_id, row_number=entry.row_number, account_code=entry.account_code, account_root=entry.account_root, counterparty_name=entry.counterparty_name, counterparty_inn=entry.counterparty_inn, counterparty_id=entry.counterparty_id, match_kind=entry.counterparty_match_kind, opening_debit=entry.opening_debit, opening_credit=entry.opening_credit, turnover_debit=entry.turnover_debit, turnover_credit=entry.turnover_credit, closing_debit=entry.closing_debit, closing_credit=entry.closing_credit, ) def _candidate_ids(rows: Sequence[domain.SourceRow], index: CounterpartyIndex) -> set[uuid.UUID]: """Идентификаторы контрагентов, имена которых понадобятся `build`: хранимые `counterparty_id` и результат дорезолва для строк без него (`resolve` — по словарям индекса, без обращений к БД; ключ склейки включает имя и ИНН, поэтому результат для представителя группы равен результату для любой её строки).""" ids: set[uuid.UUID] = set() for row in rows: if row.counterparty_id is not None: ids.add(row.counterparty_id) elif (resolution := index.resolve(row.counterparty_inn, row.counterparty_name)) is not None: ids.add(resolution.entity_id) return ids async def _entities(session: AsyncSession, ids: Collection[uuid.UUID]) -> dict[uuid.UUID, domain.Entity]: """ЮЛ справочника по `ids` одним запросом, без policy (контрагент может быть из любого филиала).""" if not ids: return {} found = await session.execute(select(LegalEntity).where(LegalEntity.id.in_(ids))) return {entity.id: _entity(entity) for entity in found.scalars()} class ConsolidatedOsvService: """Чтение сводной ОСВ версии: страница с фильтрами и сводка с пробелами (без блокировок и аудита).""" def __init__(self, session: AsyncSession, scope: VisibilityScope) -> None: self._session = session self._scope = scope async def _build(self, version_id: uuid.UUID) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation]: """Свод версии (`domain.build`) вместе с версией и консолидацией; версия вне видимости — 404.""" found = await repo.get_version(self._session, self._scope, version_id) if found is None: raise NotFoundError(VERSION_NOT_FOUND) version, consolidation = found members = {member.id: _entity(member) for _, member in await repo.members(self._session, version.id)} documents = [ _source_document(document) for document in await repo.bound_documents(self._session, version.id) if document.document_type == DocumentType.OSV ] # ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)] index = await CounterpartyIndex.load(self._session) entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members} consolidated = domain.build( year=consolidation.year, quarter=consolidation.quarter, group_id=consolidation.company_group_id, members=members, entities=entities, documents=documents, rows=rows, resolver=index, ) return consolidated, version, consolidation async def page(self, version_id: uuid.UUID, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]: """Страница свода после фильтров: `total` — число строк после фильтра, срез — в памяти (порядок D-16).""" consolidated, _, _ = await self._build(version_id) rows = domain.apply_filter(consolidated.rows, domain_filter(f)) window = rows[page.offset : page.offset + page.limit] return Page[ConsolidatedRowOut]( items=[row_out(row) for row in window], total=len(rows), limit=page.limit, offset=page.offset ) async def export(self, version_id: uuid.UUID, f: OsvFilters, audit: RequestAudit) -> tuple[bytes, str]: """xlsx сводной ОСВ (spec «Экспорт xlsx», D-14): строки после тех же фильтров, что у `page`, без пагинации, в порядке D-16; лист пробелов — по своду целиком. Имя файла — `svod-osv_<ГК>_<год>-<квартал>_v.xlsx` (без percent-кодирования: оно в маршруте). `audit_event(EXPORT)` пишется после сборки книги, в транзакции запроса.""" consolidated, version, consolidation = await self._build(version_id) rows = domain.apply_filter(consolidated.rows, domain_filter(f)) content = await run_in_threadpool(build_workbook, consolidated, rows) group_name = ( await self._session.execute( select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id) ) ).scalar_one() await audit.record( action=AuditAction.EXPORT, section=AuditSection.EXPORT, object_type="consolidation_version", object_id=version.id, consolidation_version_id=version.id, after_value={ "report": "consolidated_osv", "filters": f.model_dump(mode="json", exclude_none=True), "rows": len(rows), }, ) filename = ( f"svod-osv_{group_name}_{consolidation.year}-{consolidation.quarter.value}_v{version.version_no}.xlsx" ) return content, filename async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut: """Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы.""" consolidated, _, _ = await self._build(version_id) rows_total, unmapped_total, auto_matched_total = domain.summary(consolidated) return OsvSummaryOut( rows_total=rows_total, unmapped_total=unmapped_total, auto_matched_total=auto_matched_total, documents_total=consolidated.documents_total, gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in consolidated.gaps], ) async def row_for_entry(self, version_id: uuid.UUID, entry_id: uuid.UUID) -> ConsolidatedRowOut | None: """Строка свода, в `entry_ids` которой есть `entry_id`; `None` — строка в своде не участвует (пара с пробелом или документ не выбран источником при наличии нарастающего).""" consolidated, _, _ = await self._build(version_id) row = next((row for row in consolidated.rows if entry_id in row.entry_ids), None) return None if row is None else row_out(row) async def candidates( self, version_id: uuid.UUID, *, q: str | None, scope: CandidateScope, entry_id: uuid.UUID | None ) -> list[CandidateOut]: """Кандидаты для ручного сопоставления контрагента (spec consolidated-osv «Кандидаты для сопоставления», D-11). Версия — через видимость (404). `group` — ЮЛ периметра и ГК консолидации, `q` необязателен; `all` — весь справочник без policy, `q` без краевых пробелов не короче `MIN_QUERY_ALL` (иначе 422 `QUERY_TOO_SHORT`). `recommended` — нормализованное имя или алиас ЮЛ совпали с `normalize_alias` имени строки `entry_id` (строка свода этой версии; иначе рекомендаций нет); рекомендуемые первыми, затем по имени; не больше `MAX_CANDIDATES`. Запросов — константа: версия, периметр, (индекс контрагентов при `entry_id`), поиск с ГК, членства ГСК/ГСЗ. """ found = await repo.get_version(self._session, self._scope, version_id) if found is None: raise NotFoundError(VERSION_NOT_FOUND) version, consolidation = found query = (q or "").strip() or None if scope == "all" and (query is None or len(query) < MIN_QUERY_ALL): raise DomainValidationError( "QUERY_TOO_SHORT", f"Для поиска по всему справочнику нужно не меньше {MIN_QUERY_ALL} символов" ) members = {entity.id: _entity(entity) for _, entity in await repo.members(self._session, version.id)} recommended = await self._recommended_ids(version.id, entry_id) found_entities = await repo.search_entities( self._session, q=query, group_id=consolidation.company_group_id if scope == "group" else None, member_ids=list(members), recommended_ids=recommended, limit=MAX_CANDIDATES, ) related = await repo.current_related_groups(self._session, [entity.id for entity, _ in found_entities]) result = [] for entity, group in found_entities: groups = related.get(entity.id, {}) kind = domain.classify( entity.id, members=members, entities={entity.id: _entity(entity)}, group_id=consolidation.company_group_id, ) # fmt: skip result.append( CandidateOut( id=entity.id, inn=entity.inn, name=entity.name, company_group=_ref(group), gsk=_ref(groups.get(RelatedGroupKind.gsk)), gsz=_ref(groups.get(RelatedGroupKind.gsz)), counterparty_type=kind, recommended=entity.id in recommended, ) ) return result async def _recommended_ids(self, version_id: uuid.UUID, entry_id: uuid.UUID | None) -> set[uuid.UUID]: """ЮЛ, чьё нормализованное имя или алиас равны `normalize_alias` имени строки `entry_id` (строка свода версии). Нормализация — функция Python (§3 п. 4), поэтому совпадения ищутся тем же `CounterpartyIndex`, что резолвит строки свода (`by_normalized` — имена и алиасы всего справочника без policy).""" if entry_id is None: return set() entry = await repo.entry_in_version(self._session, entry_id, version_id) if entry is None or not (normalized := normalize_alias(entry.counterparty_name or "")): return set() index = await CounterpartyIndex.load(self._session) return set(index.by_normalized.get(normalized, ()))