30 Commits

Author SHA1 Message Date
7043743373 no message 2025-12-14 15:00:18 +03:00
0c39af460f no message 2025-12-12 23:25:39 +03:00
1222488aec no message 2025-12-11 23:06:13 +03:00
816da1eb16 no message 2025-12-11 21:28:07 +03:00
03aca0ecb9 no message 2025-12-09 22:09:13 +03:00
67b75e531a no message 2025-12-08 23:22:19 +03:00
084dc53baa no message 2025-12-07 17:03:40 +03:00
6bcac057d1 no message 2025-12-07 16:44:23 +03:00
03a64d6263 no message 2025-12-06 21:25:27 +03:00
ce12f23426 no message 2025-12-06 16:58:24 +03:00
b8d4e0ddd1 И еще один шаг к успеху 2025-11-13 22:46:49 +03:00
285948170d Продолжаем выстраивать архитектуру приложения 2025-11-12 23:11:27 +03:00
1239db4e23 добавил в игнор файлы с заказами 2025-11-11 23:09:53 +03:00
ee439c6bf6 Рефаткоринг, добавляю пайплайн 2025-11-11 23:08:40 +03:00
6abceda30e Рефакторинг, добавляю пайплайн 2025-11-11 23:07:52 +03:00
0db1509f0f Настройка парсера 2025-11-10 21:45:14 +03:00
f6d186ab56 Try parse first excel file 2025-11-08 21:46:34 +03:00
bd1faa5a79 Completed transfer of classes, corrected imports. 2025-11-08 17:46:27 +03:00
50ac5c97ab no message 2025-11-07 21:07:04 +03:00
0de91df486 Игнорим логи 2025-11-07 21:06:52 +03:00
cfefb49ef8 Починил парсинг адресов эл почты 2025-11-07 21:06:00 +03:00
e18407f33e Добавил логирование в телегу 2025-11-07 21:05:21 +03:00
9ff34bc049 Добавил в dockerfile рабочую директорию 2025-11-02 16:42:10 +03:00
b91d621efd Поправил конифг pyproject.toml - изменил установку пакета config_manager 2025-11-02 13:03:38 +03:00
22bdcafc59 Тестовое приложене готово 2025-11-01 21:56:30 +03:00
093b29e16c Актализировал пакеты и структуру 2025-11-01 08:45:55 +03:00
81bcb0c3b8 no message 2025-10-29 23:02:21 +03:00
d93964b793 Docker configs was added 2025-10-29 23:01:26 +03:00
076caf036b Basic app and config was created 2025-10-29 22:52:43 +03:00
0bb96d00d4 Update app config 2025-10-29 22:50:05 +03:00
57 changed files with 2645 additions and 542 deletions

7
.gitignore vendored
View File

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

9
business_rules/br.md Normal file
View File

@@ -0,0 +1,9 @@
Создание заказа через API ABCP
1. Логинимся под учеткой заказчика
2. Получаем остатки
3. Отсекаем не подходящие по сроку (дольше) и цене
4. Подбираем позицию максимально близкую к цене из заказа
- Приоритет отдаем складу, где есть все заказы
- Приоритет отдаем позициям из наличия, потом с доставкой с других складов
- По цене выбираем наиболее близкую к цене заказа (меньше или равно)
- При невозможности заказать в одном месте разбиваем заказ из нескольких складов

View File

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

32
pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[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",
]

View File

