no message
This commit is contained in:
35
src/mail_order_bot/abcp_api/abcp_provider.py
Normal file
35
src/mail_order_bot/abcp_api/abcp_provider.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class AbcpProvider:
|
||||||
|
HOST = "https://id23089.public.api.abcp.ru"
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = self.HOST
|
||||||
|
self.login = os.getenv("ABCP_LOGIN")
|
||||||
|
password = os.getenv("ABCP_PASSWORD")
|
||||||
|
self.password = hashlib.md5(password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def get_stock(self, order):
|
||||||
|
method = "GET"
|
||||||
|
path = "/search/articles"
|
||||||
|
|
||||||
|
for position in order.positions:
|
||||||
|
params = {"number": position.sku, "brand": position.manufacturer, "withOutAnalogs": "1"}
|
||||||
|
position.stock = self._execute(path, method, params)
|
||||||
|
|
||||||
|
def _execute(self, path, method="GET", params={}, data=None):
|
||||||
|
params["userlogin"] = self.login
|
||||||
|
params["userpsw"] = self.password
|
||||||
|
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()
|
||||||
|
|
||||||
18
src/mail_order_bot/attachment_handler/__init__.py
Normal file
18
src/mail_order_bot/attachment_handler/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Пакет содержит реализацию для создания заказов из вложений.
|
||||||
|
|
||||||
|
На входе файл из вложения
|
||||||
|
Его обработка производится хендлерами, коотрые настраиваются в конфиге
|
||||||
|
На выходе - экземпляр класса, который в себе содержит
|
||||||
|
- прочитанный файл в виде pandas DataFrame
|
||||||
|
- распарсенный файл заказа с позициями
|
||||||
|
- полученные остатки
|
||||||
|
- результат проверки возможности создания заказа
|
||||||
|
|
||||||
|
Так же класс содержит методы
|
||||||
|
- для создания заказа
|
||||||
|
- для получения отредактированного файла
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .processor import TaskProcessor
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -3,8 +3,8 @@ import pandas as pd
|
|||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
#from mail_order_bot.task_handler.handlers.order_position import OrderPosition
|
#from mail_order_bot.attachment_handler.handlers.order_position import OrderPosition
|
||||||
from mail_order_bot.task_handler.handlers.abstract_task import AbstractTask
|
from mail_order_bot.attachment_handler.handlers.abstract_task import AbstractTask
|
||||||
|
|
||||||
from ...order.auto_part_position import AutoPartPosition
|
from ...order.auto_part_position import AutoPartPosition
|
||||||
|
|
||||||
@@ -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})")
|
||||||
@@ -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 = "Превышен порог отказов"
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ class AutoPartPosition:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class AutoPartPosition2:
|
class AutoPartPosition2:
|
||||||
brand: str
|
brand: str
|
||||||
sku: str
|
sku: str
|
||||||
34
src/mail_order_bot/attachment_handler/order_request.py
Normal file
34
src/mail_order_bot/attachment_handler/order_request.py
Normal 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
|
||||||
@@ -40,26 +40,9 @@ class EmailClient:
|
|||||||
client.send_email(msg, to_addr='recipient@example.com')
|
client.send_email(msg, to_addr='recipient@example.com')
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, imap_host: str, smtp_host: str, email: str, password: str,
|
||||||
self,
|
imap_port: int = 993, smtp_port: int = 587):
|
||||||
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)
|
|
||||||
"""
|
|
||||||
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
|
||||||
@@ -68,22 +51,24 @@ class EmailClient:
|
|||||||
self.smtp_port = smtp_port
|
self.smtp_port = smtp_port
|
||||||
self.imap_conn = None
|
self.imap_conn = None
|
||||||
|
|
||||||
def _connect_imap(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):
|
||||||
|
"""Закрыть IMAP соединение"""
|
||||||
|
if self.imap_conn:
|
||||||
|
try:
|
||||||
|
self.imap_conn.disconnect()
|
||||||
|
self.imap_conn.logout()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.imap_conn = None
|
||||||
|
|
||||||
def _decode_header(self, header_value: str) -> str:
|
def _decode_header(self, header_value: str) -> str:
|
||||||
"""
|
"""Декодировать заголовок письма."""
|
||||||
Декодировать заголовок письма.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
header_value: Значение заголовка
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Декодированная строка
|
|
||||||
"""
|
|
||||||
if header_value is None:
|
if header_value is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -102,32 +87,9 @@ class EmailClient:
|
|||||||
|
|
||||||
return ''.join(decoded_parts)
|
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:
|
def _extract_body(self, msg: email.message.Message) -> str:
|
||||||
"""
|
"""Извлечь текст письма из любого типа содержимого, кроме вложений"""
|
||||||
Извлечь текст письма из любого типа содержимого, кроме вложений.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
msg: Объект письма
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Текст письма
|
|
||||||
"""
|
|
||||||
body = ""
|
body = ""
|
||||||
|
|
||||||
if msg.is_multipart():
|
if msg.is_multipart():
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
content_disposition = str(part.get("Content-Disposition", ""))
|
content_disposition = str(part.get("Content-Disposition", ""))
|
||||||
@@ -159,17 +121,17 @@ class EmailClient:
|
|||||||
return match.group(1)
|
return match.group(1)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _extract_first_sender(self, body: str):
|
||||||
|
"""Извлекает адреса отправителей из пересылаемого сообщения. Нужно для отладки"""
|
||||||
|
# Ищем email внутри скобок после строки "Пересылаемое сообщение"
|
||||||
|
pattern = r"Пересылаемое сообщение.*?\((.*?)\)"
|
||||||
|
match = re.search(pattern, body, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
def _extract_attachments(self, msg: email.message.Message) -> List[EmailAttachment]:
|
def _extract_attachments(self, msg: email.message.Message) -> List[EmailAttachment]:
|
||||||
"""
|
"""Извлечь вложения из письма."""
|
||||||
Извлечь вложения из письма.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
msg: Объект письма
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Список вложений
|
|
||||||
"""
|
|
||||||
attachments = []
|
attachments = []
|
||||||
|
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
@@ -177,38 +139,40 @@ class EmailClient:
|
|||||||
|
|
||||||
if "attachment" in content_disposition:
|
if "attachment" in content_disposition:
|
||||||
filename = part.get_filename()
|
filename = part.get_filename()
|
||||||
|
|
||||||
if filename:
|
if filename:
|
||||||
# Декодируем имя файла
|
# Декодируем имя файла
|
||||||
filename = self._decode_header(filename)
|
filename = self._decode_header(filename)
|
||||||
|
|
||||||
# Получаем содержимое
|
# Получаем содержимое
|
||||||
content = part.get_payload(decode=True)
|
content = part.get_payload(decode=True)
|
||||||
|
|
||||||
if content:
|
if content:
|
||||||
attachments.append(
|
attachments.append(EmailAttachment(filename=filename, content=content))
|
||||||
EmailAttachment(filename=filename, content=content)
|
|
||||||
)
|
|
||||||
|
|
||||||
return attachments
|
return attachments
|
||||||
|
|
||||||
def get_emails(
|
def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True, mark_as_read: bool = True) -> List[
|
||||||
self,
|
EmailMessage]:
|
||||||
folder: str = "INBOX",
|
"""Получить список новых электронных писем."""
|
||||||
only_unseen: bool = True,
|
self.connect()
|
||||||
mark_as_read: bool = True
|
|
||||||
) -> List[EmailMessage]:
|
|
||||||
"""
|
|
||||||
Получить список новых электронных писем.
|
|
||||||
|
|
||||||
Args:
|
# Выбираем папку
|
||||||
folder: Папка для получения писем (по умолчанию "INBOX")
|
self.imap_conn.select(folder, readonly=False)
|
||||||
only_unseen: Получать только непрочитанные письма (по умолчанию True)
|
|
||||||
|
|
||||||
Returns:
|
# Ищем письма
|
||||||
Список объектов EmailMessage
|
search_criteria = "(UNSEEN)" if only_unseen else "ALL"
|
||||||
"""
|
status, messages = self.imap_conn.search(None, search_criteria)
|
||||||
self._connect_imap()
|
|
||||||
|
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)
|
self.imap_conn.select(folder, readonly=False)
|
||||||
@@ -285,22 +249,8 @@ class EmailClient:
|
|||||||
|
|
||||||
return emails
|
return emails
|
||||||
|
|
||||||
def send_email(
|
def send_email(self, message: EmailMessage, to_addr: str, cc: Optional[List[str]] = None, bcc: Optional[List[str]] = None):
|
||||||
self,
|
"""Отправить электронное письмо"""
|
||||||
message: EmailMessage,
|
|
||||||
to_addr: str,
|
|
||||||
cc: Optional[List[str]] = None,
|
|
||||||
bcc: Optional[List[str]] = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Отправить электронное письмо.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: Объект EmailMessage для отправки
|
|
||||||
to_addr: Адрес получателя
|
|
||||||
cc: Список адресов для копии (необязательно)
|
|
||||||
bcc: Список адресов для скрытой копии (необязательно)
|
|
||||||
"""
|
|
||||||
# Создаем multipart сообщение
|
# Создаем multipart сообщение
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg['From'] = self.email
|
msg['From'] = self.email
|
||||||
@@ -337,21 +287,12 @@ class EmailClient:
|
|||||||
server.login(self.email, self.password)
|
server.login(self.email, self.password)
|
||||||
server.sendmail(self.email, recipients, msg.as_string())
|
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):
|
def __enter__(self):
|
||||||
"""Поддержка контекстного менеджера"""
|
"""Поддержка контекстного менеджера"""
|
||||||
|
self.connect()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
"""Поддержка контекстного менеджера"""
|
"""Поддержка контекстного менеджера"""
|
||||||
self.close()
|
self.disconnect()
|
||||||
|
|
||||||
|
|||||||
1
src/mail_order_bot/email_handler/__init__.py
Normal file
1
src/mail_order_bot/email_handler/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .email_processor import EmailProcessor
|
||||||
31
src/mail_order_bot/email_handler/context.py
Normal file
31
src/mail_order_bot/email_handler/context.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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("Новый контекст установлен")
|
||||||
47
src/mail_order_bot/email_handler/email_processor.py
Normal file
47
src/mail_order_bot/email_handler/email_processor.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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):
|
||||||
9
src/mail_order_bot/email_handler/handlers/__init__.py
Normal file
9
src/mail_order_bot/email_handler/handlers/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import random
|
import random
|
||||||
import logging
|
import logging
|
||||||
from mail_order_bot.task_handler.handlers.abstract_task import AbstractTask
|
|
||||||
|
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
from mail_order_bot.task_handler.handlers.abstract_task import AbstractTask
|
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
|
||||||
from mail_order_bot.task_handler.order.auto_part_order import OrderStatus
|
from mail_order_bot.email_handler.order.auto_part_order import OrderStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
24
src/mail_order_bot/email_handler/handlers/abstract_task.py
Normal file
24
src/mail_order_bot/email_handler/handlers/abstract_task.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
|
||||||
141
src/mail_order_bot/email_handler/handlers/email/send_email.py
Normal file
141
src/mail_order_bot/email_handler/handlers/email/send_email.py
Normal 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)}")
|
||||||
|
|
||||||
|
|
||||||
@@ -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_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
|
||||||
@@ -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_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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mail_order_bot.task_handler.handlers.abstract_task import AbstractTask
|
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import random
|
import random
|
||||||
import logging
|
import logging
|
||||||
from mail_order_bot.task_handler.handlers.abstract_task import AbstractTask
|
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
|
||||||
from mail_order_bot.task_handler.order.auto_part_order import OrderStatus
|
from mail_order_bot.email_handler.order.auto_part_order import OrderStatus
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
import random
|
import random
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
0
src/mail_order_bot/email_handler/order/__init__.py
Normal file
0
src/mail_order_bot/email_handler/order/__init__.py
Normal file
34
src/mail_order_bot/email_handler/order/auto_part_order.py
Normal file
34
src/mail_order_bot/email_handler/order/auto_part_order.py
Normal 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)
|
||||||
77
src/mail_order_bot/email_handler/order/auto_part_position.py
Normal file
77
src/mail_order_bot/email_handler/order/auto_part_position.py
Normal 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 # Производитель
|
||||||
|
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")
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
from .processor import TaskProcessor
|
|
||||||
22
tests/abcp_api/test_abcp_api.py
Normal file
22
tests/abcp_api/test_abcp_api.py
Normal 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_handler.order.auto_part_order import AutoPartOrder
|
||||||
|
from mail_order_bot.email_handler.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'))
|
||||||
@@ -26,4 +26,4 @@ if __name__ == "__main__":
|
|||||||
print(email.first_sender)
|
print(email.first_sender)
|
||||||
print('--------------------------------')
|
print('--------------------------------')
|
||||||
|
|
||||||
email_client.close()
|
email_client.disconnect()
|
||||||
@@ -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.task_handler import TaskProcessor
|
from mail_order_bot.email_handler import EmailProcessor
|
||||||
import datetime
|
import datetime
|
||||||
# установим рабочую директорию
|
# установим рабочую директорию
|
||||||
import os
|
import os
|
||||||
@@ -18,7 +18,7 @@ BASE_PATH = './files'
|
|||||||
|
|
||||||
from mail_order_bot.email_client import EmailMessage, EmailAttachment
|
from mail_order_bot.email_client import EmailMessage, EmailAttachment
|
||||||
|
|
||||||
processor = TaskProcessor("./configs")
|
processor = EmailProcessor("./configs")
|
||||||
|
|
||||||
for provider_name in os.listdir(BASE_PATH):
|
for provider_name in os.listdir(BASE_PATH):
|
||||||
provider_folder = os.path.join(BASE_PATH, provider_name)
|
provider_folder = os.path.join(BASE_PATH, provider_name)
|
||||||
|
|||||||
9
tests/site/auth.py
Normal file
9
tests/site/auth.py
Normal 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
1
tests/site/test.html
Normal 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"> \n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.7.1/img/loading.gif"> \n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.lazyloadxt/1.0.5/loading.gif"> \n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/fancybox/2.1.5/fancybox_loading@2x.gif"> \n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery-mobile/1.4.1/images/ajax-loader.gif"> \n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/fancybox/2.1.5/fancybox_loading@2x.gif"> \n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.lazyloadxt/1.0.5/loading.gif"> \n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.7.1/img/loading.gif"> \n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/file-uploader/3.7.0/processing.gif"> \n\n<p>You will be redirected to Robot Checker. Please enable Javascript in browser.</p>\n</center>\n</body>\n</html>\n'
|
||||||
Reference in New Issue
Block a user