no message

This commit is contained in:
2025-12-23 23:12:43 +03:00
parent b26485c5cc
commit 55ba627f6b
35 changed files with 308 additions and 133 deletions

View File

@@ -0,0 +1,5 @@
"""
Классы для работы с API платформы ABCP
"""
from .abcp_provider import AbcpProvider

View File

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

View File

@@ -1,3 +1,13 @@
#=========================================
config:
pipeline:
-
#=========================================
pipeline:
# Настраиваем парсинг экселя
- handler: BasicExcelParser

View File

@@ -1,2 +1,3 @@
from .client import EmailClient
from .objects import EmailMessage, EmailAttachment
from .utils import EmailUtils

View File

@@ -11,6 +11,10 @@ from email.header import decode_header
import imaplib
import smtplib
import logging
logger = logging.getLogger(__name__)
# from .objects import EmailMessage, EmailAttachment

View File

@@ -1,16 +1,11 @@
from email.header import decode_header, make_header
import re
from datetime import datetime
from typing import List, Optional
from dataclasses import dataclass
from typing import List
import email
from email import encoders
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.header import decode_header
import imaplib
import smtplib
from email.header import make_header, decode_header
import logging
logger = logging.getLogger(__name__)
from .objects import EmailMessage, EmailAttachment
@@ -99,8 +94,3 @@ class EmailUtils:
return None
# убираем пробелы по краям и берём часть после '@'
return email_message.strip().split("@", 1)[1]

View File

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

View File

@@ -1,32 +0,0 @@
from io import BytesIO
from typing import Optional
class ExcelHandler:
"""
Класс для обработки Excel файлов.
Хранит файл в формате BytesIO и предоставляет методы для работы с ним.
"""
def __init__(self, file_bytes: BytesIO):
"""
Инициализация обработчика Excel файла.
Args:
file_bytes: Файл электронных таблиц в формате BytesIO
"""
if not isinstance(file_bytes, BytesIO):
raise TypeError("file_bytes должен быть экземпляром BytesIO")
self.data: BytesIO = file_bytes
def get_file(self) -> BytesIO:
"""
Возвращает сохраненный файл в формате BytesIO.
Returns:
BytesIO: Файл электронных таблиц
"""
# Перемещаем указатель в начало файла для корректного чтения
self.data.seek(0)
return self.data

View File

@@ -1,2 +1,3 @@
"""Классы для работы с сущностью заказа и позиции"""
from .auto_part_order import AutoPartOrder, OrderStatus
from .auto_part_position import AutoPartPosition, PositionStatus

View File

