"""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)