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:
parent
4f21160cf5
commit
b7f45c6a47
@ -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 | перевод в ту же ГК |
|
||||
|
||||
@ -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", "У этого ЮЛ уже есть такой алиас"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 уже существует")
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user