12 Commits

57 changed files with 1986 additions and 450 deletions

5
.gitignore vendored
View File

@@ -4,4 +4,7 @@ __pycache__
.env
.cursorignore
logs/
files/
files/
Настроено/
Не настроено/
проблемные/

View File

View File

@@ -0,0 +1,46 @@
import os
import hashlib
import requests
import logging
logger = logging.getLogger(__name__)
class AbcpProvider:
HOST = "https://id23089.public.api.abcp.ru"
HEADERS = {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded"
}
def __init__(self, account="SYSTEM"):
self.base_url = self.HOST
def get_stock(self, sku, manufacturer, partner="SYSTEM"):
method = "GET"
path = "/search/articles"
params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"}
return self._execute(partner, path, method, params)
def _execute(self, partner, path, method="GET", params={}, data=None, ):
params["userlogin"] = os.getenv(f"ABCP_LOGIN_{partner}")
params["userpsw"] = hashlib.md5(os.getenv(f"ABCP_PASSWORD_{partner}").encode("utf-8")).hexdigest()
response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params)
payload = response.json()
if response.status_code == 200:
logger.debug(f"Получены данные об остатках на складе")
result = {
"success": True,
"data": payload
}
else:
logger.warning(f"ошибка получения данных об остатках на складе: {payload}")
result = {
"success": False,
"error": payload
}
return result

View File

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

View File

@@ -0,0 +1,9 @@
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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,141 @@
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

@@ -0,0 +1,118 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,77 @@
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

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,42 @@
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,64 +1,8 @@
# Настройки обработки =================================================================
suppliers:
# Контрагент A - стандартный формат
autostels:
sheet_name: "Лист1" # Название листа Excel
header_row: 2 # Номер строки с заголовками (0 = первая)
# Маппинг: внутреннее_поле -> названиеолонки_в_Excel
mapping:
article: "№ Детали"
manufacturer: "Производитель"
name: "Наименование"
price: "Прайс"
quantity: "Количество"
total: "Сумма"
# Дополнительные настройки (опционально)
options:
decimal_separator: ","
encoding: "utf-8"
# Контрагент B - формат с английскими названиями
parterra:
sheet_name: "TDSheet"
header_row: 6 # Заголовки во второй строке
mapping:
article: "Артикул поставщика"
manufacturer: "Производитель Поставщика"
name: "Номенклатура"
price: "Цена"
quantity: "Количество (в единицах хранения)"
total: "Сумма с НДС"
options:
decimal_separator: ","
encoding: "utf-8"
#thousand_separator: ","
# Контрагент C - с запятой как разделителем
part-kom:
sheet_name: "Лист_1" # Можно указать индекс листа
header_row: 5
mapping:
article: "Артикул"
manufacturer: "Изготовитель"
name: "Наименование товара"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
options:
#skip_footer_rows: 3
decimal_separator: ","
# Раздел с общими конфигурационными параметрами ===============================
update_interval: 10
work_interval: 30
update_interval: 1
work_interval: 60
email_dir: "spareparts"
# Логирование =================================================================
@@ -100,7 +44,7 @@ log:
loggers:
'':
handlers: [console, file, telegram]
level: INFO
level: DEBUG
propagate: False
__main__:

View File

@@ -0,0 +1,30 @@
pipeline:
# Настраиваем парсинг экселя
- handler: BasicExcelParser
config:
sheet_name: 0
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Фирма"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
# Определяем логику обработки заказа (в данном случае все с локального склада)
- handler: DeliveryPeriodLocalStore
# Запрос остатков со склада
- handler: GetStock

View File

@@ -0,0 +1,19 @@
pipeline:
- handler: BasicExcelParser
config:
sheet_name: 0
key_field: "Код детали"
mapping:
article: "Код детали"
manufacturer: "Производитель"
name: "Наименование"
price: "Цена\nдетали"
quantity: "Кол-\nво"
total: "Сумма"
- handler: GetStock

