#!/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 = """
Dear 供應商:
以下商品請補貨上架,並回覆補貨數量及日期,有任何問題歡迎提出討論,謝謝!
詳細商品清單請參考附件 Excel 檔案。
""" # 渲染模板 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 = """Dear 供應商:
以下商品請補貨上架,並回覆補貨數量及日期,有任何問題歡迎提出討論,謝謝!
詳細商品清單請參考附件 Excel 檔案。
""" # 渲染模板 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