no message

This commit is contained in:
2025-12-06 16:58:24 +03:00
parent b8d4e0ddd1
commit ce12f23426
38 changed files with 920 additions and 133 deletions

View 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()

View File

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

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

@@ -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

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

@@ -31,7 +31,6 @@ class AutoPartPosition:
class AutoPartPosition2: class AutoPartPosition2:
brand: str brand: str
sku: str sku: str

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

@@ -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()

View File

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

View 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("Новый контекст установлен")

View 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):

View 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

View File

@@ -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__)

View File

@@ -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__)

View 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

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

View File

@@ -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

View File

@@ -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__)

View File

@@ -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__)

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

View File

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

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_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'))

View File

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

View File

@@ -1,7 +1,7 @@
import os import os
import chardet # pip install chardet import chardet # pip install chardet
import traceback import traceback
from mail_order_bot.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
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'