View File

@@ -0,0 +1,78 @@
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

@@ -11,55 +11,13 @@ from email.header import decode_header
import imaplib
import smtplib
from .objects import EmailMessage, EmailAttachment
# from .objects import EmailMessage, EmailAttachment
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,
imap_port: int = 993,
smtp_port: int = 587
):
"""
Инициализация клиента электронной почты.
Args:
imap_host: IMAP сервер (например, 'imap.gmail.com')
smtp_host: SMTP сервер (например, 'smtp.gmail.com')
email: Email адрес
password: Пароль или app password
imap_port: Порт IMAP (по умолчанию 993 для SSL)
smtp_port: Порт SMTP (по умолчанию 587 для TLS)
"""
def __init__(self, imap_host: str, smtp_host: str, email: str, password: str,
imap_port: int = 993, smtp_port: int = 587):
self.imap_host = imap_host
self.smtp_host = smtp_host
self.email = email
@@ -67,26 +25,69 @@ class EmailClient:
self.imap_port = imap_port
self.smtp_port = smtp_port
self.imap_conn = None
def _connect_imap(self):
"""Установить IMAP соединение"""
def connect(self):
"""Установkение IMAP соединения"""
if self.imap_conn is None:
self.imap_conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
self.imap_conn.login(self.email, self.password)
def _decode_header(self, header_value: str) -> str:
"""
Декодировать заголовок письма.
Args:
header_value: Значение заголовка
Returns:
Декодированная строка
"""
def disconnect(self):
"""Закрытие IMAP соединения"""
if self.imap_conn:
try:
self.imap_conn.disconnect()
self.imap_conn.logout()
except:
pass
self.imap_conn = None
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]:
"""Получить список новых электронных писем."""
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()
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:
return ""
decoded_parts = []
for part, encoding in decode_header(header_value):
if isinstance(part, bytes):
@@ -99,259 +100,5 @@ class EmailClient:
decoded_parts.append(part.decode('utf-8', errors='ignore'))
else:
decoded_parts.append(str(part))
return ''.join(decoded_parts)
def _extract_first_sender(self, body: str):
"""
Извлекает адреса отправителей из пересылаемого сообщения по паттерну:
-------- Пересылаемое сообщение --------
07.10.2025, 16:01, Имя (email@example.com):
Кому: ...
"""
# Ищем email внутри скобок после строки "Пересылаемое сообщение"
pattern = r"Пересылаемое сообщение.*?\((.*?)\)"
match = re.search(pattern, body, re.DOTALL)
if match:
return match.group(1)
return None
def _extract_body(self, msg: email.message.Message) -> str:
"""
Извлечь текст письма из любого типа содержимого, кроме вложений.
Args:
msg: Объект письма
Returns:
Текст письма
"""
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
return body
def __extract_email(self, text: str) -> str:
match = re.search(r'<([^<>]+)>', text)
if match:
return match.group(1)
return None
def _extract_attachments(self, msg: email.message.Message) -> List[EmailAttachment]:
"""
Извлечь вложения из письма.
Args:
msg: Объект письма
Returns:
Список вложений
"""
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 = self._decode_header(filename)
# Получаем содержимое
content = part.get_payload(decode=True)
if content:
attachments.append(
EmailAttachment(filename=filename, content=content)
)
return attachments
def get_emails(
self,
folder: str = "INBOX",
only_unseen: bool = True,
mark_as_read: bool = True
) -> List[EmailMessage]:
"""
Получить список новых электронных писем.
Args:
folder: Папка для получения писем (по умолчанию "INBOX")
only_unseen: Получать только непрочитанные письма (по умолчанию True)
Returns:
Список объектов EmailMessage
"""
self._connect_imap()
# Выбираем папку
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
):
"""
Отправить электронное письмо.
Args:
message: Объект EmailMessage для отправки
to_addr: Адрес получателя
cc: Список адресов для копии (необязательно)
bcc: Список адресов для скрытой копии (необязательно)
"""
# Создаем 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:
server.starttls()
server.login(self.email, self.password)
server.sendmail(self.email, recipients, msg.as_string())
def close(self):
"""Закрыть IMAP соединение"""
if self.imap_conn:
try:
self.imap_conn.close()
self.imap_conn.logout()
except:
pass
self.imap_conn = None
def __enter__(self):
"""Поддержка контекстного менеджера"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Поддержка контекстного менеджера"""
self.close()
return ''.join(decoded_parts)

