no message

This commit is contained in:
2026-01-13 23:01:43 +03:00
parent 049f018232
commit 8aed3446bf
23 changed files with 409 additions and 222 deletions

View File

@@ -6,14 +6,17 @@ clients:
enabled: true enabled: true
client_id: 6148154 # Сейчас стоит айдишник Димы для тестовых заказов client_id: 6148154 # Сейчас стоит айдишник Димы для тестовых заказов
refusal_threshold: 0.01
pipeline: pipeline:
- ExcelExtractor - ExcelExtractor
- DeliveryPeriodFromConfig - DeliveryPeriodFromConfig
- OrderExtractor - OrderExtractor
- StockSelector - StockSelector
- UpdateExcelFile - UpdateExcelFile
- SaveOrderToTelegram - TelegramNotifier
- EmailReplyTask #- EmailReplyTask
#- EmailForwardErrorTask
excel: excel:
sheet_name: 0 sheet_name: 0
@@ -48,13 +51,13 @@ log:
formatters: formatters:
standard: standard:
format: '%(asctime)s %(module)15s [%(levelname)8s]: %(message)s' format: '%(asctime)s %(module)18s [%(levelname)8s]: %(message)s'
telegram: telegram:
format: '%(message)s' format: '%(message)s'
handlers: handlers:
console: console:
level: WARNING level: DEBUG
formatter: standard formatter: standard
class: logging.StreamHandler class: logging.StreamHandler
stream: ext://sys.stdout # Default is stderr stream: ext://sys.stdout # Default is stderr
@@ -79,7 +82,7 @@ log:
loggers: loggers:
'': '':
handlers: [console, file, telegram] handlers: [console, file, telegram]
level: WARNING level: INFO
propagate: False propagate: False
__main__: __main__:
@@ -90,4 +93,8 @@ log:
config_manager: config_manager:
handlers: [console, file] handlers: [console, file]
level: ERROR level: ERROR
utils:
handlers: [ console, file ]
level: DEBUG

View File

@@ -23,9 +23,9 @@ class EmailUtils:
@staticmethod @staticmethod
def extract_email(text) -> str: def extract_email(text) -> str:
match = re.search(r'<([^<>]+)>', text) match = re.search(r'<([^<>]+)>', text)
if match: email = match.group(1) if match else None
return match.group(1) logger.debug(f"Extracted email: {email}")
return None return email
@staticmethod @staticmethod
def extract_body(msg: email.message.Message) -> str: def extract_body(msg: email.message.Message) -> str:
@@ -53,7 +53,7 @@ class EmailUtils:
body = payload.decode(charset, errors='ignore') body = payload.decode(charset, errors='ignore')
except Exception: except Exception:
pass pass
logger.debug(f"Extracted body: {body}")
return body return body
@staticmethod @staticmethod
@@ -84,6 +84,8 @@ class EmailUtils:
if content: if content:
#attachments.append(EmailAttachment(filename=filename, content=content)) #attachments.append(EmailAttachment(filename=filename, content=content))
attachments.append({"name": filename, "bytes": content}) attachments.append({"name": filename, "bytes": content})
logger.debug(f"Extracted attachment {filename}")
logger.debug(f"Extracted attachments: {len(attachments)}")
return attachments return attachments

View File

