18 Commits

Author SHA1 Message Date
8aed3446bf no message 2026-01-13 23:01:43 +03:00
049f018232 Добавлен статус заказа и общие действия в декораторах 2026-01-11 01:03:22 +03:00
15500d74bd Перевел таск процессор на обработку только файлов 2026-01-10 21:55:03 +03:00
7b5bba2a17 no message 2026-01-10 19:53:17 +03:00
f96b0a076b no message 2026-01-06 21:25:45 +03:00
4cb8c9c88e Add Telegram Bot settings to example.env 2025-12-29 06:55:15 +03:00
55ba627f6b no message 2025-12-23 23:12:43 +03:00
b26485c5cc Update code structure and improve readability 2025-12-22 22:48:57 +03:00
0022141684 no message 2025-12-14 16:23:40 +03:00
7043743373 no message 2025-12-14 15:00:18 +03:00
0c39af460f no message 2025-12-12 23:25:39 +03:00
1222488aec no message 2025-12-11 23:06:13 +03:00
816da1eb16 no message 2025-12-11 21:28:07 +03:00
03aca0ecb9 no message 2025-12-09 22:09:13 +03:00
67b75e531a no message 2025-12-08 23:22:19 +03:00
084dc53baa no message 2025-12-07 17:03:40 +03:00
6bcac057d1 no message 2025-12-07 16:44:23 +03:00
03a64d6263 no message 2025-12-06 21:25:27 +03:00
83 changed files with 2562 additions and 2072 deletions

9
business_rules/br.md Normal file
View File

@@ -0,0 +1,9 @@
Создание заказа через API ABCP
1. Логинимся под учеткой заказчика
2. Получаем остатки
3. Отсекаем не подходящие по сроку (дольше) и цене
4. Подбираем позицию максимально близкую к цене из заказа
- Приоритет отдаем складу, где есть все заказы
- Приоритет отдаем позициям из наличия, потом с доставкой с других складов
- По цене выбираем наиболее близкую к цене заказа (меньше или равно)
- При невозможности заказать в одном месте разбиваем заказ из нескольких складов

View File

@@ -10,3 +10,7 @@ IMAP_PORT=993
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 SMTP_PORT=587
# Telegram Bot settings
TELEGRAM_BOT_TOKEN=your_bot_token_here
TELEGRAM_CHAT_ID=your_chat_id_here

View File

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

View File

@@ -0,0 +1,5 @@
"""
Классы для работы с API платформы ABCP
"""
from .abcp_provider import AbcpProvider

View File

@@ -1,7 +1,8 @@
import os
import hashlib import hashlib
import requests import requests
import logging
logger = logging.getLogger(__name__)
class AbcpProvider: class AbcpProvider:
HOST = "https://id23089.public.api.abcp.ru" HOST = "https://id23089.public.api.abcp.ru"
@@ -11,25 +12,46 @@ class AbcpProvider:
"Content-Type": "application/x-www-form-urlencoded" "Content-Type": "application/x-www-form-urlencoded"
} }
def __init__(self): def __init__(self, login: str, password: str):
self.base_url = self.HOST """
self.login = os.getenv("ABCP_LOGIN") Инициализация AbcpProvider.
password = os.getenv("ABCP_PASSWORD")
self.password = hashlib.md5(password.encode("utf-8")).hexdigest()
def get_stock(self, order): Args:
login: Логин для доступа к API
password: Пароль для доступа к API
"""
self.base_url = self.HOST
self.login = login
self.password = password
def get_stock(self, sku, manufacturer):
method = "GET" method = "GET"
path = "/search/articles" path = "/search/articles"
for position in order.positions: params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"}
params = {"number": position.sku, "brand": position.manufacturer, "withOutAnalogs": "1"}
position.stock = 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
params["userpsw"] = self.password params["userpsw"] = hashlib.md5(self.password.encode("utf-8")).hexdigest()
response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params)
if response.status_code != 200:
raise Exception(response.text)
return response.json()
response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params)
payload = response.json()
return response.status_code, payload

View File

@@ -1,18 +0,0 @@
"""
Пакет содержит реализацию для создания заказов из вложений.
На входе файл из вложения
Его обработка производится хендлерами, коотрые настраиваются в конфиге
На выходе - экземпляр класса, который в себе содержит
- прочитанный файл в виде pandas DataFrame
- распарсенный файл заказа с позициями
- полученные остатки
- результат проверки возможности создания заказа
Так же класс содержит методы
- для создания заказа
- для получения отредактированного файла
"""
from .processor import TaskProcessor

View File

@@ -1,9 +0,0 @@
from .abcp_clients.check_stock import GetStock
from .abcp_clients.create_order import InstantOrderTest
from .excel_parcers.basic_excel_parcer import BasicExcelParser
from .notifications.test_notifier import TestNotifier
from .validators.price_quantity_ckecker import CheckOrder

View File

@@ -1,26 +0,0 @@
import random
import logging
from mail_order_bot.attachment_handler.handlers.abstract_task import AbstractTask
logger = logging.getLogger(__name__)
def get_stock(brand, part_number):
return random.randint(0, 10)
class GetStock(AbstractTask):
def do(self) -> None:
positions = self.order.positions
for position in positions:
self._update_stock(position)
def _update_stock(self, position):
# Эмулируем получение данных
max_stock = self.config.get('max_stock',10)
stock = random.randint(0, max_stock)
price = position.requested_price
position.stock_price = price
position.stock_quantity = stock

View File

@@ -1,37 +0,0 @@
import logging
import requests
from mail_order_bot.attachment_handler.handlers.abstract_task import AbstractTask
from mail_order_bot.attachment_handler.order.auto_part_order import OrderStatus
logger = logging.getLogger(__name__)
class InstantOrderTest(AbstractTask):
URL = "https://api.telegram.org/bot{0}/sendMessage?chat_id={1}&text={2}"
def do(self) -> None:
api_key = self.config["api_key"]
chat_id = self.config["chat_id"]
if self.order.status == OrderStatus.IN_PROGRESS:
positions = self.order.positions
message = f"Запрос на создание заказа от {self.context['client']}:\n"
message += "\n".join(f"{pos.sku}: {pos.name} ({pos.order_quantity} x {pos.order_price} = {pos.total})" for pos in positions)
elif self.order.status == OrderStatus.OPERATOR_REQUIRED:
message = f"Запрос на создание заказа от {self.context['client']} отклонен - необходима ручная обработка.\n"
message += f"Причина: {self.order.reason}"
else:
message = f"Запрос на создание заказа от {self.context['client']} отклонен.\n"
message += f" Статус заказа: {self.order.status}"
#url = self.URL.format(api_key, chat_id, message)
#resp = requests.get(url).json()
print(message)
#logger.info(resp)

View File

@@ -1,24 +0,0 @@
import logging
from abc import ABC, abstractmethod
from typing import Dict, Any
from ..order.auto_part_order import AutoPartOrder
class AbstractTask(ABC):
RESULT_SECTION = "section"
"""
Абстрактный базовый класс для всех хэндлеров.
"""
def __init__(self, config: Dict[str, Any], context: Dict[str, Any], order: AutoPartOrder, *args, **kwargs) -> None:
self.config = config
self.context = context
self.order = order
@abstractmethod
def do(self) -> None:
"""
Выполняет работу над заданием
Входные и выходные данные - в self.context
Конфиг задается при инициализации
"""
raise NotImplementedError

View File

@@ -1,141 +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
class AbstractTask(ABC):
"""Базовый класс для задач"""
def __init__(self, context, config):
self.context = context
self.config = config
@abstractmethod
def do(self):
"""Метод для реализации в подклассах"""
pass
class EmailReplyTask(AbstractTask):
"""Класс для ответа на электронные письма"""
def do(self):
"""
Отправляет ответ на входящее письмо
Ожидает в self.context:
- message: email.message.Message объект входящего письма
- attachment: путь к файлу для вложения
Ожидает в self.config:
- reply_to: адрес электронной почты для копии
- smtp_host: хост SMTP сервера
- smtp_port: порт SMTP сервера
- smtp_user: пользователь SMTP
- smtp_password: пароль SMTP
- from_email: адрес отправителя
"""
incoming_message = self.context.get("message")
attachment_path = self.context.get("attacnment")
if not incoming_message:
raise ValueError("Рcontext не найдено письмо (message)")
# Получаем адрес отправителя входящего письма
from_addr = incoming_message.get("From")
if not from_addr:
raise ValueError("Входящее письмо не содержит адреса отправителя")
# Создаем ответное письмо
reply_message = MIMEMultipart()
# Заголовки ответного письма
reply_message["From"] = self.config.get("from_email", "noreply@example.com")
reply_message["To"] = from_addr
reply_message["Cc"] = self.config.get("reply_to", "")
reply_message["Subject"] = f"Re: {incoming_message.get('Subject', '')}"
reply_message["Date"] = formatdate(localtime=True)
# Тело письма
body = "Ваш заказ создан"
reply_message.attach(MIMEText(body, "plain", "utf-8"))
# Добавляем вложение если указан путь к файлу
if attachment_path and os.path.isfile(attachment_path):
self._attach_file(reply_message, attachment_path)
# Отправляем письмо
self._send_email(reply_message, from_addr)
def _attach_file(self, message, file_path):
"""
Добавляет файл в качестве вложения к письму
Args:
message: MIMEMultipart объект
file_path: путь к файлу для вложения
"""
try:
with open(file_path, "rb") as attachment:
part = MIMEBase("application", "octet-stream")
part.set_payload(attachment.read())
encoders.encode_base64(part)
file_name = os.path.basename(file_path)
part.add_header(
"Content-Disposition",
f"attachment; filename= {file_name}"
)
message.attach(part)
except FileNotFoundError:
raise FileNotFoundError(f"Файл не найден: {file_path}")
except Exception as e:
raise Exception(f"Ошибка при добавлении вложения: {str(e)}")
def _send_email(self, message, recipient):
"""
Отправляет письмо через SMTP
Args:
message: MIMEMultipart объект письма
recipient: адрес получателя
"""
try:
smtp_host = self.config.get("smtp_host")
smtp_port = self.config.get("smtp_port", 587)
smtp_user = self.config.get("smtp_user")
smtp_password = self.config.get("smtp_password")
if not all([smtp_host, smtp_user, smtp_password]):
raise ValueError("Не указаны параметры SMTP в config")
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_password)
# Получаем адреса получателей (основной + копия)
recipients = [recipient]
reply_to = self.config.get("reply_to")
if reply_to:
recipients.append(reply_to)
server.sendmail(
self.config.get("from_email"),
recipients,
message.as_string()
)
except smtplib.SMTPException as e:
raise Exception(f"Ошибка SMTP: {str(e)}")
except Exception as e:
raise Exception(f"Ошибка при отправке письма: {str(e)}")

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.attachment_handler.handlers.order_position import OrderPosition
from mail_order_bot.attachment_handler.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 +0,0 @@
import logging
from mail_order_bot.attachment_handler.handlers.abstract_task import AbstractTask
logger = logging.getLogger(__name__)
class TestNotifier(AbstractTask):
def do(self) -> None:
positions = self.context["positions"]
print(f"\nПолучено {len(positions)} позиций от {self.context["client"]}:")
for pos in positions: # Первые 5
print(f" - {pos.sku}: {pos.name} "
f"({pos.requested_quantity} x {pos.requested_price} = {pos.total})")

View File

@@ -1,50 +0,0 @@
import random
import logging
from mail_order_bot.attachment_handler.handlers.abstract_task import AbstractTask
from mail_order_bot.attachment_handler.order.auto_part_order import OrderStatus
from decimal import Decimal
import random
logger = logging.getLogger(__name__)
class CheckOrder(AbstractTask):
def do(self) -> None:
refused = 0
positions = self.order.positions
for position in positions:
self._set_order_price(position)
self._set_order_quantity(position)
if position.order_price == 0 or position.order_quantity == 0:
refused += 1
self._check_refusal_threshold(refused)
def _set_order_price(self, position):
# Эмулируем получение данных
acceptable_price_reduction = self.config.get("acceptable_price_reduction")
acceptable_price = position.stock_price* Decimal(str((1-acceptable_price_reduction/100)))
if position.requested_price < acceptable_price:
position.order_price = 0
else:
position.order_price = position.requested_price
def _set_order_quantity(self, position):
max_stock = self.config.get("max_stock", 100)
min_stock = self.config.get("min_stock", 0)
stock_quantity = random.randint(min_stock, max_stock)
position.order_quantity = max(0, min(position.stock_quantity, stock_quantity))
def _check_refusal_threshold(self, refused):
refusal_threshold_limit = self.config.get("refusal_threshold", 1)
refusal_level = refused/len(self.order.positions)
if refusal_level > refusal_threshold_limit:
self.order.status = OrderStatus.OPERATOR_REQUIRED
self.order.reason = "Превышен порог отказов"