View File

@@ -1,5 +1,6 @@
from dataclasses import dataclass
from datetime import datetime
from email.message import EmailMessage
from typing import List
@@ -13,6 +14,8 @@ class EmailAttachment:
@dataclass
class EmailMessage:
"""Класс для представления электронного письма"""
message: EmailMessage
attachments: List[EmailAttachment]
from_addr: str
from_email: str
subj: str

View File

@@ -0,0 +1,106 @@
from email.header import decode_header, make_header
import re
from datetime import datetime
from typing import List, Optional
from dataclasses import dataclass
import email
from email import encoders
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.header import decode_header
import imaplib
import smtplib
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)
if match:
return match.group(1)
return None
@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
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]
# Получаем содержимое
content = part.get_payload(decode=True)
if content:
#attachments.append(EmailAttachment(filename=filename, content=content))
attachments.append({"name": filename, "bytes": content})
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

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

View File

@@ -0,0 +1,16 @@
from .attachment_handler.attachment_handler import AttachmentHandler
from .excel_parcers.order_parcer_basic import BasicExcelParser
from .destination_time.local_store import DeliveryPeriodLocalStore
from .abcp.api_get_stock import GetStock
from .abcp.api_create_order import InstantOrderTest
from .notifications.test_notifier import TestNotifier

View File

@@ -0,0 +1,37 @@
import logging
import requests
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.email_processor.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

