From 38967ceea38455810e21b20d523e2615419d5880 Mon Sep 17 00:00:00 2001 From: OoO Date: Sat, 2 May 2026 16:26:27 +0800 Subject: [PATCH] feat(ppt): redesign all 6 reports to professional standard + cache versioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PPT 模板全面升版至市場專業標準(McKinsey / BCG 月報級別): Monthly v3.1(10 頁) - 暖紙感封面(去黑底)+ elevator pitch(亮點/警訊/動能徽章) - KPI 卡含 △% vs 上月 + 紅綠燈、YoY 同期對比帶 - matplotlib 業績趨勢折線(本月+上月+日均線+高低點) - 品類分析雙視圖:橫條 + 帕雷托累計(80% 主力線) - TOP 50 商品(自動分頁)+ vs 上月 △ 排名變化 / 🆕 新進榜 - MCP 情報 4 卡片結構化、AI 行動結構化分區、附錄頁 Daily / Weekly / Strategy / Promo / Competitor v3.0 - 統一暖紙封面、AI 頁去黑底改暖紙 + 焦糖橘色條 - 日週改 matplotlib 折線(含日均線、高低點) - 全部加附錄頁(資料來源 / 計算口徑 / 模板版本) Cache 版本控制 - TEMPLATE_VERSIONS 字典自動注入 cache key (tpl_ver) - 模板升版舊快取自動 miss → 重生 - 新增 _invalidate_ppt_cache() 與 cleanup_expired_ppt_cache() helper - 新增 /cache status / flush / cleanup Telegram 指令 字型系統 - _set_run_fonts() 用 lxml 直寫 a:latin/a:ea,中英分軌 - 中文走 Microsoft JhengHei,數字/英文走 Consolas(點陣等寬) - Dockerfile 加 fonts-noto-cjk + fonts-noto-cjk-extra 部署排程 - scripts/install_ppt_cleanup.sh 一鍵安裝 launchd(每日 03:15) - scripts/ppt_cleanup.sh 清 7 天前過期 PPT 檔 + DB row 資料層 - routes monthly: query_monthly_summary LIMIT 10 → 50,補 orders 欄位 - routes monthly: 拉 prev_month / prev_year 比較資料供 KPI △ 與商品 △ 計算 煙霧測試 6/6 全綠: - 日報 v3.0 (5 頁) / 週報 v3.0 (6 頁) / 月報 v3.1 (10 頁) - 策略 v3.0 (6 頁) / 促銷 v3.0 (6 頁) / 競品 v3.0 (5 頁) Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 2 + routes/openclaw_bot_routes.py | 174 ++- scripts/com.openclaw.ppt-cleanup.plist | 53 + scripts/install_ppt_cleanup.sh | 74 + scripts/ppt_cleanup.sh | 27 + services/ppt_generator.py | 1753 +++++++++++++++++++++--- 6 files changed, 1872 insertions(+), 211 deletions(-) create mode 100644 scripts/com.openclaw.ppt-cleanup.plist create mode 100755 scripts/install_ppt_cleanup.sh create mode 100755 scripts/ppt_cleanup.sh diff --git a/Dockerfile b/Dockerfile index d0ce644..07716ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,8 @@ RUN apt-get update && apt-get install -y \ libxrandr2 \ xdg-utils \ fonts-liberation \ + fonts-noto-cjk \ + fonts-noto-cjk-extra \ openssh-client \ libappindicator3-1 || true \ && rm -rf /var/lib/apt/lists/* diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 3deea82..a15380b 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -2154,11 +2154,105 @@ def _ppt_check_data_freshness(report_type: str, chat_id: int, reply_to: int, def _normalize_ppt_parameters(parameters: dict) -> str: - """將快取參數轉成穩定字串,避免 key 順序造成命中錯誤。""" + """將快取參數轉成穩定字串。 + 自動把當前 ppt_generator 的模板版本字串 (tpl_ver) 併入 parameters, + 任何 report_type 模板升級(bump TEMPLATE_VERSIONS)→ 舊快取全部 miss → 重新生成。 + """ try: + from services.ppt_generator import get_template_version + report_type = parameters.get('report_type') if isinstance(parameters, dict) else None + if report_type: + params_with_ver = dict(parameters) + params_with_ver['tpl_ver'] = get_template_version(report_type) + return json.dumps(params_with_ver, ensure_ascii=False, sort_keys=True, separators=(',', ':')) return json.dumps(parameters, ensure_ascii=False, sort_keys=True, separators=(',', ':')) except Exception: - return json.dumps({}, ensure_ascii=False) + try: + return json.dumps(parameters, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + except Exception: + return json.dumps({}, ensure_ascii=False) + + +def _invalidate_ppt_cache(report_type: str = None) -> int: + """強制失效指定 report_type(或全部)的 PPT 快取。 + 將 expires_at 設為 NOW() − 1 分鐘,下次查詢即 miss → 用最新模板重生。 + 回傳被影響的列數。 + + 用法(管理員): + _invalidate_ppt_cache('monthly') # 只清月報 + _invalidate_ppt_cache() # 清全部 + """ + from database.manager import DatabaseManager + from database.ppt_reports import PPTReport + + now = datetime.now(TAIPEI_TZ).replace(tzinfo=None) + expired_at = now - timedelta(minutes=1) + session = DatabaseManager().get_session() + try: + q = session.query(PPTReport).filter( + or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now) + ) + if report_type: + q = q.filter(PPTReport.report_type == report_type) + affected = q.update({PPTReport.expires_at: expired_at}, synchronize_session=False) + session.commit() + sys_log.info(f"[PPT] 強制失效快取 type={report_type or 'ALL'} affected={affected}") + return int(affected or 0) + except Exception as e: + session.rollback() + sys_log.error(f"[PPT] 失效快取失敗: {e}") + return 0 + finally: + session.close() + + +def cleanup_expired_ppt_cache(days_old: int = 7, dry_run: bool = False) -> dict: + """清理已過期且超過 days_old 天的 PPT 檔案 + DB 紀錄。 + 執行條件:expires_at < NOW() − days_old 天 → 刪檔 + 刪 row。 + 保留 days_old 天緩衝避免誤刪剛失效的紀錄。 + 可由 launchd / cron 每日呼叫一次: + from routes.openclaw_bot_routes import cleanup_expired_ppt_cache + cleanup_expired_ppt_cache(days_old=7) + + 回傳:{'deleted_files': N, 'deleted_rows': N, 'freed_bytes': N, 'errors': [...]} + """ + from database.manager import DatabaseManager + from database.ppt_reports import PPTReport + + cutoff = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=days_old) + stat = {'deleted_files': 0, 'deleted_rows': 0, + 'freed_bytes': 0, 'errors': [], 'dry_run': dry_run} + + session = DatabaseManager().get_session() + try: + rows = (session.query(PPTReport) + .filter(PPTReport.expires_at.isnot(None), + PPTReport.expires_at < cutoff) + .all()) + for r in rows: + try: + if r.file_path and os.path.exists(r.file_path): + size = os.path.getsize(r.file_path) + if not dry_run: + os.unlink(r.file_path) + stat['deleted_files'] += 1 + stat['freed_bytes'] += size + if not dry_run: + session.delete(r) + stat['deleted_rows'] += 1 + except Exception as e: + stat['errors'].append(f"id={r.id}: {e}") + if not dry_run: + session.commit() + sys_log.info(f"[PPT cleanup] {stat}") + return stat + except Exception as e: + session.rollback() + sys_log.error(f"[PPT cleanup] 失敗: {e}") + stat['errors'].append(str(e)) + return stat + finally: + session.close() def _load_cached_ppt_entry(report_type: str, parameters: dict): @@ -5314,6 +5408,82 @@ def handle_cmd(cmd, arg, chat_id, reply_to): ] send_message(chat_id, fmt_strategy(strat, target), reply_to, kb) + elif cmd in ('cache', '快取'): + # /cache flush [type] 清除指定 PPT 快取(或全部) + # /cache status 顯示目前快取數量 + sub = (arg or '').strip().lower() + if sub.startswith('flush'): + parts = sub.split(maxsplit=1) + target_type = parts[1].strip() if len(parts) > 1 else None + try: + affected = _invalidate_ppt_cache(target_type if target_type and target_type != 'all' else None) + from services.ppt_generator import TEMPLATE_VERSIONS + ver = TEMPLATE_VERSIONS.get(target_type, '—') if target_type else '—' + msg = (f"✅ PPT 快取已強制失效\n" + f"類型:{target_type or '全部'}\n" + f"當前模板版本:{ver}\n" + f"影響筆數:{affected}\n" + f"下次請求將以新模板重新生成。") + except Exception as e: + msg = f"❌ 快取清除失敗:{e}" + send_message(chat_id, msg, reply_to, parse_mode=None) + elif sub.startswith('status') or sub == '': + try: + from database.manager import DatabaseManager + from database.ppt_reports import PPTReport + from sqlalchemy import func as _f + session = DatabaseManager().get_session() + now_naive = datetime.now(TAIPEI_TZ).replace(tzinfo=None) + rows = (session.query(PPTReport.report_type, + _f.count(PPTReport.id), + _f.sum(PPTReport.file_size)) + .filter(or_(PPTReport.expires_at.is_(None), + PPTReport.expires_at > now_naive)) + .group_by(PPTReport.report_type).all()) + session.close() + from services.ppt_generator import TEMPLATE_VERSIONS + lines = ["📦 *PPT 快取狀態(未過期)*", ""] + if not rows: + lines.append("(目前無有效快取)") + else: + for rt, cnt, size in rows: + size_kb = (size or 0) / 1024 + ver = TEMPLATE_VERSIONS.get(rt, '—') + lines.append(f"• `{rt}`:{cnt} 筆 · {size_kb:,.0f} KB · 模板 `{ver}`") + lines.append("") + lines.append("使用 `/cache flush ` 強制清除") + send_message(chat_id, '\n'.join(lines), reply_to, parse_mode='Markdown') + except Exception as e: + send_message(chat_id, f"❌ 查詢快取失敗:{e}", reply_to, parse_mode=None) + elif sub.startswith('cleanup'): + # /cache cleanup [days] 實刪 N 天前過期的檔案 + DB row + # /cache cleanup dry 乾跑(只統計不刪) + parts = sub.split() + dry = 'dry' in parts + days = 7 + for p in parts[1:]: + if p.isdigit(): + days = int(p) + try: + stat = cleanup_expired_ppt_cache(days_old=days, dry_run=dry) + msg = (f"🧹 PPT 磁碟清理{'(乾跑)' if dry else ''}\n" + f"門檻:過期超過 {days} 天\n" + f"刪檔:{stat['deleted_files']} 個\n" + f"刪 row:{stat['deleted_rows']} 筆\n" + f"釋放空間:{stat['freed_bytes']/1024:,.0f} KB" + + (f"\n錯誤:{len(stat['errors'])} 筆" if stat['errors'] else "")) + except Exception as e: + msg = f"❌ 清理失敗:{e}" + send_message(chat_id, msg, reply_to, parse_mode=None) + else: + send_message(chat_id, + "用法:\n" + "• `/cache status` 查看快取狀態\n" + "• `/cache flush monthly` 清月報快取\n" + "• `/cache flush all` 清全部\n" + "• `/cache cleanup [N天] [dry]` 清磁碟過期檔案", + reply_to, parse_mode='Markdown') + elif cmd in ('ppt', 'slides', '簡報'): # /ppt daily [日期] # /ppt weekly diff --git a/scripts/com.openclaw.ppt-cleanup.plist b/scripts/com.openclaw.ppt-cleanup.plist new file mode 100644 index 0000000..6cc0cb0 --- /dev/null +++ b/scripts/com.openclaw.ppt-cleanup.plist @@ -0,0 +1,53 @@ + + + + + + Label + com.openclaw.ppt-cleanup + + ProgramArguments + + /bin/bash + __SCRIPT_PATH__ + + + StartCalendarInterval + + Hour + 3 + Minute + 15 + + + StandardOutPath + __LOG_DIR__/ppt_cleanup.stdout.log + + StandardErrorPath + __LOG_DIR__/ppt_cleanup.stderr.log + + EnvironmentVariables + + PROJECT_DIR + __PROJECT_DIR__ + DAYS_OLD + 7 + + + RunAtLoad + + + diff --git a/scripts/install_ppt_cleanup.sh b/scripts/install_ppt_cleanup.sh new file mode 100755 index 0000000..92d96fc --- /dev/null +++ b/scripts/install_ppt_cleanup.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# scripts/install_ppt_cleanup.sh +# 一鍵安裝:將 PPT cleanup 排程到 macOS launchd(每日 03:15 執行)。 +# +# 用法: +# bash scripts/install_ppt_cleanup.sh # 安裝 +# bash scripts/install_ppt_cleanup.sh --uninstall # 解除安裝 +# bash scripts/install_ppt_cleanup.sh --test # 安裝後立刻跑一次 + +set -euo pipefail + +# ── 路徑解析 ──────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LOG_DIR="${PROJECT_DIR}/logs" +SCRIPT_PATH="${SCRIPT_DIR}/ppt_cleanup.sh" + +PLIST_NAME="com.openclaw.ppt-cleanup" +PLIST_SRC="${SCRIPT_DIR}/${PLIST_NAME}.plist" +PLIST_DST="${HOME}/Library/LaunchAgents/${PLIST_NAME}.plist" + +mkdir -p "$LOG_DIR" +mkdir -p "$(dirname "$PLIST_DST")" +chmod +x "$SCRIPT_PATH" + +# ── 解除安裝 ──────────────────────────────────────────────────────────── +if [[ "${1:-}" == "--uninstall" ]]; then + if launchctl list | grep -q "$PLIST_NAME"; then + launchctl unload "$PLIST_DST" 2>/dev/null || true + fi + rm -f "$PLIST_DST" + echo "✅ 已解除安裝 ${PLIST_NAME}" + exit 0 +fi + +# ── 安裝 ──────────────────────────────────────────────────────────────── +echo "🔧 安裝 PPT cleanup launchd job..." +echo " PROJECT_DIR : $PROJECT_DIR" +echo " SCRIPT_PATH : $SCRIPT_PATH" +echo " LOG_DIR : $LOG_DIR" +echo " PLIST_DST : $PLIST_DST" + +# 替換 plist 中的 placeholder +sed -e "s|__SCRIPT_PATH__|${SCRIPT_PATH}|g" \ + -e "s|__LOG_DIR__|${LOG_DIR}|g" \ + -e "s|__PROJECT_DIR__|${PROJECT_DIR}|g" \ + "$PLIST_SRC" > "$PLIST_DST" + +# 重新載入 launchd job +launchctl unload "$PLIST_DST" 2>/dev/null || true +launchctl load "$PLIST_DST" + +echo "✅ 已安裝 ${PLIST_NAME}(每日 03:15 執行)" +echo "" +echo "驗證:" +launchctl list | grep "$PLIST_NAME" || echo " (job 尚未列出,可重啟 Mac 或執行 launchctl load)" + +# ── 即刻測試 ──────────────────────────────────────────────────────────── +if [[ "${1:-}" == "--test" ]]; then + echo "" + echo "🚀 立即觸發一次測試..." + launchctl start "$PLIST_NAME" + sleep 2 + echo "📋 stdout 最後 20 行:" + tail -20 "${LOG_DIR}/ppt_cleanup.stdout.log" 2>/dev/null || echo " (尚無輸出)" + echo "📋 stderr 最後 20 行:" + tail -20 "${LOG_DIR}/ppt_cleanup.stderr.log" 2>/dev/null || echo " (無 stderr,正常)" +fi + +echo "" +echo "🛠 後續操作:" +echo " 立即觸發: launchctl start $PLIST_NAME" +echo " 解除安裝: bash $0 --uninstall" +echo " 查看日誌: tail -f ${LOG_DIR}/ppt_cleanup.log" diff --git a/scripts/ppt_cleanup.sh b/scripts/ppt_cleanup.sh new file mode 100755 index 0000000..0c77b84 --- /dev/null +++ b/scripts/ppt_cleanup.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# scripts/ppt_cleanup.sh +# 由 launchd / cron 呼叫 — 清理 7 天前過期的 PPT 檔 + DB row。 +# 安全執行:失敗時不會 raise,只寫 log。 + +set -euo pipefail + +PROJECT_DIR="${PROJECT_DIR:-/Users/ooo/Documents/momo_pro_system}" +VENV_PY="${VENV_PY:-${PROJECT_DIR}/venv/bin/python3}" +LOG_FILE="${LOG_FILE:-${PROJECT_DIR}/logs/ppt_cleanup.log}" +DAYS_OLD="${DAYS_OLD:-7}" + +mkdir -p "$(dirname "$LOG_FILE")" + +cd "$PROJECT_DIR" + +{ + echo "[$(date +'%Y-%m-%d %H:%M:%S')] PPT cleanup start (days_old=$DAYS_OLD)" + "$VENV_PY" -c " +from routes.openclaw_bot_routes import cleanup_expired_ppt_cache +import json +stat = cleanup_expired_ppt_cache(days_old=$DAYS_OLD) +print('result:', json.dumps(stat, ensure_ascii=False)) +" 2>&1 || echo "[ERROR] cleanup failed" + echo "[$(date +'%Y-%m-%d %H:%M:%S')] PPT cleanup end" + echo "---" +} >> "$LOG_FILE" diff --git a/services/ppt_generator.py b/services/ppt_generator.py index 04055bf..b7cb055 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -36,6 +36,30 @@ from pathlib import Path REPORTS_DIR = Path(os.environ.get("REPORTS_DIR", "/app/data/reports")) REPORTS_DIR.mkdir(parents=True, exist_ok=True) +# ── 模板版本(快取失效控制)───────────────────────────────────────────────── +# 任何一份簡報的「視覺/結構」設計變動,都必須 bump 該 report_type 的版本號。 +# 路由層會把版本號併入快取 key,舊快取自然 miss → 重新生成。 +# Bump 規則:major 設計改版 +0.1;微調文案不需 bump。 +TEMPLATE_VERSIONS = { + 'daily': 'v3.0', # 2026-05-02 暖紙封面 + matplotlib 折線 + 4 卡指標 + 附錄頁 + 'weekly': 'v3.0', # 2026-05-02 暖紙封面 + matplotlib 折線 + 區間結論帶 + 附錄頁 + 'monthly': 'v3.1', # 2026-05-02 v3.1 升級:封面 elevator + KPI △% + 趨勢折線 + 帕雷托 + 商品 △/🆕 + 附錄頁 + 'strategy': 'v3.0', # 2026-05-02 AI 頁去黑改暖紙 + 附錄頁 + 'competitor': 'v3.0', # 2026-05-02 AI 頁去黑改暖紙 + 附錄頁 + 'promo': 'v3.0', # 2026-05-02 AI 頁去黑改暖紙 + 附錄頁 + 'growth': 'v2.0', + 'vendor': 'v2.0', + 'bcg': 'v2.0', +} + + +def get_template_version(report_type: str) -> str: + """取得指定 report_type 的模板版本字串。 + 路由層在組 cache key 時呼叫此函式,模板升級時 bump TEMPLATE_VERSIONS 即可 + 使舊快取全部 miss。 + """ + return TEMPLATE_VERSIONS.get(report_type, 'v1.0') + # ── 調色盤 (完整對齊 EwoooC MOMO Pro design-tokens.css) ───────────────────────────── _BG_DARK = "2A2520" # momo-ink 暖墨封面底 _BG_PAPER = "F3EEE2" # momo-bg-paper 暖紙感底 @@ -75,11 +99,14 @@ _STRAT_ORDER = ['加碼', '機會', '收割', '觀察', '持穩'] # ── 字體規範 (對齊 MOMO Pro design-tokens.css) ───────────────────────────────── -# momo-font-display: JetBrains Mono / Space Mono (點陣等寬機械感—數字/標驔) +# momo-font-display: JetBrains Mono / Space Mono (點陣等寬機械感—數字/標題) # momo-font-family: Inter + PingFang TC / Microsoft JhengHei (中英混排內文) -_FONT_MONO = "Courier New" # 時作最靠論的等寬 fallback (點陣風格) -_FONT_BODY = "Microsoft JhengHei" # 中文圓體 / 黑體 (內文、表格) -_FONT_LABEL = "Arial" # 英文標籤 (OPENCLAW badge etc.) +# 在 PPT 內統一以 Latin / EastAsian 分軌指定字型,避免中文字撞上 Courier New 醜化 +_FONT_MONO = "Consolas" # Latin 點陣等寬:Consolas 比 Courier New 點陣感更佳,Win/Mac 皆有 +_FONT_DISPLAY = "Consolas" # 大數字(KPI 值、排名)走 Consolas +_FONT_BODY = "Microsoft JhengHei" # 中文黑體(內文、表格)— 中英混排預設 +_FONT_BODY_EA = "Microsoft JhengHei" # CJK 字型(與 _FONT_BODY 同名,但供 latin/ea 分軌使用) +_FONT_LABEL = "Arial" # 純英文標籤(OPENCLAW badge) _SLIDE_H = 19.05 # 投影片體高 cm _FOOTER_Y = 18.38 # footer 起始位置(底部對齊) _FOOTER_H = 0.67 # footer 高度 @@ -129,9 +156,42 @@ def _add_rect(slide, l, t, w, h, fill_hex, line_hex=None): return s +def _set_run_fonts(run, latin_font: str = None, ea_font: str = None): + """為單一 run 同時設定 Latin 字型與 EastAsian (CJK) 字型。 + 解法:直接寫入 a:rPr 下的 a:latin / a:ea 元素,避開 python-pptx + 只能設一個 font.name 的限制 — 這是「中英分軌」呈現的關鍵。 + """ + if not latin_font and not ea_font: + return + try: + from lxml import etree + rPr = run._r.get_or_add_rPr() + nsmap = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'} + if latin_font: + for el in rPr.findall('a:latin', nsmap): + rPr.remove(el) + latin_el = etree.SubElement(rPr, '{%s}latin' % nsmap['a']) + latin_el.set('typeface', latin_font) + if ea_font: + for el in rPr.findall('a:ea', nsmap): + rPr.remove(el) + ea_el = etree.SubElement(rPr, '{%s}ea' % nsmap['a']) + ea_el.set('typeface', ea_font) + except Exception: + # 失敗時退回 font.name 單軌設定 + if latin_font: + run.font.name = latin_font + + def _add_text(slide, text, l, t, w, h, bold=False, size=14, color=_WHITE, - align="left", valign="top", wrap=True, font_name=None): + align="left", valign="top", wrap=True, font_name=None, + latin_font=None, ea_font=None): + """加入文字方塊。 + font_name:單軌字型(向後相容)。 + latin_font / ea_font:中英分軌字型 — 同一個 run 內,數字/英文走 latin_font, + CJK 走 ea_font,避免中文混到 Consolas/Courier 變成醜陋的 fallback 字。 + """ from pptx.util import Pt from pptx.enum.text import PP_ALIGN from pptx.dml.color import RGBColor @@ -159,6 +219,8 @@ def _add_text(slide, text, l, t, w, h, run.font.size = Pt(size) if font_name: run.font.name = font_name + if latin_font or ea_font: + _set_run_fonts(run, latin_font=latin_font, ea_font=ea_font) run.font.color.rgb = RGBColor( int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)) return txb @@ -175,12 +237,12 @@ def _add_footer(slide, prs_w_cm=33.87): def _add_header(slide, title_text, prs_w_cm=33.87): - """焦糖橘頁首帶 1.7cm,中文圓體標題""" + """焦糖橘頁首帶 1.7cm,中英分軌標題""" _add_rect(slide, 0, 0, prs_w_cm, 1.7, _BRAND_OG) _add_rect(slide, 0, 0, 0.5, 1.7, _BRAND_OG2) _add_text(slide, title_text, 0.8, 0.1, prs_w_cm - 1.2, 1.5, bold=True, size=18, color=_WHITE, valign="middle", - font_name=_FONT_BODY) + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) def _add_empty_state(slide, title, detail, W=33.87): @@ -196,23 +258,23 @@ def _add_empty_state(slide, title, detail, W=33.87): def _kpi_card(slide, l, t, w, h, fill, label, value, sub=""): - """暖色 KPI 卡:label 圓體小字,value 點陣等寬大字置中""" + """暖色 KPI 卡:label 中英分軌小字,value 點陣等寬大字置中(中英分軌)""" _add_rect(slide, l, t, w, h, fill) - _add_rect(slide, l, t, 0.12, h, "FFFFFF") # 左田白透明條 + _add_rect(slide, l, t, 0.12, h, "FFFFFF") _add_text(slide, label, l + 0.35, t + 0.25, w - 0.55, 0.65, - bold=False, size=10, color="FAF7F0", - font_name=_FONT_BODY) + bold=False, size=11, color="FAF7F0", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) _add_text(slide, value, l + 0.2, t + 0.85, w - 0.4, h - 1.55, bold=True, size=30, color="FFFFFF", align="center", valign="middle", - font_name=_FONT_MONO) # 數字用點陣等寬字體 + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) if sub: _add_text(slide, sub, l + 0.2, t + h - 0.7, w - 0.4, 0.6, size=9, color="FAF7F0", align="center", - font_name=_FONT_BODY) + ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY) @@ -365,51 +427,563 @@ def _add_horiz_chart(slide, l, t, w, h, categories, series_list, return chart +# ── matplotlib 專業圖表(暖色系設計)───────────────────────────────────────── +def _mpl_horiz_bar_png(categories, values, total_width_cm=18.5, total_height_cm=11.0, + value_unit="萬", title="", highlight_top_n=3) -> "io.BytesIO": + """以 matplotlib 產生暖色系橫條排行圖,回傳 PNG BytesIO。 + - 漸層暖色(焦糖橘 → 暖黃 → 米色),TOP3 用最深焦糖橘 + - 條右側標註 NT$X.X萬 + 佔比 % + - 軸線弱化、無上邊框、底色透明 — 適合貼進米色 PPT 頁 + """ + import io + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.font_manager as fm + except ImportError: + return None + + if not categories or not values: + return None + + # 嘗試找一個能顯示中文的字型(macOS / Linux 容器都適用) + cjk_candidates = [ + "PingFang TC", "PingFang SC", "Heiti TC", "Microsoft JhengHei", + "Noto Sans CJK TC", "Noto Sans TC", "Noto Sans CJK", + "Source Han Sans TC", "Source Han Sans", "WenQuanYi Zen Hei", + "Hiragino Sans GB", "Arial Unicode MS", + ] + available = {f.name for f in fm.fontManager.ttflist} + chosen_cjk = next((f for f in cjk_candidates if f in available), None) + if chosen_cjk: + plt.rcParams["font.family"] = [chosen_cjk, "DejaVu Sans"] + plt.rcParams["axes.unicode_minus"] = False + + # 數值轉萬元 + vals_wan = [float(v) / 10000.0 for v in values] + total = sum(vals_wan) or 1.0 + pcts = [v / total * 100 for v in vals_wan] + + # 由低到高反轉,讓最高排在最上 + cats_rev = list(reversed(categories)) + vals_rev = list(reversed(vals_wan)) + pcts_rev = list(reversed(pcts)) + + # 暖色階:TOP3 焦糖橘 #C96442、4-6 蜂蜜金 #B88416、7+ 米暖 #C4BAA8 + colors = [] + n = len(cats_rev) + for i, _ in enumerate(cats_rev): + rank_from_top = n - i + if rank_from_top <= highlight_top_n: + colors.append("#C96442") + elif rank_from_top <= 6: + colors.append("#B88416") + else: + colors.append("#8A5A2B") + + # 動態圖表尺寸(cm → inch) + fig_w = total_width_cm / 2.54 + fig_h = total_height_cm / 2.54 + fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150) + fig.patch.set_facecolor("#F3EEE2") # 暖紙感底(與 PPT 對齊) + ax.set_facecolor("#F3EEE2") + + bars = ax.barh(cats_rev, vals_rev, color=colors, edgecolor="none", height=0.62) + + # 數值標註(條右):NT$X.X萬 + 佔比% + max_val = max(vals_rev) if vals_rev else 1 + for i, (bar, v, p) in enumerate(zip(bars, vals_rev, pcts_rev)): + bw = bar.get_width() + ax.text(bw + max_val * 0.012, + bar.get_y() + bar.get_height() / 2, + f"NT${v:,.1f}{value_unit} · {p:.0f}%", + va="center", ha="left", + fontsize=10, color="#2A2520", + fontweight="bold") + + # 軸線美化 + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color("#9B9081") + ax.spines["bottom"].set_color("#9B9081") + ax.tick_params(colors="#645C52", labelsize=11) + ax.xaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6) + ax.yaxis.grid(False) + ax.set_axisbelow(True) + # 預留右側空間給數值標註 + ax.set_xlim(0, max_val * 1.32) + ax.set_xlabel("業績(萬元)", fontsize=10, color="#645C52", labelpad=8) + + if title: + ax.set_title(title, fontsize=13, color="#2A2520", loc="left", + pad=12, fontweight="bold") + + plt.tight_layout() + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=150, + facecolor=fig.get_facecolor(), bbox_inches="tight") + plt.close(fig) + buf.seek(0) + return buf + + +def _add_image_from_buf(slide, buf, l, t, w, h): + """把 BytesIO 圖片貼進投影片""" + if buf is None: + return None + try: + return slide.shapes.add_picture(buf, _emu(l), _emu(t), _emu(w), _emu(h)) + except Exception: + return None + + +def _mpl_setup(): + """共用:設定 CJK 字型並回傳 (matplotlib, plt)""" + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.font_manager as fm + except ImportError: + return None, None + cjk_candidates = [ + "PingFang TC", "PingFang SC", "Heiti TC", "Microsoft JhengHei", + "Noto Sans CJK TC", "Noto Sans TC", "Noto Sans CJK", + "Source Han Sans TC", "Source Han Sans", "WenQuanYi Zen Hei", + "Hiragino Sans GB", "Arial Unicode MS", + ] + available = {f.name for f in fm.fontManager.ttflist} + chosen_cjk = next((f for f in cjk_candidates if f in available), None) + if chosen_cjk: + plt.rcParams["font.family"] = [chosen_cjk, "DejaVu Sans"] + plt.rcParams["axes.unicode_minus"] = False + return matplotlib, plt + + +def _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=None, + total_width_cm=30.0, total_height_cm=11.0, + title="", curr_label="本月", prev_label="上月對比") -> "io.BytesIO": + """日業績折線:本月實線(焦糖橘)+ 上月虛線(蜂蜜金)+ 平均水平線。 + 高/低點自動標註,圖例置上。 + """ + import io + matplotlib, plt = _mpl_setup() + if plt is None or not curr_dates or not curr_vals: + return None + + curr_wan = [float(v) / 10000.0 for v in curr_vals] + fig_w = total_width_cm / 2.54 + fig_h = total_height_cm / 2.54 + fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150) + fig.patch.set_facecolor("#F3EEE2") + ax.set_facecolor("#F3EEE2") + + x = list(range(len(curr_dates))) + line_curr, = ax.plot(x, curr_wan, color="#C96442", linewidth=2.4, + marker="o", markersize=5, + markerfacecolor="#C96442", markeredgecolor="#FAF7F0", + label=curr_label, zorder=3) + ax.fill_between(x, curr_wan, alpha=0.12, color="#C96442", zorder=1) + + if prev_vals and len(prev_vals) >= 1: + prev_wan = [float(v) / 10000.0 for v in prev_vals[:len(curr_vals)]] + # 對齊長度 + while len(prev_wan) < len(curr_wan): + prev_wan.append(None) + ax.plot(x, prev_wan, color="#B88416", linewidth=1.6, + linestyle="--", marker=None, + label=prev_label, zorder=2, alpha=0.85) + + # 平均線 + avg_v = sum(curr_wan) / len(curr_wan) if curr_wan else 0 + ax.axhline(avg_v, color="#8F4530", linewidth=1.0, linestyle=":", + alpha=0.7, label=f"本月日均 {avg_v:.1f}萬") + + # 標註高點 / 低點 + if curr_wan: + max_i = curr_wan.index(max(curr_wan)) + min_i = curr_wan.index(min(curr_wan)) + ax.annotate(f"高 {curr_wan[max_i]:.1f}萬", + xy=(max_i, curr_wan[max_i]), + xytext=(0, 12), textcoords="offset points", + ha="center", fontsize=9, color="#8F4530", fontweight="bold") + if min_i != max_i: + ax.annotate(f"低 {curr_wan[min_i]:.1f}萬", + xy=(min_i, curr_wan[min_i]), + xytext=(0, -16), textcoords="offset points", + ha="center", fontsize=9, color="#B5342F", fontweight="bold") + + ax.set_xticks(x) + ax.set_xticklabels([str(d)[-5:] for d in curr_dates], rotation=0, fontsize=9) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color("#9B9081") + ax.spines["bottom"].set_color("#9B9081") + ax.tick_params(colors="#645C52", labelsize=10) + ax.yaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6) + ax.xaxis.grid(False) + ax.set_axisbelow(True) + ax.set_ylabel("日業績(萬元)", fontsize=10, color="#645C52", labelpad=8) + + if title: + ax.set_title(title, fontsize=13, color="#2A2520", loc="left", + pad=12, fontweight="bold") + + ax.legend(loc="upper left", frameon=False, fontsize=10, ncol=3, + bbox_to_anchor=(0, 1.10)) + + plt.tight_layout() + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=150, + facecolor=fig.get_facecolor(), bbox_inches="tight") + plt.close(fig) + buf.seek(0) + return buf + + +def _mpl_pareto_chart_png(categories, values, total_width_cm=18.5, + total_height_cm=11.0, title="") -> "io.BytesIO": + """品類帕雷托:暖色橫條 + 累計貢獻折線(80/20 標線)""" + import io + matplotlib, plt = _mpl_setup() + if plt is None or not categories or not values: + return None + + vals_wan = [float(v) / 10000.0 for v in values] + total = sum(vals_wan) or 1.0 + # 由大到小排 + pairs = sorted(zip(categories, vals_wan), key=lambda p: -p[1]) + cats = [p[0] for p in pairs] + vals = [p[1] for p in pairs] + cum_pct = [] + running = 0 + for v in vals: + running += v + cum_pct.append(running / total * 100) + + fig_w = total_width_cm / 2.54 + fig_h = total_height_cm / 2.54 + fig, ax1 = plt.subplots(figsize=(fig_w, fig_h), dpi=150) + fig.patch.set_facecolor("#F3EEE2") + ax1.set_facecolor("#F3EEE2") + + n = len(cats) + bar_colors = [] + for i in range(n): + if cum_pct[i] <= 80: + bar_colors.append("#C96442") # 80% 內=主力 + else: + bar_colors.append("#C4BAA8") # 80% 外=長尾 + bars = ax1.bar(range(n), vals, color=bar_colors, width=0.6, edgecolor="none") + + # 條上標 NT$X.X萬 + max_val = max(vals) if vals else 1 + for i, (bar, v) in enumerate(zip(bars, vals)): + ax1.text(bar.get_x() + bar.get_width() / 2, + v + max_val * 0.02, + f"{v:.1f}", + ha="center", va="bottom", + fontsize=9, color="#2A2520", fontweight="bold") + + ax1.set_xticks(range(n)) + ax1.set_xticklabels([c[:8] for c in cats], rotation=20, ha="right", fontsize=9) + ax1.spines["top"].set_visible(False) + ax1.spines["right"].set_visible(False) + ax1.spines["left"].set_color("#9B9081") + ax1.spines["bottom"].set_color("#9B9081") + ax1.tick_params(colors="#645C52", labelsize=9) + ax1.set_ylabel("業績(萬元)", fontsize=10, color="#645C52", labelpad=8) + ax1.yaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6) + ax1.set_axisbelow(True) + ax1.set_ylim(0, max_val * 1.18) + + # 右軸:累計 % + ax2 = ax1.twinx() + ax2.plot(range(n), cum_pct, color="#8F4530", linewidth=2.0, + marker="o", markersize=5, + markerfacecolor="#8F4530", markeredgecolor="#FAF7F0", zorder=4) + ax2.set_ylim(0, 110) + ax2.set_ylabel("累計貢獻(%)", fontsize=10, color="#8F4530", labelpad=8) + ax2.tick_params(colors="#8F4530", labelsize=9) + ax2.spines["top"].set_visible(False) + ax2.spines["right"].set_color("#8F4530") + ax2.spines["left"].set_visible(False) + + # 80% 參考線 + ax2.axhline(80, color="#B5342F", linewidth=1.0, linestyle=":", alpha=0.7) + ax2.text(n - 0.5, 82, "80% 主力線", color="#B5342F", fontsize=9, + ha="right", va="bottom", fontweight="bold") + + if title: + ax1.set_title(title, fontsize=13, color="#2A2520", loc="left", + pad=12, fontweight="bold") + + plt.tight_layout() + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=150, + facecolor=fig.get_facecolor(), bbox_inches="tight") + plt.close(fig) + buf.seek(0) + return buf + + +# ── 升級版 KPI 卡:含 △% 與紅綠燈 ───────────────────────────────────────────── +def _kpi_card_v2(slide, l, t, w, h, fill, label, value, delta_pct=None, + delta_label="vs 上月", inverse=False, sub=""): + """KPI 卡 v2:value 大字 + 右下角 △% 徽章(綠↑紅↓)+ delta_label + inverse=True:數字越低越好(如成本、退貨率),漲跌邏輯反轉 + """ + _add_rect(slide, l, t, w, h, fill) + _add_rect(slide, l, t, 0.12, h, "FFFFFF") + + # Label + _add_text(slide, label, + l + 0.35, t + 0.22, w - 0.55, 0.5, + size=11, color="FAF7F0", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + + # Value 大字(向上挪一點,留空間給 delta) + _add_text(slide, value, + l + 0.2, t + 0.7, w - 0.4, h - 1.85, + bold=True, size=28, color="FFFFFF", + align="center", valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # Delta 徽章(底部 0.85cm) + if delta_pct is not None: + try: + d = float(delta_pct) + except Exception: + d = 0 + is_up = d > 0 + good = (is_up and not inverse) or ((not is_up) and inverse) + badge_color = "#2A7A3F" if good else ("#B5342F" if d != 0 else "#645C52") + arrow = "▲" if is_up else ("▼" if d < 0 else "—") + sign = "+" if is_up else "" + # 底部白底徽章帶 + _add_rect(slide, l + 0.2, t + h - 0.9, w - 0.4, 0.7, "FFFFFF") + _add_text(slide, f"{arrow} {sign}{d:.1f}% {delta_label}", + l + 0.2, t + h - 0.85, w - 0.4, 0.6, + bold=True, size=11, color=badge_color.lstrip("#"), + align="center", valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + elif sub: + _add_text(slide, sub, + l + 0.2, t + h - 0.7, w - 0.4, 0.55, + size=10, color="FAF7F0", align="center", + ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY) + + +# ── Elevator Pitch 計算(封面 / 摘要用)────────────────────────────────────── +def _compute_elevator_pitch(curr: dict, prev_mo: dict = None) -> dict: + """從本月與上月資料萃取「業績狀態 / 最大亮點 / 最大警訊」 + 回傳 dict: {status, status_color, highlight, warning, mom_rev, mom_ord, mom_gm} + 狀態判定:MoM 業績 > +10% → 強勁;> 0 → 穩健;> -10% → 持平;其餘 → 警訊 + """ + out = { + 'status': '—', 'status_color': _SUBTEXT, + 'highlight': '', 'warning': '', + 'mom_rev': None, 'mom_ord': None, 'mom_gm': None, + } + if not curr: + return out + + rev = float(curr.get('revenue', 0) or 0) + ord_ = int(curr.get('orders', 0) or 0) + gm = float(curr.get('gross_margin', 0) or 0) + + mom_rev, mom_ord, mom_gm = None, None, None + if prev_mo: + prev_rev = float(prev_mo.get('revenue', 0) or 0) + prev_ord = int(prev_mo.get('orders', 0) or 0) + prev_gm = float(prev_mo.get('gross_margin', 0) or 0) + if prev_rev: + mom_rev = (rev - prev_rev) / prev_rev * 100 + if prev_ord: + mom_ord = (ord_ - prev_ord) / prev_ord * 100 + mom_gm = gm - prev_gm # 毛利率走絕對 pp 變化(非相對 %) + + out['mom_rev'] = mom_rev + out['mom_ord'] = mom_ord + out['mom_gm'] = mom_gm + + if mom_rev is None: + out['status'] = '基準月' + out['status_color'] = _SUBTEXT + elif mom_rev > 10: + out['status'] = '強勁成長' + out['status_color'] = "2A7A3F" + elif mom_rev > 0: + out['status'] = '穩健' + out['status_color'] = "B88416" + elif mom_rev > -10: + out['status'] = '持平偏弱' + out['status_color'] = "8A5A2B" + else: + out['status'] = '需警示' + out['status_color'] = "B5342F" + + # 最大亮點(暖色標) + cats = curr.get('top_categories') or [] + if cats: + top_cat = cats[0] + top_pct = float(top_cat.get('revenue', 0)) / rev * 100 if rev else 0 + out['highlight'] = (f"{top_cat.get('cat','')}撐起 {top_pct:.0f}% 業績" + f"(NT${float(top_cat.get('revenue',0))/10000:.1f}萬)") + + # 警訊(毛利率 / 集中度 / 衰退) + warnings = [] + if gm and gm < 10: + warnings.append(f"毛利率僅 {gm:.1f}%,低於健康水位 10%") + if cats and rev: + top_pct = float(cats[0].get('revenue', 0)) / rev * 100 + if top_pct > 60: + warnings.append(f"前一品類佔比 {top_pct:.0f}%,集中度過高") + if mom_rev is not None and mom_rev < -10: + warnings.append(f"業績較上月衰退 {abs(mom_rev):.1f}%") + out['warning'] = warnings[0] if warnings else "無顯著風險" + + return out + + +# ── 附錄頁(資料來源 / 計算口徑 / 版本)───────────────────────────────────── +def _appendix_slide(prs, report_type: str, period_str: str, + data_sources: list = None, definitions: list = None, + W: float = 33.87): + """專業附錄頁:建立報告可信度。 + 內容:資料來源、計算口徑、報告版本、生成時間 + """ + s = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s, "📎 附錄:資料來源與計算口徑") + + # 兩欄:左 = 資料來源、右 = 計算口徑 + col_w = (W - 1.2) / 2 + + # 左欄:資料來源 + _add_rect(s, 0.4, 1.95, col_w, 0.85, _BRAND_OG) + _add_text(s, "📂 資料來源", + 0.7, 2.05, col_w - 0.5, 0.65, + bold=True, size=13, color=_WHITE, valign="middle", + ea_font=_FONT_BODY_EA) + + sources = data_sources or [ + "• realtime_sales_monthly:MOMO 後台訂單明細", + "• 商品分類 L1:MOMO 主分類欄位", + "• 廠商名稱:MOMO 賣家主檔", + "• MCP 外部情報:Gemini Grounding + 靜態節日日曆", + ] + src_text = "\n\n".join(sources) + _add_rect(s, 0.4, 2.85, col_w, 11.5, _WHITE, line_hex=_SUBTLE) + _add_text(s, src_text, + 0.7, 3.05, col_w - 0.6, 11.1, + size=11, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 右欄:計算口徑 + rx = 0.4 + col_w + 0.4 + _add_rect(s, rx, 1.95, col_w, 0.85, _KPI_MAHOGANY) + _add_text(s, "📐 計算口徑", + rx + 0.3, 2.05, col_w - 0.5, 0.65, + bold=True, size=13, color=_WHITE, valign="middle", + ea_font=_FONT_BODY_EA) + + defs = definitions or [ + "• 業績 = SUM(總業績),含運費,未扣退貨", + "• 訂單 = COUNT DISTINCT(訂單編號)", + "• 毛利率 = (業績 − 總成本) / 業績 × 100%", + "• 客單價 = 業績 / 訂單數", + "• MoM = (本月 − 上月) / 上月 × 100%", + "• 品類佔比 = 該品類業績 / 總業績 × 100%", + ] + def_text = "\n\n".join(defs) + _add_rect(s, rx, 2.85, col_w, 11.5, _WHITE, line_hex=_SUBTLE) + _add_text(s, def_text, + rx + 0.3, 3.05, col_w - 0.6, 11.1, + size=11, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 底部資訊條 + _add_rect(s, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2) + tpl_ver = TEMPLATE_VERSIONS.get(report_type, 'v1.0') + info = (f"報告類型:{report_type} | 期間:{period_str} " + f"| 模板版本:{tpl_ver} | 生成時間:{datetime.now().strftime('%Y/%m/%d %H:%M')} " + f"| Powered by OpenClaw AI Agent") + _add_text(s, info, + 0.7, 14.85, W - 1.4, 0.7, + size=10, color=_WHITE, valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + _add_footer(s, W) + return s + + # ── 封面頁 ──────────────────────────────────────────────────────────────────── def _cover_slide(prs, big_title: str, sub1: str, sub2: str = ""): - """暖墨封面:左焦糖橘寬條 + 右側深焦糖細條 + 底部暖色漸層帶""" + """暖紙感封面 v3:米紙底 + 焦糖橘左寬條 + 暖色裝飾帶 + 大字暖墨標題 + 設計目標:去除大面積黑/暖墨底,改以暖紙感為主視覺;標題用暖墨色寫在米底上。 + """ slide = prs.slides.add_slide(prs.slide_layouts[6]) W = 33.87 H = 19.05 - # 整頁暖墨底 - _add_rect(slide, 0, 0, W, H, _BG_DARK) + # 主底:暖紙感米色(非黑、非純白) + _add_rect(slide, 0, 0, W, H, _BG_PAPER) - # 左側焦糖橘粗條(2.5cm) - _add_rect(slide, 0, 0, 2.5, H, _BRAND_OG) - # 左條內深色細分隔 - _add_rect(slide, 2.3, 0, 0.2, H, _BRAND_OG2) + # 左側焦糖橘粗條(3.0cm,比原來再粗一點,視覺重量平衡米底) + _add_rect(slide, 0, 0, 3.0, H, _BRAND_OG) + # 左條右緣加一條深焦糖細邊 + _add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2) - # 右側深焦糖細條 - _add_rect(slide, W - 0.6, 0, 0.6, H, _BRAND_OG2) + # 右上角裝飾:深焦糖斜帶(不蓋滿) + _add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2) + _add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _BRAND_OG) - # 底部暖色帶(高度 3.5cm) - _add_rect(slide, 0, H - 3.5, W, 3.5, _BRAND_OG2) - # 底部帶上方橘色分隔線 - _add_rect(slide, 0, H - 3.55, W, 0.12, _BRAND_OG) + # 右下角米深裝飾帶 + _add_rect(slide, W - 8.0, H - 4.2, 8.0, 0.18, _BRAND_OG) + _add_rect(slide, W - 8.0, H - 4.0, 8.0, 4.0, _SUBTLE) - # OPENCLAW 品牌標籤 - _add_rect(slide, 3.0, 1.3, 4.5, 0.75, _BRAND_OG) - _add_text(slide, "OPENCLAW", 3.1, 1.33, 4.3, 0.69, - bold=True, size=12, color=_WHITE, align="center", valign="middle") + # 中段 hairline 分隔(標題 / 副資訊 之間) + _add_rect(slide, 4.0, 9.5, 22.0, 0.06, _BRAND_OG) - # 主標題(大字) - _add_text(slide, big_title, 3.0, 2.5, 27, 4.5, - bold=True, size=40, color=_WHITE) + # OPENCLAW 標籤(深焦糖底 + 白字,落在米底上才看得見) + _add_rect(slide, 3.8, 1.6, 4.8, 0.85, _BRAND_OG2) + _add_text(slide, "OPENCLAW", 3.8, 1.62, 4.8, 0.81, + bold=True, size=12, color=_WHITE, align="center", valign="middle", + latin_font=_FONT_LABEL) - # 副標題 sub1 - _add_text(slide, sub1, 3.0, 7.3, 27, 1.0, - size=15, color="E2DCD0") + # 報告類別小標(焦糖橘小字) + _add_text(slide, "MONTHLY · OPERATIONS · AI INSIGHT", + 3.8, 2.65, 22, 0.55, + bold=True, size=10, color=_BRAND_OG2, + latin_font=_FONT_LABEL) - # 副標題 sub2(若有) + # 主標題(大字、暖墨色) + _add_text(slide, big_title, 3.8, 3.5, 25, 5.5, + bold=True, size=44, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 副標題 sub1(焦糖橘加深、bold) + _add_text(slide, sub1, 3.8, 9.95, 25, 0.85, + bold=True, size=16, color=_BRAND_OG2, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 副標題 sub2(暖灰) if sub2: - _add_text(slide, sub2, 3.0, 8.4, 27, 0.9, - size=12, color="9B9081") + _add_text(slide, sub2, 3.8, 11.0, 25, 0.85, + size=12, color=_SUBTEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) - # 底部帶內:生成資訊 + # 右下角資訊區(生成時間 / 版式說明) _add_text(slide, "月度營運智能分析報告", - 3.0, H - 3.0, 18, 0.8, - bold=True, size=14, color=_WHITE) + W - 7.5, H - 3.6, 7.0, 0.6, + bold=True, size=12, color=_DARK_TEXT, align="right", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_text(slide, "Generated by OpenClaw AI Agent", + W - 7.5, H - 3.0, 7.0, 0.5, + size=9, color=_SUBTEXT, align="right", + latin_font=_FONT_LABEL) _add_footer(slide, W) return slide @@ -450,15 +1024,20 @@ def _product_table_slide(prs, header_text, products, W=33.87, max_items=50): _add_rect(slide, 0.4, tbl_y, W - 0.8, TABLE_HDR_H, _BRAND_OG) _add_rect(slide, 0.4, tbl_y, 0.3, TABLE_HDR_H, _BRAND_OG2) _add_text(slide, "排名", 0.5, tbl_y + 0.06, 1.3, TABLE_HDR_H - 0.12, - bold=True, size=10, color=_WHITE, align="center", font_name=_FONT_BODY) + bold=True, size=10, color=_WHITE, align="center", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) _add_text(slide, "商品名稱", 1.95, tbl_y + 0.06, W - 19.5, TABLE_HDR_H - 0.12, - bold=True, size=10, color=_WHITE, font_name=_FONT_BODY) + bold=True, size=10, color=_WHITE, + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) _add_text(slide, "業績佔比", W - 17.2, tbl_y + 0.06, 5.5, TABLE_HDR_H - 0.12, - bold=True, size=9, color=_WHITE, align="center", font_name=_FONT_BODY) + bold=True, size=9, color=_WHITE, align="center", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) _add_text(slide, "月業績", W - 11.3, tbl_y + 0.06, 5.8, TABLE_HDR_H - 0.12, - bold=True, size=10, color=_WHITE, align="right", font_name=_FONT_BODY) + bold=True, size=10, color=_WHITE, align="right", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) _add_text(slide, "訂單", W - 5.0, tbl_y + 0.06, 4.4, TABLE_HDR_H - 0.12, - bold=True, size=10, color=_WHITE, align="right", font_name=_FONT_BODY) + bold=True, size=10, color=_WHITE, align="right", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) # ── 資料行 for j, p in enumerate(page_prods): @@ -477,13 +1056,15 @@ def _product_table_slide(prs, header_text, products, W=33.87, max_items=50): _add_rect(slide, 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, rank_fill) _add_text(slide, str(actual_rank), 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, - bold=(actual_rank <= 3), size=10, color=rank_color, - align="center", valign="middle", font_name=_FONT_MONO) + bold=(actual_rank <= 3), size=11, color=rank_color, + align="center", valign="middle", + latin_font=_FONT_DISPLAY) - # 商品名稱 + # 商品名稱(CJK 為主、中英分軌) _add_text(slide, str(p.get('name', ''))[:44], 1.95, row_t + 0.1, W - 19.7, ROW_H - 0.2, - size=10, color=_DARK_TEXT, font_name=_FONT_BODY) + size=10, color=_DARK_TEXT, + ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY) # 業績佔比視覺條 bar_max_w = 5.0 @@ -494,19 +1075,22 @@ def _product_table_slide(prs, header_text, products, W=33.87, max_items=50): _add_rect(slide, W - 17.0, bar_y, bar_w, bar_h, _BRAND_OG) _add_text(slide, f"{pct*100:.0f}%", W - 17.0, row_t + 0.08, bar_max_w, ROW_H - 0.2, - size=8, color=_TERTIARY, align="right", font_name=_FONT_MONO) + size=9, color=_TERTIARY, align="right", + latin_font=_FONT_DISPLAY) - # 月業績 + # 月業績(純數字 → Display 字型) _add_text(slide, f"NT${rev:,.0f}", W - 11.3, row_t + 0.1, 5.8, ROW_H - 0.2, - bold=(actual_rank == 1), size=10, color=_DARK_TEXT, - align="right", font_name=_FONT_MONO) + bold=(actual_rank == 1), size=11, color=_DARK_TEXT, + align="right", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) - # 訂單 + # 訂單(數字 + 中文「筆」→ 中英分軌) if ord_v: _add_text(slide, f"{int(ord_v):,} 筆", W - 5.0, row_t + 0.1, 4.4, ROW_H - 0.2, - size=9, color=_SUBTEXT, align="right", font_name=_FONT_MONO) + size=10, color=_SUBTEXT, align="right", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) _add_footer(slide, W) @@ -567,32 +1151,76 @@ def generate_daily_ppt(date_str: str, db_data, ai_text: str) -> str: W - 7.5, 6.05 + i * 0.85, 5, 0.72, size=10, color=_DARK_TEXT, align="right") _add_footer(s2, W) - # P3: 近7日業績走勢圖(來源:daily_sales.html trendChart) + # P3: 近7日業績走勢(matplotlib 折線專業版) s3 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s3, 0, 0, W, 19.05, _WHITE) - _add_header(s3, "近 7 日業績走勢(萬元)") + _add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s3, f"近 7 日業績走勢 — 截至 {date_str}") if wk: - dates = [str(w.get('date', ''))[-5:] for w in wk] - revs = [float(w.get('revenue', 0)) for w in wk] - _add_column_chart(s3, 0.8, 1.8, W - 1.6, 11.0, - dates, [("日業績(萬元)", revs)], - bar_colors=[_BRAND_OG]) - # 加總標注 - total_7d = sum(revs) - _add_text(s3, f"近7日合計:NT${total_7d/10000:.1f}萬", - 0.8, 13.0, 12, 0.5, size=10, color=_SUBTEXT) + dates_lst = [str(w.get('date', '')) for w in wk] + revs_lst = [float(w.get('revenue', 0)) for w in wk] + chart_w = W - 0.8 + chart_h = 11.0 + buf = _mpl_line_chart_png(dates_lst, revs_lst, + prev_vals=None, + total_width_cm=chart_w, + total_height_cm=chart_h, + title="日業績走勢(含日均線)", + curr_label="日業績") + if buf is not None: + _add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h) + else: + _add_column_chart(s3, 0.8, 1.95, W - 1.6, 11.0, + [d[-5:] for d in dates_lst], [("日業績(萬元)", revs_lst)], + bar_colors=[_BRAND_OG]) + + # 底部 4 卡:合計 / 日均 / 最高 / 最低 + total_7d = sum(revs_lst) + avg_7d = total_7d / len(revs_lst) if revs_lst else 0 + max_7d = max(revs_lst) if revs_lst else 0 + min_7d = min(revs_lst) if revs_lst else 0 + ins_y = 13.3 + ins_h = 2.4 + card_w = (W - 1.0 - 0.3 * 3) / 4 + cards = [ + (_BRAND_OG, "📊 近7日合計", f"NT${total_7d/10000:.1f}萬", f"{len(revs_lst)} 天"), + (_KPI_HONEY, "📈 日均業績", f"NT${avg_7d/10000:.1f}萬", "7-day avg"), + (_KPI_MAHOGANY,"🏆 最高單日", f"NT${max_7d/10000:.1f}萬", + dates_lst[revs_lst.index(max_7d)][-5:] if revs_lst else "—"), + (_KPI_EARTH, "📉 最低單日", f"NT${min_7d/10000:.1f}萬", + dates_lst[revs_lst.index(min_7d)][-5:] if revs_lst else "—"), + ] + for i, (col, lbl, val, sub) in enumerate(cards): + cx = 0.5 + i * (card_w + 0.3) + _add_rect(s3, cx, ins_y, card_w, ins_h, col) + _add_rect(s3, cx, ins_y, 0.15, ins_h, "FFFFFF") + _add_text(s3, lbl, cx + 0.3, ins_y + 0.2, card_w - 0.5, 0.5, + size=10, color="FAF7F0", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(s3, val, cx + 0.2, ins_y + 0.75, card_w - 0.4, 0.95, + bold=True, size=18, color="FFFFFF", align="center", valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_text(s3, sub, cx + 0.2, ins_y + 1.75, card_w - 0.4, 0.55, + size=9, color="FAF7F0", align="center", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) else: _add_empty_state(s3, "近 7 日業績資料不足", "缺少 weekly 趨勢資料,已保留 KPI 與商品頁。", W) _add_footer(s3, W) - # P4: AI 洞察 + # P4: AI 洞察(暖紙底 + 焦糖橘色條,去黑) s4 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s4, 0, 0, W, 19.05, _BG_DARK) - _add_header(s4, "AI 洞察分析") + _add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s4, "🎯 AI 洞察分析") + _add_rect(s4, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE) + _add_rect(s4, 0.6, 1.95, 0.4, 13.5, _BRAND_OG) _add_text(s4, ai_text or "(AI 分析生成中)", - 1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True) + 1.3, 2.2, W - 2.2, 13.0, + size=13, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) _add_footer(s4, W) + # P5: 附錄 + _appendix_slide(prs, 'daily', date_str) + path = _new_path("daily") prs.save(path) return path @@ -637,24 +1265,43 @@ def generate_weekly_ppt(db_data, ai_text: str) -> str: _kpi_card(s2, i * 7.8 + 0.5, 1.8, 7.4, 3.5, col, lbl, val, sub) _add_footer(s2, W) - # P3: 7日業績柱狀圖(參考 daily_sales.html trendChart) + # P3: 7日業績走勢(matplotlib 折線專業版) s3 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s3, 0, 0, W, 19.05, _WHITE) - _add_header(s3, "近 7 日業績走勢圖(萬元)") + _add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s3, "近 7 日業績走勢圖") if wk: - dates = [str(w.get('date', ''))[-5:] for w in wk] - revs = [float(w.get('revenue', 0)) for w in wk] - _add_column_chart(s3, 0.8, 1.8, W - 1.6, 11.0, - dates, [("日業績(萬元)", revs)], - bar_colors=[_BLUE_KPI]) - # 逐日數值標注(文字輔助) - max_rev = max(revs) if revs else 1 - for j, (d, r) in enumerate(zip(dates, revs)): - color = _BRAND_OG if r == max_rev else _SUBTEXT - _add_text(s3, f"{r/10000:.1f}", - 0.8 + j * ((W - 1.6) / max(len(dates), 1)), - 12.9, (W - 1.6) / max(len(dates), 1) - 0.1, 0.5, - size=8, color=color, align="center") + dates_full = [str(w.get('date', '')) for w in wk] + revs_full = [float(w.get('revenue', 0)) for w in wk] + chart_w = W - 0.8 + chart_h = 11.0 + buf = _mpl_line_chart_png(dates_full, revs_full, + prev_vals=None, + total_width_cm=chart_w, + total_height_cm=chart_h, + title="日業績走勢(含日均線、高低點)", + curr_label="日業績") + if buf is not None: + _add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h) + else: + _add_column_chart(s3, 0.8, 1.95, W - 1.6, 11.0, + [d[-5:] for d in dates_full], [("日業績(萬元)", revs_full)], + bar_colors=[_BRAND_OG]) + + # 底部結論帶(最佳/最差/標準差) + max_v = max(revs_full) + min_v = min(revs_full) + spread = max_v - min_v + spread_pct = spread / avg_daily * 100 if avg_daily else 0 + _add_rect(s3, 0.4, 13.3, W - 0.8, 2.0, _BRAND_OG2) + _add_text(s3, + f"📊 本週區間:最高 NT${max_v/10000:.1f}萬" + f"({dates_full[revs_full.index(max_v)][-5:]})" + f" | 最低 NT${min_v/10000:.1f}萬" + f"({dates_full[revs_full.index(min_v)][-5:]})" + f" | 高低差 NT${spread/10000:.1f}萬(為日均 {spread_pct:.0f}%)", + 0.7, 13.5, W - 1.4, 1.6, + bold=True, size=12, color=_WHITE, valign="middle", wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) else: _add_empty_state(s3, "近 7 日業績資料不足", "缺少 weekly 趨勢資料,已保留 KPI 與 TOP 商品頁。", W) _add_footer(s3, W) @@ -662,14 +1309,22 @@ def generate_weekly_ppt(db_data, ai_text: str) -> str: # P4: TOP10 商品表 _product_table_slide(prs, "週報 TOP 10 熱銷商品", tp) - # P5: AI 洞察 + # P5: AI 洞察(暖紙底 + 焦糖橘色條,去黑) s5 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s5, 0, 0, W, 19.05, _BG_DARK) - _add_header(s5, "週報 AI 洞察") + _add_rect(s5, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s5, "🎯 週報 AI 洞察") + _add_rect(s5, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE) + _add_rect(s5, 0.6, 1.95, 0.4, 13.5, _BRAND_OG) _add_text(s5, ai_text or "(暫無 AI 分析)", - 1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True) + 1.3, 2.2, W - 2.2, 13.0, + size=13, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) _add_footer(s5, W) + # P6: 附錄 + period_label = (str(wk[0].get('date', ''))[-10:] + " ~ " + str(wk[-1].get('date', ''))[-10:]) if wk else "近 7 日" + _appendix_slide(prs, 'weekly', period_label) + path = _new_path("weekly") prs.save(path) return path @@ -677,7 +1332,16 @@ def generate_weekly_ppt(db_data, ai_text: str) -> str: # ── 月報 PPT(6頁)──────────────────────────────────────────────────────────── def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str: - """P1封面 P2 執行摘要 P3 品類橫條圖 P4 TOP10商品 P5 MCP市場情報 P6 AI洞察與行動建議""" + """月報 v3.1(市場專業標準): + P1 封面(含 elevator pitch 三句話) + P2 執行摘要(KPI 含 △% vs 上月 + AI 解讀) + P3 業績趨勢(日業績折線 + 上月對比基準線 + 高低點標註) + P4 品類分析(橫條 + 帕雷托 80/20 雙視圖) + P5 熱銷商品 TOP 50(含 vs 上月 △ 排名變化、🆕 新進榜)— 自動分頁 + P6 MCP 市場情報(4 卡片結構化) + P7 AI 行動建議(結構化分區) + P8 附錄(資料來源 / 計算口徑 / 模板版本) + """ from pptx import Presentation from pptx.util import Cm @@ -688,44 +1352,222 @@ def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str: ms = db_data.get('monthly', {}) if isinstance(db_data, dict) else {} mcp_text = db_data.get('mcp', '') if isinstance(db_data, dict) else '' - + rev = float(ms.get('revenue', 0)) ord_ = int(ms.get('orders', 0)) gm = float(ms.get('gross_margin', 0)) aov = float(ms.get('avg_order', rev / ord_ if ord_ else 0)) top_cats = ms.get('top_categories', []) top_prod = ms.get('top_products', []) + daily = ms.get('daily', []) + prev_mo_data = ms.get('prev_month') + prev_yr_data = ms.get('prev_year') - # P1: 封面 - _cover_slide(prs, f"月度營運報告\n{yr} 年 {mo:02d} 月", f"全面解析與 AI 智能洞察", - f"業績 NT${rev:,.0f}({rev/10000:.1f}萬)|生成 {datetime.now().strftime('%Y/%m/%d %H:%M')}") + # 計算 elevator pitch(封面 + P2 共用) + elevator = _compute_elevator_pitch(ms, prev_mo_data) - # P2: 執行摘要 (Executive Summary) - s2 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s2, 0, 0, W, 19.05, _WHITE) - _add_header(s2, f"執行摘要 (Executive Summary) — {yr}/{mo:02d}") - # 月報 KPI 全部用暖色家族 + # ── P1: 封面(含 elevator pitch)───────────────────────────────────────── + _monthly_cover_slide(prs, yr, mo, rev, ord_, gm, aov, elevator) + + # ── P2: 執行摘要(KPI 含 △% + AI 解讀)───────────────────────────────── + _monthly_summary_slide(prs, yr, mo, ms, prev_mo_data, prev_yr_data, + elevator, ai_text) + + # ── P3: 業績趨勢頁(日業績折線 + 上月對比)───────────────────────────── + _monthly_trend_slide(prs, yr, mo, daily, prev_mo_data, rev, gm, aov) + + # ── P4: 品類分析(橫條 + 帕雷托 80/20)────────────────────────────────── + _monthly_category_slide(prs, yr, mo, top_cats, rev) + + # ── P5: 熱銷商品 TOP 50(含 △ 排名變化、🆕 新進榜)────────────────────── + _monthly_products_slide(prs, yr, mo, top_prod, prev_mo_data) + + # ── P6: MCP 市場情報(多卡片結構化)───────────────────────────────────── + _mcp_intel_slide(prs, mcp_text) + + # ── P7: AI 行動建議(結構化分區)──────────────────────────────────────── + _ai_insight_slide(prs, ai_text) + + # ── P8: 附錄(建立可信度)─────────────────────────────────────────────── + _appendix_slide(prs, 'monthly', f"{yr}/{mo:02d}") + + path = _new_path("monthly") + prs.save(path) + return path + + +# ── 月報 P1: 封面(含 elevator pitch)───────────────────────────────────── +def _monthly_cover_slide(prs, yr, mo, rev, ord_, gm, aov, elevator): + """月報專屬封面:暖紙底 + 焦糖橘左寬條 + 三句話 elevator pitch""" + slide = prs.slides.add_slide(prs.slide_layouts[6]) + W = 33.87 + H = 19.05 + + _add_rect(slide, 0, 0, W, H, _BG_PAPER) + _add_rect(slide, 0, 0, 3.0, H, _BRAND_OG) + _add_rect(slide, 2.85, 0, 0.15, H, _BRAND_OG2) + + # 右上角裝飾 + _add_rect(slide, W - 6.0, 0, 6.0, 0.45, _BRAND_OG2) + _add_rect(slide, W - 6.0, 0.45, 6.0, 0.12, _BRAND_OG) + + # 中段 hairline + _add_rect(slide, 4.0, 8.4, 22.0, 0.06, _BRAND_OG) + + # OPENCLAW 標籤 + _add_rect(slide, 3.8, 1.4, 4.8, 0.85, _BRAND_OG2) + _add_text(slide, "OPENCLAW", 3.8, 1.42, 4.8, 0.81, + bold=True, size=12, color=_WHITE, align="center", valign="middle", + latin_font=_FONT_LABEL) + + # 報告類別小標 + _add_text(slide, "MONTHLY · OPERATIONS REPORT · AI INSIGHT", + 3.8, 2.45, 22, 0.55, + bold=True, size=10, color=_BRAND_OG2, + latin_font=_FONT_LABEL) + + # 主標題 + _add_text(slide, f"月度營運報告\n{yr} 年 {mo:02d} 月", + 3.8, 3.2, 25, 5.0, + bold=True, size=44, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 業績狀態徽章(右上角) + status_color = elevator.get('status_color', _SUBTEXT) + _add_rect(slide, W - 9.0, 3.4, 5.0, 1.1, status_color) + _add_text(slide, f"業績狀態:{elevator.get('status', '—')}", + W - 9.0, 3.45, 5.0, 1.0, + bold=True, size=14, color=_WHITE, align="center", valign="middle", + ea_font=_FONT_BODY_EA) + + # 副標題:業績摘要 + _add_text(slide, + f"業績 NT${rev:,.0f}({rev/10000:.1f}萬) · 訂單 {ord_:,} 筆" + f" · 毛利率 {gm:.1f}% · 客單 NT${aov:,.0f}", + 3.8, 8.7, 27, 0.85, + bold=True, size=14, color=_BRAND_OG2, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 三句話 elevator pitch + pitch_y = 10.2 + pitch_h = 1.5 + pitch_w = 27.0 + # ★ 亮點 + _add_rect(slide, 3.8, pitch_y, 0.45, pitch_h, "2A7A3F") + _add_text(slide, f"★ 最大亮點", + 4.4, pitch_y + 0.1, pitch_w - 0.7, 0.55, + bold=True, size=11, color="2A7A3F", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(slide, elevator.get('highlight') or "—", + 4.4, pitch_y + 0.7, pitch_w - 0.7, 0.75, + size=12, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # ⚠ 警訊 + pitch_y2 = pitch_y + pitch_h + 0.4 + _add_rect(slide, 3.8, pitch_y2, 0.45, pitch_h, "B5342F") + _add_text(slide, f"⚠ 最大警訊", + 4.4, pitch_y2 + 0.1, pitch_w - 0.7, 0.55, + bold=True, size=11, color="B5342F", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(slide, elevator.get('warning') or "—", + 4.4, pitch_y2 + 0.7, pitch_w - 0.7, 0.75, + size=12, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 📈 vs 上月(如有資料) + pitch_y3 = pitch_y2 + pitch_h + 0.4 + mom_rev = elevator.get('mom_rev') + if mom_rev is not None: + delta_color = "2A7A3F" if mom_rev > 0 else "B5342F" + arrow = "▲" if mom_rev > 0 else ("▼" if mom_rev < 0 else "—") + _add_rect(slide, 3.8, pitch_y3, 0.45, pitch_h, delta_color) + _add_text(slide, f"📈 業績動能", + 4.4, pitch_y3 + 0.1, pitch_w - 0.7, 0.55, + bold=True, size=11, color=delta_color, + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + mom_ord = elevator.get('mom_ord') + mom_gm = elevator.get('mom_gm') + body = (f"vs 上月:業績 {arrow} {abs(mom_rev):.1f}%" + + (f" · 訂單 {'▲' if (mom_ord or 0) > 0 else '▼'} {abs(mom_ord):.1f}%" + if mom_ord is not None else "") + + (f" · 毛利率 {'+' if (mom_gm or 0) > 0 else ''}{mom_gm:.1f}pp" + if mom_gm is not None else "")) + _add_text(slide, body, + 4.4, pitch_y3 + 0.7, pitch_w - 0.7, 0.75, + size=12, color=_DARK_TEXT, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 右下角:生成資訊 + _add_text(slide, "Generated by OpenClaw AI Agent", + W - 7.5, H - 1.4, 7.0, 0.5, + size=9, color=_SUBTEXT, align="right", + latin_font=_FONT_LABEL) + _add_text(slide, f"📅 {datetime.now().strftime('%Y/%m/%d %H:%M')}", + W - 7.5, H - 1.95, 7.0, 0.5, + bold=True, size=11, color=_BRAND_OG2, align="right", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + _add_footer(slide, W) + return slide + + +# ── 月報 P2: 執行摘要(KPI 卡含 △% + AI 解讀)───────────────────────────── +def _monthly_summary_slide(prs, yr, mo, ms, prev_mo, prev_yr, + elevator, ai_text): + W = 33.87 + s = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s, f"執行摘要 (Executive Summary) — {yr}/{mo:02d}") + + rev = float(ms.get('revenue', 0)) + ord_ = int(ms.get('orders', 0)) + gm = float(ms.get('gross_margin', 0)) + aov = float(ms.get('avg_order', rev / ord_ if ord_ else 0)) + + # 計算 △% + def _delta(curr, prev_dict, key, is_pp=False): + if not prev_dict: + return None + prev_v = float(prev_dict.get(key, 0) or 0) + if not prev_v: + return None + if is_pp: + return float(curr) - prev_v # 毛利率走絕對 pp + return (float(curr) - prev_v) / prev_v * 100 + + d_rev = _delta(rev, prev_mo, 'revenue') + d_ord = _delta(ord_, prev_mo, 'orders') + d_gm = _delta(gm, prev_mo, 'gross_margin', is_pp=True) + d_aov = _delta(aov, prev_mo, 'avg_order') + + # 4 張 KPI v2 卡(含 △) kpis = [ - (_KPI_CARAMEL, "月業績", f"NT${rev/10000:.1f}萬", ""), - (_KPI_HONEY, "月訂單", f"{ord_:,} 筆", ""), - (_KPI_MAHOGANY, "毛利率", f"{gm:.1f}%", ""), - (_KPI_EARTH, "平均客單", f"NT${aov:,.0f}", ""), + (_KPI_CARAMEL, "月業績", f"NT${rev/10000:.1f}萬", d_rev, "vs 上月"), + (_KPI_HONEY, "月訂單", f"{ord_:,} 筆", d_ord, "vs 上月"), + (_KPI_MAHOGANY, "毛利率", f"{gm:.1f}%", d_gm, "vs 上月(pp)"), + (_KPI_EARTH, "平均客單", f"NT${aov:,.0f}", d_aov, "vs 上月"), ] - for i, (col, lbl, val, sub) in enumerate(kpis): - _kpi_card(s2, i * 7.8 + 0.5, 1.85, 7.4, 3.8, col, lbl, val, sub) - - # 高階營運解讀區塊(暖米底) - _add_rect(s2, 0.5, 5.9, W - 1.0, 0.7, _BRAND_OG) - _add_rect(s2, 0.5, 5.9, 0.4, 0.7, _BRAND_OG2) # 左加深條 - _add_text(s2, "📊 高階營運解讀", - 1.1, 5.95, 18, 0.58, bold=True, size=13, color=_WHITE) - _add_rect(s2, 0.5, 6.6, W - 1.0, 6.7, _BG_PAPER, line_hex=_SUBTLE) - _add_rect(s2, 0.5, 6.6, 0.4, 6.7, _BRAND_OG) # 左焦糖橘色條 - - # 萃取 AI 整體業績解讀段 + for i, (col, lbl, val, delta_pct, dlbl) in enumerate(kpis): + # 毛利率 / 客單 用 absolute pp,傳遞時用客製 label + _kpi_card_v2(s, i * 7.8 + 0.5, 1.95, 7.4, 4.5, + col, lbl, val, delta_pct=delta_pct, + delta_label=dlbl) + + # 高階解讀區塊(白卡 + 暖色色條) + _add_rect(s, 0.5, 7.0, W - 1.0, 0.7, _BRAND_OG) + _add_rect(s, 0.5, 7.0, 0.4, 0.7, _BRAND_OG2) + _add_text(s, "📊 高階營運解讀(AI Generated)", + 1.1, 7.05, W - 1.5, 0.6, bold=True, size=13, color=_WHITE, + valign="middle", ea_font=_FONT_BODY_EA) + + _add_rect(s, 0.5, 7.7, W - 1.0, 6.4, _WHITE, line_hex=_SUBTLE) + _add_rect(s, 0.5, 7.7, 0.4, 6.4, _BRAND_OG) + + # 抽 AI 整體解讀段 summary_text = "" capture = False - for line in ai_text.split('\n'): + for line in (ai_text or '').split('\n'): if '整體業績解讀' in line or '高階營運' in line: capture = True continue @@ -734,94 +1576,566 @@ def generate_monthly_ppt(yr, mo, db_data, ai_text: str) -> str: break if line.strip(): summary_text += line + "\n" - if len(summary_text) > 220: break + if len(summary_text) > 350: break if not summary_text.strip(): - for line in ai_text.split('\n'): + for line in (ai_text or '').split('\n'): if line.strip() and not line.startswith('【'): summary_text += line + "\n" - if len(summary_text) > 220: break + if len(summary_text) > 350: break if not summary_text.strip(): - summary_text = ai_text[:250] + "..." if ai_text else "(暫無 AI 分析)" - - _add_text(s2, summary_text.strip(), - 1.2, 6.85, W - 2.0, 6.0, - size=13, color=_DARK_TEXT, wrap=True) - _add_footer(s2, W) + summary_text = (ai_text or '')[:350] + "…" if ai_text else "(暫無 AI 分析)" - # P3: 品類業績橫條圖 - s3 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s3, 0, 0, W, 19.05, _WHITE) - _add_header(s3, f"品類業績排行 TOP 8 — {yr}/{mo:02d}(萬元)") - if top_cats: - cats_disp = [c.get('cat', '')[:12] for c in top_cats[:8]] - revs_cats = [float(c.get('revenue', 0)) for c in top_cats[:8]] - # 圖表區坊(左 60%) - _add_horiz_chart(s3, 0.5, 1.85, W * 0.6 - 0.5, 11.0, - cats_disp, [("業績(萬元)", revs_cats)], - bar_colors=[_BRAND_OG]) - # 右側:品類詳細數字卡片 - total_cat_rev = sum(revs_cats) - _add_rect(s3, W * 0.6 + 0.3, 1.85, W * 0.38, 11.0, _BG_PAPER, line_hex=_SUBTLE) - _add_rect(s3, W * 0.6 + 0.3, 1.85, 0.35, 11.0, _BRAND_OG) - _add_text(s3, "品類業績明細", - W * 0.6 + 0.85, 2.0, W * 0.37, 0.65, - bold=True, size=12, color=_DARK_TEXT) - top_y = 2.8 - for c in top_cats[:6]: - c_rev = float(c.get('revenue', 0)) - c_pct = c_rev / total_cat_rev * 100 if total_cat_rev else 0 - _add_text(s3, f"{c.get('cat','')[:10]}", - W * 0.6 + 0.85, top_y, W * 0.24, 0.5, - size=10, color=_DARK_TEXT) - _add_text(s3, f"{c_pct:.0f}%", - W * 0.6 + 0.85, top_y, W * 0.37 - 0.4, 0.5, - bold=True, size=11, color=_BRAND_OG, align="right") - _add_text(s3, f"NT${c_rev/10000:.1f}萬", - W * 0.6 + 0.85, top_y + 0.48, W * 0.37 - 0.4, 0.45, - size=9, color=_SUBTEXT, align="right") - _add_rect(s3, W * 0.6 + 0.85, top_y + 0.95, W * 0.36, 0.05, _SUBTLE) # 分隔線 - top_y += 1.1 - best = top_cats[0] - _add_text(s3, - f"★ 最高貢獻:{best.get('cat','')} NT${float(best.get('revenue',0)):,.0f}", - 0.8, 13.15, 25, 0.5, size=10, color=_SUBTEXT) + _add_text(s, summary_text.strip(), + 1.2, 7.95, W - 2.0, 5.9, + size=13, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 底部:YoY 同期對比帶(如有) + yoy_y = 14.4 + if prev_yr: + prev_yr_rev = float(prev_yr.get('revenue', 0) or 0) + if prev_yr_rev: + yoy = (rev - prev_yr_rev) / prev_yr_rev * 100 + yoy_color = "2A7A3F" if yoy > 0 else "B5342F" + arrow = "▲" if yoy > 0 else "▼" + _add_rect(s, 0.5, yoy_y, W - 1.0, 0.95, yoy_color) + _add_text(s, + f"📅 YoY 同期對比:去年 {yr-1}/{mo:02d} 業績 NT${prev_yr_rev/10000:.1f}萬" + f" → 本月 {arrow} {abs(yoy):.1f}%", + 0.7, yoy_y + 0.05, W - 1.4, 0.85, + bold=True, size=12, color=_WHITE, valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + _add_footer(s, W) + return s + + +# ── 月報 P3: 業績趨勢頁 ────────────────────────────────────────────────── +def _monthly_trend_slide(prs, yr, mo, daily, prev_mo, rev, gm, aov): + W = 33.87 + s = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s, f"業績趨勢分析 — {yr}/{mo:02d} 日業績走勢") + + if not daily: + _add_empty_state(s, "本月無逐日業績資料", + "請確認 realtime_sales_monthly 表是否已收齊本月資料。", W) + _add_footer(s, W) + return s + + curr_dates = [d.get('date', '')[-5:] for d in daily] + curr_vals = [float(d.get('revenue', 0)) for d in daily] + prev_vals = None + if prev_mo and prev_mo.get('daily'): + prev_vals = [float(d.get('revenue', 0)) for d in prev_mo.get('daily', [])] + + # 折線圖(占左 75%) + chart_w = W - 0.8 + chart_h = 11.0 + buf = _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=prev_vals, + total_width_cm=chart_w, + total_height_cm=chart_h, + title="日業績走勢(本月 vs 上月)", + curr_label=f"本月 {yr}/{mo:02d}", + prev_label="上月") + if buf is not None: + _add_image_from_buf(s, buf, 0.4, 1.95, chart_w, chart_h) else: - _add_empty_state(s3, "本月無品類分佈資料", "請確認月報期間是否已有分類欄位與銷售資料。", W) - _add_footer(s3, W) + _add_empty_state(s, "圖表渲染失敗", "matplotlib 不可用,請確認部署環境。", W) - # P4: TOP10 商品 - _product_table_slide(prs, f"核心動能:熱銷商品 TOP 10 — {yr}/{mo:02d}", top_prod) + # 底部 4 個分析洞察卡 + insights_y = 13.3 + insights_h = 2.4 + card_w = (W - 1.0 - 0.3 * 3) / 4 - # P5: MCP 市場情報(暖米底 + 焦糖橘左色條) - s5 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s5, 0, 0, W, 19.05, _WHITE) - _add_header(s5, "🌐 專案 RAG 情報:MCP 外部市場與競品動態監控") + avg = sum(curr_vals) / len(curr_vals) if curr_vals else 0 + max_v = max(curr_vals) if curr_vals else 0 + min_v = min(curr_vals) if curr_vals else 0 + days_above_avg = sum(1 for v in curr_vals if v >= avg) - mcp_display = mcp_text.strip() if mcp_text and mcp_text.strip() else "(未擷取到最新的 MCP 市場情報,或當前無需特別關注的外部風險)" - # 情報區塊:暖米底色 + 左色條 - _add_rect(s5, 0.8, 2.0, W - 1.6, 11.0, _BG_PAPER, line_hex=_SUBTLE) - _add_rect(s5, 0.8, 2.0, 0.45, 11.0, _BRAND_OG) # 左焦糖橘色條 - _add_text(s5, mcp_display, - 1.6, 2.3, W - 2.8, 10.3, - size=13, color=_DARK_TEXT, wrap=True) - _add_footer(s5, W) + cards = [ + (_BRAND_OG, "📈 日均業績", f"NT${avg/10000:.1f}萬", f"{len(curr_vals)} 天平均"), + (_KPI_HONEY, "🏆 最高單日", f"NT${max_v/10000:.1f}萬", + f"{curr_dates[curr_vals.index(max_v)] if curr_vals else '—'}"), + (_KPI_MAHOGANY,"📉 最低單日", f"NT${min_v/10000:.1f}萬", + f"{curr_dates[curr_vals.index(min_v)] if curr_vals else '—'}"), + (_KPI_EARTH, "✅ 超均天數", f"{days_above_avg} / {len(curr_vals)} 天", + f"{days_above_avg/len(curr_vals)*100:.0f}% 達標" + if curr_vals else "—"), + ] + for i, (col, lbl, val, sub) in enumerate(cards): + cx = 0.5 + i * (card_w + 0.3) + _add_rect(s, cx, insights_y, card_w, insights_h, col) + _add_rect(s, cx, insights_y, 0.15, insights_h, "FFFFFF") + _add_text(s, lbl, cx + 0.3, insights_y + 0.2, card_w - 0.5, 0.5, + size=10, color="FAF7F0", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(s, val, cx + 0.2, insights_y + 0.75, card_w - 0.4, 0.95, + bold=True, size=18, color="FFFFFF", align="center", valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_text(s, sub, cx + 0.2, insights_y + 1.75, card_w - 0.4, 0.55, + size=9, color="FAF7F0", align="center", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) - # P6: AI 策略洞察與行動建議(暖米暖墨文字) - s6 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s6, 0, 0, W, 19.05, _LIGHT_GRAY) - _add_header(s6, "🎯 AI Agent 專案分析與行動建議") - - # 主內容區:暖米底 + 左側焦糖橘色條 - _add_rect(s6, 0.6, 2.0, W - 1.2, 11.0, _BG_PAPER, line_hex=_SUBTLE) - _add_rect(s6, 0.6, 2.0, 0.4, 11.0, _BRAND_OG) - _add_text(s6, ai_text or "(暫無 AI 分析)", - 1.3, 2.3, W - 2.2, 10.5, - size=13, color=_DARK_TEXT, wrap=True) - _add_footer(s6, W) + _add_footer(s, W) + return s - path = _new_path("monthly") - prs.save(path) - return path + +# ── 月報 P4: 品類分析(橫條 + 帕雷托 80/20)──────────────────────────────── +def _monthly_category_slide(prs, yr, mo, top_cats, total_rev): + W = 33.87 + s = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s, f"品類業績結構分析 — {yr}/{mo:02d}(橫條 + 帕雷托 80/20)") + + if not top_cats: + _add_empty_state(s, "本月無品類分佈資料", + "請確認月報期間是否已有分類欄位與銷售資料。", W) + _add_footer(s, W) + return s + + cats_disp = [c.get('cat', '')[:14] for c in top_cats[:8]] + revs_cats = [float(c.get('revenue', 0)) for c in top_cats[:8]] + total_cat_rev = sum(revs_cats) or 1.0 + + # 左側:matplotlib 橫條 + chart_w_left = W * 0.5 - 0.4 + chart_h = 12.5 + buf1 = _mpl_horiz_bar_png(cats_disp, revs_cats, + total_width_cm=chart_w_left, + total_height_cm=chart_h, + value_unit="萬", + title="① 業績排行(焦糖橘=TOP3)", + highlight_top_n=3) + if buf1: + _add_image_from_buf(s, buf1, 0.4, 1.95, chart_w_left, chart_h) + + # 右側:帕雷托 + chart_w_right = W * 0.5 - 0.4 + rx = W * 0.5 + 0.0 + buf2 = _mpl_pareto_chart_png(cats_disp, revs_cats, + total_width_cm=chart_w_right, + total_height_cm=chart_h, + title="② 帕雷托累計貢獻(80% 主力線)") + if buf2: + _add_image_from_buf(s, buf2, rx, 1.95, chart_w_right, chart_h) + + # 底部結論帶 + best = top_cats[0] + # 計算 80% 內品類數 + cum = 0 + pareto_n = 0 + for r in revs_cats: + cum += r + pareto_n += 1 + if cum / total_cat_rev >= 0.8: + break + _add_rect(s, 0.4, 14.7, W - 0.8, 1.0, _BRAND_OG2) + _add_text(s, + f"★ 最高貢獻:{best.get('cat','')} NT${float(best.get('revenue',0)):,.0f}" + f"(佔總業績 {float(best.get('revenue',0))/(total_rev or 1)*100:.1f}%)" + f" | 80% 業績集中在前 {pareto_n} 品類" + f"(共 {len(top_cats[:8])} 品類入榜)", + 0.7, 14.85, W - 1.4, 0.7, + bold=True, size=12, color=_WHITE, valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + _add_footer(s, W) + return s + + +# ── 月報 P5: 熱銷商品 TOP 50(含 △ 排名變化、🆕 新進榜)──────────────────── +def _monthly_products_slide(prs, yr, mo, products, prev_mo, max_items=50): + """產品表格 + 上月排名比對 → 加入 △/▼/🆕 標記""" + import math + W = 33.87 + + # 建立上月排名映射(key=name 或 id) + prev_rank = {} + if prev_mo and prev_mo.get('top_products'): + for i, p in enumerate(prev_mo.get('top_products', [])[:50]): + key = p.get('id') or p.get('name', '') + if key: + prev_rank[key] = i + 1 + + HEADER_END = 1.85 + TABLE_HDR_H = 0.72 + ROW_H = 0.88 + AVAIL_H = _CONTENT_B - HEADER_END - TABLE_HDR_H + ROWS_PER_PAGE = max(1, int(AVAIL_H / ROW_H)) + + all_prods = products[:max_items] + if not all_prods: + s = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s, f"核心動能:熱銷商品 TOP 50 — {yr}/{mo:02d}") + _add_empty_state(s, "本月無商品銷售資料", + "請確認該期間是否已有匯入業績資料。", W) + _add_footer(s, W) + return + + top_rev = max(float(p.get('revenue', 1)) for p in all_prods) or 1 + total_pages = math.ceil(len(all_prods) / ROWS_PER_PAGE) + + for page in range(total_pages): + page_prods = all_prods[page * ROWS_PER_PAGE:(page + 1) * ROWS_PER_PAGE] + page_label = f" ({page + 1}/{total_pages})" + s = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s, f"核心動能:熱銷商品 TOP 50 — {yr}/{mo:02d}{page_label}") + + # 表頭 + tbl_y = HEADER_END + _add_rect(s, 0.4, tbl_y, W - 0.8, TABLE_HDR_H, _BRAND_OG) + _add_rect(s, 0.4, tbl_y, 0.3, TABLE_HDR_H, _BRAND_OG2) + _add_text(s, "排名", 0.5, tbl_y + 0.06, 1.3, TABLE_HDR_H - 0.12, + bold=True, size=10, color=_WHITE, align="center", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(s, "商品名稱", 1.95, tbl_y + 0.06, W - 22.5, TABLE_HDR_H - 0.12, + bold=True, size=10, color=_WHITE, + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(s, "vs 上月", W - 20.2, tbl_y + 0.06, 3.0, TABLE_HDR_H - 0.12, + bold=True, size=9, color=_WHITE, align="center", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(s, "業績佔比", W - 17.0, tbl_y + 0.06, 5.3, TABLE_HDR_H - 0.12, + bold=True, size=9, color=_WHITE, align="center", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(s, "月業績", W - 11.3, tbl_y + 0.06, 5.8, TABLE_HDR_H - 0.12, + bold=True, size=10, color=_WHITE, align="right", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + _add_text(s, "訂單", W - 5.0, tbl_y + 0.06, 4.4, TABLE_HDR_H - 0.12, + bold=True, size=10, color=_WHITE, align="right", + ea_font=_FONT_BODY_EA, latin_font=_FONT_LABEL) + + for j, p in enumerate(page_prods): + actual_rank = page * ROWS_PER_PAGE + j + 1 + bg = _LIGHT_GRAY if j % 2 == 0 else _WHITE + row_t = tbl_y + TABLE_HDR_H + j * ROW_H + rev = float(p.get('revenue', 0)) + pct = rev / top_rev if top_rev else 0 + ord_v = p.get('orders', p.get('order_count', 0)) + + _add_rect(s, 0.4, row_t, W - 0.8, ROW_H - 0.04, bg) + + # 排名圓 + rank_fill = _BRAND_OG if actual_rank <= 3 else (_KPI_HONEY if actual_rank <= 10 else _SUBTLE) + rank_color = _WHITE if actual_rank <= 10 else _SUBTEXT + _add_rect(s, 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, rank_fill) + _add_text(s, str(actual_rank), + 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, + bold=(actual_rank <= 3), size=11, color=rank_color, + align="center", valign="middle", + latin_font=_FONT_DISPLAY) + + # 商品名稱 + _add_text(s, str(p.get('name', ''))[:42], + 1.95, row_t + 0.1, W - 22.7, ROW_H - 0.2, + size=10, color=_DARK_TEXT, + ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY) + + # vs 上月變化(△排名 / 🆕新進榜 / —無資料) + key = p.get('id') or p.get('name', '') + if prev_mo and key: + prev_r = prev_rank.get(key) + if prev_r is None: + diff_text = "🆕 新" + diff_color = "2A7A3F" + else: + diff = prev_r - actual_rank # 正=上升 + if diff > 0: + diff_text = f"▲ {diff}" + diff_color = "2A7A3F" + elif diff < 0: + diff_text = f"▼ {abs(diff)}" + diff_color = "B5342F" + else: + diff_text = "—" + diff_color = "9B9081" + else: + diff_text = "—" + diff_color = "9B9081" + _add_text(s, diff_text, + W - 20.2, row_t + 0.1, 3.0, ROW_H - 0.2, + bold=True, size=10, color=diff_color, align="center", + ea_font=_FONT_BODY_EA, latin_font=_FONT_DISPLAY) + + # 業績佔比視覺條 + bar_max_w = 4.8 + bar_w = max(0.08, pct * bar_max_w) + bar_y = row_t + ROW_H * 0.38 + bar_h = 0.25 + _add_rect(s, W - 16.8, bar_y, bar_max_w, bar_h, _SUBTLE) + _add_rect(s, W - 16.8, bar_y, bar_w, bar_h, _BRAND_OG) + _add_text(s, f"{pct*100:.0f}%", + W - 16.8, row_t + 0.08, bar_max_w, ROW_H - 0.2, + size=9, color=_TERTIARY, align="right", + latin_font=_FONT_DISPLAY) + + # 月業績 + _add_text(s, f"NT${rev:,.0f}", + W - 11.3, row_t + 0.1, 5.8, ROW_H - 0.2, + bold=(actual_rank == 1), size=11, color=_DARK_TEXT, + align="right", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 訂單 + if ord_v: + _add_text(s, f"{int(ord_v):,} 筆", + W - 5.0, row_t + 0.1, 4.4, ROW_H - 0.2, + size=10, color=_SUBTEXT, align="right", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + _add_footer(s, W) + + +# ── 月報專用:MCP RAG 情報多卡片頁 ───────────────────────────────────────────── +def _parse_mcp_sections(mcp_text: str) -> dict: + """把 build_mcp_context() 回傳的鬆散字串切成卡片字段。 + 回傳 keys: date_header / month_focus / next_month / season / market / extra + 缺失欄位填合理預設,確保版面不空白。 + """ + sections = { + "date_header": "", + "month_focus": "", + "next_month": "", + "season": "", + "market": "", + "extra": "", + } + text = (mcp_text or "").strip() + if not text: + return sections + + for line in text.split('\n'): + line = line.strip() + if not line: + continue + if line.startswith("當前日期"): + sections["date_header"] = line.replace("當前日期:", "").strip() + elif line.startswith("本月電商重點"): + sections["month_focus"] = line.replace("本月電商重點:", "").strip() + elif line.startswith("下月預告"): + sections["next_month"] = line.replace("下月預告:", "").strip() + elif "季:" in line and len(line) < 80: + sections["season"] = line + elif line.startswith("【market_trends】") or line.startswith("【holiday_calendar】"): + sections["market"] += (line + "\n") + elif line.startswith("【"): + sections["extra"] += (line + "\n") + else: + # 無前綴的補充段落 → 歸入 market(避免漏資訊) + if sections["season"] and line == sections["season"]: + continue + sections["market"] += (line + "\n") + + return sections + + +def _mcp_intel_slide(prs, mcp_text: str, W: float = 33.87): + """MCP 外部情報 — 4 卡片 × 2 區帶結構,缺資料時填預設指引文。""" + s = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s, "🌐 專案 RAG 情報:MCP 外部市場與競品動態監控") + + sec = _parse_mcp_sections(mcp_text) + + # 頂部資訊條(日期 + 來源說明) + _add_rect(s, 0.4, 1.95, W - 0.8, 0.9, _BRAND_OG2) + date_str = sec["date_header"] or datetime.now().strftime('%Y/%m/%d') + _add_text(s, + f"📅 {date_str} | 來源:MCP Bridge ‧ Holiday Calendar ‧ Seasonal Heuristics | 更新時間:{datetime.now().strftime('%H:%M')}", + 0.7, 2.05, W - 1.4, 0.7, + bold=True, size=11, color=_WHITE, valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # 四張卡片(2 × 2) + card_top1 = 3.1 + card_top2 = 9.5 + card_h = 6.0 + col_w = (W - 1.2) / 2 + col1_x = 0.4 + col2_x = 0.4 + col_w + 0.4 + + def _draw_card(x, y, w, h, accent, icon, title, body, fallback): + _add_rect(s, x, y, w, h, _WHITE, line_hex=_SUBTLE) + _add_rect(s, x, y, w, 0.85, accent) + _add_text(s, f"{icon} {title}", + x + 0.4, y + 0.12, w - 0.8, 0.65, + bold=True, size=13, color=_WHITE, valign="middle", + ea_font=_FONT_BODY_EA) + body_text = (body or "").strip() or fallback + _add_text(s, body_text, + x + 0.5, y + 1.1, w - 1.0, h - 1.3, + size=12, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + _draw_card(col1_x, card_top1, col_w, card_h, + _BRAND_OG, "🎯", "本月電商檔期重點", + sec["month_focus"], + "(暫無檔期資料)建議手動補入:母親節、520、勞動節等大檔;" + "提前 14 天備貨、設定 TG 缺貨告警閾值。") + + _draw_card(col2_x, card_top1, col_w, card_h, + _KPI_HONEY, "🚀", "下月檔期預告", + sec["next_month"], + "(暫無預告資料)建議查 Hermes monthly_calendar 表," + "或執行 `/oc rag refresh` 強制重新拉取。") + + _draw_card(col1_x, card_top2, col_w, card_h, + _KPI_MAHOGANY, "🌿", "季節性消費情境", + sec["season"], + "(暫無季節情境)依當月切換:春換季保養 / 夏防曬 / 秋保濕 / 冬保暖。" + "可在 mcp_collector_service.py 補強季節→品類權重對照。") + + market_body = (sec["market"] + "\n" + sec["extra"]).strip() + _draw_card(col2_x, card_top2, col_w, card_h, + _KPI_EARTH, "📡", "市場/競品動態(Gemini Grounding)", + market_body, + "(本次未擷取到 Gemini Grounding 結果)" + "可能原因:Gemini 配額耗盡 / 網路抖動 / 主題未命中。" + "Fallback:請參考 PChome 競價監控與 momo 自家主推檔期排程。") + + _add_footer(s, W) + return s + + +# ── 月報專用:AI 洞察結構化頁 ───────────────────────────────────────────────── +def _parse_ai_sections(ai_text: str) -> list: + """把 AI 月報文字切成 (icon, title, body) 列表,最多 6 段。 + 支援 7-section 模板:【整體業績解讀】【品類結構】【熱銷商品洞察】 + 【MCP 市場情報整合】【三段式行動建議】【風險警示】等。 + """ + if not ai_text or not ai_text.strip(): + return [] + title_icons = { + "整體業績": "📊", + "高階營運": "📊", + "品類結構": "🧩", + "品類": "🧩", + "熱銷商品": "🏆", + "熱銷": "🏆", + "MCP": "🌐", + "外部": "🌐", + "市場情報": "🌐", + "行動建議": "🎯", + "三段式": "🎯", + "本週立即": "⚡", + "本月優化": "📈", + "下月預備": "🚀", + "風險": "⚠️", + "警示": "⚠️", + } + + sections = [] + current_title = None + current_icon = "💡" + current_body = [] + + lines = ai_text.split('\n') + for line in lines: + stripped = line.strip() + if not stripped: + if current_body: + current_body.append("") + continue + # 偵測 【XXX】 或 ■ XXX 標題 + is_header = False + header_text = None + if stripped.startswith('【') and '】' in stripped: + header_text = stripped.split('】')[0].lstrip('【').strip() + is_header = True + elif stripped.startswith('■'): + header_text = stripped.lstrip('■').strip() + is_header = True + elif stripped.startswith('##'): + header_text = stripped.lstrip('#').strip() + is_header = True + + if is_header and header_text: + if current_title: + sections.append((current_icon, current_title, + '\n'.join(current_body).strip())) + current_title = header_text + current_icon = "💡" + for kw, ic in title_icons.items(): + if kw in header_text: + current_icon = ic + break + current_body = [] + tail = stripped.split('】', 1)[1] if '】' in stripped else "" + if tail.strip(): + current_body.append(tail.strip()) + else: + current_body.append(stripped) + + if current_title: + sections.append((current_icon, current_title, + '\n'.join(current_body).strip())) + + # 若沒有任何標題(純文字),整段當一個 section + if not sections and ai_text.strip(): + sections.append(("💡", "AI 月度策略洞察", ai_text.strip())) + + return sections[:6] + + +def _ai_insight_slide(prs, ai_text: str, W: float = 33.87): + """AI 洞察分區呈現 — 至多 6 個語義卡(依 AI 段落自動分頁)""" + sections = _parse_ai_sections(ai_text) + + if not sections: + s = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s, "🎯 AI Agent 專案分析與行動建議") + _add_empty_state(s, "本期暫無 AI 洞察輸出", + "請確認 NIM/Gemini 服務狀態,或重新觸發報表生成。", W) + _add_footer(s, W) + return + + # 第一頁顯示 4 張卡,超過 4 段時開第二頁 + pages = [sections[i:i + 4] for i in range(0, len(sections), 4)] + + accent_cycle = [_BRAND_OG, _KPI_HONEY, _KPI_MAHOGANY, _KPI_EARTH, _BRAND_OG2, _BRAND_OG] + + for pg_idx, page_secs in enumerate(pages): + s = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s, 0, 0, W, _SLIDE_H, _BG_PAPER) + title_suffix = f" ({pg_idx + 1}/{len(pages)})" if len(pages) > 1 else "" + _add_header(s, f"🎯 AI Agent 月度策略洞察{title_suffix}") + + # 4 卡 2x2 排版 + grid_cols = 2 + grid_rows = 2 if len(page_secs) > 2 else 1 + card_w = (W - 1.2 - 0.4 * (grid_cols - 1)) / grid_cols + # 可用高度 + avail_h = _CONTENT_B - 1.95 - 0.4 + card_h = (avail_h - 0.4 * (grid_rows - 1)) / grid_rows + + for i, (icon, title, body) in enumerate(page_secs): + row = i // grid_cols + col = i % grid_cols + x = 0.4 + col * (card_w + 0.4) + y = 1.95 + row * (card_h + 0.4) + accent = accent_cycle[(pg_idx * 4 + i) % len(accent_cycle)] + + _add_rect(s, x, y, card_w, card_h, _WHITE, line_hex=_SUBTLE) + _add_rect(s, x, y, 0.35, card_h, accent) + _add_rect(s, x + 0.35, y, card_w - 0.35, 0.85, accent) + _add_text(s, f"{icon} {title}", + x + 0.6, y + 0.13, card_w - 0.8, 0.6, + bold=True, size=13, color=_WHITE, valign="middle", + ea_font=_FONT_BODY_EA) + + body_clean = (body or "").strip() or "(本段無內容)" + # 限長度避免溢出(每卡上限 ~ 480 字) + if len(body_clean) > 520: + body_clean = body_clean[:510].rstrip() + "…" + _add_text(s, body_clean, + x + 0.55, y + 1.05, card_w - 1.0, card_h - 1.25, + size=11, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + _add_footer(s, W) # ── 策略報告 PPT(5頁)──────────────────────────────────────────────────────── @@ -954,14 +2268,21 @@ def generate_strategy_ppt(date_str: str, db_data, ai_text: str) -> str: W - 2, row_t + 0.1, 1.5, 0.7, size=9, color=_SUBTEXT, align="right") _add_footer(s4, W) - # P5: AI 策略洞察 + # P5: AI 策略洞察(暖紙底 + 焦糖橘色條,去黑) s5 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s5, 0, 0, W, 19.05, _BG_DARK) - _add_header(s5, "AI 策略洞察") + _add_rect(s5, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s5, "🎯 AI 策略洞察") + _add_rect(s5, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE) + _add_rect(s5, 0.6, 1.95, 0.4, 13.5, _BRAND_OG) _add_text(s5, ai_text or "(暫無 AI 分析)", - 1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True) + 1.3, 2.2, W - 2.2, 13.0, + size=13, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) _add_footer(s5, W) + # P6: 附錄 + _appendix_slide(prs, 'strategy', period) + path = _new_path("strategy") prs.save(path) return path @@ -1062,14 +2383,21 @@ def generate_promo_ppt(promo_label: str, data, ai_text: str) -> str: # P4: 活動期熱銷商品 _product_table_slide(prs, f"促銷期熱銷商品 TOP 10 — {promo_label}", top_prod) - # P5: AI 洞察 + # P5: AI 洞察(暖紙底 + 焦糖橘色條,去黑) s5 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s5, 0, 0, W, 19.05, _BG_DARK) - _add_header(s5, f"促銷 AI 洞察 — {promo_label}") + _add_rect(s5, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s5, f"🎯 促銷 AI 洞察 — {promo_label}") + _add_rect(s5, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE) + _add_rect(s5, 0.6, 1.95, 0.4, 13.5, _BRAND_OG) _add_text(s5, ai_text or "(暫無 AI 分析)", - 1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True) + 1.3, 2.2, W - 2.2, 13.0, + size=13, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) _add_footer(s5, W) + # P6: 附錄 + _appendix_slide(prs, 'promo', promo_label) + path = _new_path("promo") prs.save(path) return path @@ -1184,14 +2512,21 @@ def generate_competitor_ppt(period_label: str, db_data: dict, ai_text: str) -> s x += w + 0.1 _add_footer(s3, W) - # P4: AI 洞察 + # P4: AI 洞察(暖紙底 + 焦糖橘色條,去黑) s4 = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(s4, 0, 0, W, 19.05, _BG_DARK) - _add_header(s4, "AI 競品洞察 — 策略建議") + _add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s4, "🎯 AI 競品洞察 — 策略建議") + _add_rect(s4, 0.6, 1.95, W - 1.2, 13.5, _WHITE, line_hex=_SUBTLE) + _add_rect(s4, 0.6, 1.95, 0.4, 13.5, _BRAND_OG) _add_text(s4, ai_text or "(AI 分析生成中)", - 1.0, 2.0, W - 2.0, 11.0, size=13, color=_WHITE, wrap=True) + 1.3, 2.2, W - 2.2, 13.0, + size=13, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) _add_footer(s4, W) + # P5: 附錄 + _appendix_slide(prs, 'competitor', period_label) + path = _new_path("competitor") prs.save(path) return path