diff --git a/src/mail_order_bot/config.yml b/src/mail_order_bot/config.yml index 3837277..20a9461 100644 --- a/src/mail_order_bot/config.yml +++ b/src/mail_order_bot/config.yml @@ -6,14 +6,17 @@ clients: enabled: true client_id: 6148154 # Сейчас стоит айдишник Димы для тестовых заказов + refusal_threshold: 0.01 + pipeline: - ExcelExtractor - DeliveryPeriodFromConfig - OrderExtractor - StockSelector - UpdateExcelFile - - SaveOrderToTelegram - - EmailReplyTask + - TelegramNotifier + #- EmailReplyTask + #- EmailForwardErrorTask excel: sheet_name: 0 @@ -48,13 +51,13 @@ log: formatters: standard: - format: '%(asctime)s %(module)15s [%(levelname)8s]: %(message)s' + format: '%(asctime)s %(module)18s [%(levelname)8s]: %(message)s' telegram: format: '%(message)s' handlers: console: - level: WARNING + level: DEBUG formatter: standard class: logging.StreamHandler stream: ext://sys.stdout # Default is stderr @@ -79,7 +82,7 @@ log: loggers: '': handlers: [console, file, telegram] - level: WARNING + level: INFO propagate: False __main__: @@ -90,4 +93,8 @@ log: config_manager: handlers: [console, file] level: ERROR + + utils: + handlers: [ console, file ] + level: DEBUG \ No newline at end of file diff --git a/src/mail_order_bot/email_client/utils.py b/src/mail_order_bot/email_client/utils.py index 111c0d1..7c12fa7 100644 --- a/src/mail_order_bot/email_client/utils.py +++ b/src/mail_order_bot/email_client/utils.py @@ -23,9 +23,9 @@ class EmailUtils: @staticmethod def extract_email(text) -> str: match = re.search(r'<([^<>]+)>', text) - if match: - return match.group(1) - return None + email = match.group(1) if match else None + logger.debug(f"Extracted email: {email}") + return email @staticmethod def extract_body(msg: email.message.Message) -> str: @@ -53,7 +53,7 @@ class EmailUtils: body = payload.decode(charset, errors='ignore') except Exception: pass - + logger.debug(f"Extracted body: {body}") return body @staticmethod @@ -84,6 +84,8 @@ class EmailUtils: if content: #attachments.append(EmailAttachment(filename=filename, content=content)) attachments.append({"name": filename, "bytes": content}) + logger.debug(f"Extracted attachment {filename}") + logger.debug(f"Extracted attachments: {len(attachments)}") return attachments diff --git a/src/mail_order_bot/main.py b/src/mail_order_bot/main.py index 4e41b9b..1ec098a 100644 --- a/src/mail_order_bot/main.py +++ b/src/mail_order_bot/main.py @@ -22,6 +22,8 @@ class MailOrderBot(ConfigManager): def __init__(self, *agrs, **kwargs): super().__init__(*agrs, **kwargs) + self.context = Context() + # Объявить почтового клиента self.email_client = EmailClient( imap_host=os.getenv('IMAP_HOST'), @@ -33,24 +35,17 @@ class MailOrderBot(ConfigManager): ) # Сохранить почтовый клиент в контекст - self.context = Context() self.context.email_client = self.email_client - - # Обработчик писем - #self.email_processor = TaskProcessor("./configs") - - config = self.config.get("clients") - self.email_processor = TaskProcessor(config) + self.email_processor = None logger.warning("MailOrderBot инициализирован") def execute(self): #Получить список айдишников письма - - folder = self.config.get("folder") + email_folder = self.config.get("folder") try: - unread_email_ids = self.email_client.get_emails_id(folder=folder) - logger.info(f"Новых писем - {len(unread_email_ids)}") + unread_email_ids = self.email_client.get_emails_id(folder=email_folder) + logger.warning(f"Новых писем - {len(unread_email_ids)}") # Обработать каждое письмо по идентификатору for email_id in unread_email_ids: @@ -58,16 +53,23 @@ class MailOrderBot(ConfigManager): logger.info(f"Обработка письма с идентификатором {email_id}") # Получить письмо по идентификатору и запустить его обработку email = self.email_client.get_email(email_id, mark_as_read=False) + + # Подтягиваем обновленный конфиг + self.email_processor = TaskProcessor(self.config.get("clients")) self.email_processor.process_email(email) except MailOrderBotException as e: - logger.error(f"Произошла ошибка {e}") + logger.critical(f"Не получилось обработать письмо {e}") + continue except MailOrderBotException as e: - logger.error(f"Произошла ошибка {e}") + logger.critical(f"Произошла ошибка в основном цикле {e}") except Exception as e: - logger.error(f"Произошла непредвиденная ошибка {e}") + logger.critical(f"Произошла непредвиденная ошибка в основном цикле: {e}") + raise Exception from e + + logger.warning("Обработка писем завершена") diff --git a/src/mail_order_bot/order/auto_part_order.py b/src/mail_order_bot/order/auto_part_order.py index 59fdc91..53c5ee7 100644 --- a/src/mail_order_bot/order/auto_part_order.py +++ b/src/mail_order_bot/order/auto_part_order.py @@ -1,4 +1,8 @@ from typing import List, Optional +from decimal import Decimal + +from openpyxl.pivot.fields import Boolean + from .auto_part_position import AutoPartPosition, PositionStatus from enum import Enum @@ -36,18 +40,33 @@ class AutoPartOrder: def set_delivery_period(self, delivery_period: int) -> None: self.delivery_period = delivery_period - def check_order(self, config) -> None: + def get_refusal_level(self) -> float: """ Проверяет заказ на возможность исполнения""" # 1. Проверка общего количества отказов - order_refusal_threshold = config.get("order_refusal_threshold", 1) - refusal_positions_count = len([position for position in self.positions if str(position.status) in - [PositionStatus.REFUSED, PositionStatus.STOCK_FAILED]]) - - order_refusal_rate = refusal_positions_count / len(self.positions) - if order_refusal_rate > order_refusal_threshold: - self.errors.append(f"Превышен порог отказов в заказе - {order_refusal_rate:.0%} " - f"({refusal_positions_count} из {len(self.positions)})") - self.status = OrderStatus.OPERATOR_REQUIRED + refusal_positions_count = len([position for position in self.positions if position.status != PositionStatus.READY]) + order_refusal_rate = float(refusal_positions_count) / len(self.positions) + return order_refusal_rate def __len__(self): - return len(self.positions) \ No newline at end of file + return len(self.positions) + + def get_order_description(self): + + message = f"Срок доставки: {self.delivery_period} (в часах)\n\n" + message += f"Позиции в заказе:\n" + + for position in self.positions: + message += f"{position.manufacturer}, {position.name} [{position.sku}] \n" + message += f"{position.asking_quantity} шт. x {position.asking_price:.2f} р. = {position.total:.2f} " + + rejected = position.asking_quantity - position.order_quantity + if position.order_quantity == 0: + message += f" - отказ\n" + elif rejected: + message += f" - отгружено частично {position.order_quantity}\n" #, (запрошено, {position.asking_quantity}, отказ: {rejected})\n" + message += f"Профит {position.profit:.1%}\n" + else: + message += f" - отгружено\n" + message += f"Профит {position.profit:.1%} (закупка: {Decimal(position.order_item.get("price")):.2f})\n" + message += "\n" + return message diff --git a/src/mail_order_bot/order/auto_part_position.py b/src/mail_order_bot/order/auto_part_position.py index 5fe7b59..0e37e19 100644 --- a/src/mail_order_bot/order/auto_part_position.py +++ b/src/mail_order_bot/order/auto_part_position.py @@ -2,10 +2,10 @@ from typing import List, Optional from dataclasses import dataclass, field from typing import Dict, Any from decimal import Decimal -from enum import Enum +from enum import StrEnum -class PositionStatus(Enum): +class PositionStatus(StrEnum): NEW = "new" # Новая позиция STOCK_RECIEVED = "stock_received" # Получен остаток STOCK_FAILED = "stock_failed" # Остаток не получен @@ -63,8 +63,9 @@ class AutoPartPosition: # Устанавливаем актуальный срок доставки self.order_delivery_period = self.order_item.get("deliveryPeriod") - # ФИксируем профит. Для инфо/отчетности - self.profit = (self.asking_price - Decimal(self.order_item.get("price"))) * self.order_quantity + # Фиксируем профит. Для инфо/отчетности + buy_price = Decimal(self.order_item.get("price")) + self.profit = (self.asking_price - buy_price)/buy_price * self.order_quantity # Устанавливаем статус self.status = PositionStatus.READY diff --git a/src/mail_order_bot/task_processor/__init__.py b/src/mail_order_bot/task_processor/__init__.py index b0b5e64..95201b3 100644 --- a/src/mail_order_bot/task_processor/__init__.py +++ b/src/mail_order_bot/task_processor/__init__.py @@ -1,4 +1,9 @@ from .processor import TaskProcessor from .message import LogMessage, LogMessageLevel, LogMessageStorage -from .abstract_task import AbstractTask, pass_if_error, handle_errors \ No newline at end of file +from .abstract_task import AbstractTask, pass_if_error, handle_errors + +from .attachment_status import AttachmentStatus + +from .exceptions import TaskException + diff --git a/src/mail_order_bot/task_processor/abstract_task.py b/src/mail_order_bot/task_processor/abstract_task.py index fd0c101..f8b6107 100644 --- a/src/mail_order_bot/task_processor/abstract_task.py +++ b/src/mail_order_bot/task_processor/abstract_task.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod import logging import functools +from .attachment_status import AttachmentStatus from mail_order_bot.context import Context logger = logging.getLogger(__name__) @@ -25,8 +26,8 @@ def handle_errors(func): except Exception as e: # При ошибке устанавливаем статус и логируем if attachment: - attachment["status"] = "error" - logger.error(f"Ошибка при обработке файла {file_name} на стадии {self.STEP} \n{e}", exc_info=True) + attachment["status"] = AttachmentStatus.FAILED + logger.error(f"Ошибка при обработке вложения {file_name} на стадии {self.__class__.__name__} \n{e}", exc_info=True) # Пробрасываем исключение дальше # raise return wrapper @@ -41,7 +42,7 @@ def pass_if_error(func): @functools.wraps(func) def wrapper(self, attachment) -> None: # Проверяем статус перед выполнением - if attachment and attachment.get("status") != "ok": + if attachment and attachment.get("status") != AttachmentStatus.OK: file_name = attachment.get("name", "неизвестный файл") logger.warning(f"Пропускаем шаг для файла {file_name}, статус {attachment.get('status')}") return @@ -53,7 +54,7 @@ def pass_if_error(func): class AbstractTask(): - STEP = "Название шага обработки" + STEP_NAME = "Название шага обработки" """ Абстрактный базовый класс для всех хэндлеров. diff --git a/src/mail_order_bot/task_processor/attachment_status.py b/src/mail_order_bot/task_processor/attachment_status.py new file mode 100644 index 0000000..5b72b75 --- /dev/null +++ b/src/mail_order_bot/task_processor/attachment_status.py @@ -0,0 +1,6 @@ +from enum import StrEnum + +class AttachmentStatus(StrEnum): + OK = "все в порядке" + FAILED = "ошибка" + NOT_A_ORDER = "не является заказом" diff --git a/src/mail_order_bot/task_processor/exceptions.py b/src/mail_order_bot/task_processor/exceptions.py new file mode 100644 index 0000000..0fb90e6 --- /dev/null +++ b/src/mail_order_bot/task_processor/exceptions.py @@ -0,0 +1,11 @@ +class TaskException(Exception): + """Базовый класс исключений.""" + pass + +class TaskExceptionWithEmailNotify(TaskException): + """Базовый класс исключений с уведомлением по почте.""" + pass + +class TaskExceptionSilent(TaskException): + """Базовый класс исключений без уведомления.""" + pass \ No newline at end of file diff --git a/src/mail_order_bot/task_processor/file_validator.py b/src/mail_order_bot/task_processor/file_validator.py new file mode 100644 index 0000000..bad4d3f --- /dev/null +++ b/src/mail_order_bot/task_processor/file_validator.py @@ -0,0 +1,49 @@ +""" +Модуль для проверки типов файлов +""" +import logging + +logger = logging.getLogger(__name__) + + +def is_spreadsheet_file(file_bytes: bytes) -> bool: + """ + Проверяет, является ли файл электронной таблицей (XLS, XLSX, ODS). + + Проверка выполняется по магическим байтам (magic bytes) в начале файла: + - XLSX: начинается с PK (это ZIP архив, который начинается с байтов 50 4B) + - XLS: начинается с D0 CF 11 E0 (старый формат Office) + - ODS: также ZIP архив, но для нашего случая проверяем только XLS/XLSX + + Args: + file_bytes: Байты файла для проверки + + Returns: + True, если файл является электронной таблицей, False в противном случае + """ + if not file_bytes or len(file_bytes) < 8: + return False + + # Проверка на XLSX (ZIP формат, начинается с PK) + # XLSX файлы - это ZIP архивы, которые начинаются с байтов 50 4B 03 04 + if file_bytes[:2] == b'PK': + # Дополнительная проверка: внутри ZIP должен быть файл [Content_Types].xml + # Но для простоты проверим, что это действительно ZIP архив + # Стандартный ZIP начинается с PK\x03\x04 или PK\x05\x06 (пустой архив) + if file_bytes[:4] in (b'PK\x03\x04', b'PK\x05\x06', b'PK\x07\x08'): + logger.debug("Обнаружен файл формата XLSX (ZIP/Office Open XML)") + return True + + # Проверка на XLS (старый бинарный формат Office) + # XLS файлы начинаются с D0 CF 11 E0 A1 B1 1A E1 (OLE2 compound document) + if file_bytes[:8] == b'\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1': + logger.debug("Обнаружен файл формата XLS (старый формат Office)") + return True + + # Более мягкая проверка на XLS - только первые 4 байта + if file_bytes[:4] == b'\xD0\xCF\x11\xE0': + logger.debug("Обнаружен файл формата XLS (по первым 4 байтам)") + return True + + return False + diff --git a/src/mail_order_bot/task_processor/handlers/__init__.py b/src/mail_order_bot/task_processor/handlers/__init__.py index cdf0dac..a2b5092 100644 --- a/src/mail_order_bot/task_processor/handlers/__init__.py +++ b/src/mail_order_bot/task_processor/handlers/__init__.py @@ -1,11 +1,11 @@ -from .abcp._api_get_stock import APIGetStock +#from .abcp._api_get_stock import APIGetStock from .delivery_time.local_store import DeliveryPeriodLocalStore from .delivery_time.from_config import DeliveryPeriodFromConfig -from .notifications.test_notifier import TestNotifier +from .notifications.test_notifier import TelegramNotifier from .excel_parcers.excel_extractor import ExcelExtractor from .excel_parcers.order_extractor import OrderExtractor -from .abcp.api_save_order import SaveOrderToTelegram +#from .abcp.api_save_order import SaveOrderToTelegram from .stock_selectors.stock_selector import StockSelector @@ -13,4 +13,6 @@ from .excel_parcers.update_excel_file import UpdateExcelFile from .email.email_reply_task import EmailReplyTask +from .email.email_forward_error_task import EmailForwardErrorTask + 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 deleted file mode 100644 index af02452..0000000 --- a/src/mail_order_bot/task_processor/handlers/abcp/_api_get_stock.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -Перебирает аттачменты -Для каждого ордера в аттачменте перебирает позиции -Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/ -Возможно логику выбора позиции надо вынести из позиции, но пока так -""" -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__) - - -class APIGetStock(AbstractTask): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - credential_provider = CredentialProvider(context=self.context) - - # Создаем провайдер для учетной записи клиента - client_login, client_password = credential_provider.get_client_credentials() - self.client_provider = AbcpProvider(login=client_login, password=client_password) - - def do(self, attachment) -> None: - # - order = attachment.get("order", None) - 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: - 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 deleted file mode 100644 index 1d0f543..0000000 --- a/src/mail_order_bot/task_processor/handlers/abcp/api_save_order.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Перебирает аттачменты -Для каждого ордера в аттачменте перебирает позиции -Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/ -Возможно логику выбора позиции надо вынести из позиции, но пока так -""" -import logging - -from ...abstract_task import AbstractTask, pass_if_error, handle_errors -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) - - @pass_if_error - @handle_errors - def do(self, attachment) -> None: - client = TelegramClient() - - 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.warning("Инфо по заказу отправлено в телеграм") - - #=============================== - - - diff --git a/src/mail_order_bot/task_processor/handlers/delivery_time/from_config.py b/src/mail_order_bot/task_processor/handlers/delivery_time/from_config.py index ced29c3..228eb33 100644 --- a/src/mail_order_bot/task_processor/handlers/delivery_time/from_config.py +++ b/src/mail_order_bot/task_processor/handlers/delivery_time/from_config.py @@ -14,8 +14,11 @@ class DeliveryPeriodFromConfig(AbstractTask): super().__init__(*args, **kwargs) @pass_if_error - @handle_errors def do(self, attachment) -> None: - delivery_period = self.config.get("delivery_period") - attachment["delivery_period"] = delivery_period - logger.warning(f"Срок доставки установлен из конфига - {delivery_period} (ч.)") + try: + delivery_period = self.config.get("delivery_period") + attachment["delivery_period"] = delivery_period + logger.warning(f"Срок доставки установлен из конфига - {delivery_period} (ч.)") + except Exception as e: + raise Exception(f"Ошибка при установке срока доставки из конфига. Детали ошибки: {e}") + diff --git a/src/mail_order_bot/task_processor/handlers/email/email_forward_error_task.py b/src/mail_order_bot/task_processor/handlers/email/email_forward_error_task.py new file mode 100644 index 0000000..d492b75 --- /dev/null +++ b/src/mail_order_bot/task_processor/handlers/email/email_forward_error_task.py @@ -0,0 +1,100 @@ +import logging + +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.message import MIMEMessage +from email.utils import formatdate + +from ...abstract_task import AbstractTask, pass_if_error, handle_errors +from ...attachment_status import AttachmentStatus + + +logger = logging.getLogger(__name__) + + +class EmailForwardErrorTaskException(Exception): + pass + + +class EmailForwardErrorTask(AbstractTask): + STEP_NAME = "Уведомление сотрудника об ошибке" + + """Пересылает письмо как вложение на заданный адрес при ошибке обработки""" + EMAIL = "zosimovaa@yandex.ru" # Адрес получателя пересылки + ERROR_RECIPIENT = "lesha.spb@gmail.com" # Адрес для пересылки ошибок + + #@handle_errors + def do(self, attachment=None): + """ + Пересылает письмо из контекста как вложение на заданный адрес + + Args: + attachment: Не используется, оставлен для совместимости с AbstractTask + """ + + if attachment: # and attachment.get("status") == AttachmentStatus.FAILED: + + email = self.context.data.get("email") + + if not email: + raise ValueError("В контексте нет входящего сообщения") + + email_subj = f"[ОШИБКА СОЗДАНИЯ ЗАКАЗА] - {self.context.data.get("email_subj", "Без темы")}]" + + # Создаем новое сообщение для пересылки + forward_message = MIMEMultipart() + + forward_message["From"] = self.EMAIL + forward_message["To"] = self.ERROR_RECIPIENT + forward_message["Subject"] = email_subj + forward_message["Date"] = formatdate(localtime=True) + + # Добавляем текстовый комментарий в тело письма + + body = "Ошибка обработки письма\n" + body += f"{attachment.get("error", "Нет данных по ошибке")}\n" + order = attachment.get("order") + if order is not None: + body += order.get_order_description() + else: + body += "Заказ не был распарсен" + + forward_message.attach(MIMEText(body, "plain", "utf-8")) + + # Прикрепляем исходное письмо как вложение + self._attach_email(forward_message, email) + + # Отправляем письмо + self.context.email_client.send_email(forward_message) + + logger.warning(f"Письмо переслано как вложение на {self.ERROR_RECIPIENT}") + + else: + logger.warning(f"Все окей, никуда ничего пересылать не надо") + + def _attach_email(self, forward_message, email_message): + """ + Прикрепляет исходное письмо как вложение к сообщению + + Args: + forward_message: MIMEMultipart сообщение, к которому прикрепляем + email_message: email.message.Message - исходное письмо для пересылки + """ + try: + # Создаем MIMEMessage из исходного письма + msg_part = MIMEMessage(email_message) + + # Устанавливаем имя файла для вложения + email_subj = self.context.data.get("email_subj", "message") + # Очищаем тему от недопустимых символов для имени файла + safe_subj = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in email_subj[:50]) + msg_part.add_header( + "Content-Disposition", + f'attachment; filename="forwarded_email_{safe_subj}.eml"' + ) + + forward_message.attach(msg_part) + + except Exception as e: + raise Exception(f"Ошибка при прикреплении письма: {str(e)}") + 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 1eece3a..e9bb888 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 @@ -35,7 +35,7 @@ class EmailParcer(AbstractTask): email_from_domain = EmailUtils.extract_domain(email_from) self.context.data["email_from_domain"] = email_from_domain - email_subj = EmailUtils.extract_header(email, "subj") + email_subj = EmailUtils.extract_header(email, "subject") self.context.data["email_subj"] = email_subj client = EmailUtils.extract_domain(email_from) diff --git a/src/mail_order_bot/task_processor/handlers/email/email_reply_task.py b/src/mail_order_bot/task_processor/handlers/email/email_reply_task.py index e38e0bd..3408b6a 100644 --- a/src/mail_order_bot/task_processor/handlers/email/email_reply_task.py +++ b/src/mail_order_bot/task_processor/handlers/email/email_reply_task.py @@ -1,5 +1,5 @@ import logging - +import os from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.base import MIMEBase @@ -20,7 +20,7 @@ class EmailReplyTask(AbstractTask): EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru" @pass_if_error - @handle_errors + #@handle_errors def do(self, attachment): email = self.context.data.get("email") @@ -37,7 +37,7 @@ class EmailReplyTask(AbstractTask): email_subj = self.context.data.get("email_subj") - reply_message["From"] = self.EMAIl + reply_message["From"] = os.environ.get("EMAIL_USER") reply_message["To"] = email_from #reply_message["Cc"] = self.config.get("reply_to", "") reply_message["Subject"] = f"Re: {email_subj}" 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 41005c3..e9afd7b 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 @@ -1,12 +1,13 @@ import logging from io import BytesIO -from ...abstract_task import AbstractTask, pass_if_error, handle_errors -from ....parsers.excel_parcer import ExcelFileParcer +from mail_order_bot.task_processor.abstract_task import AbstractTask, pass_if_error, handle_errors +from mail_order_bot.task_processor.handlers.excel_parcers.order_extractor import ExcelFileParcer logger = logging.getLogger(__name__) class ExcelExtractor(AbstractTask): + STEP_NAME = "Парсинг эксель файла" """ Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст """ @@ -15,7 +16,7 @@ class ExcelExtractor(AbstractTask): self.excel_config = self.config.get("excel", {}) @pass_if_error - @handle_errors + #@handle_errors def do(self, attachment) -> None: file_bytes = BytesIO(attachment['bytes']) excel_file = ExcelFileParcer(file_bytes, self.excel_config) 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 index f9349ea..fbf6b2a 100644 --- 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 @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) class OrderExtractor(AbstractTask): - STEP = "Извлечение заказа" + STEP_NAME = "Парсинг заказа" """ Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст """ @@ -19,7 +19,7 @@ class OrderExtractor(AbstractTask): self.excel_config = self.config.get("excel", {}) @pass_if_error - @handle_errors + #@handle_errors def do(self, attachment) -> None: # todo сделать проверку на наличие файла и его тип delivery_period = attachment.get("delivery_period", 0) 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 bd859c7..937e344 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 @@ -5,6 +5,8 @@ logger = logging.getLogger(__name__) class UpdateExcelFile(AbstractTask): + STEP_NAME = "Обновление файла заказа" + """ Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст """ @@ -14,7 +16,7 @@ class UpdateExcelFile(AbstractTask): self.excel_config = self.config.get("excel", {}) @pass_if_error - @handle_errors + #@handle_errors def do(self, attachment) -> None: # todo сделать проверку на наличие файла и его тип excel_file = attachment.get("excel") 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 458ef10..93e7f71 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,15 +1,58 @@ +""" +Перебирает аттачменты +Для каждого ордера в аттачменте перебирает позиции +Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/ +Возможно логику выбора позиции надо вынести из позиции, но пока так +""" import logging -from mail_order_bot.task_processor.abstract_task import AbstractTask +from ...abstract_task import AbstractTask, pass_if_error, handle_errors +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 TestNotifier(AbstractTask): - def do(self) -> None: - positions = self.context["positions"] +class TelegramNotifier(AbstractTask): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @pass_if_error + # @handle_errors + def do(self, attachment) -> None: + message = self.build_message(attachment) + + client = TelegramClient() + result = client.send_message(message) + + # Отправка экселя в телеграм + excel = attachment["excel"] + file = excel.get_file_bytes() + client.send_document( + document=file, + filename=attachment.get("name", "document.xlsx") + ) + + logger.warning("Инфо по заказу отправлено в телеграм") + + def build_message(self, attachment): + order = attachment["order"] + file_name = attachment["name"] + # positions = order.positions + + sender_email = self.context.data.get("email_from") + email_subject = self.context.data.get("email_subj") + + message = "=============================\n" + message += f"Обработка заказа от {sender_email}\n" + message += f"тема письма: {email_subject}\n" + message += f"файл: {file_name}\n" + + message += order.get_order_description() + return message + # =============================== + + - print(f"\nПолучено {len(positions)} позиций от {self.context["client"]}:") - for pos in positions: # Первые 5 - print(f" - {pos.sku}: {pos.name} " - 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 3455928..a0d5228 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 @@ -15,12 +15,19 @@ 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 ...exceptions import TaskException from typing import Dict, Any from typing import List, Optional logger = logging.getLogger(__name__) +class StockSelectorException(TaskException): + pass + +class RefusalLevelExceededException(TaskException): + pass + class StockSelector(AbstractTask): DISTRIBUTOR_ID = 1577730 # ID локального склада @@ -36,7 +43,7 @@ class StockSelector(AbstractTask): self.client_provider = AbcpProvider(login=client_login, password=client_password) @pass_if_error - @handle_errors + #@handle_errors def do(self, attachment) -> None: # todo сделать проверку на наличие файла и его тип order = attachment.get("order", None) @@ -64,11 +71,15 @@ class StockSelector(AbstractTask): else: position.status = PositionStatus.STOCK_FAILED + refusal_threshold = self.config.get("refusal_threshold", 1) + refusal_level = order.get_refusal_level() + + if refusal_level > refusal_threshold: + raise RefusalLevelExceededException(f"Превышен лимит по отказам, необходима ручная обработка. " + f"Уровень отказов: {refusal_level:.2%}, допустимый лимит: {refusal_threshold:.2%}") + logger.warning("Определены оптимальные позиции со складов") - - - def get_optimal_stock(self, stock_list, asking_price, asking_quantity, delivery_period): """Выбирает позицию для заказа""" @@ -76,7 +87,7 @@ class StockSelector(AbstractTask): stock_list = self._br1_only_local_stock(stock_list) # BR-2. Цена не должна превышать цену из заказа - #stock_list = self._br2_price_below_asked_price(stock_list, asking_price) + 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) diff --git a/src/mail_order_bot/task_processor/processor.py b/src/mail_order_bot/task_processor/processor.py index 9a4e697..ffa47a5 100644 --- a/src/mail_order_bot/task_processor/processor.py +++ b/src/mail_order_bot/task_processor/processor.py @@ -30,86 +30,109 @@ todo """ -import os -import yaml + import logging -from typing import Dict, Any, List -from pathlib import Path -import threading +from typing import Dict, Any + from mail_order_bot.context import Context 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.email.email_parcer import EmailParcer -from mail_order_bot.task_processor.message import LogMessage, LogMessageLevel, LogMessageStorage +from .attachment_status import AttachmentStatus + + +from .handlers.email.email_parcer import EmailParcer + +from .file_validator import is_spreadsheet_file logger = logging.getLogger(__name__) -class RequestStatus(Enum): - NEW = "new" - IN_PROGRESS = "in progress" - FAILED = "failed" - EXECUTED = "executed" - OPERATOR_REQUIRED = "operator required" - INVALID = "invalid" class TaskProcessor: #def __init__(self, configs_path: str): def __init__(self, config: Dict[str, Any]): super().__init__() - self.context = Context() - #self.configs_path = configs_path self.config = config - self.status = RequestStatus.NEW + self.context = Context() def process_email(self, email): # Очистить контекст и запушить туда письмо self.context.clear() self.context.data["email"] = email - try: - # Парсинг письма - email_parcer = EmailParcer() - email_parcer.do() + # Парсинг письма + #email_parcer = EmailParcer() + #email_parcer.do() + self.parse_email(email) - email_sender = self.context.data.get("email_from") - - # Определить конфиг для пайплайна - config = self._load_config(email_sender) - self.context.data["config"] = config - - pipeline = config["pipeline"] + # Определить конфиг для пайплайна + email_sender = self.context.data.get("email_from") + config = self._load_config(email_sender) + self.context.data["config"] = config + if config.get("enabled", False) == True: attachments = self.context.data.get("attachments", []) + + if not len(attachments): + logger.warning(f"В письме от {email_sender} нет вложений, пропускаем обработку") + for attachment in attachments: - file_name = attachment["name"] - logger.warning(f"Начата обработка файла: {file_name} =>") + try: + file_name = attachment.get("name", "неизвестный файл") + logger.warning(f"==================================================") + logger.warning(f"Начата обработка файла: {file_name}") - #attachment["log_messages"] = LogMessageStorage(file_name) - attachment["status"] = "ok" + # Проверка на тип файла - должен быть файлом электронных таблиц + file_bytes = attachment.get("bytes") + if not file_bytes or not is_spreadsheet_file(file_bytes): + logger.warning(f"Файл {file_name} не является файлом электронных таблиц, пропускаем обработку") + attachment["status"] = AttachmentStatus.NOT_A_ORDER + continue - # Запустить обработку пайплайна - for handler_name in pipeline: - logger.info(f"Processing handler: {handler_name}") - task = globals()[handler_name]() - task.do(attachment) + attachment["status"] = AttachmentStatus.OK - except Exception as e: - logger.error(f"Произошла ошибка: {e}") + # Запустить обработку пайплайна + pipeline = config["pipeline"] + for handler_name in pipeline: + logger.info(f"Processing handler: {handler_name}") + task = globals()[handler_name]() + task.do(attachment) - def _load_config(self, email_from) -> Dict[str, Any]: - if email_from in self.config: - return self.config[email_from] + except Exception as e: + logger.error(f"Ошибка при обработке файла {file_name}: {e}") + attachment["error"] = e + notifier = EmailForwardErrorTask() + notifier.do(attachment) - email_from_domain = EmailUtils.extract_domain(email_from) + else: + logger.info(f"Обработка писем для {email_sender} отключена. Значение в конфиге: {config.get("enabled")}") - if email_from_domain in self.config: - return self.config[email_from_domain] + def _load_config(self, sender_email) -> Dict[str, Any]: + if sender_email in self.config: + return self.config[sender_email] + + sender_domain = EmailUtils.extract_domain(sender_email) + if sender_domain in self.config: + return self.config[sender_domain] + + # Для всех ненастроенных клиентов возвращаем конфиг с "отключенной" обработкой + return {} + + def parse_email(self, email): + # todo при переводе на основной ящик переделать на другую функцию + 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 + + self.context.data["email_body"] = EmailUtils.extract_body(email) + self.context.data["email_from_domain"] = EmailUtils.extract_domain(email_from) + self.context.data["email_subj"] = EmailUtils.extract_header(email, "subject") + self.context.data["client"] = EmailUtils.extract_domain(email_from) + self.context.data["attachments"] = EmailUtils.extract_attachments(email) + logger.info(f"Извлечено вложений: {len(self.context.data["attachments"])}") - raise FileNotFoundError