@@ -1,14 +0,0 @@
[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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1 @@

View File

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

View File

@@ -0,0 +1 @@
mail_order_bot

View File

@@ -0,0 +1,58 @@
# Настройки обработки =================================================================
# Раздел с общими конфигурационными параметрами ===============================
update_interval: 1
work_interval: 60
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: DEBUG
propagate: False
__main__:
handlers: [console, file, telegram]
level: INFO
propagate: False
config_manager:
handlers: [console, file]
level: DEBUG

View File

@@ -0,0 +1,29 @@
pipeline:
# Настраиваем парсинг экселя
- handler: BasicExcelParser
config:
sheet_name: 0
key_field: "Номер"
mapping:
article: "Номер"
manufacturer: "Фирма"
name: "Наименование"
price: "Цена"
quantity: "Кол-во"
total: "Сумма"
# Определяем логику обработки заказа (в данном случае все с локального склада)
- handler: DeliveryPeriodLocalStore
# Запрос остатков со склада
- handler: APIGetStock

View File

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

View File

@@ -0,0 +1,78 @@
import threading
from typing import Any, Dict
import logging
logger = logging.getLogger()
import threading
from typing import Any
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
class Context2(metaclass=SingletonMeta):
def __init__(self):
if not hasattr(self, 'initialized'):
self.data = {}
self.email_client = None
self.initialized = True
logger.debug(f"Context создан {id}") # опциональный лог
# будет вызван только при первом создании
def clear(self):
"""Очищает self.context, устанавливая его в None или пустой словарь"""
self.data = {}
logger.debug("Context очищен") # опциональный лог
def set(self, new_context: Dict[str, Any]):
"""Устанавливает новый контекст (бонусный метод)"""
self.data = new_context
logger.debug("Новый контекст установлен")
class ThreadSafeSingletonMeta(type):
_instances = {}
_lock = threading.Lock()
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
if cls not in cls._instances:
with cls._lock:
if cls not in cls._instances:
# Инициализация ТУТ, не в __init__
instance = super().__call__(*args, **kwargs)
instance.data = {}
instance.email_client = None
instance._lock = threading.RLock()
cls._instances[cls] = instance
return cls._instances[cls]
class Context(metaclass=ThreadSafeSingletonMeta):
def __init__(self):
print(f"Context: {id(self)}, поток {threading.get_ident()}")
# будет вызван только при первом создании
def clear(self):
"""Очищает self.context, устанавливая его в None или пустой словарь"""
with self._lock:
self.data = {}
logger.debug("Context очищен") # опциональный лог
def set(self, new_context: Dict[str, Any]):
"""Устанавливает новый контекст (бонусный метод)"""
with self._lock:
self.data = new_context
logger.debug("Новый контекст установлен")

View File

@@ -0,0 +1,108 @@
import os
import logging
from typing import Optional, Tuple
from mail_order_bot.context import Context
logger = logging.getLogger(__name__)
class CredentialProvider:
"""
Класс для получения учетных данных (логин и пароль) для доступа к API.
Учетные данные берутся из переменных окружения в формате:
- {PREFIX}_LOGIN_{CLIENT_NAME} - логин для клиента
- {PREFIX}_PASSWORD_{CLIENT_NAME} - пароль для клиента
- {PREFIX}_LOGIN_SYSTEM - логин для системной учетной записи
- {PREFIX}_PASSWORD_SYSTEM - пароль для системной учетной записи
"""
SYSTEM_ACCOUNT = "SYSTEM"
def __init__(self, prefix: str = "ABCP", context: Optional[Context] = None):
"""
Инициализация CredentialProvider.
Args:
prefix: Префикс для переменных окружения (по умолчанию "ABCP")
context: Контекст приложения. Если не передан, будет получен через Context()
"""
self.prefix = prefix.upper()
self.context = context if context is not None else Context()
def get_client_credentials(self, client_name: Optional[str] = None) -> Tuple[str, str]:
"""
Получает учетные данные для клиента.
Если client_name не указан, берется из контекста (context.data.get("client")).
Args:
client_name: Имя клиента. Если None, берется из контекста.
Returns:
Tuple[str, str]: Кортеж (логин, пароль)
Raises:
ValueError: Если не удалось получить имя клиента или учетные данные не найдены
"""
if client_name is None:
client_name = self.context.data.get("client")
if client_name is None:
raise ValueError("Имя клиента не указано и не найдено в контексте")
login_key = f"{self.prefix}_LOGIN_{client_name}"
password_key = f"{self.prefix}_PASSWORD_{client_name}"
login = os.getenv(login_key)
password = os.getenv(password_key)
if login is None or password is None:
raise ValueError(
f"Учетные данные для клиента '{client_name}' не найдены. "
f"Проверьте переменные окружения: {login_key} и {password_key}"
)
logger.debug(f"Получены учетные данные для клиента '{client_name}'")
return login, password
def get_system_credentials(self) -> Tuple[str, str]:
"""
Получает учетные данные для системной учетной записи.
Returns:
Tuple[str, str]: Кортеж (логин, пароль)
Raises:
ValueError: Если учетные данные системной учетной записи не найдены
"""
login_key = f"{self.prefix}_LOGIN_{self.SYSTEM_ACCOUNT}"
password_key = f"{self.prefix}_PASSWORD_{self.SYSTEM_ACCOUNT}"
login = os.getenv(login_key)
password = os.getenv(password_key)
if login is None or password is None:
raise ValueError(
f"Учетные данные для системной учетной записи не найдены. "
f"Проверьте переменные окружения: {login_key} и {password_key}"
)
logger.debug("Получены учетные данные для системной учетной записи")
return login, password
def get_credentials(self, use_system: bool = False, client_name: Optional[str] = None) -> Tuple[str, str]:
"""
Универсальный метод для получения учетных данных.
Args:
use_system: Если True, возвращает учетные данные системной учетной записи.
Если False, возвращает учетные данные клиента.
client_name: Имя клиента. Если None и use_system=False, берется из контекста.
Returns:
Tuple[str, str]: Кортеж (логин, пароль)
"""
if use_system:
return self.get_system_credentials()
else:
return self.get_client_credentials(client_name)

View File

@@ -1,19 +1,2 @@
from .email_client import EmailClient
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()
from .client import EmailClient
from .objects import EmailMessage, EmailAttachment

View File

@@ -0,0 +1,104 @@
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.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.header import decode_header
import imaplib
import smtplib
# from .objects import EmailMessage, EmailAttachment
class EmailClient:
def __init__(self, imap_host: str, smtp_host: str, email: str, password: str,
imap_port: int = 993, smtp_port: int = 587):
self.imap_host = imap_host
self.smtp_host = smtp_host
self.email = email
self.password = password
self.imap_port = imap_port
self.smtp_port = smtp_port
self.imap_conn = None
def connect(self):
"""Установkение IMAP соединения"""
if self.imap_conn is None:
self.imap_conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
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 __enter__(self):
"""Поддержка контекстного менеджера"""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Поддержка контекстного менеджера"""
self.disconnect()
def get_emails_id(self, folder: str = "INBOX", only_unseen: bool = True) -> List[int]:
"""Получить список новых электронных писем."""
self.connect()
self.imap_conn.select(folder, readonly=False)
# Ищем письма
search_criteria = "(UNSEEN)" if only_unseen else "ALL"
status, messages = self.imap_conn.search(None, search_criteria)
# ToDo сделать обработку ошибок, подумать нужна ли она!
if status != "OK":
return []
email_ids = messages[0].split()
return email_ids
def get_email(self, email_id, mark_as_read: bool = True):
"""Получить список новых электронных писем."""
self.connect()
status, msg_data = self.imap_conn.fetch(email_id, "(RFC822)")
if status != "OK":
pass
# Парсим письмо
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
# Помечаем письмо как прочитанное
if mark_as_read:
self.imap_conn.store(email_id, '+FLAGS', '\\Seen')
return msg
def decode_header(self, header_value: str) -> str:
"""Декодировать заголовок письма."""
if header_value is None:
return ""
decoded_parts = []
for part, encoding in decode_header(header_value):
if isinstance(part, bytes):
if encoding:
try:
decoded_parts.append(part.decode(encoding))
except:
decoded_parts.append(part.decode('utf-8', errors='ignore'))
else:
decoded_parts.append(part.decode('utf-8', errors='ignore'))
else:
decoded_parts.append(str(part))
return ''.join(decoded_parts)

View File

@@ -1,353 +0,0 @@
import imaplib
import smtplib
import re
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import email
from email.header import decode_header
from datetime import datetime
from typing import List, Optional
from dataclasses import dataclass
from .email_objects import EmailMessage, EmailAttachment
class EmailClient:
"""
Класс для работы с электронной почтой по протоколам IMAP и SMTP.
Пример использования:
client = EmailClient(
imap_host='imap.gmail.com',
smtp_host='smtp.gmail.com',
email='your_email@gmail.com',
password='your_password'
)
# Получить новые письма
new_emails = client.get_emails()
# Отправить письмо
msg = EmailMessage(
from_addr='sender@example.com',
subj='Test',
dt=datetime.now(),
body='Hello!',
attachments=[]
)
client.send_email(msg, to_addr='recipient@example.com')
"""
def __init__(
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.smtp_host = smtp_host
self.email = email
self.password = password
self.imap_port = imap_port
self.smtp_port = smtp_port
self.imap_conn = None
def _connect_imap(self):
"""Установить IMAP соединение"""
if self.imap_conn is None:
self.imap_conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
self.imap_conn.login(self.email, self.password)
def _decode_header(self, header_value: str) -> str:
"""
Декодировать заголовок письма.
Args:
header_value: Значение заголовка
Returns:
Декодированная строка
"""
if header_value is None:
return ""
decoded_parts = []
for part, encoding in decode_header(header_value):
if isinstance(part, bytes):
if encoding:
try:
decoded_parts.append(part.decode(encoding))
except:
decoded_parts.append(part.decode('utf-8', errors='ignore'))
else:
decoded_parts.append(part.decode('utf-8', errors='ignore'))
else:
decoded_parts.append(str(part))
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:
"""
Извлечь текст письма.
Args:
msg: Объект письма
Returns:
Текст письма
"""
body = ""
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = str(part.get("Content-Disposition", ""))
# Ищем текстовые части без вложений
if content_type == "text/plain" and "attachment" not in content_disposition:
try:
charset = part.get_content_charset() or 'utf-8'
body += part.get_payload(decode=True).decode(charset, errors='ignore')
except:
pass
else:
try:
charset = msg.get_content_charset() or 'utf-8'
body = msg.get_payload(decode=True).decode(charset, errors='ignore')
except:
pass
return body
def _extract_attachments(self, msg: email.message.Message) -> List[EmailAttachment]:
"""
Извлечь вложения из письма.
Args:
msg: Объект письма
Returns:
Список вложений
"""
attachments = []
for part in msg.walk():
content_disposition = str(part.get("Content-Disposition", ""))
if "attachment" in content_disposition:
filename = part.get_filename()
if filename:
# Декодируем имя файла
filename = self._decode_header(filename)
# Получаем содержимое
content = part.get_payload(decode=True)
if content:
attachments.append(
EmailAttachment(filename=filename, content=content)
)
return attachments
def get_emails(
self,
folder: str = "INBOX",
only_unseen: bool = True,
mark_as_read: bool = True
) -> List[EmailMessage]:
"""
Получить список новых электронных писем.
Args:
folder: Папка для получения писем (по умолчанию "INBOX")
only_unseen: Получать только непрочитанные письма (по умолчанию True)
Returns:
Список объектов EmailMessage
"""
self._connect_imap()
# Выбираем папку
self.imap_conn.select(folder, readonly=False)
# Ищем письма
search_criteria = "(UNSEEN)" if only_unseen else "ALL"
status, messages = self.imap_conn.search(None, search_criteria)
if status != "OK":
return []
email_ids = messages[0].split()
emails = []
for email_id in email_ids:
try:
# Получаем письмо
status, msg_data = self.imap_conn.fetch(email_id, "(RFC822)")
if status != "OK":
continue
# Парсим письмо
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
# Извлекаем данные
from_addr = self._decode_header(msg.get("From", ""))
subject = self._decode_header(msg.get("Subject", ""))
# Получаем дату
date_str = msg.get("Date", "")
try:
date_tuple = email.utils.parsedate_tz(date_str)
if date_tuple:
timestamp = email.utils.mktime_tz(date_tuple)
dt = datetime.fromtimestamp(timestamp)
else:
dt = datetime.now()
except:
dt = datetime.now()
# Извлекаем тело письма
body = self._extract_body(msg)
first_sender = self._extract_first_sender(body)
# Извлекаем вложения
attachments = self._extract_attachments(msg)
# Создаем объект письма
email_obj = EmailMessage(
from_addr=from_addr,
subj=subject,
dt=dt,
body=body,
attachments=attachments,
first_sender=first_sender
)
emails.append(email_obj)
# Помечаем письмо как прочитанное
if mark_as_read:
self.imap_conn.store(email_id, '+FLAGS', '\\Seen')
except Exception as e:
print(f"Ошибка при обработке письма {email_id}: {e}")
continue
return emails
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 сообщение
msg = MIMEMultipart()
msg['From'] = self.email
msg['To'] = to_addr
msg['Subject'] = message.subj
if cc:
msg['Cc'] = ', '.join(cc)
# Добавляем тело письма
msg.attach(MIMEText(message.body, 'plain', 'utf-8'))
# Добавляем вложения
for attachment in message.attachments:
part = MIMEBase('application', 'octet-stream')
part.set_payload(attachment.content)
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
f'attachment; filename= {attachment.filename}'
)
msg.attach(part)
# Формируем список всех получателей
recipients = [to_addr]
if cc:
recipients.extend(cc)
if bcc:
recipients.extend(bcc)
# Отправляем письмо
with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
server.starttls()
server.login(self.email, self.password)
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):
"""Поддержка контекстного менеджера"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Поддержка контекстного менеджера"""
self.close()

View File

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

View File

@@ -0,0 +1,106 @@
from email.header import decode_header, make_header
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.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.header import decode_header
import imaplib
import smtplib
from .objects import EmailMessage, EmailAttachment
class EmailUtils:
@staticmethod
def extract_header(msg, header_name) -> str:
"""Декодировать заголовок письма."""
header = msg.get(header_name, "")
if header is None:
return ""
decoded = decode_header(header)
return str(make_header(decoded))
@staticmethod
def extract_email(text) -> str:
match = re.search(r'<([^<>]+)>', text)
if match:
return match.group(1)
return None
@staticmethod
def extract_body(msg: email.message.Message) -> str:
"""Извлечь текст письма из любого типа содержимого, кроме вложений"""
body = ""
if msg.is_multipart():
for part in msg.walk():
content_disposition = str(part.get("Content-Disposition", ""))
# Пропускаем вложения
if "attachment" in content_disposition.lower():
continue
try:
charset = part.get_content_charset() or 'utf-8'
payload = part.get_payload(decode=True)
if payload:
body_piece = payload.decode(charset, errors='ignore')
body += body_piece
except Exception:
pass
else:
try:
charset = msg.get_content_charset() or 'utf-8'
payload = msg.get_payload(decode=True)
if payload:
body = payload.decode(charset, errors='ignore')
except Exception:
pass
return body
@staticmethod
def extract_first_sender(body: str):
"""Извлекает адреса отправителей из пересылаемого сообщения. Нужно для отладки"""
# Ищем email внутри скобок после строки "Пересылаемое сообщение"
pattern = r"Пересылаемое сообщение.*?\((.*?)\)"
match = re.search(pattern, body, re.DOTALL)
if match:
return match.group(1)
return None
@staticmethod
def extract_attachments(msg: email.message.Message) -> List[EmailAttachment]:
"""Извлечь вложения из письма."""
attachments = []
for part in msg.walk():
content_disposition = str(part.get("Content-Disposition", ""))
if "attachment" in content_disposition:
filename = part.get_filename()
if filename:
# Декодируем имя файла
filename = decode_header(filename)[0]
# Получаем содержимое
content = part.get_payload(decode=True)
if content:
#attachments.append(EmailAttachment(filename=filename, content=content))
attachments.append({"name": filename, "bytes": content})
return attachments
@staticmethod
def extract_domain(email_message: str) -> str | None:
"""Вернуть домен из email либо None, если формат странный."""
if "@" not in email_message:
return None
# убираем пробелы по краям и берём часть после '@'
return email_message.strip().split("@", 1)[1]

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
import hashlib
import requests
import logging
logger = logging.getLogger(__name__)
class AbcpProvider:
HOST = "https://id23089.public.api.abcp.ru"
HEADERS = {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded"
}
def __init__(self, login: str, password: str):
"""
Инициализация AbcpProvider.
Args:
login: Логин для доступа к API
password: Пароль для доступа к API
"""
self.base_url = self.HOST
self.login = login
self.password = password
def get_stock(self, sku, manufacturer):
method = "GET"
path = "/search/articles"
params = {"number": sku, "brand": manufacturer, "withOutAnalogs": "1"}
return self._execute(path, method, params)
def _execute(self, path, method="GET", params={}, data=None):
params["userlogin"] = self.login
params["userpsw"] = hashlib.md5(self.password.encode("utf-8")).hexdigest()
response = requests.request(method, self.HOST+path, data=data, headers=self.HEADERS, params=params)
payload = response.json()
if response.status_code == 200:
logger.debug(f"Получены данные об остатках на складе")
result = {
"success": True,
"data": payload
}
else:
logger.warning(f"ошибка получения данных об остатках на складе: {payload}")
result = {
"success": False,
"error": payload
}
return result

View File

@@ -0,0 +1,43 @@
import logging
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
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
logger = logging.getLogger(__name__)
class APIGetStock(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
credential_provider = CredentialProvider(context=self.context)
# Создаем провайдер для системной учетной записи
system_login, system_password = credential_provider.get_system_credentials()
self.system_provider = AbcpProvider(login=system_login, password=system_password)
# Создаем провайдер для учетной записи клиента
client_login, client_password = credential_provider.get_client_credentials()
self.client_provider = AbcpProvider(login=client_login, password=client_password)
def do(self) -> None:
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
order = attachment["order"]
for position in order.positions:
# Получаем остатки из-под учетной записи клиента
client_stock = self.client_provider.get_stock(position.sku, position.manufacturer)
system_stock = self.system_provider.get_stock(position.sku, position.manufacturer)
# Используем StockSelector для фильтрации неподходящих поставщиков
selector = StockSelector(position, order.delivery_period)
available_distributors = selector.filter_stock(client_stock, system_stock)
position.set_stock(available_distributors)
position.set_order_item()
logger.info(f"Получены позиции со склада для файла {attachment.get('name', "no name")}")
def get_stock(self, sku: str, manufacturer: str) -> int:
return self.client_provider.get_stock(sku, manufacturer)

View File

@@ -0,0 +1,95 @@
import sys
from turtle import position
from typing import Dict, Any, List, Optional
from decimal import Decimal
from mail_order_bot.email_processor.order.auto_part_position import AutoPartPosition, PositionStatus
logger = __import__('logging').getLogger(__name__)
"""
1. Получить 2 вида складских остатков
2. Добавляем цену закупки
3. Применяем правила фильтрации
- можно указать какие правила применены
4. Выбираем цену по профиту
- можно указать ограничения
"""
class StockSelector:
"""
Класс для выбора оптимального поставщика для позиции заказа.
Выполняет фильтрацию складов и выбор наилучшего варианта.
"""
DISTRIBUTOR_ID = "1577730" # ID локального склада
def __init__(self, position: AutoPartPosition, delivery_period: int = 0):
"""
Инициализация StockSelector.
Args:
position: Позиция заказа
delivery_period: Период доставки в днях
"""
self.position = position
self.delivery_period = delivery_period
self.client_stock = None
self.system_stock = None
def filter_stock(self, client_stock) -> None:
"""
Обрабатывает результат запроса остатков из-под учетной записи клиента и обновляет позицию.
Args:
client_stock: Результат запроса остатков от API из-под учетной записи клиента
system_stock: Результат запроса остатков от API из-под системной учетной записи
"""
self.client_stock = client_stock
if client_stock["success"]:
available_distributors = client_stock["data"]
# Для доставки только с локального склада сперва убираем все остальные склады
if self.delivery_period == 0:
available_distributors = self._filter_only_local_storage(available_distributors)
# Отбираем склады по сроку доставки
available_distributors = self._filter_proper_delivery_time(available_distributors, self.delivery_period)
# Убираем дорогие склады с ценой выше запрошенной
available_distributors = self._filter_proper_price(available_distributors)
# Убираем отрицательные остатки
available_distributors = self._filter_proper_availability(available_distributors)
# Добавляем данные о закупочных ценах
available_distributors = self._set_system_price(available_distributors)
# Сортируем по цене
available_distributors.sort(key=lambda item: Decimal(item["price"]), reverse=True)
return available_distributors
else:
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]
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]

