no message

This commit is contained in:
2026-01-06 21:25:45 +03:00
parent 4cb8c9c88e
commit f96b0a076b
30 changed files with 931 additions and 393 deletions

View File

@@ -31,9 +31,6 @@ class AbcpProvider:
params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"} params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"}
return self._execute(path, method, params) return self._execute(path, method, params)
def save_order(self, order):
pass
def _execute(self, path, method="GET", params={}, data=None): def _execute(self, path, method="GET", params={}, data=None):
params["userlogin"] = self.login params["userlogin"] = self.login
params["userpsw"] = hashlib.md5(self.password.encode("utf-8")).hexdigest() params["userpsw"] = hashlib.md5(self.password.encode("utf-8")).hexdigest()

View File

@@ -1,4 +1,39 @@
# Настройки обработки ================================================================= # Настройки обработки =================================================================
clients:
lesha.spb@gmail.com:
enabled: true
client_id: 6148154 # Сейчас стоит айдишник Димы для тестовых заказов
pipeline:
- ExcelExtractor
- OrderExtractor
- DeliveryPeriodFromConfig
- StockSelector
- UpdateExcelFile
- SaveOrderToTelegram
- EmailReplyTask
excel:
sheet_name: 0
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Фирма"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
updatable_fields:
ordered_quantity: "Кол-во Поставщика"
ordered_price: "Цена Поставщика"
# Значение для хендлера DeliveryPeriodFromConfig
delivery_period: 100 # в часах
amtel.ru:
enabled: false
# Раздел с общими конфигурационными параметрами =============================== # Раздел с общими конфигурационными параметрами ===============================
update_interval: 1 update_interval: 1
@@ -36,10 +71,9 @@ log:
level: CRITICAL level: CRITICAL
formatter: telegram formatter: telegram
class: logging_telegram_handler.TelegramHandler class: logging_telegram_handler.TelegramHandler
chat_id: 211945135 chat_id: -1002960678041 #-1002960678041 #211945135
alias: "Mail order bot" alias: "Mail order bot"
# Логгеры # Логгеры
loggers: loggers:
'': '':

View File

@@ -1,17 +1,10 @@
#========================================= #=========================================
config: client: amtel.club
enabled: true
client_id: 6148154 # Сейчас стоит айдишник Димы, фактический id у amtel.club - 156799563
delivery_period: 100 # в часах
pipeline: excel:
-
#=========================================
pipeline:
# Настраиваем парсинг экселя
- handler: BasicExcelParser
config:
sheet_name: 0 sheet_name: 0
key_field: "Номер" key_field: "Номер"
mapping: mapping:
@@ -22,11 +15,18 @@ pipeline:
quantity: "Кол-во" quantity: "Кол-во"
total: "Сумма" total: "Сумма"
# Определяем логику обработки заказа (в данном случае все с локального склада) updatable_fields:
- handler: DeliveryPeriodLocalStore ordered_quantity: "Кол-во Поставщика"
ordered_price: "Цена Поставщика"
# Запрос остатков со склада #=========================================
- handler: APIGetStock pipeline:
- ExcelExtractor
- OrderExtractor
- DeliveryPeriodFromConfig
- StockSelector
- UpdateExcelFile
- SaveOrderToTelegram

View File

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

View File

@@ -75,8 +75,8 @@ class CredentialProvider:
Raises: Raises:
ValueError: Если учетные данные системной учетной записи не найдены ValueError: Если учетные данные системной учетной записи не найдены
""" """
login_key = f"{self.prefix}_LOGIN_{self.SYSTEM_ACCOUNT}" login_key = f"{self.prefix}_LOGIN"
password_key = f"{self.prefix}_PASSWORD_{self.SYSTEM_ACCOUNT}" password_key = f"{self.prefix}_PASSWORD"
login = os.getenv(login_key) login = os.getenv(login_key)
password = os.getenv(password_key) password = os.getenv(password_key)

View File

