Files
ewoooc/services/vendor_email_service.py
ogt 1b4f3a7bbe
Some checks failed
CD Pipeline / deploy (push) Failing after 59s
feat: EwoooC 初始化 — 完整專案推版至 Gitea
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml)
- 部署模式: rsync Python 檔案至 188 → docker restart (volume mount)
- Dockerfile/requirements 變動時自動重建 Docker image
- 部署通知: Telegram (開始/成功/失敗)
- 健康檢查: https://mo.wooo.work/health (最多 5 次重試)
- 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 01:21:13 +08:00

383 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
廠商缺貨通知 - 郵件發送服務
提供兩種發送模式:按廠商分組發送、按商品單筆發送
"""
import smtplib
import config
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from datetime import datetime
from jinja2 import Template
import pandas as pd
import io
from services.logger_manager import SystemLogger
# 初始化日誌
sys_log = SystemLogger("VendorEmailService").get_logger()
class VendorEmailService:
"""廠商缺貨郵件發送服務"""
def __init__(self):
"""初始化 SMTP 設定"""
self.smtp_host = config.EMAIL_HOST
self.smtp_port = config.EMAIL_PORT
self.smtp_user = config.EMAIL_HOST_USER
self.smtp_password = config.EMAIL_HOST_PASSWORD
self.sender_email = config.EMAIL_SENDER
def send_vendor_grouped_email(self, vendor_code, vendor_name, vendor_emails,
stockout_items, batch_id=None):
"""
按廠商分組發送(一個廠商一封郵件,包含所有缺貨商品)
Args:
vendor_code: 廠商代碼
vendor_name: 廠商名稱
vendor_emails: 廠商郵件清單 [email1, email2, ...]
stockout_items: 缺貨商品清單 (list of dict)
batch_id: 發送批次編號
Returns:
dict: {'success': bool, 'message': str, 'log_id': int}
"""
try:
if not vendor_emails or len(vendor_emails) == 0:
return {
'success': False,
'message': f'廠商 {vendor_code} 沒有設定郵件地址'
}
# 產生郵件內容
subject = f"PChome_缺貨通知_{vendor_code} {vendor_name}"
html_body = self._generate_grouped_email_html(
vendor_name=vendor_name,
vendor_code=vendor_code,
items=stockout_items
)
# 產生 Excel 附件
excel_attachment = self._generate_excel_attachment(
items=stockout_items,
filename=f"{vendor_code}_{vendor_name}_缺貨清單.xlsx"
)
# 發送郵件
recipient_email = vendor_emails[0] # 主要收件者
cc_emails = vendor_emails[1:] if len(vendor_emails) > 1 else []
result = self._send_email(
to_email=recipient_email,
cc_emails=cc_emails,
subject=subject,
html_body=html_body,
attachment=excel_attachment
)
if result['success']:
sys_log.info(f"✅ 廠商分組郵件發送成功 | 廠商: {vendor_code} | 收件者: {recipient_email}")
else:
sys_log.error(f"❌ 廠商分組郵件發送失敗 | 廠商: {vendor_code} | 錯誤: {result['message']}")
return result
except Exception as e:
error_msg = f"發送失敗: {str(e)}"
sys_log.error(f"❌ 廠商分組郵件發送異常 | 廠商: {vendor_code} | 錯誤: {e}")
return {'success': False, 'message': error_msg}
def send_single_item_email(self, vendor_code, vendor_name, vendor_emails,
stockout_item, batch_id=None):
"""
按商品單筆發送(每個缺貨商品一封獨立郵件)
Args:
vendor_code: 廠商代碼
vendor_name: 廠商名稱
vendor_emails: 廠商郵件清單
stockout_item: 單個缺貨商品資料 (dict)
batch_id: 發送批次編號
Returns:
dict: {'success': bool, 'message': str}
"""
try:
if not vendor_emails or len(vendor_emails) == 0:
return {
'success': False,
'message': f'廠商 {vendor_code} 沒有設定郵件地址'
}
# 產生郵件內容
product_name = stockout_item.get('product_name', '')
product_code = stockout_item.get('product_code', '')
subject = f"PChome_缺貨通知_{vendor_code} {vendor_name}"
html_body = self._generate_single_item_email_html(
vendor_name=vendor_name,
item=stockout_item
)
# 產生 Excel 附件(單筆資料也要附件)
excel_attachment = self._generate_excel_attachment(
items=[stockout_item], # 包裝成 list
filename=f"{vendor_code}_{vendor_name}_{product_code}_缺貨清單.xlsx"
)
# 發送郵件
recipient_email = vendor_emails[0]
cc_emails = vendor_emails[1:] if len(vendor_emails) > 1 else []
result = self._send_email(
to_email=recipient_email,
cc_emails=cc_emails,
subject=subject,
html_body=html_body,
attachment=excel_attachment
)
if result['success']:
sys_log.info(f"✅ 單品郵件發送成功 | 廠商: {vendor_code} | 商品: {product_code}")
else:
sys_log.error(f"❌ 單品郵件發送失敗 | 廠商: {vendor_code} | 商品: {product_code} | 錯誤: {result['message']}")
return result
except Exception as e:
error_msg = f"發送失敗: {str(e)}"
sys_log.error(f"❌ 單品郵件發送異常 | 廠商: {vendor_code} | 錯誤: {e}")
return {'success': False, 'message': error_msg}
def _send_email(self, to_email, subject, html_body, cc_emails=None, attachment=None):
"""
實際發送郵件的內部方法
Args:
to_email: 收件者郵件
subject: 郵件主旨
html_body: HTML 郵件內容
cc_emails: CC 郵件清單
attachment: 附件 (dict: {'filename': str, 'data': bytes})
Returns:
dict: {'success': bool, 'message': str}
"""
try:
# 建立郵件
msg = MIMEMultipart('alternative')
msg['From'] = self.sender_email
msg['To'] = to_email
msg['Subject'] = subject
# 加入 CC
if cc_emails:
msg['Cc'] = ', '.join(cc_emails)
# 加入純文字版本fallback
text_body = """Dear 供應商:
以下商品請補貨上架,並回覆補貨數量及日期,有任何問題歡迎提出討論,謝謝!"""
text_part = MIMEText(text_body, 'plain', 'utf-8')
msg.attach(text_part)
# 加入 HTML 內容
html_part = MIMEText(html_body, 'html', 'utf-8')
msg.attach(html_part)
# 加入附件
if attachment:
part = MIMEApplication(attachment['data'], Name=attachment['filename'])
part['Content-Disposition'] = f'attachment; filename="{attachment["filename"]}"'
msg.attach(part)
# 發送郵件
with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
server.starttls()
server.login(self.smtp_user, self.smtp_password)
recipients = [to_email]
if cc_emails:
recipients.extend(cc_emails)
server.sendmail(self.sender_email, recipients, msg.as_string())
return {'success': True, 'message': '發送成功'}
except Exception as e:
error_msg = f"SMTP 發送失敗: {str(e)}"
sys_log.error(f"❌ SMTP 發送異常 | 收件者: {to_email} | 錯誤: {e}")
return {'success': False, 'message': error_msg}
def _generate_grouped_email_html(self, vendor_name, vendor_code, items):
"""
產生廠商分組郵件的 HTML 內容(簡潔版)
Args:
vendor_name: 廠商名稱
vendor_code: 廠商代碼
items: 缺貨商品清單
Returns:
str: HTML 字串
"""
template = """
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>缺貨通知</title>
<style>
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
line-height: 1.8;
color: #333;
padding: 20px;
}
</style>
</head>
<body>
<p>Dear 供應商:</p>
<p>以下商品請補貨上架,並回覆補貨數量及日期,有任何問題歡迎提出討論,謝謝!</p>
<p>詳細商品清單請參考附件 Excel 檔案。</p>
</body>
</html>
"""
# 渲染模板
from jinja2 import Environment
env = Environment(autoescape=True)
t = env.from_string(template)
html = t.render(
vendor_name=vendor_name,
vendor_code=vendor_code
)
return html
def _generate_single_item_email_html(self, vendor_name, item):
"""
產生單一商品郵件的 HTML 內容(簡潔版)
Args:
vendor_name: 廠商名稱
item: 缺貨商品資料
Returns:
str: HTML 字串
"""
template = """
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>缺貨通知</title>
<style>
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
line-height: 1.8;
color: #333;
padding: 20px;
}
</style>
</head>
<body>
<p>Dear 供應商:</p>
<p>以下商品請補貨上架,並回覆補貨數量及日期,有任何問題歡迎提出討論,謝謝!</p>
<p>詳細商品清單請參考附件 Excel 檔案。</p>
</body>
</html>
"""
# 渲染模板
from jinja2 import Environment
env = Environment(autoescape=True)
t = env.from_string(template)
html = t.render(
vendor_name=vendor_name
)
return html
def _generate_excel_attachment(self, items, filename):
"""
產生 Excel 附件(包含所有欄位)
Args:
items: 缺貨商品清單
filename: 檔案名稱
Returns:
dict: {'filename': str, 'data': bytes}
"""
try:
# 轉換為 DataFrame包含所有欄位
df = pd.DataFrame(items)
# 定義完整的 18 個欄位(按照用戶要求的順序)
# 格式: (資料庫欄位, Excel 欄位名稱)
column_order = [
('import_date', '當前日期'), # 1
('department', '處別'), # 2
('section', '科別'), # 3
('pm_name', 'PM姓名'), # 4
('zone_id', '區ID'), # 5
('zone_name', '區名稱'), # 6
('product_code', '商品ID'), # 7
('product_name', '商品名稱'), # 8
('product_spec', '單品/組合商品'), # 9
('borrow_transfer', '借採轉'), # 10
('vendor_code', '來源供應商編號'), # 11
('vendor_name', '來源供應商名稱'), # 12
('current_stock', '商品可賣量'), # 13
('stockout_date', '缺貨日期'), # 14
('stockout_days', '缺貨天數'), # 15
('monthly_sales_amount', '缺貨商品前30天業績'), # 16
('monthly_sales_qty', '最近30天銷售量'), # 17
('safe_stock_days', '庫存水位'), # 18
]
# 建立輸出 DataFrame確保所有欄位都存在
export_dict = {}
row_count = len(df) if len(df) > 0 else 0
for db_col, excel_col in column_order:
if db_col is None:
# 空白欄位 - 填入空字串
export_dict[excel_col] = [''] * row_count
elif db_col in df.columns:
# 資料庫有此欄位 - 使用實際數據
export_dict[excel_col] = df[db_col].tolist()
else:
# 資料庫沒有此欄位 - 填入空字串
export_dict[excel_col] = [''] * row_count
df_export = pd.DataFrame(export_dict)
# 輸出到記憶體
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df_export.to_excel(writer, index=False, sheet_name='缺貨清單')
output.seek(0)
excel_data = output.read()
return {'filename': filename, 'data': excel_data}
except Exception as e:
sys_log.error(f"❌ 產生 Excel 附件失敗 | 錯誤: {e}")
return None