ГСК/ГСЗ: POST/PATCH принимают crm_id без сохранения (#111)
Формы 51-03/51-04/77-02 шлют «ID ГСК/ГСЗ в CRM», а extra=forbid отвечал 422. crm_id принимается (trim, пустая строка → null), отбрасывается; в ответах RelatedGroupOut/RelatedGroupDetail — всегда null, в аудит не попадает. Колонка related_group.crm_id — отдельная задача #112 (DBML v5.19). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f5ddba7005
commit
244d97b1df
@ -335,33 +335,51 @@ class LegalEntityFilter(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RelatedGroupCreate(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")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
kind: RelatedGroupKind
|
kind: RelatedGroupKind
|
||||||
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
|
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
|
||||||
created_date: datetime.date | None = None
|
created_date: datetime.date | None = None
|
||||||
|
crm_id: str | None = None
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _strip_name(cls, value: str) -> str | None:
|
def _strip_name(cls, value: str) -> str | None:
|
||||||
return _strip_required_name(value)
|
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):
|
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")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH)
|
name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH)
|
||||||
created_date: datetime.date | None = None
|
created_date: datetime.date | None = None
|
||||||
|
crm_id: str | None = None
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _strip_name(cls, value: str | None) -> str | None:
|
def _strip_name(cls, value: str | None) -> str | None:
|
||||||
return _strip_required_name(value)
|
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")
|
@model_validator(mode="after")
|
||||||
def _name_is_not_null(self) -> Self:
|
def _name_is_not_null(self) -> Self:
|
||||||
if "name" in self.model_fields_set and self.name is None:
|
if "name" in self.model_fields_set and self.name is None:
|
||||||
@ -378,7 +396,7 @@ class MemberAdd(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RelatedGroupOut(BaseModel):
|
class RelatedGroupOut(BaseModel):
|
||||||
"""ГСК/ГСЗ; `members_count` — число текущих членов."""
|
"""ГСК/ГСЗ; `members_count` — число текущих членов; `crm_id` — всегда `null`, пока нет колонки (arm-core #112)."""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@ -388,6 +406,7 @@ class RelatedGroupOut(BaseModel):
|
|||||||
kind: RelatedGroupKind
|
kind: RelatedGroupKind
|
||||||
name: str
|
name: str
|
||||||
created_date: datetime.date | None
|
created_date: datetime.date | None
|
||||||
|
crm_id: str | None = None
|
||||||
members_count: int = 0
|
members_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
85
api/tests/test_api_related_group_crm_id.py
Normal file
85
api/tests/test_api_related_group_crm_id.py
Normal 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
|
||||||
@ -83,6 +83,7 @@ async def test_create_gsk_and_gsz_codes_and_audit(auth_client: AuthClientFactory
|
|||||||
"kind": "gsk",
|
"kind": "gsk",
|
||||||
"name": "ГСК Бисквит",
|
"name": "ГСК Бисквит",
|
||||||
"created_date": "2025-01-15",
|
"created_date": "2025-01-15",
|
||||||
|
"crm_id": None,
|
||||||
"members_count": 0,
|
"members_count": 0,
|
||||||
}
|
}
|
||||||
assert gsz_body["created_date"] is None
|
assert gsz_body["created_date"] is None
|
||||||
|
|||||||
@ -152,7 +152,13 @@ SHALL установить `company_group_id = NULL`, при `CLOSE` — ещё
|
|||||||
`DIFFERENT_COMPANY_GROUP`); если у ЮЛ уже есть текущее членство в группе того же вида — 409 `MEMBER_OF_OTHER_GROUP`
|
`DIFFERENT_COMPANY_GROUP`); если у ЮЛ уже есть текущее членство в группе того же вида — 409 `MEMBER_OF_OTHER_GROUP`
|
||||||
(с `related_group_id`); повтор в той же группе — 409 `MEMBER_EXISTS`. `DELETE …/members/{legal_entity_id}` SHALL закрывать
|
(с `related_group_id`); повтор в той же группе — 409 `MEMBER_EXISTS`. `DELETE …/members/{legal_entity_id}` SHALL закрывать
|
||||||
членство (`valid_to`, `is_current = false`). Все операции пишут `MEMBERSHIP_CHANGE`. `GET …/related-groups` и
|
членство (`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: Одновременно в ГСК и ГСЗ
|
#### Scenario: Одновременно в ГСК и ГСЗ
|
||||||
- **WHEN** ЮЛ добавлено в ГСК своей ГК, затем в ГСЗ той же ГК
|
- **WHEN** ЮЛ добавлено в ГСК своей ГК, затем в ГСЗ той же ГК
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user