1 Commits

Author SHA1 Message Date
ac1daf167a Basic docker configs added 2025-10-29 11:23:02 +03:00
63 changed files with 290 additions and 2589 deletions

7
.gitignore vendored
View File

@@ -1,10 +1,5 @@
.venv venv
.vscode .vscode
__pycache__ __pycache__
.env .env
.cursorignore .cursorignore
logs/
files/
Настроено/
Не настроено/
проблемные/

View File

@@ -1,5 +1,5 @@
# Используем официальный образ Python # Используем официальный образ Python
FROM python:3.12-slim FROM python:3.11-slim
# Устанавливаем git для клонирования репозитория # Устанавливаем git для клонирования репозитория
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
@@ -27,6 +27,4 @@ ENV PYTHONDONTWRITEBYTECODE=1
# Команда запуска приложения (замените на вашу) # Команда запуска приложения (замените на вашу)
# CMD ["python", "-m", "mail_order_bot"] # CMD ["python", "-m", "mail_order_bot"]
WORKDIR /app/src/mail_order_bot CMD ["python", "src/mail_order_bot/main.py"]
CMD ["python", "/app/src/mail_order_bot/main.py"]

View File

@@ -1,32 +0,0 @@
[build-system]
requires = ["setuptools>=75.3.0"]
build-backend = "setuptools.build_meta"
[project]
name = "MailOrderBot"
description = "Config manager for building applications"
version = "1.0.4"
authors = [
{ name = "Aleksei Zosimov", email = "lesha.spb@gmail.com" }
]
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"python-dotenv>=1.0.0",
"config_manager @ git+https://git.lesha.spb.ru/alex/config_manager.git@master"
]
[tool.setuptools.packages.find]
where = ["src"]
[project.urls]
Homepage = "https://git.lesha.spb.ru/alex/mail_order_bot"
Documentation = "https://git.lesha.spb.ru/alex/mail_order_bot"
Repository = "https://git.lesha.spb.ru/alex/mail_order_bot"
[tool.pytest.ini_options]
addopts = [
"--import-mode=importlib",
]

14
pyptoject.toml Normal file
View File

@@ -0,0 +1,14 @@
[build-system]
requires = ["setuptools>=75.3.0"]
build-backend = "setuptools.build_meta"
[project]
name = "MailOrderBot"
requires-python = ">=3.12"
dependencies = [
"python-dotenv>=1.0.0"
]
dynamic = ["version"]
[tool.setuptools.packages.find]
where = ["src"]

View File

@@ -1,12 +0,0 @@
Metadata-Version: 2.4
Name: MailOrderBot
Version: 1.0.4
Summary: Config manager for building applications
Author-email: Aleksei Zosimov <lesha.spb@gmail.com>
Project-URL: Homepage, https://git.lesha.spb.ru/alex/mail_order_bot
Project-URL: Documentation, https://git.lesha.spb.ru/alex/mail_order_bot
Project-URL: Repository, https://git.lesha.spb.ru/alex/mail_order_bot
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: config_manager@ git+https://git.lesha.spb.ru/alex/config_manager.git@master

View File

@@ -1,18 +0,0 @@
README.md
pyproject.toml
src/MailOrderBot.egg-info/PKG-INFO
src/MailOrderBot.egg-info/SOURCES.txt
src/MailOrderBot.egg-info/dependency_links.txt
src/MailOrderBot.egg-info/requires.txt
src/MailOrderBot.egg-info/top_level.txt
src/mail_order_bot/__init__.py
src/mail_order_bot/main.py
src/mail_order_bot/email_client/__init__.py
src/mail_order_bot/email_client/client.py
src/mail_order_bot/email_client/objects.py
src/mail_order_bot/excel_processor/__init__.py
src/mail_order_bot/excel_processor/configurable_parser.py
src/mail_order_bot/excel_processor/excel_parser.py
src/mail_order_bot/excel_processor/order_position.py
src/mail_order_bot/excel_processor/parser_factory.py
src/mail_order_bot/excel_processor/processor.py

View File

@@ -1,2 +0,0 @@
python-dotenv>=1.0.0
config_manager@ git+https://git.lesha.spb.ru/alex/config_manager.git@master

View File

@@ -1 +0,0 @@
mail_order_bot

View File

@@ -1,35 +0,0 @@
import os
import hashlib
import requests
class AbcpProvider:
HOST = "https://id23089.public.api.abcp.ru"
HEADERS = {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded"
}
def __init__(self):
self.base_url = self.HOST
self.login = os.getenv("ABCP_LOGIN")
password = os.getenv("ABCP_PASSWORD")
self.password = hashlib.md5(password.encode("utf-8")).hexdigest()
def get_stock(self, order):
method = "GET"
path = "/search/articles"
for position in order.positions:
params = {"number": position.sku, "brand": position.manufacturer, "withOutAnalogs": "1"}
position.stock = self._execute(path, method, params)
def _execute(self, path, method="GET", params={}, data=None):
params["userlogin"] = self.login
params["userpsw"] = self.password
response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params)
if response.status_code != 200:
raise Exception(response.text)
return response.json()

View File

@@ -1,18 +0,0 @@
"""
Пакет содержит реализацию для создания заказов из вложений.
На входе файл из вложения
Его обработка производится хендлерами, коотрые настраиваются в конфиге
На выходе - экземпляр класса, который в себе содержит
- прочитанный файл в виде pandas DataFrame
- распарсенный файл заказа с позициями
- полученные остатки
- результат проверки возможности создания заказа
Так же класс содержит методы
- для создания заказа
- для получения отредактированного файла
"""
from .processor import TaskProcessor

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)}")

View File

@@ -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

View File

@@ -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})")

View File

@@ -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 = "Превышен порог отказов"

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -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)

View File

@@ -1,58 +0,0 @@
# Настройки обработки =================================================================
# Раздел с общими конфигурационными параметрами ===============================
update_interval: 10
work_interval: 30
email_dir: "spareparts"
# Логирование =================================================================
log:
version: 1
disable_existing_loggers: False
formatters:
standard:
format: '%(asctime)s %(module)15s [%(levelname)8s]: %(message)s'
telegram:
format: '%(message)s'
handlers:
console:
level: DEBUG
formatter: standard
class: logging.StreamHandler
stream: ext://sys.stdout # Default is stderr
file:
level: DEBUG
formatter: standard
class: logging.handlers.RotatingFileHandler
filename: logs/log.log
mode: a
maxBytes: 500000
backupCount: 10
telegram:
level: CRITICAL
formatter: telegram
class: logging_telegram_handler.TelegramHandler
chat_id: 211945135
alias: "Mail order bot"
# Логгеры
loggers:
'':
handlers: [console, file, telegram]
level: INFO
propagate: False
__main__:
handlers: [console, file, telegram]
level: INFO
propagate: False
config_manager:
handlers: [console, file]
level: DEBUG

