2 Commits

35 changed files with 411 additions and 944 deletions

View File

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

View File

@@ -29,7 +29,24 @@ class AbcpProvider:
path = "/search/articles"
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):
params["userlogin"] = self.login
@@ -37,18 +54,4 @@ class AbcpProvider:
response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params)
payload = response.json()
if response.status_code == 200:
logger.debug(f"Получены данные об остатках на складе")
result = {
"success": True,
"data": payload
}
else:
logger.warning(f"ошибка получения данных об остатках на складе: {payload}")
result = {
"success": False,
"error": payload
}
return result
return response.status_code, payload

View File

@@ -1,4 +1,6 @@
# Настройки обработки =================================================================
folder: "spareparts"
clients:
lesha.spb@gmail.com:
enabled: true
@@ -6,8 +8,8 @@ clients:
pipeline:
- ExcelExtractor
- OrderExtractor
- DeliveryPeriodFromConfig
- OrderExtractor
- StockSelector
- UpdateExcelFile
- SaveOrderToTelegram
@@ -39,7 +41,6 @@ clients:
update_interval: 1
work_interval: 60
email_dir: "spareparts"
# Логирование =================================================================
log:
version: 1
@@ -53,7 +54,7 @@ log:
handlers:
console:
level: DEBUG
level: WARNING
formatter: standard
class: logging.StreamHandler
stream: ext://sys.stdout # Default is stderr
@@ -78,15 +79,15 @@ log:
loggers:
'':
handlers: [console, file, telegram]
level: DEBUG
level: WARNING
propagate: False
__main__:
handlers: [console, file, telegram]
level: INFO
level: WARNING
propagate: False
config_manager:
handlers: [console, file]
level: DEBUG
level: ERROR

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
from typing import Any, Dict
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
logger = logging.getLogger(__name__)
# from .objects import EmailMessage, EmailAttachment
class EmailClient:
def __init__(self, imap_host: str, smtp_host: str, email: str, password: str,
imap_port: int = 993, smtp_port: int = 587):
@@ -32,7 +36,7 @@ class EmailClient:
self.imap_conn = None
def connect(self):
"""Установkение IMAP соединения"""
"""Установление IMAP соединения"""
if self.imap_conn is None:
self.imap_conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
self.imap_conn.login(self.email, self.password)
@@ -58,18 +62,27 @@ class EmailClient:
def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True) -> List[int]:
"""Получить список новых электронных писем."""
self.connect()
self.imap_conn.select(folder, readonly=False)
# Ищем письма
search_criteria = "(UNSEEN)" if only_unseen else "ALL"
status, messages = self.imap_conn.search(None, search_criteria)
try:
self.connect()
self.imap_conn.select(folder, readonly=False)
# Ищем письма
search_criteria = "(UNSEEN)" if only_unseen else "ALL"
status, messages = self.imap_conn.search(None, search_criteria)
# ToDo сделать обработку ошибок, подумать нужна ли она!
if status != "OK":
return []
email_ids = messages[0].split()
except Exception as e:
logger.error(e)
raise
else:
return email_ids
# ToDo сделать обработку ошибок, подумать нужна ли она!
if status != "OK":
return []
email_ids = messages[0].split()
return email_ids
def get_email(self, email_id, mark_as_read: bool = True):
"""Получить список новых электронных писем."""

View File

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

View File

@@ -7,6 +7,7 @@ import os
from dotenv import load_dotenv
from email_client import EmailClient
from mail_order_bot.task_processor.abstract_task import AbstractTask
from task_processor import TaskProcessor
from mail_order_bot.context import Context
@@ -14,6 +15,8 @@ from mail_order_bot.context import Context
logger = logging.getLogger()
class MailOrderBotException(Exception):
pass
class MailOrderBot(ConfigManager):
def __init__(self, *agrs, **kwargs):
@@ -33,35 +36,45 @@ class MailOrderBot(ConfigManager):
self.context = Context()
self.context.email_client = self.email_client
# Обработчик писем
#self.email_processor = TaskProcessor("./configs")
config = self.config.get("clients")
self.email_processor = TaskProcessor(config)
logger.warning("MailOrderBot инициализирован")
def execute(self):
# Получить список айдишников письма
logger.critical("Запуск приложения critical !!!!!!!!")
unread_email_ids = self.email_client.get_emails_id(folder="spareparts")
logger.info(f"Новых писем - {len(unread_email_ids)}")
folder = self.config.get("folder")
try:
unread_email_ids = self.email_client.get_emails_id(folder=folder)
logger.info(f"Новых писем - {len(unread_email_ids)}")
# Обработать каждое письмо по идентификатору
for email_id in unread_email_ids:
try:
logger.info(f"Обработка письма с идентификатором {email_id}")
# Получить письмо по идентификатору и запустить его обработку
email = self.email_client.get_email(email_id, mark_as_read=False)
self.email_processor.process_email(email)
except MailOrderBotException as e:
logger.error(f"Произошла ошибка {e}")
except MailOrderBotException as e:
logger.error(f"Произошла ошибка {e}")
except Exception as e:
logger.error(f"Произошла непредвиденная ошибка {e}")
# Обработать каждое письмо по идентификатору
for email_id in unread_email_ids:
logger.debug(f"==================================================")
logger.debug(f"Обработка письма с идентификатором {email_id}")
# Получить письмо по идентификатору и запустить его обработку
email = self.email_client.get_email(email_id, mark_as_read=False)
self.email_processor.process_email(email)
logger = logging.getLogger()
async def main():
logger.critical("Запуск приложения")
app = MailOrderBot("config.yml")
await app.start()

