1152 lines
41 KiB
Python
1152 lines
41 KiB
Python
"""
|
||
Модели для приложения парсеров.
|
||
|
||
Используют миксины из apps.core для стандартных полей и поведения.
|
||
"""
|
||
|
||
from apps.core.mixins import TimestampMixin
|
||
from django.db import models
|
||
from django.utils.translation import gettext_lazy as _
|
||
|
||
VACANCY_RECORD_SOURCES = ("trudvsem", "hh", "superjob")
|
||
|
||
|
||
class ParserLoadLog(TimestampMixin, models.Model):
|
||
"""
|
||
Лог загрузок парсеров.
|
||
|
||
Хранит информацию о каждой загрузке данных из внешнего источника.
|
||
"""
|
||
|
||
class Source(models.TextChoices):
|
||
INDUSTRIAL = "industrial", _("Сертификаты промышленного производства")
|
||
INDUSTRIAL_PRODUCTS = "industrial_products", _("Реестр промышленной продукции")
|
||
MANUFACTURES = "manufactures", _("Реестр производителей")
|
||
INSPECTIONS = "inspections", _("Единый реестр проверок")
|
||
PROCUREMENTS = "procurements", _("Единая информационная система закупок")
|
||
FNS_REPORTS = "fns_reports", _("Бухгалтерская отчетность ФНС")
|
||
PROCUREMENTS_44FZ = "procurements_44fz", _("Закупки 44-ФЗ")
|
||
PROCUREMENTS_223FZ = "procurements_223fz", _("Закупки 223-ФЗ")
|
||
CONTRACTS = "contracts", _("Контракты ЕИС")
|
||
UNFAIR_SUPPLIERS = "unfair_suppliers", _("Недобросовестные поставщики")
|
||
FAS_GOZ = "fas_goz", _("Уклонение от ГОЗ")
|
||
ARBITRATION = "arbitration", _("Арбитражные дела")
|
||
FEDRESURS_BANKRUPTCY = "fedresurs_bankruptcy", _("Банкротства Федресурс")
|
||
FSTEC = "fstec", _("Реестры ФСТЭК")
|
||
TRUDVSEM = "trudvsem", _("Вакансии Работа России")
|
||
|
||
class Status(models.TextChoices):
|
||
SUCCESS = "success", _("Успешно")
|
||
FAILED = "failed", _("Ошибка")
|
||
IN_PROGRESS = "in_progress", _("В процессе")
|
||
PENDING = "pending", _("В очереди")
|
||
SKIPPED = "skipped", _("Пропущено")
|
||
|
||
batch_id = models.PositiveIntegerField(
|
||
_("ID пакета"),
|
||
db_index=True,
|
||
help_text=_("Уникальный идентификатор пакета загрузки"),
|
||
)
|
||
source = models.CharField(
|
||
_("источник"),
|
||
max_length=50,
|
||
choices=Source.choices,
|
||
db_index=True,
|
||
help_text=_("Источник данных"),
|
||
)
|
||
records_count = models.PositiveIntegerField(
|
||
_("количество записей"),
|
||
default=0,
|
||
help_text=_("Количество загруженных записей"),
|
||
)
|
||
status = models.CharField(
|
||
_("статус"),
|
||
max_length=20,
|
||
choices=Status.choices,
|
||
default=Status.SUCCESS,
|
||
help_text=_("Статус загрузки"),
|
||
)
|
||
error_message = models.TextField(
|
||
_("сообщение об ошибке"),
|
||
blank=True,
|
||
help_text=_("Текст ошибки при неудачной загрузке"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_load_log"
|
||
verbose_name = _("обновление источника")
|
||
verbose_name_plural = _("история обновлений")
|
||
ordering = ["-created_at"]
|
||
indexes = [
|
||
models.Index(fields=["source", "batch_id"]),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["source", "batch_id"],
|
||
name="unique_load_batch_per_source",
|
||
),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"Load #{self.batch_id} ({self.source}) - {self.records_count} records"
|
||
|
||
|
||
class ParserBatchSequence(TimestampMixin, models.Model):
|
||
"""Атомарный счётчик batch_id по источнику парсера."""
|
||
|
||
source = models.CharField(
|
||
_("источник"),
|
||
max_length=50,
|
||
choices=ParserLoadLog.Source.choices,
|
||
unique=True,
|
||
help_text=_("Источник данных"),
|
||
)
|
||
next_batch_id = models.PositiveIntegerField(
|
||
_("следующий ID пакета"),
|
||
default=1,
|
||
help_text=_("Следующий batch_id для источника"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_batch_sequence"
|
||
verbose_name = _("счётчик пакетов парсера")
|
||
verbose_name_plural = _("счётчики пакетов парсеров")
|
||
ordering = ["source"]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.source}: next batch {self.next_batch_id}"
|
||
|
||
|
||
class CheckoCollectionAttempt(TimestampMixin, models.Model):
|
||
"""Monthly Checko collection claim for one organization and source."""
|
||
|
||
class Source(models.TextChoices):
|
||
ARBITRATION = "arbitration", _("Арбитражные дела")
|
||
BANKRUPTCY = "bankruptcy", _("Банкротства")
|
||
CONTRACTS = "contracts", _("Контракты")
|
||
INSPECTIONS = "inspections", _("Проверки")
|
||
PROCUREMENTS_44FZ = "procurements_44fz", _("Закупки 44-ФЗ")
|
||
PROCUREMENTS_223FZ = "procurements_223fz", _("Закупки 223-ФЗ")
|
||
UNFAIR_SUPPLIERS = "unfair_suppliers", _("Недобросовестные поставщики")
|
||
|
||
class Status(models.TextChoices):
|
||
IN_PROGRESS = "in_progress", _("В процессе")
|
||
SUCCESS = "success", _("Успешно")
|
||
FAILED = "failed", _("Ошибка")
|
||
|
||
organization = models.ForeignKey(
|
||
"organizations.Organization",
|
||
on_delete=models.CASCADE,
|
||
related_name="checko_collection_attempts",
|
||
verbose_name=_("организация"),
|
||
)
|
||
source = models.CharField(
|
||
_("источник"),
|
||
max_length=32,
|
||
choices=Source.choices,
|
||
)
|
||
period_month = models.DateField(
|
||
_("месяц сбора"),
|
||
help_text=_("Первый день календарного месяца, за который занята попытка"),
|
||
)
|
||
status = models.CharField(
|
||
_("статус"),
|
||
max_length=20,
|
||
choices=Status.choices,
|
||
default=Status.IN_PROGRESS,
|
||
)
|
||
records_count = models.PositiveIntegerField(_("количество записей"), default=0)
|
||
error_message = models.TextField(_("сообщение об ошибке"), blank=True)
|
||
|
||
class Meta:
|
||
db_table = "parsers_checko_collection_attempt"
|
||
verbose_name = _("попытка сбора Checko")
|
||
verbose_name_plural = _("попытки сбора Checko")
|
||
ordering = ["-period_month", "source", "organization_id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["organization", "source", "period_month"],
|
||
name="unique_checko_collection_per_org_source_month",
|
||
),
|
||
]
|
||
indexes = [
|
||
models.Index(fields=["source", "period_month"]),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.organization_id}: {self.source} ({self.period_month:%Y-%m})"
|
||
|
||
|
||
GENERIC_RECORD_SOURCE_CHOICES = [
|
||
*ParserLoadLog.Source.choices,
|
||
("hh", _("Вакансии HeadHunter")),
|
||
("superjob", _("Вакансии SuperJob")),
|
||
]
|
||
|
||
|
||
class IndustrialCertificateRecord(TimestampMixin, models.Model):
|
||
"""
|
||
Сертификат промышленного производства РФ.
|
||
|
||
Данные загружаются из Минпромторга.
|
||
"""
|
||
|
||
load_batch = models.PositiveIntegerField(
|
||
_("ID пакета загрузки"),
|
||
db_index=True,
|
||
help_text=_("Идентификатор пакета загрузки"),
|
||
)
|
||
issue_date = models.CharField(
|
||
_("дата выдачи"),
|
||
max_length=15,
|
||
blank=True,
|
||
help_text=_("Дата выдачи сертификата"),
|
||
)
|
||
issue_date_normalized = models.DateField(
|
||
_("дата выдачи (нормализованная)"),
|
||
null=True,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Нормализованная дата выдачи сертификата"),
|
||
)
|
||
certificate_number = models.CharField(
|
||
_("номер сертификата"),
|
||
max_length=100,
|
||
db_index=True,
|
||
help_text=_("Номер сертификата"),
|
||
)
|
||
expiry_date = models.CharField(
|
||
_("дата окончания"),
|
||
max_length=15,
|
||
blank=True,
|
||
help_text=_("Дата окончания действия"),
|
||
)
|
||
expiry_date_normalized = models.DateField(
|
||
_("дата окончания (нормализованная)"),
|
||
null=True,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Нормализованная дата окончания действия"),
|
||
)
|
||
certificate_file_url = models.TextField(
|
||
_("URL файла"),
|
||
blank=True,
|
||
help_text=_("Ссылка на файл сертификата"),
|
||
)
|
||
organisation_name = models.TextField(
|
||
_("наименование организации"),
|
||
help_text=_("Название организации"),
|
||
)
|
||
inn = models.CharField(
|
||
_("ИНН"),
|
||
max_length=20,
|
||
db_index=True,
|
||
help_text=_("ИНН организации"),
|
||
)
|
||
ogrn = models.CharField(
|
||
_("ОГРН"),
|
||
max_length=20,
|
||
db_index=True,
|
||
help_text=_("ОГРН организации"),
|
||
)
|
||
registry_organization = models.ForeignKey(
|
||
"organizations.Organization",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="industrial_certificate_records",
|
||
verbose_name=_("организация"),
|
||
help_text=_("Связь с верхнеуровневой организацией"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_industrial_certificate"
|
||
verbose_name = _("сертификат промышленного производства")
|
||
verbose_name_plural = _("Сертификаты промышленного производства")
|
||
ordering = ["-created_at"]
|
||
indexes = [
|
||
models.Index(fields=["inn", "certificate_number"]),
|
||
models.Index(fields=["load_batch", "inn"]),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["certificate_number"],
|
||
name="unique_certificate_number",
|
||
),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.certificate_number} - {self.organisation_name[:50]}"
|
||
|
||
|
||
class ManufacturerRecord(TimestampMixin, models.Model):
|
||
"""
|
||
Производитель из реестра Минпромторга.
|
||
|
||
Данные загружаются из Минпромторга.
|
||
"""
|
||
|
||
load_batch = models.PositiveIntegerField(
|
||
_("ID пакета загрузки"),
|
||
db_index=True,
|
||
help_text=_("Идентификатор пакета загрузки"),
|
||
)
|
||
full_legal_name = models.TextField(
|
||
_("полное наименование"),
|
||
help_text=_("Полное юридическое наименование организации"),
|
||
)
|
||
inn = models.CharField(
|
||
_("ИНН"),
|
||
max_length=15,
|
||
db_index=True,
|
||
help_text=_("ИНН организации"),
|
||
)
|
||
ogrn = models.CharField(
|
||
_("ОГРН"),
|
||
max_length=15,
|
||
db_index=True,
|
||
help_text=_("ОГРН организации"),
|
||
)
|
||
address = models.TextField(
|
||
_("адрес"),
|
||
blank=True,
|
||
help_text=_("Юридический адрес организации"),
|
||
)
|
||
registry_organization = models.ForeignKey(
|
||
"organizations.Organization",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="manufacturer_records",
|
||
verbose_name=_("организация"),
|
||
help_text=_("Связь с верхнеуровневой организацией"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_manufacturer"
|
||
verbose_name = _("запись реестра производителей")
|
||
verbose_name_plural = _("Реестр производителей")
|
||
ordering = ["-created_at"]
|
||
indexes = [
|
||
models.Index(fields=["load_batch", "inn"]),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["inn"],
|
||
name="unique_manufacturer_inn",
|
||
),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.inn} - {self.full_legal_name[:50]}"
|
||
|
||
|
||
class IndustrialProductRecord(TimestampMixin, models.Model):
|
||
"""
|
||
Промышленная продукция из реестра Минпромторга.
|
||
|
||
Данные загружаются из Минпромторга.
|
||
"""
|
||
|
||
load_batch = models.PositiveIntegerField(
|
||
_("ID пакета загрузки"),
|
||
db_index=True,
|
||
help_text=_("Идентификатор пакета загрузки"),
|
||
)
|
||
full_organisation_name = models.TextField(
|
||
_("полное наименование организации"),
|
||
help_text=_("Полное наименование организации"),
|
||
)
|
||
ogrn = models.CharField(
|
||
_("ОГРН"),
|
||
max_length=15,
|
||
db_index=True,
|
||
help_text=_("ОГРН организации"),
|
||
)
|
||
inn = models.CharField(
|
||
_("ИНН"),
|
||
max_length=12,
|
||
db_index=True,
|
||
help_text=_("ИНН организации"),
|
||
)
|
||
registry_number = models.CharField(
|
||
_("регистрационный номер"),
|
||
max_length=100,
|
||
db_index=True,
|
||
help_text=_("Регистрационный номер записи"),
|
||
)
|
||
product_name = models.TextField(
|
||
_("наименование продукции"),
|
||
help_text=_("Наименование продукции"),
|
||
)
|
||
product_model = models.CharField(
|
||
_("модель или модификация"),
|
||
max_length=255,
|
||
blank=True,
|
||
help_text=_("Модель или модификация продукции"),
|
||
)
|
||
okpd2_code = models.CharField(
|
||
_("код ОКПД2"),
|
||
max_length=255,
|
||
blank=True,
|
||
help_text=_("Код по ОКПД2"),
|
||
)
|
||
tnved_code = models.CharField(
|
||
_("код ТН ВЭД"),
|
||
max_length=255,
|
||
blank=True,
|
||
help_text=_("Код по ТН ВЭД"),
|
||
)
|
||
regulatory_document = models.CharField(
|
||
_("нормативный документ"),
|
||
max_length=500,
|
||
blank=True,
|
||
help_text=_("Наименование нормативного документа"),
|
||
)
|
||
registry_organization = models.ForeignKey(
|
||
"organizations.Organization",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="industrial_product_records",
|
||
verbose_name=_("организация"),
|
||
help_text=_("Связь с верхнеуровневой организацией"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_industrial_product"
|
||
verbose_name = _("запись реестра промышленной продукции")
|
||
verbose_name_plural = _("Реестр промышленной продукции")
|
||
ordering = ["-created_at"]
|
||
indexes = [
|
||
models.Index(fields=["load_batch", "inn"]),
|
||
models.Index(fields=["inn", "registry_number"]),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.registry_number} - {self.product_name[:50]}"
|
||
|
||
|
||
class GenericParserRecord(TimestampMixin, models.Model):
|
||
"""Универсальная запись для разнородных официальных источников."""
|
||
|
||
load_batch = models.PositiveIntegerField(
|
||
_("ID пакета загрузки"),
|
||
db_index=True,
|
||
help_text=_("Идентификатор пакета загрузки"),
|
||
)
|
||
source = models.CharField(
|
||
_("источник"),
|
||
max_length=50,
|
||
choices=GENERIC_RECORD_SOURCE_CHOICES,
|
||
db_index=True,
|
||
help_text=_("Источник данных"),
|
||
)
|
||
external_id = models.CharField(
|
||
_("внешний ID"),
|
||
max_length=255,
|
||
db_index=True,
|
||
help_text=_("Стабильный идентификатор записи во внешнем источнике"),
|
||
)
|
||
inn = models.CharField(
|
||
_("ИНН"),
|
||
max_length=20,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("ИНН организации, если есть в источнике"),
|
||
)
|
||
ogrn = models.CharField(
|
||
_("ОГРН"),
|
||
max_length=20,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("ОГРН организации, если есть в источнике"),
|
||
)
|
||
organisation_name = models.TextField(
|
||
_("наименование организации"),
|
||
blank=True,
|
||
help_text=_("Наименование организации из источника"),
|
||
)
|
||
title = models.TextField(
|
||
_("заголовок"),
|
||
blank=True,
|
||
help_text=_("Краткое описание записи"),
|
||
)
|
||
record_date = models.CharField(
|
||
_("дата записи"),
|
||
max_length=255,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Дата записи в формате источника"),
|
||
)
|
||
amount = models.DecimalField(
|
||
_("сумма"),
|
||
max_digits=20,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
help_text=_("Сумма, если источник её содержит"),
|
||
)
|
||
status = models.CharField(
|
||
_("статус"),
|
||
max_length=255,
|
||
blank=True,
|
||
help_text=_("Статус записи во внешнем источнике"),
|
||
)
|
||
url = models.TextField(
|
||
_("URL"),
|
||
blank=True,
|
||
help_text=_("Ссылка на карточку/документ во внешнем источнике"),
|
||
)
|
||
payload = models.JSONField(
|
||
_("исходные данные"),
|
||
default=dict,
|
||
blank=True,
|
||
help_text=_("Нормализованный исходный документ"),
|
||
)
|
||
registry_organization = models.ForeignKey(
|
||
"organizations.Organization",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="generic_parser_records",
|
||
verbose_name=_("организация"),
|
||
help_text=_("Связь с верхнеуровневой организацией"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_generic_record"
|
||
verbose_name = _("запись внешнего источника")
|
||
verbose_name_plural = _("записи внешних источников")
|
||
ordering = ["-created_at"]
|
||
indexes = [
|
||
models.Index(fields=["source", "load_batch"]),
|
||
models.Index(fields=["source", "inn"]),
|
||
models.Index(fields=["source", "ogrn"]),
|
||
models.Index(fields=["source", "record_date"]),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["source", "external_id"],
|
||
name="unique_generic_source_external_id",
|
||
),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
label = self.organisation_name or self.title or self.external_id
|
||
return f"{self.source}: {label[:80]}"
|
||
|
||
|
||
class Proxy(TimestampMixin, models.Model):
|
||
"""
|
||
Прокси-сервер для парсеров.
|
||
|
||
Хранит список доступных прокси для использования в клиентах.
|
||
"""
|
||
|
||
address = models.CharField(
|
||
_("адрес"),
|
||
max_length=255,
|
||
unique=True,
|
||
help_text=_("Адрес прокси (например: http://proxy:8080)"),
|
||
)
|
||
is_active = models.BooleanField(
|
||
_("активен"),
|
||
default=True,
|
||
db_index=True,
|
||
help_text=_("Доступен ли прокси для использования"),
|
||
)
|
||
last_used_at = models.DateTimeField(
|
||
_("последнее использование"),
|
||
null=True,
|
||
blank=True,
|
||
help_text=_("Время последнего использования"),
|
||
)
|
||
fail_count = models.PositiveIntegerField(
|
||
_("количество ошибок"),
|
||
default=0,
|
||
help_text=_("Количество неудачных попыток подключения"),
|
||
)
|
||
description = models.CharField(
|
||
_("описание"),
|
||
max_length=255,
|
||
blank=True,
|
||
help_text=_("Описание прокси (провайдер, локация и т.д.)"),
|
||
)
|
||
source = models.CharField(
|
||
_("источник"),
|
||
max_length=50,
|
||
default="manual",
|
||
db_index=True,
|
||
help_text=_("Источник прокси (например: manual, proxy-tools)"),
|
||
)
|
||
country_code = models.CharField(
|
||
_("код страны"),
|
||
max_length=2,
|
||
default="RU",
|
||
db_index=True,
|
||
help_text=_("ISO-3166 код страны прокси, например RU"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_proxy"
|
||
verbose_name = _("прокси")
|
||
verbose_name_plural = _("прокси")
|
||
ordering = ["fail_count", "-last_used_at"]
|
||
|
||
def __str__(self) -> str:
|
||
status = "active" if self.is_active else "inactive"
|
||
return f"{self.address} ({status})"
|
||
|
||
|
||
class InspectionRecord(TimestampMixin, models.Model):
|
||
"""
|
||
Проверка из Единого реестра проверок (proverki.gov.ru).
|
||
|
||
Данные загружаются из ФГИС "Единый реестр проверок" (Генпрокуратура РФ).
|
||
Поддерживает два типа проверок:
|
||
- ФЗ-294 (традиционные проверки)
|
||
- ФЗ-248 (новые проверки с 2021 года)
|
||
"""
|
||
|
||
load_batch = models.PositiveIntegerField(
|
||
_("ID пакета загрузки"),
|
||
db_index=True,
|
||
help_text=_("Идентификатор пакета загрузки"),
|
||
)
|
||
registration_number = models.CharField(
|
||
_("учётный номер"),
|
||
max_length=100,
|
||
db_index=True,
|
||
help_text=_("Учётный номер проверки в реестре"),
|
||
)
|
||
inn = models.CharField(
|
||
_("ИНН"),
|
||
max_length=20,
|
||
db_index=True,
|
||
help_text=_("ИНН проверяемого лица"),
|
||
)
|
||
ogrn = models.CharField(
|
||
_("ОГРН"),
|
||
max_length=20,
|
||
db_index=True,
|
||
blank=True,
|
||
help_text=_("ОГРН проверяемого лица"),
|
||
)
|
||
organisation_name = models.TextField(
|
||
_("наименование организации"),
|
||
help_text=_("Наименование проверяемого лица"),
|
||
)
|
||
control_authority = models.TextField(
|
||
_("контрольный орган"),
|
||
blank=True,
|
||
help_text=_("Наименование контрольного (надзорного) органа"),
|
||
)
|
||
inspection_type = models.CharField(
|
||
_("тип проверки"),
|
||
max_length=100,
|
||
blank=True,
|
||
help_text=_("Тип проверки (плановая/внеплановая)"),
|
||
)
|
||
inspection_form = models.CharField(
|
||
_("форма проверки"),
|
||
max_length=100,
|
||
blank=True,
|
||
help_text=_("Форма проверки (документарная/выездная)"),
|
||
)
|
||
start_date = models.CharField(
|
||
_("дата начала"),
|
||
max_length=20,
|
||
blank=True,
|
||
help_text=_("Дата начала проверки"),
|
||
)
|
||
start_date_normalized = models.DateField(
|
||
_("дата начала (нормализованная)"),
|
||
null=True,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Нормализованная дата начала проверки"),
|
||
)
|
||
end_date = models.CharField(
|
||
_("дата окончания"),
|
||
max_length=20,
|
||
blank=True,
|
||
help_text=_("Дата окончания проверки"),
|
||
)
|
||
end_date_normalized = models.DateField(
|
||
_("дата окончания (нормализованная)"),
|
||
null=True,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Нормализованная дата окончания проверки"),
|
||
)
|
||
status = models.CharField(
|
||
_("статус"),
|
||
max_length=100,
|
||
blank=True,
|
||
help_text=_("Статус проверки"),
|
||
)
|
||
legal_basis = models.CharField(
|
||
_("правовое основание"),
|
||
max_length=255,
|
||
blank=True,
|
||
help_text=_("Правовое основание проверки (ФЗ-294, ФЗ-248)"),
|
||
)
|
||
result = models.TextField(
|
||
_("результат"),
|
||
blank=True,
|
||
help_text=_("Результат проверки"),
|
||
)
|
||
is_federal_law_248 = models.BooleanField(
|
||
_("по ФЗ-248"),
|
||
default=False,
|
||
db_index=True,
|
||
help_text=_("Проверка по ФЗ-248 (новые проверки с 2021 года)"),
|
||
)
|
||
data_year = models.PositiveSmallIntegerField(
|
||
_("год данных"),
|
||
db_index=True,
|
||
null=True,
|
||
blank=True,
|
||
help_text=_("Год, за который загружены данные"),
|
||
)
|
||
data_month = models.PositiveSmallIntegerField(
|
||
_("месяц данных"),
|
||
db_index=True,
|
||
null=True,
|
||
blank=True,
|
||
help_text=_("Месяц, за который загружены данные"),
|
||
)
|
||
registry_organization = models.ForeignKey(
|
||
"organizations.Organization",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="inspection_records",
|
||
verbose_name=_("организация"),
|
||
help_text=_("Связь с верхнеуровневой организацией"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_inspection"
|
||
verbose_name = _("запись единого реестра проверок")
|
||
verbose_name_plural = _("Единый реестр проверок")
|
||
ordering = ["-created_at"]
|
||
indexes = [
|
||
models.Index(fields=["inn", "registration_number"]),
|
||
models.Index(fields=["load_batch", "inn"]),
|
||
models.Index(fields=["is_federal_law_248", "data_year", "data_month"]),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["registration_number"],
|
||
name="unique_inspection_registration_number",
|
||
),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
org_name = self.organisation_name[:50] if self.organisation_name else ""
|
||
return f"{self.registration_number} - {org_name}"
|
||
|
||
|
||
class ProcurementRecord(TimestampMixin, models.Model):
|
||
"""
|
||
Государственная закупка из ЕИС zakupki.gov.ru.
|
||
|
||
Данные загружаются из Единой информационной системы в сфере закупок.
|
||
Поддерживает закупки по 44-ФЗ и 223-ФЗ.
|
||
"""
|
||
|
||
load_batch = models.PositiveIntegerField(
|
||
_("ID пакета загрузки"),
|
||
db_index=True,
|
||
help_text=_("Идентификатор пакета загрузки"),
|
||
)
|
||
purchase_number = models.CharField(
|
||
_("реестровый номер"),
|
||
max_length=100,
|
||
db_index=True,
|
||
help_text=_("Реестровый номер закупки"),
|
||
)
|
||
purchase_name = models.TextField(
|
||
_("наименование закупки"),
|
||
help_text=_("Наименование закупки"),
|
||
)
|
||
customer_inn = models.CharField(
|
||
_("ИНН заказчика"),
|
||
max_length=20,
|
||
db_index=True,
|
||
help_text=_("ИНН заказчика"),
|
||
)
|
||
customer_kpp = models.CharField(
|
||
_("КПП заказчика"),
|
||
max_length=20,
|
||
blank=True,
|
||
help_text=_("КПП заказчика"),
|
||
)
|
||
customer_ogrn = models.CharField(
|
||
_("ОГРН заказчика"),
|
||
max_length=20,
|
||
db_index=True,
|
||
blank=True,
|
||
help_text=_("ОГРН заказчика"),
|
||
)
|
||
customer_name = models.TextField(
|
||
_("наименование заказчика"),
|
||
help_text=_("Наименование заказчика"),
|
||
)
|
||
max_price = models.CharField(
|
||
_("НМЦ"),
|
||
max_length=50,
|
||
blank=True,
|
||
help_text=_("Начальная (максимальная) цена контракта"),
|
||
)
|
||
max_price_amount = models.DecimalField(
|
||
_("НМЦ (число)"),
|
||
max_digits=20,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Нормализованная числовая НМЦ"),
|
||
)
|
||
currency_code = models.CharField(
|
||
_("валюта"),
|
||
max_length=10,
|
||
default="RUB",
|
||
help_text=_("Код валюты"),
|
||
)
|
||
placement_method = models.CharField(
|
||
_("способ определения"),
|
||
max_length=255,
|
||
blank=True,
|
||
help_text=_("Способ определения поставщика"),
|
||
)
|
||
publish_date = models.CharField(
|
||
_("дата публикации"),
|
||
max_length=30,
|
||
blank=True,
|
||
help_text=_("Дата публикации извещения"),
|
||
)
|
||
publish_date_normalized = models.DateField(
|
||
_("дата публикации (нормализованная)"),
|
||
null=True,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Нормализованная дата публикации извещения"),
|
||
)
|
||
end_date = models.CharField(
|
||
_("дата окончания"),
|
||
max_length=30,
|
||
blank=True,
|
||
help_text=_("Дата окончания подачи заявок"),
|
||
)
|
||
end_date_normalized = models.DateField(
|
||
_("дата окончания (нормализованная)"),
|
||
null=True,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Нормализованная дата окончания подачи заявок"),
|
||
)
|
||
status = models.CharField(
|
||
_("статус"),
|
||
max_length=100,
|
||
blank=True,
|
||
help_text=_("Статус закупки"),
|
||
)
|
||
law_type = models.CharField(
|
||
_("тип закона"),
|
||
max_length=20,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Тип закона (44-ФЗ, 223-ФЗ)"),
|
||
)
|
||
purchase_object_info = models.TextField(
|
||
_("объект закупки"),
|
||
blank=True,
|
||
help_text=_("Информация об объекте закупки"),
|
||
)
|
||
href = models.URLField(
|
||
_("ссылка"),
|
||
blank=True,
|
||
max_length=500,
|
||
help_text=_("Ссылка на страницу закупки"),
|
||
)
|
||
region_code = models.CharField(
|
||
_("код региона"),
|
||
max_length=10,
|
||
blank=True,
|
||
db_index=True,
|
||
help_text=_("Код региона"),
|
||
)
|
||
data_year = models.PositiveSmallIntegerField(
|
||
_("год данных"),
|
||
db_index=True,
|
||
null=True,
|
||
blank=True,
|
||
help_text=_("Год, за который загружены данные"),
|
||
)
|
||
data_month = models.PositiveSmallIntegerField(
|
||
_("месяц данных"),
|
||
db_index=True,
|
||
null=True,
|
||
blank=True,
|
||
help_text=_("Месяц, за который загружены данные"),
|
||
)
|
||
registry_organization = models.ForeignKey(
|
||
"organizations.Organization",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="procurement_records",
|
||
verbose_name=_("организация"),
|
||
help_text=_("Связь с верхнеуровневой организацией"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_procurement"
|
||
verbose_name = _("запись ЕИС закупок")
|
||
verbose_name_plural = _("Единая информационная система закупок")
|
||
ordering = ["-created_at"]
|
||
indexes = [
|
||
models.Index(fields=["customer_inn", "purchase_number"]),
|
||
models.Index(fields=["load_batch", "customer_inn"]),
|
||
models.Index(fields=["law_type", "data_year", "data_month"]),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["purchase_number"],
|
||
name="unique_procurement_purchase_number",
|
||
),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
name = self.purchase_name[:50] if self.purchase_name else ""
|
||
return f"{self.purchase_number} - {name}"
|
||
|
||
|
||
class FinancialReport(TimestampMixin, models.Model):
|
||
"""
|
||
Бухгалтерская отчетность организации из ФНС.
|
||
|
||
Данные загружаются из Excel файлов, полученных от ФНС.
|
||
Имя файла: fin_{external_id}_{ogrn}.xlsx
|
||
"""
|
||
|
||
class Status(models.TextChoices):
|
||
PENDING = "pending", _("Ожидает обработки")
|
||
PROCESSING = "processing", _("Обрабатывается")
|
||
SUCCESS = "success", _("Успешно")
|
||
FAILED = "failed", _("Ошибка")
|
||
|
||
class SourceType(models.TextChoices):
|
||
FILE_WATCH = "file_watch", _("Мониторинг папки")
|
||
API = "api", _("API загрузка")
|
||
|
||
external_id = models.CharField(
|
||
_("внешний ID"),
|
||
max_length=50,
|
||
unique=True,
|
||
db_index=True,
|
||
help_text=_("Уникальный идентификатор из имени файла"),
|
||
)
|
||
ogrn = models.CharField(
|
||
_("ОГРН"),
|
||
max_length=15,
|
||
db_index=True,
|
||
help_text=_("ОГРН организации"),
|
||
)
|
||
registry_organization = models.ForeignKey(
|
||
"organizations.Organization",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="financial_reports",
|
||
verbose_name=_("организация"),
|
||
help_text=_("Связь с верхнеуровневой организацией"),
|
||
)
|
||
file_name = models.CharField(
|
||
_("имя файла"),
|
||
max_length=255,
|
||
help_text=_("Оригинальное имя загруженного файла"),
|
||
)
|
||
file_hash = models.CharField(
|
||
_("хеш файла"),
|
||
max_length=64,
|
||
unique=True,
|
||
help_text=_("SHA256 хеш файла для дедупликации"),
|
||
)
|
||
load_batch = models.PositiveIntegerField(
|
||
_("ID пакета загрузки"),
|
||
db_index=True,
|
||
help_text=_("Идентификатор пакета загрузки"),
|
||
)
|
||
status = models.CharField(
|
||
_("статус"),
|
||
max_length=20,
|
||
choices=Status.choices,
|
||
default=Status.PENDING,
|
||
db_index=True,
|
||
help_text=_("Статус обработки файла"),
|
||
)
|
||
error_message = models.TextField(
|
||
_("сообщение об ошибке"),
|
||
blank=True,
|
||
help_text=_("Текст ошибки при неудачной обработке"),
|
||
)
|
||
source = models.CharField(
|
||
_("источник"),
|
||
max_length=20,
|
||
choices=SourceType.choices,
|
||
help_text=_("Источник загрузки файла"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_financial_report"
|
||
verbose_name = _("запись бухгалтерской отчетности ФНС")
|
||
verbose_name_plural = _("Бухгалтерская отчетность ФНС")
|
||
ordering = ["-created_at"]
|
||
indexes = [
|
||
models.Index(fields=["ogrn", "status"]),
|
||
models.Index(fields=["load_batch", "status"]),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.external_id} (ОГРН: {self.ogrn}) - {self.status}"
|
||
|
||
|
||
class FinancialReportLine(models.Model):
|
||
"""
|
||
Строка бухгалтерской отчетности.
|
||
|
||
Хранит значения по конкретной строке формы отчетности за конкретный год.
|
||
Формы: №1 (Баланс), №2 (ОФР), №3 (Изменение капитала),
|
||
№4 (Движение денежных средств), №6 (Целевое использование).
|
||
"""
|
||
|
||
report = models.ForeignKey(
|
||
FinancialReport,
|
||
on_delete=models.CASCADE,
|
||
related_name="lines",
|
||
verbose_name=_("отчет"),
|
||
)
|
||
form_code = models.CharField(
|
||
_("код формы"),
|
||
max_length=10,
|
||
db_index=True,
|
||
help_text=_("Номер формы отчетности (1, 2, 3, 4, 6)"),
|
||
)
|
||
line_code = models.CharField(
|
||
_("код строки"),
|
||
max_length=10,
|
||
db_index=True,
|
||
help_text=_("Код строки отчетности (например: 1100, 2110)"),
|
||
)
|
||
line_name = models.CharField(
|
||
_("наименование строки"),
|
||
max_length=255,
|
||
help_text=_("Наименование строки отчетности"),
|
||
)
|
||
year = models.PositiveSmallIntegerField(
|
||
_("год"),
|
||
db_index=True,
|
||
help_text=_("Отчетный год"),
|
||
)
|
||
period_start = models.BigIntegerField(
|
||
_("на начало периода"),
|
||
null=True,
|
||
blank=True,
|
||
help_text=_("Значение на начало периода (тыс. руб.)"),
|
||
)
|
||
period_end = models.BigIntegerField(
|
||
_("на конец периода"),
|
||
null=True,
|
||
blank=True,
|
||
help_text=_("Значение на конец периода (тыс. руб.)"),
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_financial_report_line"
|
||
verbose_name = _("строка финансового отчета")
|
||
verbose_name_plural = _("строки финансовых отчетов")
|
||
indexes = [
|
||
models.Index(fields=["report", "form_code", "line_code"]),
|
||
models.Index(fields=["year", "line_code"]),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["report", "form_code", "line_code", "year"],
|
||
name="unique_report_line_year",
|
||
),
|
||
]
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.line_code} ({self.line_name[:30]}) - {self.year}"
|
||
|
||
|
||
class ParsingSettings(TimestampMixin, models.Model):
|
||
"""Singleton-настройки периодичности обновления источников парсинга."""
|
||
|
||
class Frequency(models.TextChoices):
|
||
DAILY = "daily", _("Ежедневно")
|
||
WEEKLY = "weekly", _("Еженедельно")
|
||
MONTHLY = "monthly", _("Ежемесячно")
|
||
YEARLY = "yearly", _("Ежегодно")
|
||
|
||
singleton_key = models.PositiveSmallIntegerField(
|
||
_("ключ singleton-записи"),
|
||
default=1,
|
||
unique=True,
|
||
editable=False,
|
||
)
|
||
manufacturers_and_products = models.CharField(
|
||
_("производители и продукция"),
|
||
max_length=20,
|
||
choices=Frequency.choices,
|
||
default=Frequency.DAILY,
|
||
)
|
||
public_procurements = models.CharField(
|
||
_("госзакупки"),
|
||
max_length=20,
|
||
choices=Frequency.choices,
|
||
default=Frequency.DAILY,
|
||
)
|
||
defense_unreliable_suppliers = models.CharField(
|
||
_("недобросовестные поставщики ОПК"),
|
||
max_length=20,
|
||
choices=Frequency.choices,
|
||
default=Frequency.WEEKLY,
|
||
)
|
||
planned_inspections = models.CharField(
|
||
_("плановые проверки"),
|
||
max_length=20,
|
||
choices=Frequency.choices,
|
||
default=Frequency.MONTHLY,
|
||
)
|
||
arbitration_cases = models.CharField(
|
||
_("арбитражные дела"),
|
||
max_length=20,
|
||
choices=Frequency.choices,
|
||
default=Frequency.DAILY,
|
||
)
|
||
bankruptcy_procedures = models.CharField(
|
||
_("банкротные процедуры"),
|
||
max_length=20,
|
||
choices=Frequency.choices,
|
||
default=Frequency.DAILY,
|
||
)
|
||
information_security_registries = models.CharField(
|
||
_("реестры информационной безопасности"),
|
||
max_length=20,
|
||
choices=Frequency.choices,
|
||
default=Frequency.YEARLY,
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "parsers_parsing_settings"
|
||
verbose_name = _("настройки парсинга")
|
||
verbose_name_plural = _("настройки парсинга")
|
||
|
||
def __str__(self) -> str:
|
||
return "Настройки парсинга"
|