View File

@@ -1,17 +0,0 @@
pipeline:
- handler: "ConfigurableExcelParser"
result_section: "positions"
config:
sheet_name: 0
key_field: "Код детали"
mapping:
article: "Код детали"
manufacturer: "Производитель"
name: "Наименование"
price: "Цена\nдетали"
quantity: "Кол-\nво"
total: "Сумма"

View File

@@ -1,2 +1,19 @@
from .client import EmailClient from .email_client import EmailClient
from .objects import EmailMessage, EmailAttachment from .email_objects import EmailMessage, EmailAttachment
__all__ = ['EmailClient', 'EmailMessage', 'EmailAttachment']
def test_email_client():
email_client = EmailClient(
imap_host='imap.yandex.ru',
smtp_host='smtp.yandex.ru',
email='zosimovaa@yandex.ru',
password='test'
)
assert email_client is not None
email_client.close()
pytest.main()
if __name__ == "__main__":
test_email_client()

View File

@@ -1,17 +1,17 @@
import imaplib
import smtplib
import re import re
from datetime import datetime
from typing import List, Optional
from dataclasses import dataclass
import email
from email import encoders
from email.mime.text import MIMEText 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 import encoders
import email
from email.header import decode_header from email.header import decode_header
import imaplib from datetime import datetime
import smtplib from typing import List, Optional
from dataclasses import dataclass
from .objects import EmailMessage, EmailAttachment from .email_objects import EmailMessage, EmailAttachment
class EmailClient: class EmailClient:
@@ -40,9 +40,26 @@ class EmailClient:
client.send_email(msg, to_addr='recipient@example.com') client.send_email(msg, to_addr='recipient@example.com')
""" """
def __init__(self, imap_host: str, smtp_host: str, email: str, password: str, def __init__(
imap_port: int = 993, smtp_port: int = 587): self,
imap_host: str,
smtp_host: str,
email: str,
password: str,
imap_port: int = 993,
smtp_port: int = 587
):
"""
Инициализация клиента электронной почты.
Args:
imap_host: IMAP сервер (например, 'imap.gmail.com')
smtp_host: SMTP сервер (например, 'smtp.gmail.com')
email: Email адрес
password: Пароль или app password
imap_port: Порт IMAP (по умолчанию 993 для SSL)
smtp_port: Порт SMTP (по умолчанию 587 для TLS)
"""
self.imap_host = imap_host self.imap_host = imap_host
self.smtp_host = smtp_host self.smtp_host = smtp_host
self.email = email self.email = email
@@ -51,24 +68,22 @@ class EmailClient:
self.smtp_port = smtp_port self.smtp_port = smtp_port
self.imap_conn = None self.imap_conn = None
def connect(self): def _connect_imap(self):
"""Установить IMAP соединение""" """Установить IMAP соединение"""
if self.imap_conn is None: if self.imap_conn is None:
self.imap_conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port) self.imap_conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
self.imap_conn.login(self.email, self.password) self.imap_conn.login(self.email, self.password)
def disconnect(self):
"""Закрыть IMAP соединение"""
if self.imap_conn:
try:
self.imap_conn.disconnect()
self.imap_conn.logout()
except:
pass
self.imap_conn = None
def _decode_header(self, header_value: str) -> str: def _decode_header(self, header_value: str) -> str:
"""Декодировать заголовок письма.""" """
Декодировать заголовок письма.
Args:
header_value: Значение заголовка
Returns:
Декодированная строка
"""
if header_value is None: if header_value is None:
return "" return ""
@@ -87,51 +102,74 @@ class EmailClient:
return ''.join(decoded_parts) return ''.join(decoded_parts)
def _extract_first_sender(self, body: str):
"""
Извлекает адреса отправителей из пересылаемого сообщения по паттерну:
-------- Пересылаемое сообщение --------
07.10.2025, 16:01, Имя (email@example.com):
Кому: ...
"""
# Ищем первую секцию пересылаемого сообщения (по структуре письма)
match = re.search(
r"-{8,}\\s*Пересылаемое сообщение\\s*-{8,}.*?(\\d{2}\\.\\d{2}\\.\\d{4},\\s*\\d{2}:\\d{2},.*?)\\(([^\\)]+)\\):",
body, re.DOTALL)
emails = []
if match:
emails.append(match.group(2)) # email из первой строки пересыла
# Ищем все email в первой пересылаемой секции (например, в "Кому:")
forwarded_section = re.search(
r"^-{8,}.*?Пересылаемое сообщение.*?:$(.*?)(?:^[-=]{5,}|\\Z)",
body, re.MULTILINE | re.DOTALL)
if forwarded_section:
addresses = re.findall(r"\\b([\\w\\.-]+@[\\w\\.-]+)\\b", forwarded_section.group(1))
for addr in addresses:
if addr not in emails:
emails.append(addr)
return emails
def _extract_body(self, msg: email.message.Message) -> str: def _extract_body(self, msg: email.message.Message) -> str:
"""Извлечь текст письма из любого типа содержимого, кроме вложений""" """
Извлечь текст письма.
Args:
msg: Объект письма
Returns:
Текст письма
"""
body = "" body = ""
if msg.is_multipart(): if msg.is_multipart():
for part in msg.walk(): for part in msg.walk():
content_type = part.get_content_type()
content_disposition = str(part.get("Content-Disposition", "")) content_disposition = str(part.get("Content-Disposition", ""))
# Пропускаем вложения
if "attachment" in content_disposition.lower(): # Ищем текстовые части без вложений
continue if content_type == "text/plain" and "attachment" not in content_disposition:
try: try:
charset = part.get_content_charset() or 'utf-8' charset = part.get_content_charset() or 'utf-8'
payload = part.get_payload(decode=True) body += part.get_payload(decode=True).decode(charset, errors='ignore')
if payload: except:
body_piece = payload.decode(charset, errors='ignore') pass
body += body_piece
except Exception:
pass
else: else:
try: try:
charset = msg.get_content_charset() or 'utf-8' charset = msg.get_content_charset() or 'utf-8'
payload = msg.get_payload(decode=True) body = msg.get_payload(decode=True).decode(charset, errors='ignore')
if payload: except:
body = payload.decode(charset, errors='ignore')
except Exception:
pass pass
return body return body
def __extract_email(self, text: str) -> str:
match = re.search(r'<([^<>]+)>', text)
if match:
return match.group(1)
return None
def _extract_first_sender(self, body: str):
"""Извлекает адреса отправителей из пересылаемого сообщения. Нужно для отладки"""
# Ищем email внутри скобок после строки "Пересылаемое сообщение"
pattern = r"Пересылаемое сообщение.*?\((.*?)\)"
match = re.search(pattern, body, re.DOTALL)
if match:
return match.group(1)
return None
def _extract_attachments(self, msg: email.message.Message) -> List[EmailAttachment]: def _extract_attachments(self, msg: email.message.Message) -> List[EmailAttachment]:
"""Извлечь вложения из письма.""" """
Извлечь вложения из письма.
Args:
msg: Объект письма
Returns:
Список вложений
"""
attachments = [] attachments = []
for part in msg.walk(): for part in msg.walk():
@@ -139,40 +177,38 @@ class EmailClient:
if "attachment" in content_disposition: if "attachment" in content_disposition:
filename = part.get_filename() filename = part.get_filename()
if filename: if filename:
# Декодируем имя файла # Декодируем имя файла
filename = self._decode_header(filename) filename = self._decode_header(filename)
# Получаем содержимое # Получаем содержимое
content = part.get_payload(decode=True) content = part.get_payload(decode=True)
if content: if content:
attachments.append(EmailAttachment(filename=filename, content=content)) attachments.append(
EmailAttachment(filename=filename, content=content)
)
return attachments return attachments
def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True, mark_as_read: bool = True) -> List[ def get_emails(
EmailMessage]: self,
"""Получить список новых электронных писем.""" folder: str = "INBOX",
self.connect() only_unseen: bool = True,
mark_as_read: bool = True
) -> List[EmailMessage]:
"""
Получить список новых электронных писем.
# Выбираем папку Args:
self.imap_conn.select(folder, readonly=False) folder: Папка для получения писем (по умолчанию "INBOX")
only_unseen: Получать только непрочитанные письма (по умолчанию True)
# Ищем письма Returns:
search_criteria = "(UNSEEN)" if only_unseen else "ALL" Список объектов EmailMessage
status, messages = self.imap_conn.search(None, search_criteria) """
self._connect_imap()
if status != "OK":
return []
email_ids = messages[0].split()
return email_ids
def get_emails(self, folder: str = "INBOX", only_unseen: bool = True, mark_as_read: bool = True) -> List[EmailMessage]:
"""Получить список новых электронных писем."""
self.connect()
# Выбираем папку # Выбираем папку
self.imap_conn.select(folder, readonly=False) self.imap_conn.select(folder, readonly=False)
@@ -203,8 +239,6 @@ class EmailClient:
from_addr = self._decode_header(msg.get("From", "")) from_addr = self._decode_header(msg.get("From", ""))
subject = self._decode_header(msg.get("Subject", "")) subject = self._decode_header(msg.get("Subject", ""))
from_email = self.__extract_email(from_addr)
# Получаем дату # Получаем дату
date_str = msg.get("Date", "") date_str = msg.get("Date", "")
try: try:
@@ -220,7 +254,6 @@ class EmailClient:
# Извлекаем тело письма # Извлекаем тело письма
body = self._extract_body(msg) body = self._extract_body(msg)
#print(body)
first_sender = self._extract_first_sender(body) first_sender = self._extract_first_sender(body)
# Извлекаем вложения # Извлекаем вложения
@@ -229,7 +262,6 @@ class EmailClient:
# Создаем объект письма # Создаем объект письма
email_obj = EmailMessage( email_obj = EmailMessage(
from_addr=from_addr, from_addr=from_addr,
from_email=from_email,
subj=subject, subj=subject,
dt=dt, dt=dt,
body=body, body=body,
@@ -249,8 +281,22 @@ class EmailClient:
return emails return emails
def send_email(self, message: EmailMessage, to_addr: str, cc: Optional[List[str]] = None, bcc: Optional[List[str]] = None): def send_email(
"""Отправить электронное письмо""" self,
message: EmailMessage,
to_addr: str,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None
):
"""
Отправить электронное письмо.
Args:
message: Объект EmailMessage для отправки
to_addr: Адрес получателя
cc: Список адресов для копии (необязательно)
bcc: Список адресов для скрытой копии (необязательно)
"""
# Создаем multipart сообщение # Создаем multipart сообщение
msg = MIMEMultipart() msg = MIMEMultipart()
msg['From'] = self.email msg['From'] = self.email
@@ -287,12 +333,21 @@ class EmailClient:
server.login(self.email, self.password) server.login(self.email, self.password)
server.sendmail(self.email, recipients, msg.as_string()) server.sendmail(self.email, recipients, msg.as_string())
def close(self):
"""Закрыть IMAP соединение"""
if self.imap_conn:
try:
self.imap_conn.close()
self.imap_conn.logout()
except:
pass
self.imap_conn = None
def __enter__(self): def __enter__(self):
"""Поддержка контекстного менеджера""" """Поддержка контекстного менеджера"""
self.connect()
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
"""Поддержка контекстного менеджера""" """Поддержка контекстного менеджера"""
self.disconnect() self.close()

View File

@@ -1,6 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from email.message import EmailMessage
from typing import List from typing import List
@@ -14,10 +13,7 @@ class EmailAttachment:
@dataclass @dataclass
class EmailMessage: class EmailMessage:
"""Класс для представления электронного письма""" """Класс для представления электронного письма"""
message: EmailMessage
attachments: List[EmailAttachment]
from_addr: str from_addr: str
from_email: str
subj: str subj: str
dt: datetime dt: datetime
body: str body: str

View File

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

View File

@@ -1,31 +0,0 @@
import threading
from typing import Any, Dict
class _SingletonMeta(type):
_instances = {}
_lock = threading.Lock()
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
with cls._lock:
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class Context(metaclass=_SingletonMeta):
def __init__(self):
# будет вызван только при первом создании
self.context = {}
def clear_context(self):
"""Очищает self.context, устанавливая его в None или пустой словарь"""
with self._lock: # потокобезопасная очистка
self.context = {}
print("Context очищен") # опциональный лог
def set_context(self, new_context: Dict[str, Any]):
"""Устанавливает новый контекст (бонусный метод)"""
with self._lock:
self.context = new_context
print("Новый контекст установлен")

View File

@@ -1,47 +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 .context import Context
from enum import Enum
from .handlers import *
class RequestStatus(Enum):
NEW = "new"
IN_PROGRESS = "in progress"
FAILED = "failed"
EXECUTED = "executed"
OPERATOR_REQUIRED = "operator required"
INVALID = "invalid"
class EmailProcessor(Context):
def __init__(self, configs_path: Path):
super().__init__()
self.configs_path = configs_path
self.status = RequestStatus.NEW
def process(self, email_id):
config = self._load_config(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)
task.do()
def _load_config(self, client) -> Dict[str, Any]:
"""Загружает конфигурацию из YAML или JSON"""
path = os.path.join(self.configs_path, client + '.yml')
with open(path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def _load_email(self):

View File

@@ -1,9 +0,0 @@
from .abcp_clients.check_stock import GetStock
from .abcp_clients.create_order import InstantOrderTest
from .excel_parcers.order_parcer_basic import BasicExcelParser
from .notifications.test_notifier import TestNotifier
from .validators.price_quantity_ckecker import CheckOrder

View File

@@ -1,27 +0,0 @@
import random
import logging
from mail_order_bot.email_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

View File

@@ -1,37 +0,0 @@
import logging
import requests
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
from mail_order_bot.email_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)

View File

@@ -1,24 +0,0 @@
from abc import ABC, abstractmethod
from typing import Dict, Any
from mail_order_bot.email_handler.context import Context
class AbstractTask(ABC, Context):
RESULT_SECTION = "section"
"""
Абстрактный базовый класс для всех хэндлеров.
"""
def __init__(self, config: Dict[str, Any]) -> None:
Context.__init__(self, {})
self.config = config
@abstractmethod
def do(self) -> None:
"""
Выполняет работу над заданием
Входные и выходные данные - в self.context
Конфиг задается при инициализации
"""
raise NotImplementedError

View File

@@ -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)}")

