no message
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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("Обработка писем завершена")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
from .processor import TaskProcessor
|
||||
from .message import LogMessage, LogMessageLevel, LogMessageStorage
|
||||
|
||||
from .abstract_task import AbstractTask, pass_if_error, handle_errors
|
||||
from .abstract_task import AbstractTask, pass_if_error, handle_errors
|
||||
|
||||
from .attachment_status import AttachmentStatus
|
||||
|
||||
from .exceptions import TaskException
|
||||
|
||||
|
||||
@@ -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 = "Название шага обработки"
|
||||
|
||||
"""
|
||||
Абстрактный базовый класс для всех хэндлеров.
|
||||
|
||||
6
src/mail_order_bot/task_processor/attachment_status.py
Normal file
6
src/mail_order_bot/task_processor/attachment_status.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import StrEnum
|
||||
|
||||
class AttachmentStatus(StrEnum):
|
||||
OK = "все в порядке"
|
||||
FAILED = "ошибка"
|
||||
NOT_A_ORDER = "не является заказом"
|
||||
11
src/mail_order_bot/task_processor/exceptions.py
Normal file
11
src/mail_order_bot/task_processor/exceptions.py
Normal file
@@ -0,0 +1,11 @@
|
||||
class TaskException(Exception):
|
||||
"""Базовый класс исключений."""
|
||||
pass
|
||||
|
||||
class TaskExceptionWithEmailNotify(TaskException):
|
||||
"""Базовый класс исключений с уведомлением по почте."""
|
||||
pass
|
||||
|
||||
class TaskExceptionSilent(TaskException):
|
||||
"""Базовый класс исключений без уведомления."""
|
||||
pass
|
||||
49
src/mail_order_bot/task_processor/file_validator.py
Normal file
49
src/mail_order_bot/task_processor/file_validator.py
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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("Инфо по заказу отправлено в телеграм")
|
||||
|
||||
#===============================
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user