View File

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

View File

@@ -1 +1,2 @@
from .processor import TaskProcessor
from .processor import TaskProcessor
from .message import LogMessage, LogMessageLevel, LogMessageStorage

View File

@@ -15,7 +15,7 @@ class AbstractTask():
self.config = self.context.data.get("config", {})
@abstractmethod
def do(self) -> None:
def do(self, attachment) -> None:
"""
Выполняет работу над заданием
Входные и выходные данные - в self.context
@@ -23,3 +23,6 @@ class AbstractTask():
"""
raise NotImplementedError
def get_name(self) -> str:
pass

View File

@@ -1,9 +1,7 @@
from .attachment_handler.attachment_handler import AttachmentHandler
from .abcp.api_get_stock import APIGetStock
from .destination_time.local_store import DeliveryPeriodLocalStore
from .destination_time.from_config import DeliveryPeriodFromConfig
from .delivery_time.local_store import DeliveryPeriodLocalStore
from .delivery_time.from_config import DeliveryPeriodFromConfig
from .notifications.test_notifier import TestNotifier
from .excel_parcers.excel_extractor import ExcelExtractor
from .excel_parcers.order_extractor import OrderExtractor
@@ -13,6 +11,6 @@ from .stock_selectors.stock_selector import StockSelector
from .excel_parcers.update_excel_file import UpdateExcelFile
from .email.send_email import EmailReplyTask
from .email.email_reply_task import EmailReplyTask

View File

@@ -23,19 +23,16 @@ class APIGetStock(AbstractTask):
client_login, client_password = credential_provider.get_client_credentials()
self.client_provider = AbcpProvider(login=client_login, password=client_password)
def do(self) -> None:
def do(self, attachment) -> 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()
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")}")
logger.info(f"Получены позиции со склада для файла {attachment.get('name', "no name")}")
def get_stock(self, sku: str, manufacturer: str) -> int:

View File

@@ -18,11 +18,10 @@ class SaveOrderToTelegram(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
def do(self, attachment) -> None:
client = TelegramClient()
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
try:
order = attachment["order"]
positions = order.positions
message = "\nОбработка заказа {указать название контрагента}\n"
@@ -52,9 +51,12 @@ class SaveOrderToTelegram(AbstractTask):
document=file,
filename="document.xlsx"
)
# logger.critical(message)
except Exception as e:
logger.error("Ошибка при отправке инфо по заказу в телеграм")
else:
logger.warning("Инфо по заказу отправлено в телеграм")
#===============================
#===============================

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

@@ -13,13 +13,16 @@ 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)
def do(self, attachment) -> None:
try:
delivery_period = self.config.get("delivery_period")
except Exception as e:
logger.error(f"Ошибка при получении срока доставки из конфига: {e}")
else:
attachment["delivery_period"] = delivery_period
logger.info(f"Доставка только с локального склада, срок 1 день.")
logger.warning(f"Срок доставки установлен из конфига - {delivery_period} (ч.)")

View File