@@ -1,6 +1,6 @@
import re import re
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional, Union
from dataclasses import dataclass from dataclasses import dataclass
import email import email
from email import encoders from email import encoders
@@ -8,6 +8,7 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.header import decode_header from email.header import decode_header
from email.message import Message
import imaplib import imaplib
import smtplib import smtplib
@@ -106,3 +107,61 @@ class EmailClient:
decoded_parts.append(str(part)) decoded_parts.append(str(part))
return ''.join(decoded_parts) return ''.join(decoded_parts)
def send_email(self, message: Union[MIMEMultipart, MIMEText, Message]):
"""
Отправить подготовленное письмо через SMTP.
Args:
message: Подготовленное письмо (MIMEMultipart, MIMEText или email.message.Message)
Должно содержать заголовки From, To и Subject
"""
try:
# Извлекаем получателей из письма
recipients = []
# Основной получатель
to_header = message.get("To", "")
if to_header:
# Обрабатываем несколько адресов, разделенных запятыми
to_addresses = [addr.strip() for addr in to_header.split(",")]
recipients.extend(to_addresses)
# Копия
cc_header = message.get("Cc", "")
if cc_header:
cc_addresses = [addr.strip() for addr in cc_header.split(",")]
recipients.extend(cc_addresses)
# Скрытая копия
bcc_header = message.get("Bcc", "")
if bcc_header:
bcc_addresses = [addr.strip() for addr in bcc_header.split(",")]
recipients.extend(bcc_addresses)
if not recipients:
raise ValueError("Не указаны получатели письма (To, Cc или Bcc)")
# Извлекаем отправителя из письма или используем email из настроек
from_email = message.get("From", self.email)
# Подключаемся к SMTP серверу
with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
server.starttls()
server.login(self.email, self.password)
# Отправляем письмо
server.sendmail(
from_email,
recipients,
message.as_string()
)
logger.info(f"Письмо успешно отправлено получателям: {', '.join(recipients)}")
except smtplib.SMTPException as e:
logger.error(f"Ошибка SMTP при отправке письма: {str(e)}")
raise Exception(f"Ошибка SMTP: {str(e)}")
except Exception as e:
logger.error(f"Ошибка при отправке письма: {str(e)}")
raise Exception(f"Ошибка при отправке письма: {str(e)}")

View File

@@ -7,7 +7,7 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
from email_client import EmailClient from email_client import EmailClient
from email_processor import EmailProcessor from task_processor import TaskProcessor
from mail_order_bot.context import Context from mail_order_bot.context import Context
@@ -33,15 +33,21 @@ class MailOrderBot(ConfigManager):
self.context = Context() self.context = Context()
self.context.email_client = self.email_client self.context.email_client = self.email_client
# Обработчик писем # Обработчик писем
self.email_processor = EmailProcessor("./configs") #self.email_processor = TaskProcessor("./configs")
config = self.config.get("clients")
self.email_processor = TaskProcessor(config)
logger.warning("MailOrderBot инициализирован") logger.warning("MailOrderBot инициализирован")
def execute(self): def execute(self):
# Получить список айдишников письма # Получить список айдишников письма
unread_email_ids = self.email_client.get_emails_id(folder="spareparts")
logger.critical("Запуск приложения critical !!!!!!!!")
unread_email_ids = self.email_client.get_emails_id(folder="spareparts")
logger.info(f"Новых писем - {len(unread_email_ids)}") logger.info(f"Новых писем - {len(unread_email_ids)}")
# Обработать каждое письмо по идентификатору # Обработать каждое письмо по идентификатору
@@ -49,9 +55,8 @@ class MailOrderBot(ConfigManager):
logger.debug(f"==================================================") logger.debug(f"==================================================")
logger.debug(f"Обработка письма с идентификатором {email_id}") logger.debug(f"Обработка письма с идентификатором {email_id}")
# Получить письмо по идентификатору и запустить его обработку # Получить письмо по идентификатору и запустить его обработку
email = self.email_client.get_email(email_id) email = self.email_client.get_email(email_id, mark_as_read=False)
self.email_processor.process_email(email) self.email_processor.process_email(email)
pass
logger = logging.getLogger() logger = logging.getLogger()
@@ -65,9 +70,12 @@ async def main():
if __name__ == "__main__": if __name__ == "__main__":
print(os.getcwd()) print(os.getcwd())
if os.environ.get("APP_ENV") != "PRODUCTION": if os.environ.get("APP_ENV") != "PRODUCTION":
logger.warning("Non production environment") logger.warning("Non production environment")
load_dotenv() load_dotenv()
asyncio.run(main()) asyncio.run(main())

View File

@@ -1,8 +1,6 @@
from typing import List, Optional from typing import List, Optional
from .auto_part_position import AutoPartPosition, PositionStatus from .auto_part_position import AutoPartPosition, PositionStatus
from mail_order_bot.task_processor.handlers.abcp.stock_selector import StockSelector
from enum import Enum from enum import Enum
@@ -38,17 +36,6 @@ class AutoPartOrder:
def set_delivery_period(self, delivery_period: int) -> None: def set_delivery_period(self, delivery_period: int) -> None:
self.delivery_period = delivery_period self.delivery_period = delivery_period
def fill_from_local_supplier(self) -> None:
"""
Выбирает оптимального поставщика для всех позиций заказа.
Предполагается, что остатки уже получены и обработаны.
"""
for position in self.positions:
selector = StockSelector(position, self.delivery_period)
selector.select_optimal_supplier()
def check_order(self, config) -> None: def check_order(self, config) -> None:
""" Проверяет заказ на возможность исполнения""" """ Проверяет заказ на возможность исполнения"""
# 1. Проверка общего количества отказов # 1. Проверка общего количества отказов

