From b7f45c6a478e0447467e57648b188adc22a2cda5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:39:21 +0400 Subject: [PATCH] =?UTF-8?q?feat(directories):=20crm=5Fid=20=D0=93=D0=A1?= =?UTF-8?q?=D0=9A/=D0=93=D0=A1=D0=97=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F,=20409=20CRM=5FID=5FEXISTS?= =?UTF-8?q?=20(arm-core#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 4 +- api/src/common/constraint_conflicts.py | 1 + api/src/repositories/directories.py | 12 ++- api/src/schemas/directories.py | 6 +- api/src/services/related_groups.py | 20 ++++- api/tests/test_api_related_group_crm_id.py | 92 ++++++++++++++-------- api/tests/test_api_related_groups.py | 1 + api/tests/test_exception_handlers.py | 1 + 8 files changed, 95 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b4dc3d7..b82e3e8 100644 --- a/README.md +++ b/README.md @@ -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 | перевод в ту же ГК | diff --git a/api/src/common/constraint_conflicts.py b/api/src/common/constraint_conflicts.py index 0c8a015..6fc9448 100644 --- a/api/src/common/constraint_conflicts.py +++ b/api/src/common/constraint_conflicts.py @@ -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", "У этого ЮЛ уже есть такой алиас" diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index a475b57..a745220 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -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) diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index a376362..39636dd 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -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) diff --git a/api/src/services/related_groups.py b/api/src/services/related_groups.py index bfd37b4..3aab025 100644 --- a/api/src/services/related_groups.py +++ b/api/src/services/related_groups.py @@ -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 уже существует") diff --git a/api/tests/test_api_related_group_crm_id.py b/api/tests/test_api_related_group_crm_id.py index 191cf14..29de80a 100644 --- a/api/tests/test_api_related_group_crm_id.py +++ b/api/tests/test_api_related_group_crm_id.py @@ -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} diff --git a/api/tests/test_api_related_groups.py b/api/tests/test_api_related_groups.py index d6d2257..f00e98e 100644 --- a/api/tests/test_api_related_groups.py +++ b/api/tests/test_api_related_groups.py @@ -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 diff --git a/api/tests/test_exception_handlers.py b/api/tests/test_exception_handlers.py index 48c1480..fbe5cdb 100644 --- a/api/tests/test_exception_handlers.py +++ b/api/tests/test_exception_handlers.py @@ -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",