From 1222488aec82bc2af9756b3724144f07368d4618 Mon Sep 17 00:00:00 2001 From: zosimovaa Date: Thu, 11 Dec 2025 23:06:13 +0300 Subject: [PATCH] no message --- src/mail_order_bot/abcp_api/abcp_provider.py | 22 ++- .../attachment_handler/__init__.py | 18 --- .../attachment_handler/handlers/__init__.py | 9 -- .../handlers/abcp_clients/check_stock.py | 26 ---- .../handlers/abcp_clients/create_order.py | 37 ----- .../handlers/abstract_task.py | 24 --- .../handlers/email/send_email.py | 141 ------------------ .../excel_parcers/basic_excel_parcer.py | 118 --------------- .../handlers/notifications/test_notifier.py | 15 -- .../validators/price_quantity_ckecker.py | 50 ------- .../attachment_handler/order/__init__.py | 0 .../order/auto_part_order.py | 34 ----- .../order/auto_part_position.py | 77 ---------- .../attachment_handler/order_request.py | 34 ----- .../attachment_handler/processor.py | 42 ------ src/mail_order_bot/configs/amtel.club.yml | 1 - src/mail_order_bot/credential_provider.py | 108 ++++++++++++++ .../handlers/abcp/api_get_stock.py | 26 +++- .../handlers/abcp/stock_selector.py | 99 ++++++++++++ .../email_processor/order/auto_part_order.py | 10 +- .../order/auto_part_position.py | 57 ------- tests/abcp_api/test_abcp_api.py | 8 +- 22 files changed, 255 insertions(+), 701 deletions(-) delete mode 100644 src/mail_order_bot/attachment_handler/__init__.py delete mode 100644 src/mail_order_bot/attachment_handler/handlers/__init__.py delete mode 100644 src/mail_order_bot/attachment_handler/handlers/abcp_clients/check_stock.py delete mode 100644 src/mail_order_bot/attachment_handler/handlers/abcp_clients/create_order.py delete mode 100644 src/mail_order_bot/attachment_handler/handlers/abstract_task.py delete mode 100644 src/mail_order_bot/attachment_handler/handlers/email/send_email.py delete mode 100644 src/mail_order_bot/attachment_handler/handlers/excel_parcers/basic_excel_parcer.py delete mode 100644 src/mail_order_bot/attachment_handler/handlers/notifications/test_notifier.py delete mode 100644 src/mail_order_bot/attachment_handler/handlers/validators/price_quantity_ckecker.py delete mode 100644 src/mail_order_bot/attachment_handler/order/__init__.py delete mode 100644 src/mail_order_bot/attachment_handler/order/auto_part_order.py delete mode 100644 src/mail_order_bot/attachment_handler/order/auto_part_position.py delete mode 100644 src/mail_order_bot/attachment_handler/order_request.py delete mode 100644 src/mail_order_bot/attachment_handler/processor.py create mode 100644 src/mail_order_bot/credential_provider.py create mode 100644 src/mail_order_bot/email_processor/handlers/abcp/stock_selector.py diff --git a/src/mail_order_bot/abcp_api/abcp_provider.py b/src/mail_order_bot/abcp_api/abcp_provider.py index a0597f7..c35c228 100644 --- a/src/mail_order_bot/abcp_api/abcp_provider.py +++ b/src/mail_order_bot/abcp_api/abcp_provider.py @@ -1,4 +1,3 @@ -import os import hashlib import requests import logging @@ -13,19 +12,28 @@ class AbcpProvider: "Content-Type": "application/x-www-form-urlencoded" } - def __init__(self, account="SYSTEM"): + def __init__(self, login: str, password: str): + """ + Инициализация AbcpProvider. + + Args: + login: Логин для доступа к API + password: Пароль для доступа к API + """ self.base_url = self.HOST + self.login = login + self.password = password - def get_stock(self, sku, manufacturer, partner="SYSTEM"): + def get_stock(self, sku, manufacturer): method = "GET" path = "/search/articles" params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"} - return self._execute(partner, path, method, params) + return self._execute(path, method, params) - def _execute(self, partner, path, method="GET", params={}, data=None, ): - params["userlogin"] = os.getenv(f"ABCP_LOGIN_{partner}") - params["userpsw"] = hashlib.md5(os.getenv(f"ABCP_PASSWORD_{partner}").encode("utf-8")).hexdigest() + def _execute(self, path, method="GET", params={}, data=None): + params["userlogin"] = self.login + params["userpsw"] = hashlib.md5(self.password.encode("utf-8")).hexdigest() response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params) payload = response.json() diff --git a/src/mail_order_bot/attachment_handler/__init__.py b/src/mail_order_bot/attachment_handler/__init__.py deleted file mode 100644 index 87dcbe3..0000000 --- a/src/mail_order_bot/attachment_handler/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Пакет содержит реализацию для создания заказов из вложений. - -На входе файл из вложения -Его обработка производится хендлерами, коотрые настраиваются в конфиге -На выходе - экземпляр класса, который в себе содержит - - прочитанный файл в виде pandas DataFrame - - распарсенный файл заказа с позициями - - полученные остатки - - результат проверки возможности создания заказа - -Так же класс содержит методы - - для создания заказа - - для получения отредактированного файла - -""" - -from .processor import TaskProcessor \ No newline at end of file diff --git a/src/mail_order_bot/attachment_handler/handlers/__init__.py b/src/mail_order_bot/attachment_handler/handlers/__init__.py deleted file mode 100644 index da50114..0000000 --- a/src/mail_order_bot/attachment_handler/handlers/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .abcp_clients.check_stock import GetStock -from .abcp_clients.create_order import InstantOrderTest - -from .excel_parcers.basic_excel_parcer 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/attachment_handler/handlers/abcp_clients/check_stock.py b/src/mail_order_bot/attachment_handler/handlers/abcp_clients/check_stock.py deleted file mode 100644 index 70a0c20..0000000 --- a/src/mail_order_bot/attachment_handler/handlers/abcp_clients/check_stock.py +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 9c61844..0000000 --- a/src/mail_order_bot/attachment_handler/handlers/abcp_clients/create_order.py +++ /dev/null @@ -1,37 +0,0 @@ -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/attachment_handler/handlers/abstract_task.py b/src/mail_order_bot/attachment_handler/handlers/abstract_task.py deleted file mode 100644 index f41d11a..0000000 --- a/src/mail_order_bot/attachment_handler/handlers/abstract_task.py +++ /dev/null @@ -1,24 +0,0 @@ -import logging -from abc import ABC, abstractmethod -from typing import Dict, Any -from ..order.auto_part_order import AutoPartOrder - -class AbstractTask(ABC): - RESULT_SECTION = "section" - """ - Абстрактный базовый класс для всех хэндлеров. - """ - def __init__(self, config: Dict[str, Any], context: Dict[str, Any], order: AutoPartOrder, *args, **kwargs) -> None: - self.config = config - self.context = context - self.order = order - - @abstractmethod - def do(self) -> None: - """ - Выполняет работу над заданием - Входные и выходные данные - в self.context - Конфиг задается при инициализации - """ - raise NotImplementedError - diff --git a/src/mail_order_bot/attachment_handler/handlers/email/send_email.py b/src/mail_order_bot/attachment_handler/handlers/email/send_email.py deleted file mode 100644 index 31a9093..0000000 --- a/src/mail_order_bot/attachment_handler/handlers/email/send_email.py +++ /dev/null @@ -1,141 +0,0 @@ - -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/attachment_handler/handlers/excel_parcers/basic_excel_parcer.py b/src/mail_order_bot/attachment_handler/handlers/excel_parcers/basic_excel_parcer.py deleted file mode 100644 index 707aa68..0000000 --- a/src/mail_order_bot/attachment_handler/handlers/excel_parcers/basic_excel_parcer.py +++ /dev/null @@ -1,118 +0,0 @@ -import logging -import pandas as pd -from typing import Dict, Any, Optional -from decimal import Decimal -from io import BytesIO -#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 - -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/attachment_handler/handlers/notifications/test_notifier.py b/src/mail_order_bot/attachment_handler/handlers/notifications/test_notifier.py deleted file mode 100644 index 19c58ee..0000000 --- a/src/mail_order_bot/attachment_handler/handlers/notifications/test_notifier.py +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 2e11885..0000000 --- a/src/mail_order_bot/attachment_handler/handlers/validators/price_quantity_ckecker.py +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/src/mail_order_bot/attachment_handler/order/auto_part_order.py b/src/mail_order_bot/attachment_handler/order/auto_part_order.py deleted file mode 100644 index 24e0f91..0000000 --- a/src/mail_order_bot/attachment_handler/order/auto_part_order.py +++ /dev/null @@ -1,34 +0,0 @@ -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/attachment_handler/order/auto_part_position.py b/src/mail_order_bot/attachment_handler/order/auto_part_position.py deleted file mode 100644 index bd0d33f..0000000 --- a/src/mail_order_bot/attachment_handler/order/auto_part_position.py +++ /dev/null @@ -1,77 +0,0 @@ -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 # Производитель - name: str # Наименование - requested_price: Decimal # Цена за единицу - requested_quantity: int # Количество - total: Decimal # Общая сумма - stock_quantity: int = 0 # Остаток на складе - stock_price: Decimal = Decimal('0.0') # Цена на складе - order_quantity: int = 0 # Количество для заказа - order_price: Decimal = Decimal('0.0') # Цена в заказе - 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/attachment_handler/order_request.py b/src/mail_order_bot/attachment_handler/order_request.py deleted file mode 100644 index 1d7a218..0000000 --- a/src/mail_order_bot/attachment_handler/order_request.py +++ /dev/null @@ -1,34 +0,0 @@ -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/attachment_handler/processor.py b/src/mail_order_bot/attachment_handler/processor.py deleted file mode 100644 index 436300f..0000000 --- a/src/mail_order_bot/attachment_handler/processor.py +++ /dev/null @@ -1,42 +0,0 @@ -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 .handlers import * - -class TaskProcessor: - def __init__(self, config_path: Path): - self.config_path = config_path - self.context = dict() - self.order = None - - def process(self, client, attachment): - config = self._load_config(client) - self.context = dict() - self.order = AutoPartOrder() - - self.context["client"] = client - self.context["attachment"] = attachment - - self.context["status"] = 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, self.order) - task.do() - - return self.context - - - pass - def _load_config(self, client) -> Dict[str, Any]: - """Загружает конфигурацию из YAML или JSON""" - path = os.path.join(self.config_path, client + '.yml') - with open(path, 'r', encoding='utf-8') as f: - return yaml.safe_load(f) diff --git a/src/mail_order_bot/configs/amtel.club.yml b/src/mail_order_bot/configs/amtel.club.yml index 8982936..0be828c 100644 --- a/src/mail_order_bot/configs/amtel.club.yml +++ b/src/mail_order_bot/configs/amtel.club.yml @@ -27,4 +27,3 @@ pipeline: - diff --git a/src/mail_order_bot/credential_provider.py b/src/mail_order_bot/credential_provider.py new file mode 100644 index 0000000..b1c7c41 --- /dev/null +++ b/src/mail_order_bot/credential_provider.py @@ -0,0 +1,108 @@ +import os +import logging +from typing import Optional, Tuple +from mail_order_bot.context import Context + +logger = logging.getLogger(__name__) + + +class CredentialProvider: + """ + Класс для получения учетных данных (логин и пароль) для доступа к API. + + Учетные данные берутся из переменных окружения в формате: + - {PREFIX}_LOGIN_{CLIENT_NAME} - логин для клиента + - {PREFIX}_PASSWORD_{CLIENT_NAME} - пароль для клиента + - {PREFIX}_LOGIN_SYSTEM - логин для системной учетной записи + - {PREFIX}_PASSWORD_SYSTEM - пароль для системной учетной записи + """ + + SYSTEM_ACCOUNT = "SYSTEM" + + def __init__(self, prefix: str = "ABCP", context: Optional[Context] = None): + """ + Инициализация CredentialProvider. + + Args: + prefix: Префикс для переменных окружения (по умолчанию "ABCP") + context: Контекст приложения. Если не передан, будет получен через Context() + """ + self.prefix = prefix.upper() + self.context = context if context is not None else Context() + + def get_client_credentials(self, client_name: Optional[str] = None) -> Tuple[str, str]: + """ + Получает учетные данные для клиента. + + Если client_name не указан, берется из контекста (context.data.get("client")). + + Args: + client_name: Имя клиента. Если None, берется из контекста. + + Returns: + Tuple[str, str]: Кортеж (логин, пароль) + + Raises: + ValueError: Если не удалось получить имя клиента или учетные данные не найдены + """ + if client_name is None: + client_name = self.context.data.get("client") + if client_name is None: + raise ValueError("Имя клиента не указано и не найдено в контексте") + + login_key = f"{self.prefix}_LOGIN_{client_name}" + password_key = f"{self.prefix}_PASSWORD_{client_name}" + + login = os.getenv(login_key) + password = os.getenv(password_key) + + if login is None or password is None: + raise ValueError( + f"Учетные данные для клиента '{client_name}' не найдены. " + f"Проверьте переменные окружения: {login_key} и {password_key}" + ) + + logger.debug(f"Получены учетные данные для клиента '{client_name}'") + return login, password + + def get_system_credentials(self) -> Tuple[str, str]: + """ + Получает учетные данные для системной учетной записи. + + Returns: + Tuple[str, str]: Кортеж (логин, пароль) + + Raises: + ValueError: Если учетные данные системной учетной записи не найдены + """ + login_key = f"{self.prefix}_LOGIN_{self.SYSTEM_ACCOUNT}" + password_key = f"{self.prefix}_PASSWORD_{self.SYSTEM_ACCOUNT}" + + login = os.getenv(login_key) + password = os.getenv(password_key) + + if login is None or password is None: + raise ValueError( + f"Учетные данные для системной учетной записи не найдены. " + f"Проверьте переменные окружения: {login_key} и {password_key}" + ) + + logger.debug("Получены учетные данные для системной учетной записи") + return login, password + + def get_credentials(self, use_system: bool = False, client_name: Optional[str] = None) -> Tuple[str, str]: + """ + Универсальный метод для получения учетных данных. + + Args: + use_system: Если True, возвращает учетные данные системной учетной записи. + Если False, возвращает учетные данные клиента. + client_name: Имя клиента. Если None и use_system=False, берется из контекста. + + Returns: + Tuple[str, str]: Кортеж (логин, пароль) + """ + if use_system: + return self.get_system_credentials() + else: + return self.get_client_credentials(client_name) diff --git a/src/mail_order_bot/email_processor/handlers/abcp/api_get_stock.py b/src/mail_order_bot/email_processor/handlers/abcp/api_get_stock.py index 985f73c..7eb3622 100644 --- a/src/mail_order_bot/email_processor/handlers/abcp/api_get_stock.py +++ b/src/mail_order_bot/email_processor/handlers/abcp/api_get_stock.py @@ -1,8 +1,9 @@ -import random import logging from mail_order_bot.email_processor.handlers.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__) @@ -10,17 +11,30 @@ logger = logging.getLogger(__name__) class APIGetStock(AbstractTask): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.abcp_provider = AbcpProvider() + credential_provider = CredentialProvider(context=self.context) + + # Создаем провайдер для системной учетной записи + system_login, system_password = credential_provider.get_system_credentials() + self.system_provider = AbcpProvider(login=system_login, password=system_password) + + # Создаем провайдер для учетной записи клиента + client_login, client_password = credential_provider.get_client_credentials() + self.client_provider = AbcpProvider(login=client_login, password=client_password) def do(self) -> None: attachments = self.context.data.get("attachments", []) for attachment in attachments: order = attachment["order"] for position in order.positions: - stock = self.get_stock(position.sku, position.manufacturer) - position.update_stock(stock, order.delivery_period) - position.fill_from_stock() + # Получаем остатки из-под учетной записи клиента + client_stock = self.client_provider.get_stock(position.sku, position.manufacturer) + + # Используем StockSelector для обработки остатков и выбора оптимального поставщика + selector = StockSelector(position, order.delivery_period) + selector.process_stock(client_stock) + selector.select_optimal_supplier() + logger.info(f"Получены позиции со склада для файла {attachment.get('name', "no name")}") def get_stock(self, sku: str, manufacturer: str) -> int: - return self.abcp_provider.get_stock(sku, manufacturer) \ No newline at end of file + return self.client_provider.get_stock(sku, manufacturer) \ No newline at end of file diff --git a/src/mail_order_bot/email_processor/handlers/abcp/stock_selector.py b/src/mail_order_bot/email_processor/handlers/abcp/stock_selector.py new file mode 100644 index 0000000..83fa874 --- /dev/null +++ b/src/mail_order_bot/email_processor/handlers/abcp/stock_selector.py @@ -0,0 +1,99 @@ +from typing import Dict, Any, List +from decimal import Decimal +from mail_order_bot.email_processor.order.auto_part_position import AutoPartPosition, PositionStatus + +logger = __import__('logging').getLogger(__name__) + + +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 + + def process_stock(self, client_stock: Dict[str, Any]) -> None: + """ + Обрабатывает результат запроса остатков из-под учетной записи клиента и обновляет позицию. + + Args: + client_stock: Результат запроса остатков от API из-под учетной записи клиента + """ + 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.sort(key=lambda item: Decimal(item["price"]), reverse=False) + + self.position.stock = available_distributors + if len(self.position.stock): + self.position.status = PositionStatus.STOCK_RECIEVED + else: + self.position.status = PositionStatus.NO_AVAILABLE_STOCK + else: + self.position.status = PositionStatus.STOCK_FAILED + + def select_optimal_supplier(self) -> None: + """ + Выбирает оптимального поставщика из отфильтрованных складов + и обновляет позицию заказа. + """ + if self.position.status == PositionStatus.STOCK_RECIEVED: + # Вычисляем прибыль для каждого склада + for distributor in self.position.stock: + distributor["profit"] = ( + int(distributor["availability"]) * self.position.requested_price - + int(distributor["availability"]) * Decimal(distributor["price"]) + ) + + # Сортируем по прибыли (по убыванию) + self.position.stock.sort(key=lambda item: item["profit"], reverse=True) + + # Выбираем лучший вариант + self.position.order_quantity = self.position.stock[0]["availability"] + self.position.order_price = self.position.requested_price + self.position.order_item = self.position.stock[0] + + self.position.status = PositionStatus.READY + + 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] + diff --git a/src/mail_order_bot/email_processor/order/auto_part_order.py b/src/mail_order_bot/email_processor/order/auto_part_order.py index ada6b17..555c03d 100644 --- a/src/mail_order_bot/email_processor/order/auto_part_order.py +++ b/src/mail_order_bot/email_processor/order/auto_part_order.py @@ -35,9 +35,15 @@ class AutoPartOrder: self.delivery_period = delivery_period def fill_from_local_supplier(self) -> None: + """ + Выбирает оптимального поставщика для всех позиций заказа. + Предполагается, что остатки уже получены и обработаны. + """ + from mail_order_bot.email_processor.handlers.abcp.stock_selector import StockSelector + for position in self.positions: - errors = position.fill_from_stock() - self.errors += errors + selector = StockSelector(position, self.delivery_period) + selector.select_optimal_supplier() diff --git a/src/mail_order_bot/email_processor/order/auto_part_position.py b/src/mail_order_bot/email_processor/order/auto_part_position.py index 45089bd..a6748a8 100644 --- a/src/mail_order_bot/email_processor/order/auto_part_position.py +++ b/src/mail_order_bot/email_processor/order/auto_part_position.py @@ -17,7 +17,6 @@ class PositionStatus(Enum): @dataclass class AutoPartPosition: - DISTRIBUTOR_ID = "1577730" """ Унифицированная модель позиции для заказа. Все контрагенты приводятся к этой структуре. @@ -48,62 +47,6 @@ class AutoPartPosition: if self.requested_price < 0: raise ValueError(f"Цена не может быть отрицательной: {self.requested_price}") - def update_stock(self, stock: Dict[str, Any], delivery_period: int = 0) -> None: - if stock["success"]: - available_distributors = stock["data"] - - # Для доставки только с локального склада сперва убираем все остальные склады - if delivery_period == 0: - available_distributors = self._filter_only_local_storage(available_distributors) - - #Отбираем склады по сроку доставки - available_distributors = self._filter_proper_delivery_time(available_distributors, delivery_period) - - # Убираем дорогие склады с ценой выше запрошенной - available_distributors = self._filter_proper_price(available_distributors) - - # Убираем отрицательные остатки - available_distributors = self._filter_proper_availability(available_distributors) - - # Сортируем по цене - available_distributors.sort(key=lambda item: Decimal(item["price"]), reverse=False) - - self.stock = available_distributors - if len (self.stock): - self.status = PositionStatus.STOCK_RECIEVED - else: - self.status = PositionStatus.NO_AVAILABLE_STOCK - else: - self.status = PositionStatus.STOCK_FAILED - - - def fill_from_stock(self): - if self.status == PositionStatus.STOCK_RECIEVED: - - for distributor in self.stock: - distributor["profit"] = int(distributor["availability"]) * self.requested_price - int(distributor["availability"]) * Decimal(distributor["price"]) - - self.stock.sort(key=lambda item: item["profit"], reverse=True) - - self.order_quantity = self.stock[0]["availability"] - self.order_price = self.requested_price - self.order_item = self.stock[0] - - self.status = PositionStatus.READY - - - def _filter_only_local_storage(self, distributors): - return [item for item in distributors if str(item["distributorId"]) == self.DISTRIBUTOR_ID] - - def _filter_proper_delivery_time(self, distributors, delivery_period): - return [item for item in distributors if item["deliveryPeriod"] <= delivery_period] - - def _filter_proper_price(self, distributors): - return [item for item in distributors if Decimal(item["price"]) <= self.requested_price] - - def _filter_proper_availability(self, distributors): - return [item for item in distributors if Decimal(item["availability"]) > 0] - diff --git a/tests/abcp_api/test_abcp_api.py b/tests/abcp_api/test_abcp_api.py index 376012c..3915e5c 100644 --- a/tests/abcp_api/test_abcp_api.py +++ b/tests/abcp_api/test_abcp_api.py @@ -13,10 +13,12 @@ if __name__ == "__main__": position = AutoPartPosition(sku="560300054", manufacturer="VST", requested_quantity=1) order.add_position(position) - provider = AbcpProvider() + login = os.getenv('ABCP_LOGIN_SYSTEM') + password = os.getenv('ABCP_PASSWORD_SYSTEM') + provider = AbcpProvider(login=login, password=password) - provider.get_stock(order) + result = provider.get_stock(position.sku, position.manufacturer) print(order.positions[0].stock) - print(os.getenv('ABCP_LOGIN')) \ No newline at end of file + print(os.getenv('ABCP_LOGIN_SYSTEM')) \ No newline at end of file