diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 0e0ea50..53d827a 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -335,33 +335,51 @@ class LegalEntityFilter(BaseModel): class RelatedGroupCreate(BaseModel): - """Создание ГСК/ГСЗ в ГК: `code` (`GSK-`/`GSZ-NNNNNN`) назначает сервис.""" + """Создание ГСК/ГСЗ в ГК: `code` (`GSK-`/`GSZ-NNNNNN`) назначает сервис. + + `crm_id` («ID ГСК/ГСЗ в CRM» форм 51-03/51-04) принимается и отбрасывается: колонки в DBML нет (arm-core #112). + """ model_config = ConfigDict(extra="forbid") kind: RelatedGroupKind name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) created_date: datetime.date | None = None + crm_id: str | None = None @field_validator("name") @classmethod def _strip_name(cls, value: str) -> str | None: return _strip_required_name(value) + @field_validator("crm_id") + @classmethod + def _normalize_crm_id(cls, value: str | None) -> str | None: + return _blank_to_none(value) + class RelatedGroupUpdate(BaseModel): - """Изменение ГСК/ГСЗ: `name`, `created_date` (вид и ГК не меняются); переданные поля — `model_fields_set`.""" + """Изменение ГСК/ГСЗ: `name`, `created_date` (вид и ГК не меняются); переданные поля — `model_fields_set`. + + `crm_id` (форма 77-02) принимается и отбрасывается, как при создании (arm-core #112). + """ model_config = ConfigDict(extra="forbid") name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH) created_date: datetime.date | None = None + crm_id: str | None = None @field_validator("name") @classmethod def _strip_name(cls, value: str | None) -> str | None: return _strip_required_name(value) + @field_validator("crm_id") + @classmethod + def _normalize_crm_id(cls, value: str | None) -> str | None: + return _blank_to_none(value) + @model_validator(mode="after") def _name_is_not_null(self) -> Self: if "name" in self.model_fields_set and self.name is None: @@ -378,7 +396,7 @@ class MemberAdd(BaseModel): class RelatedGroupOut(BaseModel): - """ГСК/ГСЗ; `members_count` — число текущих членов.""" + """ГСК/ГСЗ; `members_count` — число текущих членов; `crm_id` — всегда `null`, пока нет колонки (arm-core #112).""" model_config = ConfigDict(from_attributes=True) @@ -388,6 +406,7 @@ class RelatedGroupOut(BaseModel): kind: RelatedGroupKind name: str created_date: datetime.date | None + crm_id: str | None = None members_count: int = 0 diff --git a/api/tests/test_api_related_group_crm_id.py b/api/tests/test_api_related_group_crm_id.py new file mode 100644 index 0000000..191cf14 --- /dev/null +++ b/api/tests/test_api_related_group_crm_id.py @@ -0,0 +1,85 @@ +"""`crm_id` ГСК/ГСЗ в API без колонки в БД (arm-core #111; колонка — #112). + +Формы 51-03 / 51-04 / 77-02 шлют «ID ГСК/ГСЗ в CRM»: POST/PATCH его принимают, значение отбрасывается, +в ответах `crm_id` всегда `null`, в аудит не попадает. +""" + +from collections.abc import Sequence +from typing import Any + +import httpx +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.audit_ex import AuditEvent +from src.models.enums import AuditAction +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_UNPROCESSABLE = 422 +GROUPS = "/directories/company-groups" +RELATED = "/directories/related-groups" +GO = "ka_go@armka.local" + + +async def _events(session: AsyncSession) -> Sequence[AuditEvent]: + stmt = select(AuditEvent).where( + AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE, AuditEvent.object_type == "related_group" + ) + return (await session.execute(stmt.order_by(AuditEvent.occurred_at, AuditEvent.id))).scalars().all() + + +async def _create(client: httpx.AsyncClient, group_id: Any, **extra: Any) -> httpx.Response: + return await client.post(f"{GROUPS}/{group_id}/related-groups", json={"kind": "gsk", "name": "ГСК А", **extra}) + + +@pytest.mark.integration +async def test_create_accepts_crm_id_and_drops_it(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 ") + 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 + + for value in ("", None): + assert (await _create(client, group.id, crm_id=value)).status_code == HTTP_CREATED + 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: + 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 не изменение + + 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": "ГСК Б"} + + +@pytest.mark.integration +async def test_list_and_detail_return_null_crm_id(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() + + 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 diff --git a/api/tests/test_api_related_groups.py b/api/tests/test_api_related_groups.py index 5fc06ed..d6d2257 100644 --- a/api/tests/test_api_related_groups.py +++ b/api/tests/test_api_related_groups.py @@ -83,6 +83,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, "members_count": 0, } assert gsz_body["created_date"] is None diff --git a/openspec/specs/directories-api/spec.md b/openspec/specs/directories-api/spec.md index 46b6364..f9491f3 100644 --- a/openspec/specs/directories-api/spec.md +++ b/openspec/specs/directories-api/spec.md @@ -152,7 +152,13 @@ SHALL установить `company_group_id = NULL`, при `CLOSE` — ещё `DIFFERENT_COMPANY_GROUP`); если у ЮЛ уже есть текущее членство в группе того же вида — 409 `MEMBER_OF_OTHER_GROUP` (с `related_group_id`); повтор в той же группе — 409 `MEMBER_EXISTS`. `DELETE …/members/{legal_entity_id}` SHALL закрывать членство (`valid_to`, `is_current = false`). Все операции пишут `MEMBERSHIP_CHANGE`. `GET …/related-groups` и -`GET …/related-groups/{id}` SHALL отдавать группы с числом текущих членов и списком членов. +`GET …/related-groups/{id}` SHALL отдавать группы с числом текущих членов и списком членов. Тела `POST` и `PATCH` SHALL +принимать необязательный `crm_id` («ID ГСК/ГСЗ в CRM» форм 51-03/51-04/77-02) и отбрасывать его: колонки у `related_group` +нет (arm-core #112); в ответах `crm_id` всегда `null`, в аудит и в список изменений `PATCH` не попадает. + +#### Scenario: `crm_id` ГСК принимается и не сохраняется +- **WHEN** ГСК создаётся с `crm_id = "3-9CGSK01"`, затем `PATCH` передаёт только `crm_id` +- **THEN** создание — 201 с `crm_id = null`; `PATCH` — 200 без изменений и без события аудита #### Scenario: Одновременно в ГСК и ГСЗ - **WHEN** ЮЛ добавлено в ГСК своей ГК, затем в ГСЗ той же ГК