View File

@@ -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.email_handler.handlers.order_position import OrderPosition
from mail_order_bot.email_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

View File

@@ -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.email_handler.handlers.order_position import OrderPosition
from mail_order_bot.email_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

View File

@@ -1,15 +0,0 @@
import logging
from mail_order_bot.email_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})")

View File

@@ -1,50 +0,0 @@
import random
import logging
from mail_order_bot.email_handler.handlers.abstract_task import AbstractTask
from mail_order_bot.email_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 = "Превышен порог отказов"

View File

@@ -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)

View File

@@ -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 # Производитель
requested_quantity: int # Количество
total: Decimal = 0 # Общая сумма
name: str = "" # Наименование
requested_price: Decimal = 0 # Цена за единицу
order_quantity: int = 0 # Количество для заказа
order_price: Decimal = Decimal('0.0') # Цена в заказе
stock: List[Dict[str, Any]] = None
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")

View File

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

View File

@@ -1,25 +1,17 @@
import logging from typing import Optional
import pandas as pd
from typing import Dict, Any, Optional, List
from decimal import Decimal
from .excel_parser import ExcelParser
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class ConfigurableExcelParser(ExcelParser): class ConfigurableExcelParser(ExcelParser):
""" """
Универсальный парсер, настраиваемый через конфигурацию. Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев. Подходит для большинства стандартных случаев.
""" """
def parse(self, file_bytes: str) -> List[OrderPosition]: def parse(self, filepath: str) -> List[OrderPosition]:
try: try:
# Читаем Excel # Читаем Excel
df = self._make_dataframe(file_bytes) df = self._read_excel(filepath)
# Удаляем пустые строки
df = df.dropna(how='all')
# Получаем маппинг колонок из конфигурации # Получаем маппинг колонок из конфигурации
mapping = self.config['mapping'] mapping = self.config['mapping']
@@ -32,50 +24,36 @@ class ConfigurableExcelParser(ExcelParser):
if position: if position:
positions.append(position) positions.append(position)
except Exception as e: except Exception as e:
logger.error(f"Ошибка парсинга строки {idx}: {e}, {row}") logger.warning(f"Ошибка парсинга строки {idx}: {e}")
continue continue
logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк") logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк")
return positions return positions
except Exception as e: except Exception as e:
logger.error(f"Ошибка при обработке файла: {e}") logger.error(f"Ошибка при обработке файла {filepath}: {e}")
raise Exception from e raise
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[OrderPosition]: def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[OrderPosition]:
"""Парсит одну строку Excel в OrderPosition""" """Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля # Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity'] required_fields = ['article', 'manufacturer', 'name', 'price', 'quantity', 'total']
for field in required_fields: for field in required_fields:
if pd.isna(row.get(mapping[field])): if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None 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 = OrderPosition( position = OrderPosition(
article=str(row[mapping['article']]).strip(), article=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer',"")]).strip(), manufacturer=str(row[mapping['manufacturer']]).strip(),
name=name, name=str(row[mapping['name']]).strip(),
price=price, price=Decimal(str(row[mapping['price']])),
quantity=quantity, quantity=int(row[mapping['quantity']]),
total=total, total=Decimal(str(row[mapping['total']])),
additional_attrs=self._extract_additional_attrs(row, mapping) additional_attrs=self._extract_additional_attrs(row, mapping)
) )
return position return position
def _extract_additional_attrs(self, row: pd.Series, mapping: Dict[str, str]) -> Dict[str, Any]: def _extract_additional_attrs(self, row: pd.Series, mapping: Dict[str, str]) -> Dict[str, Any]:
@@ -88,26 +66,3 @@ class ConfigurableExcelParser(ExcelParser):
additional[col] = row[col] additional[col] = row[col]
return additional 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

View File

@@ -1,105 +0,0 @@
import logging
import pandas as pd
from typing import Dict, Any, Optional, List
from decimal import Decimal
import xlrd
from io import BytesIO
from .excel_parser import ExcelParser
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class CustomExcelParserAutoeuro(ExcelParser):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def parse(self, file_bytes: BytesIO) -> List[OrderPosition]:
try:
# Читаем Excel
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)
except Exception as e:
logger.error(f"Ошибка парсинга строки {idx}: {e}, {row}")
continue
logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк")
return 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[OrderPosition]:
"""Парсит одну строку 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
# Создаем объект позиции
position = OrderPosition(
article=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer', "")]).strip(),
name="", #str(row[mapping.get('name', "name")]).strip(),
price=price,
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:
file_bytes = bio.read()
book = xlrd.open_workbook(file_contents=file_bytes, encoding_override='cp1251')
sheet = book.sheet_by_index(self.config.get("sheet_index", 0))
data = [sheet.row_values(row) for row in range(sheet.nrows)]
df_full = pd.DataFrame(data)
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 = df_full[header_row_idx:]
df.columns = df.iloc[0] # первая строка становится заголовком
df = df.reset_index(drop=True).drop(0).reset_index(drop=True) # удаляем первую строку и сбрасываем индекс
return df

View File

@@ -1,12 +1,7 @@
import logging
import pandas as pd
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, Any, List from typing import List
from io import BytesIO import pandas as pd
import logging
from .order_position import OrderPosition
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,9 +15,19 @@ class ExcelParser(ABC):
self.config = config self.config = config
@abstractmethod @abstractmethod
def parse(self, file: BytesIO) -> List[OrderPosition]: def parse(self, filepath: str) -> List[OrderPosition]:
""" """
Парсит Excel файл и возвращает список позиций. Парсит Excel файл и возвращает список позиций.
Должен быть реализован в каждом конкретном парсере. Должен быть реализован в каждом конкретном парсере.
""" """
pass pass
def _read_excel(self, filepath: str) -> pd.DataFrame:
"""Общий метод для чтения Excel файлов"""
return pd.read_excel(
filepath,
sheet_name=self.config.get('sheet_name', 0),
header=self.config.get('header_row', 0),
#engine='openpyxl'
engine='calamine'
)

View File

@@ -1,36 +1,31 @@
import logging
from pathlib import Path
from decimal import Decimal
from io import BytesIO
from typing import Dict, Any, List
import yaml
import json
from .parser_factory import ParserFactory
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
class ExcelProcessor: class ExcelProcessor:
""" """
Главный класс-фасад для обработки Excel файлов. Главный класс-фасад для обработки Excel файлов.
Упрощает использование системы. Упрощает использование системы.
""" """
def __init__(self, config_path: str = 'config/suppliers.yaml', ): def __init__(self, config_path: str = 'config/suppliers.yaml'):
self.config_path = Path(config_path) self.factory = ParserFactory(config_path)
self.config = self._load_config() self._setup_logging()
self.factory = ParserFactory(self.config)
def process(self, file_bytes: BytesIO, file_name: str, supplier_name: str, validate: bool = False) -> List[OrderPosition]: def _setup_logging(self):
"""Настройка логирования"""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def process_file(
self,
filepath: str,
supplier_name: str,
validate: bool = True
) -> List[OrderPosition]:
""" """
Обрабатывает Excel файл от контрагента. Обрабатывает Excel файл от контрагента.
Args: Args:
file_bytes: Байты файла filepath: Путь к Excel файлу
file_name: Имя файла
supplier_name: Название контрагента (из конфигурации) supplier_name: Название контрагента (из конфигурации)
validate: Выполнять ли дополнительную валидацию validate: Выполнять ли дополнительную валидацию
@@ -39,34 +34,25 @@ class ExcelProcessor:
Raises: Raises:
ValueError: Если контрагент не найден ValueError: Если контрагент не найден
FileNotFoundError: Если файл не найден
""" """
logger.info(f"Обработка файла: {file_name} для {supplier_name}") logger.info(f"Начало обработки файла: {filepath} для {supplier_name}")
# Проверка существования файла
if not Path(filepath).exists():
raise FileNotFoundError(f"Файл не найден: {filepath}")
# Получаем парсер и обрабатываем
parser = self.factory.get_parser(supplier_name) parser = self.factory.get_parser(supplier_name)
positions = parser.parse(file_bytes) positions = parser.parse(filepath)
# Дополнительная валидация если нужна # Дополнительная валидация если нужна
if validate: if validate:
positions = self._validate_positions(positions) positions = self._validate_positions(positions)
logger.debug(f"Обработка завершена: получено {len(positions)} позиций") logger.info(f"Обработка завершена: получено {len(positions)} позиций")
return positions return positions
def process_file(self, file_path: str, supplier_name: str, validate: bool = False) -> List[OrderPosition]:
# Проверка существования файла
logger.debug(f"Чтение файла: {file_path}")
if not Path(file_path).exists():
raise FileNotFoundError(f"Файл не найден: {file_path}")
with open(file_path, 'rb') as file: # бинарный режим
raw_data = file.read()
bio = BytesIO(raw_data)
positions = self.process(bio, file_path, supplier_name, validate=validate)
return positions
def _validate_positions(self, positions: List[OrderPosition]) -> List[OrderPosition]: def _validate_positions(self, positions: List[OrderPosition]) -> List[OrderPosition]:
"""Дополнительная валидация позиций""" """Дополнительная валидация позиций"""
valid_positions = [] valid_positions = []
@@ -97,14 +83,3 @@ class ExcelProcessor:
def get_available_suppliers(self) -> List[str]: def get_available_suppliers(self) -> List[str]:
"""Возвращает список доступных контрагентов""" """Возвращает список доступных контрагентов"""
return self.factory.list_suppliers() return self.factory.list_suppliers()
def _load_config(self) -> Dict[str, Any]:
"""Загружает конфигурацию из YAML или JSON"""
if self.config_path.suffix in ['.yaml', '.yml']:
with open(self.config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
elif self.config_path.suffix == '.json':
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
else:
raise ValueError(f"Неподдерживаемый формат конфига: {self.config_path.suffix}")

View File

@@ -1,15 +1,6 @@
import yaml import yaml
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List
from .excel_parser import ExcelParser
from .configurable_parser import ConfigurableExcelParser
from .custom_parser_autoeuro import CustomExcelParserAutoeuro
logger = logging.getLogger(__name__)
class ParserFactory: class ParserFactory:
""" """
@@ -19,36 +10,48 @@ class ParserFactory:
# Реестр кастомных парсеров # Реестр кастомных парсеров
CUSTOM_PARSERS = { CUSTOM_PARSERS = {
'autoeuro.ru': CustomExcelParserAutoeuro, 'supplier_a': SupplierAParser,
# Добавляйте сюда специализированные парсеры # Добавляйте сюда специализированные парсеры
} }
def __init__(self, config: Dict[str, Any]): def __init__(self, config_path: str):
self.config = config self.config_path = Path(config_path)
self.suppliers_config = self._load_config()
def _load_config(self) -> Dict[str, Any]:
"""Загружает конфигурацию из YAML или JSON"""
if self.config_path.suffix in ['.yaml', '.yml']:
with open(self.config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
elif self.config_path.suffix == '.json':
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
else:
raise ValueError(f"Неподдерживаемый формат конфига: {self.config_path.suffix}")
def get_parser(self, supplier_name: str) -> ExcelParser: def get_parser(self, supplier_name: str) -> ExcelParser:
""" """
Возвращает парсер для указанного контрагента. Возвращает парсер для указанного контрагента.
Использует кастомный парсер если есть, иначе конфигурируемый. Использует кастомный парсер если есть, иначе конфигурируемый.
""" """
if supplier_name not in self.config['suppliers']: if supplier_name not in self.suppliers_config['suppliers']:
raise ValueError( raise ValueError(
f"Контрагент '{supplier_name}' не найден в конфигурации. " f"Контрагент '{supplier_name}' не найден в конфигурации. "
f"Доступные: {list(self.config['suppliers'].keys())}" f"Доступные: {list(self.suppliers_config['suppliers'].keys())}"
) )
config = self.config['suppliers'][supplier_name] config = self.suppliers_config['suppliers'][supplier_name]
# Проверяем, есть ли кастомный парсер # Проверяем, есть ли кастомный парсер
if supplier_name in self.CUSTOM_PARSERS: if supplier_name in self.CUSTOM_PARSERS:
parser_class = self.CUSTOM_PARSERS[supplier_name] parser_class = self.CUSTOM_PARSERS[supplier_name]
logger.debug(f"Используется кастомный парсер для {supplier_name}") logger.info(f"Используется кастомный парсер для {supplier_name}")
else: else:
parser_class = ConfigurableExcelParser parser_class = ConfigurableExcelParser
logger.debug(f"Используется конфигурируемый парсер для {supplier_name}") logger.info(f"Используется конфигурируемый парсер для {supplier_name}")
return parser_class(config) return parser_class(config)
def list_suppliers(self) -> List[str]: def list_suppliers(self) -> List[str]:
"""Возвращает список всех доступных контрагентов""" """Возвращает список всех доступных контрагентов"""
return list(self.config['suppliers'].keys()) return list(self.suppliers_config['suppliers'].keys())

View File

@@ -1,60 +0,0 @@
from config_manager import ConfigManager
from dotenv import load_dotenv
import asyncio
import logging
import os
from dotenv import load_dotenv
from email_client import EmailClient
from excel_proceccor import ExcelProcessor
logger = logging.getLogger()
class MailOrderBot(ConfigManager):
def __init__(self, *agrs, **kwargs):
super().__init__(*agrs, **kwargs)
self.email_client = EmailClient(
imap_host=os.getenv('IMAP_HOST'),
smtp_host=os.getenv('SMTP_HOST'),
email=os.getenv('EMAIL_USER'),
password=os.getenv('EMAIL_PASSWORD'),
imap_port=os.getenv('IMAP_PORT'),
smtp_port=os.getenv('SMTP_PORT')
)
def execute(self):
logger.debug(f"Check emails for new orders")
emails = self.email_client.get_emails(folder="spareparts", only_unseen=True, mark_as_read=True)
for email in emails:
logger.info(email.subj)
logger.info(email.from_addr)
logger.info(email.dt)
logger.info(email.body)
logger.info(email.first_sender)
logger.info('--------------------------------')
logger.critical("mail checked")
logger = logging.getLogger()
async def main():
app = MailOrderBot("config.yml")
await app.start()
#await asyncio.sleep(200)
#await app.stop()
if __name__ == "__main__":
if os.environ.get("APP_ENV") != "PRODUCTION":
logger.warning("Non production environment")
load_dotenv()
asyncio.run(main())

5
src/main.py Normal file
View File

@@ -0,0 +1,5 @@
from config_manager import Configmanager
if __name__=="__main__":
print("Hello, World!")

View File

@@ -1,22 +0,0 @@
import os
from dotenv import load_dotenv
from mail_order_bot.abcp_api.abcp_provider import AbcpProvider
from mail_order_bot.email_handler.order.auto_part_order import AutoPartOrder
from mail_order_bot.email_handler.order.auto_part_position import AutoPartPosition
if __name__ == "__main__":
print(__name__)# подгружаем переменные окружения
load_dotenv()
order = AutoPartOrder()
position = AutoPartPosition(sku="560300054", manufacturer="VST", requested_quantity=1)
order.add_position(position)
provider = AbcpProvider()
provider.get_stock(order)
print(order.positions[0].stock)
print(os.getenv('ABCP_LOGIN'))

View File

@@ -1,13 +1,13 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
import sys
sys.path.append('./src')
load_dotenv()
from mail_order_bot.email_client import EmailClient from mail_order_bot.email_client import EmailClient
if __name__ == "__main__": if __name__ == "__main__":
print(__name__)
# подгружаем переменные окружения
load_dotenv()
email_client = EmailClient( email_client = EmailClient(
imap_host=os.getenv('IMAP_HOST'), imap_host=os.getenv('IMAP_HOST'),
smtp_host=os.getenv('SMTP_HOST'), smtp_host=os.getenv('SMTP_HOST'),
@@ -16,14 +16,13 @@ if __name__ == "__main__":
imap_port=os.getenv('IMAP_PORT'), imap_port=os.getenv('IMAP_PORT'),
smtp_port=os.getenv('SMTP_PORT') smtp_port=os.getenv('SMTP_PORT')
) )
emails = email_client.get_emails(folder='spareparts', only_unseen=True, mark_as_read=False) emails = email_client.get_emails(folder='spareparts', only_unseen=True, mark_as_read=True)
for email in emails: for email in emails:
print(email.subj) print(email.subj)
print(email.from_addr) print(email.from_addr)
print(email.from_email)
print(email.dt) print(email.dt)
print(email.body)
print(email.first_sender) print(email.first_sender)
print('--------------------------------') print('--------------------------------')
email_client.disconnect() email_client.close()

View File

@@ -1,42 +0,0 @@
# Конфигурационный файл для контрагента mikado-parts.ru
pipeline:
# Обработчик вложений - извлекает из экселя данные
- handler: BasicExcelParser
config:
sheet_name: 0 # Можно указать индекс листа
key_field: "артикул" # Поле, по которому будет определяться заголовок блока с данными и будут отсекаться незаполненные строки
mapping:
article: "артикул"
manufacturer: "бренд"
name: "наименование"
price: "цена"
quantity: "количество"
# Обработчик получает данные со склада о цене и остатках по каждой позиций
- handler: GetStock
config:
max_stock: 2
min_stock: 0
# Обработчик проверяет заказ на возможность автоматической обработки
- handler: CheckOrder
config:
acceptable_price_reduction: 2
refusal_threshold: 0.1
# Создание заказа
- handler: InstantOrderTest
config:
api_key: "8056899069:AAFEfw9QRMvmEwQyH0CI4e_v_sZuOSdNWcE"
chat_id: 211945135
# Отправка уведомлений менеджерам
#- handler: "TestNotifier"

View File

@@ -1,27 +0,0 @@
# Конфигурационный файл для контрагента todx.ru
pipeline:
# Обработчик вложений
- handler: "BasicExcelParser"
config:
sheet_name: 0
key_field: "Код детали"
mapping:
article: "Код детали"
manufacturer: "Производитель"
name: "Наименование"
price: "Цена\nдетали"
quantity: "Кол-\nво"
total: "Сумма"
- handler: InstantOrderTest
config:
api_key: "8056899069:AAFEfw9QRMvmEwQyH0CI4e_v_sZuOSdNWcE"
chat_id: 211945135
- handler: "TestNotifier"

View File

@@ -1,60 +0,0 @@
import os
import chardet # pip install chardet
import traceback
from mail_order_bot.email_handler import EmailProcessor
import datetime
# установим рабочую директорию
import os
os.chdir(os.path.dirname(os.path.abspath(__file__)))
from io import BytesIO
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.WARNING, format='%(module)s - %(message)s') # %(asctime)s -
BASE_PATH = './files'
from mail_order_bot.email_client import EmailMessage, EmailAttachment
processor = EmailProcessor("./configs")
for provider_name in os.listdir(BASE_PATH):
provider_folder = os.path.join(BASE_PATH, provider_name)
if os.path.isdir(provider_folder):
for file_name in os.listdir(provider_folder):
file_path = os.path.join(provider_folder, file_name)
if os.path.isfile(file_path):
with open(file_path, 'rb') as file: # бинарный режим
raw_data = file.read()
# Создаем объект EmailAttachment
att = EmailAttachment(file_name, raw_data)
email = EmailMessage(
message=None,
from_addr=provider_name,
from_email='test@gmail.com',
subj='order request',
dt=datetime.datetime.now(),
body= 'body text',
attachments=[att],
first_sender='test@gmail.com'
)
#bio = BytesIO(raw_data)
print("========================================================")
print(f'Обработка: {provider_name} - {file_name}')
try:
positions_a = processor.process(provider_name, att)
except Exception as e:
print(f"Ошибка обработки: {e}", traceback.format_exc())

View File

@@ -1,48 +0,0 @@
import os
import chardet # pip install chardet
import traceback
from mail_order_bot.excel_processor import ExcelProcessor
# установим рабочую директорию
import os
os.chdir(os.path.dirname(os.path.abspath(__file__)))
from io import BytesIO
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.WARNING, format='%(module)s - %(message)s') #%(asctime)s -
BASE_PATH = './files'
processor = ExcelProcessor("./suppliers.yml")
for provider_name in os.listdir(BASE_PATH):
provider_folder = os.path.join(BASE_PATH, provider_name)
if os.path.isdir(provider_folder):
for file_name in os.listdir(provider_folder):
file_path = os.path.join(provider_folder, file_name)
if os.path.isfile(file_path):
with open(file_path, 'rb') as file: # бинарный режим
raw_data = file.read()
bio = BytesIO(raw_data)
print("========================================================")
print(f'Обработка: {provider_name} - {file_name}')
try:
positions_a = processor.process(
file_bytes=bio,
file_name=file_name,
supplier_name=provider_name
)
print(f"\nПолучено {len(positions_a)} позиций от {provider_name}:")
for pos in positions_a: # Первые 5
print(f" - {pos.article}: {pos.name} "
f"({pos.quantity} x {pos.price} = {pos.total})")
except Exception as e:
print(f"Ошибка обработки: {e}", traceback.format_exc())

View File

@@ -1,411 +0,0 @@
suppliers:
# order@stparts.ru
"order@stparts.ru":
sheet_name: "TDSheet" # Название листа Excel
key_field: "Номер"
# Маппинг: внутреннее_поле -> названиеолонки_в_Excel
mapping:
article: "Номер"
manufacturer: "Бренд"
name: "Описание"
price: "Цена"
quantity: "Количество"
#total: "Сумма"
#Вопросы: что за поле "Фактическая_отгрузка"?
EMPTY-FROM:
sheet_name: 0
key_field: "Артикул" # Заголовки во второй строке
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Название"
price: "Цена"
quantity: "Количество"
#total: "Сумма с НДС"
options:
decimal_separator: ","
encoding: "utf-8"
#thousand_separator: ","
# Примечание: гемор - нет имейла
"order@avtoto.ru":
sheet_name: "Заказы" # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Изготовитель"
name: "Наименование товара"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
"автолига.рф":
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Название детали"
price: "Цена, р."
quantity: "Количество"
total: "Сумма, р."
abstd.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул поставщика"
mapping:
article: "Артикул поставщика"
manufacturer: "Бренд поставщика"
name: "Наименование"
price: "Цена поставщика"
quantity: "Кол-во"
total: "Сумма"
adeo.pro:
sheet_name: 0 # Можно указать индекс листа
key_field: "Каталожный номер"
mapping:
article: "Каталожный номер"
manufacturer: "Производитель"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
amtel.club:
sheet_name: 0 # Можно указать индекс листа
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Фирма"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
auto-sputnik.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
autocode.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Бренд"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
# Надо ли как-то учитывать доп поля типа Кол-во в отказ?
autopiter.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Каталог"
price: "Цена"
quantity: "Кол-во"
autostels.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "№ Детали"
mapping:
article: "№ Детали"
manufacturer: "Производитель"
name: "Наименование"
price: "Прайс"
quantity: "Количество"
total: "Сумма"
avtoformula.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "№ Детали"
mapping:
article: "№ Детали"
manufacturer: "Производитель"
name: "Наименование"
price: "Прайс"
quantity: "Количество"
total: "Сумма"
autoeuro.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Номер Производителя"
mapping:
article: "Номер Производителя"
manufacturer: "Производитель"
price: "Цена"
quantity: "Количество"
avtogut.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Наименование"
price: "Цена, р."
quantity: "Количество"
total: "Сумма, р."
avtokrep.spb.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Бренд"
name: "Наименование"
price: "Цена"
quantity: "Количество"
avtolavka.net:
sheet_name: 0 # Можно указать индекс листа
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Бренд"
name: "Описание"
price: "Цена закупки"
quantity: "Количество"
avtoto.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Название"
price: "Цена (рубли)"
quantity: "Кол-во"
detal.msk.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Наименование"
price: "Цена"
quantity: "Количество"
total: "Сумма"
detali.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Код поставщика"
mapping:
article: "Код поставщика"
manufacturer: "Бренд"
name: "Наименование"
price: "Цена"
quantity: "Количество"
e-tape.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Название детали"
price: "Цена, р."
quantity: "Количество"
total: "Сумма, р."
forum-auto.ru:
sheet_name: 0 # Можно указать индекс листа
key_field: "Номер по каталогу"
mapping:
article: "Номер по каталогу"
manufacturer: "Производитель"
name: "Название детали"
price: "Цена"
quantity: "Количество"
"info_avtor@mail.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Бренд"
name: "Товары (работы, услуги)"
price: "Цена"
quantity: "Количество"
total: "Сумма"
"mikado-parts.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "артикул"
mapping:
article: "артикул"
manufacturer: "бренд"
name: "наименование"
price: "цена"
quantity: "количество"
"multikrep.com":
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Бренд"
name: "Наименование"
price: "Цена"
quantity: "Количество"
"only-original.parts":
sheet_name: 0 # Можно указать индекс листа
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Бренд"
name: "Описание"
price: "Цена закупки"
quantity: "Количество"
"part-kom.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Код\nпоставщика"
mapping:
article: "Код\nпоставщика"
manufacturer: "Изготовитель"
name: "Наименование товара"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
"parterra.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул поставщика"
mapping:
article: "Артикул поставщика"
manufacturer: "Производитель Поставщика"
name: "Номенклатура"
price: "Цена"
quantity: "Количество (в единицах хранения)"
total: "Сумма с НДС"
"pmmobile.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Бренд"
name: "Описание"
price: "Цена закупки"
quantity: "Количество"
"rmsauto.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Номер детали"
mapping:
article: "Номер детали"
manufacturer: "Производитель"
name: "Наименование детали"
price: "Цена, рублей (с НДС)"
quantity: "Количество заказанное, штук"
total: "Сумма, рублей (с НДС)"
"rnsprice.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Марка"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
"stutzen.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Название детали"
price: "Цена, р."
quantity: "Количество"
total: "Сумма, р."
"sz-snab.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Бренд"
name: "Описание"
price: "Цена закупки"
quantity: "Количество"
"todx.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Код детали"
mapping:
article: "Код детали"
manufacturer: "Производитель"
name: "Наименование"
price: "Цена\nдетали"
quantity: "Кол-\nво"
total: "Сумма"
"uniqom.ru":
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Брэнд"
name: "Наименование товара"
price: "Цена"
quantity: "Заказ, кол-во"
total: "Сумма"
"Рай Авто СПб":
sheet_name: 0 # Можно указать индекс листа
key_field: "Артикул"
mapping:
article: "Артикул"
manufacturer: "Производитель"
name: "Название"
price: "Цена"
quantity: "Количество"

View File

@@ -1,9 +0,0 @@
from requests_html import HTMLSession
print(1)
session = HTMLSession()
response = session.get("https://zapchastiya.ru/")
print(2)
response.html.render(wait=2) # Ждем выполнения JS, 2 секунды например
print(3)
print(response.html.html) # Выводим страницу после выполнения JS

View File

@@ -1 +0,0 @@
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">\n<html>\n<head>\n<title>Robot Check Redirector</title>\n<meta http-equiv="Cache-Control" content="no-cache">\n<META NAME="ROBOTS" CONTENT="NOINDEX, NOFOLLOW">\n<meta name="Document-state" content="Dynamic">\n<meta name="Resource-type" content="document">\n<meta http-equiv="Content-Type" content="text/html; charset=utf-8">\n<script type="text/javascript">\nfunction checkTheRobot() {\n var myurl = window.location.href.toString();\n var mycheckurl = "https://hcaptcha-antibot.nodacdn.net";\n var myparams = "/?cngx=1&original_url=";\n var newurl = mycheckurl.concat(myparams,myurl);\n setTimeout(function(){ window.location.href = newurl ; } , 5000)\n}\n\n</script>\n<title></title>\n</head>\n<body onload="checkTheRobot();" style="font-family: Arial, Sans-Serif; background:#cef0fa">\n\n<p><br></p>\n<center>\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/file-uploader/3.7.0/processing.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.7.1/img/loading.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.lazyloadxt/1.0.5/loading.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/fancybox/2.1.5/fancybox_loading@2x.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery-mobile/1.4.1/images/ajax-loader.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/fancybox/2.1.5/fancybox_loading@2x.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.lazyloadxt/1.0.5/loading.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/lightbox2/2.7.1/img/loading.gif">&nbsp;\n<img border="0" src="https://cdnjs.cloudflare.com/ajax/libs/file-uploader/3.7.0/processing.gif">&nbsp;\n\n<p>You will be redirected to Robot Checker. Please enable Javascript in browser.</p>\n</center>\n</body>\n</html>\n'