View File

@@ -1,34 +0,0 @@
from typing import List, Optional
from .auto_part_position import AutoPartPosition
from enum import Enum
class OrderStatus(Enum):
NEW = "new"
IN_PROGRESS = "in progress"
FAILED = "failed"
COMPLETED = "completed"
OPERATOR_REQUIRED = "operator required"
INVALID = "invalid"
class AutoPartOrder:
def __init__(self):
self.positions: List[AutoPartPosition] = []
self.status = OrderStatus.NEW
self.reason = ""
def add_position(self, position: AutoPartPosition) -> None:
self.positions.append(position)
if self.status == OrderStatus.NEW:
self.status = OrderStatus.IN_PROGRESS
def find_positions(self, brand: Optional[str] = None, sku: Optional[str] = None) -> List[AutoPartPosition]:
results = self.positions
if brand is not None:
results = [p for p in results if p.manufacturer == brand]
if sku is not None:
results = [p for p in results if p.sku == sku]
return results
def __len__(self):
return len(self.positions)

View File

@@ -1,77 +0,0 @@
from typing import List, Optional
from dataclasses import dataclass, field
from typing import Dict, Any
from decimal import Decimal
@dataclass
class AutoPartPosition:
"""
Унифицированная модель позиции для заказа.
Все контрагенты приводятся к этой структуре.
"""
sku: str # Артикул товара
manufacturer: str # Производитель
name: str # Наименование
requested_price: Decimal # Цена за единицу
requested_quantity: int # Количество
total: Decimal # Общая сумма
stock_quantity: int = 0 # Остаток на складе
stock_price: Decimal = Decimal('0.0') # Цена на складе
order_quantity: int = 0 # Количество для заказа
order_price: Decimal = Decimal('0.0') # Цена в заказе
additional_attrs: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Валидация после инициализации"""
if self.requested_quantity < 0:
raise ValueError(f"Количество не может быть отрицательным: {self.requested_quantity}")
if self.requested_price < 0:
raise ValueError(f"Цена не может быть отрицательной: {self.requested_price}")
class AutoPartPosition2:
brand: str
sku: str
name: str
customer_price: float
customer_quantity: int
supplier_price: float
stock_remaining: int
def __init__(self, brand: str, sku: str, name: str,
customer_price: float, customer_quantity: int,
supplier_price: float, stock_remaining: int):
self.brand = brand
self.sku = sku
self.name = name
self.customer_price = customer_price
self.customer_quantity = customer_quantity
self.supplier_price = supplier_price
self.stock_remaining = stock_remaining
def customer_cost(self) -> float:
return self.customer_price * self.customer_quantity
def supplier_cost(self) -> float:
return self.supplier_price * self.customer_quantity
def is_available(self) -> bool:
return self.stock_remaining >= self.customer_quantity
def restock(self, amount: int) -> None:
if amount < 0:
raise ValueError("Restock amount must be non-negative")
self.stock_remaining += amount
def __post_init__(self):
if self.customer_price < 0:
raise ValueError("Customer price cannot be negative")
if self.customer_quantity < 0:
raise ValueError("Customer quantity cannot be negative")
if self.supplier_price < 0:
raise ValueError("Supplier price cannot be negative")
if self.stock_remaining < 0:
raise ValueError("Stock remaining cannot be negative")

View File

@@ -1,34 +0,0 @@
from mail_order_bot.attachment_handler.order.auto_part_order import AutoPartOrder
class Position:
def __init__(self):
pass
class AutoPartOrder
def __init__(self):
pass
class OrderRequest:
def __init__(self, attachment):
self.attachment = attachment
self.parsed = None
self.positions = []
self.status=None
def add_position(self, position):
pass
def find_positions(self):
pass
def __process(self):
pass
def get_file(self):
pass
def get_attachment(self):
pass

View File

@@ -1,42 +0,0 @@
import os
import yaml
import logging
from typing import Dict, Any
from pathlib import Path
logger = logging.getLogger(__name__)
from .order.auto_part_order import AutoPartOrder
from .handlers import *
class TaskProcessor:
def __init__(self, config_path: Path):
self.config_path = config_path
self.context = dict()
self.order = None
def process(self, client, attachment):
config = self._load_config(client)
self.context = dict()
self.order = AutoPartOrder()
self.context["client"] = client
self.context["attachment"] = attachment
self.context["status"] = client
for stage in config["pipeline"]:
handler_name = stage["handler"]
logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name](stage.get("config", None), self.context, self.order)
task.do()
return self.context
pass
def _load_config(self, client) -> Dict[str, Any]:
"""Загружает конфигурацию из YAML или JSON"""
path = os.path.join(self.config_path, client + '.yml')
with open(path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)

View File

@@ -1,10 +1,49 @@
# Настройки обработки ================================================================= # Настройки обработки =================================================================
folder: "spareparts"
clients:
lesha.spb@gmail.com:
enabled: true
client_id: 6148154 # Сейчас стоит айдишник Димы для тестовых заказов
refusal_threshold: 0.01
pipeline:
- ExcelExtractor
- DeliveryPeriodFromConfig
- OrderExtractor
- StockSelector
- UpdateExcelFile
- TelegramNotifier
#- EmailReplyTask
#- EmailForwardErrorTask
excel:
sheet_name: 0
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Фирма"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
updatable_fields:
ordered_quantity: "Кол-во Поставщика"
ordered_price: "Цена Поставщика"
# Значение для хендлера DeliveryPeriodFromConfig
delivery_period: 100 # в часах
amtel.ru:
enabled: false
# Раздел с общими конфигурационными параметрами =============================== # Раздел с общими конфигурационными параметрами ===============================
update_interval: 10 update_interval: 1
work_interval: 30 work_interval: 60
email_dir: "spareparts" email_dir: "spareparts"
# Логирование ================================================================= # Логирование =================================================================
log: log:
version: 1 version: 1
@@ -12,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'
@@ -36,10 +75,9 @@ log:
level: CRITICAL level: CRITICAL
formatter: telegram formatter: telegram
class: logging_telegram_handler.TelegramHandler class: logging_telegram_handler.TelegramHandler
chat_id: 211945135 chat_id: -1002960678041 #-1002960678041 #211945135
alias: "Mail order bot" alias: "Mail order bot"
# Логгеры # Логгеры
loggers: loggers:
'': '':
@@ -49,10 +87,14 @@ log:
__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]
level: ERROR
utils:
handlers: [ console, file ] handlers: [ console, file ]
level: DEBUG level: DEBUG

View File

@@ -0,0 +1,39 @@
#=========================================
client: amtel.club
enabled: true
client_id: 6148154 # Сейчас стоит айдишник Димы, фактический id у amtel.club - 156799563
delivery_period: 100 # в часах
excel:
sheet_name: 0
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Фирма"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
updatable_fields:
ordered_quantity: "Кол-во Поставщика"
ordered_price: "Цена Поставщика"
#=========================================
pipeline:
- ExcelExtractor
- OrderExtractor
- DeliveryPeriodFromConfig
- StockSelector
- UpdateExcelFile
- SaveOrderToTelegram

View File

@@ -0,0 +1,39 @@
#=========================================
client: gmail.com
enabled: true
client_id: 6148154 # Сейчас стоит айдишник Димы, фактический id у amtel.club - 156799563
delivery_period: 100 # в часах
excel:
sheet_name: 0
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Фирма"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
updatable_fields:
ordered_quantity: "Кол-во Поставщика"
ordered_price: "Цена Поставщика"
#=========================================
pipeline:
- ExcelExtractor
- OrderExtractor
- DeliveryPeriodFromConfig
- StockSelector
- UpdateExcelFile
- SaveOrderToTelegram

View File

@@ -1,6 +1,5 @@
pipeline: pipeline:
- handler: "ConfigurableExcelParser" - handler: BasicExcelParser
result_section: "positions"
config: config:
sheet_name: 0 sheet_name: 0
key_field: "Код детали" key_field: "Код детали"
@@ -12,6 +11,9 @@ pipeline:
quantity: "Кол-\nво" quantity: "Кол-\nво"
total: "Сумма" total: "Сумма"
- handler: GetStock

View File

@@ -0,0 +1,102 @@
"""
Структура контекста
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
logger = logging.getLogger()
import threading
from typing import Any
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class Context2(metaclass=SingletonMeta):
def __init__(self):
if not hasattr(self, 'initialized'):
self.data = {}
self.email_client = None
self.initialized = True
logger.debug(f"Context создан {id}") # опциональный лог
# будет вызван только при первом создании
def clear(self):
"""Очищает self.context, устанавливая его в None или пустой словарь"""
self.data = {}
logger.debug("Context очищен") # опциональный лог
def set(self, new_context: Dict[str, Any]):
"""Устанавливает новый контекст (бонусный метод)"""
self.data = new_context
logger.debug("Новый контекст установлен")
class ThreadSafeSingletonMeta(type):
_instances = {}
_lock = threading.Lock()
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
if cls not in cls._instances:
with cls._lock:
if cls not in cls._instances:
# Инициализация ТУТ, не в __init__
instance = super().__call__(*args, **kwargs)
instance.data = {}
instance.email_client = None
instance._lock = threading.RLock()
cls._instances[cls] = instance
return cls._instances[cls]
class Context(metaclass=ThreadSafeSingletonMeta):
def __init__(self):
print(f"Context: {id(self)}, поток {threading.get_ident()}")
# будет вызван только при первом создании
def clear(self):
"""Очищает self.context, устанавливая его в None или пустой словарь"""
with self._lock:
self.data = {}
logger.debug("Context очищен") # опциональный лог
def set(self, new_context: Dict[str, Any]):
"""Устанавливает новый контекст (бонусный метод)"""
with self._lock:
self.data = new_context
logger.debug("Новый контекст установлен")

View File

@@ -0,0 +1,108 @@
import os
import logging
from typing import Optional, Tuple
from mail_order_bot.context import Context
logger = logging.getLogger(__name__)
class CredentialProvider:
"""
Класс для получения учетных данных (логин и пароль) для доступа к API.
Учетные данные берутся из переменных окружения в формате:
- {PREFIX}_LOGIN_{CLIENT_NAME} - логин для клиента
- {PREFIX}_PASSWORD_{CLIENT_NAME} - пароль для клиента
- {PREFIX}_LOGIN_SYSTEM - логин для системной учетной записи
- {PREFIX}_PASSWORD_SYSTEM - пароль для системной учетной записи
"""
SYSTEM_ACCOUNT = "SYSTEM"
def __init__(self, prefix: str = "ABCP", context: Optional[Context] = None):
"""
Инициализация CredentialProvider.
Args:
prefix: Префикс для переменных окружения (по умолчанию "ABCP")
context: Контекст приложения. Если не передан, будет получен через Context()
"""
self.prefix = prefix.upper()
self.context = context if context is not None else Context()
def get_client_credentials(self, client_name: Optional[str] = None) -> Tuple[str, str]:
"""
Получает учетные данные для клиента.
Если client_name не указан, берется из контекста (context.data.get("client")).
Args:
client_name: Имя клиента. Если None, берется из контекста.
Returns:
Tuple[str, str]: Кортеж (логин, пароль)
Raises:
ValueError: Если не удалось получить имя клиента или учетные данные не найдены
"""
if client_name is None:
client_name = self.context.data.get("client")
if client_name is None:
raise ValueError("Имя клиента не указано и не найдено в контексте")
login_key = f"{self.prefix}_LOGIN_{client_name}"
password_key = f"{self.prefix}_PASSWORD_{client_name}"
login = os.getenv(login_key)
password = os.getenv(password_key)
if login is None or password is None:
raise ValueError(
f"Учетные данные для клиента '{client_name}' не найдены. "
f"Проверьте переменные окружения: {login_key} и {password_key}"
)
logger.debug(f"Получены учетные данные для клиента '{client_name}'")
return login, password
def get_system_credentials(self) -> Tuple[str, str]:
"""
Получает учетные данные для системной учетной записи.
Returns:
Tuple[str, str]: Кортеж (логин, пароль)
Raises:
ValueError: Если учетные данные системной учетной записи не найдены
"""
login_key = f"{self.prefix}_LOGIN"
password_key = f"{self.prefix}_PASSWORD"
login = os.getenv(login_key)
password = os.getenv(password_key)
if login is None or password is None:
raise ValueError(
f"Учетные данные для системной учетной записи не найдены. "
f"Проверьте переменные окружения: {login_key} и {password_key}"
)
logger.debug("Получены учетные данные для системной учетной записи")
return login, password
def get_credentials(self, use_system: bool = False, client_name: Optional[str] = None) -> Tuple[str, str]:
"""
Универсальный метод для получения учетных данных.
Args:
use_system: Если True, возвращает учетные данные системной учетной записи.
Если False, возвращает учетные данные клиента.
client_name: Имя клиента. Если None и use_system=False, берется из контекста.
Returns:
Tuple[str, str]: Кортеж (логин, пароль)
"""
if use_system:
return self.get_system_credentials()
else:
return self.get_client_credentials(client_name)

