"""Гонки уникальных ограничений: имя нарушенного ограничения PostgreSQL → тот же код и текст 409, что у pre-check. Сервисы проверяют уникальность до вставки (`INN_EXISTS`, `ALIAS_EXISTS`, …), но между проверкой и вставкой параллельный запрос может успеть раньше — тогда БД отвечает `unique_violation` (SQLSTATE 23505), и `IntegrityError` доходит до обработчика исключений. Реестр `UNIQUE_CONSTRAINT_CONFLICTS` сопоставляет имя ограничения доменному конфликту, чтобы клиент видел тот же `{"detail": {"code", "message"}}`, что и без гонки. Текст сообщения — только постоянная часть pre-check-а: динамические поля (`legal_entity_id`, `related_group_id`) в ответ гонки не попадают. Имя ограничения берётся из структурированного поля драйвера (asyncpg: `__cause__.constraint_name`, psycopg: `diag.constraint_name`), а не из текста ошибки: текст содержит DETAIL со значениями ключей (email, ИНН) — это ПДн. """ from dataclasses import dataclass from typing import Any from sqlalchemy.exc import DBAPIError UNIQUE_VIOLATION_SQLSTATE = "23505" @dataclass(frozen=True) class ConstraintConflict: """Код и постоянный текст доменного конфликта 409.""" code: str message: str #: Имя уникального ограничения/индекса → конфликт. Ключи обязаны существовать в `Base.metadata` #: (`tests/test_exception_handlers.py` падает при переименовании). Тексты совпадают с pre-check-ами сервисов. UNIQUE_CONSTRAINT_CONFLICTS: dict[str, ConstraintConflict] = { "uq_company_group_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ГК с таким crm_id уже существует"), "uq_legal_entity_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ЮЛ с таким crm_id уже существует"), "uq_legal_entity_inn": ConstraintConflict("INN_EXISTS", "ЮЛ с таким ИНН уже существует"), "uq_legal_entity_alias_legal_entity_id_normalized_alias": ConstraintConflict( "ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас" ), "uq_app_user_email": ConstraintConflict("EMAIL_EXISTS", "Пользователь с таким email уже существует"), "uq_related_group_member_group_entity_current": ConstraintConflict("MEMBER_EXISTS", "ЮЛ уже входит в эту группу"), # Нарушается и при повторном добавлении в ту же группу (PostgreSQL проверяет индексы в порядке OID, а этот создан # миграцией 0002 раньше второго), поэтому текст — общий для обоих случаев, а не «в другую группу» как у pre-check-а "uq_related_group_member_entity_kind_current": ConstraintConflict( "MEMBER_OF_OTHER_GROUP", "ЮЛ уже входит в группу этого вида" ), } def _sqlstate(orig: Any) -> str | None: value = getattr(orig, "sqlstate", None) or getattr(orig, "pgcode", None) return value if isinstance(value, str) else None def constraint_name(exc: DBAPIError) -> str | None: """Имя нарушенного ограничения из полей драйвера (asyncpg — `__cause__`, psycopg — `diag`); текст не разбирается.""" orig = exc.orig if orig is None: return None for source in (getattr(orig, "__cause__", None), orig): name = getattr(source, "constraint_name", None) if isinstance(name, str) and name: return name diag_name = getattr(getattr(source, "diag", None), "constraint_name", None) if isinstance(diag_name, str) and diag_name: return diag_name return None def resolve_unique_conflict(exc: DBAPIError) -> ConstraintConflict | None: """Конфликт из реестра, если это `unique_violation` по известному ограничению; иначе `None` (общий 409).""" if _sqlstate(exc.orig) != UNIQUE_VIOLATION_SQLSTATE: return None name = constraint_name(exc) return UNIQUE_CONSTRAINT_CONFLICTS.get(name) if name is not None else None