diff --git a/src/mail_order_bot/abcp_api/abcp_provider.py b/src/mail_order_bot/abcp_api/abcp_provider.py index 5502853..c35c228 100644 --- a/src/mail_order_bot/abcp_api/abcp_provider.py +++ b/src/mail_order_bot/abcp_api/abcp_provider.py @@ -31,9 +31,6 @@ class AbcpProvider: params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"} return self._execute(path, method, params) - def save_order(self, order): - pass - def _execute(self, path, method="GET", params={}, data=None): params["userlogin"] = self.login params["userpsw"] = hashlib.md5(self.password.encode("utf-8")).hexdigest() diff --git a/src/mail_order_bot/abcp_api/telegram/__init__.py b/src/mail_order_bot/abcp_api/telegram/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/mail_order_bot/config.yml b/src/mail_order_bot/config.yml index f854a33..a4d95d4 100644 --- a/src/mail_order_bot/config.yml +++ b/src/mail_order_bot/config.yml @@ -1,4 +1,39 @@ -# Настройки обработки ================================================================= +# Настройки обработки ================================================================= +clients: + lesha.spb@gmail.com: + enabled: true + client_id: 6148154 # Сейчас стоит айдишник Димы для тестовых заказов + + pipeline: + - ExcelExtractor + - OrderExtractor + - DeliveryPeriodFromConfig + - StockSelector + - UpdateExcelFile + - SaveOrderToTelegram + - EmailReplyTask + + excel: + sheet_name: 0 + key_field: "Номер" + mapping: + article: "Номер" + manufacturer: "Фирма" + name: "Наименование" + price: "Цена" + quantity: "Кол-во" + total: "Сумма" + updatable_fields: + ordered_quantity: "Кол-во Поставщика" + ordered_price: "Цена Поставщика" + + # Значение для хендлера DeliveryPeriodFromConfig + delivery_period: 100 # в часах + + amtel.ru: + enabled: false + + # Раздел с общими конфигурационными параметрами =============================== update_interval: 1 @@ -36,10 +71,9 @@ log: level: CRITICAL formatter: telegram class: logging_telegram_handler.TelegramHandler - chat_id: 211945135 + chat_id: -1002960678041 #-1002960678041 #211945135 alias: "Mail order bot" - # Логгеры loggers: '': diff --git a/src/mail_order_bot/configs/amtel.club.yml b/src/mail_order_bot/configs/amtel.club.yml index 3645a5a..9d6b9fd 100644 --- a/src/mail_order_bot/configs/amtel.club.yml +++ b/src/mail_order_bot/configs/amtel.club.yml @@ -1,32 +1,32 @@ #========================================= -config: - - -pipeline: - - +client: amtel.club +enabled: true +client_id: 6148154 # Сейчас стоит айдишник Димы, фактический id у amtel.club - 156799563 +delivery_period: 100 # в часах +excel: + sheet_name: 0 + key_field: "Номер" + mapping: + article: "Номер" + manufacturer: "Фирма" + name: "Наименование" + price: "Цена" + quantity: "Кол-во" + total: "Сумма" + updatable_fields: + ordered_quantity: "Кол-во Поставщика" + ordered_price: "Цена Поставщика" #========================================= pipeline: - # Настраиваем парсинг экселя - - handler: BasicExcelParser - config: - sheet_name: 0 - key_field: "Номер" - mapping: - article: "Номер" - manufacturer: "Фирма" - name: "Наименование" - price: "Цена" - quantity: "Кол-во" - total: "Сумма" - - # Определяем логику обработки заказа (в данном случае все с локального склада) - - handler: DeliveryPeriodLocalStore - - # Запрос остатков со склада - - handler: APIGetStock + - ExcelExtractor + - OrderExtractor + - DeliveryPeriodFromConfig + - StockSelector + - UpdateExcelFile + - SaveOrderToTelegram diff --git a/src/mail_order_bot/configs/gmail.com.yml b/src/mail_order_bot/configs/gmail.com.yml new file mode 100644 index 0000000..42e9bc4 --- /dev/null +++ b/src/mail_order_bot/configs/gmail.com.yml @@ -0,0 +1,39 @@ +#========================================= +client: gmail.com +enabled: true +client_id: 6148154 # Сейчас стоит айдишник Димы, фактический id у amtel.club - 156799563 + +delivery_period: 100 # в часах +excel: + sheet_name: 0 + key_field: "Номер" + mapping: + article: "Номер" + manufacturer: "Фирма" + name: "Наименование" + price: "Цена" + quantity: "Кол-во" + total: "Сумма" + + updatable_fields: + ordered_quantity: "Кол-во Поставщика" + ordered_price: "Цена Поставщика" + +#========================================= +pipeline: + - ExcelExtractor + - OrderExtractor + - DeliveryPeriodFromConfig + - StockSelector + - UpdateExcelFile + - SaveOrderToTelegram + + + + + + + + + + diff --git a/src/mail_order_bot/credential_provider.py b/src/mail_order_bot/credential_provider.py index b1c7c41..2103fae 100644 --- a/src/mail_order_bot/credential_provider.py +++ b/src/mail_order_bot/credential_provider.py @@ -75,8 +75,8 @@ class CredentialProvider: Raises: ValueError: Если учетные данные системной учетной записи не найдены """ - login_key = f"{self.prefix}_LOGIN_{self.SYSTEM_ACCOUNT}" - password_key = f"{self.prefix}_PASSWORD_{self.SYSTEM_ACCOUNT}" + login_key = f"{self.prefix}_LOGIN" + password_key = f"{self.prefix}_PASSWORD" login = os.getenv(login_key) password = os.getenv(password_key) diff --git a/src/mail_order_bot/email_client/client.py b/src/mail_order_bot/email_client/client.py index 8fad6e7..b26aaa7 100644 --- a/src/mail_order_bot/email_client/client.py +++ b/src/mail_order_bot/email_client/client.py @@ -1,6 +1,6 @@ import re from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Union from dataclasses import dataclass import email from email import encoders @@ -8,6 +8,7 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email.header import decode_header +from email.message import Message import imaplib import smtplib @@ -105,4 +106,62 @@ class EmailClient: else: decoded_parts.append(str(part)) - return ''.join(decoded_parts) \ No newline at end of file + return ''.join(decoded_parts) + + def send_email(self, message: Union[MIMEMultipart, MIMEText, Message]): + """ + Отправить подготовленное письмо через SMTP. + + Args: + message: Подготовленное письмо (MIMEMultipart, MIMEText или email.message.Message) + Должно содержать заголовки From, To и Subject + """ + try: + # Извлекаем получателей из письма + recipients = [] + + # Основной получатель + to_header = message.get("To", "") + if to_header: + # Обрабатываем несколько адресов, разделенных запятыми + to_addresses = [addr.strip() for addr in to_header.split(",")] + recipients.extend(to_addresses) + + # Копия + cc_header = message.get("Cc", "") + if cc_header: + cc_addresses = [addr.strip() for addr in cc_header.split(",")] + recipients.extend(cc_addresses) + + # Скрытая копия + bcc_header = message.get("Bcc", "") + if bcc_header: + bcc_addresses = [addr.strip() for addr in bcc_header.split(",")] + recipients.extend(bcc_addresses) + + if not recipients: + raise ValueError("Не указаны получатели письма (To, Cc или Bcc)") + + # Извлекаем отправителя из письма или используем email из настроек + from_email = message.get("From", self.email) + + # Подключаемся к SMTP серверу + with smtplib.SMTP(self.smtp_host, self.smtp_port) as server: + server.starttls() + server.login(self.email, self.password) + + # Отправляем письмо + server.sendmail( + from_email, + recipients, + message.as_string() + ) + + logger.info(f"Письмо успешно отправлено получателям: {', '.join(recipients)}") + + except smtplib.SMTPException as e: + logger.error(f"Ошибка SMTP при отправке письма: {str(e)}") + raise Exception(f"Ошибка SMTP: {str(e)}") + except Exception as e: + logger.error(f"Ошибка при отправке письма: {str(e)}") + raise Exception(f"Ошибка при отправке письма: {str(e)}") \ No newline at end of file diff --git a/src/mail_order_bot/main.py b/src/mail_order_bot/main.py index 71e9ff1..455738d 100644 --- a/src/mail_order_bot/main.py +++ b/src/mail_order_bot/main.py @@ -7,7 +7,7 @@ import os from dotenv import load_dotenv from email_client import EmailClient -from email_processor import EmailProcessor +from task_processor import TaskProcessor from mail_order_bot.context import Context @@ -33,15 +33,21 @@ class MailOrderBot(ConfigManager): self.context = Context() self.context.email_client = self.email_client + # Обработчик писем - self.email_processor = EmailProcessor("./configs") + #self.email_processor = TaskProcessor("./configs") + config = self.config.get("clients") + self.email_processor = TaskProcessor(config) logger.warning("MailOrderBot инициализирован") + + def execute(self): # Получить список айдишников письма - unread_email_ids = self.email_client.get_emails_id(folder="spareparts") + logger.critical("Запуск приложения critical !!!!!!!!") + unread_email_ids = self.email_client.get_emails_id(folder="spareparts") logger.info(f"Новых писем - {len(unread_email_ids)}") # Обработать каждое письмо по идентификатору @@ -49,9 +55,8 @@ class MailOrderBot(ConfigManager): logger.debug(f"==================================================") logger.debug(f"Обработка письма с идентификатором {email_id}") # Получить письмо по идентификатору и запустить его обработку - email = self.email_client.get_email(email_id) + email = self.email_client.get_email(email_id, mark_as_read=False) self.email_processor.process_email(email) - pass logger = logging.getLogger() @@ -65,9 +70,12 @@ async def main(): if __name__ == "__main__": print(os.getcwd()) + if os.environ.get("APP_ENV") != "PRODUCTION": logger.warning("Non production environment") load_dotenv() asyncio.run(main()) + + \ No newline at end of file diff --git a/src/mail_order_bot/order/auto_part_order.py b/src/mail_order_bot/order/auto_part_order.py index fe61ed9..59fdc91 100644 --- a/src/mail_order_bot/order/auto_part_order.py +++ b/src/mail_order_bot/order/auto_part_order.py @@ -1,8 +1,6 @@ from typing import List, Optional from .auto_part_position import AutoPartPosition, PositionStatus -from mail_order_bot.task_processor.handlers.abcp.stock_selector import StockSelector - from enum import Enum @@ -38,17 +36,6 @@ class AutoPartOrder: def set_delivery_period(self, delivery_period: int) -> None: self.delivery_period = delivery_period - def fill_from_local_supplier(self) -> None: - """ - Выбирает оптимального поставщика для всех позиций заказа. - Предполагается, что остатки уже получены и обработаны. - """ - - - for position in self.positions: - selector = StockSelector(position, self.delivery_period) - selector.select_optimal_supplier() - def check_order(self, config) -> None: """ Проверяет заказ на возможность исполнения""" # 1. Проверка общего количества отказов diff --git a/src/mail_order_bot/order/auto_part_position.py b/src/mail_order_bot/order/auto_part_position.py index f4d777a..5fe7b59 100644 --- a/src/mail_order_bot/order/auto_part_position.py +++ b/src/mail_order_bot/order/auto_part_position.py @@ -21,23 +21,22 @@ class AutoPartPosition: Унифицированная модель позиции для заказа. Все контрагенты приводятся к этой структуре. """ - - DISTRIBUTOR_ID = 1577730 # ID локального склада - sku: str # Артикул товара manufacturer: str # Производитель - requested_price: Decimal # Цена за единицу - requested_quantity: int # Количество + asking_price: Decimal # Цена за единицу + asking_quantity: int # Количество total: Decimal = 0 # Общая сумма name: str = "" # Наименование - order_delivery_period: int = 0 - order_quantity: int = 0 # Количество для заказа - order_price: Decimal = Decimal('0.0') # Цена в заказе - order_item: Dict[str, Any] = field(default_factory=dict) - stock: List[Dict[str, Any]] = None + order_item: Dict[str, Any] = field(default_factory=dict) + order_price: Decimal = Decimal('0.0') # Цена в заказе + order_quantity: int = 0 # Количество для заказа + order_delivery_period: int = 0 + + profit: Decimal = Decimal('0.0') + additional_attrs: Dict[str, Any] = field(default_factory=dict) status: PositionStatus = PositionStatus.NEW @@ -45,65 +44,30 @@ class AutoPartPosition: def __post_init__(self): """Валидация после инициализации""" - if self.requested_quantity < 0: - raise ValueError(f"Количество не может быть отрицательным: {self.requested_quantity}") - if self.requested_price < 0: - raise ValueError(f"Цена не может быть отрицательной: {self.requested_price}") + if self.asking_quantity < 0: + raise ValueError(f"Количество не может быть отрицательным: {self.asking_quantity}") + if self.asking_price < 0: + raise ValueError(f"Цена не может быть отрицательной: {self.asking_price}") - def set_stock(self, stock): - if stock.get("success"): - self.stock = stock["data"] - if len(self.stock): - self.status = PositionStatus.STOCK_RECIEVED - else: - self.status = PositionStatus.NO_AVAILABLE_STOCK - else: - self.status = PositionStatus.STOCK_FAILED + def set_order_item(self, order_item): + # Запоминаем всю позицию + self.order_item = order_item - def set_order_item(self): - """Выбирает позицию для заказа""" - if self.status == PositionStatus.STOCK_RECIEVED: - available_distributors = self.stock + # ---===Устанавливаем конкретные значения по параметрам заказа===--- + # Берем максимально доступное значение со склада, но не больше чем в заказе. + self.order_quantity = min(self.order_item.get("availability"), self.asking_quantity) - # BR-1. Отсекаем склады для заказов из наличия (только локальный склад) - if self.order_delivery_period == 0: - available_distributors = self._filter_only_local_storage(available_distributors) + # Продаем по цене, которая была заказана + self.order_price = self.asking_price - # BR-2. Цена не должна превышать цену из заказа - available_distributors = self._filter_proper_price(available_distributors) + # Устанавливаем актуальный срок доставки + self.order_delivery_period = self.order_item.get("deliveryPeriod") - # BR-3. Срок доставки не должен превышать ожидаемый - available_distributors = self._filter_proper_delivery_time(available_distributors) + # ФИксируем профит. Для инфо/отчетности + self.profit = (self.asking_price - Decimal(self.order_item.get("price"))) * self.order_quantity - # BR-4. Без отрицательных остатков - available_distributors = self._filter_proper_availability(available_distributors) + # Устанавливаем статус + self.status = PositionStatus.READY - # Приоритет на склады с полным стоком - # BR-5. Сначала оборачиваем локальный склад, потом удаленные - # BR-6. Выбираем цену максимально близкую к цене из заказа (максимальная) - available_distributors.sort(key=lambda item: Decimal(item["price"]), reverse=True) - - if len(available_distributors): - self.order_item = self.stock[0] - self.status = PositionStatus.READY - else: - self.status = PositionStatus.NO_AVAILABLE_STOCK - - def _filter_only_local_storage(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Фильтрует только локальные склады""" - - return [item for item in distributors if item["distributorId"] == self.DISTRIBUTOR_ID] - - def _filter_proper_delivery_time(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Фильтрует склады по сроку доставки""" - return [item for item in distributors if item["deliveryPeriod"] <= self.order_delivery_period] - - def _filter_proper_price(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Фильтрует склады по цене (убирает дорогие)""" - return [item for item in distributors if Decimal(item["price"]) <= self.requested_price] - - def _filter_proper_availability(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Фильтрует склады с положительным остатком""" - return [item for item in distributors if Decimal(item["availability"]) > 0] \ No newline at end of file diff --git a/src/mail_order_bot/parsers/order_parcer.py b/src/mail_order_bot/parsers/order_parcer.py index f726b32..0fee9a3 100644 --- a/src/mail_order_bot/parsers/order_parcer.py +++ b/src/mail_order_bot/parsers/order_parcer.py @@ -5,6 +5,7 @@ from decimal import Decimal from io import BytesIO from mail_order_bot.order import AutoPartPosition +from mail_order_bot.order import AutoPartOrder logger = logging.getLogger(__name__) @@ -15,11 +16,13 @@ class OrderParser: Подходит для большинства стандартных случаев. """ - def __init__(self, mapping, delivery_period): + def __init__(self, mapping, delivery_period, client_id): self.mapping = mapping self.delivery_period = delivery_period + self.client_id = client_id - def parse(self, order, df): + def parse(self, df): + order = AutoPartOrder(self.client_id) # Парсим строки positions = [] for idx, row in df.iterrows(): @@ -63,8 +66,8 @@ class OrderParser: sku=str(row[mapping['article']]).strip(), manufacturer=str(row[mapping.get('manufacturer', "")]).strip(), name=name, - requested_price=price, - requested_quantity=quantity, + asking_price=price, + asking_quantity=quantity, total=total, order_delivery_period=self.delivery_period, additional_attrs=self._extract_additional_attrs(row, mapping) diff --git a/src/mail_order_bot/task_processor/abstract_task.py b/src/mail_order_bot/task_processor/abstract_task.py index 78f97c1..bdf1e56 100644 --- a/src/mail_order_bot/task_processor/abstract_task.py +++ b/src/mail_order_bot/task_processor/abstract_task.py @@ -9,9 +9,10 @@ class AbstractTask(): """ Абстрактный базовый класс для всех хэндлеров. """ - def __init__(self, config: Dict[str, Any]={}) -> None: + def __init__(self) -> None: self.context = Context() - self.config = config + #self.config = config + self.config = self.context.data.get("config", {}) @abstractmethod def do(self) -> None: diff --git a/src/mail_order_bot/task_processor/handlers/__init__.py b/src/mail_order_bot/task_processor/handlers/__init__.py index abe25cc..ca8fbf9 100644 --- a/src/mail_order_bot/task_processor/handlers/__init__.py +++ b/src/mail_order_bot/task_processor/handlers/__init__.py @@ -1,10 +1,18 @@ from .attachment_handler.attachment_handler import AttachmentHandler -from .excel_parcers.order_parcer_basic import BasicExcelParser +from .abcp.api_get_stock import APIGetStock from .destination_time.local_store import DeliveryPeriodLocalStore +from .destination_time.from_config import DeliveryPeriodFromConfig from .notifications.test_notifier import TestNotifier - - +from .excel_parcers.excel_extractor import ExcelExtractor +from .excel_parcers.order_extractor import OrderExtractor +from .abcp.api_save_order import SaveOrderToTelegram + +from .stock_selectors.stock_selector import StockSelector + +from .excel_parcers.update_excel_file import UpdateExcelFile + +from .email.send_email import EmailReplyTask diff --git a/src/mail_order_bot/task_processor/handlers/abcp/api_get_stock.py b/src/mail_order_bot/task_processor/handlers/abcp/api_get_stock.py index f42f068..b048930 100644 --- a/src/mail_order_bot/task_processor/handlers/abcp/api_get_stock.py +++ b/src/mail_order_bot/task_processor/handlers/abcp/api_get_stock.py @@ -1,8 +1,15 @@ +""" +Перебирает аттачменты +Для каждого ордера в аттачменте перебирает позиции +Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/ +Возможно логику выбора позиции надо вынести из позиции, но пока так +""" import logging from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.abcp_api.abcp_provider import AbcpProvider from mail_order_bot.credential_provider import CredentialProvider +from mail_order_bot.order.auto_part_order import OrderStatus logger = logging.getLogger(__name__) @@ -17,17 +24,19 @@ class APIGetStock(AbstractTask): self.client_provider = AbcpProvider(login=client_login, password=client_password) def do(self) -> None: + attachments = self.context.data.get("attachments", []) for attachment in attachments: - order = attachment["order"] + + order = attachment.get("order", None) for position in order.positions: # Получаем остатки из-под учетной записи клиента client_stock = self.client_provider.get_stock(position.sku, position.manufacturer) - - position.set_stock(client_stock) - position.set_order_item() - + 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: - return self.client_provider.get_stock(sku, manufacturer) \ No newline at end of file + return self.client_provider.get_stock(sku, manufacturer) diff --git a/src/mail_order_bot/task_processor/handlers/abcp/api_save_order.py b/src/mail_order_bot/task_processor/handlers/abcp/api_save_order.py index e69de29..dca43fd 100644 --- a/src/mail_order_bot/task_processor/handlers/abcp/api_save_order.py +++ b/src/mail_order_bot/task_processor/handlers/abcp/api_save_order.py @@ -0,0 +1,60 @@ +""" +Перебирает аттачменты +Для каждого ордера в аттачменте перебирает позиции +Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/ +Возможно логику выбора позиции надо вынести из позиции, но пока так +""" +import logging + +from mail_order_bot.task_processor.abstract_task import AbstractTask +from mail_order_bot.abcp_api.abcp_provider import AbcpProvider +from mail_order_bot.credential_provider import CredentialProvider + +from mail_order_bot.telegram.client import TelegramClient + +logger = logging.getLogger(__name__) + +class SaveOrderToTelegram(AbstractTask): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def do(self) -> None: + client = TelegramClient() + + attachments = self.context.data.get("attachments", []) + for attachment in attachments: + order = attachment["order"] + positions = order.positions + message = "\nОбработка заказа {указать название контрагента}\n" + message += f"\nПолучено {len(positions)} позиций от {order.client_id}\n" + message += "===============================\n" + for position in positions: + message += f"{position.sku} - {position.manufacturer} - {position.name} \n" + message += f"{position.asking_quantity} x {position.asking_price} = {position.total} \n" + + rejected = position.asking_quantity - position.order_quantity + if position.order_quantity == 0: + message += f"Отказ\n" + elif rejected: + message += (f"Отказ: {rejected}, запрошено, {position.asking_quantity}, " + f"отгружено {position.order_quantity}, профит {position.profit}\n") + else: + message += f"Позиция отгружена полностью, профит {position.profit}\n" + message += "-------------------------------\n" + + result = client.send_message(message) + + # Отправка экселя в телеграм + excel = attachment["excel"] + file = excel.get_file_bytes() + + client.send_document( + document=file, + filename="document.xlsx" + ) + # logger.critical(message) + + #=============================== + + + diff --git a/src/mail_order_bot/task_processor/handlers/abcp/stock_selector.py b/src/mail_order_bot/task_processor/handlers/abcp/stock_selector.py deleted file mode 100644 index c30f46c..0000000 --- a/src/mail_order_bot/task_processor/handlers/abcp/stock_selector.py +++ /dev/null @@ -1,95 +0,0 @@ -import sys -from turtle import position -from typing import Dict, Any, List, Optional -from decimal import Decimal -from mail_order_bot.email_processor.order.auto_part_position import AutoPartPosition, PositionStatus - -logger = __import__('logging').getLogger(__name__) - - -""" -1. Получить 2 вида складских остатков -2. Добавляем цену закупки -3. Применяем правила фильтрации - - можно указать какие правила применены -4. Выбираем цену по профиту - - можно указать ограничения - - - -""" -class StockSelector: - """ - Класс для выбора оптимального поставщика для позиции заказа. - Выполняет фильтрацию складов и выбор наилучшего варианта. - """ - - DISTRIBUTOR_ID = "1577730" # ID локального склада - - def __init__(self, position: AutoPartPosition, delivery_period: int = 0): - """ - Инициализация StockSelector. - - Args: - position: Позиция заказа - delivery_period: Период доставки в днях - """ - self.position = position - self.delivery_period = delivery_period - self.client_stock = None - self.system_stock = None - - - def filter_stock(self, client_stock) -> None: - """ - Обрабатывает результат запроса остатков из-под учетной записи клиента и обновляет позицию. - - Args: - client_stock: Результат запроса остатков от API из-под учетной записи клиента - system_stock: Результат запроса остатков от API из-под системной учетной записи - """ - self.client_stock = client_stock - - if client_stock["success"]: - available_distributors = client_stock["data"] - - # Для доставки только с локального склада сперва убираем все остальные склады - if self.delivery_period == 0: - available_distributors = self._filter_only_local_storage(available_distributors) - - # Отбираем склады по сроку доставки - available_distributors = self._filter_proper_delivery_time(available_distributors, self.delivery_period) - - # Убираем дорогие склады с ценой выше запрошенной - available_distributors = self._filter_proper_price(available_distributors) - - # Убираем отрицательные остатки - available_distributors = self._filter_proper_availability(available_distributors) - - # Добавляем данные о закупочных ценах - available_distributors = self._set_system_price(available_distributors) - - # Сортируем по цене - available_distributors.sort(key=lambda item: Decimal(item["price"]), reverse=True) - - return available_distributors - - else: - return None - - def _filter_only_local_storage(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Фильтрует только локальные склады""" - return [item for item in distributors if str(item["distributorId"]) == self.DISTRIBUTOR_ID] - - def _filter_proper_delivery_time(self, distributors: List[Dict[str, Any]], delivery_period: int) -> List[Dict[str, Any]]: - """Фильтрует склады по сроку доставки""" - return [item for item in distributors if item["deliveryPeriod"] <= delivery_period] - - def _filter_proper_price(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Фильтрует склады по цене (убирает дорогие)""" - return [item for item in distributors if Decimal(item["price"]) <= self.position.requested_price] - - def _filter_proper_availability(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Фильтрует склады с положительным остатком""" - return [item for item in distributors if Decimal(item["availability"]) > 0] - diff --git a/src/mail_order_bot/task_processor/handlers/attachment_handler/attachment_handler.py b/src/mail_order_bot/task_processor/handlers/attachment_handler/attachment_handler.py index 7bdd752..3b44412 100644 --- a/src/mail_order_bot/task_processor/handlers/attachment_handler/attachment_handler.py +++ b/src/mail_order_bot/task_processor/handlers/attachment_handler/attachment_handler.py @@ -1,8 +1,12 @@ +""" +Извлекает вложения из имейла и складывает их в контекст +Использует EmailUtils +""" +import logging + from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.email_client.utils import EmailUtils -import logging - logger = logging.getLogger(__name__) class AttachmentHandler(AbstractTask): @@ -10,12 +14,16 @@ class AttachmentHandler(AbstractTask): super().__init__(*args, **kwargs) def do(self) -> None: - email = self.context.data["email"] - attachments = EmailUtils.extract_attachments(email) - self.context.data["attachments"] = attachments - logger.debug(f"AttachmentHandler отработал, извлек вложений: {len(attachments)} ") - - - + try: + email = self.context.data["email"] + attachments = EmailUtils.extract_attachments(email) + + except Exception as e: + logger.error(e) + self.context.data["error"] = str(e) + + else: + self.context.data["attachments"] = attachments + logger.info(f"Извлечено вложений: {len(attachments)} ") diff --git a/src/mail_order_bot/task_processor/handlers/destination_time/from_config.py b/src/mail_order_bot/task_processor/handlers/destination_time/from_config.py index e69de29..102d014 100644 --- a/src/mail_order_bot/task_processor/handlers/destination_time/from_config.py +++ b/src/mail_order_bot/task_processor/handlers/destination_time/from_config.py @@ -0,0 +1,26 @@ +""" +Устанавливает хардкодом период доставки 0, что означает использование локального склада. +Для заказчиков, которые должны всегда получать заказ только из наличия +""" + +import logging + +from mail_order_bot.task_processor.abstract_task import AbstractTask + +logger = logging.getLogger(__name__) + +class DeliveryPeriodFromConfig(AbstractTask): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def do(self) -> None: + attachments = self.context.data["attachments"] + for attachment in attachments: + delivery_period = self.config.get("delivery_period", 0) + attachment["delivery_period"] = delivery_period + + logger.info(f"Доставка только с локального склада, срок 1 день.") + + + + diff --git a/src/mail_order_bot/task_processor/handlers/destination_time/from_subject.py b/src/mail_order_bot/task_processor/handlers/destination_time/from_subject.py index 7e2602b..4d88d8c 100644 --- a/src/mail_order_bot/task_processor/handlers/destination_time/from_subject.py +++ b/src/mail_order_bot/task_processor/handlers/destination_time/from_subject.py @@ -1,3 +1,7 @@ +""" +Парсер срока доставки из темы письма +""" + from mail_order_bot.task_processor.abstract_task import AbstractTask import logging diff --git a/src/mail_order_bot/task_processor/handlers/destination_time/local_store.py b/src/mail_order_bot/task_processor/handlers/destination_time/local_store.py index 9a648b2..35abab6 100644 --- a/src/mail_order_bot/task_processor/handlers/destination_time/local_store.py +++ b/src/mail_order_bot/task_processor/handlers/destination_time/local_store.py @@ -1,7 +1,12 @@ -from mail_order_bot.task_processor.abstract_task import AbstractTask +""" +Устанавливает хардкодом период доставки 0, что означает использование локального склада. +Для заказчиков, которые должны всегда получать заказ только из наличия +""" import logging +from mail_order_bot.task_processor.abstract_task import AbstractTask + logger = logging.getLogger(__name__) class DeliveryPeriodLocalStore(AbstractTask): diff --git a/src/mail_order_bot/task_processor/handlers/email/email_parcer.py b/src/mail_order_bot/task_processor/handlers/email/email_parcer.py index 6aa758f..1ed1947 100644 --- a/src/mail_order_bot/task_processor/handlers/email/email_parcer.py +++ b/src/mail_order_bot/task_processor/handlers/email/email_parcer.py @@ -21,9 +21,14 @@ class EmailParcer(AbstractTask): self.context.data["email_body"] = email_body # todo при переводе на основной ящик переделать на другую функцию - email_from = EmailUtils.extract_first_sender(email_body) + header_from = EmailUtils.extract_header(email, "From") + email_from = EmailUtils.extract_email(header_from) + #email_from = EmailUtils.extract_first_sender(email_body) self.context.data["email_from"] = email_from + email_from_domain = EmailUtils.extract_domain(email_from) + self.context.data["email_from_domain"] = email_from_domain + email_subj = EmailUtils.extract_header(email, "subj") self.context.data["email_subj"] = email_subj diff --git a/src/mail_order_bot/task_processor/handlers/email/send_email.py b/src/mail_order_bot/task_processor/handlers/email/send_email.py index 31a9093..bdc0f4e 100644 --- a/src/mail_order_bot/task_processor/handlers/email/send_email.py +++ b/src/mail_order_bot/task_processor/handlers/email/send_email.py @@ -8,134 +8,67 @@ from email import encoders from abc import ABC, abstractmethod import os - -class AbstractTask(ABC): - """Базовый класс для задач""" - - def __init__(self, context, config): - self.context = context - self.config = config - - @abstractmethod - def do(self): - """Метод для реализации РІ подклассах""" - pass - +from mail_order_bot.task_processor.abstract_task import AbstractTask class EmailReplyTask(AbstractTask): - """Класс для ответа РЅР° электронные РїРёСЃСЊРјР°""" + """Формирует ответ на входящее письмо с запросом на заказ°""" + EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru" def do(self): - """ - Отправляет ответ РЅР° входящее РїРёСЃСЊРјРѕ - Ожидает РІ self.context: - - message: email.message.Message объект входящего РїРёСЃСЊРјР° - - attachment: путь Рє файлу для вложения + email = self.context.data.get("email") - Ожидает РІ self.config: - - reply_to: адрес электронной почты для РєРѕРїРёРё - - smtp_host: С…РѕСЃС‚ SMTP сервера - - smtp_port: РїРѕСЂС‚ SMTP сервера - - smtp_user: пользователь SMTP - - smtp_password: пароль SMTP - - from_email: адрес отправителя - """ - incoming_message = self.context.get("message") - attachment_path = self.context.get("attacnment") + if not email: + raise ValueError("В контексте нет входящего сообщения") - if not incoming_message: - raise ValueError("Р’ context РЅРµ найдено РїРёСЃСЊРјРѕ (message)") + email_from = self.context.data.get("email_from") + if not email_from: + raise ValueError("В контексте не определен адрес отправителя") - # Получаем адрес отправителя входящего РїРёСЃСЊРјР° - from_addr = incoming_message.get("From") - if not from_addr: - raise ValueError("Входящее РїРёСЃСЊРјРѕ РЅРµ содержит адреса отправителя") - # Создаем ответное РїРёСЃСЊРјРѕ reply_message = MIMEMultipart() - # Заголовки ответного РїРёСЃСЊРјР° - reply_message["From"] = self.config.get("from_email", "noreply@example.com") - reply_message["To"] = from_addr - reply_message["Cc"] = self.config.get("reply_to", "") - reply_message["Subject"] = f"Re: {incoming_message.get('Subject', '')}" + email_subj = self.context.data.get("email_subj") + + reply_message["From"] = self.EMAIl + reply_message["To"] = email_from + #reply_message["Cc"] = self.config.get("reply_to", "") + reply_message["Subject"] = f"Re: {email_subj}" reply_message["Date"] = formatdate(localtime=True) - # Тело РїРёСЃСЊРјР° - body = "Ваш заказ создан" + body = "Автоматический ответ на создание заказа" reply_message.attach(MIMEText(body, "plain", "utf-8")) - # Добавляем вложение если указан путь Рє файлу - if attachment_path and os.path.isfile(attachment_path): - self._attach_file(reply_message, attachment_path) + attachments = self.context.data.get("attachments") + for attachment in attachments: + self._attach_file(reply_message, attachment) - # Отправляем РїРёСЃСЊРјРѕ - self._send_email(reply_message, from_addr) + self.context.email_client.send_email(reply_message) - def _attach_file(self, message, file_path): + def _attach_file(self, reply_message, attachment): """ - Добавляет файл РІ качестве вложения Рє РїРёСЃСЊРјСѓ - Args: - message: MIMEMultipart объект - file_path: путь Рє файлу для вложения + message: MIMEMultipart + file_path: """ try: - with open(file_path, "rb") as attachment: - part = MIMEBase("application", "octet-stream") - part.set_payload(attachment.read()) + + part = MIMEBase("application", "octet-stream") + + excel_file = attachment["excel"] + excel_file_bytes = excel_file.get_file_bytes() + part.set_payload(excel_file_bytes.read()) encoders.encode_base64(part) - file_name = os.path.basename(file_path) + file_name = attachment["name"][0] part.add_header( "Content-Disposition", f"attachment; filename= {file_name}" ) - message.attach(part) - except FileNotFoundError: - raise FileNotFoundError(f"Файл РЅРµ найден: {file_path}") + reply_message.attach(part) + except Exception as e: - raise Exception(f"Ошибка РїСЂРё добавлении вложения: {str(e)}") - - def _send_email(self, message, recipient): - """ - Отправляет РїРёСЃСЊРјРѕ через SMTP - - Args: - message: MIMEMultipart объект РїРёСЃСЊРјР° - recipient: адрес получателя - """ - try: - smtp_host = self.config.get("smtp_host") - smtp_port = self.config.get("smtp_port", 587) - smtp_user = self.config.get("smtp_user") - smtp_password = self.config.get("smtp_password") - - if not all([smtp_host, smtp_user, smtp_password]): - raise ValueError("РќРµ указаны параметры SMTP РІ config") - - with smtplib.SMTP(smtp_host, smtp_port) as server: - server.starttls() - server.login(smtp_user, smtp_password) - - # Получаем адреса получателей (РѕСЃРЅРѕРІРЅРѕР№ + РєРѕРїРёСЏ) - recipients = [recipient] - reply_to = self.config.get("reply_to") - if reply_to: - recipients.append(reply_to) - - server.sendmail( - self.config.get("from_email"), - recipients, - message.as_string() - ) - except smtplib.SMTPException as e: - raise Exception(f"Ошибка SMTP: {str(e)}") - except Exception as e: - raise Exception(f"Ошибка РїСЂРё отправке РїРёСЃСЊРјР°: {str(e)}") - - + raise Exception(f"Ошибка при аттаче файла: {str(e)}") diff --git a/src/mail_order_bot/task_processor/handlers/excel_parcers/excel_extractor.py b/src/mail_order_bot/task_processor/handlers/excel_parcers/excel_extractor.py index 2e1e5d2..22a7e42 100644 --- a/src/mail_order_bot/task_processor/handlers/excel_parcers/excel_extractor.py +++ b/src/mail_order_bot/task_processor/handlers/excel_parcers/excel_extractor.py @@ -4,6 +4,8 @@ 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.parsers.excel_parcer import ExcelFileParcer + logger = logging.getLogger(__name__) @@ -13,6 +15,7 @@ class ExcelExtractor(AbstractTask): """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.excel_config = self.config.get("excel", {}) def do(self) -> None: @@ -21,14 +24,12 @@ class ExcelExtractor(AbstractTask): attachments = self.context.data.get("attachments", []) for attachment in attachments: file_bytes = BytesIO(attachment['bytes']) + + excel_file = ExcelFileParcer(file_bytes, self.excel_config) + attachment["excel"] = excel_file + + - # Получаем все данные из файла - sheet_name = self.config.get("sheet_name", 0) - try: - attachment["sheet"] = pd.read_excel(file_bytes, sheet_name=sheet_name, header=None) - except Exception as e: - attachment["sheet"] = None - logger.warning("Не удалось распарсить значение файла") diff --git a/src/mail_order_bot/task_processor/handlers/excel_parcers/order_extractor.py b/src/mail_order_bot/task_processor/handlers/excel_parcers/order_extractor.py new file mode 100644 index 0000000..1b29692 --- /dev/null +++ b/src/mail_order_bot/task_processor/handlers/excel_parcers/order_extractor.py @@ -0,0 +1,42 @@ +import logging +import pandas as pd +from io import BytesIO +from mail_order_bot.parsers.order_parcer import OrderParser +from mail_order_bot.task_processor.abstract_task import AbstractTask + +from mail_order_bot.parsers.excel_parcer import ExcelFileParcer + +logger = logging.getLogger(__name__) + + +class OrderExtractor(AbstractTask): + """ + Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.excel_config = self.config.get("excel", {}) + + + def do(self) -> None: + + # todo сделать проверку на наличие файла и его тип + + attachments = self.context.data.get("attachments", []) + for attachment in attachments: + + delivery_period = attachment.get("delivery_period", 0) + mapping = self.excel_config.get("mapping") + + excel_file = attachment.get("excel") + client_id = self.config.get("client_id") + order_parcer = OrderParser(mapping, delivery_period, client_id) + + order_dataframe = excel_file.get_order_rows() + + order = order_parcer.parse(order_dataframe) + attachment["order"] = order + + + + diff --git a/src/mail_order_bot/task_processor/handlers/excel_parcers/update_excel_file.py b/src/mail_order_bot/task_processor/handlers/excel_parcers/update_excel_file.py index 1c44851..80a7bb5 100644 --- a/src/mail_order_bot/task_processor/handlers/excel_parcers/update_excel_file.py +++ b/src/mail_order_bot/task_processor/handlers/excel_parcers/update_excel_file.py @@ -27,25 +27,25 @@ class UpdateExcelFile(AbstractTask): for attachment in attachments: excel_file = attachment.get("excel") order = attachment.get("order") - config = self.context.data.get("excel", {}) - updatable_fields = config.get("updatable_fields", {}) + config = self.context.data.get("config", {}) + excel_config = config.get("excel", {}) + updatable_fields = excel_config.get("updatable_fields", {}) for position in order.positions: - if position.status == PositionStatus.READY: - sku = position.sku - manufacturer = position.manufacturer - for key, value in updatable_fields.items(): + sku = position.sku + manufacturer = position.manufacturer - if key == "ordered_quantity": - column = value - value = position.ordered_quantity - excel_file.set_value(sku, manufacturer, column, value) + for key, value in updatable_fields.items(): - if key == "ordered_price": - column = value - value = position.ordered_price - excel_file.set_value(sku, manufacturer, column, value) - pass + if key == "ordered_quantity": + column = value + value = position.order_quantity + excel_file.set_value(sku, manufacturer, column, value) + + if key == "ordered_price": + column = value + value = position.order_price + excel_file.set_value(sku, manufacturer, column, value) diff --git a/src/mail_order_bot/task_processor/handlers/notifications/test_notifier.py b/src/mail_order_bot/task_processor/handlers/notifications/test_notifier.py index 151b6b9..458ef10 100644 --- a/src/mail_order_bot/task_processor/handlers/notifications/test_notifier.py +++ b/src/mail_order_bot/task_processor/handlers/notifications/test_notifier.py @@ -1,6 +1,6 @@ import logging -from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask +from mail_order_bot.task_processor.abstract_task import AbstractTask logger = logging.getLogger(__name__) @@ -12,4 +12,4 @@ class TestNotifier(AbstractTask): print(f"\nПолучено {len(positions)} позиций от {self.context["client"]}:") for pos in positions: # Первые 5 print(f" - {pos.sku}: {pos.name} " - f"({pos.requested_quantity} x {pos.requested_price} = {pos.total})") + f"({pos.asking_quantity} x {pos.asking_price} = {pos.total})") diff --git a/src/mail_order_bot/task_processor/handlers/stock_selectors/stock_selector.py b/src/mail_order_bot/task_processor/handlers/stock_selectors/stock_selector.py index e69de29..fb14292 100644 --- a/src/mail_order_bot/task_processor/handlers/stock_selectors/stock_selector.py +++ b/src/mail_order_bot/task_processor/handlers/stock_selectors/stock_selector.py @@ -0,0 +1,121 @@ +import logging +import pandas as pd +from io import BytesIO + +from dotenv.parser import Position + +from mail_order_bot.parsers.order_parcer import OrderParser +from mail_order_bot.task_processor.abstract_task import AbstractTask +from mail_order_bot.order.auto_part_position import AutoPartPosition, PositionStatus +from mail_order_bot.parsers.excel_parcer import ExcelFileParcer +from decimal import Decimal + +from mail_order_bot.task_processor.abstract_task import AbstractTask +from mail_order_bot.abcp_api.abcp_provider import AbcpProvider +from mail_order_bot.credential_provider import CredentialProvider +from mail_order_bot.order.auto_part_order import OrderStatus + + +from typing import Dict, Any +from typing import List, Optional + +logger = logging.getLogger(__name__) + + +class StockSelector(AbstractTask): + DISTRIBUTOR_ID = 1577730 # ID локального склада + """ + Выбирает подходящие позиции со склада + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + credential_provider = CredentialProvider(context=self.context) + # Создаем провайдер для учетной записи клиента + client_login, client_password = credential_provider.get_system_credentials() + self.client_provider = AbcpProvider(login=client_login, password=client_password) + + def do(self) -> None: + # todo сделать проверку на наличие файла и его тип + attachments = self.context.data.get("attachments", []) + for attachment in attachments: + order = attachment.get("order", None) + delivery_period = attachment.get("delivery_period") + for position in order.positions: + + #1. Получаем остатки со складов + stock_data = self.client_provider.get_stock(position.sku, position.manufacturer) + + #2. Из данных остатков выбираем оптимальное значение по стратегии + if stock_data["success"]: + stock_list = stock_data.get("data", []) + asking_price = position.asking_price + asking_quantity = position.asking_quantity + + + optimal_stock_positions = self.get_optimal_stock(stock_list, asking_price, asking_quantity, delivery_period) + + # 3. Устанавливаем выбранное значение в позицию + if len(optimal_stock_positions): + position.set_order_item(optimal_stock_positions[0]) + else: + position.status = PositionStatus.NO_AVAILABLE_STOCK + # Мне не очень нравится управление статусами в этом месте, кажется что лучше это делать внутри AutoPartPosition + else: + position.status = PositionStatus.STOCK_FAILED + + + + def get_optimal_stock(self, stock_list, asking_price, asking_quantity, delivery_period): + """Выбирает позицию для заказа""" + + # BR-1. Отсекаем склады для заказов из наличия (только локальный склад) + stock_list = self._br1_only_local_stock(stock_list) + + # BR-2. Цена не должна превышать цену из заказа + #stock_list = self._br2_price_below_asked_price(stock_list, asking_price) + + # BR-3. Срок доставки не должен превышать ожидаемый + stock_list = self._br3_delivery_time_shorted_asked_time(stock_list, delivery_period) + + # BR-4. Без отрицательных остатков + stock_list = self._br4_only_positive_stock(stock_list) + + # BR-5 Выбираем склад с максимальным профитом + stock_list = self._br5_max_profit(stock_list, asking_price, asking_quantity) + + # пока не реализовано + # BR-7 Приоритет на склады с полным стоком + # BR-8. Сначала оборачиваем локальный склад, потом удаленные + # BR-9. Даем немного уйти в минус при заказе из наличия + + return stock_list + + def _br1_only_local_stock(self, stocks): + return [item for item in stocks if item["distributorId"] == self.DISTRIBUTOR_ID] + + def _br2_price_below_asked_price(self, distributors: List[Dict[str, Any]], asking_price) -> List[Dict[str, Any]]: + """Фильтрует склады по цене (убирает дорогие)""" + return [item for item in distributors if Decimal(item["price"]) <= asking_price] + + def _br3_delivery_time_shorted_asked_time(self, distributors: List[Dict[str, Any]], delivery_period) -> List[Dict[str, Any]]: + """Фильтрует склады по сроку доставки""" + # Вопрос - надо ли ориентироваться на deliveryPeriodMax + return [item for item in distributors if item["deliveryPeriod"] <= delivery_period] + + def _br4_only_positive_stock(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Фильтрует склады с положительным остатком""" + return [item for item in distributors if Decimal(item["availability"]) > 0] + + def _br5_max_profit(self, distributors: List[Dict[str, Any]], asking_price, asking_quantity) -> List[Dict[str, Any]]: + """Фильтрует склады с положительным остатком""" + for item in distributors: + item["profit"] = (asking_price - Decimal(item["price"])) * min(asking_quantity, item["availability"]) + + distributors.sort(key=lambda item: Decimal(item["profit"]), reverse=False) + + return distributors + + + + diff --git a/src/mail_order_bot/task_processor/processor.py b/src/mail_order_bot/task_processor/processor.py index 92ce4c1..b6d3e15 100644 --- a/src/mail_order_bot/task_processor/processor.py +++ b/src/mail_order_bot/task_processor/processor.py @@ -1,7 +1,7 @@ import os import yaml import logging -from typing import Dict, Any +from typing import Dict, Any, List from pathlib import Path import threading from mail_order_bot.context import Context @@ -9,8 +9,8 @@ from mail_order_bot.email_client.utils import EmailUtils from enum import Enum from mail_order_bot.task_processor.handlers import * - from mail_order_bot.task_processor.handlers import AttachmentHandler +from mail_order_bot.task_processor.handlers.email.email_parcer import EmailParcer logger = logging.getLogger(__name__) @@ -25,52 +25,42 @@ class RequestStatus(Enum): class TaskProcessor: - def __init__(self, configs_path: str): + #def __init__(self, configs_path: str): + def __init__(self, config: Dict[str, Any]): super().__init__() self.context = Context() - self.configs_path = configs_path + #self.configs_path = configs_path + self.config = config self.status = RequestStatus.NEW def process_email(self, email): - # Очистить контекст + # Очистить контекст и запушить туда письмо self.context.clear() - - # Сохранить письмо в контекст self.context.data["email"] = email - # Определить клиента - email_body = EmailUtils.extract_body(email) - self.context.data["email_body"] = email_body + # Парсинг письма + email_parcer = EmailParcer() + email_parcer.do() - email_from = EmailUtils.extract_first_sender(email_body) - self.context.data["email_from"] = email_from + email_from = self.context.data.get("email_from") + #client = EmailUtils.extract_domain(email_from) + #self.context.data["client"] = client - email_subj = EmailUtils.extract_header(email, "subj") - self.context.data["email_subj"] = email_subj - - client = EmailUtils.extract_domain(email_from) - self.context.data["client"] = client - - try: # Определить конфиг для пайплайна - config = self._load_config(client) + config = self._load_config(email_from) self.context.data["config"] = config - # Обработка вложений - attachments_handler_task = AttachmentHandler() - attachments_handler_task.do() - # Запустить обработку пайплайна - for stage in config["pipeline"]: - handler_name = stage["handler"] + pipeline = config["pipeline"] + for stage in pipeline: + handler_name = stage logger.info(f"Processing handler: {handler_name}") - task = globals()[handler_name](stage.get("config", None)) + task = globals()[handler_name]() task.do() - except FileNotFoundError: - logger.error(f"Конфиг для клиента {client} не найден") + logger.error(f"Конфиг для клиента {email_from} не найден") for attachment in self.context.data["attachments"]: print(attachment["order"].__dict__) @@ -78,9 +68,16 @@ class TaskProcessor: # logger.error(f"Произошла другая ошибка: {e}") - def _load_config(self, client) -> Dict[str, Any]: - """Загружает конфигурацию из YAML или JSON""" + def _load_config(self, email_from) -> Dict[str, Any]: + if email_from in self.config: + return self.config[email_from] - path = os.path.join(self.configs_path, client + '.yml') - with open(path, 'r', encoding='utf-8') as f: - return yaml.safe_load(f) \ No newline at end of file + email_from_domain = EmailUtils.extract_domain(email_from) + if email_from_domain in self.config: + return self.config[email_from_domain] + + raise FileNotFoundError + + #path = os.path.join(self.configs_path, client + '.yml') + #with open(path, 'r', encoding='utf-8') as f: + # return yaml.safe_load(f) \ No newline at end of file diff --git a/tests/parsers/__init__.py b/tests/parsers/__init__.py new file mode 100644 index 0000000..0051334 --- /dev/null +++ b/tests/parsers/__init__.py @@ -0,0 +1,2 @@ +# Tests for parsers module + diff --git a/tests/parsers/test_excel_parcer.py b/tests/parsers/test_excel_parcer.py new file mode 100644 index 0000000..8f498e6 --- /dev/null +++ b/tests/parsers/test_excel_parcer.py @@ -0,0 +1,320 @@ +import pytest +import pandas as pd +from io import BytesIO +from unittest.mock import patch + +from mail_order_bot.parsers.excel_parcer import ExcelFileParcer + + +class TestExcelFileParcer: + """Тесты для класса ExcelFileParcer""" + + @pytest.fixture + def sample_config(self): + """Базовая конфигурация для тестов""" + return { + "sheet_name": 0, + "key_field": "Артикул", + "mapping": { + "article": "Артикул", + "manufacturer": "Производитель", + "name": "Наименование", + "price": "Цена", + "quantity": "Количество" + } + } + + @pytest.fixture + def sample_excel_bytes(self): + """Создает тестовый Excel файл в виде байтов""" + df = pd.DataFrame({ + 'Артикул': ['ART001', 'ART002', 'ART003'], + 'Производитель': ['MAN001', 'MAN002', 'MAN003'], + 'Наименование': ['Товар 1', 'Товар 2', 'Товар 3'], + 'Цена': [100.0, 200.0, 300.0], + 'Количество': [1, 2, 3] + }) + buf = BytesIO() + with pd.ExcelWriter(buf, engine='xlsxwriter') as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False) + buf.seek(0) + return buf.getvalue() + + @pytest.fixture + def excel_with_header_row(self): + """Создает Excel файл с заголовком не в первой строке""" + df = pd.DataFrame([ + ['Заголовок документа', None, None, None, None], + ['Артикул', 'Производитель', 'Наименование', 'Цена', 'Количество'], + ['ART001', 'MAN001', 'Товар 1', 100.0, 1], + ['ART002', 'MAN002', 'Товар 2', 200.0, 2], + ['ART003', 'MAN003', 'Товар 3', 300.0, 3], + [None, None, None, None, None] # Пустая строка для обрезки + ]) + buf = BytesIO() + with pd.ExcelWriter(buf, engine='xlsxwriter') as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False, header=False) + buf.seek(0) + return buf.getvalue() + + def test_init_with_valid_file(self, sample_excel_bytes, sample_config): + """Тест инициализации с валидным файлом""" + parser = ExcelFileParcer(sample_excel_bytes, sample_config) + + assert parser.config == sample_config + assert parser.bytes == sample_excel_bytes + assert parser.sheet_name == 0 + assert parser.df is not None + assert isinstance(parser.df, pd.DataFrame) + + def test_init_with_custom_sheet_name(self, sample_excel_bytes): + """Тест инициализации с кастомным именем листа""" + config = { + "sheet_name": "Sheet2", + "key_field": "Артикул", + "mapping": { + "article": "Артикул", + "manufacturer": "Производитель" + } + } + parser = ExcelFileParcer(sample_excel_bytes, config) + assert parser.sheet_name == "Sheet2" + + def test_init_with_default_sheet_name(self, sample_excel_bytes): + """Тест инициализации с дефолтным именем листа""" + config = { + "key_field": "Артикул", + "mapping": { + "article": "Артикул", + "manufacturer": "Производитель" + } + } + parser = ExcelFileParcer(sample_excel_bytes, config) + assert parser.sheet_name == 0 + + @patch('mail_order_bot.parsers.excel_parcer.pd.read_excel') + def test_init_with_invalid_file(self, mock_read_excel, sample_config): + """Тест инициализации с невалидным файлом""" + mock_read_excel.side_effect = Exception("Ошибка парсинга") + invalid_bytes = b"invalid excel content" + + parser = ExcelFileParcer(invalid_bytes, sample_config) + + assert parser.df is None + + def test_parse_file_success(self, sample_excel_bytes, sample_config): + """Тест успешного парсинга файла""" + parser = ExcelFileParcer(sample_excel_bytes, sample_config) + assert parser.df is not None + assert len(parser.df) > 0 + + @patch('mail_order_bot.parsers.excel_parcer.pd.read_excel') + def test_parse_file_failure(self, mock_read_excel, sample_config): + """Тест обработки ошибки при парсинге файла""" + mock_read_excel.side_effect = Exception("Ошибка чтения") + invalid_bytes = b"invalid" + + parser = ExcelFileParcer(invalid_bytes, sample_config) + assert parser.df is None + + def test_get_header_row(self, excel_with_header_row, sample_config): + """Тест поиска строки заголовка""" + parser = ExcelFileParcer(excel_with_header_row, sample_config) + header_row = parser._get_header_row() + + assert header_row == 1 # Заголовок во второй строке (индекс 1) + + def test_get_attr_column(self, excel_with_header_row, sample_config): + """Тест поиска индекса колонки по имени""" + parser = ExcelFileParcer(excel_with_header_row, sample_config) + col_idx = parser._get_attr_column("Артикул") + + assert isinstance(col_idx, int) + assert col_idx >= 0 + + def test_get_attr_column_nonexistent(self, excel_with_header_row, sample_config): + """Тест поиска несуществующей колонки""" + parser = ExcelFileParcer(excel_with_header_row, sample_config) + + with pytest.raises((IndexError, KeyError)): + parser._get_attr_column("Несуществующая колонка") + + def test_get_attr_row(self, excel_with_header_row, sample_config): + """Тест поиска строки по артикулу и производителю""" + parser = ExcelFileParcer(excel_with_header_row, sample_config) + row_idx = parser._get_attr_row("ART001", "MAN001") + + assert isinstance(row_idx, (int, pd.core.indexes.numeric.Int64Index)) + # Проверяем, что индекс найден + assert row_idx is not None + + def test_get_attr_row_nonexistent(self, excel_with_header_row, sample_config): + """Тест поиска несуществующей строки""" + parser = ExcelFileParcer(excel_with_header_row, sample_config) + + with pytest.raises((IndexError, KeyError)): + parser._get_attr_row("NONEXISTENT", "NONEXISTENT") + + def test_set_value(self, excel_with_header_row, sample_config): + """Тест установки значения в ячейку""" + parser = ExcelFileParcer(excel_with_header_row, sample_config) + + # Получаем исходное значение + original_value = parser.df.iloc[2, 3] # Строка с ART001, колонка "Цена" + + # Устанавливаем новое значение + parser.set_value("ART001", "MAN001", "Цена", 999.0) + + # Проверяем, что значение изменилось + new_value = parser.df.iloc[2, 3] + assert new_value == 999.0 + assert new_value != original_value + + def test_get_file_bytes(self, sample_excel_bytes, sample_config): + """Тест получения файла в виде байтов""" + parser = ExcelFileParcer(sample_excel_bytes, sample_config) + result_bytes = parser.get_file_bytes() + + assert result_bytes is not None + assert hasattr(result_bytes, 'read') + assert hasattr(result_bytes, 'seek') + + # Проверяем, что можно прочитать байты + result_bytes.seek(0) + content = result_bytes.read() + assert len(content) > 0 + + def test_get_file_bytes_creates_valid_excel(self, sample_excel_bytes, sample_config): + """Тест что get_file_bytes создает валидный Excel файл""" + parser = ExcelFileParcer(sample_excel_bytes, sample_config) + result_bytes = parser.get_file_bytes() + + # Пытаемся прочитать созданный файл + result_bytes.seek(0) + df = pd.read_excel(result_bytes, sheet_name=0, header=None) + + assert df is not None + assert len(df) > 0 + + def test_get_order_rows(self, excel_with_header_row, sample_config): + """Тест получения строк заказа""" + parser = ExcelFileParcer(excel_with_header_row, sample_config) + order_rows = parser.get_order_rows() + + assert order_rows is not None + assert isinstance(order_rows, pd.DataFrame) + assert len(order_rows) > 0 + # Проверяем, что пустая строка обрезана + assert len(order_rows) == 3 # Только строки с данными + + def test_get_order_rows_with_empty_file(self, sample_config): + """Тест получения строк заказа из пустого файла""" + # Создаем пустой DataFrame + df = pd.DataFrame([['Артикул', 'Производитель'], [None, None]]) + buf = BytesIO() + with pd.ExcelWriter(buf, engine='xlsxwriter') as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False, header=False) + buf.seek(0) + empty_bytes = buf.getvalue() + + parser = ExcelFileParcer(empty_bytes, sample_config) + + # Должен вернуть пустой DataFrame или вызвать ошибку + try: + order_rows = parser.get_order_rows() + assert len(order_rows) == 0 + except (IndexError, KeyError): + # Ожидаемое поведение при отсутствии данных + pass + + def test_set_value_updates_dataframe(self, excel_with_header_row, sample_config): + """Тест что set_value обновляет DataFrame""" + parser = ExcelFileParcer(excel_with_header_row, sample_config) + + # Находим строку с ART002 + row_idx = parser._get_attr_row("ART002", "MAN002") + price_col_idx = parser._get_attr_column("Цена") + + original_price = parser.df.iloc[row_idx, price_col_idx] + + # Устанавливаем новое значение + parser.set_value("ART002", "MAN002", "Цена", 555.0) + + # Проверяем обновление + updated_price = parser.df.iloc[row_idx, price_col_idx] + assert updated_price == 555.0 + assert updated_price != original_price + + def test_multiple_set_value_operations(self, excel_with_header_row, sample_config): + """Тест множественных операций set_value""" + parser = ExcelFileParcer(excel_with_header_row, sample_config) + + # Устанавливаем несколько значений + parser.set_value("ART001", "MAN001", "Цена", 111.0) + parser.set_value("ART002", "MAN002", "Цена", 222.0) + parser.set_value("ART003", "MAN003", "Цена", 333.0) + + # Проверяем все значения + price_col_idx = parser._get_attr_column("Цена") + row1_idx = parser._get_attr_row("ART001", "MAN001") + row2_idx = parser._get_attr_row("ART002", "MAN002") + row3_idx = parser._get_attr_row("ART003", "MAN003") + + assert parser.df.iloc[row1_idx, price_col_idx] == 111.0 + assert parser.df.iloc[row2_idx, price_col_idx] == 222.0 + assert parser.df.iloc[row3_idx, price_col_idx] == 333.0 + + def test_get_order_rows_trimmed_correctly(self, sample_config): + """Тест что get_order_rows правильно обрезает пустые строки""" + # Создаем файл с пустой строкой в середине + df = pd.DataFrame([ + ['Артикул', 'Производитель', 'Наименование'], + ['ART001', 'MAN001', 'Товар 1'], + ['ART002', 'MAN002', 'Товар 2'], + [None, None, None], # Пустая строка + ['ART003', 'MAN003', 'Товар 3'], + [None, None, None] # Еще одна пустая строка + ]) + buf = BytesIO() + with pd.ExcelWriter(buf, engine='xlsxwriter') as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False, header=False) + buf.seek(0) + excel_bytes = buf.getvalue() + + parser = ExcelFileParcer(excel_bytes, sample_config) + order_rows = parser.get_order_rows() + + # Должны остаться только строки до первой пустой + assert len(order_rows) == 2 # ART001 и ART002 + + @patch('mail_order_bot.parsers.excel_parcer.pd.read_excel') + def test_get_order_rows_with_calamine_engine(self, mock_read_excel, sample_config): + """Тест что get_order_rows использует calamine engine""" + # Создаем мок DataFrame + mock_df = pd.DataFrame({ + 'Артикул': ['ART001', 'ART002', None], + 'Производитель': ['MAN001', 'MAN002', None] + }) + mock_read_excel.return_value = mock_df + + # Создаем парсер с моком для первого чтения + df_init = pd.DataFrame([ + ['Артикул', 'Производитель'], + ['ART001', 'MAN001'], + ['ART002', 'MAN002'], + [None, None] + ]) + with patch('mail_order_bot.parsers.excel_parcer.pd.read_excel') as mock_init: + mock_init.return_value = df_init + parser = ExcelFileParcer(b"test", sample_config) + + # Тестируем get_order_rows + with patch('mail_order_bot.parsers.excel_parcer.pd.read_excel') as mock_get: + mock_get.return_value = mock_df + result = parser.get_order_rows() + + # Проверяем, что был вызван read_excel с engine='calamine' + mock_get.assert_called_once() + call_kwargs = mock_get.call_args[1] + assert call_kwargs.get('engine') == 'calamine' +