arm-core/api/src/schemas/directories.py
Andrey Epifancev 244d97b1df ГСК/ГСЗ: 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>
2026-10-14 12:40:59 +04:00

426 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Схемы API справочников (spec directories-api): филиалы, ГК, ЮЛ/ИП; поля — как в DBML v5.15."""
import datetime
import uuid
from typing import Annotated, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
from src.models.enums import EntityType, LeHistoryEvent, RelatedGroupKind
NAME_MAX_LENGTH = 500
MAX_ALIASES = 100 # алиасов в одном теле формы ЮЛ (#84): граница входа, 422 до обращения к БД
AliasText = Annotated[str, Field(min_length=1, max_length=NAME_MAX_LENGTH)]
def _blank_to_none(value: str | None) -> str | None:
"""`crm_id`: пробелы по краям срезаются, пустая строка — «нет значения» (иначе пустые `crm_id` конфликтовали бы)."""
if value is None:
return None
stripped = value.strip()
return stripped or None
class RegionalOfficeBrief(BaseModel):
"""Филиал кратко: профиль `GET /me` (форма ответа Среза 0 — без `is_active`)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
is_head_office: bool
class RegionalOfficeOut(RegionalOfficeBrief):
"""Филиал целиком (`GET /directories/regional-offices`, пользователи администратора)."""
is_active: bool
class CompanyGroupCreate(BaseModel):
"""Создание ГК: `code` и `valid_from` назначает сервис."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
crm_id: str | None = None
base_regional_office_id: uuid.UUID | None = None
@field_validator("name")
@classmethod
def _strip_name(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("название не может быть пустым")
return stripped
@field_validator("crm_id")
@classmethod
def _normalize_crm_id(cls, value: str | None) -> str | None:
return _blank_to_none(value)
class CompanyGroupUpdate(BaseModel):
"""Изменение ГК: «поле передано» определяется по `model_fields_set` (явный `null` у `crm_id` очищает значение)."""
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH)
crm_id: str | None = None
base_regional_office_id: uuid.UUID | None = None
@field_validator("name")
@classmethod
def _strip_name(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
if not stripped:
raise ValueError("название не может быть пустым")
return stripped
@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:
raise ValueError("название нельзя обнулить")
return self
class CompanyGroupOut(BaseModel):
"""Карточка ГК; `legal_entities_count` — число ЮЛ с `company_group_id` этой ГК."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
code: str
crm_id: str | None
name: str
base_regional_office_id: uuid.UUID | None
base_regional_office: RegionalOfficeBrief | None = (
None # объект филиала; неактивный тоже отдаётся (имя для истории)
)
is_archived: bool
valid_from: datetime.datetime
legal_entities_count: int = 0
created_at: datetime.datetime | None
updated_at: datetime.datetime | None
class CompanyGroupHistoryOut(BaseModel):
"""Прошлый снимок ГК (SCD4)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
crm_id: str | None
name: str | None
base_regional_office_id: uuid.UUID | None
base_regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id)
valid_from: datetime.datetime | None
valid_to: datetime.datetime | None
changed_by: uuid.UUID | None
class CompanyGroupFilter(BaseModel):
"""Фильтры списка ГК: `q` — подстрока `name`/`crm_id`/`code` без учёта регистра."""
q: str | None = None
is_archived: bool | None = None
def _strip_required_name(value: str | None) -> str | None:
"""Имя: пробелы по краям срезаются; пустое после `strip` — ошибка валидации (`None` пропускается для PATCH)."""
if value is None:
return None
stripped = value.strip()
if not stripped:
raise ValueError("название не может быть пустым")
return stripped
class LegalEntityCreate(BaseModel):
"""Создание ЮЛ/ИП: `inn` и `kpp` проверяются по `entity_type` в сервисе (`INVALID_INN`, `INVALID_KPP`); без ГК —
монозаёмщик. `kpp` необязателен, у ИП запрещён (DBML v5.17). Форма целиком (#84): `aliases` — алиасы как в ОСВ,
`gsk_id`/`gsz_id` — ГСК/ГСЗ той же ГК; создаются в той же транзакции."""
model_config = ConfigDict(extra="forbid")
inn: str
kpp: str | None = None
entity_type: EntityType
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
company_group_id: uuid.UUID | None = None
regional_office_id: uuid.UUID | None = None
crm_id: str | None = None
aliases: list[AliasText] = Field(default_factory=list, max_length=MAX_ALIASES)
gsk_id: uuid.UUID | None = None
gsz_id: uuid.UUID | 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 LegalEntityUpdate(BaseModel):
"""Изменение ЮЛ: `inn` и ГК PATCH не меняет (ГК — `move`/`withdraw`); переданные поля — `model_fields_set`.
Форма целиком (#84): `aliases` — итоговый набор (сверка по `normalized_alias`), `gsk_id`/`gsz_id` — итоговые
ГСК/ГСЗ; не переданное поле не меняется."""
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH)
entity_type: EntityType | None = None
crm_id: str | None = None
regional_office_id: uuid.UUID | None = None
kpp: str | None = None # null очищает КПП; формат и запрет для ИП проверяет сервис (INVALID_KPP)
aliases: list[AliasText] | None = Field(default=None, max_length=MAX_ALIASES) # итоговый набор; null запрещён
gsk_id: uuid.UUID | None = None # null — выйти из текущей ГСК
gsz_id: uuid.UUID | None = None # null — выйти из текущей ГСЗ
@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 _required_fields_are_not_null(self) -> Self:
for field in ("name", "entity_type", "aliases"):
if field in self.model_fields_set and getattr(self, field) is None:
raise ValueError(f"поле {field} нельзя обнулить")
return self
class LegalEntityMove(BaseModel):
"""Перевод ЮЛ в другую ГК с даты `effective_date` (не раньше сегодняшней)."""
model_config = ConfigDict(extra="forbid")
company_group_id: uuid.UUID
effective_date: datetime.date
class LegalEntityWithdraw(BaseModel):
"""Вывод ЮЛ из состава ГК: `CLOSE` (закрытие, `close_date = effective_date`) или `REMOVE_FROM_GROUP`."""
model_config = ConfigDict(extra="forbid")
event: Literal["CLOSE", "REMOVE_FROM_GROUP"]
effective_date: datetime.date
comment: str | None = None
class MembershipOut(BaseModel):
"""Текущее членство ЮЛ в ГСК/ГСЗ (список, карточка и ответы ЮЛ, #109)."""
related_group_id: uuid.UUID
code: str
kind: RelatedGroupKind
name: str
valid_from: datetime.datetime
class LegalEntityOut(BaseModel):
"""ЮЛ/ИП; `outside_group` — монозаёмщик (`company_group_id IS NULL`), `closed` — заполнен `close_date`.
`documents_count` — число документов файлов ЮЛ со статусом файла не SUPERSEDED (D-13); `memberships` — текущие
членства ГСК/ГСЗ (по виду и коду группы; после перевода и вывода из ГК — пусто, #109).
"""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
inn: str
kpp: str | None
crm_id: str | None
entity_type: EntityType | None
name: str
company_group_id: uuid.UUID | None
company_group_name: str | None = None # текущее имя ГК (архивной тоже); у монозаёмщика — null
regional_office_id: uuid.UUID | None
regional_office: RegionalOfficeBrief | None = None # объект филиала; неактивный тоже отдаётся
close_date: datetime.date | None
valid_from: datetime.datetime
created_at: datetime.datetime | None
updated_at: datetime.datetime | None
documents_count: int = 0
memberships: list[MembershipOut] = []
@computed_field # type: ignore[prop-decorator]
@property
def outside_group(self) -> bool:
return self.company_group_id is None
@computed_field # type: ignore[prop-decorator]
@property
def closed(self) -> bool:
return self.close_date is not None
class AliasCreate(BaseModel):
"""Тело `POST …/aliases`: `alias` сохраняется как есть (как в ОСВ), `normalized_alias` считает сервис (§3)."""
alias: AliasText
class AliasOut(BaseModel):
"""Алиас ЮЛ: `alias` — как в ОСВ, `normalized_alias` — по §3."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
alias: str
normalized_alias: str
created_at: datetime.datetime | None
class LegalEntityDetail(LegalEntityOut):
"""Карточка ЮЛ: поля списка (в т. ч. `memberships`) и алиасы."""
aliases: list[AliasOut] = []
class LegalEntityHistoryOut(BaseModel):
"""Прошлый снимок ЮЛ (SCD4) с событием (`UPDATE`, `MOVE_TO_GROUP`, `CLOSE`, `REMOVE_FROM_GROUP`)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
inn: str | None
kpp: str | None
crm_id: str | None
entity_type: EntityType | None
name: str | None
company_group_id: uuid.UUID | None
regional_office_id: uuid.UUID | None
regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id)
close_date: datetime.date | None
event_type: LeHistoryEvent | None
effective_date: datetime.date | None
valid_from: datetime.datetime | None
valid_to: datetime.datetime | None
changed_by: uuid.UUID | None
class LegalEntityFilter(BaseModel):
"""Фильтры списка ЮЛ: `inn` — точно (после `strip`), `q` — подстрока `name`/`inn`/`kpp`, `closed` — `close_date`."""
company_group_id: uuid.UUID | None = None
outside_group: bool | None = None
inn: str | None = None
q: str | None = None
closed: bool | None = None
@field_validator("inn")
@classmethod
def _strip_inn(cls, value: str | None) -> str | None:
return _blank_to_none(value)
class RelatedGroupCreate(BaseModel):
"""Создание ГСК/ГСЗ в ГК: `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`.
`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:
raise ValueError("название нельзя обнулить")
return self
class MemberAdd(BaseModel):
"""Тело `POST …/related-groups/{id}/members`."""
model_config = ConfigDict(extra="forbid")
legal_entity_id: uuid.UUID
class RelatedGroupOut(BaseModel):
"""ГСК/ГСЗ; `members_count` — число текущих членов; `crm_id` — всегда `null`, пока нет колонки (arm-core #112)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
code: str
company_group_id: uuid.UUID
kind: RelatedGroupKind
name: str
created_date: datetime.date | None
crm_id: str | None = None
members_count: int = 0
class RelatedGroupMemberOut(BaseModel):
"""Текущий член ГСК/ГСЗ."""
legal_entity_id: uuid.UUID
inn: str
name: str
valid_from: datetime.datetime
class RelatedGroupDetail(RelatedGroupOut):
"""Карточка ГСК/ГСЗ со списком текущих членов (по имени ЮЛ)."""
members: list[RelatedGroupMemberOut] = []