@@ -26,25 +26,30 @@ class DeliveryPeriodFromSubject(AbstractTask):
- Срок переводится в часы (умножается на 24)
"""
# Получаем тему письма
email_subj = self.context.data.get("email_subj", "")
if not email_subj:
logger.warning("Тема письма не найдена в контексте")
email_subj = ""
# Парсим срок доставки
delivery_days = self._parse_delivery_period(email_subj)
# Переводим в часы
delivery_time = delivery_days * 24
logger.info(f"Извлечен срок доставки из темы: {delivery_days} дней ({delivery_time} часов)")
# Сохраняем в каждый элемент attachments
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
attachment["delivery_time"] = delivery_time
logger.debug(f"Срок доставки сохранен в {len(attachments)} вложений")
try:
email_subj = self.context.data.get("email_subj", "")
if not email_subj:
logger.warning("Тема письма не найдена в контексте")
email_subj = ""
# Парсим срок доставки
delivery_days = self._parse_delivery_period(email_subj)
# Переводим в часы
delivery_time = delivery_days * 24
logger.info(f"Извлечен срок доставки из темы: {delivery_days} дней ({delivery_time} часов)")
# Сохраняем в каждый элемент attachments
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
attachment["delivery_time"] = delivery_time
logger.debug(f"Срок доставки для файла {attachment["name"]} установлен как {delivery_time}")
except Exception as e:
logger.error(e)
def _parse_delivery_period(self, subject: str) -> int:
"""

View File

@@ -17,8 +17,4 @@ class DeliveryPeriodLocalStore(AbstractTask):
attachments = self.context.data["attachments"]
for attachment in attachments:
attachment["delivery_period"] = 0
logger.info(f"Доставка только с локального склада, срок 1 день.")
logger.info(f"Срок доставки для файла {attachment["name"]} - только из наличия")

View File

@@ -7,37 +7,53 @@ 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 EmailParcerException(Exception):
pass
class EmailParcer(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
# Определить клиента
email = self.context.data.get("email", None)
if email is not None:
email_body = EmailUtils.extract_body(email)
self.context.data["email_body"] = email_body
try:
email = self.context.data.get("email", None)
if email is not None:
email_body = EmailUtils.extract_body(email)
self.context.data["email_body"] = email_body
# 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
# 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
email_from_domain = EmailUtils.extract_domain(email_from)
self.context.data["email_from_domain"] = email_from_domain
email_from_domain = EmailUtils.extract_domain(email_from)
self.context.data["email_from_domain"] = email_from_domain
email_subj = EmailUtils.extract_header(email, "subj")
self.context.data["email_subj"] = email_subj
email_subj = EmailUtils.extract_header(email, "subj")
self.context.data["email_subj"] = email_subj
client = EmailUtils.extract_domain(email_from)
self.context.data["client"] = client
client = EmailUtils.extract_domain(email_from)
self.context.data["client"] = client
attachments = EmailUtils.extract_attachments(email)
self.context.data["attachments"] = attachments
logger.info(f"Извлечено вложений: {len(attachments)} ")
attachments = EmailUtils.extract_attachments(email)
self.context.data["attachments"] = 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,82 @@
import logging
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 mail_order_bot.task_processor.abstract_task import AbstractTask
logger = logging.getLogger(__name__)
class EmailReplyTaskException(Exception):
pass
class EmailReplyTask(AbstractTask):
"""Формирует ответ на входящее письмо с запросом на заказ°"""
EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru"
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"] = 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"))
self._attach_file(reply_message, attachment)
self.context.email_client.send_email(reply_message)
except Exception as e:
logger.error(f"Ошибка при отправке ответа по заказу на email \n{e}")
else:
logger.warning(f"Сформирован ответ на заказ на email")
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,6 +1,8 @@
import logging
import pandas as pd
from io import BytesIO
from mail_order_bot.email_client import EmailUtils
#from mail_order_bot.task_processor.handlers.order_position import OrderPosition
from mail_order_bot.task_processor.abstract_task import AbstractTask
@@ -17,17 +19,18 @@ class ExcelExtractor(AbstractTask):
super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
def do(self) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
def do(self, attachment) -> None:
try:
file_bytes = BytesIO(attachment['bytes'])
excel_file = ExcelFileParcer(file_bytes, self.excel_config)
except Exception as e:
logger.error(f"Не удалось распарсить файл: \n{e}")
attachment["excel"] = None
else:
attachment["excel"] = excel_file
logger.warning(f"Произведен успешный парсинг файла")

View File

@@ -18,25 +18,27 @@ class OrderExtractor(AbstractTask):
self.excel_config = self.config.get("excel", {})
def do(self) -> None:
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
try:
delivery_period = attachment.get("delivery_period", 0)
mapping = self.excel_config.get("mapping")
excel_file = attachment.get("excel")
client_id = self.config.get("client_id")
order_parcer = OrderParser(mapping, delivery_period, client_id)
order_dataframe = excel_file.get_order_rows()
order = order_parcer.parse(order_dataframe)
except Exception as e:
logger.error(f"Ошибка при парсинге заказа файла: \n{e}")
else:
attachment["order"] = order
logger.warning(f"Обработан файл с заказом, извлечено позиций, {len(order.positions)}")

