Files
mostovik-backend/src/apps/parsers/models.py
Aleksandr Meshchriakov ee95628a0a
Some checks failed
CI/CD Pipeline / Run Tests (push) Failing after 37s
CI/CD Pipeline / Code Quality Checks (push) Failing after 43s
CI/CD Pipeline / Build & Push Images (push) Has been skipped
CI/CD Pipeline / Deploy (dev) (push) Has been skipped
CI/CD Pipeline / Deploy (prod) (push) Has been skipped
CI/CD Pipeline / Code Quality Checks (pull_request) Failing after 0s
CI/CD Pipeline / Run Tests (pull_request) Failing after 0s
CI/CD Pipeline / Build & Push Images (pull_request) Has been skipped
CI/CD Pipeline / Deploy (dev) (pull_request) Has been skipped
CI/CD Pipeline / Deploy (prod) (pull_request) Has been skipped
feat: обновления парсеров, тестов и миграций
- Обновлены клиенты парсеров (checko, fns, minpromtorg, proverki, zakupki)
- Добавлены новые миграции для моделей
- Расширено покрытие тестами
- Обновлены конфигурации и настройки проекта
- Добавлены утилиты для тестирования

Co-Authored-By: Warp <agent@warp.dev>
2026-02-10 10:17:47 +01:00

666 lines
23 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.
"""
Модели для приложения парсеров.
Используют миксины из apps.core для стандартных полей и поведения.
"""
from apps.core.mixins import TimestampMixin
from django.db import models
from django.utils.translation import gettext_lazy as _
class ParserLoadLog(TimestampMixin, models.Model):
"""
Лог загрузок парсеров.
Хранит информацию о каждой загрузке данных из внешнего источника.
"""
class Source(models.TextChoices):
INDUSTRIAL = "industrial", _("Промышленное производство")
MANUFACTURES = "manufactures", _("Реестр производителей")
INSPECTIONS = "inspections", _("Единый реестр проверок")
PROCUREMENTS = "procurements", _("Государственные закупки")
FNS_REPORTS = "fns_reports", _("Бухгалтерская отчетность ФНС")
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,
default="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 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=_("Дата выдачи сертификата"),
)
certificate_number = models.CharField(
_("номер сертификата"),
max_length=100,
db_index=True,
help_text=_("Номер сертификата"),
)
expiry_date = models.CharField(
_("дата окончания"),
max_length=15,
blank=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=_("ОГРН организации"),
)
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=_("Юридический адрес организации"),
)
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 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=_("Описание прокси (провайдер, локация и т.д.)"),
)
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=_("Дата начала проверки"),
)
end_date = models.CharField(
_("дата окончания"),
max_length=20,
blank=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=_("Месяц, за который загружены данные"),
)
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=_("Начальная (максимальная) цена контракта"),
)
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=_("Дата публикации извещения"),
)
end_date = models.CharField(
_("дата окончания"),
max_length=30,
blank=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=_("Месяц, за который загружены данные"),
)
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=_("ОГРН организации"),
)
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}"