View File

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

View File

@@ -0,0 +1,21 @@
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
import logging
logger = logging.getLogger(__name__)
class AttachmentHandler(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
email = self.context.data["email"]
attachments = EmailUtils.extract_attachments(email)
self.context.data["attachments"] = attachments
logger.debug(f"AttachmentHandler отработал, извлек вложений: {len(attachments)} ")

View File

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

View File

@@ -0,0 +1,20 @@
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from mail_order_bot.email_client.utils import EmailUtils
import logging
logger = logging.getLogger(__name__)
class DeliveryPeriodLocalStore(AbstractTask):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
attachments = self.context.data["attachments"]
for attachment in attachments:
attachment["delivery_period"] = 0
logger.info(f"Доставка только с локального склада, срок 1 день.")

View File

@@ -0,0 +1,141 @@
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

@@ -0,0 +1,118 @@
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_processor.handlers.order_position import OrderPosition
from mail_order_bot.email_processor.handlers.abstract_task import AbstractTask
from ...order.auto_part_position import AutoPartPosition
from ...order.auto_part_order import AutoPartOrder
logger = logging.getLogger(__name__)
class BasicExcelParser(AbstractTask):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def do(self) -> None:
# todo сделать проверку на наличие файла и его тип
attachments = self.context.data.get("attachments", [])
for attachment in attachments:
file_bytes = BytesIO(attachment['bytes']) # self.context.get("attachment") #
try:
df = self._make_dataframe(file_bytes)
mapping = self.config['mapping']
order = AutoPartOrder()
# Парсим строки
positions = []
for idx, row in df.iterrows():
position = self._parse_row(row, mapping)
if position:
order.add_position(position)
logger.info(f"Успешно обработано {len(order)} позиций из {len(df)} строк")
except Exception as e:
logger.error(f"Ошибка при обработке файла: {e}")
else:
attachment["order"] = order
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[AutoPartPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'price', 'quantity']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
logger.warning(f"Позиция не создана - не заполнено поле {mapping[field]}")
return None
price = Decimal(str(row[mapping['price']]).replace(",", ".").strip())
quantity = int(row[mapping['quantity']])
if "total" in mapping.keys():
total = Decimal(str(row[mapping['total']]).replace(",", ".").strip())
else:
total = price * quantity
if 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

@@ -0,0 +1,118 @@
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_processor.handlers.order_position import OrderPosition
from mail_order_bot.email_processor.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

@@ -0,0 +1,15 @@
import logging
from mail_order_bot.email_processor.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

@@ -0,0 +1,2 @@
from .auto_part_order import AutoPartOrder, OrderStatus
from .auto_part_position import AutoPartPosition, PositionStatus

View File

@@ -0,0 +1,65 @@
from typing import List, Optional
from .auto_part_position import AutoPartPosition, PositionStatus
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.delivery_period = 0
self.reason = ""
self.errors = []
def add_position(self, position: AutoPartPosition) -> None:
self.positions.append(position)
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 set_delivery_period(self, delivery_period: int) -> None:
self.delivery_period = delivery_period
def fill_from_local_supplier(self) -> None:
"""
Выбирает оптимального поставщика для всех позиций заказа.
Предполагается, что остатки уже получены и обработаны.
"""
from mail_order_bot.email_processor.handlers.abcp.stock_selector import StockSelector
for position in self.positions:
selector = StockSelector(position, self.delivery_period)
selector.select_optimal_supplier()
def check_order(self, config) -> None:
""" Проверяет заказ на возможность исполнения"""
# 1. Проверка общего количества отказов
order_refusal_threshold = config.get("order_refusal_threshold", 1)
refusal_positions_count = len([position for position in self.positions if str(position.status) in
[PositionStatus.REFUSED, PositionStatus.STOCK_FAILED]])
order_refusal_rate = refusal_positions_count / len(self.positions)
if order_refusal_rate > order_refusal_threshold:
self.errors.append(f"Превышен порог отказов в заказе - {order_refusal_rate:.0%} "
f"({refusal_positions_count} из {len(self.positions)})")
self.status = OrderStatus.OPERATOR_REQUIRED
def __len__(self):
return len(self.positions)

View File

@@ -0,0 +1,131 @@
from turtle import st
from typing import List, Optional
from dataclasses import dataclass, field
from typing import Dict, Any
from decimal import Decimal
from enum import Enum
class PositionStatus(Enum):
NEW = "new" # Новая позиция
STOCK_RECIEVED = "stock_received" # Получен остаток
STOCK_FAILED = "stock_failed" # Остаток не получен
NO_AVAILABLE_STOCK = "no_available_stock" #Нет доступных складов
READY = "ready"
ORDERED = "ordered" # Заказано
REFUSED = "refused" # Отказано
class AutoPartPosition:
"""
Унифицированная модель позиции для заказа.
Все контрагенты приводятся к этой структуре.
"""
DISTRIBUTOR_ID = "1577730" # ID локального склада
sku: str # Артикул товара
manufacturer: str # Производитель
requested_price: Decimal # Цена за единицу
requested_quantity: int # Количество
total: Decimal = 0 # Общая сумма
name: str = "" # Наименование
order_quantity: int = 0 # Количество для заказа
order_price: Decimal = Decimal('0.0') # Цена в заказе
order_item: Dict[str, Any] = field(default_factory=dict)
stock: List[Dict[str, Any]] = None
additional_attrs: Dict[str, Any] = field(default_factory=dict)
status: PositionStatus = PositionStatus.NEW
desc: str = ""
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}")
def set_stock(self, stock):
if stock is not None:
self.stock = stock
if len(self.stock):
self.status = PositionStatus.STOCK_RECIEVED
else:
self.status = PositionStatus.NO_AVAILABLE_STOCK
else:
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"]
self.stock.sort(key=lambda item: Decimal(item["profit"]), reverse=True)
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]