View File

@@ -1,2 +1,3 @@
from .client import EmailClient from .client import EmailClient
from .objects import EmailMessage, EmailAttachment from .objects import EmailMessage, EmailAttachment
from .utils import EmailUtils

View File

@@ -1,6 +1,6 @@
import re import re
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional, Union
from dataclasses import dataclass from dataclasses import dataclass
import email import email
from email import encoders from email import encoders
@@ -8,41 +8,25 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.header import decode_header from email.header import decode_header
from email.message import Message
import imaplib import imaplib
import smtplib import smtplib
from .objects import EmailMessage, EmailAttachment import logging
logger = logging.getLogger(__name__)
# from .objects import EmailMessage, EmailAttachment
class EmailClient: class EmailClient:
"""
Класс для работы с электронной почтой по протоколам IMAP и SMTP.
Пример использования:
client = EmailClient(
imap_host='imap.gmail.com',
smtp_host='smtp.gmail.com',
email='your_email@gmail.com',
password='your_password'
)
# Получить новые письма
new_emails = client.get_emails()
# Отправить письмо
msg = EmailMessage(
from_addr='sender@example.com',
subj='Test',
dt=datetime.now(),
body='Hello!',
attachments=[]
)
client.send_email(msg, to_addr='recipient@example.com')
"""
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):
self.imap_host = imap_host self.imap_host = imap_host
self.smtp_host = smtp_host self.smtp_host = smtp_host
self.email = email self.email = email
@@ -52,13 +36,13 @@ class EmailClient:
self.imap_conn = None self.imap_conn = None
def connect(self): def connect(self):
"""Установить 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)
def disconnect(self): def disconnect(self):
"""Закрыть IMAP соединение""" """Закрытие IMAP соединения"""
if self.imap_conn: if self.imap_conn:
try: try:
self.imap_conn.disconnect() self.imap_conn.disconnect()
@@ -67,7 +51,57 @@ class EmailClient:
pass pass
self.imap_conn = None self.imap_conn = None
def _decode_header(self, header_value: str) -> str: def __enter__(self):
"""Поддержка контекстного менеджера"""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Поддержка контекстного менеджера"""
self.disconnect()
def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True) -> List[int]:
"""Получить список новых электронных писем."""
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
def get_email(self, email_id, mark_as_read: bool = True):
"""Получить список новых электронных писем."""
self.connect()
status, msg_data = self.imap_conn.fetch(email_id, "(RFC822)")
if status != "OK":
pass
# Парсим письмо
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
# Помечаем письмо как прочитанное
if mark_as_read:
self.imap_conn.store(email_id, '+FLAGS', '\\Seen')
return msg
def decode_header(self, header_value: str) -> str:
"""Декодировать заголовок письма.""" """Декодировать заголовок письма."""
if header_value is None: if header_value is None:
return "" return ""
@@ -87,212 +121,60 @@ class EmailClient:
return ''.join(decoded_parts) return ''.join(decoded_parts)
def _extract_body(self, msg: email.message.Message) -> str: def send_email(self, message: Union[MIMEMultipart, MIMEText, Message]):
"""Извлечь текст письма из любого типа содержимого, кроме вложений""" """
body = "" Отправить подготовленное письмо через SMTP.
if msg.is_multipart():
for part in msg.walk(): Args:
content_disposition = str(part.get("Content-Disposition", "")) message: Подготовленное письмо (MIMEMultipart, MIMEText или email.message.Message)
# Пропускаем вложения Должно содержать заголовки From, To и Subject
if "attachment" in content_disposition.lower(): """
continue
try: try:
charset = part.get_content_charset() or 'utf-8' # Извлекаем получателей из письма
payload = part.get_payload(decode=True) recipients = []
if payload:
body_piece = payload.decode(charset, errors='ignore')
body += body_piece
except Exception:
pass
else:
try:
charset = msg.get_content_charset() or 'utf-8'
payload = msg.get_payload(decode=True)
if payload:
body = payload.decode(charset, errors='ignore')
except Exception:
pass
return body # Основной получатель
to_header = message.get("To", "")
if to_header:
# Обрабатываем несколько адресов, разделенных запятыми
to_addresses = [addr.strip() for addr in to_header.split(",")]
recipients.extend(to_addresses)
def __extract_email(self, text: str) -> str: # Копия
match = re.search(r'<([^<>]+)>', text) cc_header = message.get("Cc", "")
if match: if cc_header:
return match.group(1) cc_addresses = [addr.strip() for addr in cc_header.split(",")]
return None recipients.extend(cc_addresses)
def _extract_first_sender(self, body: str): # Скрытая копия
"""Извлекает адреса отправителей из пересылаемого сообщения. Нужно для отладки""" bcc_header = message.get("Bcc", "")
# Ищем email внутри скобок после строки "Пересылаемое сообщение" if bcc_header:
pattern = r"Пересылаемое сообщение.*?\((.*?)\)" bcc_addresses = [addr.strip() for addr in bcc_header.split(",")]
match = re.search(pattern, body, re.DOTALL) recipients.extend(bcc_addresses)
if match:
return match.group(1)
return None
def _extract_attachments(self, msg: email.message.Message) -> List[EmailAttachment]: if not recipients:
"""Извлечь вложения из письма.""" raise ValueError("Не указаны получатели письма (To, Cc или Bcc)")
attachments = []
for part in msg.walk(): # Извлекаем отправителя из письма или используем email из настроек
content_disposition = str(part.get("Content-Disposition", "")) from_email = message.get("From", self.email)
if "attachment" in content_disposition: # Подключаемся к SMTP серверу
filename = part.get_filename()
if filename:
# Декодируем имя файла
filename = self._decode_header(filename)
# Получаем содержимое
content = part.get_payload(decode=True)
if content:
attachments.append(EmailAttachment(filename=filename, content=content))
return attachments
def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True, mark_as_read: bool = True) -> List[
EmailMessage]:
"""Получить список новых электронных писем."""
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)
if status != "OK":
return []
email_ids = messages[0].split()
return email_ids
def get_emails(self, folder: str = "INBOX", only_unseen: bool = True, mark_as_read: bool = True) -> List[EmailMessage]:
"""Получить список новых электронных писем."""
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)
if status != "OK":
return []
email_ids = messages[0].split()
emails = []
for email_id in email_ids:
try:
# Получаем письмо
status, msg_data = self.imap_conn.fetch(email_id, "(RFC822)")
if status != "OK":
continue
# Парсим письмо
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
# Извлекаем данные
from_addr = self._decode_header(msg.get("From", ""))
subject = self._decode_header(msg.get("Subject", ""))
from_email = self.__extract_email(from_addr)
# Получаем дату
date_str = msg.get("Date", "")
try:
date_tuple = email.utils.parsedate_tz(date_str)
if date_tuple:
timestamp = email.utils.mktime_tz(date_tuple)
dt = datetime.fromtimestamp(timestamp)
else:
dt = datetime.now()
except:
dt = datetime.now()
# Извлекаем тело письма
body = self._extract_body(msg)
#print(body)
first_sender = self._extract_first_sender(body)
# Извлекаем вложения
attachments = self._extract_attachments(msg)
# Создаем объект письма
email_obj = EmailMessage(
from_addr=from_addr,
from_email=from_email,
subj=subject,
dt=dt,
body=body,
attachments=attachments,
first_sender=first_sender
)
emails.append(email_obj)
# Помечаем письмо как прочитанное
if mark_as_read:
self.imap_conn.store(email_id, '+FLAGS', '\\Seen')
except Exception as e:
print(f"Ошибка при обработке письма {email_id}: {e}")
continue
return emails
def send_email(self, message: EmailMessage, to_addr: str, cc: Optional[List[str]] = None, bcc: Optional[List[str]] = None):
"""Отправить электронное письмо"""
# Создаем multipart сообщение
msg = MIMEMultipart()
msg['From'] = self.email
msg['To'] = to_addr
msg['Subject'] = message.subj
if cc:
msg['Cc'] = ', '.join(cc)
# Добавляем тело письма
msg.attach(MIMEText(message.body, 'plain', 'utf-8'))
# Добавляем вложения
for attachment in message.attachments:
part = MIMEBase('application', 'octet-stream')
part.set_payload(attachment.content)
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
f'attachment; filename= {attachment.filename}'
)
msg.attach(part)
# Формируем список всех получателей
recipients = [to_addr]
if cc:
recipients.extend(cc)
if bcc:
recipients.extend(bcc)
# Отправляем письмо
with smtplib.SMTP(self.smtp_host, self.smtp_port) as server: with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
server.starttls() server.starttls()
server.login(self.email, self.password) server.login(self.email, self.password)
server.sendmail(self.email, recipients, msg.as_string())
def __enter__(self): # Отправляем письмо
"""Поддержка контекстного менеджера""" server.sendmail(
self.connect() from_email,
return self recipients,
message.as_string()
)
def __exit__(self, exc_type, exc_val, exc_tb): logger.info(f"Письмо успешно отправлено получателям: {', '.join(recipients)}")
"""Поддержка контекстного менеджера"""
self.disconnect()
except smtplib.SMTPException as e:
logger.error(f"Ошибка SMTP при отправке письма: {str(e)}")
raise Exception(f"Ошибка SMTP: {str(e)}")
except Exception as e:
logger.error(f"Ошибка при отправке письма: {str(e)}")
raise Exception(f"Ошибка при отправке письма: {str(e)}")

View File

@@ -0,0 +1,98 @@
import re
from typing import List
import email
from email.header import make_header, decode_header
import logging
logger = logging.getLogger(__name__)
from .objects import EmailMessage, EmailAttachment
class EmailUtils:
@staticmethod
def extract_header(msg, header_name) -> str:
"""Декодировать заголовок письма."""
header = msg.get(header_name, "")
if header is None:
return ""
decoded = decode_header(header)
return str(make_header(decoded))
@staticmethod
def extract_email(text) -> str:
match = re.search(r'<([^<>]+)>', text)
email = match.group(1) if match else None
logger.debug(f"Extracted email: {email}")
return email
@staticmethod
def extract_body(msg: email.message.Message) -> str:
"""Извлечь текст письма из любого типа содержимого, кроме вложений"""
body = ""
if msg.is_multipart():
for part in msg.walk():
content_disposition = str(part.get("Content-Disposition", ""))
# Пропускаем вложения
if "attachment" in content_disposition.lower():
continue
try:
charset = part.get_content_charset() or 'utf-8'
payload = part.get_payload(decode=True)
if payload:
body_piece = payload.decode(charset, errors='ignore')
body += body_piece
except Exception:
pass
else:
try:
charset = msg.get_content_charset() or 'utf-8'
payload = msg.get_payload(decode=True)
if payload:
body = payload.decode(charset, errors='ignore')
except Exception:
pass
logger.debug(f"Extracted body: {body}")
return body
@staticmethod
def extract_first_sender(body: str):
"""Извлекает адреса отправителей из пересылаемого сообщения. Нужно для отладки"""
# Ищем email внутри скобок после строки "Пересылаемое сообщение"
pattern = r"Пересылаемое сообщение.*?\((.*?)\)"
match = re.search(pattern, body, re.DOTALL)
if match:
return match.group(1)
return None
@staticmethod
def extract_attachments(msg: email.message.Message) -> List[EmailAttachment]:
"""Извлечь вложения из письма."""
attachments = []
for part in msg.walk():
content_disposition = str(part.get("Content-Disposition", ""))
if "attachment" in content_disposition:
filename = part.get_filename()
if filename:
# Декодируем имя файла
filename = decode_header(filename)[0][0]
# Получаем содержимое
content = part.get_payload(decode=True)
if content:
#attachments.append(EmailAttachment(filename=filename, content=content))
attachments.append({"name": filename, "bytes": content})
logger.debug(f"Extracted attachment {filename}")
logger.debug(f"Extracted attachments: {len(attachments)}")
return attachments
@staticmethod
def extract_domain(email_message: str) -> str | None:
"""Вернуть домен из email либо None, если формат странный."""
if "@" not in email_message:
return None
# убираем пробелы по краям и берём часть после '@'
return email_message.strip().split("@", 1)[1]

