crm_id пишется в колонку related_group.crm_id (v5.19), попадает в ответы и в payload аудита; занятый значением другой ГСК/ГСЗ даёт 409 CRM_ID_EXISTS (pre-check и uq_related_group_crm_id на гонку). Смена crm_id пишется как GROUP_CHANGE с before/after всех изменённых полей (Р-41, P3-18). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
83 lines
5.5 KiB
Python
83 lines
5.5 KiB
Python
"""Гонки уникальных ограничений: имя нарушенного ограничения 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_related_group_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_consolidation_group_year_quarter": ConstraintConflict(
|
||
"CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана"
|
||
),
|
||
# блокировки версии выстраиваются `FOR UPDATE` версии; индекс — последний рубеж (`consolidation_locks._insert`
|
||
# ловит его под SAVEPOINT и добавляет владельца), реестр — на случай вставки вне этого пути
|
||
"uq_consolidation_lock_version_active": ConstraintConflict(
|
||
"LOCKED_BY_OTHER", "Версию держит в работе другой пользователь"
|
||
),
|
||
"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
|