feat(directories): crm_id ГСК/ГСЗ сохраняется, 409 CRM_ID_EXISTS (arm-core#112)

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
This commit is contained in:
Andrey Epifancev 2026-10-18 13:39:21 +04:00
parent 4f21160cf5
commit b7f45c6a47
8 changed files with 95 additions and 42 deletions

View File

@ -830,7 +830,7 @@ API: список счетов `GET /directories/accounts`, правила ве
**Гонки уникальных ограничений.** Если параллельный запрос успел вставить запись между проверкой сервиса и INSERT,
БД отклоняет вставку (`unique_violation`), и ответ тот же 409 с тем же кодом, что у проверки (реестр —
`src/common/constraint_conflicts.py`): `CRM_ID_EXISTS` (ГК и ЮЛ), `INN_EXISTS`, `ALIAS_EXISTS`, `EMAIL_EXISTS`,
`src/common/constraint_conflicts.py`): `CRM_ID_EXISTS` (ГК, ЮЛ и ГСК/ГСЗ), `INN_EXISTS`, `ALIAS_EXISTS`, `EMAIL_EXISTS`,
`MEMBER_EXISTS`/`MEMBER_OF_OTHER_GROUP`. В теле гонки — только код и постоянный текст, без дополнительных полей
(`legal_entity_id`, `related_group_id`) и без значений ключей. Особенности: повторное добавление в ту же группу
отдаёт `MEMBER_OF_OTHER_GROUP` с нейтральным текстом «ЮЛ уже входит в группу этого вида» (его раньше срабатывает индекс
@ -853,7 +853,7 @@ email без учёта регистра проверяет только сер
| `WRONG_GROUP_KIND` | 422 | в `gsk_id` формы ЮЛ передана ГСЗ или в `gsz_id` — ГСК (`POST`/`PATCH /directories/legal-entities`) |
| `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) |
| `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) |
| `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть |
| `CRM_ID_EXISTS` | 409 | ГК, ЮЛ или ГСК/ГСЗ с таким `crm_id` уже есть |
| `INN_EXISTS` | 409 | ЮЛ с таким ИНН уже есть (без дополнительных полей) |
| `LEGAL_ENTITY_CLOSED` | 409 | перевод закрытого ЮЛ |
| `SAME_COMPANY_GROUP` | 409 | перевод в ту же ГК |

View File

@ -31,6 +31,7 @@ class ConstraintConflict:
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", "У этого ЮЛ уже есть такой алиас"

View File

@ -101,13 +101,23 @@ async def get_company_group(session: AsyncSession, scope: VisibilityScope, group
async def crm_id_taken(session: AsyncSession, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> bool:
"""Есть ли у какой-либо ГК (кроме `except_group_id`) такой `crm_id` — без фильтра видимости: уникален глобально."""
"""Есть ли у какой-либо ГК (кроме `except_group_id`) такой `crm_id`; без фильтра видимости (уникален глобально)."""
stmt = select(CompanyGroup.id).where(CompanyGroup.crm_id == crm_id)
if except_group_id is not None:
stmt = stmt.where(CompanyGroup.id != except_group_id)
return (await session.execute(stmt.limit(1))).first() is not None
async def related_group_crm_id_taken(
session: AsyncSession, crm_id: str, *, except_group_id: uuid.UUID | None = None
) -> bool:
"""Есть ли у ГСК/ГСЗ (кроме `except_group_id`) такой `crm_id`; без фильтра видимости (уникален глобально)."""
stmt = select(RelatedGroup.id).where(RelatedGroup.crm_id == crm_id)
if except_group_id is not None:
stmt = stmt.where(RelatedGroup.id != except_group_id)
return (await session.execute(stmt.limit(1))).first() is not None
async def count_legal_entities(session: AsyncSession, group_id: uuid.UUID) -> int:
"""Число ЮЛ/ИП с `company_group_id = group_id`."""
stmt = select(func.count(LegalEntity.id)).where(LegalEntity.company_group_id == group_id)

View File

@ -363,7 +363,7 @@ class LegalEntityFilter(BaseModel):
class RelatedGroupCreate(BaseModel):
"""Создание ГСК/ГСЗ в ГК: `code` (`GSK-`/`GSZ-NNNNNN`) назначает сервис.
`crm_id` («ID ГСК/ГСЗ в CRM» форм 51-03/51-04) принимается и отбрасывается: колонки в DBML нет (arm-core #112).
`crm_id` («ID ГСК/ГСЗ в CRM» форм 51-03/51-04, v5.19) сохраняется; пустая строка — «нет значения».
"""
model_config = ConfigDict(extra="forbid")
@ -387,7 +387,7 @@ class RelatedGroupCreate(BaseModel):
class RelatedGroupUpdate(BaseModel):
"""Изменение ГСК/ГСЗ: `name`, `created_date` (вид и ГК не меняются); переданные поля — `model_fields_set`.
`crm_id` (форма 77-02) принимается и отбрасывается, как при создании (arm-core #112).
`crm_id` (форма 77-02) сохраняется; явный `null` очищает значение.
"""
model_config = ConfigDict(extra="forbid")
@ -422,7 +422,7 @@ class MemberAdd(BaseModel):
class RelatedGroupOut(BaseModel):
"""ГСК/ГСЗ; `members_count` — число текущих членов; `crm_id` — всегда `null`, пока нет колонки (arm-core #112)."""
"""ГСК/ГСЗ; `members_count` — число текущих членов; `crm_id` — из БД (v5.19)."""
model_config = ConfigDict(from_attributes=True)

View File

@ -35,7 +35,7 @@ MEMBERSHIP_NOT_FOUND = "ЮЛ не входит в эту группу"
OBJECT_TYPE = "related_group"
MEMBER_OBJECT_TYPE = "related_group_member"
CODE_PREFIXES = {RelatedGroupKind.gsk: "GSK", RelatedGroupKind.gsz: "GSZ"}
TRACKED_FIELDS = ("name", "created_date") # поля группы, изменяемые PATCH
TRACKED_FIELDS = ("name", "created_date", "crm_id") # поля группы, изменяемые PATCH
def member_payload(member: RelatedGroupMember) -> dict[str, Any]:
@ -115,12 +115,15 @@ class RelatedGroupService:
async def create(self, company_group_id: uuid.UUID, data: RelatedGroupCreate) -> RelatedGroup:
"""Новая ГСК/ГСЗ видимой ГК с кодом `GSK-`/`GSZ-NNNNNN`; аудит `MEMBERSHIP_CHANGE`."""
company_group = await self._visible_company_group(company_group_id)
if data.crm_id is not None:
await self._check_crm_id_free(data.crm_id)
group = RelatedGroup(
code=await repo.next_code(self._session, RELATED_GROUP_CODE_SEQ, CODE_PREFIXES[data.kind]),
company_group_id=company_group.id,
kind=data.kind,
name=data.name,
created_date=data.created_date,
crm_id=data.crm_id,
)
self._session.add(group)
await self._session.flush()
@ -134,7 +137,11 @@ class RelatedGroupService:
return group
async def update(self, group_id: uuid.UUID, data: RelatedGroupUpdate) -> RelatedGroup:
"""Изменение `name`/`created_date`; без фактических изменений ничего не пишется."""
"""Изменение `name`/`created_date`/`crm_id`; без фактических изменений ничего не пишется.
Смена `crm_id` пишется как изменение группы — `GROUP_CHANGE` с `before`/`after` всех изменённых полей
(Р-41, P3-18); прочие правки — `MEMBERSHIP_CHANGE`, как раньше.
"""
group = await self._get_group(group_id)
changes: dict[str, Any] = {
field: getattr(data, field)
@ -143,12 +150,14 @@ class RelatedGroupService:
}
if not changes:
return group
if changes.get("crm_id") is not None:
await self._check_crm_id_free(changes["crm_id"], except_group_id=group.id)
before = {field: getattr(group, field) for field in changes}
for field, value in changes.items():
setattr(group, field, value)
await self._session.flush()
await self._audit.record(
action=AuditAction.MEMBERSHIP_CHANGE,
action=AuditAction.GROUP_CHANGE if "crm_id" in changes else AuditAction.MEMBERSHIP_CHANGE,
section=AuditSection.DIRECTORIES,
object_type=OBJECT_TYPE,
object_id=group.id,
@ -249,4 +258,9 @@ class RelatedGroupService:
"kind": group.kind,
"name": group.name,
"created_date": group.created_date,
"crm_id": group.crm_id,
}
async def _check_crm_id_free(self, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> None:
if await repo.related_group_crm_id_taken(self._session, crm_id, except_group_id=except_group_id):
raise ConflictError("CRM_ID_EXISTS", "ГСК/ГСЗ с таким crm_id уже существует")

View File

@ -1,8 +1,4 @@
"""`crm_id` ГСК/ГСЗ в API без колонки в БД (arm-core #111; колонка — #112).
Формы 51-03 / 51-04 / 77-02 шлют «ID ГСК/ГСЗ в CRM»: POST/PATCH его принимают, значение отбрасывается,
в ответах `crm_id` всегда `null`, в аудит не попадает.
"""
"""`crm_id` ГСК/ГСЗ (v5.19, arm-core #112): сохраняется, уникален, смена пишется в аудит как `GROUP_CHANGE` (Р-41)."""
from collections.abc import Sequence
from typing import Any
@ -19,16 +15,16 @@ from tests.factories import make_company_group
HTTP_OK = 200
HTTP_CREATED = 201
HTTP_CONFLICT = 409
HTTP_UNPROCESSABLE = 422
GROUPS = "/directories/company-groups"
RELATED = "/directories/related-groups"
GO = "ka_go@armka.local"
CONFLICT_DETAIL = {"code": "CRM_ID_EXISTS", "message": "ГСК/ГСЗ с таким crm_id уже существует"}
async def _events(session: AsyncSession) -> Sequence[AuditEvent]:
stmt = select(AuditEvent).where(
AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE, AuditEvent.object_type == "related_group"
)
async def _events(session: AsyncSession, action: AuditAction) -> Sequence[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action == action, AuditEvent.object_type == "related_group")
return (await session.execute(stmt.order_by(AuditEvent.occurred_at, AuditEvent.id))).scalars().all()
@ -37,49 +33,79 @@ async def _create(client: httpx.AsyncClient, group_id: Any, **extra: Any) -> htt
@pytest.mark.integration
async def test_create_accepts_crm_id_and_drops_it(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
async def test_create_related_group_stores_crm_id(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded)
client = await auth_client(GO)
response = await _create(client, group.id, crm_id=" 3-9CGSK01 ")
response = await _create(client, group.id, crm_id=" 51-03-0001 ")
assert response.status_code == HTTP_CREATED, response.text
body = response.json()
assert body["crm_id"] is None
assert body["name"] == "ГСК А"
events = await _events(seeded)
assert len(events) == 1
assert "crm_id" not in events[0].after_value
assert body["crm_id"] == "51-03-0001"
assert (await client.get(f"{RELATED}/{body['id']}")).json()["crm_id"] == "51-03-0001"
listed = await client.get(f"{GROUPS}/{group.id}/related-groups")
assert [row["crm_id"] for row in listed.json()] == ["51-03-0001"]
(event,) = await _events(seeded, AuditAction.MEMBERSHIP_CHANGE)
assert event.after_value["crm_id"] == "51-03-0001"
for value in ("", None):
assert (await _create(client, group.id, crm_id=value)).status_code == HTTP_CREATED
created = await _create(client, group.id, crm_id=value)
assert created.status_code == HTTP_CREATED
assert created.json()["crm_id"] is None
assert (await _create(client, group.id, crm_id=123)).status_code == HTTP_UNPROCESSABLE
@pytest.mark.integration
async def test_patch_accepts_crm_id_without_changes(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
async def test_patch_related_group_crm_id_audited(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded)
client = await auth_client(GO)
gsk = (await _create(client, group.id)).json()
only_crm = await client.patch(f"{RELATED}/{gsk['id']}", json={"crm_id": "3-9CGSK01"})
assert only_crm.status_code == HTTP_OK, only_crm.text
assert only_crm.json()["crm_id"] is None
assert len(await _events(seeded)) == 1 # только создание: crm_id не изменение
response = await client.patch(f"{RELATED}/{gsk['id']}", json={"crm_id": "77-02-0002", "name": "ГСК Б"})
assert response.status_code == HTTP_OK, response.text
assert response.json()["crm_id"] == "77-02-0002"
(event,) = await _events(seeded, AuditAction.GROUP_CHANGE)
assert event.before_value["crm_id"] is None
assert event.after_value == {"crm_id": "77-02-0002", "name": "ГСК Б"}
assert event.before_value["name"] == "ГСК А"
with_name = await client.patch(f"{RELATED}/{gsk['id']}", json={"crm_id": None, "name": "ГСК Б"})
assert with_name.status_code == HTTP_OK, with_name.text
assert (with_name.json()["name"], with_name.json()["crm_id"]) == ("ГСК Б", None)
events = await _events(seeded)
assert len(events) == 2 # noqa: PLR2004 - создание и смена имени
assert events[1].after_value == {"name": "ГСК Б"}
# смена без crm_id остаётся изменением состава/реквизитов прежнего вида
await client.patch(f"{RELATED}/{gsk['id']}", json={"name": "ГСК В"})
assert len(await _events(seeded, AuditAction.GROUP_CHANGE)) == 1
@pytest.mark.integration
async def test_list_and_detail_return_null_crm_id(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
async def test_crm_id_taken_is_409(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded)
other = await make_company_group(seeded)
client = await auth_client(GO)
assert (await _create(client, group.id, crm_id="77-02-0002")).status_code == HTTP_CREATED
same_group = await _create(client, group.id, crm_id="77-02-0002")
other_group = await _create(client, other.id, crm_id="77-02-0002", kind="gsz")
for response in (same_group, other_group):
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"] == CONFLICT_DETAIL
assert len((await client.get(f"{GROUPS}/{group.id}/related-groups")).json()) == 1
assert (await client.get(f"{GROUPS}/{other.id}/related-groups")).json() == []
second = (await _create(client, group.id, crm_id="77-02-0003")).json()
patched = await client.patch(f"{RELATED}/{second['id']}", json={"crm_id": "77-02-0002"})
assert patched.status_code == HTTP_CONFLICT
assert patched.json()["detail"] == CONFLICT_DETAIL
# своё же значение — не конфликт
same = await client.patch(f"{RELATED}/{second['id']}", json={"crm_id": "77-02-0003"})
assert same.status_code == HTTP_OK
@pytest.mark.integration
async def test_patch_crm_id_null_clears(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded)
client = await auth_client(GO)
gsk = (await _create(client, group.id, crm_id="3-9CGSK01")).json()
gsk = (await _create(client, group.id, crm_id="51-03-0001")).json()
listed = await client.get(f"{GROUPS}/{group.id}/related-groups")
assert [row["crm_id"] for row in listed.json()] == [None]
assert (await client.get(f"{RELATED}/{gsk['id']}")).json()["crm_id"] is None
response = await client.patch(f"{RELATED}/{gsk['id']}", json={"crm_id": None})
assert response.status_code == HTTP_OK, response.text
assert response.json()["crm_id"] is None
(event,) = await _events(seeded, AuditAction.GROUP_CHANGE)
assert event.before_value == {"crm_id": "51-03-0001"}
assert event.after_value == {"crm_id": None}

View File

@ -98,6 +98,7 @@ async def test_create_gsk_and_gsz_codes_and_audit(auth_client: AuthClientFactory
"kind": "gsk",
"name": "ГСК Бисквит",
"created_date": "2025-01-15",
"crm_id": None,
}
assert first.user_id is not None

View File

@ -180,6 +180,7 @@ def test_registry_codes_and_messages_match_service_precheck_texts() -> None:
assert codes == {
"uq_company_group_crm_id": "CRM_ID_EXISTS",
"uq_legal_entity_crm_id": "CRM_ID_EXISTS",
"uq_related_group_crm_id": "CRM_ID_EXISTS",
"uq_legal_entity_inn": "INN_EXISTS",
"uq_legal_entity_alias_legal_entity_id_normalized_alias": "ALIAS_EXISTS",
"uq_app_user_email": "EMAIL_EXISTS",