commit 5050becbc4f3bdeda800d52f5805ef28558b872e Author: zosimovaa Date: Mon Oct 27 23:35:40 2025 +0300 Project folder structure was added diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyptoject.toml b/pyptoject.toml new file mode 100644 index 0000000..f538a65 --- /dev/null +++ b/pyptoject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["setuptools>=75.3.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "MailOrderBot" +requires-python = ">=3.12" +dependencies = [] +dynamic = ["version"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..c21385a --- /dev/null +++ b/src/cli.py @@ -0,0 +1 @@ +from config_manager import config_manager diff --git a/src/mail_order_bot/excel_processor/configurable_parser.py b/src/mail_order_bot/excel_processor/configurable_parser.py new file mode 100644 index 0000000..c623947 --- /dev/null +++ b/src/mail_order_bot/excel_processor/configurable_parser.py @@ -0,0 +1,68 @@ +from typing import Optional +class ConfigurableExcelParser(ExcelParser): + """ + Универсальный парсер, настраиваемый через конфигурацию. + Подходит для большинства стандартных случаев. + """ + + def parse(self, filepath: str) -> List[OrderPosition]: + try: + # Читаем Excel + df = self._read_excel(filepath) + + # Удаляем пустые строки + df = df.dropna(how='all') + + # Получаем маппинг колонок из конфигурации + mapping = self.config['mapping'] + + # Парсим строки + positions = [] + for idx, row in df.iterrows(): + try: + position = self._parse_row(row, mapping) + if position: + positions.append(position) + except Exception as e: + logger.warning(f"Ошибка парсинга строки {idx}: {e}") + continue + + logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк") + return positions + + except Exception as e: + logger.error(f"Ошибка при обработке файла {filepath}: {e}") + raise + + def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[OrderPosition]: + """Парсит одну строку Excel в OrderPosition""" + + # Проверяем обязательные поля + required_fields = ['article', 'manufacturer', 'name', 'price', 'quantity', 'total'] + for field in required_fields: + if pd.isna(row.get(mapping[field])): + return None + + # Создаем объект позиции + position = OrderPosition( + article=str(row[mapping['article']]).strip(), + manufacturer=str(row[mapping['manufacturer']]).strip(), + name=str(row[mapping['name']]).strip(), + price=Decimal(str(row[mapping['price']])), + quantity=int(row[mapping['quantity']]), + total=Decimal(str(row[mapping['total']])), + additional_attrs=self._extract_additional_attrs(row, mapping) + ) + + return position + + def _extract_additional_attrs(self, row: pd.Series, mapping: Dict[str, str]) -> Dict[str, Any]: + """Извлекает дополнительные атрибуты, не входящие в основную модель""" + additional = {} + mapped_columns = set(mapping.values()) + + for col in row.index: + if col not in mapped_columns and not pd.isna(row[col]): + additional[col] = row[col] + + return additional diff --git a/src/mail_order_bot/excel_processor/excel_parser.py b/src/mail_order_bot/excel_processor/excel_parser.py new file mode 100644 index 0000000..e0b33ff --- /dev/null +++ b/src/mail_order_bot/excel_processor/excel_parser.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod +from typing import List +import pandas as pd +import logging + +logger = logging.getLogger(__name__) + +class ExcelParser(ABC): + """ + Абстрактный базовый класс для всех парсеров Excel. + Реализует Strategy Pattern - каждый контрагент = своя стратегия. + """ + + def __init__(self, config: Dict[str, Any]): + self.config = config + + @abstractmethod + def parse(self, filepath: str) -> List[OrderPosition]: + """ + Парсит Excel файл и возвращает список позиций. + Должен быть реализован в каждом конкретном парсере. + """ + pass + + def _read_excel(self, filepath: str) -> pd.DataFrame: + """Общий метод для чтения Excel файлов""" + return pd.read_excel( + filepath, + sheet_name=self.config.get('sheet_name', 0), + header=self.config.get('header_row', 0), + #engine='openpyxl' + engine='calamine' + ) diff --git a/src/mail_order_bot/excel_processor/excel_processor.py b/src/mail_order_bot/excel_processor/excel_processor.py new file mode 100644 index 0000000..adf09f8 --- /dev/null +++ b/src/mail_order_bot/excel_processor/excel_processor.py @@ -0,0 +1,85 @@ +class ExcelProcessor: + """ + Главный класс-фасад для обработки Excel файлов. + Упрощает использование системы. + """ + + def __init__(self, config_path: str = 'config/suppliers.yaml'): + self.factory = ParserFactory(config_path) + self._setup_logging() + + def _setup_logging(self): + """Настройка логирования""" + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + def process_file( + self, + filepath: str, + supplier_name: str, + validate: bool = True + ) -> List[OrderPosition]: + """ + Обрабатывает Excel файл от контрагента. + + Args: + filepath: Путь к Excel файлу + supplier_name: Название контрагента (из конфигурации) + validate: Выполнять ли дополнительную валидацию + + Returns: + Список объектов OrderPosition + + Raises: + ValueError: Если контрагент не найден + FileNotFoundError: Если файл не найден + """ + logger.info(f"Начало обработки файла: {filepath} для {supplier_name}") + + # Проверка существования файла + if not Path(filepath).exists(): + raise FileNotFoundError(f"Файл не найден: {filepath}") + + # Получаем парсер и обрабатываем + parser = self.factory.get_parser(supplier_name) + positions = parser.parse(filepath) + + # Дополнительная валидация если нужна + if validate: + positions = self._validate_positions(positions) + + logger.info(f"Обработка завершена: получено {len(positions)} позиций") + return positions + + def _validate_positions(self, positions: List[OrderPosition]) -> List[OrderPosition]: + """Дополнительная валидация позиций""" + valid_positions = [] + + for pos in positions: + try: + # Проверка на непустые обязательные поля + if not pos.article or not pos.name: + logger.warning(f"Пропущена позиция с пустыми полями: {pos}") + continue + + # Проверка корректности суммы + expected_total = pos.price * pos.quantity + if abs(pos.total - expected_total) > Decimal('0.01'): + logger.warning( + f"Несоответствие суммы для {pos.article}: " + f"ожидается {expected_total}, получено {pos.total}" + ) + + valid_positions.append(pos) + + except Exception as e: + logger.error(f"Ошибка валидации позиции: {e}") + continue + + return valid_positions + + def get_available_suppliers(self) -> List[str]: + """Возвращает список доступных контрагентов""" + return self.factory.list_suppliers() diff --git a/src/mail_order_bot/excel_processor/order_position.py b/src/mail_order_bot/excel_processor/order_position.py new file mode 100644 index 0000000..9bfe69a --- /dev/null +++ b/src/mail_order_bot/excel_processor/order_position.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field +from typing import Dict, Any +from decimal import Decimal + +@dataclass +class OrderPosition: + """ + Унифицированная модель позиции для заказа. + Все контрагенты приводятся к этой структуре. + """ + article: str # Артикул товара + manufacturer: str # Производитель + name: str # Наименование + price: Decimal # Цена за единицу + quantity: int # Количество + total: Decimal # Общая сумма + additional_attrs: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self): + """Валидация после инициализации""" + if self.quantity < 0: + raise ValueError(f"Количество не может быть отрицательным: {self.quantity}") + if self.price < 0: + raise ValueError(f"Цена не может быть отрицательной: {self.price}") \ No newline at end of file diff --git a/src/mail_order_bot/excel_processor/parser_factory.py b/src/mail_order_bot/excel_processor/parser_factory.py new file mode 100644 index 0000000..a4bf045 --- /dev/null +++ b/src/mail_order_bot/excel_processor/parser_factory.py @@ -0,0 +1,57 @@ +import yaml +import json +from pathlib import Path + +class ParserFactory: + """ + Фабрика парсеров (Factory Pattern). + Создает нужный парсер на основе названия контрагента. + """ + + # Реестр кастомных парсеров + CUSTOM_PARSERS = { + 'supplier_a': SupplierAParser, + # Добавляйте сюда специализированные парсеры + } + + def __init__(self, config_path: str): + self.config_path = Path(config_path) + self.suppliers_config = self._load_config() + + def _load_config(self) -> Dict[str, Any]: + """Загружает конфигурацию из YAML или JSON""" + if self.config_path.suffix in ['.yaml', '.yml']: + with open(self.config_path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + elif self.config_path.suffix == '.json': + with open(self.config_path, 'r', encoding='utf-8') as f: + return json.load(f) + else: + raise ValueError(f"Неподдерживаемый формат конфига: {self.config_path.suffix}") + + def get_parser(self, supplier_name: str) -> ExcelParser: + """ + Возвращает парсер для указанного контрагента. + Использует кастомный парсер если есть, иначе конфигурируемый. + """ + if supplier_name not in self.suppliers_config['suppliers']: + raise ValueError( + f"Контрагент '{supplier_name}' не найден в конфигурации. " + f"Доступные: {list(self.suppliers_config['suppliers'].keys())}" + ) + + config = self.suppliers_config['suppliers'][supplier_name] + + # Проверяем, есть ли кастомный парсер + if supplier_name in self.CUSTOM_PARSERS: + parser_class = self.CUSTOM_PARSERS[supplier_name] + logger.info(f"Используется кастомный парсер для {supplier_name}") + else: + parser_class = ConfigurableExcelParser + logger.info(f"Используется конфигурируемый парсер для {supplier_name}") + + return parser_class(config) + + def list_suppliers(self) -> List[str]: + """Возвращает список всех доступных контрагентов""" + return list(self.suppliers_config['suppliers'].keys())