@@ -22,6 +22,8 @@ class MailOrderBot(ConfigManager):
def __init__(self, *agrs, **kwargs): def __init__(self, *agrs, **kwargs):
super().__init__(*agrs, **kwargs) super().__init__(*agrs, **kwargs)
self.context = Context()
# Объявить почтового клиента # Объявить почтового клиента
self.email_client = EmailClient( self.email_client = EmailClient(
imap_host=os.getenv('IMAP_HOST'), imap_host=os.getenv('IMAP_HOST'),
@@ -33,24 +35,17 @@ class MailOrderBot(ConfigManager):
) )
# Сохранить почтовый клиент в контекст # Сохранить почтовый клиент в контекст
self.context = Context()
self.context.email_client = self.email_client self.context.email_client = self.email_client
self.email_processor = None
# Обработчик писем
#self.email_processor = TaskProcessor("./configs")
config = self.config.get("clients")
self.email_processor = TaskProcessor(config)
logger.warning("MailOrderBot инициализирован") logger.warning("MailOrderBot инициализирован")
def execute(self): def execute(self):
#Получить список айдишников письма #Получить список айдишников письма
email_folder = self.config.get("folder")
folder = self.config.get("folder")
try: try:
unread_email_ids = self.email_client.get_emails_id(folder=folder) unread_email_ids = self.email_client.get_emails_id(folder=email_folder)
logger.info(f"Новых писем - {len(unread_email_ids)}") logger.warning(f"Новых писем - {len(unread_email_ids)}")
# Обработать каждое письмо по идентификатору # Обработать каждое письмо по идентификатору
for email_id in unread_email_ids: for email_id in unread_email_ids:
@@ -58,16 +53,23 @@ class MailOrderBot(ConfigManager):
logger.info(f"Обработка письма с идентификатором {email_id}") logger.info(f"Обработка письма с идентификатором {email_id}")
# Получить письмо по идентификатору и запустить его обработку # Получить письмо по идентификатору и запустить его обработку
email = self.email_client.get_email(email_id, mark_as_read=False) 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) self.email_processor.process_email(email)
except MailOrderBotException as e: except MailOrderBotException as e:
logger.error(f"Произошла ошибка {e}") logger.critical(f"Не получилось обработать письмо {e}")
continue
except MailOrderBotException as e: except MailOrderBotException as e:
logger.error(f"Произошла ошибка {e}") logger.critical(f"Произошла ошибка в основном цикле {e}")
except Exception as 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 typing import List, Optional
from decimal import Decimal
from openpyxl.pivot.fields import Boolean
from .auto_part_position import AutoPartPosition, PositionStatus from .auto_part_position import AutoPartPosition, PositionStatus
from enum import Enum from enum import Enum
@@ -36,18 +40,33 @@ class AutoPartOrder:
def set_delivery_period(self, delivery_period: int) -> None: def set_delivery_period(self, delivery_period: int) -> None:
self.delivery_period = delivery_period self.delivery_period = delivery_period
def check_order(self, config) -> None: def get_refusal_level(self) -> float:
""" Проверяет заказ на возможность исполнения""" """ Проверяет заказ на возможность исполнения"""
# 1. Проверка общего количества отказов # 1. Проверка общего количества отказов
order_refusal_threshold = config.get("order_refusal_threshold", 1) refusal_positions_count = len([position for position in self.positions if position.status != PositionStatus.READY])
refusal_positions_count = len([position for position in self.positions if str(position.status) in order_refusal_rate = float(refusal_positions_count) / len(self.positions)
[PositionStatus.REFUSED, PositionStatus.STOCK_FAILED]]) return order_refusal_rate
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
def __len__(self): 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

View File

@@ -2,10 +2,10 @@ from typing import List, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, Any from typing import Dict, Any
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import StrEnum
class PositionStatus(Enum): class PositionStatus(StrEnum):
NEW = "new" # Новая позиция NEW = "new" # Новая позиция
STOCK_RECIEVED = "stock_received" # Получен остаток STOCK_RECIEVED = "stock_received" # Получен остаток
STOCK_FAILED = "stock_failed" # Остаток не получен STOCK_FAILED = "stock_failed" # Остаток не получен
@@ -63,8 +63,9 @@ class AutoPartPosition:
# Устанавливаем актуальный срок доставки # Устанавливаем актуальный срок доставки
self.order_delivery_period = self.order_item.get("deliveryPeriod") 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 self.status = PositionStatus.READY

View File

@@ -1,4 +1,9 @@
from .processor import TaskProcessor from .processor import TaskProcessor
from .message import LogMessage, LogMessageLevel, LogMessageStorage 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

View File

@@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
import logging import logging
import functools import functools
from .attachment_status import AttachmentStatus
from mail_order_bot.context import Context from mail_order_bot.context import Context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,8 +26,8 @@ def handle_errors(func):
except Exception as e: except Exception as e:
# При ошибке устанавливаем статус и логируем # При ошибке устанавливаем статус и логируем
if attachment: if attachment:
attachment["status"] = "error" attachment["status"] = AttachmentStatus.FAILED
logger.error(f"Ошибка при обработке файла {file_name} на стадии {self.STEP} \n{e}", exc_info=True) logger.error(f"Ошибка при обработке вложения {file_name} на стадии {self.__class__.__name__} \n{e}", exc_info=True)
# Пробрасываем исключение дальше # Пробрасываем исключение дальше
# raise # raise
return wrapper return wrapper
@@ -41,7 +42,7 @@ def pass_if_error(func):
@functools.wraps(func) @functools.wraps(func)
def wrapper(self, attachment) -> None: 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", "неизвестный файл") file_name = attachment.get("name", "неизвестный файл")
logger.warning(f"Пропускаем шаг для файла {file_name}, статус {attachment.get('status')}") logger.warning(f"Пропускаем шаг для файла {file_name}, статус {attachment.get('status')}")
return return
@@ -53,7 +54,7 @@ def pass_if_error(func):
class AbstractTask(): class AbstractTask():
STEP = "Название шага обработки" 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.local_store import DeliveryPeriodLocalStore
from .delivery_time.from_config import DeliveryPeriodFromConfig 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.excel_extractor import ExcelExtractor
from .excel_parcers.order_extractor import OrderExtractor 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 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_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 ...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("Инфо по заказу отправлено в телеграм")
#===============================