@@ -0,0 +1,26 @@
import random
import logging
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.abcp_api.abcp_provider import AbcpProvider
logger = logging.getLogger(__name__)
class APIGetStock(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.abcp_provider = AbcpProvider()
def do(self) -> None:
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
order = attachment["order"]
for position in order.positions:
stock = self.get_stock(position.sku, position.manufacturer)
position.update_stock(stock, order.delivery_period)
position.fill_from_stock()
logger.info(f"Получены позиции со склада для файла {attachment.get('name', "no name")}")
def get_stock(self, sku: str, manufacturer: str) -> int:
return self.abcp_provider.get_stock(sku, manufacturer)

View File

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

View File

@@ -0,0 +1,21 @@
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
import logging
logger = logging.getLogger(__name__)
class AttachmentHandler(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
email = self.context.data["email"]
attachments = EmailUtils.extract_attachments(email)
self.context.data["attachments"] = attachments
logger.debug(f"AttachmentHandler отработал, извлек вложений: {len(attachments)} ")

View File

@@ -0,0 +1,21 @@
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
import logging
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:
order = attachment["order"]
order.set_delivery_period(0)
logger.info(f"Доставка только с локального склада, срок 1 день.")

View File

@@ -0,0 +1,141 @@
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

@@ -0,0 +1,118 @@
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_processor.handlers.order_position import OrderPosition
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from ...order.auto_part_position import AutoPartPosition
from ...order.auto_part_order import AutoPartOrder
logger = logging.getLogger(__name__)
class BasicExcelParser(AbstractTask):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
file_bytes = BytesIO(attachment['bytes']) # self.context.get("attachment") #
try:
df = self._make_dataframe(file_bytes)
mapping = self.config['mapping']
order = AutoPartOrder()
# Парсим строки
positions = []
for idx, row in df.iterrows():
position = self._parse_row(row, mapping)
if position:
order.add_position(position)
logger.info(f"Успешно обработано {len(order)} позиций из {len(df)} строк")
except Exception as e:
logger.error(f"Ошибка при обработке файла: {e}")
else:
attachment["order"] = order
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[AutoPartPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
if 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

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

View File

@@ -0,0 +1,24 @@
import random
import logging
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.email_processor.order.auto_part_order import OrderStatus
from mail_order_bot.email_processor.order.auto_part_position import AutoPartPosition, PositionStatus
from decimal import Decimal
import random
logger = logging.getLogger(__name__)
class LocalStoreOrder(AbstractTask):
"""Сейчас логика такая
- ищем на складе наш сапплиер код, берем самую дешевую позицию и делаем заказ из нее
Другие чуть более дорогие не рассматриваем
"""
# это код нашего склада
def do(self) -> None:
attachments = self.context.data["attachments"]
for attachment in attachments:
order = attachment["order"]

View File

@@ -0,0 +1,15 @@
import logging
from mail_order_bot.email_processor.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

@@ -0,0 +1,2 @@
from .auto_part_order import AutoPartOrder, OrderStatus
from .auto_part_position import AutoPartPosition, PositionStatus

View File

@@ -0,0 +1,59 @@
from typing import List, Optional
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):
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 fill_from_local_supplier(self) -> None:
for position in self.positions:
errors = position.fill_from_stock()
self.errors += errors
def check_order(self, config) -> None:
""" Проверяет заказ на возможность исполнения"""
# 1. Проверка общего количества отказов
order_refusal_threshold = config.get("order_refusal_threshold", 1)
refusal_positions_count = len([position for position in self.positions if str(position.status) in
[PositionStatus.REFUSED, PositionStatus.STOCK_FAILED]])
order_refusal_rate = refusal_positions_count / len(self.positions)
if order_refusal_rate > order_refusal_threshold:
self.errors.append(f"Превышен порог отказов в заказе - {order_refusal_rate:.0%} "
f"({refusal_positions_count} из {len(self.positions)})")
self.status = OrderStatus.OPERATOR_REQUIRED
def __len__(self):
return len(self.positions)

View File

@@ -0,0 +1,118 @@
from typing import List, Optional
from dataclasses import dataclass, field
from typing import Dict, Any
from decimal import Decimal
from enum import Enum
class PositionStatus(Enum):
NEW = "new" # Новая позиция
STOCK_RECIEVED = "stock_received" # Получен остаток
STOCK_FAILED = "stock_failed" # Остаток не получен
NO_AVAILABLE_STOCK = "no_available_stock" #Нет доступных складов
READY = "ready"
READY_PARTIAL = "ready_partial"
ORDERED = "ordered" # Заказано
REFUSED = "refused" # Отказано
@dataclass
class AutoPartPosition:
DISTRIBUTOR_ID = "1577730"
"""
Унифицированная модель позиции для заказа.
Все контрагенты приводятся к этой структуре.
"""
sku: str # Артикул товара
manufacturer: str # Производитель
requested_price: Decimal # Цена за единицу
requested_quantity: int # Количество
total: Decimal = 0 # Общая сумма
name: str = "" # Наименование
order_quantity: int = 0 # Количество для заказа
order_price: Decimal = Decimal('0.0') # Цена в заказе
order_item: Dict[str, Any] = field(default_factory=dict)
stock: List[Dict[str, Any]] = None
additional_attrs: Dict[str, Any] = field(default_factory=dict)
status: PositionStatus = PositionStatus.NEW
desc: str = ""
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}")
def update_stock(self, stock: Dict[str, Any], delivery_period: int = 0) -> None:
if stock["success"]:
available_distributors = stock["data"]
# Для доставки только с локального склада сперва убираем все остальные склады
if delivery_period == 0:
available_distributors = self._filter_only_local_storage(available_distributors)
#Отбираем склады по сроку доставки
available_distributors = self._filter_proper_delivery_time(available_distributors, delivery_period)
# Убираем дорогие склады с ценой выше запрошенной
available_distributors = self._filter_proper_price(available_distributors)
# Убираем отрицательные остатки
available_distributors = self._filter_proper_availability(available_distributors)
# Сортируем по цене
available_distributors.sort(key=lambda item: Decimal(item["price"]), reverse=False)
self.stock = available_distributors
if len (self.stock):
self.status = PositionStatus.STOCK_RECIEVED
else:
self.status = PositionStatus.NO_AVAILABLE_STOCK
else:
self.status = PositionStatus.STOCK_FAILED
def fill_from_stock(self):
if self.status == PositionStatus.STOCK_RECIEVED:
for distributor in self.stock:
distributor["profit"] = int(distributor["availability"]) * self.requested_price - int(distributor["availability"]) * Decimal(distributor["price"])
self.stock.sort(key=lambda item: item["profit"], reverse=True)
self.order_quantity = self.stock[0]["availability"]
self.order_price = self.requested_price
self.order_item = self.stock[0]
self.status = PositionStatus.READY
def _filter_only_local_storage(self, distributors):
return [item for item in distributors if str(item["distributorId"]) == self.DISTRIBUTOR_ID]
def _filter_proper_delivery_time(self, distributors, delivery_period):
return [item for item in distributors if item["deliveryPeriod"] <= delivery_period]
def _filter_proper_price(self, distributors):
return [item for item in distributors if Decimal(item["price"]) <= self.requested_price]
def _filter_proper_availability(self, distributors):
return [item for item in distributors if Decimal(item["availability"]) > 0]

View File

@@ -0,0 +1,77 @@
import os
import yaml
import logging
from typing import Dict, Any
from pathlib import Path
import threading
from mail_order_bot.context import Context
from mail_order_bot.email_client.utils import EmailUtils
from enum import Enum
from mail_order_bot.email_processor.handlers import *
from mail_order_bot.email_processor.handlers import AttachmentHandler
logger = logging.getLogger(__name__)
class RequestStatus(Enum):
NEW = "new"
IN_PROGRESS = "in progress"
FAILED = "failed"
EXECUTED = "executed"
OPERATOR_REQUIRED = "operator required"
INVALID = "invalid"
class EmailProcessor:
def __init__(self, configs_path: str):
super().__init__()
self.context = Context()
self.configs_path = configs_path
self.status = RequestStatus.NEW
def process_email(self, email):
# Очистить контекст
self.context.clear()
# Сохранить письмо в контекст
self.context.data["email"] = email
# Определить клиента
email_body = EmailUtils.extract_body(email)
email_from = EmailUtils.extract_first_sender(email_body)
client = EmailUtils.extract_domain(email_from)
try:
# Определить конфиг для пайплайна
config = self._load_config(client)
self.context.data["config"] = config
# Обработка вложений
attachments_handler_task = AttachmentHandler()
attachments_handler_task.do()
# Запустить обработку пайплайна
for stage in config["pipeline"]:
handler_name = stage["handler"]
logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name](stage.get("config", None))
task.do()
except FileNotFoundError:
logger.error(f"Конфиг для клиента {client} не найден")
for attachment in self.context.data["attachments"]:
print(attachment["order"].__dict__)
#except Exception as e:
# logger.error(f"Произошла другая ошибка: {e}")
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)

