Формы 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>
426 lines
15 KiB
Python
426 lines
15 KiB
Python
"""Схемы 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] = []
|