3 Commits

26 changed files with 500 additions and 282 deletions

View File

@@ -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__:
@@ -91,3 +94,7 @@ log:
handlers: [console, file]
level: ERROR
utils:
handlers: [ console, file ]
level: DEBUG

View File

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

View File

@@ -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("Обработка писем завершена")

View File

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

View File

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

View File

@@ -14,10 +14,7 @@ class ExcelFileParcer:
def _parse_file(self, file_bytes):
"""Парсит вложение в формате эл таблиц"""
try:
df = pd.read_excel(file_bytes, sheet_name=self.sheet_name, header=None)
except Exception as e:
df = None
return df
def set_value(self, sku, manufacturer, column, value):

View File

@@ -1,2 +1,9 @@
from .processor import TaskProcessor
from .message import LogMessage, LogMessageLevel, LogMessageStorage
from .abstract_task import AbstractTask, pass_if_error, handle_errors
from .attachment_status import AttachmentStatus
from .exceptions import TaskException

View File

@@ -1,11 +1,61 @@
from abc import ABC, abstractmethod
from typing import Dict, Any
import logging
import functools
from .attachment_status import AttachmentStatus
from mail_order_bot.context import Context
logger = logging.getLogger(__name__)
def handle_errors(func):
"""
Декоратор для обработки ошибок в методе do класса AbstractTask.
Оборачивает выполнение метода в try-except, при ошибке устанавливает статус "error",
логирует ошибку и пробрасывает исключение дальше.
Применяется везде к методу do.
"""
@functools.wraps(func)
def wrapper(self, attachment) -> None:
file_name = attachment.get("name", "неизвестный файл")
try:
# Выполняем метод do
return func(self, attachment)
except Exception as e:
# При ошибке устанавливаем статус и логируем
if attachment:
attachment["status"] = AttachmentStatus.FAILED
logger.error(f"Ошибка при обработке вложения {file_name} на стадии {self.__class__.__name__} \n{e}", exc_info=True)
# Пробрасываем исключение дальше
# raise
return wrapper
def pass_if_error(func):
"""
Декоратор для проверки статуса attachment перед выполнением метода do.
Если статус attachment["status"] != "ok", метод не выполняется.
Применяется опционально в конкретных классах, где нужна проверка статуса.
"""
@functools.wraps(func)
def wrapper(self, attachment) -> None:
# Проверяем статус перед выполнением
if attachment and attachment.get("status") != AttachmentStatus.OK:
file_name = attachment.get("name", "неизвестный файл")
logger.warning(f"Пропускаем шаг для файла {file_name}, статус {attachment.get('status')}")
return
# Выполняем метод do
return func(self, attachment)
return wrapper
class AbstractTask():
RESULT_SECTION = "section"
STEP_NAME = "Название шага обработки"
"""
Абстрактный базовый класс для всех хэндлеров.
"""

View File

@@ -0,0 +1,6 @@
from enum import StrEnum
class AttachmentStatus(StrEnum):
OK = "все в порядке"
FAILED = "ошибка"
NOT_A_ORDER = "не является заказом"

View File

@@ -0,0 +1,11 @@
class TaskException(Exception):
"""Базовый класс исключений."""
pass
class TaskExceptionWithEmailNotify(TaskException):
"""Базовый класс исключений с уведомлением по почте."""
pass
class TaskExceptionSilent(TaskException):
"""Базовый класс исключений без уведомления."""
pass

View File

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

View File

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

View File

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

View File

@@ -1,62 +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.telegram.client import TelegramClient
logger = logging.getLogger(__name__)
class SaveOrderToTelegram(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self, attachment) -> None:
client = TelegramClient()
try:
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"
)
except Exception as e:
logger.error("Ошибка при отправке инфо по заказу в телеграм")
else:
logger.warning("Инфо по заказу отправлено в телеграм")
#===============================

View File

@@ -5,7 +5,8 @@
import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from ...exceptions import TaskExceptionWithEmailNotify
logger = logging.getLogger(__name__)
@@ -13,17 +14,12 @@ class DeliveryPeriodFromConfig(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
#@pass_if_error
def do(self, attachment) -> None:
try:
delivery_period = self.config.get("delivery_period")
except Exception as e:
logger.error(f"Ошибка при получении срока доставки из конфига: {e}")
else:
attachment["delivery_period"] = delivery_period
logger.warning(f"Срок доставки установлен из конфига - {delivery_period} (ч.)")
except Exception as e:
raise TaskExceptionWithEmailNotify(f"Ошибка при установке срока доставки из конфига. Детали ошибки: {e}")

View File

@@ -2,7 +2,8 @@
Парсер срока доставки из темы письма
"""
from mail_order_bot.task_processor.abstract_task import AbstractTask
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from ...exceptions import TaskExceptionWithEmailNotify
import logging
import re
@@ -48,7 +49,7 @@ class DeliveryPeriodFromSubject(AbstractTask):
logger.debug(f"Срок доставки для файла {attachment["name"]} установлен как {delivery_time}")
except Exception as e:
logger.error(e)
raise TaskExceptionWithEmailNotify(f"Ошибка при установке срока доставки из темы письма. Детали ошибки: {e}")
def _parse_delivery_period(self, subject: str) -> int:

View File

@@ -5,7 +5,7 @@
import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
logger = logging.getLogger(__name__)
@@ -13,8 +13,6 @@ class DeliveryPeriodLocalStore(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
attachments = self.context.data["attachments"]
for attachment in attachments:
def do(self, attachment) -> None:
attachment["delivery_period"] = 0
logger.info(f"Срок доставки для файла {attachment["name"]} - только из наличия")

View File

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

View File

@@ -4,7 +4,7 @@
"""
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.email_client.utils import EmailUtils
@@ -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)

