Перевел таск процессор на обработку только файлов

This commit is contained in:
2026-01-10 21:55:03 +03:00
parent 7b5bba2a17
commit 15500d74bd
26 changed files with 110 additions and 776 deletions

View File

@@ -29,7 +29,24 @@ class AbcpProvider:
path = "/search/articles" path = "/search/articles"
params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"} params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"}
return self._execute(path, method, params)
status_code, payload = self._execute(path, method, params)
if status_code == 200:
response = {"success": True, "data": payload}
logger.debug(f"Получены данные об остатках на складе")
return response
elif status_code == 301:
response = {"success": True, "data": []}
logger.debug(f"Не найдены позиции по запрошенным параметрам")
return response
else:
response = {"success": False, "data": payload}
logger.debug(f"Ошибка при получении остатков со склада")
return response
def _execute(self, path, method="GET", params={}, data=None): def _execute(self, path, method="GET", params={}, data=None):
params["userlogin"] = self.login params["userlogin"] = self.login
@@ -37,18 +54,4 @@ class AbcpProvider:
response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params) response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params)
payload = response.json() payload = response.json()
if response.status_code == 200: return response.status_code, payload
logger.debug(f"Получены данные об остатках на складе")
result = {
"success": True,
"data": payload
}
else:
logger.warning(f"ошибка получения данных об остатках на складе: {payload}")
result = {
"success": False,
"error": payload
}
return result

View File

@@ -54,7 +54,7 @@ log:
handlers: handlers:
console: console:
level: DEBUG level: WARNING
formatter: standard formatter: standard
class: logging.StreamHandler class: logging.StreamHandler
stream: ext://sys.stdout # Default is stderr stream: ext://sys.stdout # Default is stderr
@@ -79,15 +79,15 @@ log:
loggers: loggers:
'': '':
handlers: [console, file, telegram] handlers: [console, file, telegram]
level: DEBUG level: WARNING
propagate: False propagate: False
__main__: __main__:
handlers: [console, file, telegram] handlers: [console, file, telegram]
level: INFO level: WARNING
propagate: False propagate: False
config_manager: config_manager:
handlers: [console, file] handlers: [console, file]
level: DEBUG level: ERROR

View File

@@ -1 +0,0 @@
from .processor import ExcelProcessor

View File

