From cd0e21350bc78a017bc952724e4b1a87f7c1ddce Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Sun, 1 Feb 2026 14:44:19 +0100 Subject: [PATCH] =?UTF-8?q?feat(fns):=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5?= =?UTF-8?q?=D1=80=20=D0=A4=D0=9D=D0=A1=20=D0=B1=D1=83=D1=85=D0=B3=D0=B0?= =?UTF-8?q?=D0=BB=D1=82=D0=B5=D1=80=D1=81=D0=BA=D0=BE=D0=B9=20=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D0=B5=D1=82=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Модели FinancialReport и FinancialReportLine - FNSExcelParser для файлов fin_{id}_{ogrn}.xlsx - FNSReportService с дедупликацией по хешу файла - Celery задачи для мониторинга папки (каждые 5 мин) - API: POST /fns/upload/, GET /fns/reports/ - Django admin интеграция - 25 unit-тестов --- CHANGELOG.md | 50 ++++ src/apps/parsers/admin.py | 106 +++++++ src/apps/parsers/clients/fns/__init__.py | 10 + src/apps/parsers/clients/fns/parser.py | 208 +++++++++++++ src/apps/parsers/clients/fns/schemas.py | 61 ++++ src/apps/parsers/clients/zakupki/__init__.py | 4 +- .../parsers/migrations/0007_add_fns_models.py | 79 +++++ src/apps/parsers/models.py | 151 ++++++++++ src/apps/parsers/serializers.py | 82 +++++- src/apps/parsers/services.py | 121 ++++++++ src/apps/parsers/tasks.py | 260 ++++++++++++++++ src/apps/parsers/tests/test_fns_parser.py | 278 ++++++++++++++++++ src/apps/parsers/urls.py | 10 +- src/apps/parsers/views.py | 109 ++++++- src/config/celery.py | 5 + src/config/settings/base.py | 13 + src/input/fns/fin_0000605_1027700169089.xlsx | Bin 0 -> 12049 bytes 17 files changed, 1537 insertions(+), 10 deletions(-) create mode 100644 src/apps/parsers/clients/fns/__init__.py create mode 100644 src/apps/parsers/clients/fns/parser.py create mode 100644 src/apps/parsers/clients/fns/schemas.py create mode 100644 src/apps/parsers/migrations/0007_add_fns_models.py create mode 100644 src/apps/parsers/tests/test_fns_parser.py create mode 100644 src/input/fns/fin_0000605_1027700169089.xlsx 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 0000000000000000000000000000000000000000..2df2a0dc48d8d545638e0cb4e08af95d1560bea5 GIT binary patch literal 12049 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 literal 0 HcmV?d00001