arm-core/api/src/schemas/directories.py
Andrey Epifancev 6078532982 feat(directories): documents_count в списке и карточке ЮЛ (D-13)
Добавлено поле documents_count в LegalEntityOut, содержащее число документов
файлов ЮЛ со статусом не SUPERSEDED. Функция documents_count_by_entity в
репозитории получает счётчики одним подзапросом для эффективности. Используется
в списке и карточке через сервис. Тест проверяет, что заменённые файлы не
учитываются в подсчёте.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 13:16:47 +04:00

381 lines
12 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 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
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
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
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` проверяется по `entity_type` в сервисе (`INVALID_INN`); без ГК — монозаёмщик."""
model_config = ConfigDict(extra="forbid")
inn: str
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
@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`."""
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
@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"):
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 LegalEntityOut(BaseModel):
"""ЮЛ/ИП; `outside_group` — монозаёмщик (`company_group_id IS NULL`), `closed` — заполнен `close_date`.
`documents_count` — число документов файлов ЮЛ со статусом файла не SUPERSEDED (D-13).
"""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
inn: str
crm_id: str | None
entity_type: EntityType | None
name: str
company_group_id: uuid.UUID | None
regional_office_id: uuid.UUID | None
close_date: datetime.date | None
valid_from: datetime.datetime
created_at: datetime.datetime | None
updated_at: datetime.datetime | None
documents_count: int = 0
@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 MembershipOut(BaseModel):
"""Текущее членство ЮЛ в ГСК/ГСЗ (для карточки ЮЛ)."""
related_group_id: uuid.UUID
code: str
kind: RelatedGroupKind
name: str
valid_from: datetime.datetime
class AliasCreate(BaseModel):
"""Тело `POST …/aliases`: `alias` сохраняется как есть (как в ОСВ), `normalized_alias` считает сервис (§3)."""
alias: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
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: list[MembershipOut] = []
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
crm_id: str | None
entity_type: EntityType | None
name: str | None
company_group_id: uuid.UUID | None
regional_office_id: uuid.UUID | None
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`, `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] = []