View File

@@ -21,23 +21,22 @@ class AutoPartPosition:
Унифицированная модель позиции для заказа. Унифицированная модель позиции для заказа.
Все контрагенты приводятся к этой структуре. Все контрагенты приводятся к этой структуре.
""" """
DISTRIBUTOR_ID = 1577730 # ID локального склада
sku: str # Артикул товара sku: str # Артикул товара
manufacturer: str # Производитель manufacturer: str # Производитель
requested_price: Decimal # Цена за единицу asking_price: Decimal # Цена за единицу
requested_quantity: int # Количество asking_quantity: int # Количество
total: Decimal = 0 # Общая сумма total: Decimal = 0 # Общая сумма
name: str = "" # Наименование name: str = "" # Наименование
order_delivery_period: int = 0
order_quantity: int = 0 # Количество для заказа
order_price: Decimal = Decimal('0.0') # Цена в заказе
order_item: Dict[str, Any] = field(default_factory=dict)
stock: List[Dict[str, Any]] = None order_item: Dict[str, Any] = field(default_factory=dict)
order_price: Decimal = Decimal('0.0') # Цена в заказе
order_quantity: int = 0 # Количество для заказа
order_delivery_period: int = 0
profit: Decimal = Decimal('0.0')
additional_attrs: Dict[str, Any] = field(default_factory=dict) additional_attrs: Dict[str, Any] = field(default_factory=dict)
status: PositionStatus = PositionStatus.NEW status: PositionStatus = PositionStatus.NEW
@@ -45,65 +44,30 @@ class AutoPartPosition:
def __post_init__(self): def __post_init__(self):
"""Валидация после инициализации""" """Валидация после инициализации"""
if self.requested_quantity < 0: if self.asking_quantity < 0:
raise ValueError(f"Количество не может быть отрицательным: {self.requested_quantity}") raise ValueError(f"Количество не может быть отрицательным: {self.asking_quantity}")
if self.requested_price < 0: if self.asking_price < 0:
raise ValueError(f"Цена не может быть отрицательной: {self.requested_price}") raise ValueError(f"Цена не может быть отрицательной: {self.asking_price}")
def set_stock(self, stock): def set_order_item(self, order_item):
if stock.get("success"): # Запоминаем всю позицию
self.stock = stock["data"] self.order_item = order_item
if len(self.stock):
self.status = PositionStatus.STOCK_RECIEVED
else:
self.status = PositionStatus.NO_AVAILABLE_STOCK
else:
self.status = PositionStatus.STOCK_FAILED
def set_order_item(self): # ---===Устанавливаем конкретные значения по параметрам заказа===---
"""Выбирает позицию для заказа""" # Берем максимально доступное значение со склада, но не больше чем в заказе.
if self.status == PositionStatus.STOCK_RECIEVED: self.order_quantity = min(self.order_item.get("availability"), self.asking_quantity)
available_distributors = self.stock
# BR-1. Отсекаем склады для заказов из наличия (только локальный склад) # Продаем по цене, которая была заказана
if self.order_delivery_period == 0: self.order_price = self.asking_price
available_distributors = self._filter_only_local_storage(available_distributors)
# BR-2. Цена не должна превышать цену из заказа # Устанавливаем актуальный срок доставки
available_distributors = self._filter_proper_price(available_distributors) self.order_delivery_period = self.order_item.get("deliveryPeriod")
# BR-3. Срок доставки не должен превышать ожидаемый # ФИксируем профит. Для инфо/отчетности
available_distributors = self._filter_proper_delivery_time(available_distributors) self.profit = (self.asking_price - Decimal(self.order_item.get("price"))) * self.order_quantity
# BR-4. Без отрицательных остатков # Устанавливаем статус
available_distributors = self._filter_proper_availability(available_distributors)
# Приоритет на склады с полным стоком
# BR-5. Сначала оборачиваем локальный склад, потом удаленные
# BR-6. Выбираем цену максимально близкую к цене из заказа (максимальная)
available_distributors.sort(key=lambda item: Decimal(item["price"]), reverse=True)
if len(available_distributors):
self.order_item = self.stock[0]
self.status = PositionStatus.READY self.status = PositionStatus.READY
else:
self.status = PositionStatus.NO_AVAILABLE_STOCK
def _filter_only_local_storage(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Фильтрует только локальные склады"""
return [item for item in distributors if item["distributorId"] == self.DISTRIBUTOR_ID]
def _filter_proper_delivery_time(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Фильтрует склады по сроку доставки"""
return [item for item in distributors if item["deliveryPeriod"] <= self.order_delivery_period]
def _filter_proper_price(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Фильтрует склады по цене (убирает дорогие)"""
return [item for item in distributors if Decimal(item["price"]) <= self.requested_price]
def _filter_proper_availability(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Фильтрует склады с положительным остатком"""
return [item for item in distributors if Decimal(item["availability"]) > 0]

View File

@@ -5,6 +5,7 @@ from decimal import Decimal
from io import BytesIO from io import BytesIO
from mail_order_bot.order import AutoPartPosition from mail_order_bot.order import AutoPartPosition
from mail_order_bot.order import AutoPartOrder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -15,11 +16,13 @@ class OrderParser:
Подходит для большинства стандартных случаев. Подходит для большинства стандартных случаев.
""" """
def __init__(self, mapping, delivery_period): def __init__(self, mapping, delivery_period, client_id):
self.mapping = mapping self.mapping = mapping
self.delivery_period = delivery_period self.delivery_period = delivery_period
self.client_id = client_id
def parse(self, order, df): def parse(self, df):
order = AutoPartOrder(self.client_id)
# Парсим строки # Парсим строки
positions = [] positions = []
for idx, row in df.iterrows(): for idx, row in df.iterrows():
@@ -63,8 +66,8 @@ class OrderParser:
sku=str(row[mapping['article']]).strip(), sku=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer', "")]).strip(), manufacturer=str(row[mapping.get('manufacturer', "")]).strip(),
name=name, name=name,
requested_price=price, asking_price=price,
requested_quantity=quantity, asking_quantity=quantity,
total=total, total=total,
order_delivery_period=self.delivery_period, order_delivery_period=self.delivery_period,
additional_attrs=self._extract_additional_attrs(row, mapping) additional_attrs=self._extract_additional_attrs(row, mapping)

View File

@@ -9,9 +9,10 @@ class AbstractTask():
""" """
Абстрактный базовый класс для всех хэндлеров. Абстрактный базовый класс для всех хэндлеров.
""" """
def __init__(self, config: Dict[str, Any]={}) -> None: def __init__(self) -> None:
self.context = Context() self.context = Context()
self.config = config #self.config = config
self.config = self.context.data.get("config", {})
@abstractmethod @abstractmethod
def do(self) -> None: def do(self) -> None:

View File

@@ -1,10 +1,18 @@
from .attachment_handler.attachment_handler import AttachmentHandler from .attachment_handler.attachment_handler import AttachmentHandler
from .excel_parcers.order_parcer_basic import BasicExcelParser from .abcp.api_get_stock import APIGetStock
from .destination_time.local_store import DeliveryPeriodLocalStore from .destination_time.local_store import DeliveryPeriodLocalStore
from .destination_time.from_config import DeliveryPeriodFromConfig
from .notifications.test_notifier import TestNotifier from .notifications.test_notifier import TestNotifier
from .excel_parcers.excel_extractor import ExcelExtractor
from .excel_parcers.order_extractor import OrderExtractor
from .abcp.api_save_order import SaveOrderToTelegram
from .stock_selectors.stock_selector import StockSelector
from .excel_parcers.update_excel_file import UpdateExcelFile
from .email.send_email import EmailReplyTask

View File

@@ -1,8 +1,15 @@
"""
Перебирает аттачменты
Для каждого ордера в аттачменте перебирает позиции
Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/
Возможно логику выбора позиции надо вынести из позиции, но пока так
"""
import logging import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.abcp_api.abcp_provider import AbcpProvider from mail_order_bot.abcp_api.abcp_provider import AbcpProvider
from mail_order_bot.credential_provider import CredentialProvider from mail_order_bot.credential_provider import CredentialProvider
from mail_order_bot.order.auto_part_order import OrderStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,17 +24,19 @@ class APIGetStock(AbstractTask):
self.client_provider = AbcpProvider(login=client_login, password=client_password) self.client_provider = AbcpProvider(login=client_login, password=client_password)
def do(self) -> None: def do(self) -> None:
attachments = self.context.data.get("attachments", []) attachments = self.context.data.get("attachments", [])
for attachment in attachments: for attachment in attachments:
order = attachment["order"]
order = attachment.get("order", None)
for position in order.positions: for position in order.positions:
# Получаем остатки из-под учетной записи клиента # Получаем остатки из-под учетной записи клиента
client_stock = self.client_provider.get_stock(position.sku, position.manufacturer) client_stock = self.client_provider.get_stock(position.sku, position.manufacturer)
position.set_order_item(client_stock)
position.set_stock(client_stock) #position.set_order_item()
position.set_order_item()
logger.info(f"Получены позиции со склада для файла {attachment.get('name', "no name")}") logger.info(f"Получены позиции со склада для файла {attachment.get('name', "no name")}")
def get_stock(self, sku: str, manufacturer: str) -> int: def get_stock(self, sku: str, manufacturer: str) -> int:
return self.client_provider.get_stock(sku, manufacturer) return self.client_provider.get_stock(sku, manufacturer)

View File

@@ -0,0 +1,60 @@
"""
Перебирает аттачменты
Для каждого ордера в аттачменте перебирает позиции
Для каждой позиции запрашивает остатки и запускает процедуру выбора оптмальной позиции со склада/
Возможно логику выбора позиции надо вынести из позиции, но пока так
"""
import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.abcp_api.abcp_provider import AbcpProvider
from mail_order_bot.credential_provider import CredentialProvider
from mail_order_bot.telegram.client import TelegramClient
logger = logging.getLogger(__name__)
class SaveOrderToTelegram(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
client = TelegramClient()
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
order = attachment["order"]
positions = order.positions
message = "\nОбработка заказа {указать название контрагента}\n"
message += f"\nПолучено {len(positions)} позиций от {order.client_id}\n"
message += "===============================\n"
for position in positions:
message += f"{position.sku} - {position.manufacturer} - {position.name} \n"
message += f"{position.asking_quantity} x {position.asking_price} = {position.total} \n"
rejected = position.asking_quantity - position.order_quantity
if position.order_quantity == 0:
message += f"Отказ\n"
elif rejected:
message += (f"Отказ: {rejected}, запрошено, {position.asking_quantity}, "
f"отгружено {position.order_quantity}, профит {position.profit}\n")
else:
message += f"Позиция отгружена полностью, профит {position.profit}\n"
message += "-------------------------------\n"
result = client.send_message(message)
# Отправка экселя в телеграм
excel = attachment["excel"]
file = excel.get_file_bytes()
client.send_document(
document=file,
filename="document.xlsx"
)
# logger.critical(message)
#===============================

View File

@@ -1,95 +0,0 @@
import sys
from turtle import position
from typing import Dict, Any, List, Optional
from decimal import Decimal
from mail_order_bot.email_processor.order.auto_part_position import AutoPartPosition, PositionStatus
logger = __import__('logging').getLogger(__name__)
"""
1. Получить 2 вида складских остатков
2. Добавляем цену закупки
3. Применяем правила фильтрации
- можно указать какие правила применены
4. Выбираем цену по профиту
- можно указать ограничения
"""
class StockSelector:
"""
Класс для выбора оптимального поставщика для позиции заказа.
Выполняет фильтрацию складов и выбор наилучшего варианта.
"""
DISTRIBUTOR_ID = "1577730" # ID локального склада
def __init__(self, position: AutoPartPosition, delivery_period: int = 0):
"""
Инициализация StockSelector.
Args:
position: Позиция заказа
delivery_period: Период доставки в днях
"""
self.position = position
self.delivery_period = delivery_period
self.client_stock = None
self.system_stock = None
def filter_stock(self, client_stock) -> None:
"""
Обрабатывает результат запроса остатков из-под учетной записи клиента и обновляет позицию.
Args:
client_stock: Результат запроса остатков от API из-под учетной записи клиента
system_stock: Результат запроса остатков от API из-под системной учетной записи
"""
self.client_stock = client_stock
if client_stock["success"]:
available_distributors = client_stock["data"]
# Для доставки только с локального склада сперва убираем все остальные склады
if self.delivery_period == 0:
available_distributors = self._filter_only_local_storage(available_distributors)
# Отбираем склады по сроку доставки
available_distributors = self._filter_proper_delivery_time(available_distributors, self.delivery_period)
# Убираем дорогие склады с ценой выше запрошенной
available_distributors = self._filter_proper_price(available_distributors)
# Убираем отрицательные остатки
available_distributors = self._filter_proper_availability(available_distributors)
# Добавляем данные о закупочных ценах
available_distributors = self._set_system_price(available_distributors)
# Сортируем по цене
available_distributors.sort(key=lambda item: Decimal(item["price"]), reverse=True)
return available_distributors
else:
return None
def _filter_only_local_storage(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Фильтрует только локальные склады"""
return [item for item in distributors if str(item["distributorId"]) == self.DISTRIBUTOR_ID]
def _filter_proper_delivery_time(self, distributors: List[Dict[str, Any]], delivery_period: int) -> List[Dict[str, Any]]:
"""Фильтрует склады по сроку доставки"""
return [item for item in distributors if item["deliveryPeriod"] <= delivery_period]
def _filter_proper_price(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Фильтрует склады по цене (убирает дорогие)"""
return [item for item in distributors if Decimal(item["price"]) <= self.position.requested_price]
def _filter_proper_availability(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Фильтрует склады с положительным остатком"""
return [item for item in distributors if Decimal(item["availability"]) > 0]

View File

@@ -1,8 +1,12 @@
"""
Извлекает вложения из имейла и складывает их в контекст
Использует EmailUtils
"""
import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils from mail_order_bot.email_client.utils import EmailUtils
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AttachmentHandler(AbstractTask): class AttachmentHandler(AbstractTask):
@@ -10,12 +14,16 @@ class AttachmentHandler(AbstractTask):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def do(self) -> None: def do(self) -> None:
try:
email = self.context.data["email"] email = self.context.data["email"]
attachments = EmailUtils.extract_attachments(email) attachments = EmailUtils.extract_attachments(email)
except Exception as e:
logger.error(e)
self.context.data["error"] = str(e)
else:
self.context.data["attachments"] = attachments self.context.data["attachments"] = attachments
logger.debug(f"AttachmentHandler отработал, извлек вложений: {len(attachments)} ") logger.info(f"Извлечено вложений: {len(attachments)} ")

View File

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

View File

@@ -1,3 +1,7 @@
"""
Парсер срока доставки из темы письма
"""
from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.task_processor.abstract_task import AbstractTask
import logging import logging

View File

@@ -1,7 +1,12 @@
from mail_order_bot.task_processor.abstract_task import AbstractTask """
Устанавливает хардкодом период доставки 0, что означает использование локального склада.
Для заказчиков, которые должны всегда получать заказ только из наличия
"""
import logging import logging
from mail_order_bot.task_processor.abstract_task import AbstractTask
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DeliveryPeriodLocalStore(AbstractTask): class DeliveryPeriodLocalStore(AbstractTask):

View File

@@ -21,9 +21,14 @@ class EmailParcer(AbstractTask):
self.context.data["email_body"] = email_body self.context.data["email_body"] = email_body
# todo при переводе на основной ящик переделать на другую функцию # todo при переводе на основной ящик переделать на другую функцию
email_from = EmailUtils.extract_first_sender(email_body) header_from = EmailUtils.extract_header(email, "From")
email_from = EmailUtils.extract_email(header_from)
#email_from = EmailUtils.extract_first_sender(email_body)
self.context.data["email_from"] = email_from self.context.data["email_from"] = email_from
email_from_domain = EmailUtils.extract_domain(email_from)
self.context.data["email_from_domain"] = email_from_domain
email_subj = EmailUtils.extract_header(email, "subj") email_subj = EmailUtils.extract_header(email, "subj")
self.context.data["email_subj"] = email_subj self.context.data["email_subj"] = email_subj

View File

@@ -8,134 +8,67 @@ from email import encoders
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import os import os
from mail_order_bot.task_processor.abstract_task import AbstractTask
class AbstractTask(ABC):
"""Базовый класс для задач"""
def __init__(self, context, config):
self.context = context
self.config = config
@abstractmethod
def do(self):
"""Метод для реализации в подклассах"""
pass
class EmailReplyTask(AbstractTask): class EmailReplyTask(AbstractTask):
"""Класс для ответа РЅР° электронные РїРёСЃСЊРјР°""" """Формирует ответ на входящее письмо с запросом на заказ°"""
EMAIl = "zosimovaa@yandex.ru" #"noreply@zapchastiya.ru"
def do(self): def do(self):
"""
Отправляет ответ на входящее письмо
Ожидает в self.context: email = self.context.data.get("email")
- 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") if not email:
attachment_path = self.context.get("attacnment") raise ValueError("В контексте нет входящего сообщения")
if not incoming_message: email_from = self.context.data.get("email_from")
raise ValueError("Рcontext не найдено письмо (message)") if not email_from:
raise ValueError("В контексте не определен адрес отправителя")
# Получаем адрес отправителя входящего письма
from_addr = incoming_message.get("From")
if not from_addr:
raise ValueError("Входящее письмо не содержит адреса отправителя")
# Создаем ответное письмо
reply_message = MIMEMultipart() reply_message = MIMEMultipart()
# Заголовки ответного письма email_subj = self.context.data.get("email_subj")
reply_message["From"] = self.config.get("from_email", "noreply@example.com")
reply_message["To"] = from_addr reply_message["From"] = self.EMAIl
reply_message["Cc"] = self.config.get("reply_to", "") reply_message["To"] = email_from
reply_message["Subject"] = f"Re: {incoming_message.get('Subject', '')}" #reply_message["Cc"] = self.config.get("reply_to", "")
reply_message["Subject"] = f"Re: {email_subj}"
reply_message["Date"] = formatdate(localtime=True) reply_message["Date"] = formatdate(localtime=True)
# Тело РїРёСЃСЊРјР° body = "Автоматический ответ на создание заказа"
body = "Ваш заказ создан"
reply_message.attach(MIMEText(body, "plain", "utf-8")) reply_message.attach(MIMEText(body, "plain", "utf-8"))
# Добавляем вложение если указан путь к файлу attachments = self.context.data.get("attachments")
if attachment_path and os.path.isfile(attachment_path): for attachment in attachments:
self._attach_file(reply_message, attachment_path) self._attach_file(reply_message, attachment)
# Отправляем письмо self.context.email_client.send_email(reply_message)
self._send_email(reply_message, from_addr)
def _attach_file(self, message, file_path): def _attach_file(self, reply_message, attachment):
""" """
Добавляет файл в качестве вложения к письму
Args: Args:
message: MIMEMultipart объект message: MIMEMultipart
file_path: путь к файлу для вложения file_path:
""" """
try: try:
with open(file_path, "rb") as attachment:
part = MIMEBase("application", "octet-stream") part = MIMEBase("application", "octet-stream")
part.set_payload(attachment.read())
excel_file = attachment["excel"]
excel_file_bytes = excel_file.get_file_bytes()
part.set_payload(excel_file_bytes.read())
encoders.encode_base64(part) encoders.encode_base64(part)
file_name = os.path.basename(file_path) file_name = attachment["name"][0]
part.add_header( part.add_header(
"Content-Disposition", "Content-Disposition",
f"attachment; filename= {file_name}" f"attachment; filename= {file_name}"
) )
message.attach(part) reply_message.attach(part)
except FileNotFoundError:
raise FileNotFoundError(f"Файл не найден: {file_path}")
except Exception as e: except Exception as e:
raise Exception(f"Ошибка РїСЂРё добавлении вложения: {str(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

@@ -4,6 +4,8 @@ from io import BytesIO
#from mail_order_bot.task_processor.handlers.order_position import OrderPosition #from mail_order_bot.task_processor.handlers.order_position import OrderPosition
from mail_order_bot.task_processor.abstract_task import AbstractTask from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,6 +15,7 @@ class ExcelExtractor(AbstractTask):
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
def do(self) -> None: def do(self) -> None:
@@ -22,13 +25,11 @@ class ExcelExtractor(AbstractTask):
for attachment in attachments: for attachment in attachments:
file_bytes = BytesIO(attachment['bytes']) file_bytes = BytesIO(attachment['bytes'])
# Получаем все данные из файла excel_file = ExcelFileParcer(file_bytes, self.excel_config)
sheet_name = self.config.get("sheet_name", 0) attachment["excel"] = excel_file
try:
attachment["sheet"] = pd.read_excel(file_bytes, sheet_name=sheet_name, header=None)
except Exception as e:
attachment["sheet"] = None
logger.warning("Не удалось распарсить значение файла")

View File

@@ -0,0 +1,42 @@
import logging
import pandas as pd
from io import BytesIO
from mail_order_bot.parsers.order_parcer import OrderParser
from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.parsers.excel_parcer import ExcelFileParcer
logger = logging.getLogger(__name__)
class OrderExtractor(AbstractTask):
"""
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.excel_config = self.config.get("excel", {})
def do(self) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
delivery_period = attachment.get("delivery_period", 0)
mapping = self.excel_config.get("mapping")
excel_file = attachment.get("excel")
client_id = self.config.get("client_id")
order_parcer = OrderParser(mapping, delivery_period, client_id)
order_dataframe = excel_file.get_order_rows()
order = order_parcer.parse(order_dataframe)
attachment["order"] = order

View File

@@ -27,11 +27,12 @@ class UpdateExcelFile(AbstractTask):
for attachment in attachments: for attachment in attachments:
excel_file = attachment.get("excel") excel_file = attachment.get("excel")
order = attachment.get("order") order = attachment.get("order")
config = self.context.data.get("excel", {}) config = self.context.data.get("config", {})
updatable_fields = config.get("updatable_fields", {}) excel_config = config.get("excel", {})
updatable_fields = excel_config.get("updatable_fields", {})
for position in order.positions: for position in order.positions:
if position.status == PositionStatus.READY:
sku = position.sku sku = position.sku
manufacturer = position.manufacturer manufacturer = position.manufacturer
@@ -39,13 +40,12 @@ class UpdateExcelFile(AbstractTask):
if key == "ordered_quantity": if key == "ordered_quantity":
column = value column = value
value = position.ordered_quantity value = position.order_quantity
excel_file.set_value(sku, manufacturer, column, value) excel_file.set_value(sku, manufacturer, column, value)
if key == "ordered_price": if key == "ordered_price":
column = value column = value
value = position.ordered_price value = position.order_price
excel_file.set_value(sku, manufacturer, column, value) excel_file.set_value(sku, manufacturer, column, value)
pass

View File

@@ -1,6 +1,6 @@
import logging import logging
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask from mail_order_bot.task_processor.abstract_task import AbstractTask
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -12,4 +12,4 @@ class TestNotifier(AbstractTask):
print(f"\nПолучено {len(positions)} позиций от {self.context["client"]}:") print(f"\nПолучено {len(positions)} позиций от {self.context["client"]}:")
for pos in positions: # Первые 5 for pos in positions: # Первые 5
print(f" - {pos.sku}: {pos.name} " print(f" - {pos.sku}: {pos.name} "
f"({pos.requested_quantity} x {pos.requested_price} = {pos.total})") f"({pos.asking_quantity} x {pos.asking_price} = {pos.total})")

View File

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

View File

@@ -1,7 +1,7 @@
import os import os
import yaml import yaml
import logging import logging
from typing import Dict, Any from typing import Dict, Any, List
from pathlib import Path from pathlib import Path
import threading import threading
from mail_order_bot.context import Context from mail_order_bot.context import Context
@@ -9,8 +9,8 @@ from mail_order_bot.email_client.utils import EmailUtils
from enum import Enum from enum import Enum
from mail_order_bot.task_processor.handlers import * from mail_order_bot.task_processor.handlers import *
from mail_order_bot.task_processor.handlers import AttachmentHandler from mail_order_bot.task_processor.handlers import AttachmentHandler
from mail_order_bot.task_processor.handlers.email.email_parcer import EmailParcer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,52 +25,42 @@ class RequestStatus(Enum):
class TaskProcessor: class TaskProcessor:
def __init__(self, configs_path: str): #def __init__(self, configs_path: str):
def __init__(self, config: Dict[str, Any]):
super().__init__() super().__init__()
self.context = Context() self.context = Context()
self.configs_path = configs_path #self.configs_path = configs_path
self.config = config
self.status = RequestStatus.NEW self.status = RequestStatus.NEW
def process_email(self, email): def process_email(self, email):
# Очистить контекст # Очистить контекст и запушить туда письмо
self.context.clear() self.context.clear()
# Сохранить письмо в контекст
self.context.data["email"] = email self.context.data["email"] = email
# Определить клиента # Парсинг письма
email_body = EmailUtils.extract_body(email) email_parcer = EmailParcer()
self.context.data["email_body"] = email_body email_parcer.do()
email_from = EmailUtils.extract_first_sender(email_body)
self.context.data["email_from"] = email_from
email_subj = EmailUtils.extract_header(email, "subj")
self.context.data["email_subj"] = email_subj
client = EmailUtils.extract_domain(email_from)
self.context.data["client"] = client
email_from = self.context.data.get("email_from")
#client = EmailUtils.extract_domain(email_from)
#self.context.data["client"] = client
try: try:
# Определить конфиг для пайплайна # Определить конфиг для пайплайна
config = self._load_config(client) config = self._load_config(email_from)
self.context.data["config"] = config self.context.data["config"] = config
# Обработка вложений
attachments_handler_task = AttachmentHandler()
attachments_handler_task.do()
# Запустить обработку пайплайна # Запустить обработку пайплайна
for stage in config["pipeline"]: pipeline = config["pipeline"]
handler_name = stage["handler"] for stage in pipeline:
handler_name = stage
logger.info(f"Processing handler: {handler_name}") logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name](stage.get("config", None)) task = globals()[handler_name]()
task.do() task.do()
except FileNotFoundError: except FileNotFoundError:
logger.error(f"Конфиг для клиента {client} не найден") logger.error(f"Конфиг для клиента {email_from} не найден")
for attachment in self.context.data["attachments"]: for attachment in self.context.data["attachments"]:
print(attachment["order"].__dict__) print(attachment["order"].__dict__)
@@ -78,9 +68,16 @@ class TaskProcessor:
# logger.error(f"Произошла другая ошибка: {e}") # logger.error(f"Произошла другая ошибка: {e}")
def _load_config(self, client) -> Dict[str, Any]: def _load_config(self, email_from) -> Dict[str, Any]:
"""Загружает конфигурацию из YAML или JSON""" if email_from in self.config:
return self.config[email_from]
path = os.path.join(self.configs_path, client + '.yml') email_from_domain = EmailUtils.extract_domain(email_from)
with open(path, 'r', encoding='utf-8') as f: if email_from_domain in self.config:
return yaml.safe_load(f) return self.config[email_from_domain]
raise FileNotFoundError
#path = os.path.join(self.configs_path, client + '.yml')
#with open(path, 'r', encoding='utf-8') as f:
# return yaml.safe_load(f)

View File

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

View File

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