diff --git a/CHANGELOG.md b/CHANGELOG.md index bdb5690..42cf350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,56 @@ --- +## [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/parsers/fns/upload/` — пакетная загрузка файлов + - `GET /api/v1/parsers/fns/reports/` — список отчетов с фильтрацией + - `GET /api/v1/parsers/fns/reports/{id}/` — детали отчета со строками + +- **Админка** (`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/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..ae3f3f6 100644 --- a/src/apps/parsers/serializers.py +++ b/src/apps/parsers/serializers.py @@ -1,7 +1,83 @@ """ Сериализаторы для приложения парсеров. - -TODO: Добавить сериализаторы по мере необходимости. """ -# Сериализаторы будут добавлены по мере разработки конкретных парсеров +from apps.parsers.models import FinancialReport, FinancialReportLine +from rest_framework import serializers + + +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): + """Сериализатор финансового отчета.""" + + 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.""" + + 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 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..341611b 100644 --- a/src/apps/parsers/urls.py +++ b/src/apps/parsers/urls.py @@ -1,5 +1,13 @@ +from apps.parsers.views import FinancialReportViewSet, FNSReportUploadView +from django.urls import include, path +from rest_framework.routers import DefaultRouter + app_name = "parsers" +router = DefaultRouter() +router.register(r"fns/reports", FinancialReportViewSet, basename="fns-reports") + urlpatterns = [ - # URL-маршруты будут добавлены по мере разработки + path("fns/upload/", FNSReportUploadView.as_view(), name="fns-upload"), + path("", include(router.urls)), ] diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py index a890579..9cbec8b 100644 --- a/src/apps/parsers/views.py +++ b/src/apps/parsers/views.py @@ -1,7 +1,110 @@ """ Views для приложения парсеров. - -TODO: Добавить views по мере необходимости. """ -# Views будут добавлены по мере разработки конкретных парсеров +import hashlib +from pathlib import Path + +from apps.parsers.models import FinancialReport +from apps.parsers.serializers import ( + FinancialReportDetailSerializer, + FinancialReportSerializer, + FNSFileUploadSerializer, +) +from apps.parsers.tasks import process_fns_file +from django.conf import settings +from rest_framework import status +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ReadOnlyModelViewSet + + +class FinancialReportViewSet(ReadOnlyModelViewSet): + """ + API для просмотра финансовых отчетов ФНС. + + list: + Получить список всех отчетов. + Поддерживает фильтрацию по: ogrn, external_id, status. + + retrieve: + Получить детальную информацию об отчете, включая все строки. + """ + + queryset = FinancialReport.objects.all().order_by("-created_at") + filterset_fields = ["ogrn", "external_id", "status", "source"] + + def get_serializer_class(self): + if self.action == "retrieve": + return FinancialReportDetailSerializer + return FinancialReportSerializer + + +class FNSReportUploadView(APIView): + """ + API для загрузки файлов бухгалтерской отчетности ФНС. + + POST: + Пакетная загрузка файлов. + Файлы сохраняются во временную директорию и ставятся в очередь + на обработку через Celery. + + Request: + multipart/form-data с полем 'files' (можно несколько файлов) + + Response: + { + "queued": 3, + "skipped": 1, + "task_ids": ["uuid1", "uuid2", "uuid3"] + } + """ + + parser_classes = [MultiPartParser] + + 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, + ) 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..a9acf15 100644 --- a/src/config/settings/base.py +++ b/src/config/settings/base.py @@ -383,3 +383,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/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