View File

@@ -1 +0,0 @@
from .email_processor import EmailProcessor

View File

@@ -1,31 +0,0 @@
import threading
from typing import Any, Dict
class _SingletonMeta(type):
_instances = {}
_lock = threading.Lock()
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
with cls._lock:
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class Context(metaclass=_SingletonMeta):
def __init__(self):
# будет вызван только при первом создании
self.context = {}
def clear_context(self):
"""Очищает self.context, устанавливая его в None или пустой словарь"""
with self._lock: # потокобезопасная очистка
self.context = {}
print("Context очищен") # опциональный лог
def set_context(self, new_context: Dict[str, Any]):
"""Устанавливает новый контекст (бонусный метод)"""
with self._lock:
self.context = new_context
print("Новый контекст установлен")

View File

@@ -1,47 +0,0 @@
import os
import yaml
import logging
from typing import Dict, Any
from pathlib import Path
logger = logging.getLogger(__name__)
from .order.auto_part_order import AutoPartOrder
from .context import Context
from enum import Enum
from .handlers import *
class RequestStatus(Enum):
NEW = "new"
IN_PROGRESS = "in progress"
FAILED = "failed"
EXECUTED = "executed"
OPERATOR_REQUIRED = "operator required"
INVALID = "invalid"
class EmailProcessor(Context):
def __init__(self, configs_path: Path):
super().__init__()
self.configs_path = configs_path
self.status = RequestStatus.NEW
def process(self, email_id):
config = self._load_config(client)
for stage in config["pipeline"]:
handler_name = stage["handler"]
logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name](stage.get("config", None), self.context)
task.do()
def _load_config(self, client) -> Dict[str, Any]:
"""Загружает конфигурацию из YAML или JSON"""
path = os.path.join(self.configs_path, client + '.yml')
with open(path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def _load_email(self):

View File

@@ -1,9 +0,0 @@
from .abcp_clients.check_stock import GetStock
from .abcp_clients.create_order import InstantOrderTest
from .excel_parcers.order_parcer_basic import BasicExcelParser
from .notifications.test_notifier import TestNotifier
from .validators.price_quantity_ckecker import CheckOrder

View File

@@ -1,27 +0,0 @@
import random
import logging
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
logger = logging.getLogger(__name__)
def get_stock(brand, part_number):
return random.randint(0, 10)
class GetStock(AbstractTask):
def do(self) -> None:
positions = self.order.positions
for position in positions:
self._update_stock(position)
def _update_stock(self, position):
# Эмулируем получение данных
max_stock = self.config.get('max_stock',10)
stock = random.randint(0, max_stock)
price = position.requested_price
position.stock_price = price
position.stock_quantity = stock

View File

@@ -1,37 +0,0 @@
import logging
import requests
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
from mail_order_bot.email_handler.order.auto_part_order import OrderStatus
logger = logging.getLogger(__name__)
class InstantOrderTest(AbstractTask):
URL = "https://api.telegram.org/bot{0}/sendMessage?chat_id={1}&text={2}"
def do(self) -> None:
api_key = self.config["api_key"]
chat_id = self.config["chat_id"]
if self.order.status == OrderStatus.IN_PROGRESS:
positions = self.order.positions
message = f"Запрос на создание заказа от {self.context['client']}:\n"
message += "\n".join(f"{pos.sku}: {pos.name} ({pos.order_quantity} x {pos.order_price} = {pos.total})" for pos in positions)
elif self.order.status == OrderStatus.OPERATOR_REQUIRED:
message = f"Запрос на создание заказа от {self.context['client']} отклонен - необходима ручная обработка.\n"
message += f"Причина: {self.order.reason}"
else:
message = f"Запрос на создание заказа от {self.context['client']} отклонен.\n"
message += f" Статус заказа: {self.order.status}"
#url = self.URL.format(api_key, chat_id, message)
#resp = requests.get(url).json()
print(message)
#logger.info(resp)

View File

@@ -1,24 +0,0 @@
from abc import ABC, abstractmethod
from typing import Dict, Any
from mail_order_bot.email_handler.context import Context
class AbstractTask(ABC, Context):
RESULT_SECTION = "section"
"""
Абстрактный базовый класс для всех хэндлеров.
"""
def __init__(self, config: Dict[str, Any]) -> None:
Context.__init__(self, {})
self.config = config
@abstractmethod
def do(self) -> None:
"""
Выполняет работу над заданием
Входные и выходные данные - в self.context
Конфиг задается при инициализации
"""
raise NotImplementedError

View File

@@ -1,141 +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
class AbstractTask(ABC):
"""Базовый класс для задач"""
def __init__(self, context, config):
self.context = context
self.config = config
@abstractmethod
def do(self):
"""Метод для реализации в подклассах"""
pass
class EmailReplyTask(AbstractTask):
"""Класс для ответа на электронные письма"""
def do(self):
"""
Отправляет ответ на входящее письмо
Ожидает в self.context:
- message: email.message.Message объект входящего письма
- attachment: путь к файлу для вложения
Ожидает в self.config:
- reply_to: адрес электронной почты для копии
- smtp_host: хост SMTP сервера
- smtp_port: порт SMTP сервера
- smtp_user: пользователь SMTP
- smtp_password: пароль SMTP
- from_email: адрес отправителя
"""
incoming_message = self.context.get("message")
attachment_path = self.context.get("attacnment")
if not incoming_message:
raise ValueError("Рcontext не найдено письмо (message)")
# Получаем адрес отправителя входящего письма
from_addr = incoming_message.get("From")
if not from_addr:
raise ValueError("Входящее письмо не содержит адреса отправителя")
# Создаем ответное письмо
reply_message = MIMEMultipart()
# Заголовки ответного письма
reply_message["From"] = self.config.get("from_email", "noreply@example.com")
reply_message["To"] = from_addr
reply_message["Cc"] = self.config.get("reply_to", "")
reply_message["Subject"] = f"Re: {incoming_message.get('Subject', '')}"
reply_message["Date"] = formatdate(localtime=True)
# Тело письма
body = "Ваш заказ создан"
reply_message.attach(MIMEText(body, "plain", "utf-8"))
# Добавляем вложение если указан путь к файлу
if attachment_path and os.path.isfile(attachment_path):
self._attach_file(reply_message, attachment_path)
# Отправляем письмо
self._send_email(reply_message, from_addr)
def _attach_file(self, message, file_path):
"""
Добавляет файл в качестве вложения к письму
Args:
message: MIMEMultipart объект
file_path: путь к файлу для вложения
"""
try:
with open(file_path, "rb") as attachment:
part = MIMEBase("application", "octet-stream")
part.set_payload(attachment.read())
encoders.encode_base64(part)
file_name = os.path.basename(file_path)
part.add_header(
"Content-Disposition",
f"attachment; filename= {file_name}"
)
message.attach(part)
except FileNotFoundError:
raise FileNotFoundError(f"Файл не найден: {file_path}")
except Exception as e:
raise Exception(f"Ошибка при добавлении вложения: {str(e)}")
def _send_email(self, message, recipient):
"""
Отправляет письмо через SMTP
Args:
message: MIMEMultipart объект письма
recipient: адрес получателя
"""
try:
smtp_host = self.config.get("smtp_host")
smtp_port = self.config.get("smtp_port", 587)
smtp_user = self.config.get("smtp_user")
smtp_password = self.config.get("smtp_password")
if not all([smtp_host, smtp_user, smtp_password]):
raise ValueError("Не указаны параметры SMTP в config")
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_password)
# Получаем адреса получателей (основной + копия)
recipients = [recipient]
reply_to = self.config.get("reply_to")
if reply_to:
recipients.append(reply_to)
server.sendmail(
self.config.get("from_email"),
recipients,
message.as_string()
)
except smtplib.SMTPException as e:
raise Exception(f"Ошибка SMTP: {str(e)}")
except Exception as e:
raise Exception(f"Ошибка при отправке письма: {str(e)}")

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.email_handler.handlers.order_position import OrderPosition
from mail_order_bot.email_handler.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,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.email_handler.handlers.order_position import OrderPosition
from mail_order_bot.email_handler.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 +0,0 @@
import logging
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
logger = logging.getLogger(__name__)
class TestNotifier(AbstractTask):
def do(self) -> None:
positions = self.context["positions"]
print(f"\nПолучено {len(positions)} позиций от {self.context["client"]}:")
for pos in positions: # Первые 5
print(f" - {pos.sku}: {pos.name} "
f"({pos.requested_quantity} x {pos.requested_price} = {pos.total})")

View File

@@ -1,50 +0,0 @@
import random
import logging
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
from mail_order_bot.email_handler.order.auto_part_order import OrderStatus
from decimal import Decimal
import random
logger = logging.getLogger(__name__)
class CheckOrder(AbstractTask):
def do(self) -> None:
refused = 0
positions = self.order.positions
for position in positions:
self._set_order_price(position)
self._set_order_quantity(position)
if position.order_price == 0 or position.order_quantity == 0:
refused += 1
self._check_refusal_threshold(refused)
def _set_order_price(self, position):
# Эмулируем получение данных
acceptable_price_reduction = self.config.get("acceptable_price_reduction")
acceptable_price = position.stock_price* Decimal(str((1-acceptable_price_reduction/100)))
if position.requested_price < acceptable_price:
position.order_price = 0
else:
position.order_price = position.requested_price
def _set_order_quantity(self, position):
max_stock = self.config.get("max_stock", 100)
min_stock = self.config.get("min_stock", 0)
stock_quantity = random.randint(min_stock, max_stock)
position.order_quantity = max(0, min(position.stock_quantity, stock_quantity))
def _check_refusal_threshold(self, refused):
refusal_threshold_limit = self.config.get("refusal_threshold", 1)
refusal_level = refused/len(self.order.positions)
if refusal_level > refusal_threshold_limit:
self.order.status = OrderStatus.OPERATOR_REQUIRED
self.order.reason = "Превышен порог отказов"

View File

@@ -1,34 +0,0 @@
from typing import List, Optional
from .auto_part_position import AutoPartPosition
from enum import Enum
class OrderStatus(Enum):
NEW = "new"
IN_PROGRESS = "in progress"
FAILED = "failed"
COMPLETED = "completed"
OPERATOR_REQUIRED = "operator required"
INVALID = "invalid"
class AutoPartOrder:
def __init__(self):
self.positions: List[AutoPartPosition] = []
self.status = OrderStatus.NEW
self.reason = ""
def add_position(self, position: AutoPartPosition) -> None:
self.positions.append(position)
if self.status == OrderStatus.NEW:
self.status = OrderStatus.IN_PROGRESS
def find_positions(self, brand: Optional[str] = None, sku: Optional[str] = None) -> List[AutoPartPosition]:
results = self.positions
if brand is not None:
results = [p for p in results if p.manufacturer == brand]
if sku is not None:
results = [p for p in results if p.sku == sku]
return results
def __len__(self):
return len(self.positions)

View File

