feat: add parser source dashboard and scheduling
All checks were successful
CI/CD Pipeline / Code Quality Checks (pull_request) Successful in 1m55s
CI/CD Pipeline / Run Tests (pull_request) Successful in 3m6s
CI/CD Pipeline / Run API Inventory E2E Tests (pull_request) Successful in 2m48s
CI/CD Pipeline / Telegram Notify Success (pull_request) Successful in 19s

This commit is contained in:
2026-04-28 01:08:51 +02:00
parent 1249071a33
commit 26d61f7181
58 changed files with 7713 additions and 141 deletions

View File

@@ -23,6 +23,15 @@ class ParserLoadLog(TimestampMixin, models.Model):
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", _("Успешно")
@@ -80,6 +89,32 @@ class ParserLoadLog(TimestampMixin, models.Model):
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 IndustrialCertificateRecord(TimestampMixin, models.Model):
"""
Сертификат промышленного производства РФ.
@@ -323,6 +358,116 @@ class IndustrialProductRecord(TimestampMixin, models.Model):
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=ParserLoadLog.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=30,
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(
"registers.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):
"""
Прокси-сервер для парсеров.