View File

@@ -1,4 +1,4 @@
import threading
from config_manager import ConfigManager
from dotenv import load_dotenv
import asyncio
@@ -7,39 +7,51 @@ import os
from dotenv import load_dotenv
from email_client import EmailClient
from excel_proceccor import ExcelProcessor
from email_processor import EmailProcessor
from mail_order_bot.context import Context
logger = logging.getLogger()
class MailOrderBot(ConfigManager):
def __init__(self, *agrs, **kwargs):
super().__init__(*agrs, **kwargs)
# Объявить почтового клиента
self.email_client = EmailClient(
imap_host=os.getenv('IMAP_HOST'),
smtp_host=os.getenv('SMTP_HOST'),
email=os.getenv('EMAIL_USER'),
password=os.getenv('EMAIL_PASSWORD'),
imap_port=os.getenv('IMAP_PORT'),
smtp_port=os.getenv('SMTP_PORT')
imap_port=int(os.getenv('IMAP_PORT', default="993")),
smtp_port=int(os.getenv('SMTP_PORT', default="587")),
)
# Сохранить почтовый клиент в контекст
self.context = Context()
self.context.email_client = self.email_client
# Обработчик писем
self.email_processor = EmailProcessor("./configs")
logger.warning("MailOrderBot инициализирован")
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)
# Получить список айдишников письма
unread_email_ids = self.email_client.get_emails_id(folder="spareparts")
logger.info(f"Новых писем - {len(unread_email_ids)}")
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")
# Обработать каждое письмо по идентификатору
for email_id in unread_email_ids:
logger.debug(f"==================================================")
logger.debug(f"Обработка письма с идентификатором {email_id}")
# Получить письмо по идентификатору и запустить его обработку
email = self.email_client.get_email(email_id)
self.email_processor.process_email(email)
pass
logger = logging.getLogger()