View File

@@ -0,0 +1,86 @@
import os
import yaml
import logging
from typing import Dict, Any
from pathlib import Path
import threading
from mail_order_bot.context import Context
from mail_order_bot.email_client.utils import EmailUtils
from enum import Enum
from mail_order_bot.email_processor.handlers import *
from mail_order_bot.email_processor.handlers import AttachmentHandler
logger = logging.getLogger(__name__)
class RequestStatus(Enum):
NEW = "new"
IN_PROGRESS = "in progress"
FAILED = "failed"
EXECUTED = "executed"
OPERATOR_REQUIRED = "operator required"
INVALID = "invalid"
class EmailProcessor:
def __init__(self, configs_path: str):
super().__init__()
self.context = Context()
self.configs_path = configs_path
self.status = RequestStatus.NEW
def process_email(self, email):
# Очистить контекст
self.context.clear()
# Сохранить письмо в контекст
self.context.data["email"] = email
# Определить клиента
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)
self.context.data["config"] = config
# Обработка вложений
attachments_handler_task = AttachmentHandler()
attachments_handler_task.do()
# Запустить обработку пайплайна
for stage in config["pipeline"]:
handler_name = stage["handler"]
logger.info(f"Processing handler: {handler_name}")
task = globals()[handler_name](stage.get("config", None))
task.do()
except FileNotFoundError:
logger.error(f"Конфиг для клиента {client} не найден")
for attachment in self.context.data["attachments"]:
print(attachment["order"].__dict__)
#except Exception as e:
# logger.error(f"Произошла другая ошибка: {e}")
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)