@@ -1,77 +0,0 @@
from typing import List, Optional
from dataclasses import dataclass, field
from typing import Dict, Any
from decimal import Decimal
@dataclass
class AutoPartPosition:
"""
Унифицированная модель позиции для заказа.
Все контрагенты приводятся к этой структуре.
"""
sku: str # Артикул товара
manufacturer: str # Производитель
requested_quantity: int # Количество
total: Decimal = 0 # Общая сумма
name: str = "" # Наименование
requested_price: Decimal = 0 # Цена за единицу
order_quantity: int = 0 # Количество для заказа
order_price: Decimal = Decimal('0.0') # Цена в заказе
stock: List[Dict[str, Any]] = None
additional_attrs: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Валидация после инициализации"""
if self.requested_quantity < 0:
raise ValueError(f"Количество не может быть отрицательным: {self.requested_quantity}")
if self.requested_price < 0:
raise ValueError(f"Цена не может быть отрицательной: {self.requested_price}")
class AutoPartPosition2:
brand: str
sku: str
name: str
customer_price: float
customer_quantity: int
supplier_price: float
stock_remaining: int
def __init__(self, brand: str, sku: str, name: str,
customer_price: float, customer_quantity: int,
supplier_price: float, stock_remaining: int):
self.brand = brand
self.sku = sku
self.name = name
self.customer_price = customer_price
self.customer_quantity = customer_quantity
self.supplier_price = supplier_price
self.stock_remaining = stock_remaining
def customer_cost(self) -> float:
return self.customer_price * self.customer_quantity
def supplier_cost(self) -> float:
return self.supplier_price * self.customer_quantity
def is_available(self) -> bool:
return self.stock_remaining >= self.customer_quantity
def restock(self, amount: int) -> None:
if amount < 0:
raise ValueError("Restock amount must be non-negative")
self.stock_remaining += amount
def __post_init__(self):
if self.customer_price < 0:
raise ValueError("Customer price cannot be negative")
if self.customer_quantity < 0:
raise ValueError("Customer quantity cannot be negative")
if self.supplier_price < 0:
raise ValueError("Supplier price cannot be negative")
if self.stock_remaining < 0:
raise ValueError("Stock remaining cannot be negative")

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

@@ -1,4 +1,4 @@
import threading
from config_manager import ConfigManager from config_manager import ConfigManager
from dotenv import load_dotenv from dotenv import load_dotenv
import asyncio import asyncio
@@ -7,44 +7,76 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
from email_client import EmailClient from email_client import EmailClient
from excel_proceccor import ExcelProcessor from mail_order_bot.task_processor.abstract_task import AbstractTask
from task_processor import TaskProcessor
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'),
smtp_host=os.getenv('SMTP_HOST'), smtp_host=os.getenv('SMTP_HOST'),
email=os.getenv('EMAIL_USER'), email=os.getenv('EMAIL_USER'),
password=os.getenv('EMAIL_PASSWORD'), password=os.getenv('EMAIL_PASSWORD'),
imap_port=os.getenv('IMAP_PORT'), imap_port=int(os.getenv('IMAP_PORT', default="993")),
smtp_port=os.getenv('SMTP_PORT') smtp_port=int(os.getenv('SMTP_PORT', default="587")),
) )
# Сохранить почтовый клиент в контекст
self.context.email_client = self.email_client
self.email_processor = None
logger.warning("MailOrderBot инициализирован")
def execute(self): def execute(self):
logger.debug(f"Check emails for new orders") #Получить список айдишников письма
emails = self.email_client.get_emails(folder="spareparts", only_unseen=True, mark_as_read=True) email_folder = self.config.get("folder")
try:
unread_email_ids = self.email_client.get_emails_id(folder=email_folder)
logger.warning(f"Новых писем - {len(unread_email_ids)}")
# Обработать каждое письмо по идентификатору
for email_id in unread_email_ids:
try:
logger.info(f"Обработка письма с идентификатором {email_id}")
# Получить письмо по идентификатору и запустить его обработку
email = self.email_client.get_email(email_id, mark_as_read=False)
# Подтягиваем обновленный конфиг
self.email_processor = TaskProcessor(self.config.get("clients"))
self.email_processor.process_email(email)
except MailOrderBotException as e:
logger.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("Обработка писем завершена")
for email in emails:
logger.info(email.subj)
logger.info(email.from_addr)
logger.info(email.dt)
logger.info(email.body)
logger.info(email.first_sender)
logger.info('--------------------------------')
logger.critical("mail checked")
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()
@@ -52,9 +84,13 @@ async def main():
#await app.stop() #await app.stop()
if __name__ == "__main__": if __name__ == "__main__":
print(os.getcwd())
if os.environ.get("APP_ENV") != "PRODUCTION": if os.environ.get("APP_ENV") != "PRODUCTION":
logger.warning("Non production environment") logger.warning("Non production environment")
load_dotenv() load_dotenv()
asyncio.run(main()) asyncio.run(main())

View File

@@ -0,0 +1,3 @@
"""Классы для работы с сущностью заказа и позиции"""
from .auto_part_order import AutoPartOrder, OrderStatus
from .auto_part_position import AutoPartPosition, PositionStatus

View File

@@ -0,0 +1,72 @@
from typing import List, Optional
from decimal import Decimal
from openpyxl.pivot.fields import Boolean
from .auto_part_position import AutoPartPosition, PositionStatus
from enum import Enum
class OrderStatus(Enum):
NEW = "new"
IN_PROGRESS = "in progress"
FAILED = "failed"
COMPLETED = "completed"
OPERATOR_REQUIRED = "operator required"
INVALID = "invalid"
class AutoPartOrder:
def __init__(self, client_id):
self.client_id = client_id
self.positions: List[AutoPartPosition] = []
self.status = OrderStatus.NEW
self.delivery_period = 0
self.reason = ""
self.errors = []
def add_position(self, position: AutoPartPosition) -> None:
self.positions.append(position)
def find_positions(self, brand: Optional[str] = None, sku: Optional[str] = None) -> List[AutoPartPosition]:
results = self.positions
if brand is not None:
results = [p for p in results if p.manufacturer == brand]
if sku is not None:
results = [p for p in results if p.sku == sku]
return results
def set_delivery_period(self, delivery_period: int) -> None:
self.delivery_period = delivery_period
def get_refusal_level(self) -> float:
""" Проверяет заказ на возможность исполнения"""
# 1. Проверка общего количества отказов
refusal_positions_count = len([position for position in self.positions if position.status != PositionStatus.READY])
order_refusal_rate = float(refusal_positions_count) / len(self.positions)
return order_refusal_rate
def __len__(self):
return len(self.positions)
def get_order_description(self):
message = f"Срок доставки: {self.delivery_period} (в часах)\n\n"
message += f"Позиции в заказе:\n"
for position in self.positions:
message += f"{position.manufacturer}, {position.name} [{position.sku}] \n"
message += f"{position.asking_quantity} шт. x {position.asking_price:.2f} р. = {position.total:.2f} "
rejected = position.asking_quantity - position.order_quantity
if position.order_quantity == 0:
message += f" - отказ\n"
elif rejected:
message += f" - отгружено частично {position.order_quantity}\n" #, (запрошено, {position.asking_quantity}, отказ: {rejected})\n"
message += f"Профит {position.profit:.1%}\n"
else:
message += f" - отгружено\n"
message += f"Профит {position.profit:.1%} (закупка: {Decimal(position.order_item.get("price")):.2f})\n"
message += "\n"
return message

View File

@@ -0,0 +1,74 @@
from typing import List, Optional
from dataclasses import dataclass, field
from typing import Dict, Any
from decimal import Decimal
from enum import StrEnum
class PositionStatus(StrEnum):
NEW = "new" # Новая позиция
STOCK_RECIEVED = "stock_received" # Получен остаток
STOCK_FAILED = "stock_failed" # Остаток не получен
NO_AVAILABLE_STOCK = "no_available_stock" # Нет доступных складов
READY = "ready"
ORDERED = "ordered" # Заказано
REFUSED = "refused" # Отказано
@dataclass
class AutoPartPosition:
"""
Унифицированная модель позиции для заказа.
Все контрагенты приводятся к этой структуре.
"""
sku: str # Артикул товара
manufacturer: str # Производитель
asking_price: Decimal # Цена за единицу
asking_quantity: int # Количество
total: Decimal = 0 # Общая сумма
name: str = "" # Наименование
order_item: Dict[str, Any] = field(default_factory=dict)
order_price: Decimal = Decimal('0.0') # Цена в заказе
order_quantity: int = 0 # Количество для заказа
order_delivery_period: int = 0
profit: Decimal = Decimal('0.0')
additional_attrs: Dict[str, Any] = field(default_factory=dict)
status: PositionStatus = PositionStatus.NEW
desc: str = ""
def __post_init__(self):
"""Валидация после инициализации"""
if self.asking_quantity < 0:
raise ValueError(f"Количество не может быть отрицательным: {self.asking_quantity}")
if self.asking_price < 0:
raise ValueError(f"Цена не может быть отрицательной: {self.asking_price}")
def set_order_item(self, order_item):
# Запоминаем всю позицию
self.order_item = order_item
# ---===Устанавливаем конкретные значения по параметрам заказа===---
# Берем максимально доступное значение со склада, но не больше чем в заказе.
self.order_quantity = min(self.order_item.get("availability"), self.asking_quantity)
# Продаем по цене, которая была заказана
self.order_price = self.asking_price
# Устанавливаем актуальный срок доставки
self.order_delivery_period = self.order_item.get("deliveryPeriod")
# Фиксируем профит. Для инфо/отчетности
buy_price = Decimal(self.order_item.get("price"))
self.profit = (self.asking_price - buy_price)/buy_price * self.order_quantity
# Устанавливаем статус
self.status = PositionStatus.READY

View File

@@ -0,0 +1,4 @@
"""Данный пакет содержит модули и классы дял парсинга объектов"""
from .excel_parcer import ExcelFileParcer
from .order_parcer import OrderParser

View File

@@ -0,0 +1,102 @@
import logging
import pandas as pd
from io import BytesIO
logger = logging.getLogger(__name__)
class ExcelFileParcer:
def __init__(self, file_bytes, config):
self.config = config
self.bytes = file_bytes
self.sheet_name = self.config.get("sheet_name", 0)
self.df = self._parse_file(file_bytes)
def _parse_file(self, file_bytes):
"""Парсит вложение в формате эл таблиц"""
df = pd.read_excel(file_bytes, sheet_name=self.sheet_name, header=None)
return df
def set_value(self, sku, manufacturer, column, value):
"""Устанавливает значение в строке позиции в заданной колонке"""
# Находим строку (ось Y)
attr_row = self._get_attr_row(sku, manufacturer)
# Находим колонку (ось X)
attr_col = self._get_attr_column(column)
self.df.iloc[attr_row, attr_col] = value
logger.debug(
f"Установлено значение {value} в колонке {column} для строки {attr_row} ( {sku} | {manufacturer} )")
def get_file_bytes(self):
"Этот метод будет возвращать эксель из датафрейма в виде байтов"
buf = BytesIO()
with pd.ExcelWriter(buf, engine="xlsxwriter") as writer:
self.df.to_excel(writer, sheet_name="Sheet1", index=False, header=False)
buf.seek(0)
return buf
def get_order_rows(self):
"Будет такой метод или какой-то другой который формирует файл с заказом"
# Получаем все данные из файла
# Находим индекс строки с заголовком
key_field = self.config.get("key_field")
header_row_idx = self.df[
self.df.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
# Считываем таблицу с правильным заголовком
df = pd.read_excel(self.bytes, header=header_row_idx, sheet_name=self.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
def _get_header_row(self):
"""Метод возвращает строку заголовка по наличию в ней ключевого слова. Поиск заголовка нужен при определении колонок с данными."""
key_column = self.config.get("key_field")
header_row_idx = int(
self.df.apply(lambda row: row.astype(str).str.contains(key_column, na=False).any(), axis=1).idxmax())
# todo надо выкинуть ошибку если в файле не найдено ключевое поле
# todo надо выкинуть ошибку если найдено несколько строк с CONFIG_KEY_COLUMN
logger.debug(f"Индекс строки заголовка - {header_row_idx}")
return header_row_idx
def _get_attr_column(self, col_name):
"""Поиск по оси Х - метод возвращает индекс колонки по названию атрибута"""
header_row_idx = self._get_header_row()
header_row = self.df.iloc[header_row_idx]
col_id = header_row[header_row == col_name].index[0]
# todo добавить перехват ошибок и выброс понятного и сключения при отсутствии колонки
logger.debug(f"Индекс колонки {col_name} - {col_id}")
return int(col_id)
def _get_attr_row(self, sku, manufacturer):
"""Поиск по оси Y - метод возвращает индекс строки по бренду и артикулу"""
sku_col_name = self.config["mapping"]["article"]
sku_col_idx = self._get_attr_column(sku_col_name)
man_col_name = self.config["mapping"]["manufacturer"]
man_col_idx = self._get_attr_column(man_col_name)
matching_rows = self.df[
(self.df.iloc[:, sku_col_idx] == sku) & (self.df.iloc[:, man_col_idx] == manufacturer)].index
# todo сделать проверку на наличие дублей
logger.info(f"Индекс строки позиции {sku}/{manufacturer} - {matching_rows}")
return matching_rows.values[0]

View File

@@ -0,0 +1,86 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional
from decimal import Decimal
from io import BytesIO
from mail_order_bot.order import AutoPartPosition
from mail_order_bot.order import AutoPartOrder
logger = logging.getLogger(__name__)
class OrderParser:
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def __init__(self, mapping, delivery_period, client_id):
self.mapping = mapping
self.delivery_period = delivery_period
self.client_id = client_id
def parse(self, df):
order = AutoPartOrder(self.client_id)
# Парсим строки
positions = []
for idx, row in df.iterrows():
position = self._parse_row(row, self.mapping)
if position:
order.add_position(position)
logger.info(f"Успешно обработано {len(order)} позиций из {len(df)} строк")
# except Exception as e:
# logger.error(f"Ошибка при обработке файла: {e}")
# else:
return 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,
asking_price=price,
asking_quantity=quantity,
total=total,
order_delivery_period=self.delivery_period,
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

View File

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

View File

@@ -0,0 +1,78 @@
from abc import ABC, abstractmethod
import logging
import functools
from .attachment_status import AttachmentStatus
from mail_order_bot.context import Context
logger = logging.getLogger(__name__)
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():
STEP_NAME = "Название шага обработки"
"""
Абстрактный базовый класс для всех хэндлеров.
"""
def __init__(self) -> None:
self.context = Context()
#self.config = config
self.config = self.context.data.get("config", {})
@abstractmethod
def do(self, attachment) -> None:
"""
Выполняет работу над заданием
Входные и выходные данные - в self.context
Конфиг задается при инициализации
"""
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

@@ -0,0 +1,18 @@
#from .abcp._api_get_stock import APIGetStock
from .delivery_time.local_store import DeliveryPeriodLocalStore
from .delivery_time.from_config import DeliveryPeriodFromConfig
from .notifications.test_notifier import TelegramNotifier
from .excel_parcers.excel_extractor import ExcelExtractor
from .excel_parcers.order_extractor import OrderExtractor
#from .abcp.api_save_order import SaveOrderToTelegram
from .stock_selectors.stock_selector import StockSelector
from .excel_parcers.update_excel_file import UpdateExcelFile
from .email.email_reply_task import EmailReplyTask
from .email.email_forward_error_task import EmailForwardErrorTask

View File

@@ -0,0 +1,24 @@
"""
Устанавливает хардкодом период доставки 0, что означает использование локального склада.
Для заказчиков, которые должны всегда получать заказ только из наличия
"""
import logging
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
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 Exception(f"Ошибка при установке срока доставки из конфига. Детали ошибки: {e}")

View File

@@ -0,0 +1,100 @@
"""
Парсер срока доставки из темы письма
"""
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
import logging
import re
logger = logging.getLogger(__name__)
class DeliveryPeriodFromSubject(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
"""
Извлекает срок доставки из темы письма и сохраняет в каждый элемент attachments.
Правила парсинга:
- Если есть слово "Наличие" - срок доставки = 0 дней
- Если найдены оба варианта (диапазон и точное значение) - используется точное значение
- Если есть только фраза "N-M дней/дня/день" (диапазон) - срок доставки = минимальное значение (N)
- Если есть только фраза "N дней/дня/день" - срок доставки = N дней
- Если ничего не указано - срок доставки = 0 дней
- Срок переводится в часы (умножается на 24)
"""
# Получаем тему письма
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:
"""
Парсит срок доставки из темы письма.
Args:
subject: Тема письма
Returns:
Количество дней доставки (0 по умолчанию)
"""
if not subject:
return 0
subject_lower = subject.lower()
# Проверяем наличие слова "Наличие"
if "наличие" in subject_lower:
return 0
# Ищем оба паттерна одновременно
range_pattern = r'(\d+)-(\d+)\s+(?:дней|дня|день)'
single_pattern = r'(\d+)\s+(?:дней|дня|день)'
range_match = re.search(range_pattern, subject_lower)
single_match = re.search(single_pattern, subject_lower)
# Если найдены оба варианта - используем точное значение (одиночное число)
if range_match and single_match:
days = int(single_match.group(1))
logger.debug(f"Найдены оба варианта (диапазон и точное значение), используется точное: {days} дней")
return days
# Если найден только диапазон - используем минимальное значение
if range_match:
min_days = int(range_match.group(1))
max_days = int(range_match.group(2))
logger.debug(f"Найден диапазон: {min_days}-{max_days} дней, используется минимальное: {min_days} дней")
return min(min_days, max_days)
# Если найдено только одиночное число - используем его
if single_match:
days = int(single_match.group(1))
logger.debug(f"Найдено точное значение: {days} дней")
return days
# Если ничего не найдено, возвращаем 0 (из наличия)
return 0

View File

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

View File

@@ -0,0 +1,100 @@
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.message import MIMEMessage
from email.utils import formatdate
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from ...attachment_status import AttachmentStatus
logger = logging.getLogger(__name__)
class EmailForwardErrorTaskException(Exception):
pass
class EmailForwardErrorTask(AbstractTask):
STEP_NAME = "Уведомление сотрудника об ошибке"
"""Пересылает письмо как вложение на заданный адрес при ошибке обработки"""
EMAIL = "zosimovaa@yandex.ru" # Адрес получателя пересылки
ERROR_RECIPIENT = "lesha.spb@gmail.com" # Адрес для пересылки ошибок
#@handle_errors
def do(self, attachment=None):
"""
Пересылает письмо из контекста как вложение на заданный адрес
Args:
attachment: Не используется, оставлен для совместимости с AbstractTask
"""
if attachment: # and attachment.get("status") == AttachmentStatus.FAILED:
email = self.context.data.get("email")
if not email:
raise ValueError("В контексте нет входящего сообщения")
email_subj = f"[ОШИБКА СОЗДАНИЯ ЗАКАЗА] - {self.context.data.get("email_subj", "Без темы")}]"
# Создаем новое сообщение для пересылки
forward_message = MIMEMultipart()
forward_message["From"] = self.EMAIL
forward_message["To"] = self.ERROR_RECIPIENT
forward_message["Subject"] = email_subj
forward_message["Date"] = formatdate(localtime=True)
# Добавляем текстовый комментарий в тело письма
body = "Ошибка обработки письма\n"
body += f"{attachment.get("error", "Нет данных по ошибке")}\n"
order = attachment.get("order")
if order is not None:
body += order.get_order_description()
else:
body += "Заказ не был распарсен"
forward_message.attach(MIMEText(body, "plain", "utf-8"))
# Прикрепляем исходное письмо как вложение
self._attach_email(forward_message, email)
# Отправляем письмо
self.context.email_client.send_email(forward_message)
logger.warning(f"Письмо переслано как вложение на {self.ERROR_RECIPIENT}")
else:
logger.warning(f"Все окей, никуда ничего пересылать не надо")
def _attach_email(self, forward_message, email_message):
"""
Прикрепляет исходное письмо как вложение к сообщению
Args:
forward_message: MIMEMultipart сообщение, к которому прикрепляем
email_message: email.message.Message - исходное письмо для пересылки
"""
try:
# Создаем MIMEMessage из исходного письма
msg_part = MIMEMessage(email_message)
# Устанавливаем имя файла для вложения
email_subj = self.context.data.get("email_subj", "message")
# Очищаем тему от недопустимых символов для имени файла
safe_subj = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in email_subj[:50])
msg_part.add_header(
"Content-Disposition",
f'attachment; filename="forwarded_email_{safe_subj}.eml"'
)
forward_message.attach(msg_part)
except Exception as e:
raise Exception(f"Ошибка при прикреплении письма: {str(e)}")

View File

@@ -0,0 +1,62 @@
"""
Обрабатывает письмо
"""
import logging
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
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:
# Определить клиента
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
email_from_domain = EmailUtils.extract_domain(email_from)
self.context.data["email_from_domain"] = email_from_domain
email_subj = EmailUtils.extract_header(email, "subject")
self.context.data["email_subj"] = email_subj
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)} ")
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
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
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):
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")
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

@@ -0,0 +1,24 @@
import logging
from io import BytesIO
from mail_order_bot.task_processor.abstract_task import AbstractTask, pass_if_error, handle_errors
from mail_order_bot.task_processor.handlers.excel_parcers.order_extractor import ExcelFileParcer
logger = logging.getLogger(__name__)
class ExcelExtractor(AbstractTask):
STEP_NAME = "Парсинг эксель файла"
"""
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
@pass_if_error
#@handle_errors
def do(self, attachment) -> None:
file_bytes = BytesIO(attachment['bytes'])
excel_file = ExcelFileParcer(file_bytes, self.excel_config)
attachment["excel"] = excel_file
logger.warning(f"Произведен успешный парсинг файла {attachment.get('name', 'неизвестный файл')}")

