diff --git a/CHANGELOG.md b/CHANGELOG.md
index bdb5690..942147a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,57 @@
---
+## [0.4.0] - 2026-01-28
+
+### Добавлено
+
+#### Парсер ФНС бухгалтерской отчетности (`apps.parsers.clients.fns`)
+- **FNSExcelParser** — парсер Excel файлов бухгалтерской отчетности:
+ - Формат файла: `fin_{external_id}_{ogrn}.xlsx`
+ - Поддержка форм: №1 (Баланс), №2 (Прибыль/Убыток), №3 (Капитал), №4 (Денежные потоки), №6 (Целевое использование)
+ - Автоматическое определение года и формы по структуре листа
+ - Извлечение значений period_start/period_end для каждой строки
+
+- **Модели** (`models.py`):
+ - `FinancialReport` — отчет с метаданными (external_id, ogrn, file_hash, status, source)
+ - `FinancialReportLine` — строки отчета (form_code, line_code, year, period_start, period_end)
+ - SHA256 хэш файла для дедупликации
+ - Индексы: ogrn, year, form_code+line_code
+
+- **Сервисный слой** (`services.py`):
+ - `FNSReportService` — сохранение отчетов, проверка дубликатов по хешу
+ - Поиск по ОГРН
+ - Bulk-сохранение строк отчета
+
+- **Celery задачи** (`tasks.py`):
+ - `scan_fns_directory` — периодическое сканирование папки каждые 5 минут
+ - `process_fns_file` — обработка одного файла
+ - `process_fns_files_batch` — пакетная обработка через API
+ - Перемещение файлов в `processed/` или `failed/`
+
+- **API endpoints** (`views.py`, `urls.py`):
+ - `POST /api/v1/fns/upload/` — пакетная загрузка файлов
+ - `GET /api/v1/fns/reports/` — список отчетов с фильтрацией
+ - `GET /api/v1/fns/reports/{id}/` — детали отчета со строками
+ - Swagger теги для группировки в документации
+
+- **Админка** (`admin.py`):
+ - `FinancialReportAdmin` с inline для строк
+ - Цветовая индикация статусов
+ - Фильтры: status, source, ogrn
+
+#### Тестирование
+- 25 unit-тестов для парсера, схем и сервиса
+- Покрытие: валидация имени файла, парсинг значений, сохранение отчетов
+
+### Конфигурация
+- `FNS_WATCH_DIRECTORY` — папка для мониторинга (`/src/input/fns`)
+- `FNS_PROCESSED_DIRECTORY` — папка обработанных файлов
+- `FNS_FAILED_DIRECTORY` — папка с ошибками
+- Celery Beat: `scan-fns-directory` каждые 5 минут
+
+---
+
## [0.3.0] - 2026-01-27
### Добавлено
diff --git a/src/apps/core/views.py b/src/apps/core/views.py
index 056376c..16bc377 100644
--- a/src/apps/core/views.py
+++ b/src/apps/core/views.py
@@ -13,6 +13,7 @@ from typing import Any
from django.conf import settings
from django.db import connection
+from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
@@ -21,29 +22,29 @@ from rest_framework.views import APIView
logger = logging.getLogger(__name__)
+# Swagger теги
+HEALTH_TAG = "Мониторинг"
+JOBS_TAG = "Фоновые задачи"
+
class HealthCheckView(APIView):
"""
- Comprehensive health check endpoint.
+ Комплексная проверка состояния системы.
- GET /api/health/
- Returns detailed status of all dependencies.
-
- Response:
- {
- "status": "healthy" | "degraded" | "unhealthy",
- "version": "1.0.0",
- "checks": {
- "database": {"status": "up", "latency_ms": 5},
- "redis": {"status": "up", "latency_ms": 2},
- "celery": {"status": "up"}
- }
- }
+ Возвращает статус всех зависимостей (БД, Redis, Celery).
"""
permission_classes = [AllowAny]
authentication_classes = [] # No auth required
+ @swagger_auto_schema(
+ tags=[HEALTH_TAG],
+ operation_summary="Проверка состояния",
+ operation_description=(
+ "Комплексная проверка всех зависимостей системы.\n"
+ "Возвращает статус: healthy, degraded или unhealthy."
+ ),
+ )
def get(self, request: Request) -> Response:
"""Run all health checks and return status."""
checks = {}
@@ -131,15 +132,19 @@ class HealthCheckView(APIView):
class LivenessView(APIView):
"""
- Kubernetes liveness probe endpoint.
+ Kubernetes liveness probe.
- GET /api/health/live/
- Returns 200 if the application is running.
+ Проверяет, запущено ли приложение.
"""
permission_classes = [AllowAny]
authentication_classes = []
+ @swagger_auto_schema(
+ tags=[HEALTH_TAG],
+ operation_summary="Liveness probe",
+ operation_description="Возвращает 200 если приложение запущено.",
+ )
def get(self, request: Request) -> Response:
"""Simple liveness check."""
return Response({"status": "alive"}, status=status.HTTP_200_OK)
@@ -147,15 +152,21 @@ class LivenessView(APIView):
class ReadinessView(APIView):
"""
- Kubernetes readiness probe endpoint.
+ Kubernetes readiness probe.
- GET /api/health/ready/
- Returns 200 if the application is ready to serve traffic.
+ Проверяет, готово ли приложение обрабатывать запросы.
"""
permission_classes = [AllowAny]
authentication_classes = []
+ @swagger_auto_schema(
+ tags=[HEALTH_TAG],
+ operation_summary="Readiness probe",
+ operation_description=(
+ "Возвращает 200 если приложение готово обрабатывать запросы."
+ ),
+ )
def get(self, request: Request) -> Response:
"""Check if app is ready to serve traffic."""
# Check database connection
@@ -177,26 +188,21 @@ class BackgroundJobStatusView(APIView):
"""
Получение статуса фоновой задачи.
- GET /api/v1/jobs/{task_id}/
Возвращает статус, прогресс и результат задачи.
-
- Response:
- {
- "id": "uuid",
- "task_id": "celery-task-id",
- "status": "pending|started|success|failure|revoked",
- "progress": 75,
- "progress_message": "Обработка данных...",
- "result": {...},
- "error": "",
- "is_finished": false
- }
"""
from rest_framework.permissions import IsAuthenticated
permission_classes = [IsAuthenticated]
+ @swagger_auto_schema(
+ tags=[JOBS_TAG],
+ operation_summary="Статус задачи",
+ operation_description=(
+ "Возвращает статус конкретной фоновой задачи.\n"
+ "Доступно только владельцу задачи или администратору."
+ ),
+ )
def get(self, request: Request, task_id: str) -> Response:
"""Получить статус задачи по task_id."""
from apps.core.serializers import BackgroundJobSerializer
@@ -219,18 +225,21 @@ class BackgroundJobListView(APIView):
"""
Список фоновых задач пользователя.
- GET /api/v1/jobs/
- Возвращает список задач текущего пользователя.
-
- Query params:
- status: Фильтр по статусу (pending, started, success, failure)
- limit: Количество записей (по умолчанию 50)
+ Возвращает список задач текущего пользователя с фильтрацией.
"""
from rest_framework.permissions import IsAuthenticated
permission_classes = [IsAuthenticated]
+ @swagger_auto_schema(
+ tags=[JOBS_TAG],
+ operation_summary="Список задач",
+ operation_description=(
+ "Возвращает список фоновых задач текущего пользователя.\n"
+ "Поддерживает фильтрацию по статусу (status) и лимит (limit)."
+ ),
+ )
def get(self, request: Request) -> Response:
"""Получить список задач пользователя."""
from apps.core.serializers import BackgroundJobListSerializer
diff --git a/src/apps/parsers/admin.py b/src/apps/parsers/admin.py
index cc8f9bd..a41ff0a 100644
--- a/src/apps/parsers/admin.py
+++ b/src/apps/parsers/admin.py
@@ -3,6 +3,8 @@ Admin configuration for parsers app.
"""
from apps.parsers.models import (
+ FinancialReport,
+ FinancialReportLine,
IndustrialCertificateRecord,
InspectionRecord,
ManufacturerRecord,
@@ -520,3 +522,107 @@ class ProcurementRecordAdmin(admin.ModelAdmin):
def has_change_permission(self, request, obj=None):
"""Запретить редактирование записей."""
return False
+
+
+class FinancialReportLineInline(admin.TabularInline):
+ """Inline для строк финансового отчета."""
+
+ model = FinancialReportLine
+ extra = 0
+ readonly_fields = [
+ "form_code",
+ "line_code",
+ "line_name",
+ "year",
+ "period_start",
+ "period_end",
+ ]
+ can_delete = False
+
+ def has_add_permission(self, request, obj=None):
+ return False
+
+
+@admin.register(FinancialReport)
+class FinancialReportAdmin(admin.ModelAdmin):
+ """Admin для финансовых отчетов ФНС."""
+
+ list_display = [
+ "external_id",
+ "ogrn",
+ "file_name",
+ "status_badge",
+ "source",
+ "lines_count",
+ "load_batch",
+ "created_at",
+ ]
+ list_filter = ["status", "source", "load_batch", "created_at"]
+ search_fields = ["external_id", "ogrn", "file_name"]
+ readonly_fields = [
+ "external_id",
+ "ogrn",
+ "file_name",
+ "file_hash",
+ "load_batch",
+ "status",
+ "source",
+ "error_message",
+ "created_at",
+ "updated_at",
+ ]
+ ordering = ["-created_at"]
+ list_per_page = 50
+ date_hierarchy = "created_at"
+ inlines = [FinancialReportLineInline]
+
+ fieldsets = (
+ (
+ "Основное",
+ {"fields": ("external_id", "ogrn", "file_name", "file_hash")},
+ ),
+ (
+ "Статус",
+ {"fields": ("status", "source", "error_message")},
+ ),
+ (
+ "Системное",
+ {
+ "fields": ("load_batch", "created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+
+ def lines_count(self, obj):
+ """Количество строк в отчете."""
+ return obj.lines.count()
+
+ lines_count.short_description = "Строк"
+
+ def status_badge(self, obj):
+ """Цветной бейдж статуса."""
+ colors = {
+ "pending": "#6c757d",
+ "processing": "#ffc107",
+ "success": "#28a745",
+ "failed": "#dc3545",
+ }
+ 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 has_add_permission(self, request):
+ """Запретить создание записей вручную."""
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ """Запретить редактирование записей."""
+ return False
diff --git a/src/apps/parsers/clients/fns/__init__.py b/src/apps/parsers/clients/fns/__init__.py
new file mode 100644
index 0000000..7383ac3
--- /dev/null
+++ b/src/apps/parsers/clients/fns/__init__.py
@@ -0,0 +1,10 @@
+"""
+Парсер бухгалтерской отчетности ФНС.
+
+Обрабатывает Excel файлы формата fin_{external_id}_{ogrn}.xlsx.
+"""
+
+from apps.parsers.clients.fns.parser import FNSExcelParser
+from apps.parsers.clients.fns.schemas import ParsedReport, ReportLine
+
+__all__ = ["FNSExcelParser", "ParsedReport", "ReportLine"]
diff --git a/src/apps/parsers/clients/fns/parser.py b/src/apps/parsers/clients/fns/parser.py
new file mode 100644
index 0000000..90cca8b
--- /dev/null
+++ b/src/apps/parsers/clients/fns/parser.py
@@ -0,0 +1,208 @@
+"""
+Парсер Excel файлов бухгалтерской отчетности ФНС.
+"""
+
+import logging
+import re
+from pathlib import Path
+
+import openpyxl
+from apps.parsers.clients.fns.schemas import ParsedReport, ReportLine
+
+logger = logging.getLogger(__name__)
+
+
+class FNSParserError(Exception):
+ """Ошибка парсинга файла ФНС."""
+
+ pass
+
+
+class FNSExcelParser:
+ """
+ Парсер Excel файлов бухгалтерской отчетности.
+
+ Обрабатывает файлы формата fin_{external_id}_{ogrn}.xlsx.
+ Извлекает данные из форм №1, №2, №3, №4, №6.
+ """
+
+ FILENAME_PATTERN = re.compile(r"^fin_(\d+)_(\d{13,15})\.xlsx$")
+
+ FORM_MARKERS = {
+ "Форма №1": "1",
+ "Форма №2": "2",
+ "Форма №3": "3",
+ "Форма №4": "4",
+ "Форма №6": "6",
+ }
+
+ @classmethod
+ def parse_filename(cls, filename: str) -> tuple[str, str]:
+ """
+ Извлекает external_id и ogrn из имени файла.
+
+ Args:
+ filename: Имя файла (например: fin_0000605_1027700169089.xlsx)
+
+ Returns:
+ Кортеж (external_id, ogrn)
+
+ Raises:
+ FNSParserError: Если имя файла не соответствует формату
+ """
+ match = cls.FILENAME_PATTERN.match(filename)
+ if not match:
+ raise FNSParserError(
+ f"Имя файла не соответствует формату "
+ f"fin_{{id}}_{{ogrn}}.xlsx: {filename}"
+ )
+ return match.group(1), match.group(2)
+
+ @classmethod
+ def parse_file(cls, file_path: Path | str) -> ParsedReport:
+ """
+ Парсит Excel файл и возвращает структурированные данные.
+
+ Args:
+ file_path: Путь к файлу
+
+ Returns:
+ ParsedReport с данными отчетности
+
+ Raises:
+ FNSParserError: При ошибке парсинга
+ """
+ file_path = Path(file_path)
+ if not file_path.exists():
+ raise FNSParserError(f"Файл не найден: {file_path}")
+
+ external_id, ogrn = cls.parse_filename(file_path.name)
+
+ logger.info(
+ "Парсинг файла %s (external_id=%s, ogrn=%s)",
+ file_path.name,
+ external_id,
+ ogrn,
+ )
+
+ try:
+ workbook = openpyxl.load_workbook(file_path, data_only=True)
+ except Exception as e:
+ raise FNSParserError(f"Ошибка открытия файла: {e}") from e
+
+ lines: list[ReportLine] = []
+
+ for sheet_name in workbook.sheetnames:
+ sheet = workbook[sheet_name]
+ sheet_lines = cls._parse_sheet(sheet)
+ lines.extend(sheet_lines)
+ logger.debug("Лист '%s': извлечено %d строк", sheet_name, len(sheet_lines))
+
+ workbook.close()
+
+ logger.info(
+ "Файл %s обработан: %d строк, годы %s, формы %s",
+ file_path.name,
+ len(lines),
+ sorted({line.year for line in lines}),
+ sorted({line.form_code for line in lines}),
+ )
+
+ return ParsedReport(external_id=external_id, ogrn=ogrn, lines=lines)
+
+ @classmethod
+ def _parse_sheet(cls, sheet) -> list[ReportLine]: # noqa: C901
+ """Парсит один лист Excel."""
+ lines: list[ReportLine] = []
+ current_form: str | None = None
+ years: list[int] = []
+ header_row_found = False
+
+ for row in sheet.iter_rows(values_only=True):
+ if not any(row):
+ continue
+
+ first_cell = str(row[0]).strip() if row[0] else ""
+
+ # Ищем маркер формы
+ for marker, form_code in cls.FORM_MARKERS.items():
+ if first_cell.startswith(marker):
+ current_form = form_code
+ years = cls._extract_years_from_row(row)
+ header_row_found = False
+ logger.debug("Найдена форма %s, годы: %s", form_code, years)
+ break
+
+ # Пропускаем заголовочную строку с "Код", "Начало", "Конец"
+ is_header = len(row) > 1 and row[1] == "Код"
+ if current_form and not header_row_found and is_header:
+ header_row_found = True
+ continue
+
+ # Парсим строки данных
+ if current_form and header_row_found and years:
+ line_name = first_cell
+ line_code = str(row[1]).strip() if row[1] else ""
+
+ # Пропускаем строки без кода или заголовки секций
+ if not line_code or not line_code.isdigit():
+ continue
+
+ # Извлекаем значения по годам
+ for year_idx, year in enumerate(years):
+ col_start = 2 + year_idx * 2 # Начало: 2, 4, 6, 8
+ col_end = 3 + year_idx * 2 # Конец: 3, 5, 7, 9
+
+ period_start = cls._parse_value(
+ row[col_start] if col_start < len(row) else None
+ )
+ period_end = cls._parse_value(
+ row[col_end] if col_end < len(row) else None
+ )
+
+ # Добавляем строку только если есть хотя бы одно значение
+ if period_start is not None or period_end is not None:
+ lines.append(
+ ReportLine(
+ form_code=current_form,
+ line_code=line_code,
+ line_name=line_name,
+ year=year,
+ period_start=period_start,
+ period_end=period_end,
+ )
+ )
+
+ return lines
+
+ @classmethod
+ def _extract_years_from_row(cls, row: tuple) -> list[int]:
+ """Извлекает годы из строки заголовка формы."""
+ years = []
+ for cell in row:
+ if cell is None:
+ continue
+ try:
+ value = int(cell)
+ if 1990 <= value <= 2100:
+ years.append(value)
+ except (ValueError, TypeError):
+ continue
+ return sorted(set(years))
+
+ @classmethod
+ def _parse_value(cls, value) -> int | None:
+ """Преобразует значение ячейки в int или None."""
+ if value is None:
+ return None
+ if isinstance(value, int | float):
+ return int(value)
+ if isinstance(value, str):
+ value = value.strip()
+ if not value or value == "-":
+ return None
+ try:
+ return int(float(value.replace(",", ".").replace(" ", "")))
+ except ValueError:
+ return None
+ return None
diff --git a/src/apps/parsers/clients/fns/schemas.py b/src/apps/parsers/clients/fns/schemas.py
new file mode 100644
index 0000000..a3c10fc
--- /dev/null
+++ b/src/apps/parsers/clients/fns/schemas.py
@@ -0,0 +1,61 @@
+"""
+Схемы данных для парсера бухгалтерской отчетности ФНС.
+"""
+
+from dataclasses import dataclass, field
+
+
+@dataclass
+class ReportLine:
+ """
+ Строка бухгалтерской отчетности.
+
+ Attributes:
+ form_code: Код формы (1, 2, 3, 4, 6)
+ line_code: Код строки (например: 1100, 2110)
+ line_name: Наименование строки
+ year: Отчетный год
+ period_start: Значение на начало периода (тыс. руб.), None если пусто
+ period_end: Значение на конец периода (тыс. руб.), None если пусто
+ """
+
+ form_code: str
+ line_code: str
+ line_name: str
+ year: int
+ period_start: int | None = None
+ period_end: int | None = None
+
+
+@dataclass
+class ParsedReport:
+ """
+ Результат парсинга Excel файла отчетности.
+
+ Attributes:
+ external_id: Внешний ID из имени файла
+ ogrn: ОГРН организации
+ lines: Список строк отчетности
+ """
+
+ external_id: str
+ ogrn: str
+ lines: list[ReportLine] = field(default_factory=list)
+
+ @property
+ def years(self) -> set[int]:
+ """Получить все годы, представленные в отчете."""
+ return {line.year for line in self.lines}
+
+ @property
+ def forms(self) -> set[str]:
+ """Получить все формы, представленные в отчете."""
+ return {line.form_code for line in self.lines}
+
+ def get_lines_by_form(self, form_code: str) -> list[ReportLine]:
+ """Получить строки по коду формы."""
+ return [line for line in self.lines if line.form_code == form_code]
+
+ def get_lines_by_year(self, year: int) -> list[ReportLine]:
+ """Получить строки по году."""
+ return [line for line in self.lines if line.year == year]
diff --git a/src/apps/parsers/clients/zakupki/__init__.py b/src/apps/parsers/clients/zakupki/__init__.py
index 099811b..98da392 100644
--- a/src/apps/parsers/clients/zakupki/__init__.py
+++ b/src/apps/parsers/clients/zakupki/__init__.py
@@ -457,9 +457,7 @@ class ZakupkiClient:
if progress_callback:
progress_callback(95, f"Загружено {len(all_procurements)} закупок")
- logger.info(
- "Total fetched %d procurements via HTTP", len(all_procurements)
- )
+ logger.info("Total fetched %d procurements via HTTP", len(all_procurements))
return all_procurements
def _discover_data_files(
diff --git a/src/apps/parsers/migrations/0007_add_fns_models.py b/src/apps/parsers/migrations/0007_add_fns_models.py
new file mode 100644
index 0000000..b5fdf84
--- /dev/null
+++ b/src/apps/parsers/migrations/0007_add_fns_models.py
@@ -0,0 +1,79 @@
+# Generated by Django 3.2.25 on 2026-02-01 12:59
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('parsers', '0006_add_procurement_model'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FinancialReport',
+ 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='обновлено')),
+ ('external_id', models.CharField(db_index=True, help_text='Уникальный идентификатор из имени файла', max_length=50, unique=True, verbose_name='внешний ID')),
+ ('ogrn', models.CharField(db_index=True, help_text='ОГРН организации', max_length=15, verbose_name='ОГРН')),
+ ('file_name', models.CharField(help_text='Оригинальное имя загруженного файла', max_length=255, verbose_name='имя файла')),
+ ('file_hash', models.CharField(help_text='SHA256 хеш файла для дедупликации', max_length=64, unique=True, verbose_name='хеш файла')),
+ ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='ID пакета загрузки')),
+ ('status', models.CharField(choices=[('pending', 'Ожидает обработки'), ('processing', 'Обрабатывается'), ('success', 'Успешно'), ('failed', 'Ошибка')], db_index=True, default='pending', help_text='Статус обработки файла', max_length=20, verbose_name='статус')),
+ ('error_message', models.TextField(blank=True, help_text='Текст ошибки при неудачной обработке', verbose_name='сообщение об ошибке')),
+ ('source', models.CharField(choices=[('file_watch', 'Мониторинг папки'), ('api', 'API загрузка')], help_text='Источник загрузки файла', max_length=20, verbose_name='источник')),
+ ],
+ options={
+ 'verbose_name': 'финансовый отчет',
+ 'verbose_name_plural': 'финансовые отчеты',
+ 'db_table': 'parsers_financial_report',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.AlterField(
+ model_name='parserloadlog',
+ name='source',
+ field=models.CharField(choices=[('industrial', 'Промышленное производство'), ('manufactures', 'Реестр производителей'), ('inspections', 'Единый реестр проверок'), ('procurements', 'Государственные закупки'), ('fns_reports', 'Бухгалтерская отчетность ФНС')], db_index=True, help_text='Источник данных', max_length=50, verbose_name='источник'),
+ ),
+ migrations.CreateModel(
+ name='FinancialReportLine',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('form_code', models.CharField(db_index=True, help_text='Номер формы отчетности (1, 2, 3, 4, 6)', max_length=10, verbose_name='код формы')),
+ ('line_code', models.CharField(db_index=True, help_text='Код строки отчетности (например: 1100, 2110)', max_length=10, verbose_name='код строки')),
+ ('line_name', models.CharField(help_text='Наименование строки отчетности', max_length=255, verbose_name='наименование строки')),
+ ('year', models.PositiveSmallIntegerField(db_index=True, help_text='Отчетный год', verbose_name='год')),
+ ('period_start', models.BigIntegerField(blank=True, help_text='Значение на начало периода (тыс. руб.)', null=True, verbose_name='на начало периода')),
+ ('period_end', models.BigIntegerField(blank=True, help_text='Значение на конец периода (тыс. руб.)', null=True, verbose_name='на конец периода')),
+ ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='parsers.financialreport', verbose_name='отчет')),
+ ],
+ options={
+ 'verbose_name': 'строка финансового отчета',
+ 'verbose_name_plural': 'строки финансовых отчетов',
+ 'db_table': 'parsers_financial_report_line',
+ },
+ ),
+ migrations.AddIndex(
+ model_name='financialreport',
+ index=models.Index(fields=['ogrn', 'status'], name='parsers_fin_ogrn_defc61_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='financialreport',
+ index=models.Index(fields=['load_batch', 'status'], name='parsers_fin_load_ba_c3516a_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='financialreportline',
+ index=models.Index(fields=['report', 'form_code', 'line_code'], name='parsers_fin_report__867450_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='financialreportline',
+ index=models.Index(fields=['year', 'line_code'], name='parsers_fin_year_b93f1e_idx'),
+ ),
+ migrations.AddConstraint(
+ model_name='financialreportline',
+ constraint=models.UniqueConstraint(fields=('report', 'form_code', 'line_code', 'year'), name='unique_report_line_year'),
+ ),
+ ]
diff --git a/src/apps/parsers/models.py b/src/apps/parsers/models.py
index 7f9b0d9..2448f7d 100644
--- a/src/apps/parsers/models.py
+++ b/src/apps/parsers/models.py
@@ -21,6 +21,7 @@ class ParserLoadLog(TimestampMixin, models.Model):
MANUFACTURES = "manufactures", _("Реестр производителей")
INSPECTIONS = "inspections", _("Единый реестр проверок")
PROCUREMENTS = "procurements", _("Государственные закупки")
+ FNS_REPORTS = "fns_reports", _("Бухгалтерская отчетность ФНС")
batch_id = models.PositiveIntegerField(
_("ID пакета"),
@@ -506,3 +507,153 @@ class ProcurementRecord(TimestampMixin, models.Model):
def __str__(self) -> str:
name = self.purchase_name[:50] if self.purchase_name else ""
return f"{self.purchase_number} - {name}"
+
+
+class FinancialReport(TimestampMixin, models.Model):
+ """
+ Бухгалтерская отчетность организации из ФНС.
+
+ Данные загружаются из Excel файлов, полученных от ФНС.
+ Имя файла: fin_{external_id}_{ogrn}.xlsx
+ """
+
+ class Status(models.TextChoices):
+ PENDING = "pending", _("Ожидает обработки")
+ PROCESSING = "processing", _("Обрабатывается")
+ SUCCESS = "success", _("Успешно")
+ FAILED = "failed", _("Ошибка")
+
+ class SourceType(models.TextChoices):
+ FILE_WATCH = "file_watch", _("Мониторинг папки")
+ API = "api", _("API загрузка")
+
+ external_id = models.CharField(
+ _("внешний ID"),
+ max_length=50,
+ unique=True,
+ db_index=True,
+ help_text=_("Уникальный идентификатор из имени файла"),
+ )
+ ogrn = models.CharField(
+ _("ОГРН"),
+ max_length=15,
+ db_index=True,
+ help_text=_("ОГРН организации"),
+ )
+ file_name = models.CharField(
+ _("имя файла"),
+ max_length=255,
+ help_text=_("Оригинальное имя загруженного файла"),
+ )
+ file_hash = models.CharField(
+ _("хеш файла"),
+ max_length=64,
+ unique=True,
+ help_text=_("SHA256 хеш файла для дедупликации"),
+ )
+ load_batch = models.PositiveIntegerField(
+ _("ID пакета загрузки"),
+ db_index=True,
+ help_text=_("Идентификатор пакета загрузки"),
+ )
+ status = models.CharField(
+ _("статус"),
+ max_length=20,
+ choices=Status.choices,
+ default=Status.PENDING,
+ db_index=True,
+ help_text=_("Статус обработки файла"),
+ )
+ error_message = models.TextField(
+ _("сообщение об ошибке"),
+ blank=True,
+ help_text=_("Текст ошибки при неудачной обработке"),
+ )
+ source = models.CharField(
+ _("источник"),
+ max_length=20,
+ choices=SourceType.choices,
+ help_text=_("Источник загрузки файла"),
+ )
+
+ class Meta:
+ db_table = "parsers_financial_report"
+ verbose_name = _("финансовый отчет")
+ verbose_name_plural = _("финансовые отчеты")
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["ogrn", "status"]),
+ models.Index(fields=["load_batch", "status"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.external_id} (ОГРН: {self.ogrn}) - {self.status}"
+
+
+class FinancialReportLine(models.Model):
+ """
+ Строка бухгалтерской отчетности.
+
+ Хранит значения по конкретной строке формы отчетности за конкретный год.
+ Формы: №1 (Баланс), №2 (ОФР), №3 (Изменение капитала),
+ №4 (Движение денежных средств), №6 (Целевое использование).
+ """
+
+ report = models.ForeignKey(
+ FinancialReport,
+ on_delete=models.CASCADE,
+ related_name="lines",
+ verbose_name=_("отчет"),
+ )
+ form_code = models.CharField(
+ _("код формы"),
+ max_length=10,
+ db_index=True,
+ help_text=_("Номер формы отчетности (1, 2, 3, 4, 6)"),
+ )
+ line_code = models.CharField(
+ _("код строки"),
+ max_length=10,
+ db_index=True,
+ help_text=_("Код строки отчетности (например: 1100, 2110)"),
+ )
+ line_name = models.CharField(
+ _("наименование строки"),
+ max_length=255,
+ help_text=_("Наименование строки отчетности"),
+ )
+ year = models.PositiveSmallIntegerField(
+ _("год"),
+ db_index=True,
+ help_text=_("Отчетный год"),
+ )
+ period_start = models.BigIntegerField(
+ _("на начало периода"),
+ null=True,
+ blank=True,
+ help_text=_("Значение на начало периода (тыс. руб.)"),
+ )
+ period_end = models.BigIntegerField(
+ _("на конец периода"),
+ null=True,
+ blank=True,
+ help_text=_("Значение на конец периода (тыс. руб.)"),
+ )
+
+ class Meta:
+ db_table = "parsers_financial_report_line"
+ verbose_name = _("строка финансового отчета")
+ verbose_name_plural = _("строки финансовых отчетов")
+ indexes = [
+ models.Index(fields=["report", "form_code", "line_code"]),
+ models.Index(fields=["year", "line_code"]),
+ ]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["report", "form_code", "line_code", "year"],
+ name="unique_report_line_year",
+ ),
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.line_code} ({self.line_name[:30]}) - {self.year}"
diff --git a/src/apps/parsers/serializers.py b/src/apps/parsers/serializers.py
index 0d53405..c914d1f 100644
--- a/src/apps/parsers/serializers.py
+++ b/src/apps/parsers/serializers.py
@@ -1,7 +1,294 @@
"""
Сериализаторы для приложения парсеров.
-TODO: Добавить сериализаторы по мере необходимости.
+Все сериализаторы read-only, так как данные загружаются только через парсеры.
"""
-# Сериализаторы будут добавлены по мере разработки конкретных парсеров
+from apps.parsers.models import (
+ FinancialReport,
+ FinancialReportLine,
+ IndustrialCertificateRecord,
+ InspectionRecord,
+ ManufacturerRecord,
+ ParserLoadLog,
+ ProcurementRecord,
+ Proxy,
+)
+from rest_framework import serializers
+
+# =============================================================================
+# Минпромторг - Сертификаты промышленного производства
+# =============================================================================
+
+
+class IndustrialCertificateSerializer(serializers.ModelSerializer):
+ """
+ Сертификат промышленного производства РФ.
+
+ Данные загружаются из Минпромторга.
+ """
+
+ class Meta:
+ model = IndustrialCertificateRecord
+ fields = [
+ "id",
+ "load_batch",
+ "issue_date",
+ "certificate_number",
+ "expiry_date",
+ "certificate_file_url",
+ "organisation_name",
+ "inn",
+ "ogrn",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = fields
+
+
+# =============================================================================
+# Минпромторг - Реестр производителей
+# =============================================================================
+
+
+class ManufacturerSerializer(serializers.ModelSerializer):
+ """
+ Производитель из реестра Минпромторга.
+
+ Данные загружаются из Минпромторга.
+ """
+
+ class Meta:
+ model = ManufacturerRecord
+ fields = [
+ "id",
+ "load_batch",
+ "full_legal_name",
+ "inn",
+ "ogrn",
+ "address",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = fields
+
+
+# =============================================================================
+# Единый реестр проверок (proverki.gov.ru)
+# =============================================================================
+
+
+class InspectionSerializer(serializers.ModelSerializer):
+ """
+ Проверка из Единого реестра проверок.
+
+ Поддерживает ФЗ-294 и ФЗ-248.
+ """
+
+ class Meta:
+ model = InspectionRecord
+ fields = [
+ "id",
+ "load_batch",
+ "registration_number",
+ "inn",
+ "ogrn",
+ "organisation_name",
+ "control_authority",
+ "inspection_type",
+ "inspection_form",
+ "start_date",
+ "end_date",
+ "status",
+ "legal_basis",
+ "result",
+ "is_federal_law_248",
+ "data_year",
+ "data_month",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = fields
+
+
+# =============================================================================
+# Государственные закупки (zakupki.gov.ru)
+# =============================================================================
+
+
+class ProcurementSerializer(serializers.ModelSerializer):
+ """
+ Государственная закупка из ЕИС zakupki.gov.ru.
+
+ Поддерживает 44-ФЗ и 223-ФЗ.
+ """
+
+ class Meta:
+ model = ProcurementRecord
+ fields = [
+ "id",
+ "load_batch",
+ "purchase_number",
+ "purchase_name",
+ "customer_inn",
+ "customer_kpp",
+ "customer_ogrn",
+ "customer_name",
+ "max_price",
+ "currency_code",
+ "placement_method",
+ "publish_date",
+ "end_date",
+ "status",
+ "law_type",
+ "purchase_object_info",
+ "href",
+ "region_code",
+ "data_year",
+ "data_month",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = fields
+
+
+# =============================================================================
+# ФНС - Бухгалтерская отчетность
+# =============================================================================
+
+
+class FinancialReportLineSerializer(serializers.ModelSerializer):
+ """Строка финансового отчета."""
+
+ class Meta:
+ model = FinancialReportLine
+ fields = [
+ "id",
+ "form_code",
+ "line_code",
+ "line_name",
+ "year",
+ "period_start",
+ "period_end",
+ ]
+
+
+class FinancialReportSerializer(serializers.ModelSerializer):
+ """
+ Финансовый отчет ФНС.
+
+ Данные загружаются из Excel файлов.
+ """
+
+ lines_count = serializers.SerializerMethodField()
+
+ class Meta:
+ model = FinancialReport
+ fields = [
+ "id",
+ "external_id",
+ "ogrn",
+ "file_name",
+ "file_hash",
+ "load_batch",
+ "status",
+ "source",
+ "error_message",
+ "created_at",
+ "updated_at",
+ "lines_count",
+ ]
+ read_only_fields = fields
+
+ def get_lines_count(self, obj) -> int:
+ return obj.lines.count()
+
+
+class FinancialReportDetailSerializer(FinancialReportSerializer):
+ """Финансовый отчет с детализацией строк."""
+
+ lines = FinancialReportLineSerializer(many=True, read_only=True)
+
+ class Meta(FinancialReportSerializer.Meta):
+ fields = FinancialReportSerializer.Meta.fields + ["lines"]
+
+
+class FNSFileUploadSerializer(serializers.Serializer):
+ """
+ Сериализатор для загрузки файлов FNS.
+
+ Принимает список Excel файлов в формате fin_{id}_{ogrn}.xlsx
+ """
+
+ files = serializers.ListField(
+ child=serializers.FileField(),
+ allow_empty=False,
+ help_text="Список файлов для загрузки (fin_*.xlsx)",
+ )
+
+ def validate_files(self, value):
+ """Валидация файлов."""
+ import re
+
+ pattern = re.compile(r"^fin_\d+_\d{13,15}\.xlsx$")
+
+ for file in value:
+ if not pattern.match(file.name):
+ raise serializers.ValidationError(
+ f"Неверный формат имени файла: {file.name}. "
+ "Ожидается: fin_{{id}}_{{ogrn}}.xlsx"
+ )
+
+ return value
+
+
+# =============================================================================
+# Служебные модели
+# =============================================================================
+
+
+class ParserLoadLogSerializer(serializers.ModelSerializer):
+ """
+ Лог загрузки парсера.
+
+ Информация о каждой загрузке данных из внешнего источника.
+ """
+
+ source_display = serializers.CharField(source="get_source_display", read_only=True)
+
+ class Meta:
+ model = ParserLoadLog
+ fields = [
+ "id",
+ "batch_id",
+ "source",
+ "source_display",
+ "records_count",
+ "status",
+ "error_message",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = fields
+
+
+class ProxySerializer(serializers.ModelSerializer):
+ """
+ Прокси-сервер для парсеров.
+
+ Используется для обхода блокировок при парсинге.
+ """
+
+ class Meta:
+ model = Proxy
+ fields = [
+ "id",
+ "address",
+ "is_active",
+ "last_used_at",
+ "fail_count",
+ "description",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = fields
diff --git a/src/apps/parsers/services.py b/src/apps/parsers/services.py
index e728817..51b4a4a 100644
--- a/src/apps/parsers/services.py
+++ b/src/apps/parsers/services.py
@@ -11,6 +11,8 @@ from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manu
from apps.parsers.clients.proverki.schemas import Inspection
from apps.parsers.clients.zakupki.schemas import Procurement
from apps.parsers.models import (
+ FinancialReport,
+ FinancialReportLine,
IndustrialCertificateRecord,
InspectionRecord,
ManufacturerRecord,
@@ -731,3 +733,122 @@ class ProcurementService(BulkOperationsMixin, BaseService[ProcurementRecord]):
if batch_id:
qs = qs.filter(load_batch=batch_id)
return qs
+
+
+class FNSReportService(BulkOperationsMixin, BaseService[FinancialReport]):
+ """
+ Сервис для работы с бухгалтерской отчетностью ФНС.
+
+ Отвечает за:
+ - Сохранение отчетов и строк из парсера
+ - Дедупликацию по хешу файла
+ - Поиск отчетов по ОГРН/external_id
+ """
+
+ model = FinancialReport
+
+ @classmethod
+ @transaction.atomic
+ def save_report(
+ cls,
+ *,
+ external_id: str,
+ ogrn: str,
+ file_name: str,
+ file_hash: str,
+ source: str,
+ batch_id: int,
+ lines_data: list[dict],
+ ) -> FinancialReport:
+ """
+ Сохранить отчет и все его строки.
+
+ Args:
+ external_id: Внешний ID из имени файла
+ ogrn: ОГРН организации
+ file_name: Имя файла
+ file_hash: SHA256 хеш файла
+ source: Источник загрузки (file_watch/api)
+ batch_id: ID пакета загрузки
+ lines_data: Список словарей с данными строк
+
+ Returns:
+ Созданный FinancialReport
+ """
+ logger.info(
+ "Сохранение отчета external_id=%s, ogrn=%s, lines=%d",
+ external_id,
+ ogrn,
+ len(lines_data),
+ )
+
+ report = cls.create(
+ external_id=external_id,
+ ogrn=ogrn,
+ file_name=file_name,
+ file_hash=file_hash,
+ source=source,
+ load_batch=batch_id,
+ status=FinancialReport.Status.SUCCESS,
+ )
+
+ if lines_data:
+ line_instances = [
+ FinancialReportLine(
+ report=report,
+ form_code=line["form_code"],
+ line_code=line["line_code"],
+ line_name=line["line_name"],
+ year=line["year"],
+ period_start=line.get("period_start"),
+ period_end=line.get("period_end"),
+ )
+ for line in lines_data
+ ]
+ FinancialReportLine.objects.bulk_create(
+ line_instances, ignore_conflicts=True
+ )
+ logger.info("Сохранено %d строк отчета", len(line_instances))
+
+ return report
+
+ @classmethod
+ def exists_by_hash(cls, file_hash: str) -> bool:
+ """Проверить, существует ли отчет с таким хешем."""
+ return cls.model.objects.filter(file_hash=file_hash).exists()
+
+ @classmethod
+ def exists_by_external_id(cls, external_id: str) -> bool:
+ """Проверить, существует ли отчет с таким external_id."""
+ return cls.model.objects.filter(external_id=external_id).exists()
+
+ @classmethod
+ def find_by_ogrn(cls, ogrn: str):
+ """Найти все отчеты по ОГРН."""
+ return cls.filter(ogrn=ogrn)
+
+ @classmethod
+ def find_by_external_id(cls, external_id: str):
+ """Найти отчет по external_id."""
+ return cls.filter(external_id=external_id).first()
+
+ @classmethod
+ def mark_processing(cls, report: FinancialReport) -> FinancialReport:
+ """Отметить отчет как обрабатываемый."""
+ return cls.update(report, status=FinancialReport.Status.PROCESSING)
+
+ @classmethod
+ def mark_success(cls, report: FinancialReport) -> FinancialReport:
+ """Отметить отчет как успешно обработанный."""
+ return cls.update(report, status=FinancialReport.Status.SUCCESS)
+
+ @classmethod
+ def mark_failed(
+ cls, report: FinancialReport, error_message: str
+ ) -> FinancialReport:
+ """Отметить отчет как неудавшийся."""
+ return cls.update(
+ report,
+ status=FinancialReport.Status.FAILED,
+ error_message=error_message,
+ )
diff --git a/src/apps/parsers/tasks.py b/src/apps/parsers/tasks.py
index 4b6edd4..4d134e0 100644
--- a/src/apps/parsers/tasks.py
+++ b/src/apps/parsers/tasks.py
@@ -17,6 +17,7 @@ from apps.parsers.clients.proverki import ProverkiClient
from apps.parsers.clients.zakupki import ZakupkiClient
from apps.parsers.models import ParserLoadLog
from apps.parsers.services import (
+ FNSReportService,
IndustrialCertificateService,
InspectionService,
ManufacturerService,
@@ -908,3 +909,262 @@ def sync_procurements(
"status": "failed",
"error": str(e),
}
+
+
+# =============================================================================
+# FNS Tasks (File Watch & Processing)
+# =============================================================================
+
+
+@shared_task(bind=True)
+def scan_fns_directory(self) -> dict:
+ """
+ Периодическая задача: сканирует папку fns на новые файлы.
+
+ Запускается через Celery Beat каждые 5 минут.
+ Новые файлы ставятся в очередь на обработку.
+
+ Returns:
+ Результат сканирования: количество найденных и поставленных в очередь файлов
+ """
+ import hashlib
+ from pathlib import Path
+
+ from django.conf import settings
+
+ task_id = self.request.id
+ logger.info("Starting FNS directory scan (task_id=%s)", task_id)
+
+ watch_dir = Path(settings.FNS_WATCH_DIRECTORY)
+ if not watch_dir.exists():
+ logger.warning("FNS watch directory does not exist: %s", watch_dir)
+ watch_dir.mkdir(parents=True, exist_ok=True)
+ return {"scanned": 0, "queued": 0, "skipped": 0}
+
+ queued = 0
+ skipped = 0
+ files_found = list(watch_dir.glob("fin_*.xlsx"))
+
+ for file_path in files_found:
+ # Вычисляем хеш файла
+ file_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()
+
+ # Проверяем, обрабатывался ли файл
+ if FNSReportService.exists_by_hash(file_hash):
+ skipped += 1
+ continue
+
+ # Ставим в очередь на обработку
+ process_fns_file.delay(str(file_path))
+ queued += 1
+ logger.info("Queued FNS file for processing: %s", file_path.name)
+
+ logger.info(
+ "FNS directory scan completed: found=%d, queued=%d, skipped=%d",
+ len(files_found),
+ queued,
+ skipped,
+ )
+
+ return {
+ "scanned": len(files_found),
+ "queued": queued,
+ "skipped": skipped,
+ }
+
+
+@shared_task(bind=True)
+def process_fns_file(self, file_path: str) -> dict:
+ """
+ Обработка одного файла FNS.
+
+ Args:
+ file_path: Путь к файлу
+
+ Returns:
+ Результат обработки
+ """
+ import hashlib
+ import shutil
+ from dataclasses import asdict
+ from pathlib import Path
+
+ from apps.core.services import BackgroundJobService
+ from apps.parsers.clients.fns.parser import FNSExcelParser, FNSParserError
+ from apps.parsers.models import FinancialReport
+ from django.conf import settings
+
+ source = ParserLoadLog.Source.FNS_REPORTS
+ batch_id = ParserLoadLogService.get_next_batch_id(source)
+ task_id = self.request.id
+ file_path = Path(file_path)
+
+ logger.info(
+ "Processing FNS file (task_id=%s, batch_id=%d, file=%s)",
+ task_id,
+ batch_id,
+ file_path.name,
+ )
+
+ # Создаём BackgroundJob
+ job = BackgroundJobService.create_job(
+ task_id=task_id,
+ task_name="apps.parsers.tasks.process_fns_file",
+ meta={"source": source, "batch_id": batch_id, "file": file_path.name},
+ )
+ job.mark_started()
+ job.update_progress(0, f"Обработка файла {file_path.name}...")
+
+ # Создаём запись лога
+ load_log = ParserLoadLogService.create_load_log(
+ source=source,
+ batch_id=batch_id,
+ status="in_progress",
+ )
+
+ try:
+ # Проверяем существование файла
+ if not file_path.exists():
+ raise FNSParserError(f"Файл не найден: {file_path}")
+
+ # Вычисляем хеш
+ file_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()
+
+ # Проверяем дубликат
+ if FNSReportService.exists_by_hash(file_hash):
+ logger.info(
+ "File already processed (hash=%s): %s",
+ file_hash,
+ file_path.name,
+ )
+ job.complete(result={"status": "skipped", "reason": "duplicate"})
+ ParserLoadLogService.update(load_log, status="skipped")
+ return {"status": "skipped", "reason": "duplicate"}
+
+ # Парсим файл
+ job.update_progress(20, "Парсинг Excel файла...")
+ parsed = FNSExcelParser.parse_file(file_path)
+
+ # Сохраняем в БД
+ job.update_progress(60, f"Сохранение {len(parsed.lines)} строк...")
+ lines_data = [asdict(line) for line in parsed.lines]
+
+ report = FNSReportService.save_report(
+ external_id=parsed.external_id,
+ ogrn=parsed.ogrn,
+ file_name=file_path.name,
+ file_hash=file_hash,
+ source=FinancialReport.SourceType.FILE_WATCH,
+ batch_id=batch_id,
+ lines_data=lines_data,
+ )
+
+ # Перемещаем файл в processed
+ job.update_progress(90, "Перемещение файла...")
+ processed_dir = Path(settings.FNS_PROCESSED_DIRECTORY)
+ processed_dir.mkdir(parents=True, exist_ok=True)
+ shutil.move(str(file_path), str(processed_dir / file_path.name))
+
+ # Обновляем лог
+ ParserLoadLogService.update(
+ load_log,
+ status="success",
+ records_count=len(parsed.lines),
+ )
+
+ # Завершаем
+ job.complete(
+ result={
+ "report_id": report.id,
+ "external_id": parsed.external_id,
+ "ogrn": parsed.ogrn,
+ "lines_count": len(parsed.lines),
+ }
+ )
+
+ logger.info(
+ "FNS file processed: %s (report_id=%d, lines=%d)",
+ file_path.name,
+ report.id,
+ len(parsed.lines),
+ )
+
+ return {
+ "status": "success",
+ "report_id": report.id,
+ "external_id": parsed.external_id,
+ "ogrn": parsed.ogrn,
+ "lines_count": len(parsed.lines),
+ }
+
+ except FNSParserError as e:
+ logger.error("FNS file parsing failed: %s - %s", file_path.name, e)
+
+ # Перемещаем в failed
+ failed_dir = Path(settings.FNS_FAILED_DIRECTORY)
+ failed_dir.mkdir(parents=True, exist_ok=True)
+ if file_path.exists():
+ shutil.move(str(file_path), str(failed_dir / file_path.name))
+
+ ParserLoadLogService.mark_failed(load_log, str(e))
+ job.fail(error=str(e))
+
+ return {"status": "failed", "error": str(e)}
+
+ except Exception as e:
+ logger.error(
+ "FNS file processing error: %s - %s",
+ file_path.name,
+ e,
+ exc_info=True,
+ )
+ ParserLoadLogService.mark_failed(load_log, str(e))
+ job.fail(error=str(e))
+
+ return {"status": "failed", "error": str(e)}
+
+
+@shared_task(bind=True)
+def process_fns_files_batch(self, file_paths: list[str]) -> dict:
+ """
+ Пакетная обработка файлов FNS (для API).
+
+ Args:
+ file_paths: Список путей к файлам
+
+ Returns:
+ Результат обработки всех файлов
+ """
+ task_id = self.request.id
+ logger.info(
+ "Processing FNS batch (task_id=%s, files=%d)",
+ task_id,
+ len(file_paths),
+ )
+
+ results = []
+ success_count = 0
+ failed_count = 0
+
+ for file_path in file_paths:
+ result = process_fns_file(file_path)
+ results.append({"file": file_path, **result})
+
+ if result.get("status") == "success":
+ success_count += 1
+ else:
+ failed_count += 1
+
+ logger.info(
+ "FNS batch completed: total=%d, success=%d, failed=%d",
+ len(file_paths),
+ success_count,
+ failed_count,
+ )
+
+ return {
+ "total": len(file_paths),
+ "success": success_count,
+ "failed": failed_count,
+ "results": results,
+ }
diff --git a/src/apps/parsers/tests/test_fns_parser.py b/src/apps/parsers/tests/test_fns_parser.py
new file mode 100644
index 0000000..f3d739a
--- /dev/null
+++ b/src/apps/parsers/tests/test_fns_parser.py
@@ -0,0 +1,278 @@
+"""
+Тесты для FNS парсера бухгалтерской отчетности.
+"""
+
+from apps.parsers.clients.fns.parser import FNSExcelParser, FNSParserError
+from apps.parsers.clients.fns.schemas import ParsedReport, ReportLine
+from apps.parsers.models import FinancialReport
+from apps.parsers.services import FNSReportService
+from django.test import TestCase
+
+
+class TestFNSExcelParserFilename(TestCase):
+ """Тесты парсинга имени файла."""
+
+ def test_parse_valid_filename(self):
+ """Корректное имя файла."""
+ external_id, ogrn = FNSExcelParser.parse_filename(
+ "fin_0000605_1027700169089.xlsx"
+ )
+ self.assertEqual(external_id, "0000605")
+ self.assertEqual(ogrn, "1027700169089")
+
+ def test_parse_filename_with_long_id(self):
+ """Имя файла с длинным ID."""
+ external_id, ogrn = FNSExcelParser.parse_filename(
+ "fin_12345678_1027700169089.xlsx"
+ )
+ self.assertEqual(external_id, "12345678")
+ self.assertEqual(ogrn, "1027700169089")
+
+ def test_parse_filename_with_15_digit_ogrn(self):
+ """Имя файла с 15-значным ОГРН (ИП)."""
+ external_id, ogrn = FNSExcelParser.parse_filename(
+ "fin_123_123456789012345.xlsx"
+ )
+ self.assertEqual(external_id, "123")
+ self.assertEqual(ogrn, "123456789012345")
+
+ def test_parse_invalid_filename_no_fin_prefix(self):
+ """Неверный формат - без префикса fin."""
+ with self.assertRaises(FNSParserError):
+ FNSExcelParser.parse_filename("report_123_1234567890123.xlsx")
+
+ def test_parse_invalid_filename_wrong_extension(self):
+ """Неверный формат - неправильное расширение."""
+ with self.assertRaises(FNSParserError):
+ FNSExcelParser.parse_filename("fin_123_1234567890123.xls")
+
+ def test_parse_invalid_filename_short_ogrn(self):
+ """Неверный формат - короткий ОГРН."""
+ with self.assertRaises(FNSParserError):
+ FNSExcelParser.parse_filename("fin_123_123456789.xlsx")
+
+
+class TestReportLineSchema(TestCase):
+ """Тесты схемы ReportLine."""
+
+ def test_create_report_line(self):
+ """Создание строки отчета."""
+ line = ReportLine(
+ form_code="1",
+ line_code="1100",
+ line_name="Баланс",
+ year=2023,
+ period_start=1000,
+ period_end=2000,
+ )
+ self.assertEqual(line.form_code, "1")
+ self.assertEqual(line.line_code, "1100")
+ self.assertEqual(line.year, 2023)
+ self.assertEqual(line.period_start, 1000)
+ self.assertEqual(line.period_end, 2000)
+
+ def test_report_line_with_none_values(self):
+ """Строка отчета с пустыми значениями."""
+ line = ReportLine(
+ form_code="2",
+ line_code="2110",
+ line_name="Выручка",
+ year=2023,
+ )
+ self.assertIsNone(line.period_start)
+ self.assertIsNone(line.period_end)
+
+
+class TestParsedReportSchema(TestCase):
+ """Тесты схемы ParsedReport."""
+
+ def test_create_parsed_report(self):
+ """Создание отчета."""
+ report = ParsedReport(
+ external_id="123",
+ ogrn="1234567890123",
+ lines=[
+ ReportLine("1", "1100", "Баланс", 2023, 100, 200),
+ ReportLine("1", "1100", "Баланс", 2022, 50, 100),
+ ReportLine("2", "2110", "Выручка", 2023, 1000, 1500),
+ ],
+ )
+ self.assertEqual(report.external_id, "123")
+ self.assertEqual(report.ogrn, "1234567890123")
+ self.assertEqual(len(report.lines), 3)
+
+ def test_report_years(self):
+ """Получение годов из отчета."""
+ report = ParsedReport(
+ external_id="123",
+ ogrn="1234567890123",
+ lines=[
+ ReportLine("1", "1100", "Баланс", 2023, 100, 200),
+ ReportLine("1", "1100", "Баланс", 2022, 50, 100),
+ ReportLine("1", "1100", "Баланс", 2021, 30, 50),
+ ],
+ )
+ self.assertEqual(report.years, {2021, 2022, 2023})
+
+ def test_report_forms(self):
+ """Получение форм из отчета."""
+ report = ParsedReport(
+ external_id="123",
+ ogrn="1234567890123",
+ lines=[
+ ReportLine("1", "1100", "Баланс", 2023, 100, 200),
+ ReportLine("2", "2110", "Выручка", 2023, 1000, 1500),
+ ReportLine("4", "4100", "Сальдо", 2023, 500, 600),
+ ],
+ )
+ self.assertEqual(report.forms, {"1", "2", "4"})
+
+ def test_get_lines_by_form(self):
+ """Фильтрация строк по форме."""
+ report = ParsedReport(
+ external_id="123",
+ ogrn="1234567890123",
+ lines=[
+ ReportLine("1", "1100", "Баланс", 2023, 100, 200),
+ ReportLine("2", "2110", "Выручка", 2023, 1000, 1500),
+ ReportLine("1", "1200", "Активы", 2023, 300, 400),
+ ],
+ )
+ form1_lines = report.get_lines_by_form("1")
+ self.assertEqual(len(form1_lines), 2)
+ self.assertTrue(all(line.form_code == "1" for line in form1_lines))
+
+ def test_get_lines_by_year(self):
+ """Фильтрация строк по году."""
+ report = ParsedReport(
+ external_id="123",
+ ogrn="1234567890123",
+ lines=[
+ ReportLine("1", "1100", "Баланс", 2023, 100, 200),
+ ReportLine("1", "1100", "Баланс", 2022, 50, 100),
+ ReportLine("1", "1100", "Баланс", 2023, 150, 250),
+ ],
+ )
+ year_2023_lines = report.get_lines_by_year(2023)
+ self.assertEqual(len(year_2023_lines), 2)
+ self.assertTrue(all(line.year == 2023 for line in year_2023_lines))
+
+
+class TestFNSParserParseValue(TestCase):
+ """Тесты метода _parse_value."""
+
+ def test_parse_integer(self):
+ """Парсинг целого числа."""
+ self.assertEqual(FNSExcelParser._parse_value(100), 100)
+
+ def test_parse_float(self):
+ """Парсинг числа с плавающей точкой."""
+ self.assertEqual(FNSExcelParser._parse_value(100.5), 100)
+
+ def test_parse_string_integer(self):
+ """Парсинг строкового числа."""
+ self.assertEqual(FNSExcelParser._parse_value("100"), 100)
+
+ def test_parse_string_with_comma(self):
+ """Парсинг числа с запятой."""
+ self.assertEqual(FNSExcelParser._parse_value("100,5"), 100)
+
+ def test_parse_string_with_spaces(self):
+ """Парсинг числа с пробелами."""
+ self.assertEqual(FNSExcelParser._parse_value("1 000"), 1000)
+
+ def test_parse_none(self):
+ """Парсинг None."""
+ self.assertIsNone(FNSExcelParser._parse_value(None))
+
+ def test_parse_empty_string(self):
+ """Парсинг пустой строки."""
+ self.assertIsNone(FNSExcelParser._parse_value(""))
+
+ def test_parse_dash(self):
+ """Парсинг прочерка."""
+ self.assertIsNone(FNSExcelParser._parse_value("-"))
+
+ def test_parse_invalid_string(self):
+ """Парсинг некорректной строки."""
+ self.assertIsNone(FNSExcelParser._parse_value("abc"))
+
+
+class TestFNSReportServiceIntegration(TestCase):
+ """Интеграционные тесты сервиса FNSReportService."""
+
+ def test_save_report(self):
+ """Сохранение отчета."""
+ lines_data = [
+ {
+ "form_code": "1",
+ "line_code": "1100",
+ "line_name": "Баланс",
+ "year": 2023,
+ "period_start": 100,
+ "period_end": 200,
+ },
+ {
+ "form_code": "2",
+ "line_code": "2110",
+ "line_name": "Выручка",
+ "year": 2023,
+ "period_start": 1000,
+ "period_end": 1500,
+ },
+ ]
+
+ report = FNSReportService.save_report(
+ external_id="test_001",
+ ogrn="1234567890123",
+ file_name="fin_test_001_1234567890123.xlsx",
+ file_hash="abc123def456",
+ source=FinancialReport.SourceType.API,
+ batch_id=1,
+ lines_data=lines_data,
+ )
+
+ self.assertIsNotNone(report.id)
+ self.assertEqual(report.external_id, "test_001")
+ self.assertEqual(report.ogrn, "1234567890123")
+ self.assertEqual(report.status, FinancialReport.Status.SUCCESS)
+ self.assertEqual(report.lines.count(), 2)
+
+ def test_exists_by_hash(self):
+ """Проверка существования по хешу."""
+ FNSReportService.save_report(
+ external_id="test_hash_001",
+ ogrn="1234567890123",
+ file_name="test.xlsx",
+ file_hash="unique_hash_123",
+ source=FinancialReport.SourceType.API,
+ batch_id=1,
+ lines_data=[],
+ )
+
+ self.assertTrue(FNSReportService.exists_by_hash("unique_hash_123"))
+ self.assertFalse(FNSReportService.exists_by_hash("nonexistent_hash"))
+
+ def test_find_by_ogrn(self):
+ """Поиск по ОГРН."""
+ FNSReportService.save_report(
+ external_id="test_ogrn_001",
+ ogrn="9876543210123",
+ file_name="test1.xlsx",
+ file_hash="hash1",
+ source=FinancialReport.SourceType.FILE_WATCH,
+ batch_id=1,
+ lines_data=[],
+ )
+ FNSReportService.save_report(
+ external_id="test_ogrn_002",
+ ogrn="9876543210123",
+ file_name="test2.xlsx",
+ file_hash="hash2",
+ source=FinancialReport.SourceType.FILE_WATCH,
+ batch_id=1,
+ lines_data=[],
+ )
+
+ reports = FNSReportService.find_by_ogrn("9876543210123")
+ self.assertEqual(reports.count(), 2)
diff --git a/src/apps/parsers/urls.py b/src/apps/parsers/urls.py
index 051ad2a..12c5438 100644
--- a/src/apps/parsers/urls.py
+++ b/src/apps/parsers/urls.py
@@ -1,5 +1,88 @@
+"""
+URL конфигурация для приложения парсеров.
+
+Все эндпоинты только для чтения (GET, GET list).
+"""
+
+from apps.parsers.views import (
+ FinancialReportViewSet,
+ FNSReportUploadView,
+ IndustrialCertificateViewSet,
+ InspectionViewSet,
+ ManufacturerViewSet,
+ ParserLoadLogViewSet,
+ ProcurementViewSet,
+ ProxyViewSet,
+)
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
app_name = "parsers"
-urlpatterns = [
- # URL-маршруты будут добавлены по мере разработки
+# =============================================================================
+# Минпромторг: /api/v1/minpromtorg/
+# =============================================================================
+
+minpromtorg_router = DefaultRouter()
+minpromtorg_router.register(
+ r"certificates", IndustrialCertificateViewSet, basename="certificates"
+)
+minpromtorg_router.register(
+ r"manufacturers", ManufacturerViewSet, basename="manufacturers"
+)
+
+minpromtorg_urlpatterns = [
+ path("", include(minpromtorg_router.urls)),
]
+
+# =============================================================================
+# Единый реестр проверок: /api/v1/proverki/
+# =============================================================================
+
+proverki_router = DefaultRouter()
+proverki_router.register(r"", InspectionViewSet, basename="inspections")
+
+proverki_urlpatterns = [
+ path("", include(proverki_router.urls)),
+]
+
+# =============================================================================
+# Государственные закупки: /api/v1/zakupki/
+# =============================================================================
+
+zakupki_router = DefaultRouter()
+zakupki_router.register(r"", ProcurementViewSet, basename="procurements")
+
+zakupki_urlpatterns = [
+ path("", include(zakupki_router.urls)),
+]
+
+# =============================================================================
+# ФНС - Бухгалтерская отчетность: /api/v1/fns/
+# =============================================================================
+
+fns_router = DefaultRouter()
+fns_router.register(r"reports", FinancialReportViewSet, basename="fns-reports")
+
+fns_urlpatterns = [
+ path("upload/", FNSReportUploadView.as_view(), name="fns-upload"),
+ path("", include(fns_router.urls)),
+]
+
+# =============================================================================
+# Системные (логи, прокси): /api/v1/system/
+# =============================================================================
+
+system_router = DefaultRouter()
+system_router.register(r"logs", ParserLoadLogViewSet, basename="parser-logs")
+system_router.register(r"proxies", ProxyViewSet, basename="proxies")
+
+system_urlpatterns = [
+ path("", include(system_router.urls)),
+]
+
+# =============================================================================
+# Legacy urlpatterns (пусто, используется app_name)
+# =============================================================================
+
+urlpatterns = []
diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py
index a890579..567bc37 100644
--- a/src/apps/parsers/views.py
+++ b/src/apps/parsers/views.py
@@ -1,7 +1,472 @@
"""
Views для приложения парсеров.
-TODO: Добавить views по мере необходимости.
+Все ViewSets только для чтения (GET, GET list).
+Добавление и удаление данных - через парсеры и админку.
"""
-# Views будут добавлены по мере разработки конкретных парсеров
+import hashlib
+from pathlib import Path
+
+from apps.parsers.models import (
+ FinancialReport,
+ IndustrialCertificateRecord,
+ InspectionRecord,
+ ManufacturerRecord,
+ ParserLoadLog,
+ ProcurementRecord,
+ Proxy,
+)
+from apps.parsers.serializers import (
+ FinancialReportDetailSerializer,
+ FinancialReportSerializer,
+ FNSFileUploadSerializer,
+ IndustrialCertificateSerializer,
+ InspectionSerializer,
+ ManufacturerSerializer,
+ ParserLoadLogSerializer,
+ ProcurementSerializer,
+ ProxySerializer,
+)
+from apps.parsers.tasks import process_fns_file
+from django.conf import settings
+from drf_yasg import openapi
+from drf_yasg.utils import swagger_auto_schema
+from rest_framework import status
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import IsAdminUser, IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+from rest_framework.viewsets import ReadOnlyModelViewSet
+
+# =============================================================================
+# Swagger Tags (для группировки в документации)
+# =============================================================================
+
+MINPROMTORG_TAG = "Минпромторг"
+PROVERKI_TAG = "Единый реестр проверок"
+ZAKUPKI_TAG = "Государственные закупки"
+FNS_TAG = "ФНС - Бухгалтерская отчетность"
+SYSTEM_TAG = "Системные"
+
+
+# =============================================================================
+# Минпромторг - Сертификаты промышленного производства
+# =============================================================================
+
+
+class IndustrialCertificateViewSet(ReadOnlyModelViewSet):
+ """
+ API для просмотра сертификатов промышленного производства.
+
+ Данные загружаются из Минпромторга.
+ Только чтение - добавление через парсер/админку.
+ """
+
+ queryset = IndustrialCertificateRecord.objects.all().order_by("-created_at")
+ serializer_class = IndustrialCertificateSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_fields = ["inn", "ogrn", "certificate_number", "load_batch"]
+ search_fields = ["organisation_name", "certificate_number", "inn", "ogrn"]
+
+ @swagger_auto_schema(
+ tags=[MINPROMTORG_TAG],
+ operation_summary="Список сертификатов",
+ operation_description=(
+ "Возвращает список сертификатов промышленного производства.\n"
+ "Поддерживает фильтрацию по: inn, ogrn, certificate_number, load_batch.\n"
+ "Поддерживает поиск по: organisation_name, certificate_number, inn, ogrn."
+ ),
+ )
+ def list(self, request, *args, **kwargs):
+ return super().list(request, *args, **kwargs)
+
+ @swagger_auto_schema(
+ tags=[MINPROMTORG_TAG],
+ operation_summary="Детали сертификата",
+ operation_description="Возвращает информацию о конкретном сертификате.",
+ )
+ def retrieve(self, request, *args, **kwargs):
+ return super().retrieve(request, *args, **kwargs)
+
+
+# =============================================================================
+# Минпромторг - Реестр производителей
+# =============================================================================
+
+
+class ManufacturerViewSet(ReadOnlyModelViewSet):
+ """
+ API для просмотра реестра производителей.
+
+ Данные загружаются из Минпромторга.
+ Только чтение - добавление через парсер/админку.
+ """
+
+ queryset = ManufacturerRecord.objects.all().order_by("-created_at")
+ serializer_class = ManufacturerSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_fields = ["inn", "ogrn", "load_batch"]
+ search_fields = ["full_legal_name", "inn", "ogrn", "address"]
+
+ @swagger_auto_schema(
+ tags=[MINPROMTORG_TAG],
+ operation_summary="Список производителей",
+ operation_description=(
+ "Возвращает список производителей из реестра Минпромторга.\n"
+ "Поддерживает фильтрацию по: inn, ogrn, load_batch.\n"
+ "Поддерживает поиск по: full_legal_name, inn, ogrn, address."
+ ),
+ )
+ def list(self, request, *args, **kwargs):
+ return super().list(request, *args, **kwargs)
+
+ @swagger_auto_schema(
+ tags=[MINPROMTORG_TAG],
+ operation_summary="Детали производителя",
+ operation_description="Возвращает информацию о конкретном производителе.",
+ )
+ def retrieve(self, request, *args, **kwargs):
+ return super().retrieve(request, *args, **kwargs)
+
+
+# =============================================================================
+# Единый реестр проверок (proverki.gov.ru)
+# =============================================================================
+
+
+class InspectionViewSet(ReadOnlyModelViewSet):
+ """
+ API для просмотра проверок из Единого реестра проверок.
+
+ Данные из ФГИС "Единый реестр проверок" (Генпрокуратура РФ).
+ Поддерживает ФЗ-294 (традиционные) и ФЗ-248 (новые с 2021).
+ Только чтение - добавление через парсер/админку.
+ """
+
+ queryset = InspectionRecord.objects.all().order_by("-created_at")
+ serializer_class = InspectionSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_fields = [
+ "inn",
+ "ogrn",
+ "registration_number",
+ "is_federal_law_248",
+ "data_year",
+ "data_month",
+ "load_batch",
+ ]
+ search_fields = [
+ "organisation_name",
+ "registration_number",
+ "inn",
+ "ogrn",
+ "control_authority",
+ ]
+
+ @swagger_auto_schema(
+ tags=[PROVERKI_TAG],
+ operation_summary="Список проверок",
+ operation_description=(
+ "Возвращает список проверок из Единого реестра.\n"
+ "Поддерживает фильтрацию по: inn, ogrn, registration_number, "
+ "is_federal_law_248, data_year, data_month, load_batch.\n"
+ "Поддерживает поиск по: organisation_name, registration_number, "
+ "inn, ogrn, control_authority."
+ ),
+ )
+ def list(self, request, *args, **kwargs):
+ return super().list(request, *args, **kwargs)
+
+ @swagger_auto_schema(
+ tags=[PROVERKI_TAG],
+ operation_summary="Детали проверки",
+ operation_description="Возвращает информацию о конкретной проверке.",
+ )
+ def retrieve(self, request, *args, **kwargs):
+ return super().retrieve(request, *args, **kwargs)
+
+
+# =============================================================================
+# Государственные закупки (zakupki.gov.ru)
+# =============================================================================
+
+
+class ProcurementViewSet(ReadOnlyModelViewSet):
+ """
+ API для просмотра государственных закупок.
+
+ Данные из ЕИС zakupki.gov.ru.
+ Поддерживает 44-ФЗ и 223-ФЗ.
+ Только чтение - добавление через парсер/админку.
+ """
+
+ queryset = ProcurementRecord.objects.all().order_by("-created_at")
+ serializer_class = ProcurementSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_fields = [
+ "customer_inn",
+ "customer_ogrn",
+ "purchase_number",
+ "law_type",
+ "status",
+ "region_code",
+ "data_year",
+ "data_month",
+ "load_batch",
+ ]
+ search_fields = [
+ "purchase_name",
+ "purchase_number",
+ "customer_name",
+ "customer_inn",
+ "customer_ogrn",
+ ]
+
+ @swagger_auto_schema(
+ tags=[ZAKUPKI_TAG],
+ operation_summary="Список закупок",
+ operation_description=(
+ "Возвращает список государственных закупок.\n"
+ "Поддерживает фильтрацию по: customer_inn, customer_ogrn, "
+ "purchase_number, law_type, status, region_code, "
+ "data_year, data_month, load_batch.\n"
+ "Поддерживает поиск по: purchase_name, purchase_number, "
+ "customer_name, customer_inn, customer_ogrn."
+ ),
+ )
+ def list(self, request, *args, **kwargs):
+ return super().list(request, *args, **kwargs)
+
+ @swagger_auto_schema(
+ tags=[ZAKUPKI_TAG],
+ operation_summary="Детали закупки",
+ operation_description="Возвращает информацию о конкретной закупке.",
+ )
+ def retrieve(self, request, *args, **kwargs):
+ return super().retrieve(request, *args, **kwargs)
+
+
+# =============================================================================
+# ФНС - Бухгалтерская отчетность
+# =============================================================================
+
+
+class FinancialReportViewSet(ReadOnlyModelViewSet):
+ """
+ API для просмотра финансовых отчетов ФНС.
+
+ Данные загружаются из Excel файлов (fin_{id}_{ogrn}.xlsx).
+ Только чтение - добавление через загрузку файлов.
+ """
+
+ queryset = FinancialReport.objects.all().order_by("-created_at")
+ permission_classes = [IsAuthenticated]
+ filterset_fields = ["ogrn", "external_id", "status", "source", "load_batch"]
+ search_fields = ["ogrn", "external_id", "file_name"]
+
+ def get_serializer_class(self):
+ if self.action == "retrieve":
+ return FinancialReportDetailSerializer
+ return FinancialReportSerializer
+
+ @swagger_auto_schema(
+ tags=[FNS_TAG],
+ operation_summary="Список отчетов",
+ operation_description=(
+ "Возвращает список финансовых отчетов ФНС.\n"
+ "Поддерживает фильтрацию по: ogrn, external_id, status, "
+ "source, load_batch.\n"
+ "Поддерживает поиск по: ogrn, external_id, file_name."
+ ),
+ )
+ def list(self, request, *args, **kwargs):
+ return super().list(request, *args, **kwargs)
+
+ @swagger_auto_schema(
+ tags=[FNS_TAG],
+ operation_summary="Детали отчета",
+ operation_description=(
+ "Возвращает детальную информацию об отчете, "
+ "включая все строки бухгалтерской отчетности."
+ ),
+ )
+ def retrieve(self, request, *args, **kwargs):
+ return super().retrieve(request, *args, **kwargs)
+
+
+class FNSReportUploadView(APIView):
+ """
+ API для загрузки файлов бухгалтерской отчетности ФНС.
+
+ Файлы сохраняются во временную директорию и ставятся в очередь
+ на обработку через Celery.
+ """
+
+ parser_classes = [MultiPartParser]
+ permission_classes = [IsAuthenticated]
+
+ @swagger_auto_schema(
+ tags=[FNS_TAG],
+ operation_summary="Загрузка файлов",
+ operation_description=(
+ "Пакетная загрузка файлов бухгалтерской отчетности.\n\n"
+ "**Формат файла:** fin_{id}_{ogrn}.xlsx\n\n"
+ "**Ответ:**\n"
+ "- queued: количество файлов в очереди\n"
+ "- skipped: количество пропущенных (дубликаты)\n"
+ "- task_ids: ID задач для отслеживания статуса"
+ ),
+ manual_parameters=[
+ openapi.Parameter(
+ name="files",
+ in_=openapi.IN_FORM,
+ type=openapi.TYPE_FILE,
+ required=True,
+ description="Файл(ы) для загрузки (fin_*.xlsx)",
+ ),
+ ],
+ consumes=["multipart/form-data"],
+ responses={
+ 202: openapi.Response(
+ description="Файлы приняты в обработку",
+ schema=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ "queued": openapi.Schema(
+ type=openapi.TYPE_INTEGER,
+ description="Количество файлов в очереди",
+ ),
+ "skipped": openapi.Schema(
+ type=openapi.TYPE_INTEGER,
+ description="Количество пропущенных",
+ ),
+ "task_ids": openapi.Schema(
+ type=openapi.TYPE_ARRAY,
+ items=openapi.Schema(type=openapi.TYPE_STRING),
+ description="ID задач Celery",
+ ),
+ },
+ ),
+ ),
+ 400: "Ошибка валидации файлов",
+ },
+ )
+ def post(self, request):
+ serializer = FNSFileUploadSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ files = serializer.validated_data["files"]
+ task_ids = []
+ queued = 0
+ skipped = 0
+
+ # Создаём директорию для загрузки
+ upload_dir = Path(settings.FNS_WATCH_DIRECTORY)
+ upload_dir.mkdir(parents=True, exist_ok=True)
+
+ from apps.parsers.services import FNSReportService
+
+ for file in files:
+ # Вычисляем хеш файла
+ file_content = file.read()
+ file_hash = hashlib.sha256(file_content).hexdigest()
+ file.seek(0)
+
+ # Проверяем дубликат
+ if FNSReportService.exists_by_hash(file_hash):
+ skipped += 1
+ continue
+
+ # Сохраняем файл
+ file_path = upload_dir / file.name
+ with open(file_path, "wb") as f:
+ for chunk in file.chunks():
+ f.write(chunk)
+
+ # Ставим в очередь
+ task = process_fns_file.delay(str(file_path))
+ task_ids.append(task.id)
+ queued += 1
+
+ return Response(
+ {
+ "queued": queued,
+ "skipped": skipped,
+ "task_ids": task_ids,
+ },
+ status=status.HTTP_202_ACCEPTED,
+ )
+
+
+# =============================================================================
+# Системные (логи загрузок, прокси)
+# =============================================================================
+
+
+class ParserLoadLogViewSet(ReadOnlyModelViewSet):
+ """
+ API для просмотра логов загрузок парсеров.
+
+ Информация о каждой загрузке данных из внешних источников.
+ Только для администраторов.
+ """
+
+ queryset = ParserLoadLog.objects.all().order_by("-created_at")
+ serializer_class = ParserLoadLogSerializer
+ permission_classes = [IsAdminUser]
+ filterset_fields = ["source", "status", "batch_id"]
+
+ @swagger_auto_schema(
+ tags=[SYSTEM_TAG],
+ operation_summary="Список логов загрузок",
+ operation_description=(
+ "Возвращает историю загрузок данных парсерами.\n"
+ "Доступно только администраторам.\n"
+ "Поддерживает фильтрацию по: source, status, batch_id."
+ ),
+ )
+ def list(self, request, *args, **kwargs):
+ return super().list(request, *args, **kwargs)
+
+ @swagger_auto_schema(
+ tags=[SYSTEM_TAG],
+ operation_summary="Детали загрузки",
+ operation_description="Возвращает информацию о конкретной загрузке.",
+ )
+ def retrieve(self, request, *args, **kwargs):
+ return super().retrieve(request, *args, **kwargs)
+
+
+class ProxyViewSet(ReadOnlyModelViewSet):
+ """
+ API для просмотра списка прокси-серверов.
+
+ Используется для отладки и мониторинга парсеров.
+ Только для администраторов.
+ """
+
+ queryset = Proxy.objects.all().order_by("-last_used_at")
+ serializer_class = ProxySerializer
+ permission_classes = [IsAdminUser]
+ filterset_fields = ["is_active"]
+
+ @swagger_auto_schema(
+ tags=[SYSTEM_TAG],
+ operation_summary="Список прокси",
+ operation_description=(
+ "Возвращает список прокси-серверов для парсеров.\n"
+ "Доступно только администраторам.\n"
+ "Поддерживает фильтрацию по: is_active."
+ ),
+ )
+ def list(self, request, *args, **kwargs):
+ return super().list(request, *args, **kwargs)
+
+ @swagger_auto_schema(
+ tags=[SYSTEM_TAG],
+ operation_summary="Детали прокси",
+ operation_description="Возвращает информацию о конкретном прокси.",
+ )
+ def retrieve(self, request, *args, **kwargs):
+ return super().retrieve(request, *args, **kwargs)
diff --git a/src/apps/user/views.py b/src/apps/user/views.py
index 0259014..989dcf7 100644
--- a/src/apps/user/views.py
+++ b/src/apps/user/views.py
@@ -20,14 +20,26 @@ from .serializers import (
)
from .services import ProfileService, UserService
+# Swagger теги для группировки
+AUTH_TAG = "Аутентификация"
+USER_TAG = "Пользователь"
+
class RegisterView(APIView):
- """Регистрация нового пользователя"""
+ """
+ Регистрация нового пользователя.
+
+ Создаёт учётную запись и возвращает JWT токены.
+ """
permission_classes = [AllowAny]
@swagger_auto_schema(
- request_body=UserRegistrationSerializer, responses={201: UserSerializer}
+ tags=[AUTH_TAG],
+ operation_summary="Регистрация",
+ operation_description="Создание новой учётной записи пользователя.",
+ request_body=UserRegistrationSerializer,
+ responses={201: UserSerializer},
)
def post(self, request):
serializer = UserRegistrationSerializer(data=request.data)
@@ -49,11 +61,21 @@ class RegisterView(APIView):
class LoginView(APIView):
- """Вход пользователя"""
+ """
+ Вход пользователя.
+
+ Возвращает access и refresh токены для авторизации.
+ """
permission_classes = [AllowAny]
- @swagger_auto_schema(request_body=LoginSerializer, responses={200: TokenSerializer})
+ @swagger_auto_schema(
+ tags=[AUTH_TAG],
+ operation_summary="Вход",
+ operation_description="Аутентификация по email и паролю. Возвращает JWT токены.",
+ request_body=LoginSerializer,
+ responses={200: TokenSerializer},
+ )
def post(self, request):
serializer = LoginSerializer(data=request.data)
if serializer.is_valid():
@@ -74,20 +96,18 @@ class LoginView(APIView):
class LogoutView(APIView):
- """Выход пользователя"""
+ """
+ Выход пользователя.
+
+ Добавляет refresh токен в черный список (при наличии).
+ """
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
- manual_parameters=[
- openapi.Parameter(
- "Authorization",
- openapi.IN_HEADER,
- description="Bearer ",
- type=openapi.TYPE_STRING,
- required=True,
- )
- ],
+ tags=[AUTH_TAG],
+ operation_summary="Выход",
+ operation_description="Инвалидация refresh токена.",
responses={200: "Успешный выход"},
)
def post(self, request):
@@ -104,20 +124,14 @@ class LogoutView(APIView):
class CurrentUserView(APIView):
- """Получение данных текущего пользователя"""
+ """Получение данных текущего пользователя."""
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
- manual_parameters=[
- openapi.Parameter(
- "Authorization",
- openapi.IN_HEADER,
- description="Bearer ",
- type=openapi.TYPE_STRING,
- required=True,
- )
- ],
+ tags=[USER_TAG],
+ operation_summary="Текущий пользователь",
+ operation_description="Возвращает данные авторизованного пользователя.",
responses={200: UserSerializer},
)
def get(self, request):
@@ -126,21 +140,15 @@ class CurrentUserView(APIView):
class UserUpdateView(APIView):
- """Обновление данных пользователя"""
+ """Обновление данных пользователя."""
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
+ tags=[USER_TAG],
+ operation_summary="Обновить данные",
+ operation_description="Частичное обновление данных пользователя.",
request_body=UserUpdateSerializer,
- manual_parameters=[
- openapi.Parameter(
- "Authorization",
- openapi.IN_HEADER,
- description="Bearer ",
- type=openapi.TYPE_STRING,
- required=True,
- )
- ],
responses={200: UserSerializer},
)
def patch(self, request):
@@ -153,7 +161,7 @@ class UserUpdateView(APIView):
class ProfileDetailView(generics.RetrieveUpdateAPIView):
- """Получение и обновление профиля пользователя"""
+ """Получение и обновление профиля пользователя."""
permission_classes = [IsAuthenticated]
serializer_class = ProfileUpdateSerializer
@@ -168,15 +176,9 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView):
return profile
@swagger_auto_schema(
- manual_parameters=[
- openapi.Parameter(
- "Authorization",
- openapi.IN_HEADER,
- description="Bearer ",
- type=openapi.TYPE_STRING,
- required=True,
- )
- ]
+ tags=[USER_TAG],
+ operation_summary="Получить профиль",
+ operation_description="Возвращает профиль текущего пользователя.",
)
def get(self, request, *args, **kwargs):
profile = self.get_object()
@@ -184,16 +186,10 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView):
return Response(serializer.data)
@swagger_auto_schema(
+ tags=[USER_TAG],
+ operation_summary="Обновить профиль",
+ operation_description="Частичное обновление профиля пользователя.",
request_body=ProfileUpdateSerializer,
- manual_parameters=[
- openapi.Parameter(
- "Authorization",
- openapi.IN_HEADER,
- description="Bearer ",
- type=openapi.TYPE_STRING,
- required=True,
- )
- ],
)
def patch(self, request, *args, **kwargs):
profile = self.get_object()
@@ -207,21 +203,15 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView):
class PasswordChangeView(APIView):
- """Смена пароля"""
+ """Смена пароля пользователя."""
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
+ tags=[USER_TAG],
+ operation_summary="Сменить пароль",
+ operation_description="Смена пароля. Требуется текущий пароль для подтверждения.",
request_body=PasswordChangeSerializer,
- manual_parameters=[
- openapi.Parameter(
- "Authorization",
- openapi.IN_HEADER,
- description="Bearer ",
- type=openapi.TYPE_STRING,
- required=True,
- )
- ],
responses={200: "Пароль успешно изменен"},
)
def post(self, request):
@@ -246,20 +236,29 @@ class PasswordChangeView(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+@swagger_auto_schema(
+ method="get",
+ tags=[USER_TAG],
+ operation_summary="Полный профиль",
+ operation_description="Расширенная информация о пользователе и профиле.",
+)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def user_profile_detail(request):
- """Получение полных данных профиля пользователя"""
+ """Получение полных данных профиля пользователя."""
profile_data = ProfileService.get_full_profile_data(request.user.id)
return Response(profile_data)
class TokenRefreshView(APIView):
- """Обновление access токена через refresh токен"""
+ """Обновление access токена через refresh токен."""
permission_classes = [AllowAny]
@swagger_auto_schema(
+ tags=[AUTH_TAG],
+ operation_summary="Обновить токен",
+ operation_description="Получение нового access токена по refresh токену.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
diff --git a/src/config/api_v1_urls.py b/src/config/api_v1_urls.py
index cdf8730..119b82a 100644
--- a/src/config/api_v1_urls.py
+++ b/src/config/api_v1_urls.py
@@ -1,21 +1,49 @@
"""
API v1 URL configuration.
-All API endpoints are versioned under /api/v1/
+Все API эндпоинты версионированы под /api/v1/
+
+Структура:
+- /api/v1/users/ - Аутентификация и пользователи
+- /api/v1/jobs/ - Фоновые задачи
+- /api/v1/minpromtorg/ - Минпромторг (сертификаты, производители)
+- /api/v1/proverki/ - Единый реестр проверок
+- /api/v1/zakupki/ - Государственные закупки
+- /api/v1/fns/ - ФНС (бухгалтерская отчетность)
+- /api/v1/system/ - Системные (логи, прокси) - только для админов
"""
from apps.core.views import BackgroundJobListView, BackgroundJobStatusView
+from apps.parsers.urls import (
+ fns_urlpatterns,
+ minpromtorg_urlpatterns,
+ proverki_urlpatterns,
+ system_urlpatterns,
+ zakupki_urlpatterns,
+)
from django.urls import include, path
app_name = "api_v1"
+# Фоновые задачи
jobs_urlpatterns = [
path("", BackgroundJobListView.as_view(), name="job-list"),
path("/", BackgroundJobStatusView.as_view(), name="job-status"),
]
urlpatterns = [
+ # Аутентификация и пользователи
path("users/", include("apps.user.urls")),
- path("parsers/", include("apps.parsers.urls")),
+ # Фоновые задачи
path("jobs/", include((jobs_urlpatterns, "jobs"))),
+ # Парсеры - Минпромторг
+ path("minpromtorg/", include((minpromtorg_urlpatterns, "minpromtorg"))),
+ # Парсеры - Единый реестр проверок
+ path("proverki/", include((proverki_urlpatterns, "proverki"))),
+ # Парсеры - Государственные закупки
+ path("zakupki/", include((zakupki_urlpatterns, "zakupki"))),
+ # Парсеры - ФНС бухгалтерская отчетность
+ path("fns/", include((fns_urlpatterns, "fns"))),
+ # Системные (только админы)
+ path("system/", include((system_urlpatterns, "system"))),
]
diff --git a/src/config/celery.py b/src/config/celery.py
index 0eecb09..dc2ab8f 100644
--- a/src/config/celery.py
+++ b/src/config/celery.py
@@ -34,6 +34,11 @@ app.conf.beat_schedule = {
"task": "apps.parsers.tasks.parse_manufactures",
"schedule": 86400.0, # Every 24 hours
},
+ # Сканирование папки FNS - каждые 5 минут
+ "scan-fns-directory": {
+ "task": "apps.parsers.tasks.scan_fns_directory",
+ "schedule": 300.0, # Every 5 minutes
+ },
}
app.conf.timezone = "Europe/Moscow"
diff --git a/src/config/settings/base.py b/src/config/settings/base.py
index 044fe66..ee5a6db 100644
--- a/src/config/settings/base.py
+++ b/src/config/settings/base.py
@@ -344,6 +344,24 @@ if isinstance(CORS_ALLOWED_ORIGINS, str):
CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS.split(",")
CORS_ALLOW_CREDENTIALS = True
+# =============================================================================
+# SWAGGER SETTINGS (drf-yasg)
+# =============================================================================
+SWAGGER_SETTINGS = {
+ "SECURITY_DEFINITIONS": {
+ "Bearer": {
+ "type": "apiKey",
+ "name": "Authorization",
+ "in": "header",
+ "description": "JWT авторизация. Формат: Bearer ",
+ }
+ },
+ "USE_SESSION_AUTH": True,
+ "PERSIST_AUTH": True,
+ "REFETCH_SCHEMA_WITH_AUTH": True,
+ "REFETCH_SCHEMA_ON_LOGOUT": True,
+}
+
# Logging configuration
LOGGING = {
"version": 1,
@@ -383,3 +401,16 @@ LOGGING = {
},
},
}
+
+# =============================================================================
+# FNS Parser Settings
+# =============================================================================
+
+# Directory for watching incoming FNS files
+FNS_WATCH_DIRECTORY = BASE_DIR / "input" / "fns"
+
+# Directory for processed files (moved after successful processing)
+FNS_PROCESSED_DIRECTORY = BASE_DIR / "input" / "fns" / "processed"
+
+# Directory for failed files (moved after failed processing)
+FNS_FAILED_DIRECTORY = BASE_DIR / "input" / "fns" / "failed"
diff --git a/src/config/urls.py b/src/config/urls.py
index aca36e0..d6d698d 100644
--- a/src/config/urls.py
+++ b/src/config/urls.py
@@ -17,8 +17,21 @@ schema_view = get_schema_view(
openapi.Info(
title="Mostovik API",
default_version="v1",
- description="API documentation for Mostovik project",
- terms_of_service="https://www.google.com/policies/terms/",
+ description="""
+## API документация для проекта Mostovik
+
+### Авторизация
+Для доступа к защищённым эндпоинтам используйте JWT токен:
+1. Получите токен через `POST /api/v1/users/login/`
+2. Добавьте заголовок: `Authorization: Bearer `
+
+### Обновление токена
+Используйте `POST /api/v1/users/token/refresh/` с refresh токеном.
+
+### Парсеры
+API предоставляет только чтение данных (GET, GET list).
+Добавление и удаление записей происходит через парсеры и админку.
+ """,
contact=openapi.Contact(email="contact@mostovik.local"),
license=openapi.License(name="BSD License"),
),
diff --git a/src/input/fns/fin_0000605_1027700169089.xlsx b/src/input/fns/fin_0000605_1027700169089.xlsx
new file mode 100644
index 0000000..2df2a0d
Binary files /dev/null and b/src/input/fns/fin_0000605_1027700169089.xlsx differ