arm-core/api/src/repositories/consolidations.py
Andrey Epifancev 74ba43d1b7 AURORA-1646 чек-лист версии — только файлы стола консолидации (#188)
Кандидаты «Требуется проверка» / «Распознан» и состояние файлов («Обработка» / «Ошибка загрузки») чек-лист берёт
только со стола консолидации (§9.5 п. 11) — тем же фильтром, что панель «Первичные документы» (`files.on_desk`).
Документы организации из других консолидаций и из карточки ЮЛ ячейку больше не меняют: без документов — «Отсутствует».

В `on_desk` документ подзапроса — псевдоним: в запросе по `Document` он иначе коррелирует с внешним.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:54:50 +04:00

574 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Репозиторий консолидаций (Срез 4; spec consolidations-api, consolidated-osv; design D-1, D-5, D-13).
Функции не делают `commit` и, кроме `get_version`, `get_consolidation_for_group` и `list_periods` (они получают
`VisibilityScope` и скрывают ГК чужого филиала), не применяют policy: проверку видимости делает вызывающий сервис.
Порядок блокировок — файл → документ → advisory ГК (`lock_group_bindings`) → версии; `lock_versions_for_entity` берёт
advisory ГК ЮЛ и версии пачкой по `id`.
"""
import uuid
from collections import defaultdict
from collections.abc import Collection, Sequence
from sqlalchemy import ColumnElement, Select, and_, case, exists, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.checklist import EntityFilesState
from src.domain.osv_mapping import account_sort_key
from src.domain.policy import VisibilityScope, company_groups_visible
from src.models.access import AppUser
from src.models.accounting import ChartOfAccount
from src.models.audit_ex import RequiredDocumentRule
from src.models.consolidation import (
Consolidation,
ConsolidationDocument,
ConsolidationLock,
ConsolidationMember,
ConsolidationStatusHistory,
ConsolidationVersion,
)
from src.models.directories import CompanyGroup, LegalEntity, LegalEntityAlias, RelatedGroup, RelatedGroupMember
from src.models.enums import (
ConsolidationApproach,
ConsolidationStatus,
DocumentStatus,
DocumentType,
FileStatus,
OsvDataType,
OsvGroupType,
PeriodQuarter,
RelatedGroupKind,
)
from src.models.files import Document, StoredFile
from src.models.osv import OsvEntry
from src.repositories.directories import LIKE_ESCAPE, escape_like
from src.repositories.files import on_desk
FILES_IN_PROGRESS = (FileStatus.UPLOADED, FileStatus.PROCESSING)
GROUP_BINDINGS_LOCK_PREFIX = "consolidation-group" # пространство ключей advisory-блокировки привязок ГК (R-2)
SUMMARY_DATA_TYPES = (OsvDataType.BU, OsvDataType.SUM)
UNCONFIRMED_STATUSES = (DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK)
# Пробельные символы `str.strip()` (`str.isspace`): `btrim` в SQL срезает ровно их, как `.strip()` склейки свода (D-2)
PY_WHITESPACE = "".join(char for char in map(chr, range(0x3001)) if char.isspace())
def _visible_consolidations(scope: VisibilityScope) -> Select[tuple[Consolidation]]:
"""`SELECT consolidation` с присоединённой ГК и фильтром видимости (§10.1)."""
stmt = select(Consolidation).join(CompanyGroup, CompanyGroup.id == Consolidation.company_group_id)
return company_groups_visible(stmt, scope)
async def get_version(
session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID
) -> tuple[ConsolidationVersion, Consolidation] | None:
"""Версия и её консолидация, если ГК видна пользователю (`consolidation` → `company_group`); иначе `None`."""
stmt = (
select(ConsolidationVersion, Consolidation)
.join(Consolidation, Consolidation.id == ConsolidationVersion.consolidation_id)
.join(CompanyGroup, CompanyGroup.id == Consolidation.company_group_id)
.where(ConsolidationVersion.id == version_id)
)
row = (await session.execute(company_groups_visible(stmt, scope))).first()
return None if row is None else (row[0], row[1])
async def get_consolidation(
session: AsyncSession, scope: VisibilityScope, consolidation_id: uuid.UUID
) -> Consolidation | None:
"""Консолидация по id, если её ГК видна пользователю; чужая и несуществующая — `None` (v5.18, design D-3)."""
stmt = _visible_consolidations(scope).where(Consolidation.id == consolidation_id)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_consolidation_for_group(
session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID, year: int, quarter: PeriodQuarter
) -> Consolidation | None:
"""Консолидация ГК за период (уникальна по `(ГК, год, квартал)`), если ГК видна пользователю."""
stmt = _visible_consolidations(scope).where(
Consolidation.company_group_id == group_id, Consolidation.year == year, Consolidation.quarter == quarter
)
return (await session.execute(stmt)).scalar_one_or_none()
async def list_periods(
session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID
) -> list[tuple[Consolidation, list[ConsolidationVersion]]]:
"""Периоды ГК по убыванию года и квартала; версии каждого — по убыванию `version_no`. Двумя запросами."""
stmt = (
_visible_consolidations(scope)
.where(Consolidation.company_group_id == group_id)
.order_by(Consolidation.year.desc(), Consolidation.quarter.desc())
)
consolidations = list((await session.execute(stmt)).scalars())
if not consolidations:
return []
versions: dict[uuid.UUID, list[ConsolidationVersion]] = defaultdict(list)
rows = await session.execute(
select(ConsolidationVersion)
.where(ConsolidationVersion.consolidation_id.in_([c.id for c in consolidations]))
.order_by(ConsolidationVersion.version_no.desc())
)
for version in rows.scalars():
versions[version.consolidation_id].append(version)
return [(consolidation, versions[consolidation.id]) for consolidation in consolidations]
async def lock_version(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationVersion | None:
"""Версия под `FOR UPDATE`; `populate_existing` — состояние строки в сессии заменяется свежим из БД."""
stmt = (
select(ConsolidationVersion)
.where(ConsolidationVersion.id == version_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def lock_group_bindings(session: AsyncSession, group_id: uuid.UUID) -> None:
"""`pg_advisory_xact_lock` привязок документов ГК (R-2, design №30): сериализует создание версии консолидации ГК с
подтверждением и автозаменой документов ЮЛ этой ГК. Новая версия невидима другим транзакциям до commit, поэтому
`FOR UPDATE` версий её не ловит: создание берёт этот lock до чтения подтверждённых документов, а
`lock_versions_for_entity` — до блокировки версий. Снимается в конце транзакции."""
key = func.hashtextextended(f"{GROUP_BINDINGS_LOCK_PREFIX}:{group_id}", 0)
await session.execute(select(func.pg_advisory_xact_lock(key)))
async def lock_versions_for_entity(
session: AsyncSession, entity_id: uuid.UUID, statuses: Collection[ConsolidationStatus]
) -> list[ConsolidationVersion]:
"""Версии со статусом из `statuses`, где ЮЛ — член периметра; блокируются `FOR UPDATE ORDER BY id` (R-2).
Первой берётся advisory-блокировка привязок ГК ЮЛ (`lock_group_bindings`; у ЮЛ без ГК её нет): иначе версия,
создаваемая параллельно, не видна ни здесь, ни подтверждённый нами документ — там. ЮЛ без ГК (после выхода из
группы или переноса) может оставаться членом версий «в работе» — его ловит ветка `is_member`; такое ЮЛ в периметре
заставляет любой `PUT …/members` отвечать `ENTITY_NOT_IN_GROUP`, пока его не уберут из периметра.
Блокируется НАДМНОЖЕСТВО: версии этих статусов, где ЮЛ — член ИЛИ `consolidation.company_group_id` равен ГК ЮЛ
(`legal_entity.company_group_id`). Иначе подтверждение документа ∥ `PUT …/members` теряет привязку: подтверждение
не видит ещё не зафиксированного нового члена и не ждёт версию, которую правит периметр. Блокировка версии ГК,
где ЮЛ формально не член, заставляет такую правку дождаться нас и закоммитить периметр до того, как мы его
перечитаем. Возвращаются только версии, где ЮЛ член, — отдельным вторым запросом: в READ COMMITTED он получает
свежий снимок (уже видит периметры, закоммиченные ожидавшимися транзакциями); блокировка на нём не нужна, строки
заблокированы первым запросом. `populate_existing` — состояние версий в сессии заменяется свежим.
"""
is_member = exists().where(
ConsolidationMember.consolidation_version_id == ConsolidationVersion.id,
ConsolidationMember.legal_entity_id == entity_id,
)
group_id = (
await session.execute(select(LegalEntity.company_group_id).where(LegalEntity.id == entity_id))
).scalar_one_or_none()
if group_id is not None:
await lock_group_bindings(session, group_id)
in_group = Consolidation.company_group_id == group_id if group_id is not None else false()
superset = (
select(ConsolidationVersion.id)
.join(Consolidation, Consolidation.id == ConsolidationVersion.consolidation_id)
.where(ConsolidationVersion.status.in_(statuses), is_member | in_group)
.order_by(ConsolidationVersion.id)
.with_for_update(of=ConsolidationVersion)
)
locked_ids = list((await session.execute(superset)).scalars())
if not locked_ids:
return []
members_only = (
select(ConsolidationVersion)
.where(ConsolidationVersion.id.in_(locked_ids), is_member)
.order_by(ConsolidationVersion.id)
.execution_options(populate_existing=True)
)
return list((await session.execute(members_only)).scalars())
async def versions_of(session: AsyncSession, consolidation_id: uuid.UUID) -> list[ConsolidationVersion]:
"""Все версии консолидации по возрастанию `version_no` (без блокировки; проверки до приёма файлов, D-3)."""
stmt = (
select(ConsolidationVersion)
.where(ConsolidationVersion.consolidation_id == consolidation_id)
.order_by(ConsolidationVersion.version_no)
)
return list((await session.execute(stmt)).scalars())
async def lock_versions_of_consolidation(
session: AsyncSession,
consolidation_id: uuid.UUID,
entity_id: uuid.UUID,
statuses: Collection[ConsolidationStatus],
) -> list[ConsolidationVersion]:
"""Версии консолидации со статусом из `statuses`, где ЮЛ — член периметра, под `FOR UPDATE ORDER BY id` (R-2;
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(
ConsolidationMember.consolidation_version_id == ConsolidationVersion.id,
ConsolidationMember.legal_entity_id == entity_id,
)
members_only = (
select(ConsolidationVersion)
.where(ConsolidationVersion.id.in_(locked_ids), is_member)
.order_by(ConsolidationVersion.id)
.execution_options(populate_existing=True)
)
return list((await session.execute(members_only)).scalars())
async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationLock | None:
"""Активная блокировка версии (`released_at IS NULL`; не более одной — partial UNIQUE)."""
stmt = select(ConsolidationLock).where(
ConsolidationLock.consolidation_version_id == version_id, ConsolidationLock.released_at.is_(None)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def active_locks(
session: AsyncSession, version_ids: Collection[uuid.UUID]
) -> dict[uuid.UUID, tuple[ConsolidationLock, str]]:
"""Активные блокировки версий из `version_ids` одним запросом: версия → (блокировка, ФИО владельца)."""
if not version_ids:
return {}
stmt = (
select(ConsolidationLock, AppUser.full_name)
.join(AppUser, AppUser.id == ConsolidationLock.locked_by)
.where(ConsolidationLock.consolidation_version_id.in_(version_ids), ConsolidationLock.released_at.is_(None))
)
return {row[0].consolidation_version_id: (row[0], row[1]) for row in await session.execute(stmt)}
async def members_counts(session: AsyncSession, version_ids: Collection[uuid.UUID]) -> dict[uuid.UUID, int]:
"""Размер периметра каждой версии из `version_ids` одним запросом (версия без членов — 0)."""
if not version_ids:
return {}
stmt = (
select(ConsolidationMember.consolidation_version_id, func.count(ConsolidationMember.id))
.where(ConsolidationMember.consolidation_version_id.in_(version_ids))
.group_by(ConsolidationMember.consolidation_version_id)
)
counts = {row[0]: row[1] for row in await session.execute(stmt)}
return {version_id: counts.get(version_id, 0) for version_id in version_ids}
async def members(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[ConsolidationMember, LegalEntity]]:
"""Периметр версии: член и его ЮЛ, по имени ЮЛ (`id` — для устойчивости порядка)."""
stmt = (
select(ConsolidationMember, LegalEntity)
.join(LegalEntity, LegalEntity.id == ConsolidationMember.legal_entity_id)
.where(ConsolidationMember.consolidation_version_id == version_id)
.order_by(LegalEntity.name, LegalEntity.id)
)
return [(row[0], row[1]) for row in await session.execute(stmt)]
async def entities_in_group(
session: AsyncSession, group_id: uuid.UUID, entity_ids: Collection[uuid.UUID]
) -> set[uuid.UUID]:
"""Из `entity_ids` — те ЮЛ, что существуют и принадлежат ГК `group_id` (`legal_entity.company_group_id`)."""
if not entity_ids:
return set()
stmt = select(LegalEntity.id).where(LegalEntity.id.in_(entity_ids), LegalEntity.company_group_id == group_id)
return set((await session.execute(stmt)).scalars())
async def bound_documents(session: AsyncSession, version_id: uuid.UUID) -> list[Document]:
"""Документы, привязанные к версии (`consolidation_document`)."""
stmt = (
select(Document)
.join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id)
.where(ConsolidationDocument.consolidation_version_id == version_id)
.order_by(Document.legal_entity_id, Document.osv_account_code, Document.period_quarter, Document.id)
)
return list((await session.execute(stmt)).scalars())
async def document_versions(session: AsyncSession, document_id: uuid.UUID) -> list[uuid.UUID]:
"""Версии, к которым привязан документ (по `id`)."""
stmt = (
select(ConsolidationDocument.consolidation_version_id)
.where(ConsolidationDocument.document_id == document_id)
.order_by(ConsolidationDocument.consolidation_version_id)
)
return list((await session.execute(stmt)).scalars())
async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[OsvEntry, Document, LegalEntity]]:
"""Исходные строки свода: контрагентские (`group_type = COUNTERPARTY`) строки BU/SUM привязанных документов.
`TOTAL` не хранится, строки `ACCOUNT` и данные `NU` отбрасываются. Порядок: имя ЮЛ, код счёта документа, квартал,
`row_number` (а затем `OsvEntry.id` для устойчивости).
"""
stmt = (
select(OsvEntry, Document, LegalEntity)
.join(Document, Document.id == OsvEntry.document_id)
.join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id)
.join(LegalEntity, LegalEntity.id == Document.legal_entity_id)
.where(
ConsolidationDocument.consolidation_version_id == version_id,
OsvEntry.group_type == OsvGroupType.COUNTERPARTY,
OsvEntry.data_type.in_(SUMMARY_DATA_TYPES),
)
.order_by(
LegalEntity.name, Document.osv_account_code, Document.period_quarter, OsvEntry.row_number, OsvEntry.id
)
)
return [(row[0], row[1], row[2]) for row in await session.execute(stmt)]
async def history(session: AsyncSession, version_id: uuid.UUID) -> list[ConsolidationStatusHistory]:
"""Журнал смен статуса версии по времени (`changed_at`, затем `id`)."""
stmt = (
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version_id)
.order_by(ConsolidationStatusHistory.changed_at, ConsolidationStatusHistory.id)
)
return list((await session.execute(stmt)).scalars())
async def entity_files_state(
session: AsyncSession, entity_ids: Sequence[uuid.UUID], consolidation_id: uuid.UUID
) -> dict[uuid.UUID, EntityFilesState]:
"""Состояние файлов каждого ЮЛ из `entity_ids` на столе консолидации `consolidation_id` (чек-лист, D-13;
`files.on_desk`, AURORA-1646): файл в работе и статус последнего файла.
«В работе» — любой файл `UPLOADED`/`PROCESSING`; «последний» — по `uploaded_at` (при равенстве или NULL — по `id`),
включая замещённые файлы. ЮЛ без файлов на столе — `EntityFilesState(False, None)`. Неподтверждённые
документы-кандидаты сюда не входят (R-5).
"""
states = {entity_id: EntityFilesState(has_in_progress=False, last_file_status=None) for entity_id in entity_ids}
if not states:
return states
desk = on_desk(consolidation_id)
in_progress = select(StoredFile.legal_entity_id).where(
StoredFile.legal_entity_id.in_(states), StoredFile.status.in_(FILES_IN_PROGRESS), desk
)
busy = set((await session.execute(in_progress.distinct())).scalars())
last = (
select(StoredFile.legal_entity_id, StoredFile.status)
.where(StoredFile.legal_entity_id.in_(states), desk)
.distinct(StoredFile.legal_entity_id)
.order_by(StoredFile.legal_entity_id, StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id.desc())
)
last_status = {entity_id: status for entity_id, status in await session.execute(last)}
return {
entity_id: EntityFilesState(has_in_progress=entity_id in busy, last_file_status=last_status.get(entity_id))
for entity_id in states
}
async def rules_for(
session: AsyncSession, approach: ConsolidationApproach
) -> list[tuple[RequiredDocumentRule, str | None]]:
"""Правила комплекта подхода (`required_document_rule`) и код счёта правила (`chart_of_account.code`; у ББ/ОФР —
`None`). Порядок столбцов задаёт домен (`checklist.order_rules`); здесь — детерминированный по `id`."""
stmt = (
select(RequiredDocumentRule, ChartOfAccount.code)
.outerjoin(ChartOfAccount, ChartOfAccount.id == RequiredDocumentRule.account_id)
.where(RequiredDocumentRule.approach == approach)
.order_by(RequiredDocumentRule.id)
)
return [(row[0], row[1]) for row in await session.execute(stmt)]
def _osv_of_consolidation(consolidation_id: uuid.UUID) -> ColumnElement[bool]:
"""ОСВ, файл которой загружен в консолидацию: в режиме «период из версии» (arm-core#103) — любого периода шапки."""
return and_(Document.document_type == DocumentType.OSV, StoredFile.consolidation_id == consolidation_id)
async def file_consolidation_ids(
session: AsyncSession, file_ids: Collection[uuid.UUID]
) -> dict[uuid.UUID, uuid.UUID | None]:
"""`stored_file.consolidation_id` по id файлов одним запросом (режим «период из версии», arm-core#103)."""
if not file_ids:
return {}
stmt = select(StoredFile.id, StoredFile.consolidation_id).where(StoredFile.id.in_(list(file_ids)))
return {file_id: consolidation_id for file_id, consolidation_id in await session.execute(stmt)}
async def unconfirmed_documents(
session: AsyncSession,
entity_ids: Collection[uuid.UUID],
consolidation_id: uuid.UUID,
year: int,
*,
osv_any_year: bool = False,
) -> list[Document]:
"""Неподтверждённые документы ЮЛ за год (`RECOGNIZED`/`NEEDS_CHECK`) из файлов не `SUPERSEDED` на столе консолидации
`consolidation_id` (`files.on_desk`, AURORA-1646: документы других консолидаций и карточки ЮЛ не учитываются); любых
типов (R-5). `osv_any_year` (режим «период из версии», arm-core#103) — плюс ОСВ, загруженные в эту консолидацию,
любого года.
Кандидаты чек-листа: «подходящесть» версии (`fits`) проверяет сервис. Порядок — по ЮЛ, типу, счёту, кварталу, `id`.
"""
if not entity_ids:
return []
in_period: ColumnElement[bool] = Document.period_year == year
if osv_any_year:
in_period = or_(in_period, _osv_of_consolidation(consolidation_id))
stmt = (
select(Document)
.join(StoredFile, StoredFile.id == Document.stored_file_id)
.where(
Document.legal_entity_id.in_(list(entity_ids)),
on_desk(consolidation_id),
in_period,
Document.status.in_(UNCONFIRMED_STATUSES),
StoredFile.status != FileStatus.SUPERSEDED,
)
.order_by(
Document.legal_entity_id,
Document.document_type,
Document.osv_account_code,
Document.period_quarter,
Document.id,
)
)
return list((await session.execute(stmt)).scalars())
async def osv_accounts(
session: AsyncSession,
entity_ids: Collection[uuid.UUID],
year: int,
quarters: Collection[PeriodQuarter],
consolidation_id: uuid.UUID | None = None,
) -> dict[uuid.UUID, list[str]]:
"""Счета ОСВ, загруженные к периоду версии (колонка «ОСВ» панели [34]; front-api-gaps D-3): уникальные
`osv_account_code` документов `OSV` ЮЛ за год `year` и кварталы `quarters`; документ не `ERROR`, файл не
`SUPERSEDED`/`ERROR`, квартал и код счёта заданы; статус подтверждения и привязка к версии не важны. Порядок —
натуральный (`account_sort_key`); ЮЛ без документов в словаре нет. `consolidation_id` (режим «период из версии»,
arm-core#103) — плюс ОСВ, загруженные в эту консолидацию, любого периода."""
if not entity_ids:
return {}
in_period: ColumnElement[bool] = and_(Document.period_year == year, Document.period_quarter.in_(list(quarters)))
if consolidation_id is not None:
in_period = or_(in_period, _osv_of_consolidation(consolidation_id))
stmt = (
select(Document.legal_entity_id, Document.osv_account_code)
.join(StoredFile, StoredFile.id == Document.stored_file_id)
.where(
Document.legal_entity_id.in_(list(entity_ids)),
Document.document_type == DocumentType.OSV,
in_period,
Document.status != DocumentStatus.ERROR,
StoredFile.status.not_in([FileStatus.SUPERSEDED, FileStatus.ERROR]),
Document.osv_account_code.is_not(None),
Document.osv_account_code != "",
)
.distinct()
)
accounts: dict[uuid.UUID, list[str]] = defaultdict(list)
for entity_id, code in await session.execute(stmt):
accounts[entity_id].append(code)
return {entity_id: sorted(codes, key=account_sort_key) for entity_id, codes in accounts.items()}
def _summary_entries(version_id: uuid.UUID) -> Select[tuple[OsvEntry]]:
"""Строки свода версии: контрагентские (`COUNTERPARTY`) строки BU/SUM ОСВ-документов, привязанных к версии."""
return (
select(OsvEntry)
.join(Document, Document.id == OsvEntry.document_id)
.join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id)
.where(
ConsolidationDocument.consolidation_version_id == version_id,
Document.document_type == DocumentType.OSV,
OsvEntry.group_type == OsvGroupType.COUNTERPARTY,
OsvEntry.data_type.in_(SUMMARY_DATA_TYPES),
)
)
async def entry_in_version(session: AsyncSession, entry_id: uuid.UUID, version_id: uuid.UUID) -> OsvEntry | None:
"""Строка `entry_id`, если она — строка свода версии (`_summary_entries`): документ ОСВ привязан к версии, строка
`COUNTERPARTY` с данными BU/SUM. Иначе (непривязанный документ, `ACCOUNT`, `NU`, чужая строка) — `None`."""
stmt = _summary_entries(version_id).where(OsvEntry.id == entry_id)
return (await session.execute(stmt)).scalar_one_or_none()
async def lock_entries_same_name(
session: AsyncSession, version_id: uuid.UUID, *, entry_id: uuid.UUID, name: str
) -> list[OsvEntry]:
"""Строка `entry_id` и строки свода версии с тем же `counterparty_name.strip()` = `name` под
`FOR NO KEY UPDATE ORDER BY id` (ручное сопоставление, D-10); пустой `name` — только сама строка.
Строки ОСВ общие для всех версий, к которым привязан документ (OQ-123): сопоставления в двух версиях блокируют
пересекающиеся множества строк, порядок по `id` исключает взаимоблокировку, а `populate_existing` отдаёт
состояние после ожидания (READ COMMITTED перечитывает заблокированную строку), поэтому `counterparty_id IS NULL`
проверяется по свежим данным. Блокируется только `osv_entry` (документы и привязки не трогаются)."""
same = OsvEntry.id == entry_id
if name:
same = or_(same, func.btrim(OsvEntry.counterparty_name, PY_WHITESPACE) == name)
stmt = (
_summary_entries(version_id)
.where(same)
.order_by(OsvEntry.id)
.with_for_update(of=OsvEntry, key_share=True)
.execution_options(populate_existing=True)
)
return list((await session.execute(stmt)).scalars())
async def search_entities( # noqa: PLR0913 - фильтры поиска кандидатов передаются явными именованными аргументами
session: AsyncSession,
*,
q: str | None,
group_id: uuid.UUID | None,
member_ids: Collection[uuid.UUID],
recommended_ids: Collection[uuid.UUID],
limit: int,
) -> list[tuple[LegalEntity, CompanyGroup | None]]:
"""Кандидаты для ручного сопоставления (D-11) без policy видимости, с ГК каждого ЮЛ.
`group_id` задан — ЮЛ этой ГК и `member_ids` (периметр версии); `None` — весь справочник. `q` (уже без краевых
пробелов): ИНН по префиксу (пробелы внутри `q` не учитываются), имя или алиас — подстрока без регистра; шаблоны
LIKE в `q` экранируются. Порядок: `recommended_ids` первыми, затем имя и `id`; не больше `limit`.
"""
stmt = select(LegalEntity, CompanyGroup).outerjoin(CompanyGroup, CompanyGroup.id == LegalEntity.company_group_id)
if group_id is not None:
in_scope = LegalEntity.company_group_id == group_id
stmt = stmt.where(or_(in_scope, LegalEntity.id.in_(list(member_ids))) if member_ids else in_scope)
if q:
pattern = f"%{escape_like(q)}%"
by_alias = exists().where(
LegalEntityAlias.legal_entity_id == LegalEntity.id,
LegalEntityAlias.alias.ilike(pattern, escape=LIKE_ESCAPE),
)
conditions = [LegalEntity.name.ilike(pattern, escape=LIKE_ESCAPE), by_alias]
if inn := "".join(q.split()):
conditions.append(LegalEntity.inn.like(f"{escape_like(inn)}%", escape=LIKE_ESCAPE))
stmt = stmt.where(or_(*conditions))
first = case((LegalEntity.id.in_(list(recommended_ids)), 0), else_=1) if recommended_ids else None
order = [LegalEntity.name, LegalEntity.id] if first is None else [first, LegalEntity.name, LegalEntity.id]
return [(row[0], row[1]) for row in await session.execute(stmt.order_by(*order).limit(limit))]
async def current_related_groups(
session: AsyncSession, entity_ids: Collection[uuid.UUID]
) -> dict[uuid.UUID, dict[RelatedGroupKind, RelatedGroup]]:
"""Текущие ГСК/ГСЗ каждого ЮЛ из `entity_ids` одним запросом: ЮЛ → вид → группа (не больше одной каждого вида)."""
if not entity_ids:
return {}
stmt = (
select(RelatedGroupMember.legal_entity_id, RelatedGroup)
.join(RelatedGroup, RelatedGroup.id == RelatedGroupMember.related_group_id)
.where(RelatedGroupMember.legal_entity_id.in_(entity_ids), RelatedGroupMember.is_current.is_(True))
)
groups: dict[uuid.UUID, dict[RelatedGroupKind, RelatedGroup]] = defaultdict(dict)
for entity_id, group in await session.execute(stmt):
groups[entity_id][group.kind] = group
return dict(groups)