View File

@@ -0,0 +1,41 @@
import logging
import pandas as pd
from io import BytesIO
from mail_order_bot.parsers.order_parcer import OrderParser
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
logger = logging.getLogger(__name__)
class OrderExtractor(AbstractTask):
STEP_NAME = "Парсинг заказа"
"""
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
@pass_if_error
#@handle_errors
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип
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)
attachment["order"] = order
logger.warning(f"Файл заказа обработан успешно, извлечено {len(order.positions)} позиций")

View File

@@ -0,0 +1,45 @@
import logging
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
logger = logging.getLogger(__name__)
class UpdateExcelFile(AbstractTask):
STEP_NAME = "Обновление файла заказа"
"""
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
@pass_if_error
#@handle_errors
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип
excel_file = attachment.get("excel")
order = attachment.get("order")
config = self.context.data.get("config", {})
excel_config = config.get("excel", {})
updatable_fields = excel_config.get("updatable_fields", {})
for position in order.positions:
sku = position.sku
manufacturer = position.manufacturer
for key, value in updatable_fields.items():
if key == "ordered_quantity":
column = value
value = position.order_quantity
excel_file.set_value(sku, manufacturer, column, value)
if key == "ordered_price":
column = value
value = position.order_price
excel_file.set_value(sku, manufacturer, column, value)
logger.warning(f"Файла {attachment.get('name', 'неизвестный файл')} отредактирован")

View File