View File

@@ -1,56 +0,0 @@
suppliers:
# order@stparts.ru
"order@stparts.ru":
sheet_name: "TDSheet" # Название листа Excel
header_row: 0 # Номер строки с заголовками (0 = первая)
# Маппинг: внутреннее_поле -> названиеолонки_в_Excel
mapping:
article: "Номер"
manufacturer: "Бренд"
name: "Описание"
price: "Цена"
quantity: "Количество"
#total: "Сумма"
#Вопросы: что за поле "Фактическая_отгрузка"?
# Дополнительные настройки (опционально)
options:
decimal_separator: ","
encoding: "utf-8"
# Рай Авто СПб
EMPTY-FROM:
sheet_name: 0
header_row: 2 # Заголовки во второй строке
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Название"
price: "Цена"
quantity: "Количество"
#total: "Сумма с НДС"
options:
decimal_separator: ","
encoding: "utf-8"
#thousand_separator: ","
# Примечание: гемор - нет имейла
# АвтоТО
"order@avtoto.ru":
sheet_name: "Заказы" # Можно указать индекс листа
header_row: 4
mapping:
article: "Артикул"
manufacturer: "Изготовитель"
name: "Наименование товара"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
options:
#skip_footer_rows: 3
decimal_separator: ","

View File

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

View File

@@ -26,4 +26,4 @@ if __name__ == "__main__":
print(email.first_sender)
print('--------------------------------')
email_client.close()
email_client.disconnect()

View File

@@ -0,0 +1,42 @@
# Конфигурационный файл для контрагента mikado-parts.ru
pipeline:
# Обработчик вложений - извлекает из экселя данные
- handler: BasicExcelParser
config:
sheet_name: 0 # Можно указать индекс листа
key_field: "артикул" # Поле, по которому будет определяться заголовок блока с данными и будут отсекаться незаполненные строки
mapping:
article: "артикул"
manufacturer: "бренд"
name: "наименование"
price: "цена"
quantity: "количество"
# Обработчик получает данные со склада о цене и остатках по каждой позиций
- handler: GetStock
config:
max_stock: 2
min_stock: 0
# Обработчик проверяет заказ на возможность автоматической обработки
- handler: CheckOrder
config:
acceptable_price_reduction: 2
refusal_threshold: 0.1
# Создание заказа
- handler: InstantOrderTest
config:
api_key: "8056899069:AAFEfw9QRMvmEwQyH0CI4e_v_sZuOSdNWcE"
chat_id: 211945135
# Отправка уведомлений менеджерам
#- handler: "TestNotifier"

View File

