5 Commits

43 changed files with 839 additions and 1154 deletions

View File

@@ -0,0 +1 @@
from .main import MailOrderBotException

View File

@@ -29,7 +29,24 @@ class AbcpProvider:
path = "/search/articles" path = "/search/articles"
params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"} params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"}
return self._execute(path, method, params)
status_code, payload = self._execute(path, method, params)
if status_code == 200:
response = {"success": True, "data": payload}
logger.debug(f"Получены данные об остатках на складе")
return response
elif status_code == 301:
response = {"success": True, "data": []}
logger.debug(f"Не найдены позиции по запрошенным параметрам")
return response
else:
response = {"success": False, "data": payload}
logger.debug(f"Ошибка при получении остатков со склада")
return response
def _execute(self, path, method="GET", params={}, data=None): def _execute(self, path, method="GET", params={}, data=None):
params["userlogin"] = self.login params["userlogin"] = self.login
@@ -37,18 +54,4 @@ class AbcpProvider:
response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params) response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params)
payload = response.json() payload = response.json()
if response.status_code == 200: return response.status_code, payload
logger.debug(f"Получены данные об остатках на складе")
result = {
"success": True,
"data": payload
}
else:
logger.warning(f"ошибка получения данных об остатках на складе: {payload}")
result = {
"success": False,
"error": payload
}
return result

View File

@@ -1,17 +1,22 @@
# Настройки обработки ================================================================= # Настройки обработки =================================================================
folder: "spareparts"
clients: clients:
lesha.spb@gmail.com: lesha.spb@gmail.com:
enabled: true enabled: true
client_id: 6148154 # Сейчас стоит айдишник Димы для тестовых заказов client_id: 6148154 # Сейчас стоит айдишник Димы для тестовых заказов
refusal_threshold: 0.01
pipeline: pipeline:
- ExcelExtractor - ExcelExtractor
- OrderExtractor
- DeliveryPeriodFromConfig - DeliveryPeriodFromConfig
- OrderExtractor
- StockSelector - StockSelector
- UpdateExcelFile - UpdateExcelFile
- SaveOrderToTelegram - TelegramNotifier
- EmailReplyTask #- EmailReplyTask
#- EmailForwardErrorTask
excel: excel:
sheet_name: 0 sheet_name: 0
@@ -39,7 +44,6 @@ clients:
update_interval: 1 update_interval: 1
work_interval: 60 work_interval: 60
email_dir: "spareparts" email_dir: "spareparts"
# Логирование ================================================================= # Логирование =================================================================
log: log:
version: 1 version: 1
@@ -47,7 +51,7 @@ 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'
@@ -78,15 +82,19 @@ log:
loggers: loggers:
'': '':
handlers: [console, file, telegram] handlers: [console, file, telegram]
level: DEBUG level: INFO
propagate: False propagate: False
__main__: __main__:
handlers: [console, file, telegram] handlers: [console, file, telegram]
level: INFO level: WARNING
propagate: False propagate: False
config_manager: config_manager:
handlers: [console, file] handlers: [console, file]
level: ERROR
utils:
handlers: [ console, file ]
level: DEBUG level: DEBUG

View File

@@ -1,3 +1,27 @@
"""
Структура контекста
email : Электронное письмо в формате EmailObject
config : Конфиг для текущего клиента
attachments : [ Список вложений
{
name : Имя файла
isOrder : Признак что файл является валидным файлом заказа
bytes : содержимое файла в формате BytesIO
deliveryPeriod: Int
sheet : Распарсенный лист в формате ExcelFileParcer
order : Файл заказа в формате AutopartOrder
log : Лог сообщений по обработке файла в формате LogMessage
}
]
status:
"""
import threading import threading
from typing import Any, Dict from typing import Any, Dict
import logging import logging

View File

@@ -1 +0,0 @@
from .processor import ExcelProcessor

View File