@@ -0,0 +1,58 @@
"""
Перебирает аттачменты
Для каждого ордера в аттачменте перебирает позиции
Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/
Возможно логику выбора позиции надо вынести из позиции, но пока так
"""
import logging
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from mail_order_bot.abcp_api.abcp_provider import AbcpProvider
from mail_order_bot.credential_provider import CredentialProvider
from mail_order_bot.telegram.client import TelegramClient
logger = logging.getLogger(__name__)
class TelegramNotifier(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@pass_if_error
# @handle_errors
def do(self, attachment) -> None:
message = self.build_message(attachment)
client = TelegramClient()
result = client.send_message(message)
# Отправка экселя в телеграм
excel = attachment["excel"]
file = excel.get_file_bytes()
client.send_document(
document=file,
filename=attachment.get("name", "document.xlsx")
)
logger.warning("Инфо по заказу отправлено в телеграм")
def build_message(self, attachment):
order = attachment["order"]
file_name = attachment["name"]
# positions = order.positions
sender_email = self.context.data.get("email_from")
email_subject = self.context.data.get("email_subj")
message = "=============================\n"
message += f"Обработка заказа от {sender_email}\n"
message += f"тема письма: {email_subject}\n"
message += f"файл: {file_name}\n"
message += order.get_order_description()
return message
# ===============================

View File

@@ -0,0 +1,135 @@
import logging
import pandas as pd
from io import BytesIO
from dotenv.parser import Position
from mail_order_bot.parsers.order_parcer import OrderParser
from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.order.auto_part_position import AutoPartPosition, PositionStatus
from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
from decimal import Decimal
from ...abstract_task import AbstractTask, pass_if_error, handle_errors
from mail_order_bot.abcp_api.abcp_provider import AbcpProvider
from mail_order_bot.credential_provider import CredentialProvider
from mail_order_bot.order.auto_part_order import OrderStatus
from ...exceptions import TaskException
from typing import Dict, Any
from typing import List, Optional
logger = logging.getLogger(__name__)
class StockSelectorException(TaskException):
pass
class RefusalLevelExceededException(TaskException):
pass
class StockSelector(AbstractTask):
DISTRIBUTOR_ID = 1577730 # ID локального склада
"""
Выбирает подходящие позиции со склада
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
credential_provider = CredentialProvider(context=self.context)
# Создаем провайдер для учетной записи клиента
client_login, client_password = credential_provider.get_system_credentials()
self.client_provider = AbcpProvider(login=client_login, password=client_password)
@pass_if_error
#@handle_errors
def do(self, attachment) -> None:
# todo сделать проверку на наличие файла и его тип
order = attachment.get("order", None)
delivery_period = attachment.get("delivery_period")
for position in order.positions:
#1. Получаем остатки со складов
stock_data = self.client_provider.get_stock(position.sku, position.manufacturer)
#2. Из данных остатков выбираем оптимальное значение по стратегии
if stock_data["success"]:
stock_list = stock_data.get("data", [])
asking_price = position.asking_price
asking_quantity = position.asking_quantity
optimal_stock_positions = self.get_optimal_stock(stock_list, asking_price, asking_quantity, delivery_period)
# 3. Устанавливаем выбранное значение в позицию
if len(optimal_stock_positions):
position.set_order_item(optimal_stock_positions[0])
else:
position.status = PositionStatus.NO_AVAILABLE_STOCK
# Мне не очень нравится управление статусами в этом месте, кажется что лучше это делать внутри AutoPartPosition
else:
position.status = PositionStatus.STOCK_FAILED
refusal_threshold = self.config.get("refusal_threshold", 1)
refusal_level = order.get_refusal_level()
if refusal_level > refusal_threshold:
raise RefusalLevelExceededException(f"Превышен лимит по отказам, необходима ручная обработка. "
f"Уровень отказов: {refusal_level:.2%}, допустимый лимит: {refusal_threshold:.2%}")
logger.warning("Определены оптимальные позиции со складов")
def get_optimal_stock(self, stock_list, asking_price, asking_quantity, delivery_period):
"""Выбирает позицию для заказа"""
# BR-1. Отсекаем склады для заказов из наличия (только локальный склад)
stock_list = self._br1_only_local_stock(stock_list)
# BR-2. Цена не должна превышать цену из заказа
stock_list = self._br2_price_below_asked_price(stock_list, asking_price)
# BR-3. Срок доставки не должен превышать ожидаемый
stock_list = self._br3_delivery_time_shorted_asked_time(stock_list, delivery_period)
# BR-4. Без отрицательных остатков
stock_list = self._br4_only_positive_stock(stock_list)
# BR-5 Выбираем склад с максимальным профитом
stock_list = self._br5_max_profit(stock_list, asking_price, asking_quantity)
# пока не реализовано
# BR-7 Приоритет на склады с полным стоком
# BR-8. Сначала оборачиваем локальный склад, потом удаленные
# BR-9. Даем немного уйти в минус при заказе из наличия
return stock_list
def _br1_only_local_stock(self, stocks):
return [item for item in stocks if item["distributorId"] == self.DISTRIBUTOR_ID]
def _br2_price_below_asked_price(self, distributors: List[Dict[str, Any]], asking_price) -> List[Dict[str, Any]]:
"""Фильтрует склады по цене (убирает дорогие)"""
return [item for item in distributors if Decimal(item["price"]) <= asking_price]
def _br3_delivery_time_shorted_asked_time(self, distributors: List[Dict[str, Any]], delivery_period) -> List[Dict[str, Any]]:
"""Фильтрует склады по сроку доставки"""
# Вопрос - надо ли ориентироваться на deliveryPeriodMax
return [item for item in distributors if item["deliveryPeriod"] <= delivery_period]
def _br4_only_positive_stock(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Фильтрует склады с положительным остатком"""
return [item for item in distributors if Decimal(item["availability"]) > 0]
def _br5_max_profit(self, distributors: List[Dict[str, Any]], asking_price, asking_quantity) -> List[Dict[str, Any]]:
"""Фильтрует склады с положительным остатком"""
for item in distributors:
item["profit"] = (asking_price - Decimal(item["price"])) * min(asking_quantity, item["availability"])
distributors.sort(key=lambda item: Decimal(item["profit"]), reverse=False)
return distributors

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

@@ -0,0 +1,138 @@
"""
Общая логика обработки писем следующая
1. Общая часть
- скачиваем письмо
- складываем в контекст
- обработчик и парсим данные - тело, тема, отправитель
2. Запускаем паплайн
- прогоняем обработчик для каждого вложения
- каждый обработчик для вложения докидывает результат своей работы
- каждый обработчик анализирует общий лог на наличие фатальных ошибок. Если есть - пропускаем шаг.
Последний обработчик направляет лог ошибок на администратора
Ограничения:
- каждое вложение воспринимается как "отдельное письмо", т.е. если клиент в одном письме направит несколько вложений,
то они будут обрабатываться как отдельные письма, и на каждое будет дан ответ (если он требуется).
Исключительные ситуации:
- При невозможности создать заказ - пересылаем письмо на администратора с логом обработки вложения
- Вложения, которые не являются файлами заказа игнорируем.
todo
[ ] Нужен класс, который будет хранить сообщения от обработчиков
- метод для добавления сообщения
- метод для проверки фатальных ошибок
- метод для извлечения лога
"""
import logging
from typing import Dict, Any
from mail_order_bot.context import Context
from mail_order_bot.email_client.utils import EmailUtils
from mail_order_bot.task_processor.handlers import *
from .attachment_status import AttachmentStatus
from .handlers.email.email_parcer import EmailParcer
from .file_validator import is_spreadsheet_file
logger = logging.getLogger(__name__)
class TaskProcessor:
#def __init__(self, configs_path: str):
def __init__(self, config: Dict[str, Any]):
super().__init__()
self.config = config
self.context = Context()
def process_email(self, email):
# Очистить контекст и запушить туда письмо
self.context.clear()
self.context.data["email"] = email
# Парсинг письма
#email_parcer = EmailParcer()
#email_parcer.do()
self.parse_email(email)
# Определить конфиг для пайплайна
email_sender = self.context.data.get("email_from")
config = self._load_config(email_sender)
self.context.data["config"] = config
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"]
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"Ошибка при обработке файла {file_name}: {e}")
attachment["error"] = e
notifier = EmailForwardErrorTask()
notifier.do(attachment)
else:
logger.info(f"Обработка писем для {email_sender} отключена. Значение в конфиге: {config.get("enabled")}")
def _load_config(self, sender_email) -> Dict[str, Any]:
if sender_email in self.config:
return self.config[sender_email]
sender_domain = EmailUtils.extract_domain(sender_email)
if sender_domain in self.config:
return self.config[sender_domain]
# Для всех ненастроенных клиентов возвращаем конфиг с "отключенной" обработкой
return {}
def parse_email(self, email):
# todo при переводе на основной ящик переделать на другую функцию
header_from = EmailUtils.extract_header(email, "From")
email_from = EmailUtils.extract_email(header_from)
# email_from = EmailUtils.extract_first_sender(email_body)
self.context.data["email_from"] = email_from
self.context.data["email_body"] = EmailUtils.extract_body(email)
self.context.data["email_from_domain"] = EmailUtils.extract_domain(email_from)
self.context.data["email_subj"] = EmailUtils.extract_header(email, "subject")
self.context.data["client"] = EmailUtils.extract_domain(email_from)
self.context.data["attachments"] = EmailUtils.extract_attachments(email)
logger.info(f"Извлечено вложений: {len(self.context.data["attachments"])}")

View File

@@ -0,0 +1,4 @@
from mail_order_bot.telegram.client import TelegramClient
__all__ = ['TelegramClient']

View File

