Merge pull request 'ГСК/ГСЗ: POST/PATCH принимают crm_id без сохранения (#111)' (#113) from feat/111-rg-crm-id into main

Reviewed-on: #113
This commit is contained in:
aep 2026-10-14 12:04:51 +03:00
commit 8b86735828
4 changed files with 115 additions and 4 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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** ЮЛ добавлено в ГСК своей ГК, затем в ГСЗ той же ГК