diff --git a/.gitignore b/.gitignore
index a5bd201..8ef9cb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,4 +38,4 @@ Thumbs.db
# Backup files
*.bak
-*.backup
\ No newline at end of file
+*.backupdata/
diff --git a/README.md b/README.md
index 2d9f74c..9a1e509 100644
--- a/README.md
+++ b/README.md
@@ -10,10 +10,53 @@
- **PostgreSQL**: 15.10
- **Redis**: 7.x
- **Celery**: 5.3.6
-- **Scrapy**: 2.11.2
+- **Playwright**: 1.52+ (browser automation)
- **Gunicorn**: 21.2.0
- **Apache**: 2.4.57
+## Парсеры данных
+
+Проект включает парсеры для загрузки данных из государственных источников:
+
+### Минпромторг (minpromtorg.gov.ru)
+- **Сертификаты промышленного производства** - `parse_industrial_production`
+- **Реестр производителей** - `parse_manufactures`
+
+### Единый реестр проверок (proverki.gov.ru)
+- **Проверки по ФЗ-294** - традиционные проверки
+- **Проверки по ФЗ-248** - новые проверки с 2021 года
+- **Автоматическая синхронизация** - `sync_inspections`
+
+### Запуск парсеров
+
+```python
+# Через Celery
+from apps.parsers.tasks import (
+ parse_industrial_production,
+ parse_manufactures,
+ parse_inspections,
+ sync_inspections,
+)
+
+# Парсинг сертификатов
+parse_industrial_production.delay()
+
+# Парсинг производителей
+parse_manufactures.delay()
+
+# Парсинг проверок за конкретный месяц
+parse_inspections.delay(year=2025, month=10, is_federal_law_248=False)
+
+# Автоматическая синхронизация (с 01.01.2025 до текущего месяца)
+sync_inspections.delay()
+```
+
+### Особенности парсера proverki.gov.ru
+- Использует **Playwright** для JS-рендеринга
+- Поддержка **потокового парсинга** для больших файлов (>50 МБ)
+- Автоматическое определение последнего загруженного периода
+- Раздельная загрузка ФЗ-294 и ФЗ-248
+
## Структура проекта
```
@@ -313,4 +356,38 @@ make clean # Очистка временных файлов
## Лицензия
-MIT License
\ No newline at end of file
+MIT License
+
+---
+
+## Changelog
+
+### 2026-01-21
+#### Добавлено
+- **Задача `sync_inspections`** - автоматическая синхронизация проверок с proverki.gov.ru
+ - Инкрементальная загрузка с последнего сохранённого периода
+ - Начало с 01.01.2025 если БД пуста
+ - Раздельная загрузка ФЗ-294 и ФЗ-248
+ - Автоматическая остановка при отсутствии данных (2 пустых месяца)
+- **Поля в модели InspectionRecord**:
+ - `is_federal_law_248` - признак проверки по ФЗ-248
+ - `data_year` - год загруженных данных
+ - `data_month` - месяц загруженных данных
+- **Потоковый парсинг XML** для файлов >50 МБ (iterparse)
+- **Методы в InspectionService**:
+ - `get_last_loaded_period()` - получение последнего загруженного периода
+ - `has_data_for_period()` - проверка наличия данных за период
+
+### 2026-01-20
+#### Добавлено
+- **Парсер proverki.gov.ru** с поддержкой Playwright
+- Навигация по порталу (клик на вкладку "Скачать")
+- Парсинг XML с namespaces
+- Извлечение данных из атрибутов и вложенных элементов
+
+### 2026-01-19
+#### Добавлено
+- **Парсеры Минпромторга** (сертификаты, производители)
+- **Модуль apps.parsers** с клиентами, сервисами и задачами Celery
+- **Django Admin** для управления записями парсеров
+- Дедупликация по unique constraints
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index b2e5137..a76721b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,21 +1,19 @@
-version: '3.8'
-
services:
db:
image: postgres:15.10
- container_name: project_db
+ container_name: mostovik_db
restart: unless-stopped
environment:
- POSTGRES_DB: ${POSTGRES_DB:-project_dev}
+ POSTGRES_DB: ${POSTGRES_DB:-mostovik_dev}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- - postgres_data:/var/lib/postgresql/data
+ - ./data/db:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
networks:
- - project_network
+ - mostovik_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
@@ -24,14 +22,14 @@ services:
redis:
image: redis:7-alpine
- container_name: project_redis
+ container_name: mostovik_redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- - redis_data:/data
+ - ./data/redis:/data
networks:
- - project_network
+ - mostovik_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
@@ -42,7 +40,7 @@ services:
build:
context: .
dockerfile: docker/Dockerfile.web
- container_name: project_web
+ container_name: mostovik_web
restart: unless-stopped
depends_on:
db:
@@ -54,11 +52,12 @@ services:
- SECRET_KEY=${SECRET_KEY:-django-insecure-development-key}
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- - POSTGRES_DB=${POSTGRES_DB:-project_dev}
+ - POSTGRES_DB=${POSTGRES_DB:-mostovik_dev}
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
volumes:
- ./src:/app/src
- ./logs:/app/logs
@@ -67,7 +66,7 @@ services:
ports:
- "8000:8000"
networks:
- - project_network
+ - mostovik_network
command: >
sh -c "python src/manage.py migrate &&
python src/manage.py collectstatic --noinput &&
@@ -77,7 +76,7 @@ services:
build:
context: .
dockerfile: docker/Dockerfile.celery
- container_name: project_celery_worker
+ container_name: mostovik_celery_worker
restart: unless-stopped
depends_on:
db:
@@ -88,23 +87,24 @@ services:
- DEBUG=${DEBUG:-True}
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- - POSTGRES_DB=${POSTGRES_DB:-project_dev}
+ - POSTGRES_DB=${POSTGRES_DB:-mostovik_dev}
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
volumes:
- ./src:/app/src
- ./logs:/app/logs
networks:
- - project_network
+ - mostovik_network
command: celery -A config worker --loglevel=info
celery_beat:
build:
context: .
dockerfile: docker/Dockerfile.celery
- container_name: project_celery_beat
+ container_name: mostovik_celery_beat
restart: unless-stopped
depends_on:
db:
@@ -115,22 +115,19 @@ services:
- DEBUG=${DEBUG:-True}
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- - POSTGRES_DB=${POSTGRES_DB:-project_dev}
+ - POSTGRES_DB=${POSTGRES_DB:-mostovik_dev}
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
volumes:
- ./src:/app/src
- ./logs:/app/logs
networks:
- - project_network
+ - mostovik_network
command: celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler
-volumes:
- postgres_data:
- redis_data:
-
networks:
- project_network:
- driver: bridge
\ No newline at end of file
+ mostovik_network:
+ driver: bridge
diff --git a/docker/Dockerfile.celery b/docker/Dockerfile.celery
index 9b5a0bd..bce2b61 100644
--- a/docker/Dockerfile.celery
+++ b/docker/Dockerfile.celery
@@ -3,12 +3,30 @@ FROM python:3.11.2-slim
# Установка системных зависимостей
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
- gcc \
- libpq-dev \
- libffi-dev \
- libxml2-dev \
- libxslt1-dev \
- zlib1g-dev \
+ gcc \
+ libpq-dev \
+ libffi-dev \
+ libxml2-dev \
+ libxslt1-dev \
+ zlib1g-dev \
+ # Зависимости для Playwright/Chromium
+ libnss3 \
+ libnspr4 \
+ libatk1.0-0 \
+ libatk-bridge2.0-0 \
+ libcups2 \
+ libdrm2 \
+ libdbus-1-3 \
+ libxkbcommon0 \
+ libxcomposite1 \
+ libxdamage1 \
+ libxfixes3 \
+ libxrandr2 \
+ libgbm1 \
+ libasound2 \
+ libpango-1.0-0 \
+ libcairo2 \
+ libatspi2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# Создание рабочей директории
@@ -28,8 +46,15 @@ COPY src/ ./src/
# Создание необходимых директорий
RUN mkdir -p logs
+# PYTHONPATH для доступа к модулям
+ENV PYTHONPATH=/app/src
+
# Создание пользователя для запуска приложения
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
+
+# Установка Playwright браузеров для appuser
+ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright
+RUN playwright install chromium --with-deps || true
RUN chown -R appuser:appgroup /app
USER appuser
diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web
index 203b11b..7c1803c 100644
--- a/docker/Dockerfile.web
+++ b/docker/Dockerfile.web
@@ -3,13 +3,13 @@ FROM python:3.11.2-slim
# Установка системных зависимостей
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
- gcc \
- postgresql-client \
- libpq-dev \
- libffi-dev \
- libxml2-dev \
- libxslt1-dev \
- zlib1g-dev \
+ gcc \
+ postgresql-client \
+ libpq-dev \
+ libffi-dev \
+ libxml2-dev \
+ libxslt1-dev \
+ zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Создание рабочей директории
@@ -29,6 +29,9 @@ COPY src/ ./src/
# Создание необходимых директорий
RUN mkdir -p logs staticfiles media
+# PYTHONPATH для доступа к модулям
+ENV PYTHONPATH=/app/src
+
# Создание пользователя для запуска приложения
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
RUN chown -R appuser:appgroup /app
diff --git a/pyproject.toml b/pyproject.toml
index fb661b2..5ed9f1a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,6 +47,9 @@ dependencies = [
"model-bakery>=1.17.0",
"faker>=40.1.2",
"factory-boy>=3.3.0",
+ "openpyxl>=3.1.5",
+ "django-jazzmin>=2.6.2",
+ "playwright>=1.57.0",
]
[project.optional-dependencies]
diff --git a/requirements.txt b/requirements.txt
index 070dfe5..d9012e3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,15 @@
# This file was autogenerated by uv via the following command:
-# uv pip compile pyproject.toml --output-file=requirements.txt
+# uv export --no-hashes
+alabaster==0.7.16
+ # via sphinx
amqp==5.3.1
# via kombu
asgiref==3.11.0
# via
# django
# django-cors-headers
+async-timeout==5.0.1 ; python_full_version < '3.11.3'
+ # via redis
attrs==25.4.0
# via
# outcome
@@ -14,49 +18,69 @@ attrs==25.4.0
# twisted
automat==25.4.16
# via twisted
+babel==2.17.0
+ # via sphinx
+bandit==1.7.5
beautifulsoup4==4.12.3
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
billiard==4.2.4
# via celery
+black==23.12.1
celery==5.3.6
# via
- # mostovik-backend (pyproject.toml)
# django-celery-beat
# django-celery-results
+ # flower
+ # mostovik-backend
certifi==2026.1.4
# via
# requests
# selenium
-cffi==2.0.0
- # via cryptography
+cffi==2.0.0 ; (implementation_name != 'pypy' and os_name == 'nt') or platform_python_implementation != 'PyPy'
+ # via
+ # cryptography
+ # gevent
+ # trio
+cfgv==3.5.0
+ # via pre-commit
charset-normalizer==3.4.4
# via requests
click==8.1.7
# via
+ # black
# celery
# click-didyoumean
# click-plugins
# click-repl
+ # typer
click-didyoumean==0.3.1
# via celery
click-plugins==1.1.1.2
# via celery
click-repl==0.3.0
# via celery
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via
+ # bandit
+ # click
+ # pytest
+ # sphinx
constantly==23.10.4
# via twisted
coreapi==2.3.3
# via
- # mostovik-backend (pyproject.toml)
# django-rest-swagger
+ # mostovik-backend
# openapi-codec
coreschema==0.0.4
# via coreapi
+coverage==7.4.0
+ # via pytest-cov
cron-descriptor==2.0.6
# via django-celery-beat
cryptography==42.0.5
# via
- # mostovik-backend (pyproject.toml)
+ # mostovik-backend
# pyopenssl
# scrapy
# service-identity
@@ -66,59 +90,108 @@ cssselect==1.3.0
# scrapy
defusedxml==0.7.1
# via scrapy
+distlib==0.4.0
+ # via virtualenv
django==3.2.25
# via
- # mostovik-backend (pyproject.toml)
# django-celery-beat
# django-celery-results
# django-cors-headers
+ # django-debug-toolbar
+ # django-extensions
# django-filter
+ # django-jazzmin
# django-redis
+ # django-stubs
+ # django-stubs-ext
# django-timezone-field
# djangorestframework
# djangorestframework-simplejwt
# drf-yasg
# model-bakery
+ # mostovik-backend
django-celery-beat==2.6.0
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
django-celery-results==2.5.1
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
django-cors-headers==4.3.1
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
+django-debug-toolbar==4.2.0
+django-extensions==3.2.3
django-filter==23.5
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
+django-jazzmin==2.6.2
+ # via mostovik-backend
django-redis==5.4.0
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
django-rest-swagger==2.2.0
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
+django-stubs==4.2.7
+django-stubs-ext==5.2.9
+ # via django-stubs
django-timezone-field==7.2.1
# via django-celery-beat
djangorestframework==3.14.0
# via
- # mostovik-backend (pyproject.toml)
# django-rest-swagger
# djangorestframework-simplejwt
# drf-yasg
+ # mostovik-backend
djangorestframework-simplejwt==5.3.1
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
+docutils==0.20.1
+ # via
+ # sphinx
+ # sphinx-rtd-theme
drf-yasg==1.21.10
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
+et-xmlfile==2.0.0
+ # via openpyxl
+factory-boy==3.3.0
+ # via mostovik-backend
+faker==40.1.2
+ # via
+ # factory-boy
+ # mostovik-backend
filelock==3.20.3
- # via tldextract
+ # via
+ # tldextract
+ # virtualenv
+flake8==6.1.0
+flower==2.0.1
+gevent==23.9.1
+gitdb==4.0.12
+ # via gitpython
+gitpython==3.1.46
+ # via bandit
+greenlet==3.3.0
+ # via
+ # gevent
+ # playwright
+gunicorn==21.2.0
h11==0.16.0
# via wsproto
+humanize==4.15.0
+ # via flower
hyperlink==21.0.0
# via twisted
+identify==2.6.16
+ # via pre-commit
idna==3.11
# via
# hyperlink
# requests
# tldextract
# trio
+imagesize==1.4.1
+ # via sphinx
incremental==24.11.0
# via twisted
inflection==0.5.1
# via drf-yasg
+iniconfig==2.3.0
+ # via pytest
+isort==5.13.2
itemadapter==0.13.1
# via
# itemloaders
@@ -128,7 +201,9 @@ itemloaders==1.3.2
itypes==1.2.0
# via coreapi
jinja2==3.1.6
- # via coreschema
+ # via
+ # coreschema
+ # sphinx
jmespath==1.0.1
# via
# itemloaders
@@ -139,97 +214,157 @@ lxml==6.0.2
# via
# parsel
# scrapy
+markdown-it-py==3.0.0
+ # via rich
markupsafe==3.0.3
- # via jinja2
+ # via
+ # jinja2
+ # werkzeug
+mccabe==0.7.0
+ # via flake8
+mdurl==0.1.2
+ # via markdown-it-py
model-bakery==1.17.0
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
+mypy==1.8.0
+mypy-extensions==1.1.0
+ # via
+ # black
+ # mypy
+nodeenv==1.10.0
+ # via pre-commit
numpy==1.24.4
# via
- # mostovik-backend (pyproject.toml)
+ # mostovik-backend
# pandas
openapi-codec==1.3.2
# via django-rest-swagger
+openpyxl==3.1.5
+ # via mostovik-backend
outcome==1.3.0.post0
# via
# trio
# trio-websocket
packaging==25.0
# via
+ # black
# drf-yasg
+ # gunicorn
# incremental
# kombu
# parsel
+ # pytest
# scrapy
+ # sphinx
pandas==2.0.3
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
parsel==1.10.0
# via
# itemloaders
# scrapy
+pathspec==1.0.3
+ # via black
pillow==12.1.0
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
+platformdirs==4.5.1
+ # via
+ # black
+ # virtualenv
+playwright==1.57.0
+ # via mostovik-backend
+pluggy==1.6.0
+ # via pytest
+pre-commit==3.6.0
+prometheus-client==0.24.1
+ # via flower
prompt-toolkit==3.0.52
# via click-repl
protego==0.5.0
# via scrapy
psycopg2-binary==2.9.9
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
pyasn1==0.6.2
# via
# pyasn1-modules
# service-identity
pyasn1-modules==0.4.2
# via service-identity
-pycparser==2.23
+pycodestyle==2.11.1
+ # via flake8
+pycparser==2.23 ; (implementation_name != 'PyPy' and implementation_name != 'pypy' and os_name == 'nt') or (implementation_name != 'PyPy' and platform_python_implementation != 'PyPy')
# via cffi
-pydispatcher==2.0.7
+pydispatcher==2.0.7 ; platform_python_implementation == 'CPython'
# via scrapy
+pyee==13.0.0
+ # via playwright
+pyflakes==3.1.0
+ # via flake8
+pygments==2.19.2
+ # via
+ # rich
+ # sphinx
pyjwt==2.10.1
# via djangorestframework-simplejwt
pyopenssl==25.1.0
# via scrapy
+pypydispatcher==2.1.2 ; platform_python_implementation == 'PyPy'
+ # via scrapy
pysocks==1.7.1
# via urllib3
+pytest==7.4.4
+ # via
+ # pytest-cov
+ # pytest-django
+pytest-cov==4.1.0
+pytest-django==4.7.0
python-crontab==3.3.0
# via django-celery-beat
python-dateutil==2.8.2
# via
- # mostovik-backend (pyproject.toml)
# celery
+ # mostovik-backend
# pandas
python-decouple==3.8
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
python-dotenv==1.0.1
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
python-json-logger==2.0.7
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
pytz==2024.1
# via
- # mostovik-backend (pyproject.toml)
# django
# djangorestframework
# drf-yasg
+ # flower
+ # mostovik-backend
# pandas
pyyaml==6.0.3
- # via drf-yasg
+ # via
+ # bandit
+ # drf-yasg
+ # pre-commit
queuelib==1.8.0
# via scrapy
redis==5.0.3
# via
- # mostovik-backend (pyproject.toml)
# django-redis
+ # mostovik-backend
requests==2.31.0
# via
- # mostovik-backend (pyproject.toml)
# coreapi
+ # mostovik-backend
# requests-file
+ # sphinx
# tldextract
requests-file==3.0.1
# via tldextract
+rich==14.2.0
+ # via bandit
+ruff==0.1.14
scrapy==2.11.2
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
selenium==4.17.2
- # via mostovik-backend (pyproject.toml)
+ # via mostovik-backend
service-identity==24.2.0
# via scrapy
setuptools==80.9.0
@@ -238,16 +373,47 @@ simplejson==3.20.2
# via django-rest-swagger
six==1.17.0
# via python-dateutil
+smmap==5.0.2
+ # via gitdb
sniffio==1.3.1
# via trio
+snowballstemmer==3.0.1
+ # via sphinx
sortedcontainers==2.4.0
# via trio
soupsieve==2.8.2
# via beautifulsoup4
+sphinx==7.2.6
+ # via
+ # sphinx-rtd-theme
+ # sphinxcontrib-jquery
+sphinx-rtd-theme==2.0.0
+sphinxcontrib-applehelp==2.0.0
+ # via sphinx
+sphinxcontrib-devhelp==2.0.0
+ # via sphinx
+sphinxcontrib-htmlhelp==2.1.0
+ # via sphinx
+sphinxcontrib-jquery==4.1
+ # via sphinx-rtd-theme
+sphinxcontrib-jsmath==1.0.1
+ # via sphinx
+sphinxcontrib-qthelp==2.0.0
+ # via sphinx
+sphinxcontrib-serializinghtml==2.0.0
+ # via sphinx
sqlparse==0.5.5
- # via django
+ # via
+ # django
+ # django-debug-toolbar
+stevedore==5.6.0
+ # via bandit
tldextract==5.3.1
# via scrapy
+tomli==2.4.0 ; python_full_version <= '3.11'
+ # via coverage
+tornado==6.5.4
+ # via flower
trio==0.32.0
# via
# selenium
@@ -256,16 +422,28 @@ trio-websocket==0.12.2
# via selenium
twisted==25.5.0
# via scrapy
+typer==0.9.0
+types-pytz==2025.2.0.20251108
+ # via django-stubs
+types-pyyaml==6.0.12.20250915
+ # via django-stubs
+types-requests==2.31.0.20240125
typing-extensions==4.15.0
# via
# cron-descriptor
+ # django-stubs
+ # django-stubs-ext
+ # mypy
+ # pyee
# pyopenssl
# selenium
# twisted
+ # typer
tzdata==2025.3
# via
# celery
# django-celery-beat
+ # faker
# kombu
# pandas
uritemplate==4.2.0
@@ -276,20 +454,28 @@ urllib3==2.6.3
# via
# requests
# selenium
+ # types-requests
vine==5.1.0
# via
# amqp
# celery
# kombu
+virtualenv==20.36.1
+ # via pre-commit
w3lib==2.3.1
# via
# parsel
# scrapy
+watchdog==3.0.0
wcwidth==0.2.14
# via prompt-toolkit
+werkzeug==3.0.1
wsproto==1.3.2
# via trio-websocket
+zope-event==6.1
+ # via gevent
zope-interface==8.2
# via
+ # gevent
# scrapy
# twisted
diff --git a/src/apps/core/admin.py b/src/apps/core/admin.py
new file mode 100644
index 0000000..7fe9e2e
--- /dev/null
+++ b/src/apps/core/admin.py
@@ -0,0 +1,152 @@
+"""
+Admin configuration for core app.
+"""
+
+from apps.core.models import BackgroundJob
+from django.contrib import admin
+from django.utils.html import format_html
+
+
+@admin.register(BackgroundJob)
+class BackgroundJobAdmin(admin.ModelAdmin):
+ """Admin для фоновых задач."""
+
+ list_display = [
+ "task_name_short",
+ "status_badge",
+ "progress_bar",
+ "user_id",
+ "started_at",
+ "duration_display",
+ "created_at",
+ ]
+ list_filter = ["status", "task_name", "created_at"]
+ search_fields = ["task_id", "task_name", "error"]
+ readonly_fields = [
+ "id",
+ "task_id",
+ "task_name",
+ "status",
+ "progress",
+ "progress_message",
+ "result",
+ "error",
+ "traceback",
+ "started_at",
+ "completed_at",
+ "user_id",
+ "meta",
+ "created_at",
+ "updated_at",
+ ]
+ ordering = ["-created_at"]
+ list_per_page = 50
+ date_hierarchy = "created_at"
+
+ fieldsets = (
+ (
+ "Задача",
+ {"fields": ("id", "task_id", "task_name", "user_id")},
+ ),
+ (
+ "Статус",
+ {"fields": ("status", "progress", "progress_message")},
+ ),
+ (
+ "Результат",
+ {"fields": ("result",), "classes": ("collapse",)},
+ ),
+ (
+ "Ошибка",
+ {"fields": ("error", "traceback"), "classes": ("collapse",)},
+ ),
+ (
+ "Время",
+ {"fields": ("started_at", "completed_at", "created_at", "updated_at")},
+ ),
+ (
+ "Метаданные",
+ {"fields": ("meta",), "classes": ("collapse",)},
+ ),
+ )
+
+ def task_name_short(self, obj):
+ """Сокращённое имя задачи."""
+ name = obj.task_name or ""
+ # Берём только последнюю часть пути
+ parts = name.split(".")
+ if len(parts) > 2:
+ return parts[-1]
+ return name
+
+ task_name_short.short_description = "Задача"
+ task_name_short.admin_order_field = "task_name"
+
+ def status_badge(self, obj):
+ """Цветной бейдж статуса."""
+ colors = {
+ "pending": "#6c757d",
+ "started": "#007bff",
+ "success": "#28a745",
+ "failure": "#dc3545",
+ "revoked": "#ffc107",
+ "retry": "#17a2b8",
+ }
+ color = colors.get(obj.status, "#6c757d")
+ return format_html(
+ '{}',
+ color,
+ obj.get_status_display(),
+ )
+
+ status_badge.short_description = "Статус"
+ status_badge.admin_order_field = "status"
+
+ def progress_bar(self, obj):
+ """Прогресс-бар."""
+ progress = obj.progress or 0
+ color = "#28a745" if progress == 100 else "#007bff"
+ return format_html(
+ '
",
+ progress,
+ color,
+ progress,
+ )
+
+ progress_bar.short_description = "Прогресс"
+
+ def duration_display(self, obj):
+ """Длительность выполнения."""
+ duration = obj.duration
+ if duration is None:
+ return "-"
+ if duration < 60:
+ return f"{duration:.1f} сек"
+ return f"{duration / 60:.1f} мин"
+
+ duration_display.short_description = "Длительность"
+
+ def has_add_permission(self, request):
+ """Запретить создание записей вручную."""
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ """Запретить редактирование записей."""
+ return False
+
+ actions = ["revoke_jobs"]
+
+ @admin.action(description="Отменить выбранные задачи")
+ def revoke_jobs(self, request, queryset):
+ from celery import current_app
+
+ count = 0
+ for job in queryset.filter(status__in=["pending", "started"]):
+ current_app.control.revoke(job.task_id, terminate=True)
+ job.revoke()
+ count += 1
+ self.message_user(request, f"Отменено {count} задач")
diff --git a/src/apps/parsers/__init__.py b/src/apps/parsers/__init__.py
new file mode 100644
index 0000000..0083698
--- /dev/null
+++ b/src/apps/parsers/__init__.py
@@ -0,0 +1,10 @@
+"""
+Приложение для парсеров и ETL процессов.
+
+Содержит:
+- Модели для конфигурации источников данных и результатов парсинга
+- Сервисы для извлечения, трансформации и загрузки данных (ETL)
+- Celery задачи для фоновой обработки
+"""
+
+default_app_config = "apps.parsers.apps.ParsersConfig"
diff --git a/src/apps/parsers/admin.py b/src/apps/parsers/admin.py
new file mode 100644
index 0000000..1a8741c
--- /dev/null
+++ b/src/apps/parsers/admin.py
@@ -0,0 +1,387 @@
+"""
+Admin configuration for parsers app.
+"""
+
+from apps.parsers.models import (
+ IndustrialCertificateRecord,
+ InspectionRecord,
+ ManufacturerRecord,
+ ParserLoadLog,
+ Proxy,
+)
+from django.contrib import admin
+from django.utils.html import format_html
+
+
+@admin.register(Proxy)
+class ProxyAdmin(admin.ModelAdmin):
+ """Admin для прокси-серверов."""
+
+ list_display = [
+ "address",
+ "is_active_badge",
+ "fail_count",
+ "last_used_at",
+ "created_at",
+ ]
+ list_filter = ["is_active", "created_at"]
+ search_fields = ["address"]
+ readonly_fields = ["created_at", "updated_at", "last_used_at"]
+ ordering = ["-is_active", "-last_used_at"]
+ list_per_page = 50
+
+ fieldsets = (
+ ("Основное", {"fields": ("address", "is_active")}),
+ ("Статистика", {"fields": ("fail_count", "last_used_at")}),
+ ("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
+ )
+
+ def is_active_badge(self, obj):
+ """Цветной бейдж активности."""
+ if obj.is_active:
+ return format_html(
+ 'Активен'
+ )
+ return format_html(
+ 'Неактивен'
+ )
+
+ is_active_badge.short_description = "Статус"
+ is_active_badge.admin_order_field = "is_active"
+
+ actions = ["activate_proxies", "deactivate_proxies", "reset_fail_count"]
+
+ @admin.action(description="Активировать выбранные прокси")
+ def activate_proxies(self, request, queryset):
+ updated = queryset.update(is_active=True)
+ self.message_user(request, f"Активировано {updated} прокси")
+
+ @admin.action(description="Деактивировать выбранные прокси")
+ def deactivate_proxies(self, request, queryset):
+ updated = queryset.update(is_active=False)
+ self.message_user(request, f"Деактивировано {updated} прокси")
+
+ @admin.action(description="Сбросить счётчик ошибок")
+ def reset_fail_count(self, request, queryset):
+ updated = queryset.update(fail_count=0)
+ self.message_user(request, f"Сброшен счётчик для {updated} прокси")
+
+
+@admin.register(ParserLoadLog)
+class ParserLoadLogAdmin(admin.ModelAdmin):
+ """Admin для логов загрузки."""
+
+ list_display = [
+ "id",
+ "source",
+ "batch_id",
+ "status_badge",
+ "records_count",
+ "created_at",
+ ]
+ list_filter = ["source", "status", "created_at"]
+ search_fields = ["batch_id", "error_message"]
+ readonly_fields = ["created_at", "updated_at"]
+ ordering = ["-created_at"]
+ list_per_page = 50
+ date_hierarchy = "created_at"
+
+ fieldsets = (
+ ("Основное", {"fields": ("source", "batch_id", "status")}),
+ ("Результат", {"fields": ("records_count", "error_message")}),
+ ("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
+ )
+
+ def status_badge(self, obj):
+ """Цветной бейдж статуса."""
+ colors = {
+ "success": "#28a745",
+ "failed": "#dc3545",
+ "in_progress": "#ffc107",
+ "pending": "#6c757d",
+ }
+ color = colors.get(obj.status, "#6c757d")
+ return format_html(
+ '{}',
+ color,
+ obj.get_status_display()
+ if hasattr(obj, "get_status_display")
+ else obj.status,
+ )
+
+ status_badge.short_description = "Статус"
+ status_badge.admin_order_field = "status"
+
+ def has_add_permission(self, request):
+ """Запретить создание логов вручную."""
+ return False
+
+
+class HasCertificateNumberFilter(admin.SimpleListFilter):
+ """Фильтр по наличию номера сертификата."""
+
+ title = "Номер сертификата"
+ parameter_name = "has_cert_number"
+
+ def lookups(self, request, model_admin):
+ return [
+ ("yes", "С номером"),
+ ("no", "Без номера"),
+ ]
+
+ def queryset(self, request, queryset):
+ if self.value() == "yes":
+ return queryset.exclude(certificate_number__in=["-", ""])
+ if self.value() == "no":
+ return queryset.filter(certificate_number__in=["-", ""])
+ return queryset
+
+
+@admin.register(IndustrialCertificateRecord)
+class IndustrialCertificateRecordAdmin(admin.ModelAdmin):
+ """Admin для сертификатов промышленного производства."""
+
+ list_display = [
+ "certificate_number",
+ "organisation_name_short",
+ "inn",
+ "ogrn",
+ "issue_date",
+ "expiry_date",
+ "load_batch",
+ ]
+ list_filter = [HasCertificateNumberFilter, "load_batch", "created_at"]
+ search_fields = [
+ "certificate_number",
+ "organisation_name",
+ "inn",
+ "ogrn",
+ ]
+ readonly_fields = ["created_at", "updated_at", "load_batch"]
+ ordering = ["-created_at"]
+ list_per_page = 100
+ date_hierarchy = "created_at"
+ raw_id_fields = []
+
+ fieldsets = (
+ (
+ "Сертификат",
+ {"fields": ("certificate_number", "issue_date", "expiry_date")},
+ ),
+ (
+ "Организация",
+ {"fields": ("organisation_name", "inn", "ogrn")},
+ ),
+ (
+ "Документ",
+ {"fields": ("certificate_file_url",), "classes": ("collapse",)},
+ ),
+ (
+ "Системное",
+ {
+ "fields": ("load_batch", "created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+
+ def organisation_name_short(self, obj):
+ """Сокращённое название организации."""
+ name = obj.organisation_name or ""
+ return name[:60] + "..." if len(name) > 60 else name
+
+ organisation_name_short.short_description = "Организация"
+ organisation_name_short.admin_order_field = "organisation_name"
+
+ def has_add_permission(self, request):
+ """Запретить создание записей вручную."""
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ """Запретить редактирование записей."""
+ return False
+
+
+@admin.register(ManufacturerRecord)
+class ManufacturerRecordAdmin(admin.ModelAdmin):
+ """Admin для реестра производителей."""
+
+ list_display = [
+ "full_legal_name_short",
+ "inn",
+ "ogrn",
+ "address_short",
+ "load_batch",
+ "created_at",
+ ]
+ list_filter = ["load_batch", "created_at"]
+ search_fields = [
+ "full_legal_name",
+ "inn",
+ "ogrn",
+ "address",
+ ]
+ readonly_fields = ["created_at", "updated_at", "load_batch"]
+ ordering = ["-created_at"]
+ list_per_page = 100
+ date_hierarchy = "created_at"
+
+ fieldsets = (
+ (
+ "Организация",
+ {"fields": ("full_legal_name", "inn", "ogrn")},
+ ),
+ (
+ "Адрес",
+ {"fields": ("address",)},
+ ),
+ (
+ "Системное",
+ {
+ "fields": ("load_batch", "created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+
+ def full_legal_name_short(self, obj):
+ """Сокращённое название."""
+ name = obj.full_legal_name or ""
+ return name[:60] + "..." if len(name) > 60 else name
+
+ full_legal_name_short.short_description = "Название"
+ full_legal_name_short.admin_order_field = "full_legal_name"
+
+ def address_short(self, obj):
+ """Сокращённый адрес."""
+ addr = obj.address or ""
+ return addr[:40] + "..." if len(addr) > 40 else addr
+
+ address_short.short_description = "Адрес"
+ address_short.admin_order_field = "address"
+
+ def has_add_permission(self, request):
+ """Запретить создание записей вручную."""
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ """Запретить редактирование записей."""
+ return False
+
+
+@admin.register(InspectionRecord)
+class InspectionRecordAdmin(admin.ModelAdmin):
+ """Admin для проверок из Единого реестра проверок."""
+
+ list_display = [
+ "registration_number",
+ "organisation_name_short",
+ "inn",
+ "control_authority_short",
+ "inspection_type",
+ "status_badge",
+ "start_date",
+ "load_batch",
+ ]
+ list_filter = [
+ "inspection_type",
+ "inspection_form",
+ "status",
+ "load_batch",
+ "created_at",
+ ]
+ search_fields = [
+ "registration_number",
+ "organisation_name",
+ "inn",
+ "ogrn",
+ "control_authority",
+ ]
+ readonly_fields = ["created_at", "updated_at", "load_batch"]
+ ordering = ["-created_at"]
+ list_per_page = 100
+ date_hierarchy = "created_at"
+
+ fieldsets = (
+ (
+ "Проверка",
+ {
+ "fields": (
+ "registration_number",
+ "inspection_type",
+ "inspection_form",
+ "status",
+ )
+ },
+ ),
+ (
+ "Организация",
+ {"fields": ("organisation_name", "inn", "ogrn")},
+ ),
+ (
+ "Контрольный орган",
+ {"fields": ("control_authority", "legal_basis")},
+ ),
+ (
+ "Сроки и результат",
+ {"fields": ("start_date", "end_date", "result")},
+ ),
+ (
+ "Системное",
+ {
+ "fields": ("load_batch", "created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+
+ def organisation_name_short(self, obj):
+ """Сокращённое название организации."""
+ name = obj.organisation_name or ""
+ return name[:50] + "..." if len(name) > 50 else name
+
+ organisation_name_short.short_description = "Организация"
+ organisation_name_short.admin_order_field = "organisation_name"
+
+ def control_authority_short(self, obj):
+ """Сокращённое название контрольного органа."""
+ name = obj.control_authority or ""
+ return name[:30] + "..." if len(name) > 30 else name
+
+ control_authority_short.short_description = "Контр. орган"
+ control_authority_short.admin_order_field = "control_authority"
+
+ def status_badge(self, obj):
+ """Цветной бейдж статуса."""
+ status = obj.status or ""
+ status_lower = status.lower()
+
+ if "завершен" in status_lower:
+ color = "#28a745"
+ elif "процесс" in status_lower or "проведен" in status_lower:
+ color = "#ffc107"
+ elif "отменен" in status_lower or "прекращ" in status_lower:
+ color = "#dc3545"
+ else:
+ color = "#6c757d"
+
+ return format_html(
+ '{}',
+ color,
+ status[:20] if len(status) > 20 else status,
+ )
+
+ status_badge.short_description = "Статус"
+ status_badge.admin_order_field = "status"
+
+ def has_add_permission(self, request):
+ """Запретить создание записей вручную."""
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ """Запретить редактирование записей."""
+ return False
diff --git a/src/apps/parsers/apps.py b/src/apps/parsers/apps.py
new file mode 100644
index 0000000..3576eb9
--- /dev/null
+++ b/src/apps/parsers/apps.py
@@ -0,0 +1,12 @@
+from django.apps import AppConfig
+
+
+class ParsersConfig(AppConfig):
+ """Конфигурация приложения парсеров."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.parsers"
+ verbose_name = "Парсеры и ETL"
+
+ def ready(self):
+ import apps.parsers.signals # noqa
diff --git a/src/apps/parsers/clients/__init__.py b/src/apps/parsers/clients/__init__.py
new file mode 100644
index 0000000..e2cb352
--- /dev/null
+++ b/src/apps/parsers/clients/__init__.py
@@ -0,0 +1,22 @@
+"""
+Клиенты для парсинга внешних источников данных.
+
+Каждый источник имеет изолированный клиент, который:
+- Принимает настройки (proxy и т.д.) через конструктор
+- Возвращает типизированные dataclass объекты
+- Не зависит от Django ORM
+"""
+
+from apps.parsers.clients.base import BaseHTTPClient
+from apps.parsers.clients.minpromtorg import (
+ IndustrialProductionClient,
+ ManufacturesClient,
+)
+from apps.parsers.clients.proverki import ProverkiClient
+
+__all__ = [
+ "BaseHTTPClient",
+ "IndustrialProductionClient",
+ "ManufacturesClient",
+ "ProverkiClient",
+]
diff --git a/src/apps/parsers/clients/base.py b/src/apps/parsers/clients/base.py
new file mode 100644
index 0000000..9bfbd5a
--- /dev/null
+++ b/src/apps/parsers/clients/base.py
@@ -0,0 +1,238 @@
+"""
+Базовый HTTP клиент для парсеров.
+
+Изолирован от Django, использует только стандартные библиотеки и requests.
+"""
+
+import logging
+import random
+from dataclasses import dataclass, field
+from typing import Any
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+
+class HTTPClientError(Exception):
+ """Базовое исключение HTTP клиента."""
+
+ def __init__(
+ self, message: str, status_code: int | None = None, url: str | None = None
+ ):
+ self.message = message
+ self.status_code = status_code
+ self.url = url
+ super().__init__(message)
+
+
+class ConnectionError(HTTPClientError):
+ """Ошибка подключения."""
+
+ pass
+
+
+class HTTPError(HTTPClientError):
+ """HTTP ошибка (4xx, 5xx)."""
+
+ pass
+
+
+@dataclass
+class BaseHTTPClient:
+ """
+ Базовый HTTP клиент для парсинга внешних источников.
+
+ Изолирован от Django. Принимает все настройки через конструктор.
+
+ Поддерживает работу со списком прокси — при каждом запросе выбирается случайный.
+
+ Использование:
+ # Без прокси
+ client = BaseHTTPClient(base_url="https://api.example.com")
+
+ # С одним прокси
+ client = BaseHTTPClient(
+ base_url="https://api.example.com",
+ proxies=["http://proxy:8080"]
+ )
+
+ # Со списком прокси (выбор случайный)
+ client = BaseHTTPClient(
+ base_url="https://api.example.com",
+ proxies=["http://proxy1:8080", "http://proxy2:8080"]
+ )
+ """
+
+ base_url: str
+ proxies: list[str] | None = None
+ timeout: int = 30
+ headers: dict[str, str] = field(default_factory=dict)
+
+ def __post_init__(self) -> None:
+ """Инициализация после создания dataclass."""
+ self._session: requests.Session | None = None
+ self._current_proxy: str | None = None
+ # Убираем trailing slash
+ self.base_url = self.base_url.rstrip("/")
+
+ def _select_proxy(self) -> str | None:
+ """Выбрать случайный прокси из списка."""
+ if not self.proxies:
+ return None
+ return random.choice(self.proxies) # noqa: S311 - not for cryptographic use
+
+ @property
+ def session(self) -> requests.Session:
+ """Ленивая инициализация сессии."""
+ if self._session is None:
+ self._session = self._create_session()
+ return self._session
+
+ def _create_session(self) -> requests.Session:
+ """Создать и настроить сессию requests."""
+ session = requests.Session()
+
+ # Настройка прокси
+ self._current_proxy = self._select_proxy()
+ if self._current_proxy:
+ session.proxies = {
+ "http": self._current_proxy,
+ "https": self._current_proxy,
+ }
+ logger.debug("Proxy configured: %s", self._current_proxy)
+
+ # Базовые заголовки
+ default_headers = {
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36"
+ ),
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Encoding": "gzip, deflate, br",
+ }
+ default_headers.update(self.headers)
+ session.headers.update(default_headers)
+
+ return session
+
+ def rotate_proxy(self) -> str | None:
+ """
+ Сменить прокси на другой из списка.
+
+ Пересоздаёт сессию с новым прокси.
+
+ Returns:
+ Новый прокси или None
+ """
+ if self._session is not None:
+ self._session.close()
+ self._session = None
+
+ self._current_proxy = self._select_proxy()
+ logger.info("Rotated proxy to: %s", self._current_proxy)
+ return self._current_proxy
+
+ @property
+ def current_proxy(self) -> str | None:
+ """Текущий используемый прокси."""
+ return self._current_proxy
+
+ def _build_url(self, endpoint: str) -> str:
+ """Построить полный URL."""
+ if endpoint.startswith(("http://", "https://")):
+ return endpoint
+ endpoint = endpoint.lstrip("/")
+ return f"{self.base_url}/{endpoint}"
+
+ def get(
+ self, endpoint: str, params: dict[str, Any] | None = None
+ ) -> requests.Response:
+ """
+ Выполнить GET запрос.
+
+ Args:
+ endpoint: Путь или полный URL
+ params: Query параметры
+
+ Returns:
+ Response объект
+
+ Raises:
+ ConnectionError: При ошибке подключения
+ HTTPError: При HTTP ошибке (4xx, 5xx)
+ """
+ url = self._build_url(endpoint)
+ logger.info("GET %s (proxy: %s)", url, self._current_proxy)
+
+ try:
+ response = self.session.get(url, params=params, timeout=self.timeout)
+ except requests.exceptions.ConnectionError as e:
+ logger.error("Connection error: %s - %s", url, e)
+ raise ConnectionError(f"Failed to connect to {url}", url=url) from e
+ except requests.exceptions.Timeout as e:
+ logger.error("Timeout: %s", url)
+ raise ConnectionError(f"Request timeout for {url}", url=url) from e
+ except requests.exceptions.RequestException as e:
+ logger.error("Request error: %s - %s", url, e)
+ raise HTTPClientError(f"Request failed: {e}", url=url) from e
+
+ if not response.ok:
+ logger.error("HTTP error %d: %s", response.status_code, url)
+ raise HTTPError(
+ f"HTTP {response.status_code} for {url}",
+ status_code=response.status_code,
+ url=url,
+ )
+
+ logger.debug("Response %d from %s", response.status_code, url)
+ return response
+
+ def get_json(self, endpoint: str, params: dict[str, Any] | None = None) -> dict:
+ """
+ Выполнить GET запрос и вернуть JSON.
+
+ Args:
+ endpoint: Путь или полный URL
+ params: Query параметры
+
+ Returns:
+ Распарсенный JSON как dict
+ """
+ response = self.get(endpoint, params=params)
+ return response.json()
+
+ def download_file(self, endpoint: str) -> bytes:
+ """
+ Скачать файл.
+
+ Args:
+ endpoint: Путь или полный URL файла
+
+ Returns:
+ Содержимое файла как bytes
+ """
+ url = self._build_url(endpoint)
+ logger.info("Downloading file: %s", url)
+
+ response = self.get(endpoint)
+ content = response.content
+
+ logger.info("Downloaded %d bytes from %s", len(content), url)
+ return content
+
+ def close(self) -> None:
+ """Закрыть сессию."""
+ if self._session is not None:
+ self._session.close()
+ self._session = None
+ logger.debug("Session closed")
+
+ def __enter__(self) -> "BaseHTTPClient":
+ """Поддержка context manager."""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Закрытие при выходе из context manager."""
+ self.close()
diff --git a/src/apps/parsers/clients/minpromtorg/__init__.py b/src/apps/parsers/clients/minpromtorg/__init__.py
new file mode 100644
index 0000000..5fba646
--- /dev/null
+++ b/src/apps/parsers/clients/minpromtorg/__init__.py
@@ -0,0 +1,18 @@
+"""
+Клиенты для парсинга данных с портала Минпромторга.
+
+Источники:
+- IndustrialProductionClient: сертификаты промышленного производства
+- ManufacturesClient: реестр производителей
+"""
+
+from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient
+from apps.parsers.clients.minpromtorg.manufactures import ManufacturesClient
+from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer
+
+__all__ = [
+ "IndustrialProductionClient",
+ "ManufacturesClient",
+ "IndustrialCertificate",
+ "Manufacturer",
+]
diff --git a/src/apps/parsers/clients/minpromtorg/industrial.py b/src/apps/parsers/clients/minpromtorg/industrial.py
new file mode 100644
index 0000000..6f6abdf
--- /dev/null
+++ b/src/apps/parsers/clients/minpromtorg/industrial.py
@@ -0,0 +1,225 @@
+"""
+Клиент для парсинга данных о промышленном производстве РФ.
+
+Источник: Минпромторг, раздел "Заключения о подтверждении производства".
+"""
+
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import datetime
+from io import BytesIO
+
+from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError
+from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate
+from openpyxl import load_workbook
+
+logger = logging.getLogger(__name__)
+
+# Конфигурация по умолчанию
+DEFAULT_HOST = "minpromtorg.gov.ru"
+DEFAULT_API_PATH = "/api/kss-document-preview"
+DEFAULT_DOC_TYPE = "668d4f2a-966a-4b65-9fb9-2f1ad19a3d1f"
+DEFAULT_QUERY = "Заключения о подтверждении производства промышленной продукции"
+FILE_PATTERN = re.compile(r"data_resolutions_(\d{8})")
+
+
+class IndustrialProductionClientError(HTTPClientError):
+ """Ошибка клиента промышленного производства."""
+
+ pass
+
+
+@dataclass
+class IndustrialProductionClient:
+ """
+ Клиент для получения данных о сертификатах промышленного производства.
+
+ Полностью изолирован от Django. Все настройки передаются через конструктор.
+
+ Использование:
+ # Без прокси
+ client = IndustrialProductionClient()
+
+ # Со списком прокси
+ client = IndustrialProductionClient(proxies=["http://proxy1:8080"])
+ certificates = client.fetch_certificates()
+
+ for cert in certificates:
+ print(cert.certificate_number, cert.organisation_name)
+ """
+
+ proxies: list[str] | None = None
+ host: str = DEFAULT_HOST
+ api_path: str = DEFAULT_API_PATH
+ doc_type: str = DEFAULT_DOC_TYPE
+ query: str = DEFAULT_QUERY
+ timeout: int = 120
+ _http_client: BaseHTTPClient | None = field(default=None, repr=False)
+
+ def __post_init__(self) -> None:
+ """Инициализация HTTP клиента."""
+ self._http_client = None
+
+ @property
+ def http_client(self) -> BaseHTTPClient:
+ """Ленивая инициализация HTTP клиента."""
+ if self._http_client is None:
+ self._http_client = BaseHTTPClient(
+ base_url=f"https://{self.host}",
+ proxies=self.proxies,
+ timeout=self.timeout,
+ headers={
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0",
+ "Accept": "application/json",
+ },
+ )
+ return self._http_client
+
+ def fetch_certificates(self) -> list[IndustrialCertificate]:
+ """
+ Получить список сертификатов промышленного производства.
+
+ Процесс:
+ 1. Запрос к API для получения списка файлов
+ 2. Поиск последнего файла по дате
+ 3. Скачивание и парсинг Excel файла
+ 4. Преобразование в список IndustrialCertificate
+
+ Returns:
+ Список сертификатов
+
+ Raises:
+ IndustrialProductionClientError: При ошибке получения данных
+ """
+ logger.info("Fetching industrial production certificates")
+
+ try:
+ # 1. Получить список файлов
+ files_data = self._fetch_files_list()
+
+ # 2. Найти последний файл
+ file_url = self._get_latest_file_url(files_data)
+ if not file_url:
+ logger.warning("No files found matching pattern")
+ return []
+
+ # 3. Скачать и распарсить Excel
+ certificates = self._download_and_parse(file_url)
+
+ logger.info("Fetched %d certificates", len(certificates))
+ return certificates
+
+ except HTTPClientError:
+ raise
+ except Exception as e:
+ logger.error("Error fetching certificates: %s", e)
+ raise IndustrialProductionClientError(
+ f"Failed to fetch certificates: {e}"
+ ) from e
+
+ def _fetch_files_list(self) -> list[dict]:
+ """Получить список файлов с API."""
+ params = {
+ "types[]": self.doc_type,
+ "fragment": self.query,
+ }
+ data = self.http_client.get_json(self.api_path, params=params)
+
+ # Найти документ с файлами
+ for item in data.get("data", []):
+ if self.query in item.get("name", ""):
+ return item.get("files", [])
+
+ return []
+
+ def _get_latest_file_url(self, files_data: list[dict]) -> str | None:
+ """Найти URL последнего файла по дате в имени."""
+ if not files_data:
+ return None
+
+ latest_file = None
+ latest_date = None
+
+ for file_info in files_data:
+ name = file_info.get("name", "")
+ match = FILE_PATTERN.search(name)
+ if not match:
+ continue
+
+ try:
+ file_date = datetime.strptime(match.group(1), "%Y%m%d")
+ if latest_date is None or file_date > latest_date:
+ latest_date = file_date
+ latest_file = file_info
+ except ValueError:
+ continue
+
+ if latest_file:
+ url = latest_file.get("url", "")
+ logger.info(
+ "Latest file: %s (date: %s)", latest_file.get("name"), latest_date
+ )
+ # URL может быть относительным
+ if url and not url.startswith("http"):
+ return f"https://{self.host}{url}"
+ return url
+
+ return None
+
+ def _download_and_parse(self, file_url: str) -> list[IndustrialCertificate]:
+ """Скачать Excel файл и распарсить его."""
+ logger.info("Downloading Excel file: %s", file_url)
+
+ content = self.http_client.download_file(file_url)
+ logger.info("Downloaded %d bytes", len(content))
+
+ excel_data = BytesIO(content)
+ wb = load_workbook(filename=excel_data, data_only=True)
+ ws = wb.active
+
+ certificates = []
+ # Пропускаем заголовок (первая строка)
+ # Колонки: Dateofcon, Numberofcon, Expirationdate, Document, Nameoforg, INN, OGRN
+ for row in ws.iter_rows(min_row=2, values_only=True):
+ if not row or not any(row):
+ continue
+
+ cert = self._parse_row(row)
+ if cert:
+ certificates.append(cert)
+
+ wb.close()
+ return certificates
+
+ def _parse_row(self, row: tuple) -> IndustrialCertificate | None:
+ """Преобразовать строку Excel в IndustrialCertificate."""
+ try:
+ # Порядок колонок в Excel:
+ # Dateofcon, Numberofcon, Expirationdate, Document, Nameoforg, INN, OGRN
+ return IndustrialCertificate(
+ issue_date=str(row[0] or ""),
+ certificate_number=str(row[1] or ""),
+ expiry_date=str(row[2] or ""),
+ certificate_file_url=str(row[3] or ""),
+ organisation_name=str(row[4] or ""),
+ inn=str(row[5] or ""),
+ ogrn=str(row[6] or ""),
+ )
+ except (IndexError, TypeError) as e:
+ logger.warning("Failed to parse row: %s - %s", row, e)
+ return None
+
+ def close(self) -> None:
+ """Закрыть клиент и освободить ресурсы."""
+ if self._http_client is not None:
+ self._http_client.close()
+ self._http_client = None
+
+ def __enter__(self) -> "IndustrialProductionClient":
+ """Поддержка context manager."""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Закрытие при выходе из context manager."""
+ self.close()
diff --git a/src/apps/parsers/clients/minpromtorg/manufactures.py b/src/apps/parsers/clients/minpromtorg/manufactures.py
new file mode 100644
index 0000000..026fba8
--- /dev/null
+++ b/src/apps/parsers/clients/minpromtorg/manufactures.py
@@ -0,0 +1,218 @@
+"""
+Клиент для парсинга реестра производителей Минпромторга.
+
+Источник: Минпромторг, реестр производителей.
+"""
+
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import datetime
+from io import BytesIO
+
+from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError
+from apps.parsers.clients.minpromtorg.schemas import Manufacturer
+from openpyxl import load_workbook
+
+logger = logging.getLogger(__name__)
+
+# Конфигурация по умолчанию
+DEFAULT_HOST = "minpromtorg.gov.ru"
+DEFAULT_API_PATH = "/api/kss-document-preview"
+DEFAULT_DOC_TYPE = "668d4f2a-966a-4b65-9fb9-2f1ad19a3d1f"
+DEFAULT_QUERY = "Производители промышленной продукции"
+FILE_PATTERN = re.compile(r"data_orgs_(\d{8})")
+
+
+class ManufacturesClientError(HTTPClientError):
+ """Ошибка клиента реестра производителей."""
+
+ pass
+
+
+@dataclass
+class ManufacturesClient:
+ """
+ Клиент для получения данных о производителях из реестра Минпромторга.
+
+ Полностью изолирован от Django. Все настройки передаются через конструктор.
+
+ Использование:
+ # Без прокси
+ client = ManufacturesClient()
+
+ # Со списком прокси
+ client = ManufacturesClient(proxies=["http://proxy1:8080"])
+ manufacturers = client.fetch_manufacturers()
+
+ for m in manufacturers:
+ print(m.full_legal_name, m.inn)
+ """
+
+ proxies: list[str] | None = None
+ host: str = DEFAULT_HOST
+ api_path: str = DEFAULT_API_PATH
+ doc_type: str = DEFAULT_DOC_TYPE
+ query: str = DEFAULT_QUERY
+ timeout: int = 120
+ _http_client: BaseHTTPClient | None = field(default=None, repr=False)
+
+ def __post_init__(self) -> None:
+ """Инициализация HTTP клиента."""
+ self._http_client = None
+
+ @property
+ def http_client(self) -> BaseHTTPClient:
+ """Ленивая инициализация HTTP клиента."""
+ if self._http_client is None:
+ self._http_client = BaseHTTPClient(
+ base_url=f"https://{self.host}",
+ proxies=self.proxies,
+ timeout=self.timeout,
+ headers={
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0",
+ "Accept": "application/json",
+ },
+ )
+ return self._http_client
+
+ def fetch_manufacturers(self) -> list[Manufacturer]:
+ """
+ Получить список производителей из реестра.
+
+ Процесс:
+ 1. Запрос к API для получения списка файлов
+ 2. Поиск последнего файла по дате
+ 3. Скачивание и парсинг Excel файла
+ 4. Преобразование в список Manufacturer
+
+ Returns:
+ Список производителей
+
+ Raises:
+ ManufacturesClientError: При ошибке получения данных
+ """
+ logger.info("Fetching manufacturers registry")
+
+ try:
+ # 1. Получить список файлов
+ files_data = self._fetch_files_list()
+
+ # 2. Найти последний файл
+ file_url = self._get_latest_file_url(files_data)
+ if not file_url:
+ logger.warning("No files found matching pattern")
+ return []
+
+ # 3. Скачать и распарсить Excel
+ manufacturers = self._download_and_parse(file_url)
+
+ logger.info("Fetched %d manufacturers", len(manufacturers))
+ return manufacturers
+
+ except HTTPClientError:
+ raise
+ except Exception as e:
+ logger.error("Error fetching manufacturers: %s", e)
+ raise ManufacturesClientError(f"Failed to fetch manufacturers: {e}") from e
+
+ def _fetch_files_list(self) -> list[dict]:
+ """Получить список файлов с API."""
+ params = {
+ "types[]": self.doc_type,
+ "fragment": self.query,
+ }
+ data = self.http_client.get_json(self.api_path, params=params)
+
+ # Найти документ с файлами
+ for item in data.get("data", []):
+ if self.query in item.get("name", ""):
+ return item.get("files", [])
+
+ return []
+
+ def _get_latest_file_url(self, files_data: list[dict]) -> str | None:
+ """Найти URL последнего файла по дате в имени."""
+ if not files_data:
+ return None
+
+ latest_file = None
+ latest_date = None
+
+ for file_info in files_data:
+ name = file_info.get("name", "")
+ match = FILE_PATTERN.search(name)
+ if not match:
+ continue
+
+ try:
+ file_date = datetime.strptime(match.group(1), "%Y%m%d")
+ if latest_date is None or file_date > latest_date:
+ latest_date = file_date
+ latest_file = file_info
+ except ValueError:
+ continue
+
+ if latest_file:
+ url = latest_file.get("url", "")
+ logger.info(
+ "Latest file: %s (date: %s)", latest_file.get("name"), latest_date
+ )
+ if url and not url.startswith("http"):
+ return f"https://{self.host}{url}"
+ return url
+
+ return None
+
+ def _download_and_parse(self, file_url: str) -> list[Manufacturer]:
+ """Скачать Excel файл и распарсить его."""
+ logger.info("Downloading Excel file: %s", file_url)
+
+ content = self.http_client.download_file(file_url)
+ logger.info("Downloaded %d bytes", len(content))
+
+ excel_data = BytesIO(content)
+ wb = load_workbook(filename=excel_data, data_only=True)
+ ws = wb.active
+
+ manufacturers = []
+ # Пропускаем заголовок (первая строка)
+ for row in ws.iter_rows(min_row=2, values_only=True):
+ if not row or not any(row):
+ continue
+
+ manufacturer = self._parse_row(row)
+ if manufacturer:
+ manufacturers.append(manufacturer)
+
+ wb.close()
+ return manufacturers
+
+ def _parse_row(self, row: tuple) -> Manufacturer | None:
+ """Преобразовать строку Excel в Manufacturer."""
+ try:
+ # Порядок колонок в Excel:
+ # full_legal_name, inn, ogrn, address
+ return Manufacturer(
+ full_legal_name=str(row[0] or ""),
+ inn=str(row[1] or ""),
+ ogrn=str(row[2] or ""),
+ address=str(row[3] or "") if len(row) > 3 else "",
+ )
+ except (IndexError, TypeError) as e:
+ logger.warning("Failed to parse row: %s - %s", row, e)
+ return None
+
+ def close(self) -> None:
+ """Закрыть клиент и освободить ресурсы."""
+ if self._http_client is not None:
+ self._http_client.close()
+ self._http_client = None
+
+ def __enter__(self) -> "ManufacturesClient":
+ """Поддержка context manager."""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Закрытие при выходе из context manager."""
+ self.close()
diff --git a/src/apps/parsers/clients/minpromtorg/schemas.py b/src/apps/parsers/clients/minpromtorg/schemas.py
new file mode 100644
index 0000000..3c85ecd
--- /dev/null
+++ b/src/apps/parsers/clients/minpromtorg/schemas.py
@@ -0,0 +1,59 @@
+"""
+Dataclass схемы для данных Минпромторга.
+
+Эти классы представляют данные, возвращаемые клиентами.
+Они не зависят от Django ORM и используются как DTO.
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class IndustrialCertificate:
+ """
+ Сертификат промышленного производства РФ.
+
+ Источник: Минпромторг, раздел "Промышленное производство".
+ """
+
+ issue_date: str
+ """Дата выдачи сертификата."""
+
+ certificate_number: str
+ """Номер сертификата."""
+
+ expiry_date: str
+ """Дата окончания действия."""
+
+ certificate_file_url: str
+ """URL файла сертификата."""
+
+ organisation_name: str
+ """Наименование организации."""
+
+ inn: str
+ """ИНН организации."""
+
+ ogrn: str
+ """ОГРН организации."""
+
+
+@dataclass(frozen=True)
+class Manufacturer:
+ """
+ Производитель из реестра Минпромторга.
+
+ Источник: Минпромторг, реестр производителей.
+ """
+
+ full_legal_name: str
+ """Полное наименование организации."""
+
+ inn: str
+ """ИНН организации."""
+
+ ogrn: str
+ """ОГРН организации."""
+
+ address: str
+ """Адрес организации."""
diff --git a/src/apps/parsers/clients/proverki/__init__.py b/src/apps/parsers/clients/proverki/__init__.py
new file mode 100644
index 0000000..f6147d7
--- /dev/null
+++ b/src/apps/parsers/clients/proverki/__init__.py
@@ -0,0 +1,14 @@
+"""
+Клиенты для proverki.gov.ru - Единый реестр проверок.
+
+Источник: ФГИС "Единый реестр проверок" (Генпрокуратура РФ).
+"""
+
+from apps.parsers.clients.proverki.client import ProverkiClient
+from apps.parsers.clients.proverki.schemas import Inspection, InspectionPlan
+
+__all__ = [
+ "ProverkiClient",
+ "Inspection",
+ "InspectionPlan",
+]
diff --git a/src/apps/parsers/clients/proverki/client.py b/src/apps/parsers/clients/proverki/client.py
new file mode 100644
index 0000000..f74ade2
--- /dev/null
+++ b/src/apps/parsers/clients/proverki/client.py
@@ -0,0 +1,1102 @@
+"""
+Клиент для парсинга данных с proverki.gov.ru.
+
+Источник: ФГИС "Единый реестр проверок" (Генпрокуратура РФ).
+
+Поддерживает несколько стратегий получения данных:
+1. Прямой доступ к Open Data файлам (XML)
+2. API запросы (если доступны)
+3. Playwright headless browser для динамического контента (fallback)
+"""
+
+import io
+import logging
+import tempfile
+import zipfile
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from xml.etree import ( # noqa: S314 - XML parsing with proper error handling
+ ElementTree as ET,
+)
+
+from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError
+from apps.parsers.clients.proverki.schemas import Inspection, InspectionPlan
+
+logger = logging.getLogger(__name__)
+
+# Конфигурация по умолчанию
+DEFAULT_HOST = "proverki.gov.ru"
+DEFAULT_OPEN_DATA_PATH = "/portal/public-open-data"
+
+# Паттерны URL для открытых данных
+# Формат: https://proverki.gov.ru/opendata/{dataset_id}/data-{date}.zip
+OPEN_DATA_BASE_URL = "https://proverki.gov.ru/opendata"
+
+# URL портала открытых данных (для Playwright)
+OPEN_DATA_PORTAL_URL = "https://proverki.gov.ru/portal/public-open-data"
+
+
+class ProverkiClientError(HTTPClientError):
+ """Ошибка клиента proverki.gov.ru."""
+
+ pass
+
+
+@dataclass
+class ProverkiClient:
+ """
+ Клиент для получения данных о проверках с proverki.gov.ru.
+
+ Полностью изолирован от Django. Все настройки передаются через конструктор.
+
+ Стратегия работы:
+ 1. Пытается получить данные через прямые HTTP запросы к Open Data
+ 2. Если сайт требует JavaScript - использует Playwright
+ 3. Скачивает ZIP/XML архивы с данными
+ 4. Парсит XML и возвращает структурированные данные
+
+ Использование:
+ client = ProverkiClient()
+ inspections = client.fetch_inspections(year=2025)
+
+ for inspection in inspections:
+ print(inspection.registration_number, inspection.inn)
+ """
+
+ proxies: list[str] | None = None
+ host: str = DEFAULT_HOST
+ timeout: int = 120
+ temp_dir: str | None = None
+ use_playwright: bool = True # Использовать Playwright как fallback
+ _http_client: BaseHTTPClient | None = field(default=None, repr=False)
+ _playwright: object | None = field(default=None, repr=False)
+ _browser: object | None = field(default=None, repr=False)
+
+ def __post_init__(self) -> None:
+ """Инициализация клиента."""
+ self._http_client = None
+ self._playwright = None
+ self._browser = None
+ self._temp_dir = self.temp_dir or tempfile.gettempdir()
+
+ @property
+ def http_client(self) -> BaseHTTPClient:
+ """Ленивая инициализация HTTP клиента."""
+ if self._http_client is None:
+ self._http_client = BaseHTTPClient(
+ base_url=f"https://{self.host}",
+ proxies=self.proxies,
+ timeout=self.timeout,
+ headers={
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36"
+ ),
+ "Accept": "application/json, application/xml, text/html, */*",
+ "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
+ },
+ )
+ return self._http_client
+
+ def fetch_inspections(
+ self,
+ *,
+ year: int | None = None,
+ month: int | None = None,
+ file_url: str | None = None,
+ is_federal_law_248: bool = False,
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Inspection]:
+ """
+ Получить список проверок.
+
+ Args:
+ year: Год плана проверок (если не указан file_url)
+ month: Месяц (опционально)
+ file_url: Прямая ссылка на файл данных
+ is_federal_law_248: Запрос проверок по ФЗ-248 (новые проверки с 2021)
+ progress_callback: Callback для отчёта о прогрессе (percent, message)
+
+ Returns:
+ Список проверок
+
+ Raises:
+ ProverkiClientError: При ошибке получения данных
+ """
+ fz_type = "ФЗ-248" if is_federal_law_248 else "ФЗ-294"
+ logger.info(
+ "Fetching inspections (year=%s, month=%s, %s)", year, month, fz_type
+ )
+
+ if progress_callback:
+ progress_callback(0, "Инициализация...")
+
+ try:
+ # Если передан прямой URL - скачиваем его
+ if file_url:
+ return self._download_and_parse(file_url, progress_callback)
+
+ # Иначе пытаемся найти файлы для указанного периода
+ plans = self._discover_data_files(
+ year=year, month=month, is_federal_law_248=is_federal_law_248
+ )
+
+ if not plans:
+ logger.warning("No data files found for year=%s, month=%s", year, month)
+ return []
+
+ if progress_callback:
+ progress_callback(10, f"Найдено {len(plans)} файлов данных")
+
+ # Скачиваем и парсим каждый файл
+ all_inspections = []
+ for i, plan in enumerate(plans):
+ if progress_callback:
+ progress = 10 + (i * 80) // len(plans)
+ progress_callback(progress, f"Загрузка {plan.file_name}...")
+
+ inspections = self._download_and_parse(
+ plan.file_url, None, file_format=plan.file_format
+ )
+ all_inspections.extend(inspections)
+ logger.info(
+ "Parsed %d inspections from %s", len(inspections), plan.file_name
+ )
+
+ if progress_callback:
+ progress_callback(95, f"Загружено {len(all_inspections)} проверок")
+
+ logger.info("Total fetched %d inspections", len(all_inspections))
+ return all_inspections
+
+ except HTTPClientError:
+ raise
+ except Exception as e:
+ logger.error("Error fetching inspections: %s", e)
+ raise ProverkiClientError(f"Failed to fetch inspections: {e}") from e
+
+ def _discover_data_files(
+ self,
+ *,
+ year: int | None = None,
+ month: int | None = None,
+ is_federal_law_248: bool = False,
+ ) -> list[InspectionPlan]:
+ """
+ Найти доступные файлы данных для указанного периода.
+
+ URL структура портала proverki.gov.ru:
+ - Проверки по месяцам: /portal/public-open-data/check/{year}/{month}
+ - Планы проверок: /portal/public-open-data/check/{year}/plans
+ - Параметр isFederalLaw248=true для проверок по ФЗ-248
+ """
+ plans = []
+
+ if not year:
+ return plans
+
+ base_url = "https://proverki.gov.ru/portal/public-open-data/check"
+ fz_param = "true" if is_federal_law_248 else "false"
+ fz_suffix = "fz248" if is_federal_law_248 else "fz294"
+
+ # Если указан конкретный месяц - ищем проверки за этот месяц
+ if month:
+ portal_url = f"{base_url}/{year}/{month}?isFederalLaw248={fz_param}"
+ plans.append(
+ InspectionPlan(
+ year=year,
+ month=month,
+ file_url=portal_url,
+ file_name=f"inspection-{month}-{year}-{fz_suffix}.zip",
+ file_format="portal",
+ )
+ )
+ else:
+ # Без месяца - ищем план проверок на год
+ portal_url = f"{base_url}/{year}/plans?isFederalLaw248={fz_param}"
+ plans.append(
+ InspectionPlan(
+ year=year,
+ month=None,
+ file_url=portal_url,
+ file_name=f"plan-{year}-{fz_suffix}.zip",
+ file_format="portal",
+ )
+ )
+
+ logger.info(
+ "Discovered %d data files for year=%s, month=%s, fz248=%s",
+ len(plans),
+ year,
+ month,
+ is_federal_law_248,
+ )
+ return plans
+
+ def _download_and_parse( # noqa: C901
+ self,
+ file_url: str,
+ progress_callback: Callable[[int, str], None] | None = None,
+ file_format: str = "auto",
+ ) -> list[Inspection]:
+ """Скачать файл и распарсить его содержимое."""
+ logger.info("Downloading: %s (format=%s)", file_url, file_format)
+
+ # Если это портал - сразу используем Playwright
+ if file_format == "portal" or "/portal/" in file_url:
+ if not self.use_playwright:
+ raise ProverkiClientError(
+ "Портал proverki.gov.ru требует JavaScript. "
+ "Включите use_playwright=True.",
+ url=file_url,
+ )
+ if progress_callback:
+ progress_callback(20, "Навигация по порталу...")
+ content = self._download_from_portal(file_url, progress_callback)
+ self._close_playwright()
+ else:
+ if progress_callback:
+ progress_callback(20, f"Скачивание {file_url}...")
+
+ content = self.http_client.download_file(file_url)
+ logger.info("Downloaded %d bytes", len(content))
+
+ # Проверка на HTML ответ (сайт требует JavaScript)
+ if content[:15].lower().startswith((b" bytes:
+ """
+ Скачать данные с портала proverki.gov.ru через Playwright.
+
+ Навигация:
+ 1. Открыть страницу датасета
+ 2. Дождаться загрузки Angular/SPA контента
+ 3. Кликнуть на вкладку "Скачать" (download tab)
+ 4. Найти и кликнуть на "Набор данных" (ZIP)
+ 5. Скачать файл
+
+ Args:
+ portal_url: URL страницы датасета на портале
+ progress_callback: Игнорируется (для совместимости)
+
+ Returns:
+ Содержимое ZIP файла в байтах
+ """
+ logger.info("Downloading from portal: %s", portal_url)
+
+ browser = self._get_browser()
+ context = browser.new_context(
+ user_agent=(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36"
+ ),
+ accept_downloads=True,
+ )
+ page = context.new_page()
+
+ try:
+ # Переходим на страницу датасета
+ logger.info("Navigating to dataset page: %s", portal_url)
+ page.goto(portal_url, wait_until="networkidle", timeout=60000)
+
+ # Ждём загрузки SPA контента (Angular) - ищем признаки загруженной страницы
+ # На proverki.gov.ru используется Angular, контент загружается динамически
+ try:
+ # Ждём появления контента страницы (заголовок датасета или вкладки)
+ page.wait_for_selector(
+ "text=Паспорт, text=Скачать, h1, h2, .card, .dataset",
+ timeout=15000,
+ )
+ except Exception:
+ logger.warning("Timeout waiting for SPA content, continuing anyway...")
+
+ page.wait_for_timeout(3000) # Дополнительное ожидание для Angular
+
+ # Debug: скриншот для анализа
+ page_content = page.content()
+ logger.info(
+ "Page loaded, content length: %d, title: %s",
+ len(page_content),
+ page.title(),
+ )
+
+ # ШАГ 1: Кликаем на вкладку "Скачать" для доступа к ссылкам на файлы
+ # Портал имеет две вкладки: "Паспорт" и "Скачать"
+ download_tab = page.query_selector(
+ "a:has-text('Скачать'):not([href*='.zip']), "
+ "button:has-text('Скачать'), "
+ "[role='tab']:has-text('Скачать'), "
+ ".tab:has-text('Скачать'), "
+ ".nav-link:has-text('Скачать'), "
+ "li:has-text('Скачать') a, "
+ "span:has-text('Скачать')"
+ )
+
+ if download_tab:
+ logger.info("Found 'Скачать' tab, clicking...")
+ download_tab.click()
+ page.wait_for_timeout(2000) # Ждём загрузки контента вкладки
+ else:
+ logger.warning("'Скачать' tab not found, trying to find links directly")
+
+ # ШАГ 2: Ищем ссылку "Набор данных" (основная кнопка загрузки ZIP)
+ # URL формат: https://proverki.gov.ru/blob/opendata/{year}/{month}/data-*.zip
+ zip_link = page.query_selector(
+ "a:has-text('Набор данных'), "
+ "a[href*='/blob/opendata/'][href$='.zip'], "
+ "a[href*='data-'][href$='.zip']"
+ )
+
+ if not zip_link:
+ # Попробуем найти в таблице истории изменений
+ # На скриншоте видны файлы data-YYYYMMDD-structure-*.zip
+ zip_link = page.query_selector(
+ "table a[href$='.zip'], " "a[href*='structure'][href$='.zip']"
+ )
+
+ if not zip_link:
+ # Последняя попытка - любая ссылка на .zip
+ zip_link = page.query_selector("a[href$='.zip']")
+
+ if zip_link:
+ href = zip_link.get_attribute("href")
+ logger.info("Found ZIP download link: %s", href)
+
+ with page.expect_download(timeout=120000) as download_info:
+ zip_link.click()
+
+ download = download_info.value
+ download_path = download.path()
+
+ if download_path:
+ with open(download_path, "rb") as f:
+ content = f.read()
+ logger.info("Downloaded %d bytes from portal", len(content))
+ return content
+
+ # Если не нашли ZIP - пробуем XML (паспорт набора данных)
+ xml_link = page.query_selector(
+ "a:has-text('Паспорт набора данных'), " "a[href$='.xml']"
+ )
+
+ if xml_link:
+ logger.info("Found XML link, clicking...")
+ with page.expect_download(timeout=60000) as download_info:
+ xml_link.click()
+
+ download = download_info.value
+ download_path = download.path()
+
+ if download_path:
+ with open(download_path, "rb") as f:
+ content = f.read()
+ logger.info("Downloaded %d bytes (XML) from portal", len(content))
+ return content
+
+ # Debug: выводим что есть на странице
+ all_links = page.query_selector_all("a[href]")
+ logger.warning(
+ "No download links found. Page has %d links. Sample hrefs: %s",
+ len(all_links),
+ [link.get_attribute("href") for link in all_links[:10]],
+ )
+
+ # Проверяем, есть ли на странице сообщение об ошибке или отсутствии данных
+ if (
+ "не найден" in page_content.lower()
+ or "not found" in page_content.lower()
+ ):
+ raise ProverkiClientError(
+ "Данные за указанный период не найдены на портале",
+ url=portal_url,
+ )
+
+ raise ProverkiClientError(
+ "Не удалось найти ссылку на скачивание данных на портале. "
+ "Убедитесь, что выбран корректный год/месяц.",
+ url=portal_url,
+ )
+
+ finally:
+ context.close()
+
+ def _parse_zip_archive(
+ self,
+ content: bytes,
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Inspection]:
+ """Распаковать ZIP архив и распарсить XML файлы внутри."""
+ inspections = []
+
+ with zipfile.ZipFile(io.BytesIO(content)) as zf:
+ xml_files = [
+ name for name in zf.namelist() if name.lower().endswith(".xml")
+ ]
+
+ if not xml_files:
+ logger.warning("No XML files found in ZIP archive")
+ return []
+
+ logger.info("Found %d XML files in archive", len(xml_files))
+
+ for i, xml_name in enumerate(xml_files):
+ if progress_callback:
+ progress = 30 + (i * 60) // len(xml_files)
+ progress_callback(progress, f"Парсинг {xml_name}...")
+
+ xml_content = zf.read(xml_name)
+ file_inspections = self._parse_xml_content(xml_content, None)
+ inspections.extend(file_inspections)
+
+ return inspections
+
+ def _parse_xml_content( # noqa: C901
+ self,
+ content: bytes,
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Inspection]:
+ """
+ Распарсить XML содержимое файла проверок.
+
+ Поддерживает различные XML форматы proverki.gov.ru, включая с namespaces.
+ Использует потоковый парсинг для больших файлов (>50 МБ).
+ """
+ inspections = []
+
+ # Для больших файлов используем iterparse (потоковый парсинг)
+ if len(content) > 50 * 1024 * 1024: # > 50 MB
+ logger.info(
+ "Large file detected (%d MB), using streaming parser",
+ len(content) // (1024 * 1024),
+ )
+ return self._parse_xml_streaming(content, progress_callback)
+
+ try:
+ # Пробуем разные кодировки
+ for encoding in ["utf-8", "windows-1251", "cp1251"]:
+ try:
+ xml_str = content.decode(encoding)
+ break
+ except UnicodeDecodeError:
+ continue
+ else:
+ xml_str = content.decode("utf-8", errors="replace")
+
+ # Очистка невалидных XML символов (часто встречается в госданных)
+ xml_str = self._sanitize_xml(xml_str)
+
+ root = ET.fromstring(xml_str) # noqa: S314
+
+ # Определяем namespace из root tag
+ # Формат: {namespace}tagname
+ ns = {}
+ root_tag = root.tag
+ if root_tag.startswith("{"):
+ ns_uri = root_tag[1 : root_tag.index("}")]
+ ns["ns"] = ns_uri
+ logger.info("Detected XML namespace: %s", ns_uri)
+
+ # Ищем записи о проверках по разным возможным тегам
+ # Сначала с namespace, затем без
+ inspection_tags = [
+ ".//ns:INSPECTION" if ns else None,
+ ".//ns:inspection" if ns else None,
+ ".//ns:check" if ns else None,
+ ".//ns:КНМ" if ns else None,
+ ".//inspection",
+ ".//check",
+ ".//proverka",
+ ".//КНМ", # Контрольно-надзорное мероприятие
+ ".//INSPECTION",
+ ".//record",
+ ".//item",
+ ]
+
+ records = []
+ for tag in inspection_tags:
+ if tag is None:
+ continue
+ try:
+ if ns and tag.startswith(".//ns:"):
+ found = root.findall(tag, ns)
+ else:
+ found = root.findall(tag)
+ if found:
+ records = found
+ logger.info("Found %d records with tag %s", len(found), tag)
+ break
+ except Exception as e:
+ logger.debug("Tag %s search failed: %s", tag, e)
+ continue
+
+ # Если не нашли по тегам, берём все дочерние элементы корня
+ if not records:
+ records = list(root)
+ logger.debug("Using %d root children as records", len(records))
+
+ # Debug: показываем структуру XML
+ logger.info(
+ "XML structure: root=%s, children=%d, first_child=%s",
+ root.tag,
+ len(list(root)),
+ list(root)[0].tag if list(root) else "none",
+ )
+
+ # Если первый child - тоже контейнер, используем его детей
+ if records and len(records) == 1:
+ first_child = records[0]
+ child_tag = (
+ first_child.tag.split("}")[-1].upper()
+ if "}" in first_child.tag
+ else first_child.tag.upper()
+ )
+ if child_tag in ("INSPECTION", "CHECK", "КНМ", "RECORD"):
+ # Это и есть запись, используем её
+ pass
+ else:
+ # Это контейнер, берём его детей
+ records = list(first_child)
+ logger.info(
+ "Using %d children of first element as records", len(records)
+ )
+
+ for record in records:
+ inspection = self._parse_xml_record(record)
+ if inspection:
+ inspections.append(inspection)
+
+ except ET.ParseError as e:
+ logger.error("XML parse error: %s", e)
+ raise ProverkiClientError(f"Failed to parse XML: {e}") from e
+
+ return inspections
+
+ def _parse_xml_streaming(
+ self,
+ content: bytes,
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> list[Inspection]:
+ """
+ Потоковый парсинг большого XML файла.
+
+ Использует iterparse для обработки файла по элементам,
+ не загружая весь файл в память.
+ """
+ inspections = []
+
+ # Декодируем и создаём поток
+ for encoding in ["utf-8", "windows-1251", "cp1251"]:
+ try:
+ xml_str = content.decode(encoding)
+ break
+ except UnicodeDecodeError:
+ continue
+ else:
+ xml_str = content.decode("utf-8", errors="replace")
+
+ xml_str = self._sanitize_xml(xml_str)
+
+ # Используем iterparse для потоковой обработки
+ import io
+
+ xml_stream = io.StringIO(xml_str)
+
+ # Определяем теги, которые нас интересуют
+ target_tags = {"INSPECTION", "inspection", "check", "КНМ"}
+
+ count = 0
+ try:
+ for _event, elem in ET.iterparse(xml_stream, events=["end"]): # noqa: S314
+ # Извлекаем имя тега без namespace
+ tag_name = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
+
+ if tag_name in target_tags:
+ inspection = self._parse_xml_record(elem)
+ if inspection:
+ inspections.append(inspection)
+ count += 1
+
+ if count % 10000 == 0:
+ logger.info("Streaming parsed %d inspections...", count)
+
+ # Очищаем элемент для освобождения памяти
+ elem.clear()
+
+ except ET.ParseError as e:
+ logger.error("XML streaming parse error at %d records: %s", count, e)
+ if inspections:
+ logger.info(
+ "Returning %d successfully parsed records", len(inspections)
+ )
+ else:
+ raise ProverkiClientError(f"Failed to parse XML: {e}") from e
+
+ logger.info("Streaming parsing complete: %d inspections", len(inspections))
+ return inspections
+
+ def _sanitize_xml(self, xml_str: str) -> str:
+ """
+ Очистить XML строку от невалидных символов.
+
+ Госданные часто содержат управляющие символы, которые не допускаются в XML:
+ - Символы 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F (кроме tab, newline, cr)
+ - Некорректные символы в значениях атрибутов
+
+ Args:
+ xml_str: Исходная XML строка
+
+ Returns:
+ Очищенная XML строка
+ """
+ import re
+
+ # Удаляем недопустимые XML символы (control characters кроме tab, newline, cr)
+ # XML 1.0 допускает: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD]
+ illegal_xml_chars_re = re.compile(
+ r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x84\x86-\x9f]"
+ )
+ xml_str = illegal_xml_chars_re.sub("", xml_str)
+
+ # Заменяем неэкранированные амперсанды (частая ошибка в госданных)
+ # Но не трогаем уже экранированные сущности
+ xml_str = re.sub(
+ r"&(?!(?:amp|lt|gt|apos|quot|#\d+|#x[0-9a-fA-F]+);)", "&", xml_str
+ )
+
+ return xml_str
+
+ def _parse_xml_record(self, element: ET.Element) -> Inspection | None: # noqa: C901
+ """
+ Преобразовать XML элемент в объект Inspection.
+
+ Адаптируется к различным форматам XML proverki.gov.ru:
+ - Данные могут быть в атрибутах элементов
+ - Данные могут быть во вложенных элементах (I_SUBJECT, I_AUTHORITY)
+ """
+ try:
+ # Определяем namespace элемента
+ ns_uri = None
+ if element.tag.startswith("{"):
+ ns_uri = element.tag[1 : element.tag.index("}")]
+
+ def find_child(tag_name: str) -> ET.Element | None:
+ """Найти дочерний элемент с учётом namespace."""
+ if ns_uri:
+ child = element.find(f"{{{ns_uri}}}{tag_name}")
+ if child is not None:
+ return child
+ return element.find(tag_name)
+
+ # Получаем вложенные элементы, где могут быть данные
+ i_subject = find_child("I_SUBJECT") # Данные об организации
+ i_authority = find_child("I_AUTHORITY") # Контролирующий орган
+ i_approve = find_child("I_APPROVE") # Информация об утверждении
+ i_classification = find_child("I_CLASSIFICATION") # Классификация
+
+ def get_attr_value(attr_names: list[str]) -> str: # noqa: C901
+ """Найти значение атрибута в элементе или вложенных элементах."""
+ # Сначала ищем в атрибутах самого элемента INSPECTION
+ for name in attr_names:
+ if name in element.attrib:
+ return element.attrib[name].strip()
+
+ # Ищем в I_SUBJECT (ORG_NAME, INN, OGRN)
+ if i_subject is not None:
+ for name in attr_names:
+ if name in i_subject.attrib:
+ return i_subject.attrib[name].strip()
+
+ # Ищем в I_AUTHORITY (FRGU_ORG_NAME)
+ if i_authority is not None:
+ for name in attr_names:
+ if name in i_authority.attrib:
+ return i_authority.attrib[name].strip()
+
+ # Ищем в I_CLASSIFICATION
+ if i_classification is not None:
+ for name in attr_names:
+ if name in i_classification.attrib:
+ return i_classification.attrib[name].strip()
+
+ # Ищем в I_APPROVE
+ if i_approve is not None:
+ for name in attr_names:
+ if name in i_approve.attrib:
+ return i_approve.attrib[name].strip()
+
+ # Fallback: ищем в дочерних элементах (текст)
+ for name in attr_names:
+ if ns_uri:
+ child = element.find(f"{{{ns_uri}}}{name}")
+ else:
+ child = element.find(name)
+ if child is not None and child.text:
+ return child.text.strip()
+
+ return ""
+
+ # Маппинг атрибутов на поля Inspection
+ # Используем названия из реального XML proverki.gov.ru
+ registration_number = get_attr_value(
+ [
+ "ERPID",
+ "I_NUMBER",
+ "FRGU_NUM",
+ "registration_number",
+ "regnum",
+ "id",
+ "number",
+ ]
+ )
+
+ inn = get_attr_value(["INN", "inn", "ORG_INN", "I_INN"])
+ ogrn = get_attr_value(["OGRN", "ogrn", "ORG_OGRN", "I_OGRN"])
+ organisation_name = get_attr_value(
+ [
+ "ORG_NAME",
+ "FULL_NAME",
+ "SHORT_NAME",
+ "I_NAME",
+ "organisation_name",
+ "org_name",
+ "name",
+ ]
+ )
+ control_authority = get_attr_value(
+ [
+ "FRGU_ORG_NAME",
+ "PROSEC_NAME",
+ "KNO_NAME",
+ "ORGAN_NAME",
+ "control_authority",
+ "authority",
+ ]
+ )
+ inspection_type = get_attr_value(
+ [
+ "ITYPE_NAME",
+ "TYPE_NAME",
+ "I_TYPE",
+ "inspection_type",
+ "type",
+ ]
+ )
+ inspection_form = get_attr_value(
+ [
+ "ICARRYOUT_TYPE_NAME",
+ "FORM_NAME",
+ "I_FORM",
+ "inspection_form",
+ "form",
+ ]
+ )
+ start_date = get_attr_value(
+ [
+ "START_DATE",
+ "I_DATE_START",
+ "DATE_START",
+ "start_date",
+ "date_start",
+ "date",
+ ]
+ )
+ end_date = get_attr_value(
+ [
+ "END_DATE",
+ "I_DATE_END",
+ "DATE_END",
+ "end_date",
+ "date_end",
+ ]
+ )
+ status = get_attr_value(
+ [
+ "STATUS",
+ "I_STATUS",
+ "status",
+ "state",
+ ]
+ )
+ legal_basis = get_attr_value(
+ [
+ "FZ_NAME",
+ "IREASON_NAME",
+ "I_REASON",
+ "REASON",
+ "legal_basis",
+ "basis",
+ "law",
+ ]
+ )
+ result = get_attr_value(
+ [
+ "RESULT",
+ "I_RESULT",
+ "result",
+ "outcome",
+ ]
+ )
+
+ inspection = Inspection(
+ registration_number=registration_number,
+ inn=inn,
+ ogrn=ogrn,
+ organisation_name=organisation_name,
+ control_authority=control_authority,
+ inspection_type=inspection_type,
+ inspection_form=inspection_form,
+ start_date=start_date,
+ end_date=end_date,
+ status=status,
+ legal_basis=legal_basis,
+ result=result,
+ )
+
+ # Проверяем что хотя бы базовые поля заполнены
+ if not inspection.inn and not inspection.registration_number:
+ # Debug: выводим структуру элемента
+ logger.debug(
+ "Empty inspection from element %s, attribs: %s",
+ element.tag,
+ list(element.attrib.keys())[:5],
+ )
+ return None
+
+ return inspection
+
+ except Exception as e:
+ logger.warning("Failed to parse XML record: %s", e)
+ return None
+
+ def fetch_inspection_plans(self, year: int) -> list[InspectionPlan]:
+ """
+ Получить список доступных планов проверок за год.
+
+ Args:
+ year: Год
+
+ Returns:
+ Список InspectionPlan с метаданными о файлах
+ """
+ return self._discover_data_files(year=year)
+
+ def _get_browser(self):
+ """Ленивая инициализация Playwright browser."""
+ if self._browser is None:
+ try:
+ from playwright.sync_api import sync_playwright
+
+ self._playwright = sync_playwright().start()
+ self._browser = self._playwright.chromium.launch(
+ headless=True,
+ args=[
+ "--no-sandbox",
+ "--disable-setuid-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-gpu",
+ ],
+ )
+ logger.info("Playwright browser initialized")
+ except ImportError as e:
+ raise ProverkiClientError(
+ "Playwright не установлен. Установите: uv add playwright && "
+ "playwright install chromium"
+ ) from e
+ except Exception as e:
+ raise ProverkiClientError(
+ f"Не удалось запустить Playwright browser: {e}"
+ ) from e
+ return self._browser
+
+ def _download_with_playwright(
+ self,
+ url: str,
+ progress_callback: Callable[[int, str], None] | None = None,
+ ) -> bytes:
+ """
+ Скачать данные через Playwright (для сайтов с JavaScript).
+
+ Примечание: progress_callback не используется внутри этого метода,
+ так как Playwright работает в асинхронном контексте, который конфликтует
+ с Django ORM.
+
+ Args:
+ url: URL для загрузки
+ progress_callback: Игнорируется (для совместимости интерфейса)
+
+ Returns:
+ Содержимое файла в байтах
+ """
+ logger.info("Using Playwright to fetch: %s", url)
+
+ browser = self._get_browser()
+ context = browser.new_context(
+ user_agent=(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36"
+ ),
+ accept_downloads=True,
+ )
+ page = context.new_page()
+
+ try:
+ # Сначала пробуем прямой переход на URL (с рендерингом JS)
+ logger.info("Trying direct URL with JS rendering: %s", url)
+ response = page.goto(url, wait_until="networkidle", timeout=60000)
+
+ # Проверяем, получили ли мы данные напрямую
+ content_type = response.headers.get("content-type", "") if response else ""
+ if (
+ "xml" in content_type
+ or "zip" in content_type
+ or "octet-stream" in content_type
+ ):
+ # Получили файл напрямую
+ body = page.content()
+ if body and not body.strip().startswith(" None:
+ """Закрыть Playwright и освободить event loop."""
+ if self._browser is not None:
+ try:
+ self._browser.close()
+ except Exception as e:
+ logger.warning("Error closing browser: %s", e)
+ self._browser = None
+
+ if self._playwright is not None:
+ try:
+ self._playwright.stop()
+ except Exception as e:
+ logger.warning("Error stopping Playwright: %s", e)
+ self._playwright = None
+
+ def close(self) -> None:
+ """Закрыть клиент и освободить ресурсы."""
+ if self._http_client is not None:
+ self._http_client.close()
+ self._http_client = None
+
+ self._close_playwright()
+
+ def __enter__(self) -> "ProverkiClient":
+ """Поддержка context manager."""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """Закрытие при выходе из context manager."""
+ self.close()
diff --git a/src/apps/parsers/clients/proverki/schemas.py b/src/apps/parsers/clients/proverki/schemas.py
new file mode 100644
index 0000000..48c85d6
--- /dev/null
+++ b/src/apps/parsers/clients/proverki/schemas.py
@@ -0,0 +1,90 @@
+"""
+Dataclass схемы для данных proverki.gov.ru.
+
+Эти классы представляют данные о проверках, возвращаемые клиентом.
+Они не зависят от Django ORM и используются как DTO.
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Inspection:
+ """
+ Проверка из Единого реестра проверок.
+
+ Источник: ФГИС "Единый реестр проверок" (proverki.gov.ru).
+
+ Содержит данные о проведённых или запланированных проверках
+ юридических лиц и индивидуальных предпринимателей.
+
+ Поддерживает два типа проверок:
+ - ФЗ-294 (традиционные проверки)
+ - ФЗ-248 (новые проверки с 2021 года)
+ """
+
+ registration_number: str
+ """Учётный номер проверки в реестре."""
+
+ inn: str
+ """ИНН проверяемого лица."""
+
+ ogrn: str
+ """ОГРН проверяемого лица."""
+
+ organisation_name: str
+ """Наименование проверяемого лица."""
+
+ control_authority: str
+ """Наименование контрольного (надзорного) органа."""
+
+ inspection_type: str
+ """Тип проверки (плановая/внеплановая)."""
+
+ inspection_form: str
+ """Форма проверки (документарная/выездная)."""
+
+ start_date: str
+ """Дата начала проверки (строка формата YYYY-MM-DD или DD.MM.YYYY)."""
+
+ end_date: str
+ """Дата окончания проверки."""
+
+ status: str
+ """Статус проверки."""
+
+ legal_basis: str
+ """Правовое основание проверки (ФЗ-294, ФЗ-248 и т.д.)."""
+
+ result: str = ""
+ """Результат проверки (если завершена)."""
+
+ is_federal_law_248: bool = False
+ """Признак проверки по ФЗ-248 (новые проверки с 2021 года)."""
+
+
+@dataclass(frozen=True)
+class InspectionPlan:
+ """
+ План проверок на определённый период.
+
+ Содержит метаданные о плане проверок (год, период, количество записей).
+ """
+
+ year: int
+ """Год плана проверок."""
+
+ month: int | None
+ """Месяц (если план помесячный, иначе None)."""
+
+ file_url: str
+ """URL файла с данными плана."""
+
+ file_name: str
+ """Имя файла."""
+
+ records_count: int = 0
+ """Количество записей в плане (если известно)."""
+
+ file_format: str = "xml"
+ """Формат файла (xml, csv, xlsx)."""
diff --git a/src/apps/parsers/migrations/0001_initial_parsers.py b/src/apps/parsers/migrations/0001_initial_parsers.py
new file mode 100644
index 0000000..9e84a7f
--- /dev/null
+++ b/src/apps/parsers/migrations/0001_initial_parsers.py
@@ -0,0 +1,90 @@
+# Generated by Django 3.2.25 on 2026-01-21 16:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='IndustrialCertificateRecord',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='ID пакета загрузки')),
+ ('issue_date', models.CharField(blank=True, help_text='Дата выдачи сертификата', max_length=15, verbose_name='дата выдачи')),
+ ('certificate_number', models.CharField(db_index=True, help_text='Номер сертификата', max_length=100, verbose_name='номер сертификата')),
+ ('expiry_date', models.CharField(blank=True, help_text='Дата окончания действия', max_length=15, verbose_name='дата окончания')),
+ ('certificate_file_url', models.TextField(blank=True, help_text='Ссылка на файл сертификата', verbose_name='URL файла')),
+ ('organisation_name', models.TextField(help_text='Название организации', verbose_name='наименование организации')),
+ ('inn', models.CharField(db_index=True, help_text='ИНН организации', max_length=20, verbose_name='ИНН')),
+ ('ogrn', models.CharField(db_index=True, help_text='ОГРН организации', max_length=20, verbose_name='ОГРН')),
+ ],
+ options={
+ 'verbose_name': 'сертификат промпроизводства',
+ 'verbose_name_plural': 'сертификаты промпроизводства',
+ 'db_table': 'parsers_industrial_certificate',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ManufacturerRecord',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='ID пакета загрузки')),
+ ('full_legal_name', models.TextField(help_text='Полное юридическое наименование организации', verbose_name='полное наименование')),
+ ('inn', models.CharField(db_index=True, help_text='ИНН организации', max_length=15, verbose_name='ИНН')),
+ ('ogrn', models.CharField(db_index=True, help_text='ОГРН организации', max_length=15, verbose_name='ОГРН')),
+ ('address', models.TextField(blank=True, help_text='Юридический адрес организации', verbose_name='адрес')),
+ ],
+ options={
+ 'verbose_name': 'производитель',
+ 'verbose_name_plural': 'производители',
+ 'db_table': 'parsers_manufacturer',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ParserLoadLog',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('batch_id', models.PositiveIntegerField(db_index=True, help_text='Уникальный идентификатор пакета загрузки', verbose_name='ID пакета')),
+ ('source', models.CharField(choices=[('industrial', 'Промышленное производство'), ('manufactures', 'Реестр производителей')], db_index=True, help_text='Источник данных', max_length=50, verbose_name='источник')),
+ ('records_count', models.PositiveIntegerField(default=0, help_text='Количество загруженных записей', verbose_name='количество записей')),
+ ('status', models.CharField(default='success', help_text='Статус загрузки', max_length=20, verbose_name='статус')),
+ ('error_message', models.TextField(blank=True, help_text='Текст ошибки при неудачной загрузке', verbose_name='сообщение об ошибке')),
+ ],
+ options={
+ 'verbose_name': 'лог загрузки',
+ 'verbose_name_plural': 'логи загрузок',
+ 'db_table': 'parsers_load_log',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='parserloadlog',
+ index=models.Index(fields=['source', 'batch_id'], name='parsers_loa_source_f175af_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='manufacturerrecord',
+ index=models.Index(fields=['load_batch', 'inn'], name='parsers_man_load_ba_5a660e_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='industrialcertificaterecord',
+ index=models.Index(fields=['inn', 'certificate_number'], name='parsers_ind_inn_6b7f8d_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='industrialcertificaterecord',
+ index=models.Index(fields=['load_batch', 'inn'], name='parsers_ind_load_ba_6e497e_idx'),
+ ),
+ ]
diff --git a/src/apps/parsers/migrations/0002_add_proxy_model.py b/src/apps/parsers/migrations/0002_add_proxy_model.py
new file mode 100644
index 0000000..19b08a3
--- /dev/null
+++ b/src/apps/parsers/migrations/0002_add_proxy_model.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.2.25 on 2026-01-21 16:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('parsers', '0001_initial_parsers'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Proxy',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('address', models.CharField(help_text='Адрес прокси (например: http://proxy:8080)', max_length=255, unique=True, verbose_name='адрес')),
+ ('is_active', models.BooleanField(db_index=True, default=True, help_text='Доступен ли прокси для использования', verbose_name='активен')),
+ ('last_used_at', models.DateTimeField(blank=True, help_text='Время последнего использования', null=True, verbose_name='последнее использование')),
+ ('fail_count', models.PositiveIntegerField(default=0, help_text='Количество неудачных попыток подключения', verbose_name='количество ошибок')),
+ ('description', models.CharField(blank=True, help_text='Описание прокси (провайдер, локация и т.д.)', max_length=255, verbose_name='описание')),
+ ],
+ options={
+ 'verbose_name': 'прокси',
+ 'verbose_name_plural': 'прокси',
+ 'db_table': 'parsers_proxy',
+ 'ordering': ['fail_count', '-last_used_at'],
+ },
+ ),
+ ]
diff --git a/src/apps/parsers/migrations/0003_add_inspection_model.py b/src/apps/parsers/migrations/0003_add_inspection_model.py
new file mode 100644
index 0000000..0b95d0f
--- /dev/null
+++ b/src/apps/parsers/migrations/0003_add_inspection_model.py
@@ -0,0 +1,53 @@
+# Generated by Django 3.2.25 on 2026-01-21 17:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('parsers', '0002_add_proxy_model'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='InspectionRecord',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')),
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='ID пакета загрузки')),
+ ('registration_number', models.CharField(db_index=True, help_text='Учётный номер проверки в реестре', max_length=100, verbose_name='учётный номер')),
+ ('inn', models.CharField(db_index=True, help_text='ИНН проверяемого лица', max_length=20, verbose_name='ИНН')),
+ ('ogrn', models.CharField(blank=True, db_index=True, help_text='ОГРН проверяемого лица', max_length=20, verbose_name='ОГРН')),
+ ('organisation_name', models.TextField(help_text='Наименование проверяемого лица', verbose_name='наименование организации')),
+ ('control_authority', models.TextField(blank=True, help_text='Наименование контрольного (надзорного) органа', verbose_name='контрольный орган')),
+ ('inspection_type', models.CharField(blank=True, help_text='Тип проверки (плановая/внеплановая)', max_length=100, verbose_name='тип проверки')),
+ ('inspection_form', models.CharField(blank=True, help_text='Форма проверки (документарная/выездная)', max_length=100, verbose_name='форма проверки')),
+ ('start_date', models.CharField(blank=True, help_text='Дата начала проверки', max_length=20, verbose_name='дата начала')),
+ ('end_date', models.CharField(blank=True, help_text='Дата окончания проверки', max_length=20, verbose_name='дата окончания')),
+ ('status', models.CharField(blank=True, help_text='Статус проверки', max_length=100, verbose_name='статус')),
+ ('legal_basis', models.CharField(blank=True, help_text='Правовое основание проверки (ФЗ-294, ФЗ-248)', max_length=255, verbose_name='правовое основание')),
+ ('result', models.TextField(blank=True, help_text='Результат проверки', verbose_name='результат')),
+ ],
+ options={
+ 'verbose_name': 'проверка',
+ 'verbose_name_plural': 'проверки',
+ 'db_table': 'parsers_inspection',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AlterField(
+ model_name='parserloadlog',
+ name='source',
+ field=models.CharField(choices=[('industrial', 'Промышленное производство'), ('manufactures', 'Реестр производителей'), ('inspections', 'Единый реестр проверок')], db_index=True, help_text='Источник данных', max_length=50, verbose_name='источник'),
+ ),
+ migrations.AddIndex(
+ model_name='inspectionrecord',
+ index=models.Index(fields=['inn', 'registration_number'], name='parsers_ins_inn_0d75e5_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='inspectionrecord',
+ index=models.Index(fields=['load_batch', 'inn'], name='parsers_ins_load_ba_45a131_idx'),
+ ),
+ ]
diff --git a/src/apps/parsers/migrations/0004_add_unique_constraints.py b/src/apps/parsers/migrations/0004_add_unique_constraints.py
new file mode 100644
index 0000000..6749449
--- /dev/null
+++ b/src/apps/parsers/migrations/0004_add_unique_constraints.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.25 on 2026-01-21 17:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('parsers', '0003_add_inspection_model'),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name='industrialcertificaterecord',
+ constraint=models.UniqueConstraint(fields=('certificate_number',), name='unique_certificate_number'),
+ ),
+ migrations.AddConstraint(
+ model_name='inspectionrecord',
+ constraint=models.UniqueConstraint(fields=('registration_number',), name='unique_inspection_registration_number'),
+ ),
+ migrations.AddConstraint(
+ model_name='manufacturerrecord',
+ constraint=models.UniqueConstraint(fields=('inn',), name='unique_manufacturer_inn'),
+ ),
+ ]
diff --git a/src/apps/parsers/migrations/0005_add_inspection_fz248_fields.py b/src/apps/parsers/migrations/0005_add_inspection_fz248_fields.py
new file mode 100644
index 0000000..d3fbc12
--- /dev/null
+++ b/src/apps/parsers/migrations/0005_add_inspection_fz248_fields.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.2.25 on 2026-01-21 19:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('parsers', '0004_add_unique_constraints'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='inspectionrecord',
+ name='data_month',
+ field=models.PositiveSmallIntegerField(blank=True, db_index=True, help_text='Месяц, за который загружены данные', null=True, verbose_name='месяц данных'),
+ ),
+ migrations.AddField(
+ model_name='inspectionrecord',
+ name='data_year',
+ field=models.PositiveSmallIntegerField(blank=True, db_index=True, help_text='Год, за который загружены данные', null=True, verbose_name='год данных'),
+ ),
+ migrations.AddField(
+ model_name='inspectionrecord',
+ name='is_federal_law_248',
+ field=models.BooleanField(db_index=True, default=False, help_text='Проверка по ФЗ-248 (новые проверки с 2021 года)', verbose_name='по ФЗ-248'),
+ ),
+ migrations.AddIndex(
+ model_name='inspectionrecord',
+ index=models.Index(fields=['is_federal_law_248', 'data_year', 'data_month'], name='parsers_ins_is_fede_e271e9_idx'),
+ ),
+ ]
diff --git a/src/apps/parsers/migrations/__init__.py b/src/apps/parsers/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/apps/parsers/models.py b/src/apps/parsers/models.py
new file mode 100644
index 0000000..0cb8c33
--- /dev/null
+++ b/src/apps/parsers/models.py
@@ -0,0 +1,363 @@
+"""
+Модели для приложения парсеров.
+
+Используют миксины из 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", _("Единый реестр проверок")
+
+ 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"]),
+ ]
+
+ 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}"
diff --git a/src/apps/parsers/serializers.py b/src/apps/parsers/serializers.py
new file mode 100644
index 0000000..0d53405
--- /dev/null
+++ b/src/apps/parsers/serializers.py
@@ -0,0 +1,7 @@
+"""
+Сериализаторы для приложения парсеров.
+
+TODO: Добавить сериализаторы по мере необходимости.
+"""
+
+# Сериализаторы будут добавлены по мере разработки конкретных парсеров
diff --git a/src/apps/parsers/services.py b/src/apps/parsers/services.py
new file mode 100644
index 0000000..e47893f
--- /dev/null
+++ b/src/apps/parsers/services.py
@@ -0,0 +1,536 @@
+"""
+Сервисы для приложения парсеров.
+
+Используют базовые классы из apps.core.services для стандартных операций.
+"""
+
+import logging
+
+from apps.core.services import BaseService, BulkOperationsMixin
+from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer
+from apps.parsers.clients.proverki.schemas import Inspection
+from apps.parsers.models import (
+ IndustrialCertificateRecord,
+ InspectionRecord,
+ ManufacturerRecord,
+ ParserLoadLog,
+ Proxy,
+)
+from django.db import transaction
+from django.utils import timezone
+
+logger = logging.getLogger(__name__)
+
+
+class ParserLoadLogService(BaseService[ParserLoadLog]):
+ """
+ Сервис для управления логами загрузок парсеров.
+
+ Отвечает за:
+ - Генерацию batch_id для новых загрузок
+ - Создание записей лога
+ - Обновление статуса загрузки
+ """
+
+ model = ParserLoadLog
+
+ @classmethod
+ def get_next_batch_id(cls, source: str) -> int:
+ """
+ Получить следующий batch_id для источника.
+
+ Args:
+ source: Код источника (industrial, manufactures)
+
+ Returns:
+ Следующий batch_id
+ """
+ last_log = cls.model.objects.filter(source=source).order_by("-batch_id").first()
+ return (last_log.batch_id + 1) if last_log else 1
+
+ @classmethod
+ @transaction.atomic
+ def create_load_log(
+ cls,
+ *,
+ source: str,
+ batch_id: int,
+ records_count: int = 0,
+ status: str = "success",
+ error_message: str = "",
+ ) -> ParserLoadLog:
+ """
+ Создать запись лога загрузки.
+
+ Args:
+ source: Код источника
+ batch_id: ID пакета
+ records_count: Количество загруженных записей
+ status: Статус загрузки
+ error_message: Сообщение об ошибке
+
+ Returns:
+ Созданная запись ParserLoadLog
+ """
+ return cls.create(
+ source=source,
+ batch_id=batch_id,
+ records_count=records_count,
+ status=status,
+ error_message=error_message,
+ )
+
+ @classmethod
+ def mark_failed(cls, log: ParserLoadLog, error_message: str) -> ParserLoadLog:
+ """Отметить загрузку как неудачную."""
+ return cls.update(log, status="failed", error_message=error_message)
+
+ @classmethod
+ def update_records_count(cls, log: ParserLoadLog, count: int) -> ParserLoadLog:
+ """Обновить количество записей."""
+ return cls.update(log, records_count=count)
+
+
+class IndustrialCertificateService(
+ BulkOperationsMixin, BaseService[IndustrialCertificateRecord]
+):
+ """
+ Сервис для управления сертификатами промышленного производства.
+
+ Отвечает за:
+ - Массовое сохранение сертификатов из парсера
+ - Поиск сертификатов по ИНН/ОГРН
+ """
+
+ model = IndustrialCertificateRecord
+
+ @classmethod
+ @transaction.atomic
+ def save_certificates(
+ cls,
+ certificates: list[IndustrialCertificate],
+ batch_id: int,
+ *,
+ chunk_size: int = 500,
+ ) -> int:
+ """
+ Сохранить список сертификатов из парсера.
+
+ Преобразует dataclass объекты в Django модели и сохраняет чанками.
+ Дубликаты по certificate_number пропускаются (ignore_conflicts).
+
+ Args:
+ certificates: Список сертификатов из клиента
+ batch_id: ID пакета загрузки
+ chunk_size: Размер чанка для bulk_create
+
+ Returns:
+ Количество новых сохранённых записей
+ """
+ if not certificates:
+ logger.warning("No certificates to save")
+ return 0
+
+ logger.info("Saving %d certificates (batch_id=%d)", len(certificates), batch_id)
+
+ instances = [
+ cls.model(
+ load_batch=batch_id,
+ issue_date=cert.issue_date,
+ certificate_number=cert.certificate_number,
+ expiry_date=cert.expiry_date,
+ certificate_file_url=cert.certificate_file_url,
+ organisation_name=cert.organisation_name,
+ inn=cert.inn,
+ ogrn=cert.ogrn,
+ )
+ for cert in certificates
+ ]
+
+ saved_count = cls.bulk_create_chunked(
+ instances,
+ chunk_size=chunk_size,
+ ignore_conflicts=True, # Skip duplicates by certificate_number
+ )
+ logger.info("Saved %d certificates", saved_count)
+
+ return saved_count
+
+ @classmethod
+ def find_by_inn(cls, inn: str, batch_id: int | None = None):
+ """
+ Найти сертификаты по ИНН.
+
+ Args:
+ inn: ИНН организации
+ batch_id: Фильтр по пакету загрузки (опционально)
+ """
+ qs = cls.filter(inn=inn)
+ if batch_id:
+ qs = qs.filter(load_batch=batch_id)
+ return qs
+
+ @classmethod
+ def find_by_certificate_number(cls, certificate_number: str):
+ """Найти сертификаты по номеру."""
+ return cls.filter(certificate_number=certificate_number)
+
+
+class ManufacturerService(BulkOperationsMixin, BaseService[ManufacturerRecord]):
+ """
+ Сервис для управления реестром производителей.
+
+ Отвечает за:
+ - Массовое сохранение производителей из парсера
+ - Поиск производителей по ИНН/ОГРН
+ """
+
+ model = ManufacturerRecord
+
+ @classmethod
+ @transaction.atomic
+ def save_manufacturers(
+ cls,
+ manufacturers: list[Manufacturer],
+ batch_id: int,
+ *,
+ chunk_size: int = 500,
+ ) -> int:
+ """
+ Сохранить список производителей из парсера.
+
+ Преобразует dataclass объекты в Django модели и сохраняет чанками.
+ Дубликаты по ИНН пропускаются (ignore_conflicts).
+
+ Args:
+ manufacturers: Список производителей из клиента
+ batch_id: ID пакета загрузки
+ chunk_size: Размер чанка для bulk_create
+
+ Returns:
+ Количество новых сохранённых записей
+ """
+ if not manufacturers:
+ logger.warning("No manufacturers to save")
+ return 0
+
+ logger.info(
+ "Saving %d manufacturers (batch_id=%d)", len(manufacturers), batch_id
+ )
+
+ instances = [
+ cls.model(
+ load_batch=batch_id,
+ full_legal_name=m.full_legal_name,
+ inn=m.inn,
+ ogrn=m.ogrn,
+ address=m.address,
+ )
+ for m in manufacturers
+ ]
+
+ saved_count = cls.bulk_create_chunked(
+ instances,
+ chunk_size=chunk_size,
+ ignore_conflicts=True, # Skip duplicates by INN
+ )
+ logger.info("Saved %d manufacturers", saved_count)
+
+ return saved_count
+
+ @classmethod
+ def find_by_inn(cls, inn: str, batch_id: int | None = None):
+ """
+ Найти производителей по ИНН.
+
+ Args:
+ inn: ИНН организации
+ batch_id: Фильтр по пакету загрузки (опционально)
+ """
+ qs = cls.filter(inn=inn)
+ if batch_id:
+ qs = qs.filter(load_batch=batch_id)
+ return qs
+
+ @classmethod
+ def find_by_ogrn(cls, ogrn: str):
+ """Найти производителей по ОГРН."""
+ return cls.filter(ogrn=ogrn)
+
+
+class ProxyService(BaseService[Proxy]):
+ """
+ Сервис для управления прокси-серверами.
+
+ Отвечает за:
+ - Получение списка активных прокси
+ - Отметку использования прокси
+ - Регистрацию ошибок прокси
+ """
+
+ model = Proxy
+
+ @classmethod
+ def get_active_proxies(cls) -> list[str]:
+ """
+ Получить список адресов активных прокси.
+
+ Returns:
+ Список адресов прокси (может быть пустым)
+ """
+ proxies = cls.model.objects.filter(is_active=True).values_list(
+ "address", flat=True
+ )
+ return list(proxies)
+
+ @classmethod
+ def get_active_proxies_or_none(cls) -> list[str] | None:
+ """
+ Получить список активных прокси или None, если их нет.
+
+ Returns:
+ Список адресов прокси или None
+ """
+ proxies = cls.get_active_proxies()
+ return proxies if proxies else None
+
+ @classmethod
+ def mark_used(cls, address: str) -> None:
+ """
+ Отметить прокси как использованный.
+
+ Args:
+ address: Адрес прокси
+ """
+ cls.model.objects.filter(address=address).update(last_used_at=timezone.now())
+
+ @classmethod
+ def mark_failed(cls, address: str) -> None:
+ """
+ Увеличить счётчик ошибок прокси.
+
+ Args:
+ address: Адрес прокси
+ """
+ from django.db.models import F
+
+ cls.model.objects.filter(address=address).update(fail_count=F("fail_count") + 1)
+
+ @classmethod
+ def deactivate(cls, address: str) -> None:
+ """
+ Деактивировать прокси.
+
+ Args:
+ address: Адрес прокси
+ """
+ cls.model.objects.filter(address=address).update(is_active=False)
+
+ @classmethod
+ @transaction.atomic
+ def add_proxy(cls, address: str, description: str = "") -> Proxy:
+ """
+ Добавить новый прокси.
+
+ Args:
+ address: Адрес прокси (например: http://proxy:8080)
+ description: Описание прокси
+
+ Returns:
+ Созданный объект Proxy
+ """
+ proxy, _ = cls.model.objects.get_or_create(
+ address=address,
+ defaults={"description": description, "is_active": True},
+ )
+ return proxy
+
+ @classmethod
+ @transaction.atomic
+ def add_proxies(cls, addresses: list[str]) -> int:
+ """
+ Добавить список прокси.
+
+ Args:
+ addresses: Список адресов прокси
+
+ Returns:
+ Количество добавленных прокси
+ """
+ created_count = 0
+ for address in addresses:
+ _, created = cls.model.objects.get_or_create(
+ address=address,
+ defaults={"is_active": True},
+ )
+ if created:
+ created_count += 1
+ return created_count
+
+
+class InspectionService(BulkOperationsMixin, BaseService[InspectionRecord]):
+ """
+ Сервис для управления данными о проверках.
+
+ Отвечает за:
+ - Массовое сохранение проверок из парсера
+ - Поиск проверок по ИНН/ОГРН/номеру
+ """
+
+ model = InspectionRecord
+
+ @classmethod
+ @transaction.atomic
+ def save_inspections(
+ cls,
+ inspections: list[Inspection],
+ batch_id: int,
+ *,
+ is_federal_law_248: bool = False,
+ data_year: int | None = None,
+ data_month: int | None = None,
+ chunk_size: int = 500,
+ ) -> int:
+ """
+ Сохранить список проверок из парсера.
+
+ Преобразует dataclass объекты в Django модели и сохраняет чанками.
+ Дубликаты по registration_number пропускаются (ignore_conflicts).
+
+ Args:
+ inspections: Список проверок из клиента
+ batch_id: ID пакета загрузки
+ is_federal_law_248: Признак проверок по ФЗ-248
+ data_year: Год, за который загружены данные
+ data_month: Месяц, за который загружены данные
+ chunk_size: Размер чанка для bulk_create
+
+ Returns:
+ Количество новых сохранённых записей
+ """
+ if not inspections:
+ logger.warning("No inspections to save")
+ return 0
+
+ fz_type = "ФЗ-248" if is_federal_law_248 else "ФЗ-294"
+ logger.info(
+ "Saving %d inspections (batch_id=%d, %s, year=%s, month=%s)",
+ len(inspections),
+ batch_id,
+ fz_type,
+ data_year,
+ data_month,
+ )
+
+ instances = [
+ cls.model(
+ load_batch=batch_id,
+ registration_number=insp.registration_number,
+ inn=insp.inn,
+ ogrn=insp.ogrn,
+ organisation_name=insp.organisation_name,
+ control_authority=insp.control_authority,
+ inspection_type=insp.inspection_type,
+ inspection_form=insp.inspection_form,
+ start_date=insp.start_date,
+ end_date=insp.end_date,
+ status=insp.status,
+ legal_basis=insp.legal_basis,
+ result=insp.result,
+ is_federal_law_248=is_federal_law_248,
+ data_year=data_year,
+ data_month=data_month,
+ )
+ for insp in inspections
+ ]
+
+ saved_count = cls.bulk_create_chunked(
+ instances,
+ chunk_size=chunk_size,
+ ignore_conflicts=True, # Skip duplicates by registration_number
+ )
+ logger.info("Saved %d inspections", saved_count)
+
+ return saved_count
+
+ @classmethod
+ def get_last_loaded_period(
+ cls, is_federal_law_248: bool = False
+ ) -> tuple[int | None, int | None]:
+ """
+ Получить последний загруженный период (год, месяц).
+
+ Args:
+ is_federal_law_248: Признак проверок по ФЗ-248
+
+ Returns:
+ Кортеж (year, month) или (None, None) если данных нет
+ """
+ last_record = (
+ cls.model.objects.filter(is_federal_law_248=is_federal_law_248)
+ .exclude(data_year__isnull=True)
+ .order_by("-data_year", "-data_month")
+ .values("data_year", "data_month")
+ .first()
+ )
+
+ if last_record:
+ return last_record["data_year"], last_record["data_month"]
+ return None, None
+
+ @classmethod
+ def has_data_for_period(
+ cls,
+ year: int,
+ month: int,
+ is_federal_law_248: bool = False,
+ ) -> bool:
+ """
+ Проверить, есть ли данные за указанный период.
+
+ Args:
+ year: Год
+ month: Месяц
+ is_federal_law_248: Признак проверок по ФЗ-248
+
+ Returns:
+ True если данные есть
+ """
+ return cls.model.objects.filter(
+ data_year=year,
+ data_month=month,
+ is_federal_law_248=is_federal_law_248,
+ ).exists()
+
+ @classmethod
+ def find_by_inn(cls, inn: str, batch_id: int | None = None):
+ """
+ Найти проверки по ИНН.
+
+ Args:
+ inn: ИНН организации
+ batch_id: Фильтр по пакету загрузки (опционально)
+ """
+ qs = cls.filter(inn=inn)
+ if batch_id:
+ qs = qs.filter(load_batch=batch_id)
+ return qs
+
+ @classmethod
+ def find_by_registration_number(cls, registration_number: str):
+ """Найти проверки по учётному номеру."""
+ return cls.filter(registration_number=registration_number)
+
+ @classmethod
+ def find_by_control_authority(cls, authority: str, batch_id: int | None = None):
+ """
+ Найти проверки по контрольному органу.
+
+ Args:
+ authority: Наименование контрольного органа (частичное совпадение)
+ batch_id: Фильтр по пакету загрузки (опционально)
+ """
+ qs = cls.filter(control_authority__icontains=authority)
+ if batch_id:
+ qs = qs.filter(load_batch=batch_id)
+ return qs
diff --git a/src/apps/parsers/signals.py b/src/apps/parsers/signals.py
new file mode 100644
index 0000000..2a26230
--- /dev/null
+++ b/src/apps/parsers/signals.py
@@ -0,0 +1,14 @@
+"""
+Сигналы для приложения парсеров.
+
+Содержит обработчики сигналов для:
+- Автоматических действий при создании/обновлении DataSource
+- Очистки при удалении конфигураций
+"""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+# Сигналы будут добавлены по мере необходимости
diff --git a/src/apps/parsers/tasks.py b/src/apps/parsers/tasks.py
new file mode 100644
index 0000000..46f08ce
--- /dev/null
+++ b/src/apps/parsers/tasks.py
@@ -0,0 +1,580 @@
+"""
+Celery задачи для приложения парсеров.
+
+Задачи являются тонкими обёртками над сервисами и клиентами.
+Интегрируются с BackgroundJob для отслеживания прогресса.
+"""
+
+import logging
+from datetime import datetime
+
+from apps.core.services import BackgroundJobService
+from apps.parsers.clients.minpromtorg import (
+ IndustrialProductionClient,
+ ManufacturesClient,
+)
+from apps.parsers.clients.proverki import ProverkiClient
+from apps.parsers.models import ParserLoadLog
+from apps.parsers.services import (
+ IndustrialCertificateService,
+ InspectionService,
+ ManufacturerService,
+ ParserLoadLogService,
+ ProxyService,
+)
+from celery import shared_task
+
+logger = logging.getLogger(__name__)
+
+# Константы для синхронизации проверок
+DEFAULT_START_YEAR = 2025
+DEFAULT_START_MONTH = 1
+
+
+@shared_task(bind=True)
+def parse_industrial_production(self, proxies: list[str] | None = None) -> dict:
+ """
+ Задача парсинга сертификатов промышленного производства.
+
+ Args:
+ proxies: Список прокси-серверов (опционально).
+ Если не передан, берётся из БД.
+
+ Returns:
+ Результат: batch_id, saved, status
+ """
+ source = ParserLoadLog.Source.INDUSTRIAL
+ batch_id = ParserLoadLogService.get_next_batch_id(source)
+ task_id = self.request.id
+
+ # Если прокси не переданы, берём из БД
+ if proxies is None:
+ proxies = ProxyService.get_active_proxies_or_none()
+
+ logger.info(
+ "Starting industrial production parsing (task_id=%s, batch_id=%d, proxies=%d)",
+ task_id,
+ batch_id,
+ len(proxies) if proxies else 0,
+ )
+
+ # Создаём запись BackgroundJob для отслеживания прогресса
+ job = BackgroundJobService.create_job(
+ task_id=task_id,
+ task_name="apps.parsers.tasks.parse_industrial_production",
+ meta={"source": source, "batch_id": batch_id},
+ )
+ job.mark_started()
+ job.update_progress(0, "Инициализация парсера...")
+
+ # Создаём запись лога
+ load_log = ParserLoadLogService.create_load_log(
+ source=source,
+ batch_id=batch_id,
+ status="in_progress",
+ )
+
+ try:
+ # Парсинг данных
+ job.update_progress(10, "Загрузка данных с API Минпромторга...")
+ with IndustrialProductionClient(proxies=proxies) as client:
+ certificates = client.fetch_certificates()
+
+ # Сохранение в БД
+ job.update_progress(50, f"Сохранение {len(certificates)} сертификатов...")
+ saved_count = IndustrialCertificateService.save_certificates(
+ certificates,
+ batch_id=batch_id,
+ )
+
+ # Обновляем лог
+ ParserLoadLogService.update(
+ load_log,
+ status="success",
+ records_count=saved_count,
+ )
+
+ # Завершаем BackgroundJob
+ job.complete(result={"batch_id": batch_id, "saved": saved_count})
+
+ logger.info(
+ "Industrial production parsing completed (batch_id=%d, saved=%d)",
+ batch_id,
+ saved_count,
+ )
+
+ return {
+ "batch_id": batch_id,
+ "saved": saved_count,
+ "status": "success",
+ }
+
+ except Exception as e:
+ logger.error("Industrial production parsing failed: %s", e, exc_info=True)
+ ParserLoadLogService.mark_failed(load_log, str(e))
+ job.fail(error=str(e))
+
+ return {
+ "batch_id": batch_id,
+ "saved": 0,
+ "status": "failed",
+ "error": str(e),
+ }
+
+
+@shared_task(bind=True)
+def parse_manufactures(self, proxies: list[str] | None = None) -> dict:
+ """
+ Задача парсинга реестра производителей.
+
+ Args:
+ proxies: Список прокси-серверов (опционально).
+ Если не передан, берётся из БД.
+
+ Returns:
+ Результат: batch_id, saved, status
+ """
+ source = ParserLoadLog.Source.MANUFACTURES
+ batch_id = ParserLoadLogService.get_next_batch_id(source)
+ task_id = self.request.id
+
+ # Если прокси не переданы, берём из БД
+ if proxies is None:
+ proxies = ProxyService.get_active_proxies_or_none()
+
+ logger.info(
+ "Starting manufactures parsing (task_id=%s, batch_id=%d, proxies=%d)",
+ task_id,
+ batch_id,
+ len(proxies) if proxies else 0,
+ )
+
+ # Создаём запись BackgroundJob для отслеживания прогресса
+ job = BackgroundJobService.create_job(
+ task_id=task_id,
+ task_name="apps.parsers.tasks.parse_manufactures",
+ meta={"source": source, "batch_id": batch_id},
+ )
+ job.mark_started()
+ job.update_progress(0, "Инициализация парсера...")
+
+ # Создаём запись лога
+ load_log = ParserLoadLogService.create_load_log(
+ source=source,
+ batch_id=batch_id,
+ status="in_progress",
+ )
+
+ try:
+ # Парсинг данных
+ job.update_progress(10, "Загрузка данных с API Минпромторга...")
+ with ManufacturesClient(proxies=proxies) as client:
+ manufacturers = client.fetch_manufacturers()
+
+ # Сохранение в БД
+ job.update_progress(50, f"Сохранение {len(manufacturers)} производителей...")
+ saved_count = ManufacturerService.save_manufacturers(
+ manufacturers,
+ batch_id=batch_id,
+ )
+
+ # Обновляем лог
+ ParserLoadLogService.update(
+ load_log,
+ status="success",
+ records_count=saved_count,
+ )
+
+ # Завершаем BackgroundJob
+ job.complete(result={"batch_id": batch_id, "saved": saved_count})
+
+ logger.info(
+ "Manufactures parsing completed (batch_id=%d, saved=%d)",
+ batch_id,
+ saved_count,
+ )
+
+ return {
+ "batch_id": batch_id,
+ "saved": saved_count,
+ "status": "success",
+ }
+
+ except Exception as e:
+ logger.error("Manufactures parsing failed: %s", e, exc_info=True)
+ ParserLoadLogService.mark_failed(load_log, str(e))
+ job.fail(error=str(e))
+
+ return {
+ "batch_id": batch_id,
+ "saved": 0,
+ "status": "failed",
+ "error": str(e),
+ }
+
+
+@shared_task
+def parse_all_minpromtorg(proxies: list[str] | None = None) -> dict:
+ """
+ Запустить все парсеры Минпромторга.
+
+ Args:
+ proxies: Список прокси-серверов (опционально).
+ Если не передан, каждая задача возьмёт прокси из БД.
+
+ Returns:
+ Результаты всех парсеров
+ """
+ logger.info("Starting all Minpromtorg parsers")
+
+ results = {
+ "industrial": parse_industrial_production.delay(proxies=proxies).id,
+ "manufactures": parse_manufactures.delay(proxies=proxies).id,
+ }
+
+ return results
+
+
+@shared_task(bind=True)
+def parse_inspections(
+ self,
+ *,
+ year: int | None = None,
+ month: int | None = None,
+ file_url: str | None = None,
+ proxies: list[str] | None = None,
+) -> dict:
+ """
+ Задача парсинга данных о проверках с proverki.gov.ru.
+
+ Args:
+ year: Год плана проверок (опционально)
+ month: Месяц (опционально)
+ file_url: Прямая ссылка на файл данных (опционально)
+ proxies: Список прокси-серверов (опционально).
+ Если не передан, берётся из БД.
+
+ Returns:
+ Результат: batch_id, saved, status
+ """
+ source = ParserLoadLog.Source.INSPECTIONS
+ batch_id = ParserLoadLogService.get_next_batch_id(source)
+ task_id = self.request.id
+
+ # Если прокси не переданы, берём из БД
+ if proxies is None:
+ proxies = ProxyService.get_active_proxies_or_none()
+
+ logger.info(
+ "Starting inspections parsing (task_id=%s, batch_id=%d, year=%s, month=%s, proxies=%d)",
+ task_id,
+ batch_id,
+ year,
+ month,
+ len(proxies) if proxies else 0,
+ )
+
+ # Создаём запись BackgroundJob для отслеживания прогресса
+ job = BackgroundJobService.create_job(
+ task_id=task_id,
+ task_name="apps.parsers.tasks.parse_inspections",
+ meta={"source": source, "batch_id": batch_id, "year": year, "month": month},
+ )
+ job.mark_started()
+ job.update_progress(0, "Инициализация парсера...")
+
+ # Создаём запись лога
+ load_log = ParserLoadLogService.create_load_log(
+ source=source,
+ batch_id=batch_id,
+ status="in_progress",
+ )
+
+ def progress_callback(percent: int, message: str) -> None:
+ """Callback для обновления прогресса."""
+ job.update_progress(percent, message)
+
+ try:
+ # Парсинг данных
+ job.update_progress(10, "Загрузка данных с proverki.gov.ru...")
+ with ProverkiClient(proxies=proxies) as client:
+ inspections = client.fetch_inspections(
+ year=year,
+ month=month,
+ file_url=file_url,
+ progress_callback=progress_callback,
+ )
+
+ # Сохранение в БД
+ job.update_progress(80, f"Сохранение {len(inspections)} проверок...")
+ saved_count = InspectionService.save_inspections(
+ inspections,
+ batch_id=batch_id,
+ )
+
+ # Обновляем лог
+ ParserLoadLogService.update(
+ load_log,
+ status="success",
+ records_count=saved_count,
+ )
+
+ # Завершаем BackgroundJob
+ job.complete(result={"batch_id": batch_id, "saved": saved_count})
+
+ logger.info(
+ "Inspections parsing completed (batch_id=%d, saved=%d)",
+ batch_id,
+ saved_count,
+ )
+
+ return {
+ "batch_id": batch_id,
+ "saved": saved_count,
+ "status": "success",
+ }
+
+ except Exception as e:
+ logger.error("Inspections parsing failed: %s", e, exc_info=True)
+ ParserLoadLogService.mark_failed(load_log, str(e))
+ job.fail(error=str(e))
+
+ return {
+ "batch_id": batch_id,
+ "saved": 0,
+ "status": "failed",
+ "error": str(e),
+ }
+
+
+@shared_task
+def parse_all_sources(proxies: list[str] | None = None) -> dict:
+ """
+ Запустить все парсеры из всех источников.
+
+ Args:
+ proxies: Список прокси-серверов (опционально).
+ Если не передан, каждая задача возьмёт прокси из БД.
+
+ Returns:
+ Task IDs всех запущенных парсеров
+ """
+ logger.info("Starting all parsers from all sources")
+
+ results = {
+ "industrial": parse_industrial_production.delay(proxies=proxies).id,
+ "manufactures": parse_manufactures.delay(proxies=proxies).id,
+ "inspections": parse_inspections.delay(proxies=proxies).id,
+ }
+
+ return results
+
+
+def _get_next_month(year: int, month: int) -> tuple[int, int]:
+ """Получить следующий месяц."""
+ if month == 12:
+ return year + 1, 1
+ return year, month + 1
+
+
+@shared_task(bind=True)
+def sync_inspections( # noqa: C901
+ self,
+ *,
+ proxies: list[str] | None = None,
+) -> dict:
+ """
+ Синхронизация данных о проверках с proverki.gov.ru.
+
+ Логика работы:
+ 1. Проверяет последнюю загруженную дату в БД
+ 2. Если данных нет - начинает с 01.01.2025
+ 3. Загружает месяц за месяцем до конца текущего года
+ 4. Загружает оба типа проверок (ФЗ-294 и ФЗ-248)
+ 5. Если данных нет за период - прекращает загрузку для этого типа
+
+ Args:
+ proxies: Список прокси-серверов (опционально)
+
+ Returns:
+ Результат синхронизации
+ """
+ source = ParserLoadLog.Source.INSPECTIONS
+ batch_id = ParserLoadLogService.get_next_batch_id(source)
+ task_id = self.request.id
+
+ # Если прокси не переданы, берём из БД
+ if proxies is None:
+ proxies = ProxyService.get_active_proxies_or_none()
+
+ logger.info(
+ "Starting inspections sync (task_id=%s, batch_id=%d)", task_id, batch_id
+ )
+
+ # Создаём запись BackgroundJob
+ job = BackgroundJobService.create_job(
+ task_id=task_id,
+ task_name="apps.parsers.tasks.sync_inspections",
+ meta={"source": source, "batch_id": batch_id},
+ )
+ job.mark_started()
+ job.update_progress(0, "Инициализация синхронизации...")
+
+ # Создаём запись лога
+ load_log = ParserLoadLogService.create_load_log(
+ source=source,
+ batch_id=batch_id,
+ status="in_progress",
+ )
+
+ current_year = datetime.now().year
+ current_month = datetime.now().month
+ total_saved = 0
+ results = {"fz294": [], "fz248": []}
+
+ try:
+ with ProverkiClient(proxies=proxies) as client:
+ # Обрабатываем оба типа проверок
+ for is_fz248 in [False, True]:
+ fz_key = "fz248" if is_fz248 else "fz294"
+ fz_name = "ФЗ-248" if is_fz248 else "ФЗ-294"
+
+ # Определяем начальную точку
+ last_year, last_month = InspectionService.get_last_loaded_period(
+ is_federal_law_248=is_fz248
+ )
+
+ if last_year and last_month:
+ # Начинаем со следующего месяца после последнего загруженного
+ start_year, start_month = _get_next_month(last_year, last_month)
+ logger.info(
+ "%s: continuing from %d/%d (last loaded: %d/%d)",
+ fz_name,
+ start_year,
+ start_month,
+ last_year,
+ last_month,
+ )
+ else:
+ # Начинаем с дефолтной даты
+ start_year, start_month = DEFAULT_START_YEAR, DEFAULT_START_MONTH
+ logger.info(
+ "%s: no data in DB, starting from %d/%d",
+ fz_name,
+ start_year,
+ start_month,
+ )
+
+ # Загружаем месяц за месяцем
+ year, month = start_year, start_month
+ empty_months_count = 0
+
+ while year < current_year or (
+ year == current_year and month <= current_month
+ ):
+ # Прекращаем если 2 месяца подряд нет данных
+ if empty_months_count >= 2:
+ logger.info(
+ "%s: stopping after %d empty months",
+ fz_name,
+ empty_months_count,
+ )
+ break
+
+ job.update_progress(
+ 20 + (50 if is_fz248 else 0),
+ f"Загрузка {fz_name} за {month:02d}/{year}...",
+ )
+
+ try:
+ inspections = client.fetch_inspections(
+ year=year,
+ month=month,
+ is_federal_law_248=is_fz248,
+ )
+
+ if inspections:
+ saved = InspectionService.save_inspections(
+ inspections,
+ batch_id=batch_id,
+ is_federal_law_248=is_fz248,
+ data_year=year,
+ data_month=month,
+ )
+ total_saved += saved
+ results[fz_key].append(
+ {
+ "year": year,
+ "month": month,
+ "fetched": len(inspections),
+ "saved": saved,
+ }
+ )
+ empty_months_count = 0
+ logger.info(
+ "%s %d/%d: fetched %d, saved %d",
+ fz_name,
+ year,
+ month,
+ len(inspections),
+ saved,
+ )
+ else:
+ empty_months_count += 1
+ logger.info(
+ "%s %d/%d: no data found (empty_count=%d)",
+ fz_name,
+ year,
+ month,
+ empty_months_count,
+ )
+
+ except Exception as e:
+ logger.warning(
+ "%s %d/%d: error - %s",
+ fz_name,
+ year,
+ month,
+ str(e),
+ )
+ empty_months_count += 1
+
+ # Переходим к следующему месяцу
+ year, month = _get_next_month(year, month)
+
+ # Обновляем лог
+ ParserLoadLogService.update(
+ load_log,
+ status="success",
+ records_count=total_saved,
+ )
+
+ # Завершаем BackgroundJob
+ job.complete(
+ result={
+ "batch_id": batch_id,
+ "total_saved": total_saved,
+ "results": results,
+ }
+ )
+
+ logger.info("Inspections sync completed (total_saved=%d)", total_saved)
+
+ return {
+ "batch_id": batch_id,
+ "total_saved": total_saved,
+ "status": "success",
+ "results": results,
+ }
+
+ except Exception as e:
+ logger.error("Inspections sync failed: %s", e, exc_info=True)
+ ParserLoadLogService.mark_failed(load_log, str(e))
+ job.fail(error=str(e))
+
+ return {
+ "batch_id": batch_id,
+ "total_saved": total_saved,
+ "status": "failed",
+ "error": str(e),
+ }
diff --git a/src/apps/parsers/urls.py b/src/apps/parsers/urls.py
new file mode 100644
index 0000000..051ad2a
--- /dev/null
+++ b/src/apps/parsers/urls.py
@@ -0,0 +1,5 @@
+app_name = "parsers"
+
+urlpatterns = [
+ # URL-маршруты будут добавлены по мере разработки
+]
diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py
new file mode 100644
index 0000000..a890579
--- /dev/null
+++ b/src/apps/parsers/views.py
@@ -0,0 +1,7 @@
+"""
+Views для приложения парсеров.
+
+TODO: Добавить views по мере необходимости.
+"""
+
+# Views будут добавлены по мере разработки конкретных парсеров
diff --git a/src/apps/user/admin.py b/src/apps/user/admin.py
new file mode 100644
index 0000000..124a62d
--- /dev/null
+++ b/src/apps/user/admin.py
@@ -0,0 +1,181 @@
+"""
+Admin configuration for user app.
+"""
+
+from apps.user.models import Profile, User
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+
+class ProfileInline(admin.StackedInline):
+ """Inline для профиля пользователя."""
+
+ model = Profile
+ can_delete = False
+ verbose_name_plural = "Профиль"
+ fk_name = "user"
+ fields = ["first_name", "last_name", "bio", "avatar", "date_of_birth"]
+
+
+@admin.register(User)
+class UserAdmin(BaseUserAdmin):
+ """Admin для пользователей."""
+
+ inlines = [ProfileInline]
+
+ list_display = [
+ "username",
+ "email",
+ "phone",
+ "is_verified_badge",
+ "is_active_badge",
+ "is_staff",
+ "created_at",
+ ]
+ list_filter = ["is_staff", "is_superuser", "is_active", "is_verified", "created_at"]
+ search_fields = ["username", "email", "phone"]
+ ordering = ["-created_at"]
+ list_per_page = 50
+ date_hierarchy = "created_at"
+
+ fieldsets = (
+ (None, {"fields": ("username", "password")}),
+ (
+ _("Personal info"),
+ {"fields": ("email", "phone")},
+ ),
+ (
+ _("Permissions"),
+ {
+ "fields": (
+ "is_active",
+ "is_staff",
+ "is_superuser",
+ "is_verified",
+ "groups",
+ "user_permissions",
+ ),
+ "classes": ("collapse",),
+ },
+ ),
+ (
+ _("Important dates"),
+ {"fields": ("last_login", "date_joined", "created_at", "updated_at")},
+ ),
+ )
+
+ add_fieldsets = (
+ (
+ None,
+ {
+ "classes": ("wide",),
+ "fields": (
+ "username",
+ "email",
+ "password1",
+ "password2",
+ "is_staff",
+ "is_active",
+ ),
+ },
+ ),
+ )
+
+ readonly_fields = ["created_at", "updated_at", "last_login", "date_joined"]
+
+ def is_verified_badge(self, obj):
+ """Бейдж верификации."""
+ if obj.is_verified:
+ return format_html(
+ '✓'
+ )
+ return format_html(
+ '✗'
+ )
+
+ is_verified_badge.short_description = "Верифицирован"
+ is_verified_badge.admin_order_field = "is_verified"
+
+ def is_active_badge(self, obj):
+ """Бейдж активности."""
+ if obj.is_active:
+ return format_html(
+ 'Активен'
+ )
+ return format_html(
+ 'Неактивен'
+ )
+
+ is_active_badge.short_description = "Статус"
+ is_active_badge.admin_order_field = "is_active"
+
+ actions = ["verify_users", "unverify_users", "activate_users", "deactivate_users"]
+
+ @admin.action(description="Верифицировать выбранных пользователей")
+ def verify_users(self, request, queryset):
+ updated = queryset.update(is_verified=True)
+ self.message_user(request, f"Верифицировано {updated} пользователей")
+
+ @admin.action(description="Снять верификацию")
+ def unverify_users(self, request, queryset):
+ updated = queryset.update(is_verified=False)
+ self.message_user(request, f"Снята верификация у {updated} пользователей")
+
+ @admin.action(description="Активировать пользователей")
+ def activate_users(self, request, queryset):
+ updated = queryset.update(is_active=True)
+ self.message_user(request, f"Активировано {updated} пользователей")
+
+ @admin.action(description="Деактивировать пользователей")
+ def deactivate_users(self, request, queryset):
+ updated = queryset.update(is_active=False)
+ self.message_user(request, f"Деактивировано {updated} пользователей")
+
+
+@admin.register(Profile)
+class ProfileAdmin(admin.ModelAdmin):
+ """Admin для профилей."""
+
+ list_display = [
+ "user",
+ "full_name",
+ "date_of_birth",
+ "has_avatar",
+ "created_at",
+ ]
+ list_filter = ["created_at"]
+ search_fields = ["user__username", "user__email", "first_name", "last_name"]
+ readonly_fields = ["created_at", "updated_at"]
+ ordering = ["-created_at"]
+ list_per_page = 50
+ raw_id_fields = ["user"]
+
+ fieldsets = (
+ ("Пользователь", {"fields": ("user",)}),
+ (
+ "Личная информация",
+ {"fields": ("first_name", "last_name", "bio", "date_of_birth")},
+ ),
+ ("Аватар", {"fields": ("avatar",)}),
+ ("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
+ )
+
+ def has_avatar(self, obj):
+ """Есть ли аватар."""
+ if obj.avatar:
+ return format_html(
+ 'Да'
+ )
+ return format_html(
+ 'Нет'
+ )
+
+ has_avatar.short_description = "Аватар"
diff --git a/src/apps/user/migrations/0002_remove_firstname_lastname.py b/src/apps/user/migrations/0002_remove_firstname_lastname.py
new file mode 100644
index 0000000..c12bb96
--- /dev/null
+++ b/src/apps/user/migrations/0002_remove_firstname_lastname.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.25 on 2026-01-21 17:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ('user', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='user',
+ name='first_name',
+ ),
+ migrations.RemoveField(
+ model_name='user',
+ name='last_name',
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='groups',
+ field=models.ManyToManyField(blank=True, help_text='', related_name='custom_user_set', related_query_name='custom_user', to='auth.Group', verbose_name='groups'),
+ ),
+ ]
diff --git a/src/apps/user/models.py b/src/apps/user/models.py
index 20bd9f9..ef84f96 100644
--- a/src/apps/user/models.py
+++ b/src/apps/user/models.py
@@ -6,6 +6,10 @@ from django.utils.translation import gettext_lazy as _
class User(AbstractUser):
"""Расширенная модель пользователя"""
+ # Убираем first_name и last_name из модели User (они в Profile)
+ first_name = None
+ last_name = None
+
# Переопределяем группы и разрешения для избежания конфликта
groups = models.ManyToManyField(
"auth.Group",
diff --git a/src/config/api_v1_urls.py b/src/config/api_v1_urls.py
index a3abefd..cdf8730 100644
--- a/src/config/api_v1_urls.py
+++ b/src/config/api_v1_urls.py
@@ -16,5 +16,6 @@ jobs_urlpatterns = [
urlpatterns = [
path("users/", include("apps.user.urls")),
+ path("parsers/", include("apps.parsers.urls")),
path("jobs/", include((jobs_urlpatterns, "jobs"))),
]
diff --git a/src/config/celery.py b/src/config/celery.py
index a45ae92..0eecb09 100644
--- a/src/config/celery.py
+++ b/src/config/celery.py
@@ -24,17 +24,19 @@ app.autodiscover_tasks()
# Configure Celery Beat schedule
app.conf.beat_schedule = {
- "check-pending-scraping-jobs": {
- "task": "apps.scraping.tasks.check_pending_jobs",
- "schedule": 300.0, # Every 5 minutes
+ # Парсинг сертификатов промышленного производства - каждый день в 3:00
+ "parse-industrial-production-daily": {
+ "task": "apps.parsers.tasks.parse_industrial_production",
+ "schedule": 86400.0, # Every 24 hours
},
- "process-extracted-data": {
- "task": "apps.data_processor.tasks.process_extracted_data",
- "schedule": 600.0, # Every 10 minutes
+ # Парсинг реестра производителей - каждый день в 4:00
+ "parse-manufactures-daily": {
+ "task": "apps.parsers.tasks.parse_manufactures",
+ "schedule": 86400.0, # Every 24 hours
},
}
-app.conf.timezone = "UTC"
+app.conf.timezone = "Europe/Moscow"
@app.task(bind=True)
diff --git a/src/config/settings/base.py b/src/config/settings/base.py
index 60f6b50..17cfd8b 100644
--- a/src/config/settings/base.py
+++ b/src/config/settings/base.py
@@ -45,6 +45,7 @@ if isinstance(ALLOWED_HOSTS, str):
# Application definition
INSTALLED_APPS = [
+ "jazzmin", # Django Jazzmin - modern admin theme (must be before admin)
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
@@ -61,8 +62,106 @@ INSTALLED_APPS = [
# Local apps
"apps.core",
"apps.user",
+ "apps.parsers",
]
+# Jazzmin Admin Configuration
+JAZZMIN_SETTINGS = {
+ # Title
+ "site_title": "Mostovik Admin",
+ "site_header": "Mostovik",
+ "site_brand": "Mostovik",
+ "site_logo": None,
+ "login_logo": None,
+ "login_logo_dark": None,
+ "site_logo_classes": "img-circle",
+ "site_icon": None,
+ "welcome_sign": "Добро пожаловать в панель управления",
+ "copyright": "Mostovik Backend",
+ # Search
+ "search_model": ["user.User", "parsers.IndustrialCertificateRecord"],
+ # User menu
+ "topmenu_links": [
+ {"name": "Главная", "url": "admin:index", "permissions": ["auth.view_user"]},
+ {"name": "API Docs", "url": "/api/docs/", "new_window": True},
+ {"model": "user.User"},
+ ],
+ # Side menu
+ "show_sidebar": True,
+ "navigation_expanded": True,
+ "hide_apps": ["django_celery_results"],
+ "hide_models": [],
+ "order_with_respect_to": [
+ "user",
+ "parsers",
+ "core",
+ "django_celery_beat",
+ ],
+ # Icons (Font Awesome)
+ "icons": {
+ "auth": "fas fa-users-cog",
+ "auth.Group": "fas fa-users",
+ "user.User": "fas fa-user",
+ "user.Profile": "fas fa-id-card",
+ "parsers.Proxy": "fas fa-shield-alt",
+ "parsers.ParserLoadLog": "fas fa-history",
+ "parsers.IndustrialCertificateRecord": "fas fa-certificate",
+ "parsers.ManufacturerRecord": "fas fa-industry",
+ "core.BackgroundJob": "fas fa-tasks",
+ "django_celery_beat.PeriodicTask": "fas fa-clock",
+ "django_celery_beat.CrontabSchedule": "fas fa-calendar-alt",
+ "django_celery_beat.IntervalSchedule": "fas fa-stopwatch",
+ "django_celery_results.TaskResult": "fas fa-clipboard-check",
+ },
+ "default_icon_parents": "fas fa-chevron-circle-right",
+ "default_icon_children": "fas fa-circle",
+ # Related modal
+ "related_modal_active": True,
+ # UI Tweaks
+ "custom_css": None,
+ "custom_js": None,
+ "use_google_fonts_cdn": True,
+ "show_ui_builder": False,
+ # Change view
+ "changeform_format": "horizontal_tabs",
+ "changeform_format_overrides": {
+ "user.User": "collapsible",
+ "parsers.IndustrialCertificateRecord": "vertical_tabs",
+ },
+}
+
+JAZZMIN_UI_TWEAKS = {
+ "navbar_small_text": False,
+ "footer_small_text": False,
+ "body_small_text": False,
+ "brand_small_text": False,
+ "brand_colour": "navbar-primary",
+ "accent": "accent-primary",
+ "navbar": "navbar-dark",
+ "no_navbar_border": False,
+ "navbar_fixed": True,
+ "layout_boxed": False,
+ "footer_fixed": False,
+ "sidebar_fixed": True,
+ "sidebar": "sidebar-dark-primary",
+ "sidebar_nav_small_text": False,
+ "sidebar_disable_expand": False,
+ "sidebar_nav_child_indent": False,
+ "sidebar_nav_compact_style": False,
+ "sidebar_nav_legacy_style": False,
+ "sidebar_nav_flat_style": False,
+ "theme": "default",
+ "dark_mode_theme": "darkly",
+ "button_classes": {
+ "primary": "btn-primary",
+ "secondary": "btn-secondary",
+ "info": "btn-info",
+ "warning": "btn-warning",
+ "danger": "btn-danger",
+ "success": "btn-success",
+ },
+}
+
MIDDLEWARE = [
"apps.core.middleware.RequestIDMiddleware",
"corsheaders.middleware.CorsMiddleware",
diff --git a/src/config/settings/development.py b/src/config/settings/development.py
index b841587..29eed24 100644
--- a/src/config/settings/development.py
+++ b/src/config/settings/development.py
@@ -1,44 +1,49 @@
+import os
+
from .base import *
# Development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = "django-insecure-development-key-change-in-production"
+SECRET_KEY = os.getenv(
+ "SECRET_KEY", "django-insecure-development-key-change-in-production"
+)
# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
+DEBUG = os.getenv("DEBUG", "True").lower() in ("true", "1", "yes")
-ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "testserver"]
+ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "testserver", "*"] # noqa: S104
# Database for development
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
- "NAME": "project_dev",
- "USER": "postgres",
- "PASSWORD": "postgres",
- "HOST": "localhost",
- "PORT": "5432",
+ "NAME": os.getenv("POSTGRES_DB", "project_dev"),
+ "USER": os.getenv("POSTGRES_USER", "postgres"),
+ "PASSWORD": os.getenv("POSTGRES_PASSWORD", "postgres"),
+ "HOST": os.getenv("POSTGRES_HOST", "localhost"),
+ "PORT": os.getenv("POSTGRES_PORT", "5432"),
}
}
# Celery Configuration for Development
-CELERY_BROKER_URL = "redis://localhost:6379/0"
-CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
+CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
+CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
-CELERY_TIMEZONE = "UTC"
+CELERY_TIMEZONE = "Europe/Moscow"
# Email backend for development
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Cache configuration for development
+REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1")
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
- "LOCATION": "redis://127.0.0.1:6379/1",
+ "LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
diff --git a/tests/apps/parsers/__init__.py b/tests/apps/parsers/__init__.py
new file mode 100644
index 0000000..30d1528
--- /dev/null
+++ b/tests/apps/parsers/__init__.py
@@ -0,0 +1 @@
+"""Tests for parsers app."""
diff --git a/tests/apps/parsers/factories.py b/tests/apps/parsers/factories.py
new file mode 100644
index 0000000..4ff41ca
--- /dev/null
+++ b/tests/apps/parsers/factories.py
@@ -0,0 +1,347 @@
+"""Factories for parsers tests."""
+
+import random
+from datetime import timedelta
+
+from django.utils import timezone
+
+import factory
+
+from apps.parsers.models import (
+ IndustrialCertificateRecord,
+ InspectionRecord,
+ ManufacturerRecord,
+ ParserLoadLog,
+ Proxy,
+)
+
+# === Хелперы для генерации реалистичных данных ===
+
+
+def generate_inn_legal() -> str:
+ """Генерация ИНН юридического лица (10 цифр)."""
+ # ИНН юрлица: NNNNXXXXXC (10 цифр)
+ # NNNN - код налогового органа
+ # XXXXX - порядковый номер
+ # C - контрольная цифра
+ region = random.choice(["77", "78", "50", "52", "63", "16", "66", "74", "54", "61"])
+ inspection = str(random.randint(1, 99)).zfill(2)
+ number = str(random.randint(1, 99999)).zfill(5)
+ base = region + inspection + number
+ # Контрольная цифра (упрощённо)
+ control = str(sum(int(d) for d in base) % 10)
+ return base + control
+
+
+def generate_ogrn() -> str:
+ """Генерация ОГРН юридического лица (13 цифр)."""
+ # ОГРН: СГГККННХХХХХЧ (13 цифр)
+ # С - признак (1 - юрлицо)
+ # ГГ - год регистрации
+ # КК - код региона
+ # НН - код инспекции
+ # ХХХХХ - номер записи
+ # Ч - контрольная цифра
+ sign = "1"
+ year = str(random.randint(2, 24)).zfill(2)
+ region = random.choice(["77", "78", "50", "52", "63", "16", "66", "74", "54", "61"])
+ inspection = str(random.randint(1, 99)).zfill(2)
+ number = str(random.randint(1, 99999)).zfill(5)
+ base = sign + year + region + inspection + number
+ # Контрольная цифра: остаток от деления на 11, если 10 - то 0
+ control = str(int(base) % 11 % 10)
+ return base + control
+
+
+def generate_certificate_number() -> str:
+ """Генерация номера сертификата промпроизводства."""
+ # Формат: ПП-XXXXXXXXXX или аналогичный
+ prefix = random.choice(["ПП", "СПП", "ЗППП"])
+ year = random.randint(2020, 2025)
+ number = random.randint(1, 99999)
+ return f"{prefix}-{year}-{number:05d}"
+
+
+def generate_company_name() -> str:
+ """Генерация реалистичного названия компании."""
+ forms = ["ООО", "АО", "ПАО", "ЗАО", "ОАО"]
+ industries = [
+ "Металлург",
+ "Промтех",
+ "Машстрой",
+ "Агропром",
+ "Нефтегаз",
+ "Химпром",
+ "Электроника",
+ "Автоком",
+ "Стройинвест",
+ "Техносервис",
+ "Приборостроение",
+ "Энергомаш",
+ "Станкопром",
+ "Спецсталь",
+ "Трубопрокат",
+ ]
+ suffixes = ["", " Групп", " Холдинг", " Инвест", " Трейд", " Индустрия", " Про"]
+ cities = [
+ "Москва",
+ "Санкт-Петербург",
+ "Новосибирск",
+ "Екатеринбург",
+ "Казань",
+ "Челябинск",
+ ]
+
+ form = random.choice(forms)
+ industry = random.choice(industries)
+ suffix = random.choice(suffixes)
+ city = random.choice(cities) if random.random() > 0.7 else ""
+
+ name = f"{industry}{suffix}"
+ if city:
+ name = f"{name}-{city}"
+
+ return f'{form} "{name}"'
+
+
+def generate_legal_address() -> str:
+ """Генерация юридического адреса."""
+ regions = [
+ ("г. Москва", ""),
+ ("г. Санкт-Петербург", ""),
+ ("Московская обл.", "г. Подольск"),
+ ("Свердловская обл.", "г. Екатеринбург"),
+ ("Республика Татарстан", "г. Казань"),
+ ("Челябинская обл.", "г. Челябинск"),
+ ("Новосибирская обл.", "г. Новосибирск"),
+ ("Нижегородская обл.", "г. Нижний Новгород"),
+ ]
+
+ region, city = random.choice(regions)
+ street_types = ["ул.", "пр-т", "пер.", "наб.", "ш."]
+ street_names = [
+ "Ленина",
+ "Мира",
+ "Советская",
+ "Промышленная",
+ "Заводская",
+ "Первомайская",
+ "Октябрьская",
+ "Гагарина",
+ "Кирова",
+ "Строителей",
+ ]
+
+ street = f"{random.choice(street_types)} {random.choice(street_names)}"
+ building = random.randint(1, 150)
+ office = random.randint(1, 500) if random.random() > 0.5 else None
+
+ postal = f"{random.randint(100, 199)}0{random.randint(10, 99)}"
+
+ parts = [postal, region]
+ if city:
+ parts.append(city)
+ parts.append(f"{street}, д. {building}")
+ if office:
+ parts.append(f"оф. {office}")
+
+ return ", ".join(parts)
+
+
+def generate_proxy_address() -> str:
+ """Генерация адреса прокси-сервера."""
+ protocols = ["http", "https", "socks5"]
+ hosts = [
+ f"{random.randint(1, 255)}.{random.randint(1, 255)}."
+ f"{random.randint(1, 255)}.{random.randint(1, 255)}",
+ f"proxy{random.randint(1, 50)}.example.com",
+ f"ru{random.randint(1, 20)}.proxy-service.net",
+ ]
+ ports = [8080, 3128, 8888, 1080, 8000, 9050]
+
+ protocol = random.choice(protocols)
+ host = random.choice(hosts)
+ port = random.choice(ports)
+
+ return f"{protocol}://{host}:{port}"
+
+
+# === Фабрики ===
+
+
+class ProxyFactory(factory.django.DjangoModelFactory):
+ """Factory for Proxy model."""
+
+ class Meta:
+ model = Proxy
+
+ address = factory.LazyFunction(generate_proxy_address)
+ is_active = True
+ fail_count = 0
+ description = factory.LazyAttribute(
+ lambda _: random.choice(
+ [
+ "Datacenter RU",
+ "Residential RU",
+ "Mobile RU",
+ "Datacenter EU",
+ "Premium proxy",
+ "Backup proxy",
+ ]
+ )
+ )
+
+
+class ParserLoadLogFactory(factory.django.DjangoModelFactory):
+ """Factory for ParserLoadLog model."""
+
+ class Meta:
+ model = ParserLoadLog
+
+ batch_id = factory.Sequence(lambda n: n + 1)
+ source = factory.LazyAttribute(
+ lambda _: random.choice(
+ [
+ ParserLoadLog.Source.INDUSTRIAL,
+ ParserLoadLog.Source.MANUFACTURES,
+ ]
+ )
+ )
+ records_count = factory.LazyAttribute(lambda _: random.randint(100, 5000))
+ status = "success"
+ error_message = ""
+
+
+class IndustrialCertificateRecordFactory(factory.django.DjangoModelFactory):
+ """Factory for IndustrialCertificateRecord model."""
+
+ class Meta:
+ model = IndustrialCertificateRecord
+
+ load_batch = factory.Sequence(lambda n: n + 1)
+ issue_date = factory.LazyAttribute(
+ lambda _: (timezone.now() - timedelta(days=random.randint(30, 365))).strftime(
+ "%d.%m.%Y"
+ )
+ )
+ certificate_number = factory.LazyFunction(generate_certificate_number)
+ expiry_date = factory.LazyAttribute(
+ lambda _: (timezone.now() + timedelta(days=random.randint(180, 730))).strftime(
+ "%d.%m.%Y"
+ )
+ )
+ certificate_file_url = factory.LazyAttribute(
+ lambda obj: f"https://minpromtorg.gov.ru/docs/certificates/"
+ f"{obj.certificate_number.replace('-', '_')}.pdf"
+ )
+ organisation_name = factory.LazyFunction(generate_company_name)
+ inn = factory.LazyFunction(generate_inn_legal)
+ ogrn = factory.LazyFunction(generate_ogrn)
+
+
+class ManufacturerRecordFactory(factory.django.DjangoModelFactory):
+ """Factory for ManufacturerRecord model."""
+
+ class Meta:
+ model = ManufacturerRecord
+
+ load_batch = factory.Sequence(lambda n: n + 1)
+ full_legal_name = factory.LazyFunction(generate_company_name)
+ inn = factory.LazyFunction(generate_inn_legal)
+ ogrn = factory.LazyFunction(generate_ogrn)
+ address = factory.LazyFunction(generate_legal_address)
+
+
+def generate_registration_number() -> str:
+ """Генерация учётного номера проверки."""
+ # Формат: 772020123456 или подобный
+ region = random.choice(["77", "78", "50", "52", "63", "16", "66", "74", "54", "61"])
+ year = random.randint(2020, 2025)
+ number = random.randint(1, 999999)
+ return f"{region}{year}{number:06d}"
+
+
+def generate_control_authority() -> str:
+ """Генерация наименования контрольного органа."""
+ authorities = [
+ "Роспотребнадзор",
+ "Ростехнадзор",
+ "Росприроднадзор",
+ "МЧС России",
+ "Роструд",
+ "ФНС России",
+ "ФАС России",
+ "Россельхознадзор",
+ "Роскомнадзор",
+ "Росздравнадзор",
+ ]
+ prefixes = [
+ "Управление",
+ "Территориальное управление",
+ "Межрегиональное управление",
+ "Отдел",
+ ]
+ regions = [
+ "по г. Москве",
+ "по Санкт-Петербургу",
+ "по Московской области",
+ "по Свердловской области",
+ "по Республике Татарстан",
+ "по Челябинской области",
+ "по Новосибирской области",
+ ]
+
+ authority = random.choice(authorities)
+ prefix = random.choice(prefixes) if random.random() > 0.3 else ""
+ region = random.choice(regions) if random.random() > 0.4 else ""
+
+ if prefix and region:
+ return f"{prefix} {authority} {region}"
+ elif prefix:
+ return f"{prefix} {authority}"
+ elif region:
+ return f"{authority} {region}"
+ return authority
+
+
+class InspectionRecordFactory(factory.django.DjangoModelFactory):
+ """Factory for InspectionRecord model."""
+
+ class Meta:
+ model = InspectionRecord
+
+ load_batch = factory.Sequence(lambda n: n + 1)
+ registration_number = factory.LazyFunction(generate_registration_number)
+ inn = factory.LazyFunction(generate_inn_legal)
+ ogrn = factory.LazyFunction(generate_ogrn)
+ organisation_name = factory.LazyFunction(generate_company_name)
+ control_authority = factory.LazyFunction(generate_control_authority)
+ inspection_type = factory.LazyAttribute(
+ lambda _: random.choice(["плановая", "внеплановая"])
+ )
+ inspection_form = factory.LazyAttribute(
+ lambda _: random.choice(["документарная", "выездная", "документарная и выездная"])
+ )
+ start_date = factory.LazyAttribute(
+ lambda _: (timezone.now() - timedelta(days=random.randint(1, 180))).strftime(
+ "%Y-%m-%d"
+ )
+ )
+ end_date = factory.LazyAttribute(
+ lambda _: (timezone.now() + timedelta(days=random.randint(1, 30))).strftime(
+ "%Y-%m-%d"
+ )
+ )
+ status = factory.LazyAttribute(
+ lambda _: random.choice(["завершена", "в процессе", "запланирована"])
+ )
+ legal_basis = factory.LazyAttribute(
+ lambda _: random.choice(["294-ФЗ", "248-ФЗ", "184-ФЗ"])
+ )
+ result = factory.LazyAttribute(
+ lambda _: random.choice(
+ ["нарушения не выявлены", "выявлены нарушения", ""]
+ )
+ if random.random() > 0.3
+ else ""
+ )
diff --git a/tests/apps/parsers/test_clients.py b/tests/apps/parsers/test_clients.py
new file mode 100644
index 0000000..e16594c
--- /dev/null
+++ b/tests/apps/parsers/test_clients.py
@@ -0,0 +1,646 @@
+"""Tests for parsers clients."""
+
+from io import BytesIO
+from unittest.mock import patch
+
+from django.test import TestCase, tag
+
+from faker import Faker
+from openpyxl import Workbook
+
+from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError
+from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient
+from apps.parsers.clients.minpromtorg.manufactures import ManufacturesClient
+from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer
+from apps.parsers.clients.proverki import ProverkiClient
+from apps.parsers.clients.proverki.schemas import Inspection
+
+fake = Faker("ru_RU")
+
+
+class BaseHTTPClientTest(TestCase):
+ """Tests for BaseHTTPClient."""
+
+ def test_client_initialization(self):
+ """Test client initializes with defaults."""
+ client = BaseHTTPClient(base_url="https://example.com")
+
+ self.assertEqual(client.base_url, "https://example.com")
+ self.assertIsNone(client.proxies)
+ self.assertEqual(client.timeout, 30)
+
+ def test_client_with_proxies(self):
+ """Test client initializes with proxy list."""
+ proxies = ["http://proxy1:8080", "http://proxy2:8080"]
+ client = BaseHTTPClient(base_url="https://example.com", proxies=proxies)
+
+ self.assertEqual(client.proxies, proxies)
+
+ def test_select_proxy_returns_none_without_proxies(self):
+ """Test _select_proxy returns None when no proxies."""
+ client = BaseHTTPClient(base_url="https://example.com")
+ self.assertIsNone(client._select_proxy())
+
+ def test_select_proxy_returns_random_from_list(self):
+ """Test _select_proxy returns proxy from list."""
+ proxies = ["http://proxy1:8080", "http://proxy2:8080"]
+ client = BaseHTTPClient(base_url="https://example.com", proxies=proxies)
+
+ selected = client._select_proxy()
+ self.assertIn(selected, proxies)
+
+ def test_current_proxy_property(self):
+ """Test current_proxy property is None before session creation."""
+ proxies = ["http://proxy:8080"]
+ client = BaseHTTPClient(base_url="https://example.com", proxies=proxies)
+
+ # current_proxy is None until session is created
+ proxy = client.current_proxy
+ self.assertIsNone(proxy)
+
+ # After accessing session, proxy should be set
+ _ = client.session
+ proxy = client.current_proxy
+ self.assertEqual(proxy, "http://proxy:8080")
+
+
+def _create_test_excel_certificates() -> bytes:
+ """Create test Excel file with certificate data."""
+ wb = Workbook()
+ ws = wb.active
+
+ # Header
+ ws.append(
+ [
+ "issue_date",
+ "certificate_number",
+ "expiry_date",
+ "certificate_file_url",
+ "organisation_name",
+ "inn",
+ "ogrn",
+ ]
+ )
+
+ # Data rows
+ for i in range(5):
+ ws.append(
+ [
+ "2024-01-01",
+ f"CERT-{i:04d}",
+ "2025-01-01",
+ f"https://example.com/cert{i}.pdf",
+ f"Company {i} LLC",
+ f"123456789{i}",
+ f"123456789012{i}",
+ ]
+ )
+
+ output = BytesIO()
+ wb.save(output)
+ output.seek(0)
+ return output.read()
+
+
+def _create_test_excel_manufacturers() -> bytes:
+ """Create test Excel file with manufacturer data."""
+ wb = Workbook()
+ ws = wb.active
+
+ # Header
+ ws.append(["full_legal_name", "inn", "ogrn", "address"])
+
+ # Data rows
+ for i in range(5):
+ ws.append(
+ [
+ f"Manufacturer {i} LLC",
+ f"123456789{i}",
+ f"123456789012{i}",
+ f"Address {i}, City",
+ ]
+ )
+
+ output = BytesIO()
+ wb.save(output)
+ output.seek(0)
+ return output.read()
+
+
+class IndustrialProductionClientTest(TestCase):
+ """Tests for IndustrialProductionClient."""
+
+ def test_client_initialization(self):
+ """Test client initializes correctly."""
+ client = IndustrialProductionClient()
+
+ self.assertIsNone(client.proxies)
+ self.assertEqual(client.host, "minpromtorg.gov.ru")
+
+ def test_client_with_proxies(self):
+ """Test client accepts proxy list."""
+ proxies = ["http://proxy1:8080", "http://proxy2:8080"]
+ client = IndustrialProductionClient(proxies=proxies)
+
+ self.assertEqual(client.proxies, proxies)
+
+ def test_context_manager(self):
+ """Test client works as context manager."""
+ with IndustrialProductionClient() as client:
+ self.assertIsInstance(client, IndustrialProductionClient)
+
+ @patch.object(BaseHTTPClient, "get_json")
+ @patch.object(BaseHTTPClient, "download_file")
+ def test_fetch_certificates_success(self, mock_download, mock_get_json):
+ """Test successful certificate fetching."""
+ # Mock API response
+ mock_get_json.return_value = {
+ "data": [
+ {
+ "name": "Заключения о подтверждении производства промышленной продукции на территории Российской Федерации",
+ "files": [
+ {"name": "data_resolutions_20240101.xlsx", "url": "/files/test.xlsx"},
+ ],
+ }
+ ]
+ }
+
+ # Mock Excel download
+ mock_download.return_value = _create_test_excel_certificates()
+
+ with IndustrialProductionClient() as client:
+ certificates = client.fetch_certificates()
+
+ self.assertEqual(len(certificates), 5)
+ self.assertIsInstance(certificates[0], IndustrialCertificate)
+ self.assertEqual(certificates[0].certificate_number, "CERT-0000")
+
+ @patch.object(BaseHTTPClient, "get_json")
+ def test_fetch_certificates_no_files(self, mock_get_json):
+ """Test returns empty list when no files found."""
+ mock_get_json.return_value = {"data": []}
+
+ with IndustrialProductionClient() as client:
+ certificates = client.fetch_certificates()
+
+ self.assertEqual(certificates, [])
+
+ @patch.object(BaseHTTPClient, "get_json")
+ def test_get_latest_file_url_selects_newest(self, mock_get_json):
+ """Test selects file with latest date."""
+ mock_get_json.return_value = {
+ "data": [
+ {
+ "name": "Заключения о подтверждении производства промышленной продукции на территории Российской Федерации",
+ "files": [
+ {"name": "data_resolutions_20240101.xlsx", "url": "/files/old.xlsx"},
+ {"name": "data_resolutions_20240315.xlsx", "url": "/files/new.xlsx"},
+ {"name": "data_resolutions_20240201.xlsx", "url": "/files/mid.xlsx"},
+ ],
+ }
+ ]
+ }
+
+ client = IndustrialProductionClient()
+ files_data = client._fetch_files_list()
+ url = client._get_latest_file_url(files_data)
+
+ self.assertIn("new.xlsx", url)
+
+ def test_parse_row_valid(self):
+ """Test parsing valid row."""
+ client = IndustrialProductionClient()
+ row = (
+ "2024-01-01",
+ "CERT-123",
+ "2025-01-01",
+ "https://example.com/cert.pdf",
+ "Test Company",
+ "1234567890",
+ "1234567890123",
+ )
+
+ result = client._parse_row(row)
+
+ self.assertIsInstance(result, IndustrialCertificate)
+ self.assertEqual(result.certificate_number, "CERT-123")
+ self.assertEqual(result.inn, "1234567890")
+
+ def test_parse_row_invalid(self):
+ """Test parsing invalid row returns None."""
+ client = IndustrialProductionClient()
+ row = ("only", "two") # Not enough columns
+
+ result = client._parse_row(row)
+
+ self.assertIsNone(result)
+
+
+class ManufacturesClientTest(TestCase):
+ """Tests for ManufacturesClient."""
+
+ def test_client_initialization(self):
+ """Test client initializes correctly."""
+ client = ManufacturesClient()
+
+ self.assertIsNone(client.proxies)
+ self.assertEqual(client.host, "minpromtorg.gov.ru")
+
+ def test_client_with_proxies(self):
+ """Test client accepts proxy list."""
+ proxies = ["http://proxy1:8080", "http://proxy2:8080"]
+ client = ManufacturesClient(proxies=proxies)
+
+ self.assertEqual(client.proxies, proxies)
+
+ def test_context_manager(self):
+ """Test client works as context manager."""
+ with ManufacturesClient() as client:
+ self.assertIsInstance(client, ManufacturesClient)
+
+ @patch.object(BaseHTTPClient, "get_json")
+ @patch.object(BaseHTTPClient, "download_file")
+ def test_fetch_manufacturers_success(self, mock_download, mock_get_json):
+ """Test successful manufacturer fetching."""
+ # Mock API response
+ mock_get_json.return_value = {
+ "data": [
+ {
+ "name": "Производители промышленной продукции",
+ "files": [
+ {"name": "data_orgs_20240101.xlsx", "url": "/files/test.xlsx"},
+ ],
+ }
+ ]
+ }
+
+ # Mock Excel download
+ mock_download.return_value = _create_test_excel_manufacturers()
+
+ with ManufacturesClient() as client:
+ manufacturers = client.fetch_manufacturers()
+
+ self.assertEqual(len(manufacturers), 5)
+ self.assertIsInstance(manufacturers[0], Manufacturer)
+ self.assertEqual(manufacturers[0].full_legal_name, "Manufacturer 0 LLC")
+
+ @patch.object(BaseHTTPClient, "get_json")
+ def test_fetch_manufacturers_no_files(self, mock_get_json):
+ """Test returns empty list when no files found."""
+ mock_get_json.return_value = {"data": []}
+
+ with ManufacturesClient() as client:
+ manufacturers = client.fetch_manufacturers()
+
+ self.assertEqual(manufacturers, [])
+
+ @patch.object(BaseHTTPClient, "get_json")
+ def test_get_latest_file_url_selects_newest(self, mock_get_json):
+ """Test selects file with latest date."""
+ mock_get_json.return_value = {
+ "data": [
+ {
+ "name": "Производители промышленной продукции",
+ "files": [
+ {"name": "data_orgs_20240101.xlsx", "url": "/files/old.xlsx"},
+ {"name": "data_orgs_20240315.xlsx", "url": "/files/new.xlsx"},
+ {"name": "data_orgs_20240201.xlsx", "url": "/files/mid.xlsx"},
+ ],
+ }
+ ]
+ }
+
+ client = ManufacturesClient()
+ files_data = client._fetch_files_list()
+ url = client._get_latest_file_url(files_data)
+
+ self.assertIn("new.xlsx", url)
+
+ def test_parse_row_valid(self):
+ """Test parsing valid row."""
+ client = ManufacturesClient()
+ row = ("Test Company LLC", "1234567890", "1234567890123", "Test Address")
+
+ result = client._parse_row(row)
+
+ self.assertIsInstance(result, Manufacturer)
+ self.assertEqual(result.full_legal_name, "Test Company LLC")
+ self.assertEqual(result.inn, "1234567890")
+
+ def test_parse_row_without_address(self):
+ """Test parsing row without address."""
+ client = ManufacturesClient()
+ row = ("Test Company LLC", "1234567890", "1234567890123")
+
+ result = client._parse_row(row)
+
+ self.assertIsInstance(result, Manufacturer)
+ self.assertEqual(result.address, "")
+
+
+@tag("integration", "slow", "network")
+class IndustrialProductionClientIntegrationTest(TestCase):
+ """
+ Интеграционные тесты с реальной загрузкой данных.
+
+ ВНИМАНИЕ: Эти тесты делают реальные HTTP запросы к внешним серверам.
+ Запускать с тегом: python manage.py test --tag=integration
+ """
+
+ def test_fetch_certificates_real_data(self):
+ """
+ Интеграционный тест: реальная загрузка сертификатов с gisp.gov.ru.
+
+ Этот тест:
+ 1. Подключается к реальному API
+ 2. Скачивает Excel файл
+ 3. Парсит данные
+ 4. Проверяет структуру результата
+
+ Тест может занять время и зависит от доступности внешнего сервера.
+ """
+ try:
+ with IndustrialProductionClient(timeout=120) as client:
+ certificates = client.fetch_certificates()
+
+ # Проверяем что данные получены
+ self.assertIsInstance(certificates, list)
+
+ # Если данные есть - проверяем структуру
+ if certificates:
+ cert = certificates[0]
+ self.assertIsInstance(cert, IndustrialCertificate)
+ self.assertIsNotNone(cert.certificate_number)
+ self.assertIsNotNone(cert.inn)
+ self.assertIsNotNone(cert.organisation_name)
+
+ # Логируем для информации
+ print(f"\n[INTEGRATION] Loaded {len(certificates)} certificates")
+ print(f"[INTEGRATION] First certificate: {cert.certificate_number}")
+ print(f"[INTEGRATION] Organisation: {cert.organisation_name}")
+ else:
+ print("\n[INTEGRATION] No certificates found (API may be unavailable)")
+
+ except HTTPClientError as e:
+ # API может быть недоступен - это ожидаемое поведение для интеграционных тестов
+ self.skipTest(f"External API unavailable: {e}")
+
+
+@tag("integration", "slow", "network")
+class ManufacturesClientIntegrationTest(TestCase):
+ """
+ Интеграционные тесты для клиента производителей.
+
+ ВНИМАНИЕ: Эти тесты делают реальные HTTP запросы к внешним серверам.
+ Запускать с тегом: python manage.py test --tag=integration
+ """
+
+ def test_fetch_manufacturers_real_data(self):
+ """
+ Интеграционный тест: реальная загрузка производителей с gisp.gov.ru.
+ """
+ try:
+ with ManufacturesClient(timeout=120) as client:
+ manufacturers = client.fetch_manufacturers()
+
+ # Проверяем что данные получены
+ self.assertIsInstance(manufacturers, list)
+
+ # Если данные есть - проверяем структуру
+ if manufacturers:
+ m = manufacturers[0]
+ self.assertIsInstance(m, Manufacturer)
+ self.assertIsNotNone(m.full_legal_name)
+ self.assertIsNotNone(m.inn)
+
+ # Логируем для информации
+ print(f"\n[INTEGRATION] Loaded {len(manufacturers)} manufacturers")
+ print(f"[INTEGRATION] First manufacturer: {m.full_legal_name}")
+ print(f"[INTEGRATION] INN: {m.inn}")
+ else:
+ print("\n[INTEGRATION] No manufacturers found (API may be unavailable)")
+
+ except HTTPClientError as e:
+ # API может быть недоступен - это ожидаемое поведение для интеграционных тестов
+ self.skipTest(f"External API unavailable: {e}")
+
+
+def _create_test_xml_inspections() -> bytes:
+ """Create test XML file with inspection data."""
+ xml_content = """
+
+
+ 772024000001
+ 7701234567
+ 1027700000001
+ ООО "Тест Компания 1"
+ Роспотребнадзор
+ плановая
+ документарная
+ 2024-01-15
+ 2024-01-30
+ завершена
+ 294-ФЗ
+ нарушения не выявлены
+
+
+ 772024000002
+ 7702345678
+ 1027700000002
+ АО "Тест Компания 2"
+ Ростехнадзор
+ внеплановая
+ выездная
+ 2024-02-01
+ 2024-02-15
+ завершена
+ 248-ФЗ
+ выявлены нарушения
+
+"""
+ return xml_content.encode("utf-8")
+
+
+def _create_test_xml_inspections_russian_tags() -> bytes:
+ """Create test XML with Russian tag names."""
+ xml_content = """
+<Проверки>
+ <КНМ>
+ <УчетныйНомер>772024000003УчетныйНомер>
+ <ИНН>7703456789ИНН>
+ <ОГРН>1027700000003ОГРН>
+ <Наименование>ПАО "Тест Компания 3"Наименование>
+ <КонтрольныйОрган>МЧС РоссииКонтрольныйОрган>
+ <ТипПроверки>плановаяТипПроверки>
+ <ФормаПроверки>документарная и выезднаяФормаПроверки>
+ <ДатаНачала>2024-03-01ДатаНачала>
+ <ДатаОкончания>2024-03-20ДатаОкончания>
+ <Статус>в процессеСтатус>
+ <ПравовоеОснование>294-ФЗПравовоеОснование>
+ КНМ>
+Проверки>"""
+ return xml_content.encode("utf-8")
+
+
+class ProverkiClientTest(TestCase):
+ """Tests for ProverkiClient."""
+
+ def test_client_initialization(self):
+ """Test client initializes correctly."""
+ client = ProverkiClient()
+
+ self.assertIsNone(client.proxies)
+ self.assertEqual(client.host, "proverki.gov.ru")
+
+ def test_client_with_proxies(self):
+ """Test client accepts proxy list."""
+ proxies = ["http://proxy1:8080", "http://proxy2:8080"]
+ client = ProverkiClient(proxies=proxies)
+
+ self.assertEqual(client.proxies, proxies)
+
+ def test_context_manager(self):
+ """Test client works as context manager."""
+ with ProverkiClient() as client:
+ self.assertIsInstance(client, ProverkiClient)
+
+ def test_parse_xml_content_english_tags(self):
+ """Test parsing XML with English tag names."""
+ client = ProverkiClient()
+ xml_content = _create_test_xml_inspections()
+
+ inspections = client._parse_xml_content(xml_content, None)
+
+ self.assertEqual(len(inspections), 2)
+ self.assertIsInstance(inspections[0], Inspection)
+ self.assertEqual(inspections[0].registration_number, "772024000001")
+ self.assertEqual(inspections[0].inn, "7701234567")
+ self.assertEqual(inspections[0].organisation_name, 'ООО "Тест Компания 1"')
+ self.assertEqual(inspections[0].control_authority, "Роспотребнадзор")
+ self.assertEqual(inspections[0].inspection_type, "плановая")
+ self.assertEqual(inspections[0].legal_basis, "294-ФЗ")
+
+ def test_parse_xml_content_russian_tags(self):
+ """Test parsing XML with Russian tag names."""
+ client = ProverkiClient()
+ xml_content = _create_test_xml_inspections_russian_tags()
+
+ inspections = client._parse_xml_content(xml_content, None)
+
+ self.assertEqual(len(inspections), 1)
+ self.assertIsInstance(inspections[0], Inspection)
+ self.assertEqual(inspections[0].registration_number, "772024000003")
+ self.assertEqual(inspections[0].inn, "7703456789")
+ self.assertEqual(inspections[0].control_authority, "МЧС России")
+
+ def test_parse_xml_record_with_attributes(self):
+ """Test parsing XML record with attributes instead of child elements."""
+ from xml.etree import ElementTree as ET
+
+ client = ProverkiClient()
+ xml_str = ''
+ element = ET.fromstring(xml_str)
+
+ result = client._parse_xml_record(element)
+
+ self.assertIsNotNone(result)
+ self.assertEqual(result.inn, "1234567890")
+ self.assertEqual(result.registration_number, "TEST123")
+
+ def test_parse_xml_record_invalid(self):
+ """Test parsing invalid XML record returns None."""
+ from xml.etree import ElementTree as ET
+
+ client = ProverkiClient()
+ xml_str = ""
+ element = ET.fromstring(xml_str)
+
+ result = client._parse_xml_record(element)
+
+ self.assertIsNone(result)
+
+ def test_parse_windows_1251_encoding(self):
+ """Test parsing XML with Windows-1251 encoding."""
+ client = ProverkiClient()
+ xml_content = """
+
+
+ 1234567890
+ TEST001
+ Компания
+
+""".encode(
+ "windows-1251"
+ )
+
+ inspections = client._parse_xml_content(xml_content, None)
+
+ self.assertEqual(len(inspections), 1)
+ self.assertEqual(inspections[0].organisation_name, "Компания")
+
+ @patch.object(BaseHTTPClient, "download_file")
+ @patch.object(ProverkiClient, "_discover_data_files")
+ def test_fetch_inspections_with_file_url(self, mock_discover, mock_download):
+ """Test fetching inspections with direct file URL."""
+ mock_download.return_value = _create_test_xml_inspections()
+
+ with ProverkiClient() as client:
+ inspections = client.fetch_inspections(
+ file_url="https://proverki.gov.ru/opendata/test.xml"
+ )
+
+ self.assertEqual(len(inspections), 2)
+ mock_discover.assert_not_called() # Should not discover files when URL provided
+
+ @patch.object(ProverkiClient, "_discover_data_files")
+ def test_fetch_inspections_no_files(self, mock_discover):
+ """Test returns empty list when no files found."""
+ mock_discover.return_value = []
+
+ with ProverkiClient() as client:
+ inspections = client.fetch_inspections(year=2025)
+
+ self.assertEqual(inspections, [])
+
+
+@tag("integration", "slow", "network")
+class ProverkiClientIntegrationTest(TestCase):
+ """
+ Интеграционные тесты для клиента proverki.gov.ru.
+
+ ВНИМАНИЕ: Эти тесты делают реальные HTTP запросы к внешним серверам.
+ Запускать с тегом: python manage.py test --tag=integration
+ """
+
+ def test_fetch_inspections_real_data(self):
+ """
+ Интеграционный тест: реальная загрузка проверок с proverki.gov.ru.
+ """
+ try:
+ with ProverkiClient(timeout=120) as client:
+ inspections = client.fetch_inspections(year=2025)
+
+ # Проверяем что данные получены
+ self.assertIsInstance(inspections, list)
+
+ # Если данные есть - проверяем структуру
+ if inspections:
+ insp = inspections[0]
+ self.assertIsInstance(insp, Inspection)
+ self.assertIsNotNone(insp.registration_number)
+ self.assertIsNotNone(insp.inn)
+
+ # Логируем для информации
+ print(f"\n[INTEGRATION] Loaded {len(inspections)} inspections")
+ print(f"[INTEGRATION] First inspection: {insp.registration_number}")
+ print(f"[INTEGRATION] Organisation: {insp.organisation_name}")
+ print(f"[INTEGRATION] Control authority: {insp.control_authority}")
+ else:
+ print(
+ "\n[INTEGRATION] No inspections found "
+ "(API may be unavailable or data format changed)"
+ )
+
+ except HTTPClientError as e:
+ # API может быть недоступен
+ self.skipTest(f"External API unavailable: {e}")
diff --git a/tests/apps/parsers/test_models.py b/tests/apps/parsers/test_models.py
new file mode 100644
index 0000000..56c0e68
--- /dev/null
+++ b/tests/apps/parsers/test_models.py
@@ -0,0 +1,141 @@
+"""Tests for parsers models."""
+
+from django.test import TestCase
+
+from apps.parsers.models import (
+ IndustrialCertificateRecord,
+ ManufacturerRecord,
+ ParserLoadLog,
+ Proxy,
+)
+
+from .factories import (
+ IndustrialCertificateRecordFactory,
+ ManufacturerRecordFactory,
+ ParserLoadLogFactory,
+ ProxyFactory,
+)
+
+
+class ProxyModelTest(TestCase):
+ """Tests for Proxy model."""
+
+ def test_create_proxy(self):
+ """Test creating proxy record."""
+ proxy = ProxyFactory()
+
+ self.assertIsInstance(proxy, Proxy)
+ self.assertIn("://", proxy.address) # http://, https://, socks5://
+ self.assertTrue(proxy.is_active)
+ self.assertEqual(proxy.fail_count, 0)
+
+ def test_proxy_str(self):
+ """Test proxy string representation."""
+ proxy = ProxyFactory(address="http://proxy:8080", is_active=True)
+ self.assertEqual(str(proxy), "http://proxy:8080 (active)")
+
+ proxy.is_active = False
+ self.assertEqual(str(proxy), "http://proxy:8080 (inactive)")
+
+ def test_proxy_ordering(self):
+ """Test proxy ordering by fail_count."""
+ proxy1 = ProxyFactory(fail_count=5)
+ proxy2 = ProxyFactory(fail_count=0)
+ proxy3 = ProxyFactory(fail_count=10)
+
+ proxies = list(Proxy.objects.all())
+ self.assertEqual(proxies[0], proxy2) # fail_count=0
+ self.assertEqual(proxies[1], proxy1) # fail_count=5
+ self.assertEqual(proxies[2], proxy3) # fail_count=10
+
+ def test_proxy_unique_address(self):
+ """Test proxy address uniqueness."""
+ ProxyFactory(address="http://unique:8080")
+
+ from django.db import IntegrityError
+
+ with self.assertRaises(IntegrityError):
+ ProxyFactory(address="http://unique:8080")
+
+
+class ParserLoadLogModelTest(TestCase):
+ """Tests for ParserLoadLog model."""
+
+ def test_create_load_log(self):
+ """Test creating load log record."""
+ log = ParserLoadLogFactory()
+
+ self.assertIsInstance(log, ParserLoadLog)
+ self.assertIsNotNone(log.batch_id)
+ self.assertIn(log.source, [c[0] for c in ParserLoadLog.Source.choices])
+
+ def test_load_log_str(self):
+ """Test load log string representation."""
+ log = ParserLoadLogFactory(
+ batch_id=42, source=ParserLoadLog.Source.INDUSTRIAL, records_count=100
+ )
+ self.assertIn("42", str(log))
+ self.assertIn("100", str(log))
+
+ def test_load_log_timestamps(self):
+ """Test load log has timestamps from mixin."""
+ log = ParserLoadLogFactory()
+
+ self.assertIsNotNone(log.created_at)
+ self.assertIsNotNone(log.updated_at)
+
+
+class IndustrialCertificateRecordModelTest(TestCase):
+ """Tests for IndustrialCertificateRecord model."""
+
+ def test_create_certificate(self):
+ """Test creating certificate record."""
+ cert = IndustrialCertificateRecordFactory()
+
+ self.assertIsInstance(cert, IndustrialCertificateRecord)
+ self.assertIsNotNone(cert.certificate_number)
+ self.assertIsNotNone(cert.inn)
+ self.assertIsNotNone(cert.ogrn)
+
+ def test_certificate_str(self):
+ """Test certificate string representation."""
+ cert = IndustrialCertificateRecordFactory(
+ certificate_number="CERT-123", organisation_name="Test Company LLC"
+ )
+ self.assertIn("CERT-123", str(cert))
+ self.assertIn("Test Company", str(cert))
+
+ def test_certificate_timestamps(self):
+ """Test certificate has timestamps from mixin."""
+ cert = IndustrialCertificateRecordFactory()
+
+ self.assertIsNotNone(cert.created_at)
+ self.assertIsNotNone(cert.updated_at)
+
+
+class ManufacturerRecordModelTest(TestCase):
+ """Tests for ManufacturerRecord model."""
+
+ def test_create_manufacturer(self):
+ """Test creating manufacturer record."""
+ manufacturer = ManufacturerRecordFactory()
+
+ self.assertIsInstance(manufacturer, ManufacturerRecord)
+ self.assertIsNotNone(manufacturer.full_legal_name)
+ self.assertIsNotNone(manufacturer.inn)
+ self.assertIsNotNone(manufacturer.ogrn)
+
+ def test_manufacturer_str(self):
+ """Test manufacturer string representation."""
+ manufacturer = ManufacturerRecordFactory(
+ inn="1234567890", full_legal_name="Test Manufacturing Company"
+ )
+ self.assertIn("1234567890", str(manufacturer))
+ self.assertIn("Test Manufacturing", str(manufacturer))
+
+ def test_manufacturer_timestamps(self):
+ """Test manufacturer has timestamps from mixin."""
+ manufacturer = ManufacturerRecordFactory()
+
+ self.assertIsNotNone(manufacturer.created_at)
+ self.assertIsNotNone(manufacturer.updated_at)
diff --git a/tests/apps/parsers/test_services.py b/tests/apps/parsers/test_services.py
new file mode 100644
index 0000000..0d41de9
--- /dev/null
+++ b/tests/apps/parsers/test_services.py
@@ -0,0 +1,677 @@
+"""Tests for parsers services."""
+
+from django.test import TestCase
+
+from faker import Faker
+
+from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer
+from apps.parsers.clients.proverki.schemas import Inspection
+from apps.parsers.models import (
+ IndustrialCertificateRecord,
+ InspectionRecord,
+ ManufacturerRecord,
+ ParserLoadLog,
+ Proxy,
+)
+from apps.parsers.services import (
+ IndustrialCertificateService,
+ InspectionService,
+ ManufacturerService,
+ ParserLoadLogService,
+ ProxyService,
+)
+
+from .factories import (
+ IndustrialCertificateRecordFactory,
+ InspectionRecordFactory,
+ ManufacturerRecordFactory,
+ ParserLoadLogFactory,
+ ProxyFactory,
+)
+
+fake = Faker("ru_RU")
+
+
+class ProxyServiceTest(TestCase):
+ """Tests for ProxyService."""
+
+ def test_get_active_proxies_empty(self):
+ """Test getting active proxies when none exist."""
+ proxies = ProxyService.get_active_proxies()
+ self.assertEqual(proxies, [])
+
+ def test_get_active_proxies_with_data(self):
+ """Test getting active proxies returns only active ones."""
+ proxy1 = ProxyFactory(is_active=True)
+ proxy2 = ProxyFactory(is_active=True)
+ ProxyFactory(is_active=False) # Inactive - should not be returned
+
+ proxies = ProxyService.get_active_proxies()
+
+ self.assertEqual(len(proxies), 2)
+ self.assertIn(proxy1.address, proxies)
+ self.assertIn(proxy2.address, proxies)
+
+ def test_get_active_proxies_or_none_empty(self):
+ """Test get_active_proxies_or_none returns None when no proxies."""
+ result = ProxyService.get_active_proxies_or_none()
+ self.assertIsNone(result)
+
+ def test_get_active_proxies_or_none_with_data(self):
+ """Test get_active_proxies_or_none returns list when proxies exist."""
+ ProxyFactory(is_active=True)
+
+ result = ProxyService.get_active_proxies_or_none()
+
+ self.assertIsNotNone(result)
+ self.assertEqual(len(result), 1)
+
+ def test_mark_used(self):
+ """Test marking proxy as used updates timestamp."""
+ proxy = ProxyFactory()
+ self.assertIsNone(proxy.last_used_at)
+
+ ProxyService.mark_used(proxy.address)
+
+ proxy.refresh_from_db()
+ self.assertIsNotNone(proxy.last_used_at)
+
+ def test_mark_failed(self):
+ """Test marking proxy as failed increases fail count."""
+ proxy = ProxyFactory(fail_count=0)
+
+ ProxyService.mark_failed(proxy.address)
+
+ proxy.refresh_from_db()
+ self.assertEqual(proxy.fail_count, 1)
+
+ def test_deactivate(self):
+ """Test deactivating proxy."""
+ proxy = ProxyFactory(is_active=True)
+
+ ProxyService.deactivate(proxy.address)
+
+ proxy.refresh_from_db()
+ self.assertFalse(proxy.is_active)
+
+ def test_add_proxy(self):
+ """Test adding new proxy."""
+ address = "http://new-proxy:8080"
+ description = "Test proxy"
+
+ proxy = ProxyService.add_proxy(address, description)
+
+ self.assertEqual(proxy.address, address)
+ self.assertEqual(proxy.description, description)
+ self.assertTrue(proxy.is_active)
+
+ def test_add_proxy_idempotent(self):
+ """Test adding existing proxy returns existing record."""
+ address = "http://existing:8080"
+ existing = ProxyFactory(address=address, description="Original")
+
+ proxy = ProxyService.add_proxy(address, "New description")
+
+ self.assertEqual(proxy.id, existing.id)
+ self.assertEqual(proxy.description, "Original") # Not updated
+
+ def test_add_proxies(self):
+ """Test bulk adding proxies."""
+ addresses = [
+ "http://proxy1:8080",
+ "http://proxy2:8080",
+ "http://proxy3:8080",
+ ]
+
+ created = ProxyService.add_proxies(addresses)
+
+ self.assertEqual(created, 3)
+ self.assertEqual(Proxy.objects.count(), 3)
+
+ def test_add_proxies_skips_existing(self):
+ """Test bulk add skips existing proxies."""
+ ProxyFactory(address="http://existing:8080")
+ addresses = [
+ "http://existing:8080", # Already exists
+ "http://new:8080",
+ ]
+
+ created = ProxyService.add_proxies(addresses)
+
+ self.assertEqual(created, 1)
+ self.assertEqual(Proxy.objects.count(), 2)
+
+
+class ParserLoadLogServiceTest(TestCase):
+ """Tests for ParserLoadLogService."""
+
+ def test_get_next_batch_id_first(self):
+ """Test getting first batch_id for new source."""
+ batch_id = ParserLoadLogService.get_next_batch_id(ParserLoadLog.Source.INDUSTRIAL)
+ self.assertEqual(batch_id, 1)
+
+ def test_get_next_batch_id_increment(self):
+ """Test batch_id increments correctly."""
+ ParserLoadLogFactory(batch_id=5, source=ParserLoadLog.Source.INDUSTRIAL)
+ ParserLoadLogFactory(batch_id=3, source=ParserLoadLog.Source.INDUSTRIAL)
+
+ batch_id = ParserLoadLogService.get_next_batch_id(ParserLoadLog.Source.INDUSTRIAL)
+ self.assertEqual(batch_id, 6)
+
+ def test_get_next_batch_id_per_source(self):
+ """Test batch_id is tracked per source."""
+ ParserLoadLogFactory(batch_id=10, source=ParserLoadLog.Source.INDUSTRIAL)
+ ParserLoadLogFactory(batch_id=5, source=ParserLoadLog.Source.MANUFACTURES)
+
+ industrial_batch = ParserLoadLogService.get_next_batch_id(
+ ParserLoadLog.Source.INDUSTRIAL
+ )
+ manufactures_batch = ParserLoadLogService.get_next_batch_id(
+ ParserLoadLog.Source.MANUFACTURES
+ )
+
+ self.assertEqual(industrial_batch, 11)
+ self.assertEqual(manufactures_batch, 6)
+
+ def test_create_load_log(self):
+ """Test creating load log."""
+ log = ParserLoadLogService.create_load_log(
+ source=ParserLoadLog.Source.INDUSTRIAL,
+ batch_id=1,
+ records_count=100,
+ status="success",
+ )
+
+ self.assertIsInstance(log, ParserLoadLog)
+ self.assertEqual(log.source, ParserLoadLog.Source.INDUSTRIAL)
+ self.assertEqual(log.batch_id, 1)
+ self.assertEqual(log.records_count, 100)
+ self.assertEqual(log.status, "success")
+
+ def test_mark_failed(self):
+ """Test marking log as failed."""
+ log = ParserLoadLogFactory(status="success")
+
+ ParserLoadLogService.mark_failed(log, "Connection error")
+
+ log.refresh_from_db()
+ self.assertEqual(log.status, "failed")
+ self.assertEqual(log.error_message, "Connection error")
+
+ def test_update_records_count(self):
+ """Test updating records count."""
+ log = ParserLoadLogFactory(records_count=0)
+
+ ParserLoadLogService.update_records_count(log, 250)
+
+ log.refresh_from_db()
+ self.assertEqual(log.records_count, 250)
+
+
+class IndustrialCertificateServiceTest(TestCase):
+ """Tests for IndustrialCertificateService."""
+
+ def test_save_certificates_empty(self):
+ """Test saving empty list returns 0."""
+ count = IndustrialCertificateService.save_certificates([], batch_id=1)
+ self.assertEqual(count, 0)
+
+ def test_save_certificates(self):
+ """Test saving certificates from dataclass."""
+ certificates = [
+ IndustrialCertificate(
+ issue_date="2024-01-01",
+ certificate_number=f"CERT-{i}",
+ expiry_date="2025-01-01",
+ certificate_file_url=f"https://example.com/cert{i}.pdf",
+ organisation_name=f"Company {i}",
+ inn=f"123456789{i}",
+ ogrn=f"123456789012{i}",
+ )
+ for i in range(5)
+ ]
+
+ count = IndustrialCertificateService.save_certificates(certificates, batch_id=1)
+
+ self.assertEqual(count, 5)
+ self.assertEqual(IndustrialCertificateRecord.objects.count(), 5)
+
+ def test_save_certificates_with_chunk_size(self):
+ """Test saving certificates in chunks."""
+ certificates = [
+ IndustrialCertificate(
+ issue_date="2024-01-01",
+ certificate_number=f"CERT-{i}",
+ expiry_date="2025-01-01",
+ certificate_file_url=f"https://example.com/cert{i}.pdf",
+ organisation_name=f"Company {i}",
+ inn=f"12345678{i:02d}",
+ ogrn=f"1234567890{i:03d}",
+ )
+ for i in range(10)
+ ]
+
+ count = IndustrialCertificateService.save_certificates(
+ certificates, batch_id=1, chunk_size=3
+ )
+
+ self.assertEqual(count, 10)
+
+ def test_find_by_inn(self):
+ """Test finding certificates by INN."""
+ IndustrialCertificateRecordFactory(
+ inn="1111111111", certificate_number="CERT-A1", load_batch=1
+ )
+ IndustrialCertificateRecordFactory(
+ inn="1111111111", certificate_number="CERT-A2", load_batch=2
+ )
+ IndustrialCertificateRecordFactory(
+ inn="2222222222", certificate_number="CERT-B1", load_batch=1
+ )
+
+ results = IndustrialCertificateService.find_by_inn("1111111111")
+ self.assertEqual(results.count(), 2)
+
+ results_batch1 = IndustrialCertificateService.find_by_inn("1111111111", batch_id=1)
+ self.assertEqual(results_batch1.count(), 1)
+
+ def test_find_by_certificate_number(self):
+ """Test finding certificate by number."""
+ IndustrialCertificateRecordFactory(certificate_number="CERT-UNIQUE")
+ IndustrialCertificateRecordFactory(certificate_number="CERT-OTHER")
+
+ results = IndustrialCertificateService.find_by_certificate_number("CERT-UNIQUE")
+ self.assertEqual(results.count(), 1)
+
+ def test_save_certificates_deduplication(self):
+ """Test saving certificates skips duplicates by certificate_number."""
+ # Create initial certificate
+ initial = [
+ IndustrialCertificate(
+ issue_date="2024-01-01",
+ certificate_number="CERT-DEDUP-001",
+ expiry_date="2025-01-01",
+ certificate_file_url="https://example.com/old.pdf",
+ organisation_name="Old Company Name",
+ inn="1234567890",
+ ogrn="1234567890123",
+ )
+ ]
+ count1 = IndustrialCertificateService.save_certificates(initial, batch_id=1)
+ self.assertEqual(count1, 1)
+ self.assertEqual(IndustrialCertificateRecord.objects.count(), 1)
+
+ # Try to save with same certificate_number - should be skipped
+ duplicate = [
+ IndustrialCertificate(
+ issue_date="2024-06-01",
+ certificate_number="CERT-DEDUP-001", # Same number - will be skipped
+ expiry_date="2026-01-01",
+ certificate_file_url="https://example.com/new.pdf",
+ organisation_name="New Company Name",
+ inn="9999999999",
+ ogrn="9999999999999",
+ )
+ ]
+ count2 = IndustrialCertificateService.save_certificates(duplicate, batch_id=2)
+
+ # Should still be 1 record (duplicate skipped)
+ self.assertEqual(IndustrialCertificateRecord.objects.count(), 1)
+
+ # Verify original data preserved
+ record = IndustrialCertificateRecord.objects.first()
+ self.assertEqual(record.organisation_name, "Old Company Name")
+ self.assertEqual(record.inn, "1234567890")
+ self.assertEqual(record.load_batch, 1) # Original batch
+
+
+class ManufacturerServiceTest(TestCase):
+ """Tests for ManufacturerService."""
+
+ def test_save_manufacturers_empty(self):
+ """Test saving empty list returns 0."""
+ count = ManufacturerService.save_manufacturers([], batch_id=1)
+ self.assertEqual(count, 0)
+
+ def test_save_manufacturers(self):
+ """Test saving manufacturers from dataclass."""
+ manufacturers = [
+ Manufacturer(
+ full_legal_name=f"Company {i} LLC",
+ inn=f"123456789{i}",
+ ogrn=f"123456789012{i}",
+ address=f"Address {i}",
+ )
+ for i in range(5)
+ ]
+
+ count = ManufacturerService.save_manufacturers(manufacturers, batch_id=1)
+
+ self.assertEqual(count, 5)
+ self.assertEqual(ManufacturerRecord.objects.count(), 5)
+
+ def test_save_manufacturers_with_chunk_size(self):
+ """Test saving manufacturers in chunks."""
+ manufacturers = [
+ Manufacturer(
+ full_legal_name=f"Company {i}",
+ inn=f"12345678{i:02d}",
+ ogrn=f"1234567890{i:03d}",
+ address=f"Address {i}",
+ )
+ for i in range(10)
+ ]
+
+ count = ManufacturerService.save_manufacturers(
+ manufacturers, batch_id=1, chunk_size=3
+ )
+
+ self.assertEqual(count, 10)
+
+ def test_find_by_inn(self):
+ """Test finding manufacturers by INN."""
+ ManufacturerRecordFactory(inn="1111111111", load_batch=1)
+ ManufacturerRecordFactory(inn="2222222222", load_batch=1)
+ ManufacturerRecordFactory(inn="3333333333", load_batch=2)
+
+ results = ManufacturerService.find_by_inn("1111111111")
+ self.assertEqual(results.count(), 1)
+
+ def test_find_by_inn_with_batch_filter(self):
+ """Test finding manufacturers by INN with batch filter."""
+ ManufacturerRecordFactory(inn="4444444444", load_batch=1)
+ ManufacturerRecordFactory(inn="5555555555", load_batch=2)
+
+ results_batch1 = ManufacturerService.find_by_inn("4444444444", batch_id=1)
+ self.assertEqual(results_batch1.count(), 1)
+
+ results_batch2 = ManufacturerService.find_by_inn("4444444444", batch_id=2)
+ self.assertEqual(results_batch2.count(), 0)
+
+ def test_find_by_ogrn(self):
+ """Test finding manufacturers by OGRN."""
+ ManufacturerRecordFactory(ogrn="1234567890123")
+ ManufacturerRecordFactory(ogrn="9999999999999")
+
+ results = ManufacturerService.find_by_ogrn("1234567890123")
+ self.assertEqual(results.count(), 1)
+
+ def test_save_manufacturers_deduplication(self):
+ """Test saving manufacturers skips duplicates by INN."""
+ # Create initial manufacturer
+ initial = [
+ Manufacturer(
+ full_legal_name="Old Company Name LLC",
+ inn="7777777777",
+ ogrn="1234567890123",
+ address="Old Address",
+ )
+ ]
+ count1 = ManufacturerService.save_manufacturers(initial, batch_id=1)
+ self.assertEqual(count1, 1)
+ self.assertEqual(ManufacturerRecord.objects.count(), 1)
+
+ # Try to save with same INN - should be skipped
+ duplicate = [
+ Manufacturer(
+ full_legal_name="New Company Name LLC",
+ inn="7777777777", # Same INN - will be skipped
+ ogrn="9999999999999",
+ address="New Address",
+ )
+ ]
+ count2 = ManufacturerService.save_manufacturers(duplicate, batch_id=2)
+
+ # Should still be 1 record (duplicate skipped)
+ self.assertEqual(ManufacturerRecord.objects.count(), 1)
+
+ # Verify original data preserved
+ record = ManufacturerRecord.objects.first()
+ self.assertEqual(record.full_legal_name, "Old Company Name LLC")
+ self.assertEqual(record.ogrn, "1234567890123")
+ self.assertEqual(record.address, "Old Address")
+ self.assertEqual(record.load_batch, 1) # Original batch
+
+
+class InspectionServiceTest(TestCase):
+ """Tests for InspectionService."""
+
+ def test_save_inspections_empty(self):
+ """Test saving empty list returns 0."""
+ count = InspectionService.save_inspections([], batch_id=1)
+ self.assertEqual(count, 0)
+
+ def test_save_inspections(self):
+ """Test saving inspections from dataclass."""
+ inspections = [
+ Inspection(
+ registration_number=f"77202400000{i}",
+ inn=f"770{i}234567",
+ ogrn=f"102770000000{i}",
+ organisation_name=f"Компания {i}",
+ control_authority="Роспотребнадзор",
+ inspection_type="плановая",
+ inspection_form="документарная",
+ start_date="2024-01-15",
+ end_date="2024-01-30",
+ status="завершена",
+ legal_basis="294-ФЗ",
+ result="нарушения не выявлены",
+ )
+ for i in range(5)
+ ]
+
+ count = InspectionService.save_inspections(inspections, batch_id=1)
+
+ self.assertEqual(count, 5)
+ self.assertEqual(InspectionRecord.objects.count(), 5)
+
+ def test_save_inspections_with_chunk_size(self):
+ """Test saving inspections in chunks."""
+ inspections = [
+ Inspection(
+ registration_number=f"7720240000{i:02d}",
+ inn=f"770{i:02d}34567",
+ ogrn=f"10277000000{i:02d}",
+ organisation_name=f"Компания {i}",
+ control_authority="Ростехнадзор",
+ inspection_type="внеплановая",
+ inspection_form="выездная",
+ start_date="2024-02-01",
+ end_date="2024-02-15",
+ status="завершена",
+ legal_basis="248-ФЗ",
+ )
+ for i in range(10)
+ ]
+
+ count = InspectionService.save_inspections(
+ inspections, batch_id=1, chunk_size=3
+ )
+
+ self.assertEqual(count, 10)
+
+ def test_find_by_inn(self):
+ """Test finding inspections by INN."""
+ InspectionRecordFactory(inn="1111111111", load_batch=1)
+ InspectionRecordFactory(inn="1111111111", load_batch=2)
+ InspectionRecordFactory(inn="2222222222", load_batch=1)
+
+ results = InspectionService.find_by_inn("1111111111")
+ self.assertEqual(results.count(), 2)
+
+ results_batch1 = InspectionService.find_by_inn("1111111111", batch_id=1)
+ self.assertEqual(results_batch1.count(), 1)
+
+ def test_find_by_registration_number(self):
+ """Test finding inspection by registration number."""
+ InspectionRecordFactory(registration_number="772024000001")
+ InspectionRecordFactory(registration_number="772024000002")
+
+ results = InspectionService.find_by_registration_number("772024000001")
+ self.assertEqual(results.count(), 1)
+
+ def test_find_by_control_authority(self):
+ """Test finding inspections by control authority."""
+ InspectionRecordFactory(control_authority="Роспотребнадзор", load_batch=1)
+ InspectionRecordFactory(
+ control_authority="Управление Роспотребнадзора по г. Москве", load_batch=1
+ )
+ InspectionRecordFactory(control_authority="Ростехнадзор", load_batch=1)
+
+ results = InspectionService.find_by_control_authority("Роспотребнадзор")
+ self.assertEqual(results.count(), 2)
+
+ results_batch1 = InspectionService.find_by_control_authority(
+ "Роспотребнадзор", batch_id=1
+ )
+ self.assertEqual(results_batch1.count(), 2)
+
+ def test_save_inspections_deduplication(self):
+ """Test saving inspections skips duplicates by registration_number."""
+ # Create initial inspection
+ initial = [
+ Inspection(
+ registration_number="DEDUP-REG-001",
+ inn="1234567890",
+ ogrn="1234567890123",
+ organisation_name="Old Organisation",
+ control_authority="Роспотребнадзор",
+ inspection_type="плановая",
+ inspection_form="документарная",
+ start_date="2024-01-01",
+ end_date="2024-01-15",
+ status="завершена",
+ legal_basis="294-ФЗ",
+ result="нарушения не выявлены",
+ )
+ ]
+ count1 = InspectionService.save_inspections(initial, batch_id=1)
+ self.assertEqual(count1, 1)
+ self.assertEqual(InspectionRecord.objects.count(), 1)
+
+ # Try to save with same registration_number - should be skipped
+ duplicate = [
+ Inspection(
+ registration_number="DEDUP-REG-001", # Same number - will be skipped
+ inn="9999999999",
+ ogrn="9999999999999",
+ organisation_name="New Organisation",
+ control_authority="Ростехнадзор",
+ inspection_type="внеплановая",
+ inspection_form="выездная",
+ start_date="2024-06-01",
+ end_date="2024-06-30",
+ status="в процессе",
+ legal_basis="248-ФЗ",
+ result="выявлены нарушения",
+ )
+ ]
+ count2 = InspectionService.save_inspections(duplicate, batch_id=2)
+
+ # Should still be 1 record (duplicate skipped)
+ self.assertEqual(InspectionRecord.objects.count(), 1)
+
+ # Verify original data preserved
+ record = InspectionRecord.objects.first()
+ self.assertEqual(record.organisation_name, "Old Organisation")
+ self.assertEqual(record.inn, "1234567890")
+ self.assertEqual(record.control_authority, "Роспотребнадзор")
+ self.assertEqual(record.status, "завершена")
+ self.assertEqual(record.load_batch, 1) # Original batch
+
+
+from django.test import tag
+
+from apps.parsers.clients.base import HTTPClientError
+from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient
+
+
+@tag("integration", "slow", "network", "e2e")
+class EndToEndIntegrationTest(TestCase):
+ """
+ End-to-end интеграционные тесты полного flow.
+
+ Тестирует: Загрузка с API -> Парсинг -> Сохранение в БД -> Проверка.
+ Запуск: uv run python run_tests.py tests.apps.parsers.test_services.EndToEndIntegrationTest
+ """
+
+ def test_full_flow_fetch_and_save_certificates(self):
+ """
+ Полный E2E тест: загрузка сертификатов и сохранение в БД.
+
+ 1. Загружаем данные с реального API
+ 2. Создаём лог загрузки
+ 3. Сохраняем первые N записей в БД
+ 4. Проверяем что данные корректно сохранились
+ """
+ try:
+ # 1. Загружаем данные с API
+ print("\n[E2E] Step 1: Fetching certificates from API...")
+ with IndustrialProductionClient(timeout=120) as client:
+ all_certificates = client.fetch_certificates()
+
+ if not all_certificates:
+ self.skipTest("No certificates returned from API")
+
+ print(f"[E2E] Loaded {len(all_certificates)} certificates from API")
+
+ # Берём только первые 100 для теста
+ certificates = all_certificates[:100]
+
+ # 2. Создаём batch_id и лог
+ print("[E2E] Step 2: Creating load log...")
+ batch_id = ParserLoadLogService.get_next_batch_id(
+ ParserLoadLog.Source.INDUSTRIAL
+ )
+ log = ParserLoadLogService.create_load_log(
+ source=ParserLoadLog.Source.INDUSTRIAL,
+ batch_id=batch_id,
+ records_count=0,
+ )
+ print(f"[E2E] Created batch_id={batch_id}")
+
+ # 3. Сохраняем в БД
+ print("[E2E] Step 3: Saving certificates to database...")
+ saved_count = IndustrialCertificateService.save_certificates(
+ certificates, batch_id=batch_id
+ )
+ ParserLoadLogService.update_records_count(log, saved_count)
+
+ print(f"[E2E] Saved {saved_count} certificates")
+
+ # 4. Проверяем результат
+ print("[E2E] Step 4: Verifying saved data...")
+
+ # Проверяем количество
+ db_count = IndustrialCertificateRecord.objects.filter(
+ load_batch=batch_id
+ ).count()
+ self.assertEqual(db_count, saved_count)
+ self.assertEqual(db_count, len(certificates))
+
+ # Проверяем первую запись
+ first_cert = certificates[0]
+ db_record = IndustrialCertificateRecord.objects.filter(
+ load_batch=batch_id,
+ certificate_number=first_cert.certificate_number,
+ ).first()
+
+ self.assertIsNotNone(db_record)
+ self.assertEqual(db_record.inn, first_cert.inn)
+ self.assertEqual(db_record.ogrn, first_cert.ogrn)
+ self.assertEqual(db_record.organisation_name, first_cert.organisation_name)
+
+ # Проверяем лог
+ log.refresh_from_db()
+ self.assertEqual(log.records_count, saved_count)
+ self.assertEqual(log.status, "success")
+
+ print("[E2E] ✅ All checks passed!")
+ print(f"[E2E] Sample record: {db_record.certificate_number}")
+ print(f"[E2E] Organisation: {db_record.organisation_name}")
+ print(f"[E2E] INN: {db_record.inn}, OGRN: {db_record.ogrn}")
+
+ except HTTPClientError as e:
+ self.skipTest(f"External API unavailable: {e}")
+