diff --git a/business_rules/br.md b/business_rules/br.md new file mode 100644 index 0000000..c77b760 --- /dev/null +++ b/business_rules/br.md @@ -0,0 +1,9 @@ +Создание заказа через API ABCP +1. Логинимся под учеткой заказчика +2. Получаем остатки +3. Отсекаем не подходящие по сроку (дольше) и цене +4. Подбираем позицию максимально близкую к цене из заказа + - Приоритет отдаем складу, где есть все заказы + - Приоритет отдаем позициям из наличия, потом с доставкой с других складов + - По цене выбираем наиболее близкую к цене заказа (меньше или равно) + - При невозможности заказать в одном месте разбиваем заказ из нескольких складов \ No newline at end of file diff --git a/src/mail_order_bot/abcp_api/__init__.py b/src/mail_order_bot/abcp_api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/mail_order_bot/abcp_api/abcp_provider.py b/src/mail_order_bot/email_processor/handlers/abcp/abcp_provider.py similarity index 100% rename from src/mail_order_bot/abcp_api/abcp_provider.py rename to src/mail_order_bot/email_processor/handlers/abcp/abcp_provider.py 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 1288b89..fcd3d80 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,7 +1,7 @@ import logging 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.email_processor.handlers.abcp.stock_selector import StockSelector 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 index a5c4706..c30f46c 100644 --- a/src/mail_order_bot/email_processor/handlers/abcp/stock_selector.py +++ b/src/mail_order_bot/email_processor/handlers/abcp/stock_selector.py @@ -40,7 +40,7 @@ class StockSelector: 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 из-под системной учетной записи """ 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"] # Для доставки только с локального склада сперва убираем все остальные склады @@ -71,40 +70,13 @@ class StockSelector: 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 else: 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]]: """Фильтрует только локальные склады""" return [item for item in distributors if str(item["distributorId"]) == self.DISTRIBUTOR_ID] diff --git a/src/mail_order_bot/email_processor/handlers/destination_time/from_subject.py b/src/mail_order_bot/email_processor/handlers/destination_time/from_subject.py new file mode 100644 index 0000000..f745e43 --- /dev/null +++ b/src/mail_order_bot/email_processor/handlers/destination_time/from_subject.py @@ -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 \ No newline at end of file diff --git a/src/mail_order_bot/email_processor/handlers/destination_time/local_store.py b/src/mail_order_bot/email_processor/handlers/destination_time/local_store.py index e1c1878..b27d61d 100644 --- a/src/mail_order_bot/email_processor/handlers/destination_time/local_store.py +++ b/src/mail_order_bot/email_processor/handlers/destination_time/local_store.py @@ -12,8 +12,7 @@ class DeliveryPeriodLocalStore(AbstractTask): def do(self) -> None: attachments = self.context.data["attachments"] for attachment in attachments: - order = attachment["order"] - order.set_delivery_period(0) + attachment["delivery_period"] = 0 logger.info(f"Доставка только с локального склада, срок 1 день.") diff --git a/src/mail_order_bot/email_processor/handlers/logic/order_acceptance_criteria.py b/src/mail_order_bot/email_processor/handlers/logic/order_acceptance_criteria.py deleted file mode 100644 index 3b1b04d..0000000 --- a/src/mail_order_bot/email_processor/handlers/logic/order_acceptance_criteria.py +++ /dev/null @@ -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"] - 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 43049ce..01ee31e 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 @@ -14,12 +14,14 @@ class PositionStatus(Enum): ORDERED = "ordered" # Заказано REFUSED = "refused" # Отказано -@dataclass class AutoPartPosition: """ Унифицированная модель позиции для заказа. Все контрагенты приводятся к этой структуре. """ + + DISTRIBUTOR_ID = "1577730" # ID локального склада + sku: str # Артикул товара manufacturer: str # Производитель @@ -57,8 +59,35 @@ class AutoPartPosition: self.status = PositionStatus.STOCK_FAILED def set_order_item(self): - """Выбирает позицию для заказа по максимальному профиту""" + """Выбирает позицию для заказа""" 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: available_quantity = min(self.requested_quantity, stock_item["availability"]) 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] + 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] + + + diff --git a/src/mail_order_bot/email_processor/processor.py b/src/mail_order_bot/email_processor/processor.py index 2e6a679..30cb53c 100644 --- a/src/mail_order_bot/email_processor/processor.py +++ b/src/mail_order_bot/email_processor/processor.py @@ -40,10 +40,18 @@ class EmailProcessor: # Определить клиента email_body = EmailUtils.extract_body(email) + self.context.data["email_body"] = 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) self.context.data["client"] = client + try: # Определить конфиг для пайплайна config = self._load_config(client)