diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index d9c4b32..7175459 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -1884,6 +1884,7 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: is_competitor = '競品' in report_type is_promo = '促銷' in report_type is_vendor = '廠商' in report_type + is_period = any(k in report_type for k in ('quarterly', 'half_yearly', 'annual', 'ttm', '季報', '半年報', '年報')) # ── 格式鐵律(所有 prompt 共用後綴)──────────────────────── FORMAT_RULES = ( @@ -2041,6 +2042,47 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: + FORMAT_RULES ) max_tokens = 1400 + elif is_period: + sys_instruction = ( + "你身兼三職:(1) 資深電商策略顧問(10 年 BCG / 麥肯錫零售諮詢經驗)" + "(2) momo BU 主管(決策季度/半年/年度資源分配)" + "(3) CFO 觀點(看 P&L 結構、毛利歸因、預算 vs 實際)。\n" + "你的客戶是 momo 高層管理層,會用本報告做下季度/半年度/年度的" + "戰略校正、資源重分配、OKR 設定。所有判斷必須有量化依據。\n\n" + f"請針對以下{report_type}資料,輸出戰略級期間回顧報告,結構嚴格如下:\n\n" + "【期間整體解讀】(4-5句)\n" + "引用期間業績、訂單、毛利率、客單價,評估等級(卓越/穩健/普通/警訊);" + "與上期 / 去年同期分別作 △% 比較;指出最關鍵亮點與最大警訊;" + "與台灣電商市場同期表現比較定位(健康成長 5~15%、強勁 >20%、警訊 <0%)。\n\n" + "【市場趨勢與本期對位】(3-4句)\n" + "結合本期所跨檔期(依期間落點:母親節/520/618/雙11/雙12)回顧檔期效益," + "對比歷史拉動幅度(如雙11 +50~80%、618 +30~50%);" + "點出本期是否充分捕捉市場紅利或錯失機會。\n\n" + "【月度走勢分析】(3-4句)\n" + "解讀月度業績曲線:高點月成因(檔期/活動/季節)、低點月成因;" + "識別連續 2 個月以上的趨勢(持續上升/下降/震盪);" + "QoQ / HoH / YoY 的成長動能差異。\n\n" + "【品類與商品結構洞察】(3-4句)\n" + "TOP3 主力品類佔比與健康度(前一品類 >60% 為集中度過高);" + "新進榜商品 vs 跌出榜商品的比例與業績規模;" + "毛利結構(高毛利品類 vs 低毛利品類)的 mix 健康度。\n\n" + "【行動建議 — 戰略級 SMART】\n" + "■ 下期立即啟動(3 條,✅ 開頭,含期限):\n" + " 針對庫存補貨、廣告投放、定價調整、品類 mix 調整等 30 天內可見效的決策。\n" + "■ 下期戰略重點(3 條,✅ 開頭,含 60-90 天目標):\n" + " 針對品類 mix、商品組合、廠商議價、會員活動等 1 季可改善的結構性議題。\n" + "■ 下下期預備佈局(2 條,✅ 開頭,含 6-12 個月目標):\n" + " 針對年度大檔(雙11/雙12/618)、新品類進入、自有品牌、平台戰略等長期議題。\n" + "每條必須含「具體商品/品類 + 量化目標(業績 +X% / 毛利 +Y pp / 客單 +NT$Z)+ 期限」。\n\n" + "【最大三大風險與防禦】(2-3句)\n" + "點出 3 項最大潛在風險(集中度 / 毛利下滑 / 競品價格戰 / 庫存積壓 / 廠商斷供 等)," + "對應「立即啟動」防禦動作(具體至:建立 N 天安全庫存 / 與 TOP3 簽 N 年協議)。\n\n" + "要求:每段引用至少 2 個具體數字,全文 1000~1300 字," + "語氣為資深顧問遞交給 BU 主管/CEO 的戰略決策報告,禁用模糊用詞,要明確期限與量化。" + + MARKET_TREND_2026 + + FORMAT_RULES + ) + max_tokens = 2600 elif is_vendor: sys_instruction = ( "你身兼 (1) 資深採購主管(10 年零售/電商採購實戰經驗,精通議價、選品、" @@ -2581,7 +2623,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, generate_daily_ppt, generate_weekly_ppt, generate_monthly_ppt, generate_strategy_ppt, generate_competitor_ppt, generate_promo_ppt, - generate_vendor_ppt, + generate_vendor_ppt, generate_period_review_ppt, check_pptx_available ) except ImportError: @@ -3106,10 +3148,158 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, }) return ppt_path + elif sub_type in ('quarterly', '季報', 'half_yearly', '半年報', 'annual', '年報', 'ttm'): + # 期間回顧報告 — period_review 共用 generator + # /ppt quarterly [YYYY/Q1-4] 季報 + # /ppt half_yearly [YYYY/H1-2] 半年報 + # /ppt annual [YYYY] 年報 + # /ppt ttm 最近 12 個月(滾動) + from datetime import datetime as _dt, timedelta as _td + import calendar as _cal + + # ── 解析期間 ──────────────────────────────────────── + if sub_type in ('quarterly', '季報'): + period_type = 'quarterly' + yr = int(sub_arg.split('/')[0]) if sub_arg else now.year + q = int(sub_arg.split('/Q')[1]) if sub_arg and 'Q' in sub_arg else ((now.month - 1) // 3 + 1) + start_mo, end_mo = (q - 1) * 3 + 1, q * 3 + start_str = f"{yr}/{start_mo:02d}/01" + end_last = _cal.monthrange(yr, end_mo)[1] + end_str = f"{yr}/{end_mo:02d}/{end_last:02d}" + period_label = f"{yr} Q{q}" + # 上期 = 上一季 + prev_q = q - 1 if q > 1 else 4 + prev_yr = yr if q > 1 else yr - 1 + prev_start_mo, prev_end_mo = (prev_q - 1) * 3 + 1, prev_q * 3 + prev_start = f"{prev_yr}/{prev_start_mo:02d}/01" + prev_end_last = _cal.monthrange(prev_yr, prev_end_mo)[1] + prev_end = f"{prev_yr}/{prev_end_mo:02d}/{prev_end_last:02d}" + yoy_start = f"{yr-1}/{start_mo:02d}/01" + yoy_end_last = _cal.monthrange(yr-1, end_mo)[1] + yoy_end = f"{yr-1}/{end_mo:02d}/{yoy_end_last:02d}" + + elif sub_type in ('half_yearly', '半年報'): + period_type = 'half_yearly' + yr = int(sub_arg.split('/')[0]) if sub_arg else now.year + h = int(sub_arg.split('/H')[1]) if sub_arg and 'H' in sub_arg else (1 if now.month <= 6 else 2) + start_mo, end_mo = (1, 6) if h == 1 else (7, 12) + start_str = f"{yr}/{start_mo:02d}/01" + end_last = _cal.monthrange(yr, end_mo)[1] + end_str = f"{yr}/{end_mo:02d}/{end_last:02d}" + period_label = f"{yr} H{h}" + prev_h = h - 1 if h > 1 else 2 + prev_yr = yr if h > 1 else yr - 1 + prev_start_mo, prev_end_mo = (1, 6) if prev_h == 1 else (7, 12) + prev_start = f"{prev_yr}/{prev_start_mo:02d}/01" + prev_end_last = _cal.monthrange(prev_yr, prev_end_mo)[1] + prev_end = f"{prev_yr}/{prev_end_mo:02d}/{prev_end_last:02d}" + yoy_start = f"{yr-1}/{start_mo:02d}/01" + yoy_end_last = _cal.monthrange(yr-1, end_mo)[1] + yoy_end = f"{yr-1}/{end_mo:02d}/{yoy_end_last:02d}" + + elif sub_type in ('annual', '年報'): + period_type = 'annual' + yr = int(sub_arg) if sub_arg and sub_arg.isdigit() else now.year + start_str = f"{yr}/01/01" + end_str = f"{yr}/12/31" + period_label = f"{yr}" + prev_start = f"{yr-1}/01/01" + prev_end = f"{yr-1}/12/31" + yoy_start = f"{yr-2}/01/01" + yoy_end = f"{yr-2}/12/31" + + else: # ttm 滾動 12 月 + period_type = 'ttm' + today = now.date() if hasattr(now, 'date') else now + ttm_end = today + ttm_start = today.replace(day=1) - _td(days=365) + ttm_start = ttm_start.replace(day=1) + start_str = ttm_start.strftime('%Y/%m/%d') + end_str = ttm_end.strftime('%Y/%m/%d') + period_label = f"TTM {start_str[:7]}~{end_str[:7]}" + # 上期 TTM = 再往前 12 個月 + prev_end_d = ttm_start - _td(days=1) + prev_start_d = prev_end_d.replace(day=1) - _td(days=365) + prev_start_d = prev_start_d.replace(day=1) + prev_start = prev_start_d.strftime('%Y/%m/%d') + prev_end = prev_end_d.strftime('%Y/%m/%d') + yoy_start = prev_start + yoy_end = prev_end + + params = {'report_type': period_type, 'period': period_label} + cached, cached_ai = _load_cached_ppt_path_and_analysis(period_type, params) + if cached: + return cached + + mcp_text = '' + if not cached_ai: + mcp_text = _fetch_mcp_context() + + # 抓三段資料:本期、上期、去年同期 + curr = query_period_summary(start_str, end_str) + if not curr.get('found'): + raise RuntimeError(f'{period_type} 期間 {period_label} 無資料,請確認 DB') + try: + prev = query_period_summary(prev_start, prev_end) + except Exception: + prev = {'found': False} + try: + yoy = query_period_summary(yoy_start, yoy_end) + if yoy.get('found'): + yoy['period_label'] = f"{yoy_start} ~ {yoy_end}" + except Exception: + yoy = {'found': False} + + # 組 db_data + kpis = curr.get('kpis', {}) + prod_breakdown = '\n'.join( + f" {i+1}. {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}" + for i, p in enumerate(curr.get('top_products', [])[:5]) + ) + cat_breakdown = '\n'.join( + f" - {c.get('cat','')}: NT${c.get('revenue', 0):,.0f}" + for c in curr.get('top_categories', [])[:5] + ) + data_summary = ( + f"【期間】{period_label}({period_type})\n" + f"【業績】NT${kpis.get('revenue', 0):,.0f}({kpis.get('revenue', 0)/10000:.1f}萬)\n" + f"【訂單】{kpis.get('orders', 0):,} 筆\n" + f"【毛利率】{kpis.get('gross_margin', 0):.1f}%\n" + f"【平均客單】NT${kpis.get('avg_order', 0):,.0f}\n" + f"【商品數】{kpis.get('product_count', 0)}\n" + f"【廠商數】{kpis.get('vendor_count', 0)}\n\n" + f"【品類 TOP 5】\n{cat_breakdown}\n\n" + f"【熱銷商品 TOP 5】\n{prod_breakdown}\n\n" + f"【上期業績】NT${prev.get('kpis', {}).get('revenue', 0):,.0f}\n" + f"【去年同期業績】NT${yoy.get('kpis', {}).get('revenue', 0):,.0f}\n\n" + f"【MCP 外部市場情報】\n{mcp_text[:600] if mcp_text else '(無)'}" + ) + ai_text = cached_ai or _ppt_ai_analysis( + data_summary, + f'{period_type}({period_label})' + ) + if not cached_ai and _ppt_needs_fallback(ai_text): + ai_text = _ppt_fallback_insight(period_type, data_summary, mcp_text) + + db_data_pr = dict(curr) + db_data_pr['prev_period'] = prev if prev.get('found') else None + db_data_pr['yoy_period'] = yoy if yoy.get('found') else None + db_data_pr['mcp'] = mcp_text + + ppt_path = generate_period_review_ppt(period_type, period_label, db_data_pr, ai_text) + _store_ppt_cache(period_type, params, ppt_path, { + 'report_type': period_type, + 'parameters': params, + 'data_summary': data_summary, + 'analysis': ai_text, + 'mcp': mcp_text, + }) + return ppt_path + else: raise RuntimeError( f'不支援的簡報類型:{sub_type}' - f'(支援:daily / weekly / monthly / strategy / competitor / promo / vendor)' + f'(支援:daily / weekly / monthly / quarterly / half_yearly / annual / ttm / strategy / competitor / promo / vendor)' ) @@ -4129,6 +4319,123 @@ def query_date_range(start_str: str, end_str: str) -> dict: return {'found': False, 'range': f'{start_str}~{end_str}'} +def query_period_summary(start_date: str, end_date: str) -> dict: + """期間業績完整摘要(quarterly / half_yearly / annual / ttm 共用) + + 回傳:{ + kpis: {revenue, orders, gross_margin, avg_order, product_count, vendor_count, days}, + monthly_breakdown: [{month, revenue, orders, gross_margin}], + top_products: [...], + top_categories: [...], + top_vendors: [...], + found: bool + } + """ + try: + s = start_date.replace('/', '-') + e = end_date.replace('/', '-') + with _db().connect() as c: + row = c.execute(text(""" + SELECT COUNT(DISTINCT "訂單編號"), + COALESCE(SUM(CAST("總業績" AS FLOAT)), 0), + COALESCE(SUM(CAST("總成本" AS FLOAT)), 0), + COUNT(DISTINCT "商品ID"), + COUNT(DISTINCT "廠商名稱"), + COUNT(DISTINCT "日期") + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + """), {'s': s, 'e': e}).fetchone() + + # 月度聚合(YYYY-MM) + monthly_rows = c.execute(text(""" + SELECT TO_CHAR(CAST("日期" AS DATE), 'YYYY-MM') AS ym, + SUM(CAST("總業績" AS FLOAT)) AS rev, + SUM(CAST("總成本" AS FLOAT)) AS cost, + COUNT(DISTINCT "訂單編號") AS orders + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + GROUP BY ym ORDER BY ym ASC + """), {'s': s, 'e': e}).fetchall() + + # TOP 50 商品 + prod_rows = c.execute(text(""" + SELECT "商品ID", "商品名稱", + SUM(CAST("總業績" AS FLOAT)) AS rev, + SUM(CAST("數量" AS INTEGER)) AS qty, + COUNT(DISTINCT "訂單編號") AS orders + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + GROUP BY "商品ID", "商品名稱" + ORDER BY 3 DESC LIMIT 50 + """), {'s': s, 'e': e}).fetchall() + + # TOP 8 品類 + cat_rows = c.execute(text(""" + SELECT "商品分類L1", SUM(CAST("總業績" AS FLOAT)) AS rev + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + AND "商品分類L1" IS NOT NULL AND "商品分類L1" != '' + GROUP BY "商品分類L1" ORDER BY 2 DESC LIMIT 8 + """), {'s': s, 'e': e}).fetchall() + + # TOP 30 廠商 + vendor_rows = c.execute(text(""" + SELECT "廠商名稱", + SUM(CAST("總業績" AS FLOAT)) AS rev, + SUM(CAST("總成本" AS FLOAT)) AS cost + FROM realtime_sales_monthly + WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) + AND "廠商名稱" IS NOT NULL AND "廠商名稱" != '' + GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT 30 + """), {'s': s, 'e': e}).fetchall() + + if not row or row[0] == 0: + return {'found': False} + + orders, revenue, cost = int(row[0]), float(row[1]), float(row[2]) + gm = (revenue - cost) / revenue * 100 if revenue > 0 else 0 + + return { + 'found': True, + 'kpis': { + 'revenue': revenue, + 'orders': orders, + 'gross_margin': gm, + 'avg_order': revenue / orders if orders else 0, + 'product_count': int(row[3] or 0), + 'vendor_count': int(row[4] or 0), + 'days': int(row[5] or 0), + }, + 'monthly_breakdown': [ + {'month': r[0], + 'revenue': float(r[1] or 0), + 'gross_margin': ((float(r[1] or 0) - float(r[2] or 0)) / float(r[1] or 1) * 100) + if float(r[1] or 0) else 0, + 'orders': int(r[3] or 0)} + for r in monthly_rows + ], + 'top_products': [ + {'id': r[0], 'name': r[1], 'revenue': float(r[2]), + 'qty': int(r[3] or 0), 'orders': int(r[4] or 0)} + for r in prod_rows + ], + 'top_categories': [ + {'cat': r[0], 'revenue': float(r[1])} for r in cat_rows + ], + 'top_vendors': [ + {'name': r[0], + 'sales': float(r[1] or 0), + 'profit': float(r[1] or 0) - float(r[2] or 0), + 'margin': ((float(r[1] or 0) - float(r[2] or 0)) / float(r[1] or 1) * 100) + if float(r[1] or 0) else 0} + for r in vendor_rows + ], + } + except Exception as e: + sys_log.error(f"[query_period_summary] {e}") + return {'found': False} + + def query_available_months() -> list: """取得 DB 中有資料的月份清單(支援 YYYY/MM/DD 和 YYYY-MM-DD 兩種日期格式)""" try: diff --git a/services/openclaw_bot/menu_keyboards.py b/services/openclaw_bot/menu_keyboards.py index d1cb6ea..a77adb0 100644 --- a/services/openclaw_bot/menu_keyboards.py +++ b/services/openclaw_bot/menu_keyboards.py @@ -212,6 +212,10 @@ def _submenu_reports(): _row(('🏭 廠商業績報告', 'cmd:ppt:vendor'), ('📅 指定日期日報', 'await:date_ppt_daily')), _row(('📅 指定月份月報', 'await:date_ppt_monthly'),), + _row(('📊 季報 (本季)', 'cmd:ppt:quarterly'), + ('📊 半年報 (本半)', 'cmd:ppt:half_yearly')), + _row(('📊 年報 (本年)', 'cmd:ppt:annual'), + ('📊 TTM 滾動 12 月', 'cmd:ppt:ttm')), ]) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index 09bc1e1..9a15810 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -52,6 +52,10 @@ TEMPLATE_VERSIONS = { # 但路由層未綁定指令;保留版本字串避免如未來重啟時快取 schema 對不上。 'growth': 'v2.0', # DEPRECATED — 從未落地 'vendor': 'v3.1.0', # 2026-05-03 喚醒 + v3 暖紙風 + matplotlib 雙視圖 + 採購策略 SMART prompt + 集中度警示 + 'quarterly': 'v3.1.0', # 2026-05-03 季報(period_review 共用 generator) + 'half_yearly': 'v3.1.0', # 2026-05-03 半年報 + 'annual': 'v3.1.0', # 2026-05-03 年報 + 'ttm': 'v3.1.0', # 2026-05-03 TTM 滾動 12 月 'bcg': 'v2.0', # DEPRECATED — 從未落地 } @@ -2943,6 +2947,348 @@ def generate_vendor_ppt(yr, mo, db_data, ai_text: str) -> str: return path +# ── 期間回顧報告(quarterly / half_yearly / annual / ttm 共用)─────────────── +def generate_period_review_ppt(period_type: str, period_label: str, + db_data: dict, ai_text: str) -> str: + """期間回顧報告 — 一份 generator 解 4 種: + period_type: 'quarterly' → '2026 Q1' + 'half_yearly' → '2026 H1' + 'annual' → '2026' + 'ttm' → 'TTM 2025-05~2026-04' + db_data: { + kpis, monthly_breakdown, top_products, top_categories, top_vendors, + prev_period: dict (上一期同等) + yoy_period: dict (去年同期) + mcp: str + } + """ + from pptx import Presentation + from pptx.util import Cm + + prs = Presentation() + prs.slide_width = Cm(33.87) + prs.slide_height = Cm(19.05) + W = 33.87 + + kpis = db_data.get('kpis', {}) or {} + monthly = db_data.get('monthly_breakdown', []) or [] + top_cats = db_data.get('top_categories', []) or [] + top_prods = db_data.get('top_products', []) or [] + top_vendors = db_data.get('top_vendors', []) or [] + prev_period = db_data.get('prev_period') or {} + yoy_period = db_data.get('yoy_period') or {} + mcp_text = db_data.get('mcp', '') or '' + + rev = float(kpis.get('revenue', 0)) + ord_ = int(kpis.get('orders', 0)) + gm = float(kpis.get('gross_margin', 0)) + aov = float(kpis.get('avg_order', rev / ord_ if ord_ else 0)) + + # 期間類型徽章 + type_badges = { + 'quarterly': ('季報', 'QUARTERLY REVIEW', _KPI_HONEY), + 'half_yearly': ('半年報', 'HALF-YEARLY REVIEW', _KPI_MAHOGANY), + 'annual': ('年報', 'ANNUAL REVIEW', _BRAND_OG2), + 'ttm': ('TTM 滾動', 'TRAILING 12 MONTHS', _KPI_EARTH), + } + type_label, type_en, type_color = type_badges.get(period_type, ('期間回顧', 'PERIOD REVIEW', _BRAND_OG)) + + # ── P1: 封面 ───────────────────────────────────────────── + elevator = _compute_elevator_pitch( + {'revenue': rev, 'orders': ord_, 'gross_margin': gm, 'top_categories': top_cats}, + prev_period.get('kpis') if prev_period else None + ) + _period_review_cover_slide(prs, period_label, type_label, type_en, type_color, + rev, ord_, gm, aov, elevator) + + # ── P2: 執行摘要(KPI v2 + QoQ/HoH/YoY 對比帶)────────────── + s2 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s2, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s2, f"{type_label}執行摘要 — {period_label}") + + def _delta(curr_v, prev_dict, key, is_pp=False): + if not prev_dict: + return None + prev_kpis = prev_dict.get('kpis', {}) + prev_v = float(prev_kpis.get(key, 0) or 0) + if not prev_v: + return None + if is_pp: + return float(curr_v) - prev_v + return (float(curr_v) - prev_v) / prev_v * 100 + + d_rev = _delta(rev, prev_period, 'revenue') + d_ord = _delta(ord_, prev_period, 'orders') + d_gm = _delta(gm, prev_period, 'gross_margin', is_pp=True) + d_aov = _delta(aov, prev_period, 'avg_order') + + qoh_label = { + 'quarterly': 'vs 上季', + 'half_yearly': 'vs 上半', + 'annual': 'vs 去年', + 'ttm': 'vs 上期', + }.get(period_type, 'vs 上期') + + kpis_v2 = [ + (_KPI_CARAMEL, "期間業績", f"NT${rev/10000:.1f}萬", d_rev, qoh_label), + (_KPI_HONEY, "期間訂單", f"{ord_:,} 筆", d_ord, qoh_label), + (_KPI_MAHOGANY, "毛利率", f"{gm:.1f}%", d_gm, f"{qoh_label}(pp)"), + (_KPI_EARTH, "平均客單", f"NT${aov:,.0f}", d_aov, qoh_label), + ] + for i, (col, lbl, val, dp, dl) in enumerate(kpis_v2): + _kpi_card_v2(s2, i * 7.8 + 0.5, 1.95, 7.4, 4.5, + col, lbl, val, delta_pct=dp, delta_label=dl) + + # 高階解讀區塊 + _add_rect(s2, 0.5, 7.0, W - 1.0, 0.7, type_color) + _add_text(s2, f"📊 {type_label}高階營運解讀(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(s2, 0.5, 7.7, W - 1.0, 5.8, _WHITE, line_hex=_SUBTLE) + _add_rect(s2, 0.5, 7.7, 0.4, 5.8, type_color) + + # 抽取 AI 整體解讀段 + summary_text = "" + capture = False + for line in (ai_text or '').split('\n'): + if any(k in line for k in ['整體業績解讀', '高階營運', '整體表現']): + capture = True + continue + if capture: + if line.strip().startswith('【') and '整體' not in line: + break + if line.strip(): + summary_text += line + "\n" + if len(summary_text) > 350: break + if not summary_text.strip(): + for line in (ai_text or '').split('\n'): + if line.strip() and not line.startswith('【'): + summary_text += line + "\n" + if len(summary_text) > 350: break + if not summary_text.strip(): + summary_text = (ai_text or '')[:350] + "…" if ai_text else "(暫無 AI 分析)" + + _add_text(s2, summary_text.strip(), + 1.2, 7.95, W - 2.0, 5.3, + size=13, color=_DARK_TEXT, wrap=True, + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + + # YoY 對比帶(如有) + yoy_y = 13.8 + if yoy_period and yoy_period.get('kpis'): + prev_yr_rev = float(yoy_period['kpis'].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(s2, 0.5, yoy_y, W - 1.0, 1.4, yoy_color) + _add_text(s2, + f"📅 YoY 同期對比:去年 {yoy_period.get('period_label', '同期')} 業績 NT${prev_yr_rev/10000:.1f}萬" + f" → 本期 {arrow} {abs(yoy):.1f}%", + 0.7, yoy_y + 0.1, W - 1.4, 1.2, + bold=True, size=13, color=_WHITE, valign="middle", + latin_font=_FONT_DISPLAY, ea_font=_FONT_BODY_EA) + _add_footer(s2, W) + + # ── P3: 月度業績走勢(折線圖:本期 + 上期 + 去年同期) ──────── + s3 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s3, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s3, f"{type_label}業績走勢 — {period_label}(月度)") + if monthly: + m_dates = [m.get('month', '') for m in monthly] + m_revs = [float(m.get('revenue', 0)) for m in monthly] + prev_revs = None + if prev_period and prev_period.get('monthly_breakdown'): + prev_revs = [float(m.get('revenue', 0)) + for m in prev_period['monthly_breakdown']] + chart_w = W - 0.8 + chart_h = 11.0 + buf = _mpl_line_chart_png( + m_dates, m_revs, prev_vals=prev_revs, + total_width_cm=chart_w, total_height_cm=chart_h, + title=f"月度業績走勢({type_label} {period_label})", + curr_label="本期", prev_label="上期" + ) + if buf: + _add_image_from_buf(s3, buf, 0.4, 1.95, chart_w, chart_h) + + # 底部 4 卡:合計 / 月均 / 最高月 / 最低月 + if m_revs: + avg_m = sum(m_revs) / len(m_revs) + max_m, min_m = max(m_revs), min(m_revs) + ins_y = 13.3 + ins_h = 2.4 + card_w = (W - 1.0 - 0.3 * 3) / 4 + cards = [ + (_BRAND_OG, "📊 期間合計", f"NT${sum(m_revs)/10000:.1f}萬", f"{len(m_revs)} 個月"), + (_KPI_HONEY, "📈 月均業績", f"NT${avg_m/10000:.1f}萬", "月平均"), + (_KPI_MAHOGANY, "🏆 最高月", f"NT${max_m/10000:.1f}萬", + m_dates[m_revs.index(max_m)] if m_revs else "—"), + (_KPI_EARTH, "📉 最低月", f"NT${min_m/10000:.1f}萬", + m_dates[m_revs.index(min_m)] if m_revs 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, "無月度業績資料", + "請確認該期間是否已有銷售資料。", W) + _add_footer(s3, W) + + # ── P4: 品類分析(橫條 + 帕雷托)──────────────────────────── + if top_cats: + s4 = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(s4, 0, 0, W, _SLIDE_H, _BG_PAPER) + _add_header(s4, f"{type_label}品類業績結構分析 — {period_label}") + 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]] + 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(s4, 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(s4, buf2, rx, 1.95, chart_w_right, chart_h) + _add_footer(s4, W) + + # ── P5-P7: TOP 50 商品(自動分頁)────────────────────────── + _product_table_slide(prs, f"{type_label}熱銷商品 TOP 50 — {period_label}", + top_prods, max_items=50) + + # ── P8: TOP 30 廠商 ──────────────────────────────────────── + if top_vendors: + _vendor_table_slide(prs, top_vendors[:30], period_label, {}, + sum(float(v.get('sales', 0)) for v in top_vendors), max_items=30) + + # ── P9: MCP 市場情報 ────────────────────────────────────── + _mcp_intel_slide(prs, mcp_text) + + # ── P10: AI 結構化洞察 ───────────────────────────────────── + _ai_insight_slide(prs, ai_text) + + # ── P11: 附錄 ───────────────────────────────────────────── + _appendix_slide(prs, period_type, period_label) + + path = _new_path(period_type) + prs.save(path) + return path + + +def _period_review_cover_slide(prs, period_label, type_label, type_en, type_color, + rev, ord_, gm, aov, elevator): + """期間回顧封面 — 含期間類型徽章""" + 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, type_color) + _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, type_color) + _add_rect(slide, 4.0, 8.4, 22.0, 0.06, type_color) + + _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, f"{type_en} · AI INSIGHT", + 3.8, 2.45, 22, 0.55, + bold=True, size=10, color=_BRAND_OG2, + latin_font=_FONT_LABEL) + + _add_text(slide, f"{type_label}\n{period_label}", + 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) + + 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, "★ 最大亮點", 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, "⚠ 最大警訊", 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) + + 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 "▼" + _add_rect(slide, 3.8, pitch_y3, 0.45, pitch_h, delta_color) + _add_text(slide, "📈 期間動能", 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) + _add_text(slide, + f"vs 上期:業績 {arrow} {abs(mom_rev):.1f}%", + 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 + + def _vendor_cover_slide(prs, period_lbl, vcount, total_sales, total_profit, avg_margin, pareto_n, risk_label, risk_color): """廠商報告封面 — 含集中度警示徽章"""