View File

@@ -14,8 +14,11 @@ class DeliveryPeriodFromConfig(AbstractTask):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@pass_if_error @pass_if_error
@handle_errors
def do(self, attachment) -> None: def do(self, attachment) -> None:
delivery_period = self.config.get("delivery_period") try:
attachment["delivery_period"] = delivery_period delivery_period = self.config.get("delivery_period")
logger.warning(f"Срок доставки установлен из конфига - {delivery_period} (ч.)") attachment["delivery_period"] = delivery_period
logger.warning(f"Срок доставки установлен из конфига - {delivery_period} (ч.)")
except Exception as e:
raise Exception(f"Ошибка при установке срока доставки из конфига. Детали ошибки: {e}")

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

@@ -35,7 +35,7 @@ class EmailParcer(AbstractTask):
email_from_domain = EmailUtils.extract_domain(email_from) email_from_domain = EmailUtils.extract_domain(email_from)
self.context.data["email_from_domain"] = email_from_domain 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 self.context.data["email_subj"] = email_subj
client = EmailUtils.extract_domain(email_from) client = EmailUtils.extract_domain(email_from)

View File

@@ -1,5 +1,5 @@
import logging import logging
import os
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
@@ -20,7 +20,7 @@ class EmailReplyTask(AbstractTask):
EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru" EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru"
@pass_if_error @pass_if_error
@handle_errors #@handle_errors
def do(self, attachment): def do(self, attachment):
email = self.context.data.get("email") email = self.context.data.get("email")
@@ -37,7 +37,7 @@ class EmailReplyTask(AbstractTask):
email_subj = self.context.data.get("email_subj") 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["To"] = email_from
#reply_message["Cc"] = self.config.get("reply_to", "") #reply_message["Cc"] = self.config.get("reply_to", "")
reply_message["Subject"] = f"Re: {email_subj}" reply_message["Subject"] = f"Re: {email_subj}"

View File

@@ -1,12 +1,13 @@
import logging import logging
from io import BytesIO from io import BytesIO
from ...abstract_task import AbstractTask, pass_if_error, handle_errors from mail_order_bot.task_processor.abstract_task import AbstractTask, pass_if_error, handle_errors
from ....parsers.excel_parcer import ExcelFileParcer from mail_order_bot.task_processor.handlers.excel_parcers.order_extractor import ExcelFileParcer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ExcelExtractor(AbstractTask): class ExcelExtractor(AbstractTask):
STEP_NAME = "Парсинг эксель файла"
""" """
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
""" """
@@ -15,7 +16,7 @@ class ExcelExtractor(AbstractTask):
self.excel_config = self.config.get("excel", {}) self.excel_config = self.config.get("excel", {})
@pass_if_error @pass_if_error
@handle_errors #@handle_errors
def do(self, attachment) -> None: def do(self, attachment) -> None:
file_bytes = BytesIO(attachment['bytes']) file_bytes = BytesIO(attachment['bytes'])
excel_file = ExcelFileParcer(file_bytes, self.excel_config) excel_file = ExcelFileParcer(file_bytes, self.excel_config)

View File

@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
class OrderExtractor(AbstractTask): class OrderExtractor(AbstractTask):
STEP = "Извлечение заказа" STEP_NAME = "Парсинг заказа"
""" """
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
""" """
@@ -19,7 +19,7 @@ class OrderExtractor(AbstractTask):
self.excel_config = self.config.get("excel", {}) self.excel_config = self.config.get("excel", {})
@pass_if_error @pass_if_error
@handle_errors #@handle_errors
def do(self, attachment) -> None: def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип # todo сделать проверку на наличие файла и его тип
delivery_period = attachment.get("delivery_period", 0) delivery_period = attachment.get("delivery_period", 0)

View File

@@ -5,6 +5,8 @@ logger = logging.getLogger(__name__)
class UpdateExcelFile(AbstractTask): class UpdateExcelFile(AbstractTask):
STEP_NAME = "Обновление файла заказа"
""" """
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
""" """
@@ -14,7 +16,7 @@ class UpdateExcelFile(AbstractTask):
self.excel_config = self.config.get("excel", {}) self.excel_config = self.config.get("excel", {})
@pass_if_error @pass_if_error
@handle_errors #@handle_errors
def do(self, attachment) -> None: def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип # todo сделать проверку на наличие файла и его тип
excel_file = attachment.get("excel") excel_file = attachment.get("excel")

