no message
This commit is contained in:
9
business_rules/br.md
Normal file
9
business_rules/br.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Создание заказа через API ABCP
|
||||||
|
1. Логинимся под учеткой заказчика
|
||||||
|
2. Получаем остатки
|
||||||
|
3. Отсекаем не подходящие по сроку (дольше) и цене
|
||||||
|
4. Подбираем позицию максимально близкую к цене из заказа
|
||||||
|
- Приоритет отдаем складу, где есть все заказы
|
||||||
|
- Приоритет отдаем позициям из наличия, потом с доставкой с других складов
|
||||||
|
- По цене выбираем наиболее близкую к цене заказа (меньше или равно)
|
||||||
|
- При невозможности заказать в одном месте разбиваем заказ из нескольких складов
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
|
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
|
||||||
from mail_order_bot.abcp_api.abcp_provider import AbcpProvider
|
from .abcp_provider import AbcpProvider
|
||||||
from mail_order_bot.credential_provider import CredentialProvider
|
from mail_order_bot.credential_provider import CredentialProvider
|
||||||
from mail_order_bot.email_processor.handlers.abcp.stock_selector import StockSelector
|
from mail_order_bot.email_processor.handlers.abcp.stock_selector import StockSelector
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class StockSelector:
|
|||||||
self.system_stock = None
|
self.system_stock = None
|
||||||
|
|
||||||
|
|
||||||
def filter_stock(self, client_stock, system_stock) -> None:
|
def filter_stock(self, client_stock) -> None:
|
||||||
"""
|
"""
|
||||||
Обрабатывает результат запроса остатков из-под учетной записи клиента и обновляет позицию.
|
Обрабатывает результат запроса остатков из-под учетной записи клиента и обновляет позицию.
|
||||||
|
|
||||||
@@ -49,9 +49,8 @@ class StockSelector:
|
|||||||
system_stock: Результат запроса остатков от API из-под системной учетной записи
|
system_stock: Результат запроса остатков от API из-под системной учетной записи
|
||||||
"""
|
"""
|
||||||
self.client_stock = client_stock
|
self.client_stock = client_stock
|
||||||
self.system_stock = system_stock
|
|
||||||
|
|
||||||
if client_stock["success"] and system_stock["success"]:
|
if client_stock["success"]:
|
||||||
available_distributors = client_stock["data"]
|
available_distributors = client_stock["data"]
|
||||||
|
|
||||||
# Для доставки только с локального склада сперва убираем все остальные склады
|
# Для доставки только с локального склада сперва убираем все остальные склады
|
||||||
@@ -71,40 +70,13 @@ class StockSelector:
|
|||||||
available_distributors = self._set_system_price(available_distributors)
|
available_distributors = self._set_system_price(available_distributors)
|
||||||
|
|
||||||
# Сортируем по цене
|
# Сортируем по цене
|
||||||
available_distributors.sort(key=lambda item: Decimal(item["price"]), reverse=False)
|
available_distributors.sort(key=lambda item: Decimal(item["price"]), reverse=True)
|
||||||
|
|
||||||
return available_distributors
|
return available_distributors
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _set_system_price(self, available_distributors):
|
|
||||||
"""Добавляет закупочную (системную) цену к позициям"""
|
|
||||||
for distributor in available_distributors:
|
|
||||||
matching_system_item = self._find_matching_system_item(
|
|
||||||
distributor.get("distributorId"),
|
|
||||||
distributor.get("supplierCode")
|
|
||||||
)
|
|
||||||
if matching_system_item:
|
|
||||||
# Добавляем поле system_price со значением price из найденного элемента
|
|
||||||
distributor["system_price"] = matching_system_item.get("price")
|
|
||||||
else:
|
|
||||||
# Если соответствие не найдено, устанавливаем None
|
|
||||||
distributor["system_price"] = None
|
|
||||||
return available_distributors
|
|
||||||
|
|
||||||
def _find_matching_system_item(self, distributor_id: Any, supplier_code: Any) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Находит соответствующий элемент в system_stock_data по distributorId и supplierCode"""
|
|
||||||
for system_item in self.system_stock_data:
|
|
||||||
if (str(system_item.get("distributorId")) == str(distributor_id) and
|
|
||||||
str(system_item.get("supplierCode")) == str(supplier_code)):
|
|
||||||
return system_item
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _filter_only_local_storage(self, distributors: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
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 str(item["distributorId"]) == self.DISTRIBUTOR_ID]
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
|
||||||
|
from mail_order_bot.email_client.utils import EmailUtils
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class DeliveryPeriodFromSubject(AbstractTask):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def do(self) -> None:
|
||||||
|
"""
|
||||||
|
Извлекает срок доставки из темы письма и сохраняет в каждый элемент attachments.
|
||||||
|
|
||||||
|
Правила парсинга:
|
||||||
|
- Если есть слово "Наличие" - срок доставки = 0 дней
|
||||||
|
- Если найдены оба варианта (диапазон и точное значение) - используется точное значение
|
||||||
|
- Если есть только фраза "N-M дней/дня/день" (диапазон) - срок доставки = минимальное значение (N)
|
||||||
|
- Если есть только фраза "N дней/дня/день" - срок доставки = N дней
|
||||||
|
- Если ничего не указано - срок доставки = 0 дней
|
||||||
|
- Срок переводится в часы (умножается на 24)
|
||||||
|
"""
|
||||||
|
# Получаем тему письма
|
||||||
|
email_subj = self.context.data.get("email_subj", "")
|
||||||
|
if not email_subj:
|
||||||
|
logger.warning("Тема письма не найдена в контексте")
|
||||||
|
email_subj = ""
|
||||||
|
|
||||||
|
# Парсим срок доставки
|
||||||
|
delivery_days = self._parse_delivery_period(email_subj)
|
||||||
|
|
||||||
|
# Переводим в часы
|
||||||
|
delivery_time = delivery_days * 24
|
||||||
|
|
||||||
|
logger.info(f"Извлечен срок доставки из темы: {delivery_days} дней ({delivery_time} часов)")
|
||||||
|
|
||||||
|
# Сохраняем в каждый элемент attachments
|
||||||
|
attachments = self.context.data.get("attachments", [])
|
||||||
|
for attachment in attachments:
|
||||||
|
attachment["delivery_time"] = delivery_time
|
||||||
|
|
||||||
|
logger.debug(f"Срок доставки сохранен в {len(attachments)} вложений")
|
||||||
|
|
||||||
|
def _parse_delivery_period(self, subject: str) -> int:
|
||||||
|
"""
|
||||||
|
Парсит срок доставки из темы письма.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject: Тема письма
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Количество дней доставки (0 по умолчанию)
|
||||||
|
"""
|
||||||
|
if not subject:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
subject_lower = subject.lower()
|
||||||
|
|
||||||
|
# Проверяем наличие слова "Наличие"
|
||||||
|
if "наличие" in subject_lower:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Ищем оба паттерна одновременно
|
||||||
|
range_pattern = r'(\d+)-(\d+)\s+(?:дней|дня|день)'
|
||||||
|
single_pattern = r'(\d+)\s+(?:дней|дня|день)'
|
||||||
|
|
||||||
|
range_match = re.search(range_pattern, subject_lower)
|
||||||
|
single_match = re.search(single_pattern, subject_lower)
|
||||||
|
|
||||||
|
# Если найдены оба варианта - используем точное значение (одиночное число)
|
||||||
|
if range_match and single_match:
|
||||||
|
days = int(single_match.group(1))
|
||||||
|
logger.debug(f"Найдены оба варианта (диапазон и точное значение), используется точное: {days} дней")
|
||||||
|
return days
|
||||||
|
|
||||||
|
# Если найден только диапазон - используем минимальное значение
|
||||||
|
if range_match:
|
||||||
|
min_days = int(range_match.group(1))
|
||||||
|
max_days = int(range_match.group(2))
|
||||||
|
logger.debug(f"Найден диапазон: {min_days}-{max_days} дней, используется минимальное: {min_days} дней")
|
||||||
|
return min(min_days, max_days)
|
||||||
|
|
||||||
|
# Если найдено только одиночное число - используем его
|
||||||
|
if single_match:
|
||||||
|
days = int(single_match.group(1))
|
||||||
|
logger.debug(f"Найдено точное значение: {days} дней")
|
||||||
|
return days
|
||||||
|
|
||||||
|
# Если ничего не найдено, возвращаем 0 (из наличия)
|
||||||
|
return 0
|
||||||
@@ -12,8 +12,7 @@ class DeliveryPeriodLocalStore(AbstractTask):
|
|||||||
def do(self) -> None:
|
def do(self) -> None:
|
||||||
attachments = self.context.data["attachments"]
|
attachments = self.context.data["attachments"]
|
||||||
for attachment in attachments:
|
for attachment in attachments:
|
||||||
order = attachment["order"]
|
attachment["delivery_period"] = 0
|
||||||
order.set_delivery_period(0)
|
|
||||||
logger.info(f"Доставка только с локального склада, срок 1 день.")
|
logger.info(f"Доставка только с локального склада, срок 1 день.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import random
|
|
||||||
import logging
|
|
||||||
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
|
|
||||||
from mail_order_bot.email_processor.order.auto_part_order import OrderStatus
|
|
||||||
from mail_order_bot.email_processor.order.auto_part_position import AutoPartPosition, PositionStatus
|
|
||||||
from decimal import Decimal
|
|
||||||
import random
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class LocalStoreOrder(AbstractTask):
|
|
||||||
"""Сейчас логика такая
|
|
||||||
- ищем на складе наш сапплиер код, берем самую дешевую позицию и делаем заказ из нее
|
|
||||||
|
|
||||||
Другие чуть более дорогие не рассматриваем
|
|
||||||
|
|
||||||
"""
|
|
||||||
# это код нашего склада
|
|
||||||
|
|
||||||
def do(self) -> None:
|
|
||||||
attachments = self.context.data["attachments"]
|
|
||||||
for attachment in attachments:
|
|
||||||
order = attachment["order"]
|
|
||||||
|
|
||||||
@@ -14,12 +14,14 @@ class PositionStatus(Enum):
|
|||||||
ORDERED = "ordered" # Заказано
|
ORDERED = "ordered" # Заказано
|
||||||
REFUSED = "refused" # Отказано
|
REFUSED = "refused" # Отказано
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AutoPartPosition:
|
class AutoPartPosition:
|
||||||
"""
|
"""
|
||||||
Унифицированная модель позиции для заказа.
|
Унифицированная модель позиции для заказа.
|
||||||
Все контрагенты приводятся к этой структуре.
|
Все контрагенты приводятся к этой структуре.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
DISTRIBUTOR_ID = "1577730" # ID локального склада
|
||||||
|
|
||||||
sku: str # Артикул товара
|
sku: str # Артикул товара
|
||||||
manufacturer: str # Производитель
|
manufacturer: str # Производитель
|
||||||
|
|
||||||
@@ -57,8 +59,35 @@ class AutoPartPosition:
|
|||||||
self.status = PositionStatus.STOCK_FAILED
|
self.status = PositionStatus.STOCK_FAILED
|
||||||
|
|
||||||
def set_order_item(self):
|
def set_order_item(self):
|
||||||
"""Выбирает позицию для заказа по максимальному профиту"""
|
"""Выбирает позицию для заказа"""
|
||||||
if self.status == PositionStatus.STOCK_RECIEVED:
|
if self.status == PositionStatus.STOCK_RECIEVED:
|
||||||
|
available_distributors = self.stock
|
||||||
|
|
||||||
|
# BR-1. Отсекаем склады для заказов из наличия (только локальный склад)
|
||||||
|
if self.delivery_period == 0:
|
||||||
|
available_distributors = self._filter_only_local_storage(available_distributors)
|
||||||
|
|
||||||
|
# BR-2. Цена не должна превышать цену из заказа
|
||||||
|
available_distributors = self._filter_proper_price(available_distributors)
|
||||||
|
|
||||||
|
# BR-3. Срок доставки не должен превышать ожидаемый
|
||||||
|
available_distributors = self._filter_proper_delivery_time(available_distributors, self.delivery_period)
|
||||||
|
|
||||||
|
# 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:
|
for stock_item in self.stock:
|
||||||
available_quantity = min(self.requested_quantity, stock_item["availability"])
|
available_quantity = min(self.requested_quantity, stock_item["availability"])
|
||||||
stock_item["profit"] = available_quantity * stock_item["price"] - available_quantity * stock_item["system_price"]
|
stock_item["profit"] = available_quantity * stock_item["price"] - available_quantity * stock_item["system_price"]
|
||||||
@@ -67,6 +96,28 @@ class AutoPartPosition:
|
|||||||
self.order_item = self.stock[0]
|
self.order_item = self.stock[0]
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,18 @@ class EmailProcessor:
|
|||||||
|
|
||||||
# Определить клиента
|
# Определить клиента
|
||||||
email_body = EmailUtils.extract_body(email)
|
email_body = EmailUtils.extract_body(email)
|
||||||
|
self.context.data["email_body"] = email_body
|
||||||
|
|
||||||
email_from = EmailUtils.extract_first_sender(email_body)
|
email_from = EmailUtils.extract_first_sender(email_body)
|
||||||
|
self.context.data["email_from"] = email_from
|
||||||
|
|
||||||
|
email_subj = EmailUtils.extract_header(email_body, "Subject")
|
||||||
|
self.context.data["email_subj"] = email_subj
|
||||||
|
|
||||||
client = EmailUtils.extract_domain(email_from)
|
client = EmailUtils.extract_domain(email_from)
|
||||||
self.context.data["client"] = client
|
self.context.data["client"] = client
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Определить конфиг для пайплайна
|
# Определить конфиг для пайплайна
|
||||||
config = self._load_config(client)
|
config = self._load_config(client)
|
||||||
|
|||||||
Reference in New Issue
Block a user