Project folder structure was added

This commit is contained in:
2025-10-27 23:35:40 +03:00
commit 5050becbc4
8 changed files with 280 additions and 0 deletions

1
src/cli.py Normal file
View File

@@ -0,0 +1 @@
from config_manager import config_manager

View File

@@ -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

View File

@@ -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'
)

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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())