View File

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

View File

@@ -0,0 +1,113 @@
import logging
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):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def parse(self, file_bytes: str) -> 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
if mapping.get('name',"") in mapping.keys():
name = str(row[mapping.get('name', "")]).strip()
else:
name = ""
# Создаем объект позиции
position = OrderPosition(
article=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping.get('manufacturer',"")]).strip(),
name=name,
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:
# Получаем все данные из файла
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

@@ -0,0 +1,105 @@
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,7 +1,12 @@
from abc import ABC, abstractmethod
from typing import List
import pandas as pd
import logging
import pandas as pd
from abc import ABC, abstractmethod
from typing import Dict, Any, List
from io import BytesIO
from .order_position import OrderPosition
logger = logging.getLogger(__name__)
@@ -15,19 +20,9 @@ class ExcelParser(ABC):
self.config = config
@abstractmethod
def parse(self, filepath: str) -> List[OrderPosition]:
def parse(self, file: BytesIO) -> List[OrderPosition]:
"""
Парсит Excel файл и возвращает список позиций.
Должен быть реализован в каждом конкретном парсере.
"""
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,6 +1,15 @@
import yaml
import json
import logging
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:
"""
@@ -10,48 +19,36 @@ class ParserFactory:
# Реестр кастомных парсеров
CUSTOM_PARSERS = {
'supplier_a': SupplierAParser,
'autoeuro.ru': CustomExcelParserAutoeuro,
# Добавляйте сюда специализированные парсеры
}
def __init__(self, config_path: str):
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 __init__(self, config: Dict[str, Any]):
self.config = config
def get_parser(self, supplier_name: str) -> ExcelParser:
"""
Возвращает парсер для указанного контрагента.
Использует кастомный парсер если есть, иначе конфигурируемый.
"""
if supplier_name not in self.suppliers_config['suppliers']:
if supplier_name not in self.config['suppliers']:
raise ValueError(
f"Контрагент '{supplier_name}' не найден в конфигурации. "
f"Доступные: {list(self.suppliers_config['suppliers'].keys())}"
f"Доступные: {list(self.config['suppliers'].keys())}"
)
config = self.suppliers_config['suppliers'][supplier_name]
config = self.config['suppliers'][supplier_name]
# Проверяем, есть ли кастомный парсер
if supplier_name in self.CUSTOM_PARSERS:
parser_class = self.CUSTOM_PARSERS[supplier_name]
logger.info(f"Используется кастомный парсер для {supplier_name}")
logger.debug(f"Используется кастомный парсер для {supplier_name}")
else:
parser_class = ConfigurableExcelParser
logger.info(f"Используется конфигурируемый парсер для {supplier_name}")
logger.debug(f"Используется конфигурируемый парсер для {supplier_name}")
return parser_class(config)
def list_suppliers(self) -> List[str]:
"""Возвращает список всех доступных контрагентов"""
return list(self.suppliers_config['suppliers'].keys())
return list(self.config['suppliers'].keys())

View File

@@ -1,31 +1,36 @@
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:
"""
Главный класс-фасад для обработки Excel файлов.
Упрощает использование системы.
"""
def __init__(self, config_path: str = 'config/suppliers.yaml'):
self.factory = ParserFactory(config_path)
self._setup_logging()
def __init__(self, config_path: str = 'config/suppliers.yaml', ):
self.config_path = Path(config_path)
self.config = self._load_config()
self.factory = ParserFactory(self.config)
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]:
def process(self, file_bytes: BytesIO, file_name: str, supplier_name: str, validate: bool = False) -> List[OrderPosition]:
"""
Обрабатывает Excel файл от контрагента.
Args:
filepath: Путь к Excel файлу
file_bytes: Байты файла
file_name: Имя файла
supplier_name: Название контрагента (из конфигурации)
validate: Выполнять ли дополнительную валидацию
@@ -34,25 +39,34 @@ class ExcelProcessor:
Raises:
ValueError: Если контрагент не найден
FileNotFoundError: Если файл не найден
"""
logger.info(f"Начало обработки файла: {filepath} для {supplier_name}")
logger.info(f"Обработка файла: {file_name} для {supplier_name}")
# Проверка существования файла
if not Path(filepath).exists():
raise FileNotFoundError(f"Файл не найден: {filepath}")
# Получаем парсер и обрабатываем
parser = self.factory.get_parser(supplier_name)
positions = parser.parse(filepath)
positions = parser.parse(file_bytes)
# Дополнительная валидация если нужна
if validate:
positions = self._validate_positions(positions)
logger.info(f"Обработка завершена: получено {len(positions)} позиций")
logger.debug(f"Обработка завершена: получено {len(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]:
"""Дополнительная валидация позиций"""
valid_positions = []
@@ -83,3 +97,14 @@ class ExcelProcessor:
def get_available_suppliers(self) -> List[str]:
"""Возвращает список доступных контрагентов"""
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,68 +0,0 @@
from typing import Optional
class ConfigurableExcelParser(ExcelParser):
"""
Универсальный парсер, настраиваемый через конфигурацию.
Подходит для большинства стандартных случаев.
"""
def parse(self, filepath: str) -> List[OrderPosition]:
try:
# Читаем Excel
df = self._read_excel(filepath)
# Удаляем пустые строки
df = df.dropna(how='all')
# Получаем маппинг колонок из конфигурации
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.warning(f"Ошибка парсинга строки {idx}: {e}")
continue
logger.info(f"Успешно обработано {len(positions)} позиций из {len(df)} строк")
return positions
except Exception as e:
logger.error(f"Ошибка при обработке файла {filepath}: {e}")
raise
def _parse_row(self, row: pd.Series, mapping: Dict[str, str]) -> Optional[OrderPosition]:
"""Парсит одну строку Excel в OrderPosition"""
# Проверяем обязательные поля
required_fields = ['article', 'manufacturer', 'name', 'price', 'quantity', 'total']
for field in required_fields:
if pd.isna(row.get(mapping[field])):
return None
# Создаем объект позиции
position = OrderPosition(
article=str(row[mapping['article']]).strip(),
manufacturer=str(row[mapping['manufacturer']]).strip(),
name=str(row[mapping['name']]).strip(),
price=Decimal(str(row[mapping['price']])),
quantity=int(row[mapping['quantity']]),
total=Decimal(str(row[mapping['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

View File

@@ -0,0 +1,72 @@
import threading
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 email_processor import EmailProcessor
from mail_order_bot.context import Context
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=int(os.getenv('IMAP_PORT', default="993")),
smtp_port=int(os.getenv('SMTP_PORT', default="587")),
)
# Сохранить почтовый клиент в контекст
self.context = Context()
self.context.email_client = self.email_client
# Обработчик писем
self.email_processor = EmailProcessor("./configs")
logger.warning("MailOrderBot инициализирован")
def execute(self):
# Получить список айдишников письма
unread_email_ids = self.email_client.get_emails_id(folder="spareparts")
logger.info(f"Новых писем - {len(unread_email_ids)}")
# Обработать каждое письмо по идентификатору
for email_id in unread_email_ids:
logger.debug(f"==================================================")
logger.debug(f"Обработка письма с идентификатором {email_id}")
# Получить письмо по идентификатору и запустить его обработку
email = self.email_client.get_email(email_id)
self.email_processor.process_email(email)
pass
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())

View File

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

View File

@@ -0,0 +1,24 @@
import os
from dotenv import load_dotenv
from mail_order_bot.abcp_api.abcp_provider import AbcpProvider
from mail_order_bot.email_processor.order.auto_part_order import AutoPartOrder
from mail_order_bot.email_processor.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)
login = os.getenv('ABCP_LOGIN_SYSTEM')
password = os.getenv('ABCP_PASSWORD_SYSTEM')
provider = AbcpProvider(login=login, password=password)
result = provider.get_stock(position.sku, position.manufacturer)
print(order.positions[0].stock)
print(os.getenv('ABCP_LOGIN_SYSTEM'))

View File

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

View File

@@ -0,0 +1,42 @@
# Конфигурационный файл для контрагента 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

@@ -0,0 +1,27 @@
# Конфигурационный файл для контрагента 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

@@ -0,0 +1,60 @@
import os
import chardet # pip install chardet
import traceback
from mail_order_bot.email_processor 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

@@ -0,0 +1,48 @@
import os
import chardet # pip install chardet
import traceback
from mail_order_bot.excel_parcer 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

@@ -0,0 +1,411 @@
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: "Количество"

9
tests/site/auth.py Normal file
View File

@@ -0,0 +1,9 @@
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

1
tests/site/test.html Normal file
View File

@@ -0,0 +1 @@
'<!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'