View File

@@ -1,15 +1,58 @@
"""
Перебирает аттачменты
Для каждого ордера в аттачменте перебирает позиции
Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/
Возможно логику выбора позиции надо вынести из позиции, но пока так
"""
import logging 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__) 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})")

View File

@@ -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.credential_provider import CredentialProvider
from mail_order_bot.order.auto_part_order import OrderStatus from mail_order_bot.order.auto_part_order import OrderStatus
from ...exceptions import TaskException
from typing import Dict, Any from typing import Dict, Any
from typing import List, Optional from typing import List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class StockSelectorException(TaskException):
pass
class RefusalLevelExceededException(TaskException):
pass
class StockSelector(AbstractTask): class StockSelector(AbstractTask):
DISTRIBUTOR_ID = 1577730 # ID локального склада DISTRIBUTOR_ID = 1577730 # ID локального склада
@@ -36,7 +43,7 @@ class StockSelector(AbstractTask):
self.client_provider = AbcpProvider(login=client_login, password=client_password) self.client_provider = AbcpProvider(login=client_login, password=client_password)
@pass_if_error @pass_if_error
@handle_errors #@handle_errors
def do(self, attachment) -> None: def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип # todo сделать проверку на наличие файла и его тип
order = attachment.get("order", None) order = attachment.get("order", None)
@@ -64,11 +71,15 @@ class StockSelector(AbstractTask):
else: else:
position.status = PositionStatus.STOCK_FAILED 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("Определены оптимальные позиции со складов") logger.warning("Определены оптимальные позиции со складов")
def get_optimal_stock(self, stock_list, asking_price, asking_quantity, delivery_period): 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) stock_list = self._br1_only_local_stock(stock_list)
# BR-2. Цена не должна превышать цену из заказа # 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. Срок доставки не должен превышать ожидаемый # BR-3. Срок доставки не должен превышать ожидаемый
stock_list = self._br3_delivery_time_shorted_asked_time(stock_list, delivery_period) stock_list = self._br3_delivery_time_shorted_asked_time(stock_list, delivery_period)

View File

@@ -30,86 +30,109 @@ todo
""" """
import os
import yaml
import logging import logging
from typing import Dict, Any, List from typing import Dict, Any
from pathlib import Path
import threading
from mail_order_bot.context import Context from mail_order_bot.context import Context
from mail_order_bot.email_client.utils import EmailUtils from mail_order_bot.email_client.utils import EmailUtils
from enum import Enum
from mail_order_bot.task_processor.handlers import * from mail_order_bot.task_processor.handlers import *
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__) 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: class TaskProcessor:
#def __init__(self, configs_path: str): #def __init__(self, configs_path: str):
def __init__(self, config: Dict[str, Any]): def __init__(self, config: Dict[str, Any]):
super().__init__() super().__init__()
self.context = Context()
#self.configs_path = configs_path
self.config = config self.config = config
self.status = RequestStatus.NEW self.context = Context()
def process_email(self, email): def process_email(self, email):
# Очистить контекст и запушить туда письмо # Очистить контекст и запушить туда письмо
self.context.clear() self.context.clear()
self.context.data["email"] = email self.context.data["email"] = email
try: # Парсинг письма
# Парсинг письма #email_parcer = EmailParcer()
email_parcer = EmailParcer() #email_parcer.do()
email_parcer.do() self.parse_email(email)
email_sender = self.context.data.get("email_from") # Определить конфиг для пайплайна
email_sender = self.context.data.get("email_from")
# Определить конфиг для пайплайна config = self._load_config(email_sender)
config = self._load_config(email_sender) self.context.data["config"] = config
self.context.data["config"] = config
pipeline = config["pipeline"]
if config.get("enabled", False) == True:
attachments = self.context.data.get("attachments", []) attachments = self.context.data.get("attachments", [])
if not len(attachments):
logger.warning(f"В письме от {email_sender} нет вложений, пропускаем обработку")
for attachment in attachments: for attachment in attachments:
file_name = attachment["name"] try:
logger.warning(f"Начата обработка файла: {file_name} =>") 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
# Запустить обработку пайплайна attachment["status"] = AttachmentStatus.OK
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}") 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]: except Exception as e:
if email_from in self.config: logger.error(f"Ошибка при обработке файла {file_name}: {e}")
return self.config[email_from] 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: def _load_config(self, sender_email) -> Dict[str, Any]:
return self.config[email_from_domain] 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