@@ -1,9 +1,11 @@
from typing import List, Optional
from .auto_part_position import AutoPartPosition, PositionStatus
from mail_order_bot.task_processor.handlers.abcp.stock_selector import StockSelector
from enum import Enum
class OrderStatus(Enum):
NEW = "new"
IN_PROGRESS = "in progress"
@@ -14,7 +16,8 @@ class OrderStatus(Enum):
class AutoPartOrder:
def __init__(self):
def __init__(self, client_id):
self.client_id = client_id
self.positions: List[AutoPartPosition] = []
self.status = OrderStatus.NEW
self.delivery_period = 0
@@ -40,14 +43,12 @@ class AutoPartOrder:
Выбирает оптимального поставщика для всех позиций заказа.
Предполагается, что остатки уже получены и обработаны.
"""
from mail_order_bot.email_processor.handlers.abcp.stock_selector import StockSelector
for position in self.positions:
selector = StockSelector(position, self.delivery_period)
selector.select_optimal_supplier()
def check_order(self, config) -> None:
""" Проверяет заказ на возможность исполнения"""
# 1. Проверка общего количества отказов
@@ -61,6 +62,5 @@ class AutoPartOrder:
f"({refusal_positions_count} из {len(self.positions)})")
self.status = OrderStatus.OPERATOR_REQUIRED
def __len__(self):
return len(self.positions)

View File

@@ -1,10 +1,10 @@
from turtle import st
from typing import List, Optional
from dataclasses import dataclass, field
from typing import Dict, Any
from decimal import Decimal
from enum import Enum
class PositionStatus(Enum):
NEW = "new" # Новая позиция
STOCK_RECIEVED = "stock_received" # Получен остаток
@@ -14,13 +14,15 @@ class PositionStatus(Enum):
ORDERED = "ordered" # Заказано
REFUSED = "refused" # Отказано
@dataclass
class AutoPartPosition:
"""
Унифицированная модель позиции для заказа.
Все контрагенты приводятся к этой структуре.
"""
DISTRIBUTOR_ID = "1577730" # ID локального склада
DISTRIBUTOR_ID = 1577730 # ID локального склада
sku: str # Артикул товара
manufacturer: str # Производитель
@@ -30,11 +32,10 @@ class AutoPartPosition:
total: Decimal = 0 # Общая сумма
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)
order_delivery_period = 0
stock: List[Dict[str, Any]] = None
additional_attrs: Dict[str, Any] = field(default_factory=dict)
@@ -50,8 +51,8 @@ class AutoPartPosition:
raise ValueError(f"Цена не может быть отрицательной: {self.requested_price}")
def set_stock(self, stock):
if stock is not None:
self.stock = stock
if stock.get("success"):
self.stock = stock["data"]
if len(self.stock):
self.status = PositionStatus.STOCK_RECIEVED
else:
@@ -65,7 +66,7 @@ class AutoPartPosition:
available_distributors = self.stock
# BR-1. Отсекаем склады для заказов из наличия (только локальный склад)
if self.delivery_period == 0:
if self.order_delivery_period == 0:
available_distributors = self._filter_only_local_storage(available_distributors)
# BR-2. Цена не должна превышать цену из заказа
@@ -77,29 +78,23 @@ class AutoPartPosition:
# 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)
# BR-7.
for stock_item in self.stock:
available_quantity = min(self.requested_quantity, stock_item["availability"])
stock_item["profit"] = available_quantity * stock_item["price"] - available_quantity * stock_item["system_price"]
self.stock.sort(key=lambda item: Decimal(item["profit"]), reverse=True)
if len(available_distributors):
self.order_item = self.stock[0]
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 str(item["distributorId"]) == self.DISTRIBUTOR_ID]
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]]:
"""Фильтрует склады по сроку доставки"""
@@ -107,26 +102,8 @@ class AutoPartPosition:
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]
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]
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

@@ -0,0 +1,4 @@
"""Данный пакет содержит модули и классы дял парсинга объектов"""
from .excel_parcer import ExcelFileParcer
from .order_parcer import OrderParser

View File

@@ -0,0 +1,106 @@
import logging
import pandas as pd
from io import BytesIO
logger = logging.getLogger(__name__)
class ExcelFileParcer:
def __init__(self, file_bytes, config):
self.config = config
self.bytes = file_bytes
self.sheet_name = self.config.get("sheet_name", 0)
self.df = self._parse_file(file_bytes)
def _parse_file(self, file_bytes):
"""Парсит вложение в формате эл таблиц"""
try:
df = pd.read_excel(file_bytes, sheet_name=self.sheet_name, header=None)
except Exception as e:
df = None
logger.warning("Не удалось распарсить значение файла")
return df
def set_value(self, sku, manufacturer, column, value):
"""Устанавливает значение в строке позиции в заданной колонке"""
# Находим строку (ось Y)
attr_row = self._get_attr_row(sku, manufacturer)
# Находим колонку (ось X)
attr_col = self._get_attr_column(column)
self.df.iloc[attr_row, attr_col] = value
logger.debug(
f"Установлено значение {value} в колонке {column} для строки {attr_row} ( {sku} | {manufacturer} )")
def get_file_bytes(self):
"Этот метод будет возвращать эксель из датафрейма в виде байтов"
buf = BytesIO()
with pd.ExcelWriter(buf, engine="xlsxwriter") as writer:
self.df.to_excel(writer, sheet_name="Sheet1", index=False, header=False)
buf.seek(0)
return buf
def get_order_rows(self):
"Будет такой метод или какой-то другой который формирует файл с заказом"
# Получаем все данные из файла
# Находим индекс строки с заголовком
key_field = self.config.get("key_field")
header_row_idx = self.df[
self.df.apply(lambda row: row.astype(str).str.contains(key_field, case=False, na=False).any(),
axis=1)].index[0]
# Считываем таблицу с правильным заголовком
df = pd.read_excel(self.bytes, header=header_row_idx, sheet_name=self.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
def _get_header_row(self):
"""Метод возвращает строку заголовка по наличию в ней ключевого слова. Поиск заголовка нужен при определении колонок с данными."""
key_column = self.config.get("key_field")
header_row_idx = int(
self.df.apply(lambda row: row.astype(str).str.contains(key_column, na=False).any(), axis=1).idxmax())
# todo надо выкинуть ошибку если в файле не найдено ключевое поле
# todo надо выкинуть ошибку если найдено несколько строк с CONFIG_KEY_COLUMN
logger.debug(f"Индекс строки заголовка - {header_row_idx}")
return header_row_idx
def _get_attr_column(self, col_name):
"""Поиск по оси Х - метод возвращает индекс колонки по названию атрибута"""
header_row_idx = self._get_header_row()
header_row = self.df.iloc[header_row_idx]
col_id = header_row[header_row == col_name].index[0]
# todo добавить перехват ошибок и выброс понятного и сключения при отсутствии колонки
logger.debug(f"Индекс колонки {col_name} - {col_id}")
return int(col_id)
def _get_attr_row(self, sku, manufacturer):
"""Поиск по оси Y - метод возвращает индекс строки по бренду и артикулу"""
sku_col_name = self.config["mapping"]["article"]
sku_col_idx = self._get_attr_column(sku_col_name)
man_col_name = self.config["mapping"]["manufacturer"]
man_col_idx = self._get_attr_column(man_col_name)
matching_rows = self.df[
(self.df.iloc[:, sku_col_idx] == sku) & (self.df.iloc[:, man_col_idx] == manufacturer)].index
# todo сделать проверку на наличие дублей
logger.info(f"Индекс строки позиции {sku}/{manufacturer} - {matching_rows}")
return matching_rows.values[0]

View File

@@ -0,0 +1,83 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional
from decimal import Decimal
from io import BytesIO
from mail_order_bot.order import AutoPartPosition
logger = logging.getLogger(__name__)
class OrderParser:
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def __init__(self, mapping, delivery_period):
self.mapping = mapping
self.delivery_period = delivery_period
def parse(self, order, df):
# Парсим строки
positions = []
for idx, row in df.iterrows():
position = self._parse_row(row, self.mapping)
if position:
order.add_position(position)
logger.info(f"Успешно обработано {len(order)} позиций из {len(df)} строк")
# except Exception as e:
# logger.error(f"Ошибка при обработке файла: {e}")
# else:
return order
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[AutoPartPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
if "name" in mapping:
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,
order_delivery_period=self.delivery_period,
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

View File

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

View File

@@ -1,13 +1,8 @@
from .attachment_handler.attachment_handler import AttachmentHandler
from .excel_parcers.order_parcer_basic import BasicExcelParser
from .destination_time.local_store import DeliveryPeriodLocalStore
from .abcp.api_get_stock import APIGetStock
from .notifications.test_notifier import TestNotifier

View File

@@ -1,9 +1,8 @@
import logging
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from .abcp_provider import AbcpProvider
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.email_processor.handlers.abcp.stock_selector import StockSelector
logger = logging.getLogger(__name__)
@@ -25,11 +24,7 @@ class APIGetStock(AbstractTask):
# Получаем остатки из-под учетной записи клиента
client_stock = self.client_provider.get_stock(position.sku, position.manufacturer)
# Используем StockSelector для фильтрации неподходящих поставщиков
selector = StockSelector(position, order.delivery_period)
available_distributors = selector.filter_stock(client_stock)
position.set_stock(available_distributors)
position.set_stock(client_stock)
position.set_order_item()
logger.info(f"Получены позиции со склада для файла {attachment.get('name', "no name")}")

View File

@@ -1,4 +1,4 @@
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
import logging

View File

@@ -1,5 +1,4 @@
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
from mail_order_bot.task_processor.abstract_task import AbstractTask
import logging
import re

View File

@@ -1,5 +1,4 @@
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
from mail_order_bot.task_processor.abstract_task import AbstractTask
import logging

View File

@@ -3,16 +3,16 @@ import pandas as pd
from typing import Dict, Any, Optional
from decimal import Decimal
from io import BytesIO
#from mail_order_bot.email_processor.handlers.order_position import OrderPosition
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
#from mail_order_bot.task_processor.handlers.order_position import OrderPosition
from mail_order_bot.task_processor.abstract_task import AbstractTask
from mail_order_bot.email_processor.order.auto_part_position import AutoPartPosition
from mail_order_bot.email_processor.order.auto_part_order import AutoPartOrder
from mail_order_bot.task_processor.order.auto_part_position import AutoPartPosition
from mail_order_bot.task_processor.order.auto_part_order import AutoPartOrder
logger = logging.getLogger(__name__)
class BasicExcelParser(AbstractTask):
class OrderParser(AbstractTask):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
@@ -30,7 +30,8 @@ class BasicExcelParser(AbstractTask):
delivery_period = attachment.get("delivery_period", 0)
#try:
df = self._make_dataframe(file_bytes)
mapping = self.config['mapping']
mapping = self.config["mapping"]
client_id = self.config["client_id"]
order = AutoPartOrder()
attachment["order"] = order

View File

@@ -3,7 +3,7 @@ import pandas as pd
from typing import Dict, Any, Optional
from decimal import Decimal
from io import BytesIO
#from mail_order_bot.email_processor.handlers.order_position import OrderPosition
#from mail_order_bot.task_processor.handlers.order_position import OrderPosition
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from ...order.auto_part_position import AutoPartPosition

View File

@@ -0,0 +1,34 @@
import logging
import pandas as pd
from io import BytesIO
#from mail_order_bot.task_processor.handlers.order_position import OrderPosition
from mail_order_bot.task_processor.abstract_task import AbstractTask
logger = logging.getLogger(__name__)
class ExcelExtractor(AbstractTask):
"""
Хендлер для каждого вложения считывает эксель файл и сохраняет его контекст
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
file_bytes = BytesIO(attachment['bytes'])
# Получаем все данные из файла
sheet_name = self.config.get("sheet_name", 0)
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

@@ -8,9 +8,9 @@ from mail_order_bot.context import Context
from mail_order_bot.email_client.utils import EmailUtils
from enum import Enum
from mail_order_bot.email_processor.handlers import *
from mail_order_bot.task_processor.handlers import *
from mail_order_bot.email_processor.handlers import AttachmentHandler
from mail_order_bot.task_processor.handlers import AttachmentHandler
logger = logging.getLogger(__name__)
@@ -24,7 +24,7 @@ class RequestStatus(Enum):
INVALID = "invalid"
class EmailProcessor:
class TaskProcessor:
def __init__(self, configs_path: str):
super().__init__()
self.context = Context()