View File

@@ -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
@@ -7,7 +7,8 @@ from email.utils import formatdate
from email import encoders
from mail_order_bot.task_processor.abstract_task import AbstractTask
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from ...exceptions import TaskExceptionWithEmailNotify
logger = logging.getLogger(__name__)
@@ -19,8 +20,9 @@ class EmailReplyTask(AbstractTask):
"""Формирует ответ на входящее письмо с запросом на заказ°"""
EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru"
#@pass_if_error
#@handle_errors
def do(self, attachment):
try:
email = self.context.data.get("email")
@@ -36,7 +38,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}"
@@ -49,10 +51,10 @@ class EmailReplyTask(AbstractTask):
self._attach_file(reply_message, attachment)
self.context.email_client.send_email(reply_message)
except Exception as e:
logger.error(f"Ошибка при отправке ответа по заказу на email \n{e}")
else:
logger.warning(f"Сформирован ответ на заказ на email")
except Exception as e:
raise TaskExceptionWithEmailNotify("Произошла ошибка при отправке уведомления клиенту об успешном заказе")
def _attach_file(self, reply_message, attachment):
"""

View File

@@ -1,17 +1,14 @@
import logging
import pandas as pd
from io import BytesIO
from mail_order_bot.email_client import EmailUtils
#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
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
from ...exceptions import TaskExceptionWithEmailNotify
logger = logging.getLogger(__name__)
class ExcelExtractor(AbstractTask):
STEP_NAME = "Парсинг эксель файла"
"""
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
"""
@@ -19,20 +16,13 @@ class ExcelExtractor(AbstractTask):
super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
#@pass_if_error
#@handle_errors
def do(self, attachment) -> None:
try:
file_bytes = BytesIO(attachment['bytes'])
excel_file = ExcelFileParcer(file_bytes, self.excel_config)
except Exception as e:
logger.error(f"Не удалось распарсить файл: \n{e}")
attachment["excel"] = None
else:
attachment["excel"] = excel_file
logger.warning(f"Произведен успешный парсинг файла")
logger.warning(f"Произведен успешный парсинг файла {attachment.get('name', 'неизвестный файл')}")
except Exception as e:
raise TaskExceptionWithEmailNotify("Произошла ошибка при парсинге эксель файла. Детали ошибки: {e}")

View File

@@ -2,7 +2,8 @@ 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 ...abstract_task import AbstractTask, pass_if_error, handle_errors
from ...exceptions import TaskExceptionWithEmailNotify
from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
@@ -10,6 +11,7 @@ logger = logging.getLogger(__name__)
class OrderExtractor(AbstractTask):
STEP_NAME = "Парсинг заказа"
"""
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
"""
@@ -17,11 +19,11 @@ class OrderExtractor(AbstractTask):
super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
@pass_if_error
#@handle_errors("Произошла ошибка при парсинге заказа")
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип
try:
# todo сделать проверку на наличие файла и его тип
delivery_period = attachment.get("delivery_period", 0)
mapping = self.excel_config.get("mapping")
@@ -33,12 +35,11 @@ class OrderExtractor(AbstractTask):
order_dataframe = excel_file.get_order_rows()
order = order_parcer.parse(order_dataframe)
except Exception as e:
logger.error(f"Ошибка при парсинге заказа файла: \n{e}")
else:
attachment["order"] = order
logger.warning(f"Обработан файл с заказом, извлечено позиций, {len(order.positions)}")
logger.warning(f"Файл заказа обработан успешно, извлечено {len(order.positions)} позиций")
except Exception as e:
raise TaskExceptionWithEmailNotify(f"Ошибка при парсинге заказа. Детали ошибки: {e}")

View File

@@ -1,17 +1,12 @@
import logging
import pandas as pd
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.order.auto_part_position import PositionStatus
from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from ...exceptions import TaskExceptionWithEmailNotify
logger = logging.getLogger(__name__)
class UpdateExcelFile(AbstractTask):
STEP_NAME = "Обновление файла заказа"
"""
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
"""
@@ -20,10 +15,11 @@ class UpdateExcelFile(AbstractTask):
super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
#@pass_if_error
#@handle_errors
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип
try:
# todo сделать проверку на наличие файла и его тип
excel_file = attachment.get("excel")
order = attachment.get("order")
config = self.context.data.get("config", {})
@@ -46,9 +42,7 @@ class UpdateExcelFile(AbstractTask):
column = value
value = position.order_price
excel_file.set_value(sku, manufacturer, column, value)
logger.warning(f"Файла {attachment.get('name', 'неизвестный файл')} отредактирован")
except Exception as e:
logger.error(f"Ошибка при правке excel файла: \n{e}")
else:
logger.warning(f"Файл excel успешно обновлен")
raise TaskExceptionWithEmailNotify(f"Не удалось отредактировать исходный файл с заказом. Детали ошибки: {e}")

View File

@@ -1,15 +1,62 @@
"""
Перебирает аттачменты
Для каждого ордера в аттачменте перебирает позиции
Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/
Возможно логику выбора позиции надо вынести из позиции, но пока так
"""
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 ...exceptions import TaskExceptionSilent
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:
try:
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("Инфо по заказу отправлено в телеграм")
except Exception as e:
raise TaskExceptionSilent(f"Ошибка при отправке в телеграм. Детали ошибки: {e}")
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})")

View File

@@ -10,11 +10,12 @@ from mail_order_bot.order.auto_part_position import AutoPartPosition, PositionSt
from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
from decimal import Decimal
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.order.auto_part_order import OrderStatus
from ...exceptions import TaskExceptionWithEmailNotify
from typing import Dict, Any
from typing import List, Optional
@@ -22,6 +23,10 @@ from typing import List, Optional
logger = logging.getLogger(__name__)
class RefusalLevelExceededException(TaskExceptionWithEmailNotify):
pass
class StockSelector(AbstractTask):
DISTRIBUTOR_ID = 1577730 # ID локального склада
"""
@@ -35,9 +40,10 @@ class StockSelector(AbstractTask):
client_login, client_password = credential_provider.get_system_credentials()
self.client_provider = AbcpProvider(login=client_login, password=client_password)
@pass_if_error
#@handle_errors
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип
try:
order = attachment.get("order", None)
delivery_period = attachment.get("delivery_period")
@@ -63,13 +69,20 @@ class StockSelector(AbstractTask):
# Мне не очень нравится управление статусами в этом месте, кажется что лучше это делать внутри AutoPartPosition
else:
position.status = PositionStatus.STOCK_FAILED
except Exception as e:
logger.error(f"Ошибка при выборе позиции со складов: {e}")
else:
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("Определены оптимальные позиции со складов")
except RefusalLevelExceededException:
raise RefusalLevelExceededException
except Exception as e:
raise TaskExceptionWithEmailNotify(f"Произошла ошибка при выборе позиций со складов. Детали ошибки: {e}")
def get_optimal_stock(self, stock_list, asking_price, asking_quantity, delivery_period):
"""Выбирает позицию для заказа"""
@@ -78,7 +91,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)