@@ -1,113 +0,0 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional, List
from decimal import Decimal
from .excel_parser import ExcelParser
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class ConfigurableExcelParser(ExcelParser):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def parse(self, file_bytes: str) -> List[OrderPosition]:
try:
# Читаем Excel
df = self._make_dataframe(file_bytes)
# Получаем маппинг колонок из конфигурации
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.error(f"Ошибка парсинга строки {idx}: {e}, {row}")
continue
logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк")
return positions
except Exception as e:
logger.error(f"Ошибка при обработке файла: {e}")
raise Exception from e
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[OrderPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
if mapping.get('name',"") in mapping.keys():
name = str(row[mapping.get('name', "")]).strip()
else:
name = ""
# Создаем объект позиции
position = OrderPosition(
article=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer',"")]).strip(),
name=name,
price=price,
quantity=quantity,
total=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
def _make_dataframe(self, bio) -> pd.DataFrame:
# Получаем все данные из файла
sheet_name = self.config.get("sheet_name", 0)
df_full = pd.read_excel(bio, sheet_name=sheet_name, header=None)
# Находим индекс строки с заголовком
key_field = self.config.get("key_field")
header_row_idx = df_full[
df_full.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
# Считываем таблицу с правильным заголовком
df = pd.read_excel(bio, header=header_row_idx, sheet_name=sheet_name, engine='calamine') #openpyxl calamine
# Находим индекс первой строки с пустым 'Артикул'
first_empty_index = df[df[key_field].isna()].index.min()
# Обрезаем DataFrame до первой пустой строки (не включая её)
df_trimmed = df.loc[:first_empty_index - 1]
return df_trimmed

View File

@@ -1,105 +0,0 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional, List
from decimal import Decimal
import xlrd
from io import BytesIO
from .excel_parser import ExcelParser
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class CustomExcelParserAutoeuro(ExcelParser):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def parse(self, file_bytes: BytesIO) -> List[OrderPosition]:
try:
# Читаем Excel
df = self._make_dataframe(file_bytes)
# Получаем маппинг колонок из конфигурации
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.error(f"Ошибка парсинга строки {idx}: {e}, {row}")
continue
logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк")
return positions
except Exception as e:
logger.error(f"Ошибка при обработке файла: {e}")
raise Exception from e
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[OrderPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
# Создаем объект позиции
position = OrderPosition(
article=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer', "")]).strip(),
name="", #str(row[mapping.get('name', "name")]).strip(),
price=price,
quantity=quantity,
total=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
def _make_dataframe(self, bio) -> pd.DataFrame:
file_bytes = bio.read()
book = xlrd.open_workbook(file_contents=file_bytes, encoding_override='cp1251')
sheet = book.sheet_by_index(self.config.get("sheet_index", 0))
data = [sheet.row_values(row) for row in range(sheet.nrows)]
df_full = pd.DataFrame(data)
key_field = self.config.get("key_field")
header_row_idx = df_full[
df_full.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
df = df_full[header_row_idx:]
df.columns = df.iloc[0] # первая строка становится заголовком
df = df.reset_index(drop=True).drop(0).reset_index(drop=True) # удаляем первую строку и сбрасываем индекс
return df

View File

@@ -1,28 +0,0 @@
import logging
import pandas as pd
from abc import ABC, abstractmethod
from typing import Dict, Any, List
from io import BytesIO
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class ExcelParser(ABC):
"""
Абстрактный базовый класс для всех парсеров Excel.
Реализует Strategy Pattern - каждый контрагент = своя стратегия.
"""
def __init__(self, config: Dict[str, Any]):
self.config = config
@abstractmethod
def parse(self, file: BytesIO) -> List[OrderPosition]:
"""
Парсит Excel файл и возвращает список позиций.
Должен быть реализован в каждом конкретном парсере.
"""
pass

View File

@@ -1,24 +0,0 @@
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

@@ -1,54 +0,0 @@
import yaml
import json
import logging
from pathlib import Path
from typing import Dict, Any, List
from .excel_parser import ExcelParser
from .configurable_parser import ConfigurableExcelParser
from .custom_parser_autoeuro import CustomExcelParserAutoeuro
logger = logging.getLogger(__name__)
class ParserFactory:
"""
Фабрика парсеров (Factory Pattern).
Создает нужный парсер на основе названия контрагента.
"""
# Реестр кастомных парсеров
CUSTOM_PARSERS = {
'autoeuro.ru': CustomExcelParserAutoeuro,
# Добавляйте сюда специализированные парсеры
}
def __init__(self, config: Dict[str, Any]):
self.config = config
def get_parser(self, supplier_name: str) -> ExcelParser:
"""
Возвращает парсер для указанного контрагента.
Использует кастомный парсер если есть, иначе конфигурируемый.
"""
if supplier_name not in self.config['suppliers']:
raise ValueError(
f"Контрагент '{supplier_name}' не найден в конфигурации. "
f"Доступные: {list(self.config['suppliers'].keys())}"
)
config = self.config['suppliers'][supplier_name]
# Проверяем, есть ли кастомный парсер
if supplier_name in self.CUSTOM_PARSERS:
parser_class = self.CUSTOM_PARSERS[supplier_name]
logger.debug(f"Используется кастомный парсер для {supplier_name}")
else:
parser_class = ConfigurableExcelParser
logger.debug(f"Используется конфигурируемый парсер для {supplier_name}")
return parser_class(config)
def list_suppliers(self) -> List[str]:
"""Возвращает список всех доступных контрагентов"""
return list(self.config['suppliers'].keys())

View File

@@ -1,110 +0,0 @@
import logging
from pathlib import Path
from decimal import Decimal
from io import BytesIO
from typing import Dict, Any, List
import yaml
import json
from .parser_factory import ParserFactory
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class ExcelProcessor:
"""
Главный класс-фасад для обработки Excel файлов.
Упрощает использование системы.
"""
def __init__(self, config_path: str = 'config/suppliers.yaml', ):
self.config_path = Path(config_path)
self.config = self._load_config()
self.factory = ParserFactory(self.config)
def process(self, file_bytes: BytesIO, file_name: str, supplier_name: str, validate: bool = False) -> List[OrderPosition]:
"""
Обрабатывает Excel файл от контрагента.
Args:
file_bytes: Байты файла
file_name: Имя файла
supplier_name: Название контрагента (из конфигурации)
validate: Выполнять ли дополнительную валидацию
Returns:
Список объектов OrderPosition
Raises:
ValueError: Если контрагент не найден
"""
logger.info(f"Обработка файла: {file_name} для {supplier_name}")
parser = self.factory.get_parser(supplier_name)
positions = parser.parse(file_bytes)
# Дополнительная валидация если нужна
if validate:
positions = self._validate_positions(positions)
logger.debug(f"Обработка завершена: получено {len(positions)} позиций")
return positions
def process_file(self, file_path: str, supplier_name: str, validate: bool = False) -> List[OrderPosition]:
# Проверка существования файла
logger.debug(f"Чтение файла: {file_path}")
if not Path(file_path).exists():
raise FileNotFoundError(f"Файл не найден: {file_path}")
with open(file_path, 'rb') as file: # бинарный режим
raw_data = file.read()
bio = BytesIO(raw_data)
positions = self.process(bio, file_path, supplier_name, validate=validate)
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()
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}")

View File

@@ -14,7 +14,7 @@ import smtplib
import logging import logging
from mail_order_bot import MailOrderBotException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,8 +22,6 @@ logger = logging.getLogger(__name__)
# from .objects import EmailMessage, EmailAttachment # from .objects import EmailMessage, EmailAttachment
class EmailClientException(MailOrderBotException):
pass
class EmailClient: class EmailClient:

View File

@@ -78,7 +78,7 @@ class EmailUtils:
filename = part.get_filename() filename = part.get_filename()
if filename: if filename:
# Декодируем имя файла # Декодируем имя файла
filename = decode_header(filename)[0] filename = decode_header(filename)[0][0]
# Получаем содержимое # Получаем содержимое
content = part.get_payload(decode=True) content = part.get_payload(decode=True)
if content: if content:

View File

@@ -18,7 +18,6 @@ class ExcelFileParcer:
df = pd.read_excel(file_bytes, sheet_name=self.sheet_name, header=None) df = pd.read_excel(file_bytes, sheet_name=self.sheet_name, header=None)
except Exception as e: except Exception as e:
df = None df = None
logger.warning("Не удалось распарсить значение файла")
return df return df
def set_value(self, sku, manufacturer, column, value): def set_value(self, sku, manufacturer, column, value):

View File

@@ -15,7 +15,7 @@ class AbstractTask():
self.config = self.context.data.get("config", {}) self.config = self.context.data.get("config", {})
@abstractmethod @abstractmethod
def do(self) -> None: def do(self, attachment) -> None:
""" """
Выполняет работу над заданием Выполняет работу над заданием
Входные и выходные данные - в self.context Входные и выходные данные - в self.context

View File

@@ -1,6 +1,4 @@
from .attachment_handler.attachment_handler import AttachmentHandler
from .abcp.api_get_stock import APIGetStock from .abcp.api_get_stock import APIGetStock
from .delivery_time.local_store import DeliveryPeriodLocalStore from .delivery_time.local_store import DeliveryPeriodLocalStore
from .delivery_time.from_config import DeliveryPeriodFromConfig from .delivery_time.from_config import DeliveryPeriodFromConfig
@@ -13,6 +11,6 @@ from .stock_selectors.stock_selector import StockSelector
from .excel_parcers.update_excel_file import UpdateExcelFile from .excel_parcers.update_excel_file import UpdateExcelFile
from .email.send_email import EmailReplyTask from .email.email_reply_task import EmailReplyTask

View File

@@ -23,19 +23,16 @@ class APIGetStock(AbstractTask):
client_login, client_password = credential_provider.get_client_credentials() client_login, client_password = credential_provider.get_client_credentials()
self.client_provider = AbcpProvider(login=client_login, password=client_password) self.client_provider = AbcpProvider(login=client_login, password=client_password)
def do(self) -> None: def do(self, attachment) -> None:
attachments = self.context.data.get("attachments", []) order = attachment.get("order", None)
for attachment in attachments: for position in order.positions:
# Получаем остатки из-под учетной записи клиента
client_stock = self.client_provider.get_stock(position.sku, position.manufacturer)
position.set_order_item(client_stock)
#position.set_order_item()
order = attachment.get("order", None) logger.info(f"Получены позиции со склада для файла {attachment.get('name', "no name")}")
for position in order.positions:
# Получаем остатки из-под учетной записи клиента
client_stock = self.client_provider.get_stock(position.sku, position.manufacturer)
position.set_order_item(client_stock)
#position.set_order_item()
logger.info(f"Получены позиции со склада для файла {attachment.get('name', "no name")}")
def get_stock(self, sku: str, manufacturer: str) -> int: def get_stock(self, sku: str, manufacturer: str) -> int:

View File

@@ -18,11 +18,10 @@ class SaveOrderToTelegram(AbstractTask):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def do(self) -> None: def do(self, attachment) -> None:
client = TelegramClient() client = TelegramClient()
attachments = self.context.data.get("attachments", []) try:
for attachment in attachments:
order = attachment["order"] order = attachment["order"]
positions = order.positions positions = order.positions
message = "\nОбработка заказа {указать название контрагента}\n" message = "\nОбработка заказа {указать название контрагента}\n"
@@ -52,9 +51,12 @@ class SaveOrderToTelegram(AbstractTask):
document=file, document=file,
filename="document.xlsx" filename="document.xlsx"
) )
# logger.critical(message) except Exception as e:
logger.error("Ошибка при отправке инфо по заказу в телеграм")
else:
logger.warning("Инфо по заказу отправлено в телеграм")
#=============================== #===============================

View File

@@ -13,12 +13,16 @@ class DeliveryPeriodFromConfig(AbstractTask):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def do(self) -> None: def do(self, attachment) -> None:
attachments = self.context.data["attachments"] try:
for attachment in attachments: delivery_period = self.config.get("delivery_period")
delivery_period = self.config.get("delivery_period", 0)
except Exception as e:
logger.error(f"Ошибка при получении срока доставки из конфига: {e}")
else:
attachment["delivery_period"] = delivery_period attachment["delivery_period"] = delivery_period
logger.info(f"Срок доставки для файла {attachment["name"]} установлен из конфига - {delivery_period}") logger.warning(f"Срок доставки установлен из конфига - {delivery_period} (ч.)")

View File

@@ -6,13 +6,11 @@ import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils from mail_order_bot.email_client.utils import EmailUtils
from mail_order_bot import MailOrderBotException
from mail_order_bot.task_processor import LogMessage, LogMessageLevel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class EmailParcerException(MailOrderBotException): class EmailParcerException(Exception):
pass pass
@@ -52,12 +50,7 @@ class EmailParcer(AbstractTask):
logger.error(e) logger.error(e)
self.context.data["error"].add( self.context.data["error"].add(
LogMessage( "переделать ошибку на нормальную"
handler="EmailParcer",
level=LogMessageLevel.ERROR,
message="Возникла ошибка при парсинге письма",
error_data=str(e)
)
) )
#raise EmailParcerException(f"Ошибка при парсинге письма {e}") from e #raise EmailParcerException(f"Ошибка при парсинге письма {e}") from e

View File

@@ -6,12 +6,12 @@ from email.mime.base import MIMEBase
from email.utils import formatdate from email.utils import formatdate
from email import encoders from email import encoders
from mail_order_bot import MailOrderBotException
from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.task_processor.abstract_task import AbstractTask
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class EmailReplyTaskException(MailOrderBotException): class EmailReplyTaskException(Exception):
pass pass
@@ -19,7 +19,7 @@ class EmailReplyTask(AbstractTask):
"""Формирует ответ на входящее письмо с запросом на заказ°""" """Формирует ответ на входящее письмо с запросом на заказ°"""
EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru" EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru"
def do(self): def do(self, attachment):
try: try:
email = self.context.data.get("email") email = self.context.data.get("email")
@@ -45,13 +45,14 @@ class EmailReplyTask(AbstractTask):
body = "Автоматический ответ на создание заказа" body = "Автоматический ответ на создание заказа"
reply_message.attach(MIMEText(body, "plain", "utf-8")) reply_message.attach(MIMEText(body, "plain", "utf-8"))
attachments = self.context.data.get("attachments")
for attachment in attachments: self._attach_file(reply_message, attachment)
self._attach_file(reply_message, attachment)
self.context.email_client.send_email(reply_message) self.context.email_client.send_email(reply_message)
except Exception as e: except Exception as e:
pass logger.error(f"Ошибка при отправке ответа по заказу на email \n{e}")
else:
logger.warning(f"Сформирован ответ на заказ на email")
def _attach_file(self, reply_message, attachment): def _attach_file(self, reply_message, attachment):
""" """
@@ -69,7 +70,7 @@ class EmailReplyTask(AbstractTask):
encoders.encode_base64(part) encoders.encode_base64(part)
file_name = attachment["name"][0] file_name = attachment["name"]
part.add_header( part.add_header(
"Content-Disposition", "Content-Disposition",
f"attachment; filename= {file_name}" f"attachment; filename= {file_name}"

View File

@@ -1,122 +0,0 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional
from decimal import Decimal
from io import BytesIO
#from mail_order_bot.task_processor.handlers.order_position import OrderPosition
from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.task_processor.order.auto_part_position import AutoPartPosition
from mail_order_bot.task_processor.order.auto_part_order import AutoPartOrder
logger = logging.getLogger(__name__)
class OrderParser(AbstractTask):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
file_bytes = BytesIO(attachment['bytes']) # self.context.get("attachment") #
delivery_period = attachment.get("delivery_period", 0)
#try:
df = self._make_dataframe(file_bytes)
mapping = self.config["mapping"]
client_id = self.config["client_id"]
order = AutoPartOrder()
attachment["order"] = order
# Парсим строки
positions = []
for idx, row in df.iterrows():
position = self._parse_row(row, mapping)
if position:
position.order_delivery_period = delivery_period
order.add_position(position)
logger.info(f"Успешно обработано {len(order)} позиций из {len(df)} строк")
#except Exception as e:
# logger.error(f"Ошибка при обработке файла: {e}")
#else:
attachment["order"] = order
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[AutoPartPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
if "name" in mapping:
name = str(row[mapping.get('name', "")]).strip()
else:
name = ""
# Создаем объект позиции
position = AutoPartPosition(
sku=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer', "")]).strip(),
name=name,
requested_price=price,
requested_quantity=quantity,
total=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
def _make_dataframe(self, bio) -> pd.DataFrame:
# Получаем все данные из файла
sheet_name = self.config.get("sheet_name", 0)
df_full = pd.read_excel(bio, sheet_name=sheet_name, header=None)
# Находим индекс строки с заголовком
key_field = self.config.get("key_field")
header_row_idx = df_full[
df_full.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
# Считываем таблицу с правильным заголовком
df = pd.read_excel(bio, header=header_row_idx, sheet_name=sheet_name, engine='calamine') # openpyxl calamine
# Находим индекс первой строки с пустым 'Артикул'
first_empty_index = df[df[key_field].isna()].index.min()
# Обрезаем DataFrame до первой пустой строки (не включая её)
df_trimmed = df.loc[:first_empty_index - 1]
return df_trimmed

View File

@@ -1,118 +0,0 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional
from decimal import Decimal
from io import BytesIO
#from mail_order_bot.task_processor.handlers.order_position import OrderPosition
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from ...order.auto_part_position import AutoPartPosition
logger = logging.getLogger(__name__)
class BasicExcelParser(AbstractTask):
RESULT_SECTION = "positions"
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def do(self) -> None:
# todo сделать проверку на наличие файла и его тип
file_bytes = BytesIO(self.context.get("attachment").content) # self.context.get("attachment") #
try:
df = self._make_dataframe(file_bytes)
# Получаем маппинг колонок из конфигурации
mapping = self.config['mapping']
# Парсим строки
positions = []
for idx, row in df.iterrows():
try:
position = self._parse_row(row, mapping)
if position:
positions.append(position)
self.order.add_position(position)
except Exception as e:
logger.error(f"Ошибка парсинга строки {idx}: {e}, {row}")
continue
logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк")
self.context[self.RESULT_SECTION] = positions
except Exception as e:
logger.error(f"Ошибка при обработке файла: {e}")
raise Exception from e
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[AutoPartPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
if mapping.get('name', "") in mapping.keys():
name = str(row[mapping.get('name', "")]).strip()
else:
name = ""
# Создаем объект позиции
position = AutoPartPosition(
sku=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer', "")]).strip(),
name=name,
requested_price=price,
requested_quantity=quantity,
total=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
def _make_dataframe(self, bio) -> pd.DataFrame:
# Получаем все данные из файла
sheet_name = self.config.get("sheet_name", 0)
df_full = pd.read_excel(bio, sheet_name=sheet_name, header=None)
# Находим индекс строки с заголовком
key_field = self.config.get("key_field")
header_row_idx = df_full[
df_full.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
# Считываем таблицу с правильным заголовком
df = pd.read_excel(bio, header=header_row_idx, sheet_name=sheet_name, engine='calamine') # openpyxl calamine
# Находим индекс первой строки с пустым 'Артикул'
first_empty_index = df[df[key_field].isna()].index.min()
# Обрезаем DataFrame до первой пустой строки (не включая её)
df_trimmed = df.loc[:first_empty_index - 1]
return df_trimmed

View File

@@ -19,23 +19,18 @@ class ExcelExtractor(AbstractTask):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {}) self.excel_config = self.config.get("excel", {})
def do(self) -> None: def do(self, attachment) -> None:
try:
file_bytes = BytesIO(attachment['bytes'])
excel_file = ExcelFileParcer(file_bytes, self.excel_config)
# todo сделать проверку на наличие файла и его тип except Exception as e:
logger.error(f"Не удалось распарсить файл: \n{e}")
attachment["excel"] = None
attachments = self.context.data.get("attachments", []) else:
attachment["excel"] = excel_file
for attachment in attachments: logger.warning(f"Произведен успешный парсинг файла")
try:
file_bytes = BytesIO(attachment['bytes'])
excel_file = ExcelFileParcer(file_bytes, self.excel_config)
except Exception as e:
logger.warning(f"Не удалось прочитать файл {attachment['name']}: {e}")
attachment["excel"] = None
else:
attachment["excel"] = excel_file

View File

@@ -18,25 +18,27 @@ class OrderExtractor(AbstractTask):
self.excel_config = self.config.get("excel", {}) self.excel_config = self.config.get("excel", {})
def do(self) -> None: def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип # todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", []) try:
for attachment in attachments:
delivery_period = attachment.get("delivery_period", 0) delivery_period = attachment.get("delivery_period", 0)
mapping = self.excel_config.get("mapping") mapping = self.excel_config.get("mapping")
excel_file = attachment.get("excel") excel_file = attachment.get("excel")
client_id = self.config.get("client_id") client_id = self.config.get("client_id")
order_parcer = OrderParser(mapping, delivery_period, client_id) order_parcer = OrderParser(mapping, delivery_period, client_id)
order_dataframe = excel_file.get_order_rows() order_dataframe = excel_file.get_order_rows()
order = order_parcer.parse(order_dataframe) order = order_parcer.parse(order_dataframe)
except Exception as e:
logger.error(f"Ошибка при парсинге заказа файла: \n{e}")
else:
attachment["order"] = order attachment["order"] = order
logger.warning(f"Обработан файл с заказом, извлечено позиций, {len(order.positions)}")

View File

@@ -20,11 +20,10 @@ class UpdateExcelFile(AbstractTask):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {}) self.excel_config = self.config.get("excel", {})
def do(self) -> None: def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип # todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", []) try:
for attachment in attachments:
excel_file = attachment.get("excel") excel_file = attachment.get("excel")
order = attachment.get("order") order = attachment.get("order")
config = self.context.data.get("config", {}) config = self.context.data.get("config", {})
@@ -47,5 +46,9 @@ class UpdateExcelFile(AbstractTask):
column = value column = value
value = position.order_price value = position.order_price
excel_file.set_value(sku, manufacturer, column, value) excel_file.set_value(sku, manufacturer, column, value)
except Exception as e:
logger.error(f"Ошибка при правке excel файла: \n{e}")
else:
logger.warning(f"Файл excel успешно обновлен")

View File

@@ -35,10 +35,10 @@ class StockSelector(AbstractTask):
client_login, client_password = credential_provider.get_system_credentials() client_login, client_password = credential_provider.get_system_credentials()
self.client_provider = AbcpProvider(login=client_login, password=client_password) self.client_provider = AbcpProvider(login=client_login, password=client_password)
def do(self) -> None: def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип # todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments: try:
order = attachment.get("order", None) order = attachment.get("order", None)
delivery_period = attachment.get("delivery_period") delivery_period = attachment.get("delivery_period")
for position in order.positions: for position in order.positions:
@@ -63,6 +63,11 @@ class StockSelector(AbstractTask):
# Мне не очень нравится управление статусами в этом месте, кажется что лучше это делать внутри AutoPartPosition # Мне не очень нравится управление статусами в этом месте, кажется что лучше это делать внутри AutoPartPosition
else: else:
position.status = PositionStatus.STOCK_FAILED position.status = PositionStatus.STOCK_FAILED
except Exception as e:
logger.error(f"Ошибка при выборе позиции со складов: {e}")
else:
logger.warning("Определены оптимальные позиции со складов")

View File

@@ -43,7 +43,8 @@ from enum import Enum
from mail_order_bot.task_processor.handlers import * from mail_order_bot.task_processor.handlers import *
from mail_order_bot.task_processor.handlers.email.email_parcer import EmailParcer from mail_order_bot.task_processor.handlers.email.email_parcer import EmailParcer
from mail_order_bot import MailOrderBotException from mail_order_bot.task_processor.message import LogMessage, LogMessageLevel, LogMessageStorage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -82,12 +83,20 @@ class TaskProcessor:
config = self._load_config(email_sender) config = self._load_config(email_sender)
self.context.data["config"] = config self.context.data["config"] = config
# Запустить обработку пайплайна
pipeline = config["pipeline"] pipeline = config["pipeline"]
for handler_name in pipeline:
logger.info(f"Processing handler: {handler_name}") attachments = self.context.data.get("attachments", [])
task = globals()[handler_name]() for attachment in attachments:
task.do() file_name = attachment["name"]
logger.warning(f"Начата обработка файла: {file_name} =>")
attachment["log_messages"] = LogMessageStorage(file_name)
# Запустить обработку пайплайна
for handler_name in pipeline:
logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name]()
task.do(attachment)
except Exception as e: except Exception as e:
logger.error(f"Произошла ошибка: {e}") logger.error(f"Произошла ошибка: {e}")