@@ -0,0 +1,176 @@
import os
import logging
import requests
from typing import Optional
from io import BytesIO
logger = logging.getLogger(__name__)
class TelegramClient:
"""
Класс для отправки сообщений через Telegram Bot API.
Поддерживает отправку:
- Текстовых сообщений
- Сообщений с вложением (Excel файл в формате BytesIO)
"""
BASE_URL = "https://api.telegram.org/bot"
def __init__(self, bot_token: Optional[str] = None, chat_id: Optional[str] = None):
"""
Инициализация TelegramClient.
Args:
bot_token: Токен бота Telegram. Если не указан, берется из TELEGRAM_BOT_TOKEN
chat_id: ID чата для отправки сообщений. Если не указан, берется из TELEGRAM_CHAT_ID
"""
self.bot_token = bot_token or os.getenv('TELEGRAM_BOT_TOKEN')
self.chat_id = chat_id or os.getenv('TELEGRAM_CHAT_ID')
if not self.bot_token:
raise ValueError("Telegram bot token is required. Set TELEGRAM_BOT_TOKEN environment variable or pass bot_token parameter.")
if not self.chat_id:
raise ValueError("Telegram chat ID is required. Set TELEGRAM_CHAT_ID environment variable or pass chat_id parameter.")
self.api_url = f"{self.BASE_URL}{self.bot_token}"
def send_message(self, text: str, parse_mode: Optional[str] = None) -> dict:
"""
Отправляет текстовое сообщение в Telegram.
Args:
text: Текст сообщения для отправки
parse_mode: Режим парсинга (HTML, Markdown, MarkdownV2). По умолчанию None
Returns:
dict: Результат запроса с полями success (bool) и data/error
"""
url = f"{self.api_url}/sendMessage"
payload = {
"chat_id": self.chat_id,
"text": text
}
if parse_mode:
payload["parse_mode"] = parse_mode
try:
response = requests.post(url, json=payload)
response.raise_for_status()
result = response.json()
if result.get("ok"):
logger.debug(f"Сообщение успешно отправлено в Telegram")
return {
"success": True,
"data": result.get("result")
}
else:
error_description = result.get("description", "Unknown error")
logger.warning(f"Ошибка отправки сообщения в Telegram: {error_description}")
return {
"success": False,
"error": error_description
}
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка при отправке сообщения в Telegram: {e}")
return {
"success": False,
"error": str(e)
}
def send_document(
self,
document: BytesIO,
filename: str = "document.xlsx",
caption: Optional[str] = None,
parse_mode: Optional[str] = None
) -> dict:
"""
Отправляет документ (Excel файл) в Telegram.
Args:
document: BytesIO объект с содержимым файла
filename: Имя файла для отправки (по умолчанию "document.xlsx")
caption: Подпись к документу (опционально)
parse_mode: Режим парсинга для подписи (HTML, Markdown, MarkdownV2). По умолчанию None
Returns:
dict: Результат запроса с полями success (bool) и data/error
"""
url = f"{self.api_url}/sendDocument"
# Убедимся, что указатель файла находится в начале
document.seek(0)
files = {
"document": (filename, document, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
}
data = {
"chat_id": self.chat_id
}
if caption:
data["caption"] = caption
if parse_mode:
data["parse_mode"] = parse_mode
try:
response = requests.post(url, files=files, data=data)
response.raise_for_status()
result = response.json()
if result.get("ok"):
logger.debug(f"Документ успешно отправлен в Telegram")
return {
"success": True,
"data": result.get("result")
}
else:
error_description = result.get("description", "Unknown error")
logger.warning(f"Ошибка отправки документа в Telegram: {error_description}")
return {
"success": False,
"error": error_description
}
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка при отправке документа в Telegram: {e}")
return {
"success": False,
"error": str(e)
}
def send_message_with_document(
self,
text: str,
document: BytesIO,
filename: str = "document.xlsx",
parse_mode: Optional[str] = None
) -> dict:
"""
Отправляет сообщение с документом. Текст используется как подпись к документу.
Args:
text: Текст сообщения (будет использован как подпись к документу)
document: BytesIO объект с содержимым файла
filename: Имя файла для отправки (по умолчанию "document.xlsx")
parse_mode: Режим парсинга для подписи (HTML, Markdown, MarkdownV2). По умолчанию None
Returns:
dict: Результат запроса с полями success (bool) и data/error
"""
return self.send_document(
document=document,
filename=filename,
caption=text,
parse_mode=parse_mode
)

View File

@@ -1,8 +1,8 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
from mail_order_bot.abcp_api.abcp_provider import AbcpProvider from mail_order_bot.abcp_api.abcp_provider import AbcpProvider
from mail_order_bot.email_handler.order.auto_part_order import AutoPartOrder from mail_order_bot.email_processor.order.auto_part_order import AutoPartOrder
from mail_order_bot.email_handler.order.auto_part_position import AutoPartPosition from mail_order_bot.email_processor.order.auto_part_position import AutoPartPosition
if __name__ == "__main__": if __name__ == "__main__":
print(__name__)# подгружаем переменные окружения print(__name__)# подгружаем переменные окружения
load_dotenv() load_dotenv()
@@ -13,10 +13,12 @@ if __name__ == "__main__":
position = AutoPartPosition(sku="560300054", manufacturer="VST", requested_quantity=1) position = AutoPartPosition(sku="560300054", manufacturer="VST", requested_quantity=1)
order.add_position(position) order.add_position(position)
provider = AbcpProvider() login = os.getenv('ABCP_LOGIN_SYSTEM')
password = os.getenv('ABCP_PASSWORD_SYSTEM')
provider = AbcpProvider(login=login, password=password)
provider.get_stock(order) result = provider.get_stock(position.sku, position.manufacturer)
print(order.positions[0].stock) print(order.positions[0].stock)
print(os.getenv('ABCP_LOGIN')) print(os.getenv('ABCP_LOGIN_SYSTEM'))

View File

@@ -1,7 +1,7 @@
import os import os
import chardet # pip install chardet import chardet # pip install chardet
import traceback import traceback
from mail_order_bot.email_handler import EmailProcessor from mail_order_bot.email_processor import EmailProcessor
import datetime import datetime
# установим рабочую директорию # установим рабочую директорию
import os import os

View File

@@ -1,7 +1,7 @@
import os import os
import chardet # pip install chardet import chardet # pip install chardet
import traceback import traceback
from mail_order_bot.excel_processor import ExcelProcessor from mail_order_bot.excel_parcer import ExcelProcessor
# установим рабочую директорию # установим рабочую директорию
import os import os

View File

@@ -0,0 +1,2 @@
# Tests for parsers module

View File

@@ -0,0 +1,320 @@
import pytest
import pandas as pd
from io import BytesIO
from unittest.mock import patch
from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
class TestExcelFileParcer:
"""Тесты для класса ExcelFileParcer"""
@pytest.fixture
def sample_config(self):
"""Базовая конфигурация для тестов"""
return {
"sheet_name": 0,
"key_field": "Артикул",
"mapping": {
"article": "Артикул",
"manufacturer": "Производитель",
"name": "Наименование",
"price": "Цена",
"quantity": "Количество"
}
}
@pytest.fixture
def sample_excel_bytes(self):
"""Создает тестовый Excel файл в виде байтов"""
df = pd.DataFrame({
'Артикул': ['ART001', 'ART002', 'ART003'],
'Производитель': ['MAN001', 'MAN002', 'MAN003'],
'Наименование': ['Товар 1', 'Товар 2', 'Товар 3'],
'Цена': [100.0, 200.0, 300.0],
'Количество': [1, 2, 3]
})
buf = BytesIO()
with pd.ExcelWriter(buf, engine='xlsxwriter') as writer:
df.to_excel(writer, sheet_name='Sheet1', index=False)
buf.seek(0)
return buf.getvalue()
@pytest.fixture
def excel_with_header_row(self):
"""Создает Excel файл с заголовком не в первой строке"""
df = pd.DataFrame([
['Заголовок документа', None, None, None, None],
['Артикул', 'Производитель', 'Наименование', 'Цена', 'Количество'],
['ART001', 'MAN001', 'Товар 1', 100.0, 1],
['ART002', 'MAN002', 'Товар 2', 200.0, 2],
['ART003', 'MAN003', 'Товар 3', 300.0, 3],
[None, None, None, None, None] # Пустая строка для обрезки
])
buf = BytesIO()
with pd.ExcelWriter(buf, engine='xlsxwriter') as writer:
df.to_excel(writer, sheet_name='Sheet1', index=False, header=False)
buf.seek(0)
return buf.getvalue()
def test_init_with_valid_file(self, sample_excel_bytes, sample_config):
"""Тест инициализации с валидным файлом"""
parser = ExcelFileParcer(sample_excel_bytes, sample_config)
assert parser.config == sample_config
assert parser.bytes == sample_excel_bytes
assert parser.sheet_name == 0
assert parser.df is not None
assert isinstance(parser.df, pd.DataFrame)
def test_init_with_custom_sheet_name(self, sample_excel_bytes):
"""Тест инициализации с кастомным именем листа"""
config = {
"sheet_name": "Sheet2",
"key_field": "Артикул",
"mapping": {
"article": "Артикул",
"manufacturer": "Производитель"
}
}
parser = ExcelFileParcer(sample_excel_bytes, config)
assert parser.sheet_name == "Sheet2"
def test_init_with_default_sheet_name(self, sample_excel_bytes):
"""Тест инициализации с дефолтным именем листа"""
config = {
"key_field": "Артикул",
"mapping": {
"article": "Артикул",
"manufacturer": "Производитель"
}
}
parser = ExcelFileParcer(sample_excel_bytes, config)
assert parser.sheet_name == 0
@patch('mail_order_bot.parsers.excel_parcer.pd.read_excel')
def test_init_with_invalid_file(self, mock_read_excel, sample_config):
"""Тест инициализации с невалидным файлом"""
mock_read_excel.side_effect = Exception("Ошибка парсинга")
invalid_bytes = b"invalid excel content"
parser = ExcelFileParcer(invalid_bytes, sample_config)
assert parser.df is None
def test_parse_file_success(self, sample_excel_bytes, sample_config):
"""Тест успешного парсинга файла"""
parser = ExcelFileParcer(sample_excel_bytes, sample_config)
assert parser.df is not None
assert len(parser.df) > 0
@patch('mail_order_bot.parsers.excel_parcer.pd.read_excel')
def test_parse_file_failure(self, mock_read_excel, sample_config):
"""Тест обработки ошибки при парсинге файла"""
mock_read_excel.side_effect = Exception("Ошибка чтения")
invalid_bytes = b"invalid"
parser = ExcelFileParcer(invalid_bytes, sample_config)
assert parser.df is None
def test_get_header_row(self, excel_with_header_row, sample_config):
"""Тест поиска строки заголовка"""
parser = ExcelFileParcer(excel_with_header_row, sample_config)
header_row = parser._get_header_row()
assert header_row == 1 # Заголовок во второй строке (индекс 1)
def test_get_attr_column(self, excel_with_header_row, sample_config):
"""Тест поиска индекса колонки по имени"""
parser = ExcelFileParcer(excel_with_header_row, sample_config)
col_idx = parser._get_attr_column("Артикул")
assert isinstance(col_idx, int)
assert col_idx >= 0
def test_get_attr_column_nonexistent(self, excel_with_header_row, sample_config):
"""Тест поиска несуществующей колонки"""
parser = ExcelFileParcer(excel_with_header_row, sample_config)
with pytest.raises((IndexError, KeyError)):
parser._get_attr_column("Несуществующая колонка")
def test_get_attr_row(self, excel_with_header_row, sample_config):
"""Тест поиска строки по артикулу и производителю"""
parser = ExcelFileParcer(excel_with_header_row, sample_config)
row_idx = parser._get_attr_row("ART001", "MAN001")
assert isinstance(row_idx, (int, pd.core.indexes.numeric.Int64Index))
# Проверяем, что индекс найден
assert row_idx is not None
def test_get_attr_row_nonexistent(self, excel_with_header_row, sample_config):
"""Тест поиска несуществующей строки"""
parser = ExcelFileParcer(excel_with_header_row, sample_config)
with pytest.raises((IndexError, KeyError)):
parser._get_attr_row("NONEXISTENT", "NONEXISTENT")
def test_set_value(self, excel_with_header_row, sample_config):
"""Тест установки значения в ячейку"""
parser = ExcelFileParcer(excel_with_header_row, sample_config)
# Получаем исходное значение
original_value = parser.df.iloc[2, 3] # Строка с ART001, колонка "Цена"
# Устанавливаем новое значение
parser.set_value("ART001", "MAN001", "Цена", 999.0)
# Проверяем, что значение изменилось
new_value = parser.df.iloc[2, 3]
assert new_value == 999.0
assert new_value != original_value
def test_get_file_bytes(self, sample_excel_bytes, sample_config):
"""Тест получения файла в виде байтов"""
parser = ExcelFileParcer(sample_excel_bytes, sample_config)
result_bytes = parser.get_file_bytes()
assert result_bytes is not None
assert hasattr(result_bytes, 'read')
assert hasattr(result_bytes, 'seek')
# Проверяем, что можно прочитать байты
result_bytes.seek(0)
content = result_bytes.read()
assert len(content) > 0
def test_get_file_bytes_creates_valid_excel(self, sample_excel_bytes, sample_config):
"""Тест что get_file_bytes создает валидный Excel файл"""
parser = ExcelFileParcer(sample_excel_bytes, sample_config)
result_bytes = parser.get_file_bytes()
# Пытаемся прочитать созданный файл
result_bytes.seek(0)
df = pd.read_excel(result_bytes, sheet_name=0, header=None)
assert df is not None
assert len(df) > 0
def test_get_order_rows(self, excel_with_header_row, sample_config):
"""Тест получения строк заказа"""
parser = ExcelFileParcer(excel_with_header_row, sample_config)
order_rows = parser.get_order_rows()
assert order_rows is not None
assert isinstance(order_rows, pd.DataFrame)
assert len(order_rows) > 0
# Проверяем, что пустая строка обрезана
assert len(order_rows) == 3 # Только строки с данными
def test_get_order_rows_with_empty_file(self, sample_config):
"""Тест получения строк заказа из пустого файла"""
# Создаем пустой DataFrame
df = pd.DataFrame([['Артикул', 'Производитель'], [None, None]])
buf = BytesIO()
with pd.ExcelWriter(buf, engine='xlsxwriter') as writer:
df.to_excel(writer, sheet_name='Sheet1', index=False, header=False)
buf.seek(0)
empty_bytes = buf.getvalue()
parser = ExcelFileParcer(empty_bytes, sample_config)
# Должен вернуть пустой DataFrame или вызвать ошибку
try:
order_rows = parser.get_order_rows()
assert len(order_rows) == 0
except (IndexError, KeyError):
# Ожидаемое поведение при отсутствии данных
pass
def test_set_value_updates_dataframe(self, excel_with_header_row, sample_config):
"""Тест что set_value обновляет DataFrame"""
parser = ExcelFileParcer(excel_with_header_row, sample_config)
# Находим строку с ART002
row_idx = parser._get_attr_row("ART002", "MAN002")
price_col_idx = parser._get_attr_column("Цена")
original_price = parser.df.iloc[row_idx, price_col_idx]
# Устанавливаем новое значение
parser.set_value("ART002", "MAN002", "Цена", 555.0)
# Проверяем обновление
updated_price = parser.df.iloc[row_idx, price_col_idx]
assert updated_price == 555.0
assert updated_price != original_price
def test_multiple_set_value_operations(self, excel_with_header_row, sample_config):
"""Тест множественных операций set_value"""
parser = ExcelFileParcer(excel_with_header_row, sample_config)
# Устанавливаем несколько значений
parser.set_value("ART001", "MAN001", "Цена", 111.0)
parser.set_value("ART002", "MAN002", "Цена", 222.0)
parser.set_value("ART003", "MAN003", "Цена", 333.0)
# Проверяем все значения
price_col_idx = parser._get_attr_column("Цена")
row1_idx = parser._get_attr_row("ART001", "MAN001")
row2_idx = parser._get_attr_row("ART002", "MAN002")
row3_idx = parser._get_attr_row("ART003", "MAN003")
assert parser.df.iloc[row1_idx, price_col_idx] == 111.0
assert parser.df.iloc[row2_idx, price_col_idx] == 222.0
assert parser.df.iloc[row3_idx, price_col_idx] == 333.0
def test_get_order_rows_trimmed_correctly(self, sample_config):
"""Тест что get_order_rows правильно обрезает пустые строки"""
# Создаем файл с пустой строкой в середине
df = pd.DataFrame([
['Артикул', 'Производитель', 'Наименование'],
['ART001', 'MAN001', 'Товар 1'],
['ART002', 'MAN002', 'Товар 2'],
[None, None, None], # Пустая строка
['ART003', 'MAN003', 'Товар 3'],
[None, None, None] # Еще одна пустая строка
])
buf = BytesIO()
with pd.ExcelWriter(buf, engine='xlsxwriter') as writer:
df.to_excel(writer, sheet_name='Sheet1', index=False, header=False)
buf.seek(0)
excel_bytes = buf.getvalue()
parser = ExcelFileParcer(excel_bytes, sample_config)
order_rows = parser.get_order_rows()
# Должны остаться только строки до первой пустой
assert len(order_rows) == 2 # ART001 и ART002
@patch('mail_order_bot.parsers.excel_parcer.pd.read_excel')
def test_get_order_rows_with_calamine_engine(self, mock_read_excel, sample_config):
"""Тест что get_order_rows использует calamine engine"""
# Создаем мок DataFrame
mock_df = pd.DataFrame({
'Артикул': ['ART001', 'ART002', None],
'Производитель': ['MAN001', 'MAN002', None]
})
mock_read_excel.return_value = mock_df
# Создаем парсер с моком для первого чтения
df_init = pd.DataFrame([
['Артикул', 'Производитель'],
['ART001', 'MAN001'],
['ART002', 'MAN002'],
[None, None]
])
with patch('mail_order_bot.parsers.excel_parcer.pd.read_excel') as mock_init:
mock_init.return_value = df_init
parser = ExcelFileParcer(b"test", sample_config)
# Тестируем get_order_rows
with patch('mail_order_bot.parsers.excel_parcer.pd.read_excel') as mock_get:
mock_get.return_value = mock_df
result = parser.get_order_rows()
# Проверяем, что был вызван read_excel с engine='calamine'
mock_get.assert_called_once()
call_kwargs = mock_get.call_args[1]
assert call_kwargs.get('engine') == 'calamine'