Files
mostovik-backend/src/organizations/models.py
Aleksandr Meshchriakov 6ba8fa8d88
Some checks failed
CI/CD Pipeline / Quality Gate (push) Failing after 9s
CI/CD Pipeline / Build and Push Images (push) Has been skipped
CI/CD Pipeline / Deploy Dev via Compose (push) Has been skipped
CI/CD Pipeline / Internal Notify (push) Successful in 0s
feat: use authoritative organization directory
2026-06-07 16:04:43 +02:00

913 lines
30 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.
"""Models for the canonical organizations directory."""
import uuid
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel
from organizations.data_sources import snapshot_data_source_summary
from organizations.name_normalization import normalize_organization_name
class SourceGroup(models.TextChoices):
"""Product-level organization source groups."""
FINANCIAL_INDICATORS = (
"financial_indicators",
_("Финансово-экономические показатели"),
)
GOVERNMENT_PROCUREMENTS = "government_procurements", _("Государственные закупки")
INDUSTRIAL_PRODUCTION = (
"industrial_production",
_("Производители и продукция России"),
)
PLANNED_INSPECTIONS = "planned_inspections", _("Плановые проверки")
BANKRUPTCY = "bankruptcy", _("Сведения о процедурах банкротства")
DEFENSE_SUPPLIERS = "defense_suppliers", _("Недобросовестные поставщики ГОЗ")
ARBITRATION = "arbitration", _("Арбитражные дела")
SECURITY_REGISTRIES = (
"security_registries",
_("Реестры по информационной безопасности"),
)
VACANCIES = "vacancies", _("Вакансии")
class SourceExtensionStatus(models.TextChoices):
"""Lifecycle status for an organization source extension."""
ACTIVE = "active", _("Активно")
INACTIVE = "inactive", _("Неактивно")
ERROR = "error", _("Ошибка")
class Organization(models.Model):
"""Canonical organization without source-specific relations."""
class IdentityStatus(models.TextChoices):
"""Completeness of legal identity identifiers."""
COMPLETE = "complete", _("Полная")
PARTIAL = "partial", _("Частичная")
MISSING = "missing", _("Отсутствует")
uid = models.UUIDField(
_("UID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
rn = models.PositiveBigIntegerField(
_("регистрационный номер"),
null=True,
blank=True,
unique=True,
db_index=True,
help_text=_("Регистрационный номер организации из перечня"),
)
gk_code = models.CharField(
_("код государственной корпорации"),
max_length=16,
blank=True,
db_column="_gk",
db_index=True,
help_text=_("Отношение к ГК Росатом и ГК Роскосмос"),
)
gk_name = models.CharField(
_("государственная корпорация"),
max_length=255,
blank=True,
help_text=_("Расшифровка кода государственной корпорации"),
)
in_korp_code = models.CharField(
_("код положения внутри ГК"),
max_length=16,
blank=True,
db_index=True,
help_text=_("Положение внутри ГК Росатом и ГК Роскосмос"),
)
in_korp_name = models.CharField(
_("положение внутри ГК"),
max_length=255,
blank=True,
help_text=_("Расшифровка положения внутри ГК"),
)
name = models.CharField(
_("наименование"),
max_length=1024,
db_index=True,
help_text=_("Наименование организации или ИП"),
)
full_name = models.TextField(
_("полное наименование"),
blank=True,
)
short_name = models.TextField(
_("краткое наименование"),
blank=True,
)
pn_name = models.TextField(
_("приведенное наименование"),
blank=True,
)
pn_name_en = models.TextField(
_("приведенное наименование на английском"),
blank=True,
)
inn = models.CharField(
_("ИНН"),
max_length=12,
blank=True,
db_index=True,
help_text=_("ИНН ЮЛ или ИП"),
)
kpp = models.CharField(
_("КПП"),
max_length=9,
blank=True,
db_index=True,
help_text=_("КПП только для юридических лиц"),
)
ogrn = models.CharField(
_("ОГРН"),
max_length=13,
blank=True,
db_index=True,
help_text=_("ОГРН только для юридических лиц"),
)
okpo = models.CharField(
_("ОКПО"),
max_length=32,
blank=True,
db_index=True,
)
filial = models.CharField(
_("признак филиала из источника"),
max_length=64,
blank=True,
db_index=True,
)
is_branch = models.BooleanField(
_("филиал"),
null=True,
blank=True,
db_index=True,
help_text=_("Нормализованный признак filial: .T. = филиал, .F. = головная"),
)
ogrip = models.CharField(
_("ОГРИП"),
max_length=15,
blank=True,
db_index=True,
help_text=_("ОГРИП только для индивидуальных предпринимателей"),
)
registration_date = models.DateField(
_("дата регистрации"),
null=True,
blank=True,
db_index=True,
)
create_date = models.CharField(
_("год или дата создания"),
max_length=32,
blank=True,
)
organizational_legal_form = models.CharField(
_("код организационно-правовой формы"),
max_length=32,
blank=True,
db_index=True,
)
organizational_legal_form1 = models.CharField(
_("организационно-правовая форма"),
max_length=512,
blank=True,
)
ownership_form = models.CharField(
_("код формы собственности"),
max_length=32,
blank=True,
db_index=True,
)
ownership_form1 = models.CharField(
_("форма собственности"),
max_length=512,
blank=True,
)
authorized_capital = models.DecimalField(
_("уставный капитал"),
max_digits=24,
decimal_places=2,
null=True,
blank=True,
)
legal_address = models.TextField(
_("юридический адрес"),
blank=True,
)
business_act_cod = models.CharField(
_("код вида деятельности"),
max_length=32,
blank=True,
db_index=True,
)
business_activity = models.CharField(
_("вид деятельности"),
max_length=255,
blank=True,
)
general_director = models.TextField(
_("руководитель"),
blank=True,
)
general_director_tax_id = models.CharField(
_("ИНН руководителя"),
max_length=32,
blank=True,
)
uk = models.TextField(
_("управляющая компания"),
blank=True,
)
inn_uk = models.CharField(
_("ИНН управляющей компании"),
max_length=12,
blank=True,
db_index=True,
)
appointment_date = models.DateField(
_("дата назначения руководителя"),
null=True,
blank=True,
)
cf_fl_rn = models.CharField(
_("регистрационный номер руководителя"),
max_length=64,
blank=True,
)
akc_fs = models.CharField(
_("акционерный капитал федеральная собственность"),
max_length=64,
blank=True,
)
akc_sf = models.CharField(
_("акционерный капитал собственность субъекта РФ"),
max_length=64,
blank=True,
)
re_za = models.BooleanField(
_("реестр организаций-заказчиков"),
null=True,
blank=True,
db_index=True,
)
re_zasf = models.BooleanField(
_("реестр организаций-заказчиков субъекта РФ"),
null=True,
blank=True,
db_index=True,
)
goz_participation = models.BooleanField(
_("участие в ГОЗ"),
null=True,
blank=True,
db_index=True,
)
opk_registry_membership = models.BooleanField(
_("членство в реестре ОПК"),
null=True,
blank=True,
db_index=True,
)
ropk_num = models.CharField(
_("номер в реестре ОПК"),
max_length=64,
blank=True,
db_index=True,
)
ropk_razdel_num = models.CharField(
_("номер раздела реестра ОПК"),
max_length=64,
blank=True,
)
ropk_razdel_name = models.TextField(
_("раздел реестра ОПК"),
blank=True,
)
min = models.TextField(
_("министерство"),
blank=True,
)
dep = models.TextField(
_("департамент"),
blank=True,
)
otr = models.TextField(
_("отрасль"),
blank=True,
)
integrated_structure = models.TextField(
_("интегрированная структура"),
blank=True,
db_column="is",
)
state_sector_code = models.CharField(
_("код госсектора"),
max_length=16,
blank=True,
db_column="_k",
db_index=True,
)
state_sector_name = models.CharField(
_("госсектор"),
max_length=255,
blank=True,
)
directory_source_file_hash = models.CharField(
_("хеш файла перечня"),
max_length=64,
blank=True,
db_index=True,
)
directory_source_row_number = models.PositiveIntegerField(
_("номер строки файла перечня"),
null=True,
blank=True,
)
directory_imported_at = models.DateTimeField(
_("дата импорта перечня"),
null=True,
blank=True,
)
identity_status = models.CharField(
_("полнота реквизитов"),
max_length=16,
choices=IdentityStatus.choices,
default=IdentityStatus.MISSING,
db_index=True,
help_text=_("Оценка полноты идентификационных реквизитов"),
)
primary_identity = models.CharField(
_("основной идентификатор"),
max_length=255,
blank=True,
db_index=True,
editable=False,
help_text=_("Нормализованный ключ для диагностики и дедупликации"),
)
class Meta:
db_table = "organizations_organization"
verbose_name = _("организация")
verbose_name_plural = _("организации")
ordering = ["name"]
indexes = [
models.Index(fields=["inn", "kpp"]),
models.Index(fields=["ogrn", "kpp"]),
models.Index(fields=["inn", "ogrn"]),
models.Index(fields=["inn", "ogrip"]),
models.Index(fields=["okpo"]),
models.Index(fields=["is_branch", "inn", "ogrn"]),
]
constraints = [
models.UniqueConstraint(
fields=["ogrip"],
condition=~Q(ogrip=""),
name="unique_organizations_ogrip_not_blank",
),
models.CheckConstraint(
check=Q(ogrip="") | (Q(kpp="") & Q(ogrn="")),
name="check_entrepreneur_has_no_kpp_ogrn",
),
]
def __str__(self) -> str:
identifier = self.inn or self.ogrn or self.ogrip
if identifier:
return f"{self.name} ({identifier})"
return self.name
@property
def id(self):
"""Compatibility alias for code paths that expect a model id attribute."""
return self.pk
@staticmethod
def _legacy_digits(value, *, max_length: int, zfill: int | None = None) -> str:
digits = "".join(char for char in str(value or "") if char.isdigit())
if zfill and 0 < len(digits) < zfill:
digits = digits.zfill(zfill)
return digits[:max_length]
@property
def mn_inn(self) -> str:
"""Compatibility alias for the removed registers.Organization field."""
return self.inn
@mn_inn.setter
def mn_inn(self, value) -> None:
self.inn = self._legacy_digits(value, max_length=12, zfill=10)
@property
def mn_ogrn(self) -> str:
"""Compatibility alias for the removed registers.Organization field."""
return self.ogrn
@mn_ogrn.setter
def mn_ogrn(self, value) -> None:
self.ogrn = self._legacy_digits(value, max_length=13)
@property
def in_kpp(self) -> str:
"""Compatibility alias for the removed registers.Organization field."""
return self.kpp
@in_kpp.setter
def in_kpp(self, value) -> None:
self.kpp = self._legacy_digits(value, max_length=9, zfill=9)
@property
def mn_okpo(self) -> str:
"""Compatibility alias for the removed registers.Organization field."""
return self.okpo
@mn_okpo.setter
def mn_okpo(self, value) -> None:
self.okpo = self._legacy_digits(value, max_length=32)
@property
def normalized_name(self) -> str:
return normalize_organization_name(self.name)
def save(self, *args, **kwargs) -> None:
if not self.name:
self.name = self.pn_name or self.short_name or self.full_name
self.identity_status = self._resolve_identity_status()
self.primary_identity = self._resolve_primary_identity()
update_fields = kwargs.get("update_fields")
if update_fields is not None:
kwargs["update_fields"] = list(
dict.fromkeys(
[*update_fields, "name", "identity_status", "primary_identity"]
)
)
super().save(*args, **kwargs)
def _resolve_identity_status(self) -> str:
if self.inn and (self.ogrn or self.ogrip):
return self.IdentityStatus.COMPLETE
if self.inn or self.ogrn or self.ogrip:
return self.IdentityStatus.PARTIAL
return self.IdentityStatus.MISSING
def _resolve_primary_identity(self) -> str:
if self.inn and self.kpp:
return f"inn:{self.inn}:kpp:{self.kpp}"
if self.ogrn:
return f"ogrn:{self.ogrn}"
if self.ogrip:
return f"ogrip:{self.ogrip}"
if self.inn:
return f"inn:{self.inn}"
normalized_name = normalize_organization_name(self.name)
if normalized_name:
return f"name:{normalized_name}"[:255]
return ""
class OrganizationSourceExtension(PolymorphicModel):
"""Base source group extension for one canonical organization."""
uid = models.UUIDField(
_("UID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="source_extensions",
verbose_name=_("организация"),
)
source_group = models.CharField(
_("группа источников"),
max_length=64,
choices=SourceGroup.choices,
db_index=True,
)
title = models.CharField(
_("название"),
max_length=255,
help_text=_("Человекочитаемое название блока источника"),
)
status = models.CharField(
_("статус"),
max_length=16,
choices=SourceExtensionStatus.choices,
default=SourceExtensionStatus.ACTIVE,
db_index=True,
)
records_count = models.PositiveIntegerField(
_("количество записей"),
default=0,
)
first_seen_at = models.DateTimeField(
_("первая запись"),
null=True,
blank=True,
db_index=True,
)
last_seen_at = models.DateTimeField(
_("последняя запись"),
null=True,
blank=True,
db_index=True,
)
last_load_batch = models.PositiveIntegerField(
_("последний пакет загрузки"),
null=True,
blank=True,
db_index=True,
)
metadata = models.JSONField(
_("метаданные"),
default=dict,
blank=True,
)
created_at = models.DateTimeField(_("дата создания"), auto_now_add=True)
updated_at = models.DateTimeField(_("дата обновления"), auto_now=True)
class Meta:
db_table = "organizations_source_extension"
verbose_name = _("расширение источника организации")
verbose_name_plural = _("расширения источников организаций")
ordering = ["organization__name", "source_group"]
constraints = [
models.UniqueConstraint(
fields=["organization", "source_group"],
name="unique_organization_source_group_extension",
),
]
indexes = [
models.Index(fields=["source_group", "status"]),
models.Index(fields=["organization", "source_group"]),
]
def __str__(self) -> str:
return f"{self.organization}: {self.title}"
def save(self, *args, **kwargs) -> None:
source_group = getattr(self, "source_group_value", "")
if source_group:
self.source_group = source_group
super().save(*args, **kwargs)
class FinancialIndicatorsExtension(OrganizationSourceExtension):
"""Financial and accounting indicators source group."""
source_group_value = SourceGroup.FINANCIAL_INDICATORS
class Meta:
db_table = "organizations_financial_indicators_extension"
verbose_name = _("финансово-экономические показатели")
verbose_name_plural = _("финансово-экономические показатели")
class GovernmentProcurementExtension(OrganizationSourceExtension):
"""Government procurement source group."""
source_group_value = SourceGroup.GOVERNMENT_PROCUREMENTS
class Meta:
db_table = "organizations_government_procurement_extension"
verbose_name = _("государственные закупки")
verbose_name_plural = _("государственные закупки")
class IndustrialProductionExtension(OrganizationSourceExtension):
"""Russian manufacturers and products source group."""
source_group_value = SourceGroup.INDUSTRIAL_PRODUCTION
class Meta:
db_table = "organizations_industrial_production_extension"
verbose_name = _("производители и продукция России")
verbose_name_plural = _("производители и продукция России")
class PlannedInspectionExtension(OrganizationSourceExtension):
"""Planned inspections source group."""
source_group_value = SourceGroup.PLANNED_INSPECTIONS
class Meta:
db_table = "organizations_planned_inspection_extension"
verbose_name = _("плановые проверки")
verbose_name_plural = _("плановые проверки")
class BankruptcyExtension(OrganizationSourceExtension):
"""Bankruptcy procedures source group."""
source_group_value = SourceGroup.BANKRUPTCY
class Meta:
db_table = "organizations_bankruptcy_extension"
verbose_name = _("сведения о процедурах банкротства")
verbose_name_plural = _("сведения о процедурах банкротства")
class DefenseSupplierExtension(OrganizationSourceExtension):
"""Defense supplier risk source group."""
source_group_value = SourceGroup.DEFENSE_SUPPLIERS
class Meta:
db_table = "organizations_defense_supplier_extension"
verbose_name = _("недобросовестные поставщики ГОЗ")
verbose_name_plural = _("недобросовестные поставщики ГОЗ")
class ArbitrationExtension(OrganizationSourceExtension):
"""Arbitration cases source group."""
source_group_value = SourceGroup.ARBITRATION
class Meta:
db_table = "organizations_arbitration_extension"
verbose_name = _("арбитражные дела")
verbose_name_plural = _("арбитражные дела")
class SecurityRegistryExtension(OrganizationSourceExtension):
"""Information security registries source group."""
source_group_value = SourceGroup.SECURITY_REGISTRIES
class Meta:
db_table = "organizations_security_registry_extension"
verbose_name = _("реестры по информационной безопасности")
verbose_name_plural = _("реестры по информационной безопасности")
class VacancyExtension(OrganizationSourceExtension):
"""Vacancies source group."""
source_group_value = SourceGroup.VACANCIES
class Meta:
db_table = "organizations_vacancy_extension"
verbose_name = _("вакансии")
verbose_name_plural = _("вакансии")
class OrganizationSourceRecord(models.Model):
"""Subordinate source record stored under a source extension."""
uid = models.UUIDField(
_("UID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
extension = models.ForeignKey(
OrganizationSourceExtension,
on_delete=models.CASCADE,
related_name="records",
verbose_name=_("расширение источника"),
)
record_type = models.CharField(
_("тип записи"),
max_length=64,
db_index=True,
)
source = models.CharField(
_("источник"),
max_length=64,
db_index=True,
)
external_id = models.CharField(
_("внешний ID"),
max_length=255,
blank=True,
db_index=True,
)
title = models.TextField(
_("заголовок"),
blank=True,
)
record_date = models.CharField(
_("дата записи"),
max_length=255,
blank=True,
db_index=True,
)
amount = models.DecimalField(
_("сумма"),
max_digits=20,
decimal_places=2,
null=True,
blank=True,
)
status = models.CharField(
_("статус"),
max_length=255,
blank=True,
db_index=True,
)
url = models.TextField(
_("URL"),
blank=True,
)
payload = models.JSONField(
_("исходные данные"),
default=dict,
blank=True,
)
legacy_model = models.CharField(
_("legacy model"),
max_length=255,
blank=True,
db_index=True,
)
legacy_pk = models.CharField(
_("legacy pk"),
max_length=64,
blank=True,
db_index=True,
)
load_batch = models.PositiveIntegerField(
_("ID пакета загрузки"),
null=True,
blank=True,
db_index=True,
)
created_at = models.DateTimeField(_("дата создания"), auto_now_add=True)
updated_at = models.DateTimeField(_("дата обновления"), auto_now=True)
class Meta:
db_table = "organizations_source_record"
verbose_name = _("запись источника организации")
verbose_name_plural = _("записи источников организаций")
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["source", "external_id"],
condition=~Q(external_id=""),
name="unique_source_record_external_id",
),
models.UniqueConstraint(
fields=["legacy_model", "legacy_pk"],
condition=~Q(legacy_model="") & ~Q(legacy_pk=""),
name="unique_source_record_legacy_identity",
),
]
indexes = [
models.Index(fields=["extension", "source"]),
models.Index(fields=["source", "record_type"]),
models.Index(fields=["load_batch", "source"]),
]
def __str__(self) -> str:
return self.title or self.external_id or str(self.uid)
@property
def id(self):
"""Compatibility alias for legacy parser services that exposed integer id."""
return self.pk
@property
def lines(self):
"""Compatibility alias for financial reports stored as source records."""
return self.financial_lines
@property
def inn(self) -> str:
"""Return the canonical organization INN or source payload INN."""
organization = self.extension.organization
return organization.inn or str((self.payload or {}).get("inn") or "")
@property
def kpp(self) -> str:
"""Return the canonical organization KPP or source payload KPP."""
organization = self.extension.organization
return organization.kpp or str((self.payload or {}).get("kpp") or "")
@property
def ogrn(self) -> str:
"""Return the canonical organization OGRN or source payload OGRN."""
organization = self.extension.organization
return organization.ogrn or str((self.payload or {}).get("ogrn") or "")
@property
def ogrip(self) -> str:
"""Return the canonical organization OGRIP or source payload OGRIP."""
organization = self.extension.organization
return organization.ogrip or str((self.payload or {}).get("ogrip") or "")
@property
def registry_organization(self):
"""Compatibility alias for callers that still use registry wording."""
return self.extension.organization
@property
def registry_organization_id(self):
return self.extension.organization_id
class OrganizationSourceFinancialLine(models.Model):
"""Structured financial report line under a source record."""
source_record = models.ForeignKey(
OrganizationSourceRecord,
on_delete=models.CASCADE,
related_name="financial_lines",
verbose_name=_("запись источника"),
)
form_code = models.CharField(_("код формы"), max_length=10, db_index=True)
line_code = models.CharField(_("код строки"), max_length=10, db_index=True)
line_name = models.CharField(_("наименование строки"), max_length=255)
year = models.PositiveSmallIntegerField(_("год"), db_index=True)
period_start = models.BigIntegerField(
_("на начало периода"),
null=True,
blank=True,
)
period_end = models.BigIntegerField(
_("на конец периода"),
null=True,
blank=True,
)
class Meta:
db_table = "organizations_source_financial_line"
verbose_name = _("строка финансового источника")
verbose_name_plural = _("строки финансовых источников")
constraints = [
models.UniqueConstraint(
fields=["source_record", "form_code", "line_code", "year"],
name="unique_source_financial_line_year",
),
]
indexes = [
models.Index(fields=["source_record", "form_code", "line_code"]),
models.Index(fields=["year", "line_code"]),
]
def __str__(self) -> str:
return f"{self.line_code} ({self.line_name[:30]}) - {self.year}"
class OrganizationDataSnapshot(models.Model):
"""Precomputed API v2 data payload for one canonical organization."""
organization = models.OneToOneField(
Organization,
on_delete=models.CASCADE,
primary_key=True,
related_name="data_snapshot",
verbose_name=_("организация"),
)
data = models.JSONField(
_("данные источников"),
default=dict,
help_text=_("Готовый JSON data для API v2"),
)
registries = models.JSONField(
_("реестры"),
default=list,
help_text=_("Готовый JSON registries для API v2"),
)
data_source_counts = models.JSONField(
_("счетчики источников"),
default=list,
help_text=_("Готовый JSON data_sources для API v2"),
)
updated_at = models.DateTimeField(
_("дата обновления"),
auto_now=True,
db_index=True,
)
class Meta:
db_table = "organizations_data_snapshot"
verbose_name = _("снапшот данных организации")
verbose_name_plural = _("снапшоты данных организаций")
def __str__(self) -> str:
return f"Snapshot for {self.organization_id}"
def save(self, *args, **kwargs) -> None:
update_fields = kwargs.get("update_fields")
if update_fields is None or "data" in update_fields:
self.data_source_counts = snapshot_data_source_summary(self.data)
if update_fields is not None:
kwargs["update_fields"] = list(
dict.fromkeys([*update_fields, "data_source_counts"])
)
super().save(*args, **kwargs)