@@ -1,113 +0,0 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional, List
from decimal import Decimal
from .excel_parser import ExcelParser
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class ConfigurableExcelParser(ExcelParser):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def parse(self, file_bytes: str) -> List[OrderPosition]:
try:
# Читаем Excel
df = self._make_dataframe(file_bytes)
# Получаем маппинг колонок из конфигурации
mapping = self.config['mapping']
# Парсим строки
positions = []
for idx, row in df.iterrows():
try:
position = self._parse_row(row, mapping)
if position:
positions.append(position)
except Exception as e:
logger.error(f"Ошибка парсинга строки {idx}: {e}, {row}")
continue
logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк")
return positions
except Exception as e:
logger.error(f"Ошибка при обработке файла: {e}")
raise Exception from e
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[OrderPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
if mapping.get('name',"") in mapping.keys():
name = str(row[mapping.get('name', "")]).strip()
else:
name = ""
# Создаем объект позиции
position = OrderPosition(
article=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer',"")]).strip(),
name=name,
price=price,
quantity=quantity,
total=total,
additional_attrs=self._extract_additional_attrs(row, mapping)
)
return position
def _extract_additional_attrs(self, row: pd.Series, mapping: Dict[str, str]) -> Dict[str, Any]:
"""Извлекает дополнительные атрибуты, не входящие в основную модель"""
additional = {}
mapped_columns = set(mapping.values())
for col in row.index:
if col not in mapped_columns and not pd.isna(row[col]):
additional[col] = row[col]
return additional
def _make_dataframe(self, bio) -> pd.DataFrame:
# Получаем все данные из файла
sheet_name = self.config.get("sheet_name", 0)
df_full = pd.read_excel(bio, sheet_name=sheet_name, header=None)
# Находим индекс строки с заголовком
key_field = self.config.get("key_field")
header_row_idx = df_full[
df_full.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
# Считываем таблицу с правильным заголовком
df = pd.read_excel(bio, header=header_row_idx, sheet_name=sheet_name, engine='calamine') #openpyxl calamine
# Находим индекс первой строки с пустым 'Артикул'
first_empty_index = df[df[key_field].isna()].index.min()
# Обрезаем DataFrame до первой пустой строки (не включая её)
df_trimmed = df.loc[:first_empty_index - 1]
return df_trimmed

View File

@@ -1,105 +0,0 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional, List
from decimal import Decimal
import xlrd
from io import BytesIO
from .excel_parser import ExcelParser
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class CustomExcelParserAutoeuro(ExcelParser):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def parse(self, file_bytes: BytesIO) -> List[OrderPosition]:
try:
# Читаем Excel
df = self._make_dataframe(file_bytes)
# Получаем маппинг колонок из конфигурации
mapping = self.config['mapping']
# Парсим строки
positions = []
for idx, row in df.iterrows():
try:
position = self._parse_row(row, mapping)
if position:
positions.append(position)
except Exception as e:
logger.error(f"Ошибка парсинга строки {idx}: {e}, {row}")
continue
logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк")
return positions
except Exception as e:
logger.error(f"Ошибка при обработке файла: {e}")
raise Exception from e
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[OrderPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
# Создаем объект позиции
position = OrderPosition(
article=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer', "")]).strip(),
name="", #str(row[mapping.get('name', "name")]).strip(),
price=price,
quantity=quantity,
total=total,
additional_attrs=self._extract_additional_attrs(row, mapping)
)
return position
def _extract_additional_attrs(self, row: pd.Series, mapping: Dict[str, str]) -> Dict[str, Any]:
"""Извлекает дополнительные атрибуты, не входящие в основную модель"""
additional = {}
mapped_columns = set(mapping.values())
for col in row.index:
if col not in mapped_columns and not pd.isna(row[col]):
additional[col] = row[col]
return additional
def _make_dataframe(self, bio) -> pd.DataFrame:
file_bytes = bio.read()
book = xlrd.open_workbook(file_contents=file_bytes, encoding_override='cp1251')
sheet = book.sheet_by_index(self.config.get("sheet_index", 0))
data = [sheet.row_values(row) for row in range(sheet.nrows)]
df_full = pd.DataFrame(data)
key_field = self.config.get("key_field")
header_row_idx = df_full[
df_full.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
df = df_full[header_row_idx:]
df.columns = df.iloc[0] # первая строка становится заголовком
df = df.reset_index(drop=True).drop(0).reset_index(drop=True) # удаляем первую строку и сбрасываем индекс
return df

View File

@@ -1,28 +0,0 @@
import logging
import pandas as pd
from abc import ABC, abstractmethod
from typing import Dict, Any, List
from io import BytesIO
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class ExcelParser(ABC):
"""
Абстрактный базовый класс для всех парсеров Excel.
Реализует Strategy Pattern - каждый контрагент = своя стратегия.
"""
def __init__(self, config: Dict[str, Any]):
self.config = config
@abstractmethod
def parse(self, file: BytesIO) -> List[OrderPosition]:
"""
Парсит Excel файл и возвращает список позиций.
Должен быть реализован в каждом конкретном парсере.
"""
pass

View File

@@ -1,24 +0,0 @@
from dataclasses import dataclass, field
from typing import Dict, Any
from decimal import Decimal
@dataclass
class OrderPosition:
"""
Унифицированная модель позиции для заказа.
Все контрагенты приводятся к этой структуре.
"""
article: str # Артикул товара
manufacturer: str # Производитель
name: str # Наименование
price: Decimal # Цена за единицу
quantity: int # Количество
total: Decimal # Общая сумма
additional_attrs: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Валидация после инициализации"""
if self.quantity < 0:
raise ValueError(f"Количество не может быть отрицательным: {self.quantity}")
if self.price < 0:
raise ValueError(f"Цена не может быть отрицательной: {self.price}")

View File

@@ -1,54 +0,0 @@
import yaml
import json
import logging
from pathlib import Path
from typing import Dict, Any, List
from .excel_parser import ExcelParser
from .configurable_parser import ConfigurableExcelParser
from .custom_parser_autoeuro import CustomExcelParserAutoeuro
logger = logging.getLogger(__name__)
class ParserFactory:
"""
Фабрика парсеров (Factory Pattern).
Создает нужный парсер на основе названия контрагента.
"""
# Реестр кастомных парсеров
CUSTOM_PARSERS = {
'autoeuro.ru': CustomExcelParserAutoeuro,
# Добавляйте сюда специализированные парсеры
}
def __init__(self, config: Dict[str, Any]):
self.config = config
def get_parser(self, supplier_name: str) -> ExcelParser:
"""
Возвращает парсер для указанного контрагента.
Использует кастомный парсер если есть, иначе конфигурируемый.
"""
if supplier_name not in self.config['suppliers']:
raise ValueError(
f"Контрагент '{supplier_name}' не найден в конфигурации. "
f"Доступные: {list(self.config['suppliers'].keys())}"
)
config = self.config['suppliers'][supplier_name]
# Проверяем, есть ли кастомный парсер
if supplier_name in self.CUSTOM_PARSERS:
parser_class = self.CUSTOM_PARSERS[supplier_name]
logger.debug(f"Используется кастомный парсер для {supplier_name}")
else:
parser_class = ConfigurableExcelParser
logger.debug(f"Используется конфигурируемый парсер для {supplier_name}")
return parser_class(config)
def list_suppliers(self) -> List[str]:
"""Возвращает список всех доступных контрагентов"""
return list(self.config['suppliers'].keys())

View File

@@ -1,110 +0,0 @@
import logging
from pathlib import Path
from decimal import Decimal
from io import BytesIO
from typing import Dict, Any, List
import yaml
import json
from .parser_factory import ParserFactory
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class ExcelProcessor:
"""
Главный класс-фасад для обработки Excel файлов.
Упрощает использование системы.
"""
def __init__(self, config_path: str = 'config/suppliers.yaml', ):
self.config_path = Path(config_path)
self.config = self._load_config()
self.factory = ParserFactory(self.config)
def process(self, file_bytes: BytesIO, file_name: str, supplier_name: str, validate: bool = False) -> List[OrderPosition]:
"""
Обрабатывает Excel файл от контрагента.
Args:
file_bytes: Байты файла
file_name: Имя файла
supplier_name: Название контрагента (из конфигурации)
validate: Выполнять ли дополнительную валидацию
Returns:
Список объектов OrderPosition
Raises:
ValueError: Если контрагент не найден
"""
logger.info(f"Обработка файла: {file_name} для {supplier_name}")
parser = self.factory.get_parser(supplier_name)
positions = parser.parse(file_bytes)
# Дополнительная валидация если нужна
if validate:
positions = self._validate_positions(positions)
logger.debug(f"Обработка завершена: получено {len(positions)} позиций")
return positions
def process_file(self, file_path: str, supplier_name: str, validate: bool = False) -> List[OrderPosition]:
# Проверка существования файла
logger.debug(f"Чтение файла: {file_path}")
if not Path(file_path).exists():
raise FileNotFoundError(f"Файл не найден: {file_path}")
with open(file_path, 'rb') as file: # бинарный режим
raw_data = file.read()
bio = BytesIO(raw_data)
positions = self.process(bio, file_path, supplier_name, validate=validate)
return positions
def _validate_positions(self, positions: List[OrderPosition]) -> List[OrderPosition]:
"""Дополнительная валидация позиций"""
valid_positions = []
for pos in positions:
try:
# Проверка на непустые обязательные поля
if not pos.article or not pos.name:
logger.warning(f"Пропущена позиция с пустыми полями: {pos}")
continue
# Проверка корректности суммы
expected_total = pos.price * pos.quantity
if abs(pos.total - expected_total) > Decimal('0.01'):
logger.warning(
f"Несоответствие суммы для {pos.article}: "
f"ожидается {expected_total}, получено {pos.total}"
)
valid_positions.append(pos)
except Exception as e:
logger.error(f"Ошибка валидации позиции: {e}")
continue
return valid_positions
def get_available_suppliers(self) -> List[str]:
"""Возвращает список доступных контрагентов"""
return self.factory.list_suppliers()
def _load_config(self) -> Dict[str, Any]:
"""Загружает конфигурацию из YAML или JSON"""
if self.config_path.suffix in ['.yaml', '.yml']:
with open(self.config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
elif self.config_path.suffix == '.json':
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
else:
raise ValueError(f"Неподдерживаемый формат конфига: {self.config_path.suffix}")

View File

@@ -14,12 +14,16 @@ import smtplib
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# from .objects import EmailMessage, EmailAttachment # from .objects import EmailMessage, EmailAttachment
class EmailClient: class EmailClient:
def __init__(self, imap_host: str, smtp_host: str, email: str, password: str, def __init__(self, imap_host: str, smtp_host: str, email: str, password: str,
imap_port: int = 993, smtp_port: int = 587): imap_port: int = 993, smtp_port: int = 587):
@@ -32,7 +36,7 @@ class EmailClient:
self.imap_conn = None self.imap_conn = None
def connect(self): def connect(self):
"""Установkение IMAP соединения""" """Установление IMAP соединения"""
if self.imap_conn is None: if self.imap_conn is None:
self.imap_conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port) self.imap_conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
self.imap_conn.login(self.email, self.password) self.imap_conn.login(self.email, self.password)
@@ -58,6 +62,7 @@ class EmailClient:
def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True) -> List[int]: def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True) -> List[int]:
"""Получить список новых электронных писем.""" """Получить список новых электронных писем."""
try:
self.connect() self.connect()
self.imap_conn.select(folder, readonly=False) self.imap_conn.select(folder, readonly=False)
# Ищем письма # Ищем письма
@@ -69,8 +74,16 @@ class EmailClient:
return [] return []
email_ids = messages[0].split() email_ids = messages[0].split()
except Exception as e:
logger.error(e)
raise
else:
return email_ids return email_ids
def get_email(self, email_id, mark_as_read: bool = True): def get_email(self, email_id, mark_as_read: bool = True):
"""Получить список новых электронных писем.""" """Получить список новых электронных писем."""
self.connect() self.connect()

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
@@ -78,12 +78,14 @@ class EmailUtils:
filename = part.get_filename() filename = part.get_filename()
if filename: if filename:
# Декодируем имя файла # Декодируем имя файла
filename = decode_header(filename)[0] filename = decode_header(filename)[0][0]
# Получаем содержимое # Получаем содержимое
content = part.get_payload(decode=True) content = part.get_payload(decode=True)
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

@@ -7,6 +7,7 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
from email_client import EmailClient from email_client import EmailClient
from mail_order_bot.task_processor.abstract_task import AbstractTask
from task_processor import TaskProcessor from task_processor import TaskProcessor
from mail_order_bot.context import Context from mail_order_bot.context import Context
@@ -14,11 +15,15 @@ from mail_order_bot.context import Context
logger = logging.getLogger() logger = logging.getLogger()
class MailOrderBotException(Exception):
pass
class MailOrderBot(ConfigManager): 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'),
@@ -30,38 +35,48 @@ 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")
logger.critical("Запуск приложения critical !!!!!!!!") try:
unread_email_ids = self.email_client.get_emails_id(folder="spareparts") 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:
logger.debug(f"==================================================") try:
logger.debug(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:
logger.critical(f"Не получилось обработать письмо {e}")
continue
except MailOrderBotException as e:
logger.critical(f"Произошла ошибка в основном цикле {e}")
except Exception as e:
logger.critical(f"Произошла непредвиденная ошибка в основном цикле: {e}")
raise Exception from e
logger.warning("Обработка писем завершена")
logger = logging.getLogger() logger = logging.getLogger()
async def main(): async def main():
logger.critical("Запуск приложения")
app = MailOrderBot("config.yml") app = MailOrderBot("config.yml")
await app.start() await app.start()

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

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

View File

@@ -1 +1,9 @@
from .processor import TaskProcessor 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 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 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(): class AbstractTask():
RESULT_SECTION = "section" STEP_NAME = "Название шага обработки"
""" """
Абстрактный базовый класс для всех хэндлеров. Абстрактный базовый класс для всех хэндлеров.
""" """
@@ -15,7 +65,7 @@ class AbstractTask():
self.config = self.context.data.get("config", {}) self.config = self.context.data.get("config", {})
@abstractmethod @abstractmethod
def do(self) -> None: def do(self, attachment) -> None:
""" """
Выполняет работу над заданием Выполняет работу над заданием
Входные и выходные данные - в self.context Входные и выходные данные - в self.context
@@ -23,3 +73,6 @@ class AbstractTask():
""" """
raise NotImplementedError raise NotImplementedError
def get_name(self) -> str:
pass

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,18 +1,18 @@
#from .abcp._api_get_stock import APIGetStock
from .attachment_handler.attachment_handler import AttachmentHandler from .delivery_time.local_store import DeliveryPeriodLocalStore
from .abcp.api_get_stock import APIGetStock from .delivery_time.from_config import DeliveryPeriodFromConfig
from .destination_time.local_store import DeliveryPeriodLocalStore from .notifications.test_notifier import TelegramNotifier
from .destination_time.from_config import DeliveryPeriodFromConfig
from .notifications.test_notifier import TestNotifier
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
from .excel_parcers.update_excel_file import UpdateExcelFile from .excel_parcers.update_excel_file import UpdateExcelFile
from .email.send_email import EmailReplyTask from .email.email_reply_task import EmailReplyTask
from .email.email_forward_error_task import EmailForwardErrorTask

View File

@@ -1,42 +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) -> None:
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
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,60 +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) -> None:
client = TelegramClient()
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
order = attachment["order"]
positions = order.positions
message = "\nОбработка заказа {указать название контрагента}\n"
message += f"\nПолучено {len(positions)} позиций от {order.client_id}\n"
message += "===============================\n"
for position in positions:
message += f"{position.sku} - {position.manufacturer} - {position.name} \n"
message += f"{position.asking_quantity} x {position.asking_price} = {position.total} \n"
rejected = position.asking_quantity - position.order_quantity
if position.order_quantity == 0:
message += f"Отказ\n"
elif rejected:
message += (f"Отказ: {rejected}, запрошено, {position.asking_quantity}, "
f"отгружено {position.order_quantity}, профит {position.profit}\n")
else:
message += f"Позиция отгружена полностью, профит {position.profit}\n"
message += "-------------------------------\n"
result = client.send_message(message)
# Отправка экселя в телеграм
excel = attachment["excel"]
file = excel.get_file_bytes()
client.send_document(
document=file,
filename="document.xlsx"
)
# logger.critical(message)
#===============================

View File

@@ -1,29 +0,0 @@
"""
Извлекает вложения из имейла и складывает их в контекст
Использует EmailUtils
"""
import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
logger = logging.getLogger(__name__)
class AttachmentHandler(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
try:
email = self.context.data["email"]
attachments = EmailUtils.extract_attachments(email)
except Exception as e:
logger.error(e)
self.context.data["error"] = str(e)
else:
self.context.data["attachments"] = attachments
logger.info(f"Извлечено вложений: {len(attachments)} ")

View File

@@ -0,0 +1,25 @@
"""
Устанавливает хардкодом период доставки 0, что означает использование локального склада.
Для заказчиков, которые должны всегда получать заказ только из наличия
"""
import logging
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from ...exceptions import TaskExceptionWithEmailNotify
logger = logging.getLogger(__name__)
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")
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 logging
import re import re
@@ -26,6 +27,8 @@ class DeliveryPeriodFromSubject(AbstractTask):
- Срок переводится в часы (умножается на 24) - Срок переводится в часы (умножается на 24)
""" """
# Получаем тему письма # Получаем тему письма
try:
email_subj = self.context.data.get("email_subj", "") email_subj = self.context.data.get("email_subj", "")
if not email_subj: if not email_subj:
logger.warning("Тема письма не найдена в контексте") logger.warning("Тема письма не найдена в контексте")
@@ -43,8 +46,11 @@ class DeliveryPeriodFromSubject(AbstractTask):
attachments = self.context.data.get("attachments", []) attachments = self.context.data.get("attachments", [])
for attachment in attachments: for attachment in attachments:
attachment["delivery_time"] = delivery_time attachment["delivery_time"] = delivery_time
logger.debug(f"Срок доставки для файла {attachment["name"]} установлен как {delivery_time}")
except Exception as e:
raise TaskExceptionWithEmailNotify(f"Ошибка при установке срока доставки из темы письма. Детали ошибки: {e}")
logger.debug(f"Срок доставки сохранен в {len(attachments)} вложений")
def _parse_delivery_period(self, subject: str) -> int: def _parse_delivery_period(self, subject: str) -> int:
""" """

View File

@@ -5,7 +5,7 @@
import logging 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__) logger = logging.getLogger(__name__)
@@ -13,12 +13,6 @@ class DeliveryPeriodLocalStore(AbstractTask):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def do(self) -> None: def do(self, attachment) -> None:
attachments = self.context.data["attachments"]
for attachment in attachments:
attachment["delivery_period"] = 0 attachment["delivery_period"] = 0
logger.info(f"Доставка только с локального склада, срок 1 день.") logger.info(f"Срок доставки для файла {attachment["name"]} - только из наличия")

View File

@@ -1,26 +0,0 @@
"""
Устанавливает хардкодом период доставки 0, что означает использование локального склада.
Для заказчиков, которые должны всегда получать заказ только из наличия
"""
import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask
logger = logging.getLogger(__name__)
class DeliveryPeriodFromConfig(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
attachments = self.context.data["attachments"]
for attachment in attachments:
delivery_period = self.config.get("delivery_period", 0)
attachment["delivery_period"] = delivery_period
logger.info(f"Доставка только с локального склада, срок 1 день.")

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,17 +4,23 @@
""" """
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.email_client.utils import EmailUtils from mail_order_bot.email_client.utils import EmailUtils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class EmailParcerException(Exception):
pass
class EmailParcer(AbstractTask): class EmailParcer(AbstractTask):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def do(self) -> None: def do(self) -> None:
# Определить клиента # Определить клиента
try:
email = self.context.data.get("email", None) email = self.context.data.get("email", None)
if email is not None: if email is not None:
email_body = EmailUtils.extract_body(email) email_body = EmailUtils.extract_body(email)
@@ -29,16 +35,26 @@ 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)
self.context.data["client"] = client self.context.data["client"] = client
attachments = EmailUtils.extract_attachments(email) attachments = EmailUtils.extract_attachments(email)
self.context.data["attachments"] = attachments self.context.data["attachments"] = attachments
logger.info(f"Извлечено вложений: {len(attachments)} ") logger.info(f"Извлечено вложений: {len(attachments)} ")
except Exception as e:
logger.error(e)
self.context.data["error"].add(
"переделать ошибку на нормальную"
)
#raise EmailParcerException(f"Ошибка при парсинге письма {e}") from e

View File

@@ -0,0 +1,84 @@
import logging
import os
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email.utils import formatdate
from email import encoders
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from ...exceptions import TaskExceptionWithEmailNotify
logger = logging.getLogger(__name__)
class EmailReplyTaskException(Exception):
pass
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")
if not email:
raise ValueError("В контексте нет входящего сообщения")
email_from = self.context.data.get("email_from")
if not email_from:
raise ValueError("В контексте не определен адрес отправителя")
reply_message = MIMEMultipart()
email_subj = self.context.data.get("email_subj")
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}"
reply_message["Date"] = formatdate(localtime=True)
body = "Автоматический ответ на создание заказа"
reply_message.attach(MIMEText(body, "plain", "utf-8"))
self._attach_file(reply_message, attachment)
self.context.email_client.send_email(reply_message)
logger.warning(f"Сформирован ответ на заказ на email")
except Exception as e:
raise TaskExceptionWithEmailNotify("Произошла ошибка при отправке уведомления клиенту об успешном заказе")
def _attach_file(self, reply_message, attachment):
"""
Args:
message: MIMEMultipart
file_path:
"""
try:
part = MIMEBase("application", "octet-stream")
excel_file = attachment["excel"]
excel_file_bytes = excel_file.get_file_bytes()
part.set_payload(excel_file_bytes.read())
encoders.encode_base64(part)
file_name = attachment["name"]
part.add_header(
"Content-Disposition",
f"attachment; filename= {file_name}"
)
reply_message.attach(part)
except Exception as e:
raise Exception(f"Ошибка при аттаче файла: {str(e)}")

View File

@@ -1,74 +0,0 @@
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email.utils import formatdate
from email import encoders
from abc import ABC, abstractmethod
import os
from mail_order_bot.task_processor.abstract_task import AbstractTask
class EmailReplyTask(AbstractTask):
"""Формирует ответ на входящее письмо с запросом на заказ°"""
EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru"
def do(self):
email = self.context.data.get("email")
if not email:
raise ValueError("В контексте нет входящего сообщения")
email_from = self.context.data.get("email_from")
if not email_from:
raise ValueError("В контексте не определен адрес отправителя")
reply_message = MIMEMultipart()
email_subj = self.context.data.get("email_subj")
reply_message["From"] = self.EMAIl
reply_message["To"] = email_from
#reply_message["Cc"] = self.config.get("reply_to", "")
reply_message["Subject"] = f"Re: {email_subj}"
reply_message["Date"] = formatdate(localtime=True)
body = "Автоматический ответ на создание заказа"
reply_message.attach(MIMEText(body, "plain", "utf-8"))
attachments = self.context.data.get("attachments")
for attachment in attachments:
self._attach_file(reply_message, attachment)
self.context.email_client.send_email(reply_message)
def _attach_file(self, reply_message, attachment):
"""
Args:
message: MIMEMultipart
file_path:
"""
try:
part = MIMEBase("application", "octet-stream")
excel_file = attachment["excel"]
excel_file_bytes = excel_file.get_file_bytes()
part.set_payload(excel_file_bytes.read())
encoders.encode_base64(part)
file_name = attachment["name"][0]
part.add_header(
"Content-Disposition",
f"attachment; filename= {file_name}"
)
reply_message.attach(part)
except Exception as e:
raise Exception(f"Ошибка при аттаче файла: {str(e)}")

View File

@@ -1,122 +0,0 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional
from decimal import Decimal
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.task_processor.order.auto_part_position import AutoPartPosition
from mail_order_bot.task_processor.order.auto_part_order import AutoPartOrder
logger = logging.getLogger(__name__)
class OrderParser(AbstractTask):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
file_bytes = BytesIO(attachment['bytes']) # self.context.get("attachment") #
delivery_period = attachment.get("delivery_period", 0)
#try:
df = self._make_dataframe(file_bytes)
mapping = self.config["mapping"]
client_id = self.config["client_id"]
order = AutoPartOrder()
attachment["order"] = order
# Парсим строки
positions = []
for idx, row in df.iterrows():
position = self._parse_row(row, mapping)
if position:
position.order_delivery_period = delivery_period
order.add_position(position)
logger.info(f"Успешно обработано {len(order)} позиций из {len(df)} строк")
#except Exception as e:
# logger.error(f"Ошибка при обработке файла: {e}")
#else:
attachment["order"] = order
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[AutoPartPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
if "name" in mapping:
name = str(row[mapping.get('name', "")]).strip()
else:
name = ""
# Создаем объект позиции
position = AutoPartPosition(
sku=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer', "")]).strip(),
name=name,
requested_price=price,
requested_quantity=quantity,
total=total,
additional_attrs=self._extract_additional_attrs(row, mapping)
)
return position
def _extract_additional_attrs(self, row: pd.Series, mapping: Dict[str, str]) -> Dict[str, Any]:
"""Извлекает дополнительные атрибуты, не входящие в основную модель"""
additional = {}
mapped_columns = set(mapping.values())
for col in row.index:
if col not in mapped_columns and not pd.isna(row[col]):
additional[col] = row[col]
return additional
def _make_dataframe(self, bio) -> pd.DataFrame:
# Получаем все данные из файла
sheet_name = self.config.get("sheet_name", 0)
df_full = pd.read_excel(bio, sheet_name=sheet_name, header=None)
# Находим индекс строки с заголовком
key_field = self.config.get("key_field")
header_row_idx = df_full[
df_full.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
# Считываем таблицу с правильным заголовком
df = pd.read_excel(bio, header=header_row_idx, sheet_name=sheet_name, engine='calamine') # openpyxl calamine
# Находим индекс первой строки с пустым 'Артикул'
first_empty_index = df[df[key_field].isna()].index.min()
# Обрезаем DataFrame до первой пустой строки (не включая её)
df_trimmed = df.loc[:first_empty_index - 1]
return df_trimmed

View File

@@ -1,118 +0,0 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional
from decimal import Decimal
from io import BytesIO
#from mail_order_bot.task_processor.handlers.order_position import OrderPosition
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from ...order.auto_part_position import AutoPartPosition
logger = logging.getLogger(__name__)
class BasicExcelParser(AbstractTask):
RESULT_SECTION = "positions"
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def do(self) -> None:
# todo сделать проверку на наличие файла и его тип
file_bytes = BytesIO(self.context.get("attachment").content) # self.context.get("attachment") #
try:
df = self._make_dataframe(file_bytes)
# Получаем маппинг колонок из конфигурации
mapping = self.config['mapping']
# Парсим строки
positions = []
for idx, row in df.iterrows():
try:
position = self._parse_row(row, mapping)
if position:
positions.append(position)
self.order.add_position(position)
except Exception as e:
logger.error(f"Ошибка парсинга строки {idx}: {e}, {row}")
continue
logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк")
self.context[self.RESULT_SECTION] = positions
except Exception as e:
logger.error(f"Ошибка при обработке файла: {e}")
raise Exception from e
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[AutoPartPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
if mapping.get('name', "") in mapping.keys():
name = str(row[mapping.get('name', "")]).strip()
else:
name = ""
# Создаем объект позиции
position = AutoPartPosition(
sku=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer', "")]).strip(),
name=name,
requested_price=price,
requested_quantity=quantity,
total=total,
additional_attrs=self._extract_additional_attrs(row, mapping)
)
return position
def _extract_additional_attrs(self, row: pd.Series, mapping: Dict[str, str]) -> Dict[str, Any]:
"""Извлекает дополнительные атрибуты, не входящие в основную модель"""
additional = {}
mapped_columns = set(mapping.values())
for col in row.index:
if col not in mapped_columns and not pd.isna(row[col]):
additional[col] = row[col]
return additional
def _make_dataframe(self, bio) -> pd.DataFrame:
# Получаем все данные из файла
sheet_name = self.config.get("sheet_name", 0)
df_full = pd.read_excel(bio, sheet_name=sheet_name, header=None)
# Находим индекс строки с заголовком
key_field = self.config.get("key_field")
header_row_idx = df_full[
df_full.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
# Считываем таблицу с правильным заголовком
df = pd.read_excel(bio, header=header_row_idx, sheet_name=sheet_name, engine='calamine') # openpyxl calamine
# Находим индекс первой строки с пустым 'Артикул'
first_empty_index = df[df[key_field].isna()].index.min()
# Обрезаем DataFrame до первой пустой строки (не включая её)
df_trimmed = df.loc[:first_empty_index - 1]
return df_trimmed

View File

@@ -1,15 +1,14 @@
import logging import logging
import pandas as pd
from io import BytesIO 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, pass_if_error, handle_errors
from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.task_processor.handlers.excel_parcers.order_extractor import ExcelFileParcer
from ...exceptions import TaskExceptionWithEmailNotify
from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ExcelExtractor(AbstractTask): class ExcelExtractor(AbstractTask):
STEP_NAME = "Парсинг эксель файла"
""" """
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
""" """
@@ -17,19 +16,13 @@ class ExcelExtractor(AbstractTask):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {}) self.excel_config = self.config.get("excel", {})
def do(self) -> None: #@pass_if_error
#@handle_errors
# todo сделать проверку на наличие файла и его тип def do(self, attachment) -> None:
try:
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
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)
attachment["excel"] = excel_file attachment["excel"] = excel_file
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 import pandas as pd
from io import BytesIO from io import BytesIO
from mail_order_bot.parsers.order_parcer import OrderParser 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 from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
@@ -10,6 +11,7 @@ logger = logging.getLogger(__name__)
class OrderExtractor(AbstractTask): class OrderExtractor(AbstractTask):
STEP_NAME = "Парсинг заказа"
""" """
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
""" """
@@ -17,26 +19,27 @@ class OrderExtractor(AbstractTask):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {}) self.excel_config = self.config.get("excel", {})
@pass_if_error
def do(self) -> None: #@handle_errors("Произошла ошибка при парсинге заказа")
def do(self, attachment) -> None:
try:
# todo сделать проверку на наличие файла и его тип # todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
delivery_period = attachment.get("delivery_period", 0) delivery_period = attachment.get("delivery_period", 0)
mapping = self.excel_config.get("mapping") mapping = self.excel_config.get("mapping")
excel_file = attachment.get("excel") excel_file = attachment.get("excel")
client_id = self.config.get("client_id") client_id = self.config.get("client_id")
order_parcer = OrderParser(mapping, delivery_period, client_id) order_parcer = OrderParser(mapping, delivery_period, client_id)
order_dataframe = excel_file.get_order_rows() order_dataframe = excel_file.get_order_rows()
order = order_parcer.parse(order_dataframe) order = order_parcer.parse(order_dataframe)
attachment["order"] = order attachment["order"] = order
logger.warning(f"Файл заказа обработан успешно, извлечено {len(order.positions)} позиций")
except Exception as e:
raise TaskExceptionWithEmailNotify(f"Ошибка при парсинге заказа. Детали ошибки: {e}")

View File

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

View File

@@ -1,15 +1,62 @@
"""
Перебирает аттачменты
Для каждого ордера в аттачменте перебирает позиции
Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/
Возможно логику выбора позиции надо вынести из позиции, но пока так
"""
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 ...exceptions import TaskExceptionSilent
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:
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 mail_order_bot.parsers.excel_parcer import ExcelFileParcer
from decimal import Decimal 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.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 TaskExceptionWithEmailNotify
from typing import Dict, Any from typing import Dict, Any
from typing import List, Optional from typing import List, Optional
@@ -22,6 +23,10 @@ from typing import List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RefusalLevelExceededException(TaskExceptionWithEmailNotify):
pass
class StockSelector(AbstractTask): class StockSelector(AbstractTask):
DISTRIBUTOR_ID = 1577730 # ID локального склада DISTRIBUTOR_ID = 1577730 # ID локального склада
""" """
@@ -35,10 +40,11 @@ class StockSelector(AbstractTask):
client_login, client_password = credential_provider.get_system_credentials() client_login, client_password = credential_provider.get_system_credentials()
self.client_provider = AbcpProvider(login=client_login, password=client_password) self.client_provider = AbcpProvider(login=client_login, password=client_password)
def do(self) -> None: @pass_if_error
#@handle_errors
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип # todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", []) try:
for attachment in attachments:
order = attachment.get("order", None) order = attachment.get("order", None)
delivery_period = attachment.get("delivery_period") delivery_period = attachment.get("delivery_period")
for position in order.positions: for position in order.positions:
@@ -64,7 +70,19 @@ 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("Определены оптимальные позиции со складов")
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): def get_optimal_stock(self, stock_list, asking_price, asking_quantity, delivery_period):
"""Выбирает позицию для заказа""" """Выбирает позицию для заказа"""
@@ -73,7 +91,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

@@ -0,0 +1,44 @@
from enum import Enum
class LogMessageLevel(Enum):
SUCCESS = "SUCCESS"
WARNING = "WARNING"
ERROR = "ERROR"
class LogMessage:
def __init__(self, handler=None, level=None, message=None, error_data=None):
self.handler = handler
self.level = level
self.message = message
self.error_data = error_data
def __str__(self):
return self.message
class LogMessageStorage:
def __init__(self, filename=None):
self.filename = filename
self.messages = []
def append(self, message):
self.messages.append(message)
def check_errors(self) -> bool:
fatal_statuses = [message.level == LogMessageLevel.ERROR for message in self.messages]
return bool(sum(fatal_statuses))
def get_messages_log(self) -> str:
response = ""
if self.filename is not None:
response += f" Лог обработки файла: {self.filename}"
for message in self.messages:
if len(response):
response += "\n"
response += f"{message.handler} [{message.level}]: {message.message}"
if message.error_data is not None:
response += f"\n{message.error_data}"

View File

@@ -1,37 +1,62 @@
import os """
import yaml Общая логика обработки писем следующая
1. Общая часть
- скачиваем письмо
- складываем в контекст
- обработчик и парсим данные - тело, тема, отправитель
2. Запускаем паплайн
- прогоняем обработчик для каждого вложения
- каждый обработчик для вложения докидывает результат своей работы
- каждый обработчик анализирует общий лог на наличие фатальных ошибок. Если есть - пропускаем шаг.
Последний обработчик направляет лог ошибок на администратора
Ограничения:
- каждое вложение воспринимается как "отдельное письмо", т.е. если клиент в одном письме направит несколько вложений,
то они будут обрабатываться как отдельные письма, и на каждое будет дан ответ (если он требуется).
Исключительные ситуации:
- При невозможности создать заказ - пересылаем письмо на администратора с логом обработки вложения
- Вложения, которые не являются файлами заказа игнорируем.
todo
[ ] Нужен класс, который будет хранить сообщения от обработчиков
- метод для добавления сообщения
- метод для проверки фатальных ошибок
- метод для извлечения лога
"""
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 import AttachmentHandler
from mail_order_bot.task_processor.handlers.email.email_parcer import EmailParcer 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):
# Очистить контекст и запушить туда письмо # Очистить контекст и запушить туда письмо
@@ -39,45 +64,75 @@ class TaskProcessor:
self.context.data["email"] = email self.context.data["email"] = email
# Парсинг письма # Парсинг письма
email_parcer = EmailParcer() #email_parcer = EmailParcer()
email_parcer.do() #email_parcer.do()
self.parse_email(email)
email_from = self.context.data.get("email_from")
#client = EmailUtils.extract_domain(email_from)
#self.context.data["client"] = client
try:
# Определить конфиг для пайплайна # Определить конфиг для пайплайна
config = self._load_config(email_from) email_sender = self.context.data.get("email_from")
config = self._load_config(email_sender)
self.context.data["config"] = config 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:
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"] pipeline = config["pipeline"]
for stage in pipeline:
handler_name = stage for handler_name in pipeline:
logger.info(f"Processing handler: {handler_name}") logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name]() task = globals()[handler_name]()
task.do() task.do(attachment)
except FileNotFoundError: except Exception as e:
logger.error(f"Конфиг для клиента {email_from} не найден") logger.error(f"Ошибка при обработке файла {file_name}: {e}")
attachment["error"] = e
notifier = EmailForwardErrorTask()
notifier.do(attachment)
for attachment in self.context.data["attachments"]: else:
print(attachment["order"].__dict__) logger.info(f"Обработка писем для {email_sender} отключена. Значение в конфиге: {config.get("enabled")}")
#except Exception as e:
# logger.error(f"Произошла другая ошибка: {e}")
def _load_config(self, sender_email) -> Dict[str, Any]:
if sender_email in self.config:
return self.config[sender_email]
def _load_config(self, email_from) -> Dict[str, Any]: sender_domain = EmailUtils.extract_domain(sender_email)
if email_from in self.config: if sender_domain in self.config:
return self.config[email_from] return self.config[sender_domain]
email_from_domain = EmailUtils.extract_domain(email_from) # Для всех ненастроенных клиентов возвращаем конфиг с "отключенной" обработкой
if email_from_domain in self.config: return {}
return self.config[email_from_domain]
raise FileNotFoundError 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"])}")
#path = os.path.join(self.configs_path, client + '.yml')
#with open(path, 'r', encoding='utf-8') as f:
# return yaml.safe_load(f)