@@ -0,0 +1,27 @@
# Конфигурационный файл для контрагента todx.ru
pipeline:
# Обработчик вложений
- handler: "BasicExcelParser"
config:
sheet_name: 0
key_field: "Код детали"
mapping:
article: "Код детали"
manufacturer: "Производитель"
name: "Наименование"
price: "Цена\nдетали"
quantity: "Кол-\nво"
total: "Сумма"
- handler: InstantOrderTest
config:
api_key: "8056899069:AAFEfw9QRMvmEwQyH0CI4e_v_sZuOSdNWcE"
chat_id: 211945135
- handler: "TestNotifier"

View File

@@ -0,0 +1,60 @@
import os
import chardet # pip install chardet
import traceback
from mail_order_bot.email_processor import EmailProcessor
import datetime
# установим рабочую директорию
import os
os.chdir(os.path.dirname(os.path.abspath(__file__)))
from io import BytesIO
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.WARNING, format='%(module)s - %(message)s') # %(asctime)s -
BASE_PATH = './files'
from mail_order_bot.email_client import EmailMessage, EmailAttachment
processor = EmailProcessor("./configs")
for provider_name in os.listdir(BASE_PATH):
provider_folder = os.path.join(BASE_PATH, provider_name)
if os.path.isdir(provider_folder):
for file_name in os.listdir(provider_folder):
file_path = os.path.join(provider_folder, file_name)
if os.path.isfile(file_path):
with open(file_path, 'rb') as file: # бинарный режим
raw_data = file.read()
# Создаем объект EmailAttachment
att = EmailAttachment(file_name, raw_data)
email = EmailMessage(
message=None,
from_addr=provider_name,
from_email='test@gmail.com',
subj='order request',
dt=datetime.datetime.now(),
body= 'body text',
attachments=[att],
first_sender='test@gmail.com'
)
#bio = BytesIO(raw_data)
print("========================================================")
print(f'Обработка: {provider_name} - {file_name}')
try:
positions_a = processor.process(provider_name, att)
except Exception as e:
print(f"Ошибка обработки: {e}", traceback.format_exc())

View File

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

9
tests/site/auth.py Normal file
View File

@@ -0,0 +1,9 @@
from requests_html import HTMLSession
print(1)
session = HTMLSession()
response = session.get("https://zapchastiya.ru/")
print(2)
response.html.render(wait=2) # Ждем выполнения JS, 2 секунды например
print(3)
print(response.html.html) # Выводим страницу после выполнения JS

1
tests/site/test.html Normal file
View File

@@ -0,0 +1 @@
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">\n<html>\n<head>\n<title>Robot Check Redirector</title>\n<meta http-equiv="Cache-Control" content="no-cache">\n<META NAME="ROBOTS" CONTENT="NOINDEX, NOFOLLOW">\n<meta name="Document-state" content="Dynamic">\n<meta name="Resource-type" content="document">\n<meta http-equiv="Content-Type" content="text/html; charset=utf-8">\n<script type="text/javascript">\nfunction checkTheRobot() {\n var myurl = window.location.href.toString();\n var mycheckurl = "https://hcaptcha-antibot.nodacdn.net";\n var myparams = "/?cngx=1&original_url=";\n var newurl = mycheckurl.concat(myparams,myurl);\n setTimeout(function(){ window.location.href = newurl ; } , 5000)\n}\n\n</script>\n<title></title>\n</head>\n<body onload="checkTheRobot();" style="font-family: Arial, Sans-Serif; background:#cef0fa">\n\n<p><br></p>\n<center>\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/file-uploader/3.7.0/processing.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.7.1/img/loading.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.lazyloadxt/1.0.5/loading.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/fancybox/2.1.5/fancybox_loading@2x.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery-mobile/1.4.1/images/ajax-loader.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/fancybox/2.1.5/fancybox_loading@2x.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.lazyloadxt/1.0.5/loading.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.7.1/img/loading.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/file-uploader/3.7.0/processing.gif">&nbsp;\n\n<p>You will be redirected to Robot Checker. Please enable Javascript in browser.</p>\n</center>\n</body>\n</html>\n'