Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 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>
383 lines
13 KiB
Python
383 lines
13 KiB
Python
#!/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
|