View File

@@ -20,11 +20,10 @@ class UpdateExcelFile(AbstractTask):
super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
def do(self) -> None:
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
try:
excel_file = attachment.get("excel")
order = attachment.get("order")
config = self.context.data.get("config", {})
@@ -47,5 +46,9 @@ class UpdateExcelFile(AbstractTask):
column = value
value = position.order_price
excel_file.set_value(sku, manufacturer, column, value)
except Exception as e:
logger.error(f"Ошибка при правке excel файла: \n{e}")
else:
logger.warning(f"Файл excel успешно обновлен")

View File

@@ -35,10 +35,10 @@ class StockSelector(AbstractTask):
client_login, client_password = credential_provider.get_system_credentials()
self.client_provider = AbcpProvider(login=client_login, password=client_password)
def do(self) -> None:
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
try:
order = attachment.get("order", None)
delivery_period = attachment.get("delivery_period")
for position in order.positions:
@@ -63,6 +63,11 @@ class StockSelector(AbstractTask):
# Мне не очень нравится управление статусами в этом месте, кажется что лучше это делать внутри AutoPartPosition
else:
position.status = PositionStatus.STOCK_FAILED
except Exception as e:
logger.error(f"Ошибка при выборе позиции со складов: {e}")
else:
logger.warning("Определены оптимальные позиции со складов")

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,3 +1,35 @@
"""
Общая логика обработки писем следующая
1. Общая часть
- скачиваем письмо
- складываем в контекст
- обработчик и парсим данные - тело, тема, отправитель
2. Запускаем паплайн
- прогоняем обработчик для каждого вложения
- каждый обработчик для вложения докидывает результат своей работы
- каждый обработчик анализирует общий лог на наличие фатальных ошибок. Если есть - пропускаем шаг.
Последний обработчик направляет лог ошибок на администратора
Ограничения:
- каждое вложение воспринимается как "отдельное письмо", т.е. если клиент в одном письме направит несколько вложений,
то они будут обрабатываться как отдельные письма, и на каждое будет дан ответ (если он требуется).
Исключительные ситуации:
- При невозможности создать заказ - пересылаем письмо на администратора с логом обработки вложения
- Вложения, которые не являются файлами заказа игнорируем.
todo
[ ] Нужен класс, который будет хранить сообщения от обработчиков
- метод для добавления сообщения
- метод для проверки фатальных ошибок
- метод для извлечения лога
"""
import os
import yaml
import logging
@@ -9,9 +41,11 @@ 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 AttachmentHandler
from mail_order_bot.task_processor.handlers.email.email_parcer import EmailParcer
from mail_order_bot.task_processor.message import LogMessage, LogMessageLevel, LogMessageStorage
logger = logging.getLogger(__name__)
@@ -38,34 +72,34 @@ class TaskProcessor:
self.context.clear()
self.context.data["email"] = email
# Парсинг письма
email_parcer = EmailParcer()
email_parcer.do()
email_from = self.context.data.get("email_from")
#client = EmailUtils.extract_domain(email_from)
#self.context.data["client"] = client
try:
# Парсинг письма
email_parcer = EmailParcer()
email_parcer.do()
email_sender = self.context.data.get("email_from")
# Определить конфиг для пайплайна
config = self._load_config(email_from)
config = self._load_config(email_sender)
self.context.data["config"] = config
# Запустить обработку пайплайна
pipeline = config["pipeline"]
for stage in pipeline:
handler_name = stage
logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name]()
task.do()
except FileNotFoundError:
logger.error(f"Конфиг для клиента {email_from} не найден")
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
file_name = attachment["name"]
logger.warning(f"Начата обработка файла: {file_name} =>")
for attachment in self.context.data["attachments"]:
print(attachment["order"].__dict__)
#except Exception as e:
# logger.error(f"Произошла другая ошибка: {e}")
attachment["log_messages"] = LogMessageStorage(file_name)
# Запустить обработку пайплайна
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}")
def _load_config(self, email_from) -> Dict[str, Any]:
@@ -73,11 +107,8 @@ class TaskProcessor:
return self.config[email_from]
email_from_domain = EmailUtils.extract_domain(email_from)
if email_from_domain in self.config:
return self.config[email_from_domain]
raise FileNotFoundError
#path = os.path.join(self.configs_path, client + '.yml')
#with open(path, 'r', encoding='utf-8') as f:
# return yaml.safe_load(f)