"""Схемы 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`) назначает сервис.""" model_config = ConfigDict(extra="forbid") kind: RelatedGroupKind name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) created_date: datetime.date | None = None @field_validator("name") @classmethod def _strip_name(cls, value: str) -> str | None: return _strip_required_name(value) class RelatedGroupUpdate(BaseModel): """Изменение ГСК/ГСЗ: `name`, `created_date` (вид и ГК не меняются); переданные поля — `model_fields_set`.""" 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 @field_validator("name") @classmethod def _strip_name(cls, value: str | None) -> str | None: return _strip_required_name(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` — число текущих членов.""" 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 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] = []