View File

@@ -30,85 +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_sender = self.context.data.get("email_from")
#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"]
if config.get("enabled", False) == True:
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
file_name = attachment["name"]
logger.warning(f"Начата обработка файла: {file_name} =>")
attachment["log_messages"] = LogMessageStorage(file_name)
if not len(attachments):
logger.warning(f"В письме от {email_sender} нет вложений, пропускаем обработку")
for attachment in attachments:
try:
file_name = attachment.get("name", "неизвестный файл")
logger.warning(f"==================================================")
logger.warning(f"Начата обработка файла: {file_name}")
# Проверка на тип файла - должен быть файлом электронных таблиц
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
attachment["status"] = AttachmentStatus.OK
# Запустить обработку пайплайна
pipeline = config["pipeline"]
for handler_name in pipeline:
logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name]()
task.do(attachment)
except Exception as e:
logger.error(f"Произошла ошибка: {e}")
logger.error(f"Ошибка при обработке файла {file_name}: {e}")
attachment["error"] = e
notifier = EmailForwardErrorTask()
notifier.do(attachment)
else:
logger.info(f"Обработка писем для {email_sender} отключена. Значение в конфиге: {config.get("enabled")}")
def _load_config(self, email_from) -> Dict[str, Any]:
if email_from in self.config:
return self.config[email_from]
def _load_config(self, sender_email) -> Dict[str, Any]:
if sender_email in self.config:
return self.config[sender_email]
email_from_domain = EmailUtils.extract_domain(email_from)
sender_domain = EmailUtils.extract_domain(sender_email)
if sender_domain in self.config:
return self.config[sender_domain]
if email_from_domain in self.config:
return self.config[email_from_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