diff --git a/src/mail_order_bot/task_handler/order/__init__.py b/src/mail_order_bot/abcp_api/__init__.py similarity index 100% rename from src/mail_order_bot/task_handler/order/__init__.py rename to src/mail_order_bot/abcp_api/__init__.py diff --git a/src/mail_order_bot/abcp_api/abcp_provider.py b/src/mail_order_bot/abcp_api/abcp_provider.py new file mode 100644 index 0000000..0fab1ba --- /dev/null +++ b/src/mail_order_bot/abcp_api/abcp_provider.py @@ -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() + diff --git a/src/mail_order_bot/attachment_handler/__init__.py b/src/mail_order_bot/attachment_handler/__init__.py new file mode 100644 index 0000000..87dcbe3 --- /dev/null +++ b/src/mail_order_bot/attachment_handler/__init__.py @@ -0,0 +1,18 @@ +""" +Пакет содержит реализацию для создания заказов из вложений. + +На входе файл из вложения +Его обработка производится хендлерами, коотрые настраиваются в конфиге +На выходе - экземпляр класса, который в себе содержит + - прочитанный файл в виде pandas DataFrame + - распарсенный файл заказа с позициями + - полученные остатки + - результат проверки возможности создания заказа + +Так же класс содержит методы + - для создания заказа + - для получения отредактированного файла + +""" + +from .processor import TaskProcessor \ No newline at end of file diff --git a/src/mail_order_bot/task_handler/handlers/__init__.py b/src/mail_order_bot/attachment_handler/handlers/__init__.py similarity index 100% rename from src/mail_order_bot/task_handler/handlers/__init__.py rename to src/mail_order_bot/attachment_handler/handlers/__init__.py diff --git a/src/mail_order_bot/attachment_handler/handlers/abcp_clients/check_stock.py b/src/mail_order_bot/attachment_handler/handlers/abcp_clients/check_stock.py new file mode 100644 index 0000000..70a0c20 --- /dev/null +++ b/src/mail_order_bot/attachment_handler/handlers/abcp_clients/check_stock.py @@ -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 diff --git a/src/mail_order_bot/attachment_handler/handlers/abcp_clients/create_order.py b/src/mail_order_bot/attachment_handler/handlers/abcp_clients/create_order.py new file mode 100644 index 0000000..9c61844 --- /dev/null +++ b/src/mail_order_bot/attachment_handler/handlers/abcp_clients/create_order.py @@ -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) + + + + + diff --git a/src/mail_order_bot/task_handler/handlers/abstract_task.py b/src/mail_order_bot/attachment_handler/handlers/abstract_task.py similarity index 100% rename from src/mail_order_bot/task_handler/handlers/abstract_task.py rename to src/mail_order_bot/attachment_handler/handlers/abstract_task.py diff --git a/src/mail_order_bot/task_handler/handlers/email/send_email.py b/src/mail_order_bot/attachment_handler/handlers/email/send_email.py similarity index 100% rename from src/mail_order_bot/task_handler/handlers/email/send_email.py rename to src/mail_order_bot/attachment_handler/handlers/email/send_email.py diff --git a/src/mail_order_bot/task_handler/handlers/excel_parcers/basic_excel_parcer.py b/src/mail_order_bot/attachment_handler/handlers/excel_parcers/basic_excel_parcer.py similarity index 96% rename from src/mail_order_bot/task_handler/handlers/excel_parcers/basic_excel_parcer.py rename to src/mail_order_bot/attachment_handler/handlers/excel_parcers/basic_excel_parcer.py index ebc130c..707aa68 100644 --- a/src/mail_order_bot/task_handler/handlers/excel_parcers/basic_excel_parcer.py +++ b/src/mail_order_bot/attachment_handler/handlers/excel_parcers/basic_excel_parcer.py @@ -3,8 +3,8 @@ import pandas as pd from typing import Dict, Any, Optional from decimal import Decimal from io import BytesIO -#from mail_order_bot.task_handler.handlers.order_position import OrderPosition -from mail_order_bot.task_handler.handlers.abstract_task import AbstractTask +#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 diff --git a/src/mail_order_bot/attachment_handler/handlers/notifications/test_notifier.py b/src/mail_order_bot/attachment_handler/handlers/notifications/test_notifier.py new file mode 100644 index 0000000..19c58ee --- /dev/null +++ b/src/mail_order_bot/attachment_handler/handlers/notifications/test_notifier.py @@ -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})") diff --git a/src/mail_order_bot/attachment_handler/handlers/validators/price_quantity_ckecker.py b/src/mail_order_bot/attachment_handler/handlers/validators/price_quantity_ckecker.py new file mode 100644 index 0000000..2e11885 --- /dev/null +++ b/src/mail_order_bot/attachment_handler/handlers/validators/price_quantity_ckecker.py @@ -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 = "Превышен порог отказов" + diff --git a/src/mail_order_bot/attachment_handler/order/__init__.py b/src/mail_order_bot/attachment_handler/order/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mail_order_bot/task_handler/order/auto_part_order.py b/src/mail_order_bot/attachment_handler/order/auto_part_order.py similarity index 100% rename from src/mail_order_bot/task_handler/order/auto_part_order.py rename to src/mail_order_bot/attachment_handler/order/auto_part_order.py diff --git a/src/mail_order_bot/task_handler/order/auto_part_position.py b/src/mail_order_bot/attachment_handler/order/auto_part_position.py similarity index 99% rename from src/mail_order_bot/task_handler/order/auto_part_position.py rename to src/mail_order_bot/attachment_handler/order/auto_part_position.py index 6b5c65f..bd0d33f 100644 --- a/src/mail_order_bot/task_handler/order/auto_part_position.py +++ b/src/mail_order_bot/attachment_handler/order/auto_part_position.py @@ -31,7 +31,6 @@ class AutoPartPosition: - class AutoPartPosition2: brand: str sku: str diff --git a/src/mail_order_bot/attachment_handler/order_request.py b/src/mail_order_bot/attachment_handler/order_request.py new file mode 100644 index 0000000..1d7a218 --- /dev/null +++ b/src/mail_order_bot/attachment_handler/order_request.py @@ -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 \ No newline at end of file diff --git a/src/mail_order_bot/task_handler/processor.py b/src/mail_order_bot/attachment_handler/processor.py similarity index 100% rename from src/mail_order_bot/task_handler/processor.py rename to src/mail_order_bot/attachment_handler/processor.py diff --git a/src/mail_order_bot/email_client/client.py b/src/mail_order_bot/email_client/client.py index 54c1045..c5e1065 100644 --- a/src/mail_order_bot/email_client/client.py +++ b/src/mail_order_bot/email_client/client.py @@ -40,26 +40,9 @@ class EmailClient: 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 @@ -68,25 +51,27 @@ class EmailClient: self.smtp_port = smtp_port self.imap_conn = None - def _connect_imap(self): + def connect(self): """Установить 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 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: - """ - Декодировать заголовок письма. - - Args: - header_value: Значение заголовка - - Returns: - Декодированная строка - """ + """Декодировать заголовок письма.""" if header_value is None: return "" - + decoded_parts = [] for part, encoding in decode_header(header_value): if isinstance(part, bytes): @@ -102,32 +87,9 @@ class EmailClient: 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", "")) @@ -159,56 +121,58 @@ class EmailClient: return match.group(1) 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]: - """ - Извлечь вложения из письма. - - 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) - ) - + 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() + + def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True, mark_as_read: bool = True) -> List[ + EmailMessage]: + """Получить список новых электронных писем.""" + self.connect() + + # Выбираем папку + self.imap_conn.select(folder, readonly=False) + + # Ищем письма + search_criteria = "(UNSEEN)" if only_unseen else "ALL" + status, messages = self.imap_conn.search(None, search_criteria) + + if status != "OK": + return [] + + email_ids = messages[0].split() + return email_ids + + + + + + def get_emails(self, folder: str = "INBOX", only_unseen: bool = True, mark_as_read: bool = True) -> List[EmailMessage]: + """Получить список новых электронных писем.""" + self.connect() # Выбираем папку self.imap_conn.select(folder, readonly=False) @@ -285,22 +249,8 @@ class EmailClient: 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: Список адресов для скрытой копии (необязательно) - """ + def send_email(self, message: EmailMessage, to_addr: str, cc: Optional[List[str]] = None, bcc: Optional[List[str]] = None): + """Отправить электронное письмо""" # Создаем multipart сообщение msg = MIMEMultipart() msg['From'] = self.email @@ -336,22 +286,13 @@ class EmailClient: 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): """Поддержка контекстного менеджера""" + self.connect() return self def __exit__(self, exc_type, exc_val, exc_tb): """Поддержка контекстного менеджера""" - self.close() + self.disconnect() diff --git a/src/mail_order_bot/email_handler/__init__.py b/src/mail_order_bot/email_handler/__init__.py new file mode 100644 index 0000000..920cb04 --- /dev/null +++ b/src/mail_order_bot/email_handler/__init__.py @@ -0,0 +1 @@ +from .email_processor import EmailProcessor \ No newline at end of file diff --git a/src/mail_order_bot/email_handler/context.py b/src/mail_order_bot/email_handler/context.py new file mode 100644 index 0000000..9e9dd86 --- /dev/null +++ b/src/mail_order_bot/email_handler/context.py @@ -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("Новый контекст установлен") \ No newline at end of file diff --git a/src/mail_order_bot/email_handler/email_processor.py b/src/mail_order_bot/email_handler/email_processor.py new file mode 100644 index 0000000..cb5cb2c --- /dev/null +++ b/src/mail_order_bot/email_handler/email_processor.py @@ -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): diff --git a/src/mail_order_bot/email_handler/handlers/__init__.py b/src/mail_order_bot/email_handler/handlers/__init__.py new file mode 100644 index 0000000..d2de743 --- /dev/null +++ b/src/mail_order_bot/email_handler/handlers/__init__.py @@ -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 \ No newline at end of file diff --git a/src/mail_order_bot/task_handler/handlers/abcp_clients/check_stock.py b/src/mail_order_bot/email_handler/handlers/abcp_clients/check_stock.py similarity index 88% rename from src/mail_order_bot/task_handler/handlers/abcp_clients/check_stock.py rename to src/mail_order_bot/email_handler/handlers/abcp_clients/check_stock.py index ae8b1aa..69475ed 100644 --- a/src/mail_order_bot/task_handler/handlers/abcp_clients/check_stock.py +++ b/src/mail_order_bot/email_handler/handlers/abcp_clients/check_stock.py @@ -1,6 +1,7 @@ import random 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__) diff --git a/src/mail_order_bot/task_handler/handlers/abcp_clients/create_order.py b/src/mail_order_bot/email_handler/handlers/abcp_clients/create_order.py similarity index 89% rename from src/mail_order_bot/task_handler/handlers/abcp_clients/create_order.py rename to src/mail_order_bot/email_handler/handlers/abcp_clients/create_order.py index 2f233e6..945484d 100644 --- a/src/mail_order_bot/task_handler/handlers/abcp_clients/create_order.py +++ b/src/mail_order_bot/email_handler/handlers/abcp_clients/create_order.py @@ -1,7 +1,7 @@ import logging import requests -from mail_order_bot.task_handler.handlers.abstract_task import AbstractTask -from mail_order_bot.task_handler.order.auto_part_order import OrderStatus +from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask +from mail_order_bot.email_handler.order.auto_part_order import OrderStatus logger = logging.getLogger(__name__) diff --git a/src/mail_order_bot/email_handler/handlers/abstract_task.py b/src/mail_order_bot/email_handler/handlers/abstract_task.py new file mode 100644 index 0000000..0ddd22b --- /dev/null +++ b/src/mail_order_bot/email_handler/handlers/abstract_task.py @@ -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 + diff --git a/src/mail_order_bot/email_handler/handlers/email/send_email.py b/src/mail_order_bot/email_handler/handlers/email/send_email.py new file mode 100644 index 0000000..31a9093 --- /dev/null +++ b/src/mail_order_bot/email_handler/handlers/email/send_email.py @@ -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)}") + + diff --git a/src/mail_order_bot/email_handler/handlers/excel_parcers/order_parcer_basic.py b/src/mail_order_bot/email_handler/handlers/excel_parcers/order_parcer_basic.py new file mode 100644 index 0000000..c6f1548 --- /dev/null +++ b/src/mail_order_bot/email_handler/handlers/excel_parcers/order_parcer_basic.py @@ -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 diff --git a/src/mail_order_bot/email_handler/handlers/excel_parcers/sheet_parcer.py b/src/mail_order_bot/email_handler/handlers/excel_parcers/sheet_parcer.py new file mode 100644 index 0000000..c6f1548 --- /dev/null +++ b/src/mail_order_bot/email_handler/handlers/excel_parcers/sheet_parcer.py @@ -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 diff --git a/src/mail_order_bot/task_handler/handlers/notifications/test_notifier.py b/src/mail_order_bot/email_handler/handlers/notifications/test_notifier.py similarity index 85% rename from src/mail_order_bot/task_handler/handlers/notifications/test_notifier.py rename to src/mail_order_bot/email_handler/handlers/notifications/test_notifier.py index 055fb12..9c47674 100644 --- a/src/mail_order_bot/task_handler/handlers/notifications/test_notifier.py +++ b/src/mail_order_bot/email_handler/handlers/notifications/test_notifier.py @@ -1,6 +1,6 @@ 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__) diff --git a/src/mail_order_bot/task_handler/handlers/validators/price_quantity_ckecker.py b/src/mail_order_bot/email_handler/handlers/validators/price_quantity_ckecker.py similarity index 91% rename from src/mail_order_bot/task_handler/handlers/validators/price_quantity_ckecker.py rename to src/mail_order_bot/email_handler/handlers/validators/price_quantity_ckecker.py index 1954f3d..67377f3 100644 --- a/src/mail_order_bot/task_handler/handlers/validators/price_quantity_ckecker.py +++ b/src/mail_order_bot/email_handler/handlers/validators/price_quantity_ckecker.py @@ -1,7 +1,7 @@ import random import logging -from mail_order_bot.task_handler.handlers.abstract_task import AbstractTask -from mail_order_bot.task_handler.order.auto_part_order import OrderStatus +from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask +from mail_order_bot.email_handler.order.auto_part_order import OrderStatus from decimal import Decimal import random logger = logging.getLogger(__name__) diff --git a/src/mail_order_bot/email_handler/order/__init__.py b/src/mail_order_bot/email_handler/order/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mail_order_bot/email_handler/order/auto_part_order.py b/src/mail_order_bot/email_handler/order/auto_part_order.py new file mode 100644 index 0000000..24e0f91 --- /dev/null +++ b/src/mail_order_bot/email_handler/order/auto_part_order.py @@ -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) diff --git a/src/mail_order_bot/email_handler/order/auto_part_position.py b/src/mail_order_bot/email_handler/order/auto_part_position.py new file mode 100644 index 0000000..d43fab1 --- /dev/null +++ b/src/mail_order_bot/email_handler/order/auto_part_position.py @@ -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") + diff --git a/src/mail_order_bot/task_handler/__init__.py b/src/mail_order_bot/task_handler/__init__.py deleted file mode 100644 index fb90fb8..0000000 --- a/src/mail_order_bot/task_handler/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .processor import TaskProcessor \ No newline at end of file diff --git a/tests/abcp_api/test_abcp_api.py b/tests/abcp_api/test_abcp_api.py new file mode 100644 index 0000000..6dd900b --- /dev/null +++ b/tests/abcp_api/test_abcp_api.py @@ -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')) \ No newline at end of file diff --git a/tests/email_client/test_email_client.py b/tests/email_client/test_email_client.py index 09e346c..775c80b 100644 --- a/tests/email_client/test_email_client.py +++ b/tests/email_client/test_email_client.py @@ -26,4 +26,4 @@ if __name__ == "__main__": print(email.first_sender) print('--------------------------------') - email_client.close() \ No newline at end of file + email_client.disconnect() \ No newline at end of file diff --git a/tests/excel_processor/hanler_test.py b/tests/excel_processor/hanler_test.py index c3a53bf..ca7099d 100644 --- a/tests/excel_processor/hanler_test.py +++ b/tests/excel_processor/hanler_test.py @@ -1,7 +1,7 @@ import os import chardet # pip install chardet import traceback -from mail_order_bot.task_handler import TaskProcessor +from mail_order_bot.email_handler import EmailProcessor import datetime # установим рабочую директорию import os @@ -18,7 +18,7 @@ BASE_PATH = './files' from mail_order_bot.email_client import EmailMessage, EmailAttachment -processor = TaskProcessor("./configs") +processor = EmailProcessor("./configs") for provider_name in os.listdir(BASE_PATH): provider_folder = os.path.join(BASE_PATH, provider_name) diff --git a/tests/site/auth.py b/tests/site/auth.py new file mode 100644 index 0000000..bff9a3c --- /dev/null +++ b/tests/site/auth.py @@ -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 diff --git a/tests/site/test.html b/tests/site/test.html new file mode 100644 index 0000000..b66e19c --- /dev/null +++ b/tests/site/test.html @@ -0,0 +1 @@ +'\n\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n\nYou will be redirected to Robot Checker. Please enable Javascript in browser.
\n