#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ OpenClaw Telegram 群組智能助理 v5 ───────────────────────────────────────── 核心功能: • 群組自然對話,Ollama-first 三主機級聯,Gemini 僅備援 • Inline Keyboard 15 個功能入口 • 全商品查詢帶出商品ID • AI 分析強制比對內部DB + 外部MCP情報 v5 新增(2026-04-16): • 每日早報 08:30 / 晚報 21:00 / 週一週報 • 異常偵測(4次/日,偏差>30%即告警) • 目標達成率(日/月目標設定) • 分類業績、同期比較(上週/上月) • 商品策略矩陣(加碼/機會/收割/觀察/持穩) • 商品健康分析(異常+策略分佈) • 趨勢圖 + 熱銷商品橫條圖(matplotlib) • 完整報表 PDF 下載(fpdf2 → reportlab → CSV) """ from __future__ import annotations import os import json import re import threading import hashlib # Operation Ollama-First v5.0 P1: H6 PII fix — chat_id 進 meta 改 hash[:8] from contextvars import ContextVar from contextlib import contextmanager import requests from datetime import datetime, timezone, timedelta from flask import Blueprint, request, jsonify from sqlalchemy import text, or_ from database.manager import DatabaseManager from services.logger_manager import SystemLogger from services.telegram_update_guard import is_duplicate_update as is_global_duplicate_update from services.mcp_context_service import ( build_mcp_context, MCPRouter, query_mcp, get_tw_media_news, get_ecommerce_news, get_taiwan_trends, get_dcard_trends, get_youtube_trending, get_taiwan_weather, get_twbank_exchange_rates, get_upcoming_events, ) from services.openclaw_bot.telegram_api import ( _tg, answer_callback, edit_message_text, send_document, send_message, send_photo, send_typing, ) from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1 from services.gemini_guard import get_gemini_api_key, is_gemini_fallback_enabled from services.openclaw_bot.menu_keyboards import ( _BACK, _SUBMENUS, _chunk_rows, _submenu_goals, _submenu_market, _submenu_sales, _submenu_trend, _row, configure_menu_keyboards, quick_menu_keyboard, main_menu_keyboard, ) try: from services.openclaw_learning_service import ( build_rag_context, store_conversation, store_insight, update_feedback, get_learning_stats, ) _LEARNING_ENABLED = True except Exception as _le: _LEARNING_ENABLED = False sys_log = __import__('logging').getLogger('openclaw') sys_log.warning(f"[OCLearn] learning service unavailable: {_le}") try: from services.pchome_crawler import ( compare_product as pchome_compare, batch_compare_top as pchome_batch, save_matches as pchome_save, fmt_compare_msg as pchome_fmt_compare, fmt_daily_report as pchome_fmt_report, search_pchome as pchome_search, ensure_tables as pchome_ensure_tables, ) _PCHOME_AVAILABLE = True except ImportError: _PCHOME_AVAILABLE = False # V-New: 引入 Ollama 探測機制 try: from services.ollama_service import OllamaService, get_host_label, get_provider_tag _OLLAMA_AVAILABLE = True except ImportError: _OLLAMA_AVAILABLE = False # AI 引擎:Ollama 三主機級聯 → NIM → Gemini emergency fallback。 # Gemini fallback default is hard-disabled by GEMINI_API_HARD_DISABLED=true. GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models' GEMINI_MODEL = 'gemini-2.0-flash' IMAGE_VISION_OLLAMA_MODEL = os.getenv( 'OPENCLAW_IMAGE_VISION_MODEL', os.getenv('PPT_VISION_MODEL', 'minicpm-v:latest'), ) IMAGE_VISION_GEMINI_MODEL = os.getenv('OPENCLAW_IMAGE_GEMINI_MODEL', 'gemini-1.5-flash') PPT_CACHE_TTL_HOURS = max(1, int(os.getenv('OPENCLAW_PPT_CACHE_TTL_HOURS', '24'))) TAIPEI_TZ = timezone(timedelta(hours=8)) sys_log = SystemLogger("OpenClawBot").get_logger() openclaw_bot_bp = Blueprint('openclaw_bot', __name__) def _gemini_fallback_api_key(context: str) -> str: return get_gemini_api_key(context) def _gemini_fallback_allowed(context: str) -> bool: return is_gemini_fallback_enabled(context) and bool(_gemini_fallback_api_key(context)) # ── Telegram retry 去重 (update_id 快取,最多保留 500 筆) ───── BOT_TOKEN = os.getenv('OPENCLAW_BOT_TOKEN') or os.getenv('TELEGRAM_BOT_TOKEN', '') BOT_API_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" ALLOWED_GROUP = int(os.getenv('OPENCLAW_GROUP_ID', '-1003940688311')) MOMO_BASE_URL = os.getenv('MOMO_BASE_URL', 'https://mo.wooo.work') NVIDIA_API_KEY = os.getenv('NVIDIA_API_KEY', '') NVIDIA_BASE_URL = 'https://integrate.api.nvidia.com/v1' CHAT_MODEL = 'deepseek-ai/deepseek-v3.2' BOT_USERNAME = os.getenv('OPENCLAW_BOT_USERNAME') or os.getenv('TELEGRAM_BOT_USERNAME', '@OpenClawAwoooI_Bot') # ── 存取控制白名單 ───────────────────────────────────────────── # OPENCLAW_ALLOWED_USERS:逗號分隔的 Telegram user_id(整數) # 空字串 + OPENCLAW_ALLOW_PRIVATE_WITHOUT_WHITELIST=1 => 允許任何私訊(舊行為) # OPENCLAW_ALLOW_PRIVATE_WITHOUT_WHITELIST 未開啟時,空白名單會拒絕私訊 # 例:'123456789,987654321' _allowed_users_raw = os.getenv('OPENCLAW_ALLOWED_USERS', '') ALLOWED_USERS: set = ( {int(uid.strip()) for uid in _allowed_users_raw.split(',') if uid.strip().isdigit()} if _allowed_users_raw.strip() else set() ) _ALLOW_PRIVATE_WITHOUT_WHITELIST = ( os.getenv('OPENCLAW_ALLOW_PRIVATE_WITHOUT_WHITELIST', '1').strip().lower() in {'1', 'true', 'yes', 'on'} ) # ADR-019 Phase 3: Feature-flagged agent dispatch # 預設 OFF;啟用步驟: # export OPENCLAW_AGENT_DISPATCH=1 # export OPENCLAW_AGENT_DISPATCH_CMDS=sales,top,vendor (逗號分隔白名單) # 啟用後白名單內 cmd 改走 OpenClaw NL agent,agent 自決查資料/詢問用戶/答覆 _OPENCLAW_AGENT_DISPATCH_ENABLED = ( os.getenv('OPENCLAW_AGENT_DISPATCH', '0').strip().lower() in {'1', 'true', 'yes', 'on'} ) _AGENT_DISPATCH_CMDS = { c.strip() for c in os.getenv('OPENCLAW_AGENT_DISPATCH_CMDS', '').split(',') if c.strip() } # ── fail-closed 統一授權檢查 ─────────────────────────────────── # 規則(任一滿足即通過,否則一律拒絕): # 1. group/supergroup 且 chat_id == ALLOWED_GROUP # 2. private 且 user_id ∈ ALLOWED_USERS(env 未設 → 空 set → 全拒) # channel / 未知 chat_type / 缺欄位 → 拒絕 # 修補 C3:callback handler 原本只擋 group/supergroup 不匹配,private 完全放行; # message handler `if ALLOWED_USERS and ...` 空 set 時整段失效。 def _is_authorized(chat_type: str, chat_id, user_id) -> bool: try: cid = int(chat_id) if chat_id is not None else None uid = int(user_id) if user_id is not None else None except (TypeError, ValueError): return False if chat_type in ('group', 'supergroup'): return cid == ALLOWED_GROUP if chat_type == 'private': if uid is None: return False if ALLOWED_USERS: return uid in ALLOWED_USERS return _ALLOW_PRIVATE_WITHOUT_WHITELIST return False # ── 速率限制(每用戶每分鐘最多 30 次 AI 呼叫)────────────────── import time as _time_mod _rate_tracker: dict = {} # {user_id: [timestamp, ...]} _RATE_LIMIT_PER_MIN = 30 # 每分鐘上限 _RATE_WINDOW_SEC = 60 # V-Fix:callback 期間覆寫 send_message 會跨 request 競態,全域鎖可避免重入干擾。 _CALLBACK_SEND_LOCK = threading.Lock() _CMD_FROM_CALLBACK_CTX = ContextVar('openclaw_cmd_from_callback', default=False) # critic Medium-3:當前請求的 user_id ContextVar(webhook 入口設定,handle_cmd 讀取) # 用 ContextVar 而非改 handle_cmd 簽名 — 避免動到 30+ 處呼叫端。 _CURRENT_USER_ID_CTX = ContextVar('openclaw_current_user_id', default=None) # 管理員白名單:可執行破壞性指令(/cache flush all / cleanup confirm 等) # OPENCLAW_ADMIN_USER_IDS 未設 → 退回 ALLOWED_USERS(保持向後兼容) # 建議部署時明確設定,避免群組內所有人都能動快取 _admin_users_raw = os.getenv('OPENCLAW_ADMIN_USER_IDS', '') ADMIN_USER_IDS: set = ( {int(uid.strip()) for uid in _admin_users_raw.split(',') if uid.strip().isdigit()} if _admin_users_raw.strip() else set() ) def _is_admin(user_id) -> bool: """判定 user_id 是否為管理員。 優先用 ADMIN_USER_IDS(明確設定);未設時退回 ALLOWED_USERS(兼容)。 回傳 True 才允許執行破壞性指令。 """ try: uid = int(user_id) if user_id is not None else None except (TypeError, ValueError): return False if uid is None: return False if ADMIN_USER_IDS: return uid in ADMIN_USER_IDS # 兼容:ADMIN 未設時,退回 ALLOWED_USERS(私訊白名單即視為 admin) return bool(ALLOWED_USERS) and uid in ALLOWED_USERS def _check_rate_limit(user_id: int) -> bool: """回傳 True = 允許,False = 超過速率限制""" now = _time_mod.time() window = _rate_tracker.setdefault(user_id, []) # 清掉 60 秒以前的紀錄 _rate_tracker[user_id] = [t for t in window if now - t < _RATE_WINDOW_SEC] if len(_rate_tracker[user_id]) >= _RATE_LIMIT_PER_MIN: return False _rate_tracker[user_id].append(now) return True def _is_duplicate_update(update_id) -> bool: """Telegram webhook 可能重送同一 update_id;優先以 DB 導向去重,再回退本機快取。""" return is_global_duplicate_update(update_id, namespace="telegram_update") def _is_non_modified_error(response) -> bool: """Telegram API 回覆若是 `message is not modified` 則視為可忽略。""" if not isinstance(response, dict): return False desc = (response.get("description") or "").lower() return "message is not modified" in desc def _is_non_editable_message_error(response) -> bool: """V-Fix:若訊息已刪除、不可編輯或過舊,僅回報不再 fallback。""" if not isinstance(response, dict): return False desc = (response.get("description") or "").lower() markers = ( "message to edit not found", "message can't be edited", "message_id_invalid", "message id is invalid", "message identifier is invalid", "message is too old to edit", "not found", "reply message not found", "message to delete not found", ) return any(m in desc for m in markers) @contextmanager def _run_with_callback_cmd_context(): """在 callback 路徑處理指令時,暫時封鎖 NL agent dispatch。""" token = _CMD_FROM_CALLBACK_CTX.set(True) try: yield finally: _CMD_FROM_CALLBACK_CTX.reset(token) def _build_callback_dedupe_key(update_id, cq_id, message_id=None, data=None, chat_id=None, user_id=None) -> str: """V-Fix:多維度組成 callback key,降低重複回報機率。""" key_parts = [] if cq_id: key_parts.append(f"cbq:{cq_id}") elif update_id is not None: key_parts.append(f"uid:{update_id}") if chat_id is not None: key_parts.append(f"chat:{chat_id}") if user_id is not None: key_parts.append(f"user:{user_id}") if message_id is not None: key_parts.append(f"msg:{message_id}") if data: key_parts.append(f"data:{data}") if key_parts: return "cb:" + "|".join(key_parts) base = f"cb-query:{cq_id}" if message_id is not None: base += f":msg:{message_id}" if data: base += f":{data}" return base def _should_fallback_send_message(edit_result) -> bool: """V-Fix:判斷 editMessageText 失敗是否要改走 sendMessage。""" if not isinstance(edit_result, dict): return True if edit_result.get("ok"): return False if _is_non_modified_error(edit_result): return False if _is_non_editable_message_error(edit_result): return False return True def _normalize_callback_data(data: str) -> str: """V-Fix:兼容舊版 callback_data(例如 menu_main、menu_trend、await_date...)。""" if not data: return data data = data.strip() if data.startswith(("menu:", "cmd:", "await:")): return data if data.startswith("menu_"): key = data[5:] if key in _SUBMENUS: return f"menu:{key}" elif data.startswith("await_"): key = data[6:] if key in _AWAIT_PROMPTS: return f"await:{key}" elif data.startswith("cmd_"): return f"cmd:{data[4:]}" return data # 群組內回應觸發(包含這些字才回應;若為空則全部回應) # 設為空 list = 所有訊息都回應 TRIGGER_KEYWORDS = [] # 空 = 全部回應(小龍蝦是專用業務群組) # ── 目標管理(記憶體,跨 session 用 DB 儲存)───────────────────── _GOALS: dict = {} # {'daily','monthly','quarterly','half','yearly': float} _scheduler = None # ── 輸入等待狀態機(chat_id → pending action)──────────────────── _input_pending: dict = {} # {chat_id: {'action': str, 'label': str}} # ── Excel 匯入暫存(chat_id → pending import info)──────────────── _excel_pending: dict = {} # {chat_id: {'file_path': str, 'filename': str, 'preview': str}} # ── 中文字型搜尋 ────────────────────────────────────────────── _CHINESE_FONT_PATHS = [ '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc', '/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc', '/tmp/ocbot_chinese_font.ttf', ] _FONT_DL_URL = ( 'https://github.com/googlefonts/noto-cjk/raw/main/' 'Sans/SubsetOTF/SC/NotoSansSC-Regular.otf' ) def _get_chinese_font() -> str: """取得中文字型路徑(找不到就嘗試下載),回傳路徑或空字串""" for p in _CHINESE_FONT_PATHS: if os.path.exists(p): return p # 嘗試下載 Noto Sans SC(約 3MB) cache = '/tmp/ocbot_chinese_font.ttf' try: r = requests.get(_FONT_DL_URL, timeout=20) if r.ok: with open(cache, 'wb') as f: f.write(r.content) return cache except Exception: sys_log.debug('[OpenClawBot] Chinese font download failed', exc_info=True) return '' # ── 報表產生:Excel(主)→ CSV(備援)──────────────────────────── def generate_daily_pdf(date_str: str) -> str: """ 產生日報:優先用 openpyxl 生成 .xlsx(支援中文、可直接在 Excel/Numbers 開啟)。 openpyxl 未安裝時 fallback CSV。 """ import tempfile sales = query_sales(date_str) products = query_top_products(date_str, 20) vendors = query_top_vendors(date_str, 10) weekly = query_weekly_trend() now_str = datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M') safe_date = date_str.replace('/', '-') # ── openpyxl Excel(主要,支援中文,秒產生)───────────────── try: from openpyxl import Workbook from openpyxl.styles import (Font, PatternFill, Alignment, Border, Side, numbers) from openpyxl.utils import get_column_letter wb = Workbook() HDR = Font(bold=True, color='FFFFFF', size=11) HDR_FILL = PatternFill('solid', fgColor='1565C0') # 深藍 SUB_FILL = PatternFill('solid', fgColor='E3F2FD') # 淡藍 ORG_FILL = PatternFill('solid', fgColor='FFF8E1') # 淡黃 thin = Side(style='thin', color='BDBDBD') border = Border(left=thin, right=thin, top=thin, bottom=thin) center = Alignment(horizontal='center', vertical='center') right = Alignment(horizontal='right', vertical='center') def _hdr(ws, row, cols, fills=HDR_FILL): for c in cols: ws.cell(row, c).font = HDR ws.cell(row, c).fill = fills ws.cell(row, c).alignment = center def _border_row(ws, row, max_col): for c in range(1, max_col + 1): ws.cell(row, c).border = border # ════ Sheet 1:業績摘要 ════ ws1 = wb.active ws1.title = '業績摘要' ws1.column_dimensions['A'].width = 18 ws1.column_dimensions['B'].width = 22 ws1.merge_cells('A1:B1') ws1['A1'] = f'📊 業績報表 — {date_str}' ws1['A1'].font = Font(bold=True, size=14, color='1565C0') ws1['A1'].alignment = center ws1.merge_cells('A2:B2') ws1['A2'] = f'產生時間:{now_str} (UTC+8) | OpenClaw AI' ws1['A2'].font = Font(size=9, color='757575') ws1['A2'].alignment = center r = 4 if sales.get('found'): headers = ['項目', '數值'] for i, h in enumerate(headers, 1): ws1.cell(r, i, h) _hdr(ws1, r, [1, 2]) _border_row(ws1, r, 2) r += 1 rows_data = [ ('💰 總業績', f"NT$ {float(sales.get('revenue', 0)):,.0f}"), ('📦 訂單數', f"{sales.get('orders', '-')} 筆"), ('🛒 客單價', f"NT$ {sales.get('avg_order', 0):,.0f}"), ('📈 毛利率', f"{sales.get('gross_margin', 0):.1f}%"), ('🛍️ 商品數', f"{sales.get('products', '-')} 件"), ] for i, (k, v) in enumerate(rows_data): ws1.cell(r, 1, k) ws1.cell(r, 2, v) fill = SUB_FILL if i % 2 == 0 else None if fill: ws1.cell(r, 1).fill = fill ws1.cell(r, 2).fill = fill ws1.cell(r, 2).alignment = right _border_row(ws1, r, 2) r += 1 # ── 近7日趨勢 ── if weekly: r += 1 ws1.cell(r, 1, '📅 近7日業績趨勢').font = Font(bold=True, size=11, color='1565C0') r += 1 for h, hdr in enumerate(['日期', '業績', '環比'], 1): ws1.cell(r, h, hdr) _hdr(ws1, r, [1, 2, 3]) _border_row(ws1, r, 3) r += 1 for idx, w in enumerate(weekly): prev = weekly[idx - 1]['revenue'] if idx > 0 else None rev = w['revenue'] pct = f"{'▲' if rev >= prev else '▼'}{abs((rev - prev) / prev * 100):.1f}%" \ if prev else '—' ws1.cell(r, 1, w['date']) ws1.cell(r, 2, f"NT$ {rev:,.0f}") ws1.cell(r, 3, pct) ws1.cell(r, 2).alignment = right ws1.cell(r, 3).alignment = center if idx % 2 == 0: for c in range(1, 4): ws1.cell(r, c).fill = ORG_FILL _border_row(ws1, r, 3) r += 1 ws1.column_dimensions['C'].width = 10 # ════ Sheet 2:熱銷商品 ════ if products: ws2 = wb.create_sheet('熱銷商品 TOP20') cols = ['排名', '商品ID', '商品名稱', '業績 (NT$)', '數量 (件)', '佔比 %'] widths = [6, 28, 30, 16, 10, 10] for i, (h, w) in enumerate(zip(cols, widths), 1): ws2.cell(1, i, h) ws2.column_dimensions[get_column_letter(i)].width = w _hdr(ws2, 1, list(range(1, len(cols) + 1))) _border_row(ws2, 1, len(cols)) total_rev = sum(p['revenue'] for p in products) for idx, p in enumerate(products, 1): pct = p['revenue'] / total_rev * 100 if total_rev else 0 row_data = [idx, p.get('id', ''), p['name'], p['revenue'], p['qty'], round(pct, 2)] for c, val in enumerate(row_data, 1): ws2.cell(idx + 1, c, val) ws2.cell(idx + 1, c).border = border ws2.cell(idx + 1, 4).number_format = '#,##0' ws2.cell(idx + 1, 5).number_format = '#,##0' ws2.cell(idx + 1, 6).number_format = '0.00"%"' ws2.cell(idx + 1, 4).alignment = right ws2.cell(idx + 1, 5).alignment = right ws2.cell(idx + 1, 6).alignment = right if idx % 2 == 0: for c in range(1, len(cols) + 1): ws2.cell(idx + 1, c).fill = SUB_FILL # ════ Sheet 3:熱銷廠商 ════ if vendors: ws3 = wb.create_sheet('熱銷廠商 TOP10') vcols = ['排名', '廠商名稱', '業績 (NT$)', '佔比 %'] vwidths = [6, 32, 16, 10] for i, (h, w) in enumerate(zip(vcols, vwidths), 1): ws3.cell(1, i, h) ws3.column_dimensions[get_column_letter(i)].width = w _hdr(ws3, 1, list(range(1, len(vcols) + 1))) _border_row(ws3, 1, len(vcols)) total_vrev = sum(v['revenue'] for v in vendors) for idx, v in enumerate(vendors, 1): pct = v['revenue'] / total_vrev * 100 if total_vrev else 0 for c, val in enumerate([idx, v['name'], v['revenue'], round(pct, 2)], 1): ws3.cell(idx + 1, c, val) ws3.cell(idx + 1, c).border = border ws3.cell(idx + 1, 3).number_format = '#,##0' ws3.cell(idx + 1, 4).number_format = '0.00"%"' ws3.cell(idx + 1, 3).alignment = right ws3.cell(idx + 1, 4).alignment = right if idx % 2 == 0: for c in range(1, len(vcols) + 1): ws3.cell(idx + 1, c).fill = ORG_FILL # ════ Sheet 4:分類業績 ════ try: cats = query_category_sales(date_str, lim=15) if cats: ws4 = wb.create_sheet('分類業績 TOP15') ccols = ['排名', '分類名稱', '業績 (NT$)', '訂單數', '佔比 %'] cwidths = [6, 28, 16, 10, 10] for i, (h, w) in enumerate(zip(ccols, cwidths), 1): ws4.cell(1, i, h) ws4.column_dimensions[get_column_letter(i)].width = w _hdr(ws4, 1, list(range(1, len(ccols) + 1))) _border_row(ws4, 1, len(ccols)) total_crev = sum(c.get('revenue', 0) for c in cats) for idx, c in enumerate(cats, 1): pct = c.get('revenue', 0) / total_crev * 100 if total_crev else 0 for col, val in enumerate([idx, c.get('cat', c.get('category','')), c.get('revenue',0), c.get('qty', c.get('orders',0)), round(pct,2)], 1): ws4.cell(idx+1, col, val) ws4.cell(idx+1, col).border = border ws4.cell(idx+1, 3).number_format = '#,##0' ws4.cell(idx+1, 5).number_format = '0.00"%"' if idx % 2 == 0: for col in range(1, len(ccols)+1): ws4.cell(idx+1, col).fill = SUB_FILL except Exception as _e: sys_log.warning(f"[Excel] 分類業績 sheet 失敗: {_e}") # ════ Sheet 5:同期比較 ════ try: cmp = query_comparison(date_str) if cmp: ws5 = wb.create_sheet('同期比較') ws5.column_dimensions['A'].width = 18 ws5.column_dimensions['B'].width = 20 ws5.column_dimensions['C'].width = 20 ws5.column_dimensions['D'].width = 12 ws5.cell(1, 1, '指標'); ws5.cell(1, 2, '本期'); ws5.cell(1, 3, '對比期'); ws5.cell(1, 4, '增減 %') _hdr(ws5, 1, [1,2,3,4]) _border_row(ws5, 1, 4) periods = ['today', 'yesterday', 'last_week', 'last_month'] labels = ['今日', '昨日', '上週同日', '上月同日'] metrics = [('業績 (NT$)', 'revenue'), ('訂單數', 'orders'), ('客單價 (NT$)', 'avg_order'), ('毛利率 (%)', 'gross_margin')] base = cmp.get('today', {}) r = 2 for period, label in zip(periods[1:], labels[1:]): comp = cmp.get(period, {}) if not comp: continue ws5.cell(r, 1, f'vs {label}').font = Font(bold=True, color='1565C0') ws5.cell(r, 1).fill = PatternFill('solid', fgColor='E3F2FD') _border_row(ws5, r, 4) r += 1 for mlabel, mkey in metrics: bv = float(base.get(mkey, 0) or 0) cv = float(comp.get(mkey, 0) or 0) pct = f"{'▲' if bv >= cv else '▼'}{abs((bv-cv)/cv*100):.1f}%" if cv else '—' ws5.cell(r, 1, mlabel); ws5.cell(r, 2, f'{bv:,.1f}') ws5.cell(r, 3, f'{cv:,.1f}'); ws5.cell(r, 4, pct) for col in range(1,5): ws5.cell(r,col).border = border ws5.cell(r,4).alignment = center r += 1 except Exception as _e: sys_log.warning(f"[Excel] 同期比較 sheet 失敗: {_e}") # ════ Sheet 6:目標達成率 ════ try: goal = get_goal_status(date_str) if goal: ws6 = wb.create_sheet('目標達成率') ws6.column_dimensions['A'].width = 16 ws6.column_dimensions['B'].width = 20 ws6.column_dimensions['C'].width = 20 ws6.column_dimensions['D'].width = 14 ws6.cell(1, 1, '週期'); ws6.cell(1, 2, '目標 (NT$)'); ws6.cell(1, 3, '實際 (NT$)'); ws6.cell(1, 4, '達成率') _hdr(ws6, 1, [1,2,3,4]) _border_row(ws6, 1, 4) r = 2 for period, label in [('daily','日'), ('monthly','月'), ('quarterly','季'), ('half','半年'), ('yearly','年')]: tgt = float(goal.get(f'{period}_target', 0) or 0) act = float(goal.get(f'{period}_actual', 0) or 0) if tgt <= 0: continue rate = act / tgt * 100 ws6.cell(r,1,label); ws6.cell(r,2,tgt); ws6.cell(r,3,act) ws6.cell(r,4,f'{rate:.1f}%') ws6.cell(r,2).number_format = '#,##0' ws6.cell(r,3).number_format = '#,##0' color = '1B5E20' if rate >= 100 else ('E65100' if rate < 70 else '1565C0') ws6.cell(r,4).font = Font(bold=True, color=color) for col in range(1,5): ws6.cell(r,col).border = border r += 1 except Exception as _e: sys_log.warning(f"[Excel] 目標達成率 sheet 失敗: {_e}") tmp = tempfile.NamedTemporaryFile( suffix=f'_{safe_date}.xlsx', prefix='momo_report_', delete=False, dir='/tmp' ) wb.save(tmp.name) sys_log.info(f"[OpenClawBot] Excel report generated: {tmp.name}") return tmp.name except ImportError: sys_log.warning("[OpenClawBot] openpyxl 未安裝,fallback CSV") except Exception as e: sys_log.error(f"[OpenClawBot] openpyxl error: {e}") # ── Fallback:CSV ────────────────────────────────────────── try: import csv tmp = tempfile.NamedTemporaryFile( suffix=f'_{safe_date}.csv', prefix='momo_report_', delete=False, dir='/tmp', mode='w', encoding='utf-8-sig', newline='' ) writer = csv.writer(tmp) writer.writerow(['業績報表', date_str, f'產生時間:{now_str}']) writer.writerow([]) if sales.get('found'): writer.writerow(['=== 業績摘要 ===']) writer.writerow(['總業績', float(sales.get('revenue', 0))]) writer.writerow(['訂單數', sales.get('orders', '-')]) writer.writerow(['商品數', sales.get('products', '-')]) writer.writerow([]) if products: writer.writerow(['=== 熱銷商品 TOP20 ===']) writer.writerow(['排名', '商品ID', '商品名稱', '業績', '數量']) for i, p in enumerate(products, 1): writer.writerow([i, p.get('id', ''), p['name'], p['revenue'], p['qty']]) writer.writerow([]) if vendors: writer.writerow(['=== 熱銷廠商 TOP10 ===']) writer.writerow(['排名', '廠商名稱', '業績']) for i, v in enumerate(vendors, 1): writer.writerow([i, v['name'], v['revenue']]) tmp.close() sys_log.info(f"[OpenClawBot] CSV fallback generated: {tmp.name}") return tmp.name except Exception as e: sys_log.error(f"[OpenClawBot] CSV fallback error: {e}") return '' # ══════════════════════════════════════════════════════════════ # v5 — 進階業績智能功能 # ══════════════════════════════════════════════════════════════ # ── 新增 DB 查詢 ────────────────────────────────────────────── def query_category_sales(date_str, lim=10): """按商品分類查業績(優先用 商品分類L1,fallback 小分類)""" d = normalize_date(date_str) for col in ('"商品分類L1"', '"商品分類L2"', '"小分類"', '"商品分類"', '"類別"', '"category"'): try: with _db().connect() as c: rows = c.execute(text(f""" SELECT COALESCE({col}, '未分類') as cat, COUNT(DISTINCT "商品ID") as products, SUM(CAST("總業績" AS FLOAT)) as revenue, SUM(CAST("數量" AS INTEGER)) as qty FROM realtime_sales_monthly WHERE "日期"=:d GROUP BY cat ORDER BY revenue DESC LIMIT :lim """), {'d': d, 'lim': lim}).fetchall() if rows: return [{'cat': r[0], 'products': r[1], 'revenue': r[2], 'qty': r[3]} for r in rows] except Exception: continue return [] def query_category_monthly(year: int, month: int, lim: int = 10) -> list: """按月份查分類業績(用 LIKE YYYY/MM/%)""" prefix = f"{year}/{month:02d}/%" for col in ('"商品分類L1"', '"商品分類L2"', '"小分類"', '"商品分類"'): try: with _db().connect() as c: rows = c.execute(text(f""" SELECT COALESCE({col}, '未分類') as cat, COUNT(DISTINCT "商品ID") as products, SUM(CAST("總業績" AS FLOAT)) as revenue, SUM(CAST("數量" AS INTEGER)) as qty FROM realtime_sales_monthly WHERE "日期" LIKE :prefix GROUP BY cat ORDER BY revenue DESC LIMIT :lim """), {'prefix': prefix, 'lim': lim}).fetchall() if rows: return [{'cat': r[0], 'products': int(r[1]), 'revenue': float(r[2]), 'qty': int(r[3])} for r in rows] except Exception: continue return [] def query_comparison(date_str): """今日 vs 上週同日 vs 上月同日""" from datetime import datetime as dt try: d = dt.strptime(normalize_date(date_str).replace('/', '-'), '%Y-%m-%d').date() lw_str = (d - timedelta(days=7)).strftime('%Y/%m/%d') lm_str = (d - timedelta(days=30)).strftime('%Y/%m/%d') def _fetch(day_s): try: with _db().connect() as c: row = c.execute(text(""" SELECT SUM(CAST("總業績" AS FLOAT)), COUNT(DISTINCT "商品ID") FROM realtime_sales_monthly WHERE "日期"=:d """), {'d': day_s}).fetchone() if row and row[0]: return {'date': day_s, 'revenue': float(row[0]), 'products': row[1]} except Exception: sys_log.exception('[OpenClawBot] trend comparison fetch failed for date=%s', day_s) return {'date': day_s, 'revenue': 0, 'products': 0} return { 'today': _fetch(normalize_date(date_str)), 'last_week': _fetch(lw_str), 'last_month': _fetch(lm_str), } except Exception as e: sys_log.error(f"[OpenClawBot] query_comparison: {e}") return None def query_daily_history(days=14): """取得近N天日業績(用於趨勢圖)""" try: with _db().connect() as c: rows = c.execute(text(f""" SELECT "日期", SUM(CAST("總業績" AS FLOAT)) as rev FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{int(days)} days' GROUP BY "日期" ORDER BY "日期" ASC """)).fetchall() return [{'date': r[0], 'revenue': float(r[1])} for r in rows if r[1]] except Exception as e: sys_log.error(f"[OpenClawBot] query_daily_history: {e}") return [] def query_restock_forecast(top_n: int = 20) -> list: """基於近7日銷售速度的補貨預測(高速+加速商品優先)""" try: with _db().connect() as c: rows = c.execute(text(""" WITH recent AS ( SELECT "商品ID", "商品名稱", SUM(CAST("數量" AS INTEGER)) AS qty7, SUM(CAST("總業績" AS FLOAT)) AS rev7, COUNT(DISTINCT "日期") AS active_days FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '7 days' GROUP BY "商品ID", "商品名稱" ), older AS ( SELECT "商品ID", COALESCE(SUM(CAST("數量" AS INTEGER)), 0) AS qty_prev7 FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CURRENT_DATE - INTERVAL '14 days' AND CURRENT_DATE - INTERVAL '8 days' GROUP BY "商品ID" ) SELECT r."商品ID", r."商品名稱", r.qty7, r.active_days, r.rev7, COALESCE(o.qty_prev7, 0) AS qty_prev7 FROM recent r LEFT JOIN older o ON r."商品ID" = o."商品ID" WHERE r.active_days >= 3 AND r.qty7 > 0 ORDER BY r.qty7 DESC LIMIT :n """), {'n': top_n}).fetchall() result = [] for row in rows: pid, name, qty7, active, rev7, qty_prev7 = row daily_vel = qty7 / 7 prev_vel = qty_prev7 / 7 if qty_prev7 else daily_vel accel = (daily_vel - prev_vel) / prev_vel * 100 if prev_vel > 0 else 0 urgency = ('HIGH' if daily_vel >= 5 and accel >= 15 else 'MED' if daily_vel >= 2 or accel >= 30 else 'LOW') result.append({ 'id': pid, 'name': name, 'daily_vel': round(daily_vel, 1), 'qty7': int(qty7), 'rev7': rev7, 'accel': round(accel, 1), 'urgency': urgency, }) return result except Exception as e: sys_log.error(f"[restock] {e}") return [] def query_category_detail(category: str, date_str: str = '', limit: int = 10) -> list: """查詢指定L1分類的L2細項業績""" try: # 決定查詢範圍:有日期查單日,否則查近7天 if date_str: d = normalize_date(date_str) where_clause = '"日期" = :d' params = {'d': d, 'lim': limit, 'cat': category} else: where_clause = 'CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL \'7 days\'' params = {'lim': limit, 'cat': category} for l2_col in ('"商品分類L2"', '"小分類"', '"商品分類"'): try: with _db().connect() as c: rows = c.execute(text(f""" SELECT COALESCE({l2_col}, '其他') AS sub_cat, COUNT(DISTINCT "商品ID") AS products, SUM(CAST("總業績" AS FLOAT)) AS revenue, SUM(CAST("數量" AS INTEGER)) AS qty FROM realtime_sales_monthly WHERE "商品分類L1" = :cat AND {where_clause} GROUP BY sub_cat ORDER BY revenue DESC LIMIT :lim """), params).fetchall() if rows: return [{'cat': r[0], 'products': r[1], 'revenue': float(r[2]), 'qty': int(r[3])} for r in rows] except Exception: continue return [] except Exception as e: sys_log.error(f"[category_detail] {e}") return [] def query_promo_comparison(start_str: str, end_str: str) -> dict: """查詢促銷期間 vs 前同天數業績比較""" try: from datetime import datetime as _dt start = _dt.strptime(start_str.replace('/', '-'), '%Y-%m-%d').date() end = _dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d').date() days = (end - start).days + 1 # 前期:同等天數 from datetime import timedelta as _td pre_end = start - _td(days=1) pre_start = pre_end - _td(days=days - 1) def _fetch_period(s, e): try: 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") FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) """), {'s': str(s), 'e': str(e)}).fetchone() tops = c.execute(text(""" SELECT "商品名稱", SUM(CAST("總業績" AS FLOAT)) AS rev FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY "商品名稱" ORDER BY rev DESC LIMIT 5 """), {'s': str(s), 'e': str(e)}).fetchall() orders, rev, cost, prods = row margin = (rev - cost) / rev * 100 if rev else 0 return { 'start': str(s), 'end': str(e), 'days': days, 'orders': int(orders or 0), 'revenue': float(rev), 'margin': round(margin, 1), 'products': int(prods or 0), 'top_products': [{'name': r[0], 'revenue': float(r[1])} for r in tops], } except Exception as ex: sys_log.error(f"[promo_cmp] {ex}") return {'start': str(s), 'end': str(e), 'days': days, 'orders': 0, 'revenue': 0, 'margin': 0, 'products': 0, 'top_products': []} promo = _fetch_period(start, end) pre = _fetch_period(pre_start, pre_end) rev_lift = (promo['revenue'] - pre['revenue']) / pre['revenue'] * 100 if pre['revenue'] else 0 ord_lift = (promo['orders'] - pre['orders']) / pre['orders'] * 100 if pre['orders'] else 0 return { 'promo': promo, 'pre': pre, 'rev_lift': round(rev_lift, 1), 'ord_lift': round(ord_lift, 1), } except Exception as e: sys_log.error(f"[promo_comparison] {e}") return {} def query_anomalies(date_str): """偵測當日業績異常商品(vs 7日均值,偏差>30%)""" d = normalize_date(date_str) try: with _db().connect() as c: rows = c.execute(text(""" WITH today AS ( SELECT "商品ID", "商品名稱", SUM(CAST("總業績" AS FLOAT)) AS today_rev FROM realtime_sales_monthly WHERE "日期"=:d GROUP BY "商品ID", "商品名稱" ), avg7 AS ( SELECT "商品ID", AVG(day_rev) AS avg_rev FROM ( SELECT "商品ID", "日期", SUM(CAST("總業績" AS FLOAT)) AS day_rev FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:d AS DATE) - INTERVAL '7 days' AND CAST(:d AS DATE) - INTERVAL '1 day' GROUP BY "商品ID", "日期" ) sub GROUP BY "商品ID" ) SELECT t."商品ID", t."商品名稱", t.today_rev, a.avg_rev, (t.today_rev - a.avg_rev) / NULLIF(a.avg_rev, 0) * 100 AS pct FROM today t JOIN avg7 a ON t."商品ID" = a."商品ID" WHERE ABS((t.today_rev - a.avg_rev) / NULLIF(a.avg_rev, 0)) > 0.3 ORDER BY ABS((t.today_rev - a.avg_rev) / NULLIF(a.avg_rev, 0)) DESC LIMIT 10 """), {'d': d}).fetchall() return [{'id': r[0], 'name': r[1], 'today': r[2], 'avg7': r[3], 'pct': r[4]} for r in rows] except Exception as e: sys_log.error(f"[OpenClawBot] query_anomalies: {e}") return [] # ── 目標管理 ────────────────────────────────────────────────── def get_goal_status(date_str: str) -> dict: d = normalize_date(date_str) sales = query_sales(date_str) today_rev = float(sales.get('revenue', 0)) if sales.get('found') else 0.0 # 月業績 try: month_prefix = d[:7] # e.g. '2026/04' with _db().connect() as c: row = c.execute(text(""" SELECT SUM(CAST("總業績" AS FLOAT)) FROM realtime_sales_monthly WHERE "日期" LIKE :prefix """), {'prefix': f"{month_prefix}%"}).fetchone() month_rev = float(row[0]) if row and row[0] else 0.0 except Exception: month_rev = 0.0 daily_goal = _GOALS.get('daily', 0) monthly_goal = _GOALS.get('monthly', 0) quarterly_goal = _GOALS.get('quarterly', 0) half_goal = _GOALS.get('half', 0) yearly_goal = _GOALS.get('yearly', 0) # P9 — 週目標自動推算:若未手動設定,從月目標 ÷ 4.3 推算 weekly_goal = _GOALS.get('weekly', 0) if not weekly_goal and monthly_goal: weekly_goal = round(monthly_goal / 4.3) # 季/半年/年業績 year_s = d[:4] try: with _db().connect() as c: row_y = c.execute(text(""" SELECT SUM(CAST("總業績" AS FLOAT)) FROM realtime_sales_monthly WHERE "日期" LIKE :y """), {'y': f"{year_s}/%"}).fetchone() year_rev = float(row_y[0]) if row_y and row_y[0] else 0.0 except Exception: year_rev = 0.0 # 近7日業績(週目標達成率用) try: weekly_rows = query_weekly_trend() week_rev = sum(w['revenue'] for w in weekly_rows) if weekly_rows else 0.0 except Exception: week_rev = 0.0 return { 'date': d, 'today_rev': today_rev, 'daily_goal': daily_goal, 'daily_pct': today_rev / daily_goal * 100 if daily_goal > 0 else None, 'week_rev': week_rev, 'weekly_goal': weekly_goal, 'weekly_pct': week_rev / weekly_goal * 100 if weekly_goal > 0 else None, 'weekly_auto': not bool(_GOALS.get('weekly', 0)) and bool(monthly_goal), # 是否自動推算 'month_rev': month_rev, 'monthly_goal': monthly_goal, 'monthly_pct': month_rev / monthly_goal * 100 if monthly_goal > 0 else None, 'year_rev': year_rev, 'quarterly_goal': quarterly_goal, 'quarterly_pct': year_rev / 4 / quarterly_goal * 100 if quarterly_goal > 0 else None, 'half_goal': half_goal, 'half_pct': year_rev / 2 / half_goal * 100 if half_goal > 0 else None, 'yearly_goal': yearly_goal, 'yearly_pct': year_rev / yearly_goal * 100 if yearly_goal > 0 else None, # 月目標追蹤輔助欄位 'days_elapsed': int(d[8:10]) if len(d) >= 10 else 0, 'days_in_month': __import__('calendar').monthrange(int(d[:4]), int(d[5:7]))[1] if len(d) >= 7 else 30, } # ── 圖表產生(matplotlib)──────────────────────────────────── _MPL_FONT_SETUP_DONE = False def _setup_mpl_chinese(): """確保 matplotlib 使用中文字型(只執行一次)""" global _MPL_FONT_SETUP_DONE if _MPL_FONT_SETUP_DONE: return try: import matplotlib.font_manager as fm import matplotlib as mpl font_paths = [ '/usr/local/lib/python3.11/site-packages/matplotlib/mpl-data/fonts/ttf/WQYZenHei.ttf', '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', ] for fp in font_paths: if os.path.exists(fp): fm.fontManager.addfont(fp) prop = fm.FontProperties(fname=fp) mpl.rcParams['font.family'] = prop.get_name() mpl.rcParams['axes.unicode_minus'] = False _MPL_FONT_SETUP_DONE = True sys_log.info(f"[OpenClawBot] matplotlib 中文字型: {fp}") return sys_log.warning("[OpenClawBot] 找不到中文字型,圖表文字可能亂碼") except Exception as e: sys_log.warning(f"[OpenClawBot] _setup_mpl_chinese: {e}") def gen_trend_chart(days=14, data_points=None, title=None) -> str: """產生業績趨勢折線圖 PNG — 上升紅色/下降綠色,每日金額標註 data_points: 若提供則使用此資料 [{'date': ..., 'revenue': ...}],否則查近 days 日 title: 自訂圖表標題 """ try: import matplotlib matplotlib.use('Agg') _setup_mpl_chinese() import matplotlib.pyplot as plt import matplotlib.dates as mdates import matplotlib.patches as mpatches from datetime import datetime as dt import tempfile history = data_points if data_points else query_daily_history(days) if not history: return '' dates = [dt.strptime(r['date'].replace('/', '-'), '%Y-%m-%d') for r in history] revenues = [r['revenue'] / 10000 for r in history] # 萬元 # 台灣股市慣例:上升=紅,下降=綠 UP_COLOR = '#E53935' # 上升 紅 DOWN_COLOR = '#43A047' # 下降 綠 NEUTRAL = '#1565C0' # 第一點 藍 fig, ax = plt.subplots(figsize=(14, 6)) fig.patch.set_facecolor('#FAFAFA') ax.set_facecolor('#FAFAFA') # 逐段繪製(每段依漲跌著色) for i in range(1, len(dates)): seg_color = UP_COLOR if revenues[i] >= revenues[i - 1] else DOWN_COLOR ax.plot([dates[i - 1], dates[i]], [revenues[i - 1], revenues[i]], '-', color=seg_color, linewidth=2.8, solid_capstyle='round') # 每日節點(顏色依和前日比較) for i, (d, r) in enumerate(zip(dates, revenues)): if i == 0: pt_color = NEUTRAL elif r >= revenues[i - 1]: pt_color = UP_COLOR else: pt_color = DOWN_COLOR ax.plot(d, r, 'o', color=pt_color, markersize=8, zorder=5, markeredgecolor='white', markeredgewidth=1.5) # 每日金額標籤 for i, (d, r) in enumerate(zip(dates, revenues)): # 決定標籤位置(奇偶交錯避免重疊) y_offset = 12 if i % 2 == 0 else -22 ha = 'center' if i == 0: label_color = NEUTRAL elif r >= revenues[i - 1]: label_color = UP_COLOR else: label_color = DOWN_COLOR ax.annotate(f'NT${r:,.1f}萬', (d, r), textcoords='offset points', xytext=(0, y_offset), ha=ha, fontsize=8.5, color=label_color, fontweight='bold', bbox=dict(boxstyle='round,pad=0.2', facecolor='white', edgecolor=label_color, alpha=0.85, linewidth=0.8)) # 全域最高/最低特別標記 if revenues: max_i = revenues.index(max(revenues)) min_i = revenues.index(min(revenues)) ax.scatter([dates[max_i]], [revenues[max_i]], s=180, color=UP_COLOR, zorder=6, marker='*', edgecolors='white', linewidths=0.8) ax.scatter([dates[min_i]], [revenues[min_i]], s=180, color=DOWN_COLOR, zorder=6, marker='v', edgecolors='white', linewidths=0.8) # X 軸:日期 + 星期 WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d')) ax.xaxis.set_major_locator(mdates.DayLocator(interval=1)) # 替換 x tick labels 加上星期 tick_dates = dates tick_labels = [f"{d.strftime('%m/%d')}\n{WEEKDAYS_ZH[d.weekday()]}" for d in tick_dates] ax.set_xticks(tick_dates) ax.set_xticklabels(tick_labels, fontsize=9) ax.set_ylabel('業績(萬元)', fontsize=12) today_str = datetime.now(TAIPEI_TZ).strftime('%Y/%m/%d') chart_title = title or f'業績趨勢走勢圖 — 近 {days} 日 (截至 {today_str})' ax.set_title(chart_title, fontsize=14, fontweight='bold', pad=14) ax.grid(True, alpha=0.25, linestyle='--', color='#BDBDBD') ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:,.0f}')) # 圖例 up_patch = mpatches.Patch(color=UP_COLOR, label='▲ 較前日上升') down_patch = mpatches.Patch(color=DOWN_COLOR, label='▼ 較前日下降') ax.legend(handles=[up_patch, down_patch], loc='upper left', fontsize=10, framealpha=0.85) # 統計副標題 if len(revenues) >= 2: total_rev = sum(revenues) avg_rev = total_rev / len(revenues) max_rev = max(revenues) min_rev = min(revenues) last_change_pct = (revenues[-1] - revenues[-2]) / revenues[-2] * 100 if revenues[-2] else 0 arrow = '▲' if last_change_pct >= 0 else '▼' stats_text = (f"總計 NT${total_rev:,.1f}萬 | " f"日均 NT${avg_rev:,.1f}萬 | " f"最高 NT${max_rev:,.1f}萬 | 最低 NT${min_rev:,.1f}萬 | " f"昨日 {arrow}{abs(last_change_pct):.1f}%") fig.text(0.5, 0.01, stats_text, ha='center', fontsize=9, color='#616161', style='italic') plt.tight_layout(rect=[0, 0.04, 1, 1]) tmp = tempfile.NamedTemporaryFile( suffix='.png', prefix='ocbot_trend_', delete=False, dir='/tmp') plt.savefig(tmp.name, dpi=130, bbox_inches='tight') plt.close() return tmp.name except ImportError: sys_log.warning('[OpenClawBot] matplotlib not installed — chart disabled') return '' except Exception as e: sys_log.error(f'[OpenClawBot] gen_trend_chart: {e}') return '' def gen_products_chart(date_str, n=10) -> str: """產生熱銷商品橫條圖 PNG — 上升紅/下降綠,標示業績金額""" try: import matplotlib matplotlib.use('Agg') _setup_mpl_chinese() import matplotlib.pyplot as plt import matplotlib.patches as mpatches from datetime import datetime as dt import tempfile products = query_top_products(date_str, n) if not products: return '' # 取得上週同日資料做顏色比較 try: d = dt.strptime(date_str.replace('/', '-'), '%Y-%m-%d').date() lw_str = (d - timedelta(days=7)).strftime('%Y/%m/%d') lw_products = query_top_products(lw_str, n * 2) lw_map = {p.get('id'): p['revenue'] for p in lw_products if p.get('id')} except Exception: lw_map = {} UP_COLOR = '#E53935' # 上升/優 紅 DOWN_COLOR = '#43A047' # 下降 綠 NEUTRAL = '#1976D2' # 無前期資料 藍 # 短名稱(限14字)+ ID labels = [f"{p['name'][:13]}…\n[{_short_id(p.get('id',''))}]" if len(p['name']) > 13 else f"{p['name']}\n[{_short_id(p.get('id',''))}]" for p in products] revenues = [p['revenue'] / 10000 for p in products] # 決定每條顏色 colors = [] for p in products: pid = p.get('id') lw_rev = lw_map.get(pid) if lw_rev is None: colors.append(NEUTRAL) elif p['revenue'] >= lw_rev: colors.append(UP_COLOR) else: colors.append(DOWN_COLOR) # 翻轉(matplotlib barh 從下往上) labels.reverse(); revenues.reverse(); colors.reverse() fig, ax = plt.subplots(figsize=(13, max(6, n * 0.75))) fig.patch.set_facecolor('#FAFAFA') ax.set_facecolor('#FAFAFA') bars = ax.barh(labels, revenues, color=colors, alpha=0.88, height=0.62, edgecolor='white', linewidth=0.8) max_rev = max(revenues) if revenues else 1 for bar, rev, col in zip(bars, revenues, colors): # 金額標籤(在 bar 右側) ax.text(bar.get_width() + max_rev * 0.008, bar.get_y() + bar.get_height() / 2, f'NT${rev:,.2f}萬', va='center', ha='left', fontsize=9.5, color=col, fontweight='bold') ax.set_xlabel('業績(萬元)', fontsize=11) ax.set_title(f'🏆 熱銷商品 TOP{n} — {date_str}', fontsize=14, fontweight='bold', pad=12) ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:,.0f}')) ax.grid(True, alpha=0.25, linestyle='--', color='#BDBDBD', axis='x') # 設定 xlim 留右側空間給標籤 ax.set_xlim(0, max_rev * 1.35) # 圖例 up_p = mpatches.Patch(color=UP_COLOR, label='▲ 週環比上升') dn_p = mpatches.Patch(color=DOWN_COLOR, label='▼ 週環比下降') ne_p = mpatches.Patch(color=NEUTRAL, label='— 無前期資料') ax.legend(handles=[up_p, dn_p, ne_p], loc='lower right', fontsize=9, framealpha=0.85) plt.tight_layout() tmp = tempfile.NamedTemporaryFile( suffix='.png', prefix='ocbot_products_', delete=False, dir='/tmp') plt.savefig(tmp.name, dpi=130, bbox_inches='tight') plt.close() return tmp.name except ImportError: return '' except Exception as e: sys_log.error(f'[OpenClawBot] gen_products_chart: {e}') return '' # ── 商品策略矩陣 ─────────────────────────────────────────────── def analyze_product_strategy(date_str: str, top_n=10) -> list: """內部業績週環比 × 外部MCP趨勢 → 策略標籤""" products = query_top_products(date_str, top_n * 2) if not products: return [] from datetime import datetime as dt try: d = dt.strptime(normalize_date(date_str).replace('/', '-'), '%Y-%m-%d').date() lw_str = (d - timedelta(days=7)).strftime('%Y/%m/%d') lw_products = query_top_products(lw_str, top_n * 2) lw_map = {p.get('id'): p['revenue'] for p in lw_products if p.get('id')} except Exception: lw_map = {} try: from services.mcp_context_service import get_taiwan_trends trend_kws = [t['keyword'] for t in get_taiwan_trends().get('trends', [])][:20] except Exception: trend_kws = [] result = [] for p in products[:top_n]: pid, name, rev = p.get('id', ''), p['name'], p['revenue'] lw_rev = lw_map.get(pid, 0) growth = (rev - lw_rev) / lw_rev if lw_rev > 0 else 0 ext_hot = any(kw in name for kw in trend_kws) if trend_kws else None if growth > 0.1 and (ext_hot or ext_hot is None): tag, strat, advice = '🔥', '加碼', '內外皆熱,立即加大廣告投放' elif growth >= -0.1 and ext_hot: tag, strat, advice = '💡', '機會', '外部熱搜但放量不足,提升曝光' elif growth > 0.1 and not ext_hot: tag, strat, advice = '⚡', '收割', '內部增長強,外部降溫前先收割' elif growth < -0.1 and not ext_hot: tag, strat, advice = '⚠️', '觀察', '內外皆疲,考慮轉移資源' else: tag, strat, advice = '✅', '持穩', '表現平穩,維持現狀' result.append({ 'id': pid, 'name': name, 'revenue': rev, 'growth': growth, 'ext_hot': ext_hot, 'tag': tag, 'strategy': strat, 'advice': advice, }) return result def _analyze_strategy_range(start_str: str, end_str: str, products: list) -> list: """區間版策略分析(週/月/季/年用):比對前一等長區間的成長率""" if not products: return [] try: s = datetime.strptime(start_str.replace('/', '-'), '%Y-%m-%d') e = datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d') delta = (e - s).days + 1 prev_end = (s - timedelta(days=1)).strftime('%Y/%m/%d') prev_start = (s - timedelta(days=delta)).strftime('%Y/%m/%d') prev_prods = query_top_products_range(prev_start, prev_end, len(products) * 2) prev_map = {p.get('id'): p['revenue'] for p in prev_prods if p.get('id')} except Exception: prev_map = {} try: from services.mcp_context_service import get_taiwan_trends trend_kws = [t['keyword'] for t in get_taiwan_trends().get('trends', [])][:20] except Exception: trend_kws = [] result = [] for p in products: pid, name, rev = p.get('id', ''), p['name'], p['revenue'] prev_rev = prev_map.get(pid, 0) growth = (rev - prev_rev) / prev_rev if prev_rev > 0 else 0 ext_hot = any(kw in name for kw in trend_kws) if trend_kws else None if growth > 0.1 and (ext_hot or ext_hot is None): tag, strat, advice = '🔥', '加碼', '內外皆熱,立即加大廣告投放' elif growth >= -0.1 and ext_hot: tag, strat, advice = '💡', '機會', '外部熱搜但放量不足,提升曝光' elif growth > 0.1 and not ext_hot: tag, strat, advice = '⚡', '收割', '內部增長強,外部降溫前先收割' elif growth < -0.1 and not ext_hot: tag, strat, advice = '⚠️', '觀察', '內外皆疲,考慮轉移資源' else: tag, strat, advice = '✅', '持穩', '表現平穩,維持現狀' result.append({ 'id': pid, 'name': name, 'revenue': rev, 'growth': growth, 'ext_hot': ext_hot, 'tag': tag, 'strategy': strat, 'advice': advice, }) return result # ── 新增格式化函數 ───────────────────────────────────────────── def fmt_category(cats, date_str): if not cats: return (f"⚠️ *分類資料不足*\n\n" f"`{date_str}` 無分類業績資料\n" f"_請確認資料是否包含「商品分類」欄位_") total = sum(c['revenue'] for c in cats) lines = [ f"🗂 *{date_str} 分類業績分析*", f"合計 `NT$ {total:,.0f}` | 共 {len(cats)} 個分類", f"{'─' * 30}", "", ] for i, c in enumerate(cats): rev = c['revenue'] pct = rev / total * 100 if total > 0 else 0 medal = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`" bar_len = max(1, int(pct / 100 * 10)) mini_bar = '█' * bar_len + '░' * (10 - bar_len) lines.append(f"{medal} *{c['cat']}*") lines.append(f" 💰 `NT$ {rev:,.0f}` 🛍 {c['products']}款") lines.append(f" `{mini_bar}` 佔比 *{pct:.1f}%*") lines.append("") # 前3名佔比 top3_pct = sum(c['revenue'] for c in cats[:3]) / total * 100 if total else 0 lines.append(f"_📌 前3類合計佔比:{top3_pct:.1f}%_") return "\n".join(lines) def fmt_comparison(data, date_str): if not data: return "⚠️ *無法取得比較資料*\n\n請確認歷史資料已匯入。" def _delta_str(a, b): if not b: return '`—`' pct = (a - b) / b * 100 arrow = '▲' if pct >= 0 else '▼' emoji = '🟢' if pct >= 0 else '🔴' return f"{emoji} `{arrow}{abs(pct):.1f}%` (`NT$ {a - b:+,.0f}`)" today = data.get('today', {}) lw = data.get('last_week', {}) lm = data.get('last_month', {}) r_t = today.get('revenue', 0) r_lw = lw.get('revenue', 0) r_lm = lm.get('revenue', 0) lines = [ f"🔄 *{date_str} 業績同期比較*", f"{'─' * 30}", "", f"📅 *本日業績* `NT$ {r_t:,.0f}`", "", ] if r_lw: lines.append(f"📌 *vs 上週同日*({lw['date']})") lines.append(f" 上週:`NT$ {r_lw:,.0f}`") lines.append(f" 差異:{_delta_str(r_t, r_lw)}") lines.append("") if r_lm: lines.append(f"📌 *vs 上月同日*({lm['date']})") lines.append(f" 上月:`NT$ {r_lm:,.0f}`") lines.append(f" 差異:{_delta_str(r_t, r_lm)}") lines.append("") # 趨勢判斷 if r_lw and r_lm: both_up = r_t >= r_lw and r_t >= r_lm both_down = r_t < r_lw and r_t < r_lm if both_up: lines.append("✅ *整體趨勢*:本日業績優於上週及上月同期,表現亮眼!") elif both_down: lines.append("⚠️ *整體趨勢*:本日業績低於上週及上月同期,需關注原因。") else: lines.append("📊 *整體趨勢*:表現不一,建議深入分析商品結構。") return "\n".join(lines) def fmt_goal_status(status): if not status: return "⚠️ *無法取得目標資料*" rev = status.get('today_rev', 0) or 0 dg = status.get('daily_goal', 0) or 0 mg = status.get('monthly_goal', 0) or 0 lines = [ f"🎯 *目標達成率* _({status.get('date','')})_", f"{'─' * 30}", "", ] lines.append(f"💰 *今日業績* `NT$ {rev:,.0f}`") lines.append("") if dg: pct = status.get('daily_pct', 0) or 0 gap = dg - rev bar_len = min(10, max(0, int(pct // 10))) bar = '█' * bar_len + '░' * (10 - bar_len) # 顏色 emoji if pct >= 100: status_emoji = '🏆' status_text = f"*超標達成!* 🎉 超出 `NT$ {abs(gap):,.0f}`" elif pct >= 80: status_emoji = '🟢' status_text = f"接近達標 還差 `NT$ {gap:,.0f}`" elif pct >= 50: status_emoji = '🟡' status_text = f"進度落後 還差 `NT$ {gap:,.0f}`" else: status_emoji = '🔴' status_text = f"需要加速 還差 `NT$ {gap:,.0f}`" lines.append(f"📌 *日目標* `NT$ {dg:,.0f}`") lines.append(f" `[{bar}]` *{pct:.1f}%* {status_emoji}") lines.append(f" {status_text}") lines.append("") else: lines.append("_⚙️ 尚未設定日目標_") lines.append("_用 `/goal 200000` 設定每日目標_") lines.append("") def _goal_bar(pct, goal, actual, label, emoji): if not goal: return None gap = goal - actual bar_len = min(10, max(0, int(pct // 10))) bar = '█' * bar_len + '░' * (10 - bar_len) suffix = f" 🎉 超標 `NT$ {abs(gap):,.0f}`" if gap <= 0 else f" 還差 `NT$ {gap:,.0f}`" return [ f"{emoji} *{label}* `NT$ {goal:,.0f}` → 已達 `NT$ {actual:,.0f}`", f" `[{bar}]` *{pct:.1f}%*{suffix}", ] # 週目標(P9) wg = status.get('weekly_goal', 0) or 0 if wg: week_rows = _goal_bar(status.get('weekly_pct') or 0, wg, status.get('week_rev', 0) or 0, '週目標', '📅') auto_tag = ' _(自動推算)_' if status.get('weekly_auto') else '' if week_rows: week_rows[0] += auto_tag lines += week_rows lines.append("") for rows in [ _goal_bar(status.get('monthly_pct') or 0, mg, status.get('month_rev', 0) or 0, '月目標', '📅'), _goal_bar(status.get('quarterly_pct') or 0, status.get('quarterly_goal', 0) or 0, (status.get('year_rev', 0) or 0) / 4, '季目標', '📆'), _goal_bar(status.get('half_pct') or 0, status.get('half_goal', 0) or 0, (status.get('year_rev', 0) or 0) / 2, '半年目標', '🗓'), _goal_bar(status.get('yearly_pct') or 0, status.get('yearly_goal', 0) or 0, status.get('year_rev', 0) or 0, '年度目標', '🏁'), ]: if rows: lines += rows lines.append("") # ── 月目標倒計時 ───────────────────────────────────────── if mg: month_rev = status.get('month_rev', 0) or 0 days_elapsed = status.get('days_elapsed', 0) or 0 days_total = status.get('days_in_month', 30) or 30 days_remain = days_total - days_elapsed if days_remain > 0 and month_rev < mg: needed_daily = (mg - month_rev) / days_remain gap_pct = (mg - month_rev) / mg * 100 lines.append(f"{'─' * 26}") lines.append(f"📆 *月目標倒計時*") lines.append(f" 已過 {days_elapsed} 天 / 剩 {days_remain} 天") lines.append(f" 還差 `NT$ {mg - month_rev:,.0f}`({gap_pct:.0f}%)") lines.append(f" ✅ 每日至少需達 `NT$ {needed_daily:,.0f}`") if days_elapsed > 0: actual_daily = month_rev / days_elapsed diff = actual_daily - needed_daily if diff >= 0: lines.append(f" 📈 日均 `NT$ {actual_daily:,.0f}` — *超標 +`NT$ {diff:,.0f}`*") else: lines.append(f" 📉 日均 `NT$ {actual_daily:,.0f}` — 落後 `NT$ {abs(diff):,.0f}`") elif days_remain > 0 and month_rev >= mg: lines.append(f"🎉 *月目標已達成!* 超出 `NT$ {month_rev - mg:,.0f}`,剩 {days_remain} 天繼續衝!") if not any([mg, status.get('quarterly_goal'), status.get('half_goal'), status.get('yearly_goal')]): lines.append("_⚙️ 尚未設定長期目標_") lines.append("_點選 🎯 目標管理 → 設定各週期目標_") return "\n".join(lines) def _short_id(pid: str) -> str: """DDABGC-A900H854P-000 → A900H854P(取中段,較易讀)""" parts = str(pid).split('-') return parts[1] if len(parts) >= 3 else str(pid)[:12] def fmt_restock_forecast(items: list) -> str: """補貨預測報告格式化""" if not items: return "⚠️ *補貨預測*\n\n暫無足夠銷售資料(需 3 天以上紀錄)" high = [i for i in items if i['urgency'] == 'HIGH'] med = [i for i in items if i['urgency'] == 'MED'] low = [i for i in items if i['urgency'] == 'LOW'] lines = [ "📦 *補貨預測報告*", f"{'─' * 26}", f"_基於近 7 日銷售速度分析_", "", ] if high: lines.append("🔴 *緊急補貨(高銷速 + 加速中)*") for i in high[:6]: accel_s = f"↑{i['accel']:.0f}%" if i['accel'] > 0 else f"↓{abs(i['accel']):.0f}%" lines.append( f" 🔥 *{i['name'][:22]}*\n" f" 日均 {i['daily_vel']:.1f} 件 趨勢 {accel_s} 週業績 `NT$ {i['rev7']:,.0f}`" ) lines.append("") if med: lines.append("🟡 *建議補貨(中速 or 快速加速)*") for i in med[:5]: accel_s = f"↑{i['accel']:.0f}%" if i['accel'] > 0 else f"↓{abs(i['accel']):.0f}%" lines.append(f" ⚡ *{i['name'][:22]}* 日均 {i['daily_vel']:.1f} 件 {accel_s}") lines.append("") if low: lines.append(f"🟢 *持續追蹤({len(low)} 件)* 銷售平穩,暫無急迫性") lines.append("") lines.append("_💡 高速商品建議備貨 ≥ 14 日量;中速備貨 ≥ 7 日_") return "\n".join(lines) def fmt_category_detail(category: str, items: list, date_label: str = '') -> str: """分類業績鑽取格式化""" if not items: return f"⚠️ *{category}* 分類無細項資料" total_rev = sum(i['revenue'] for i in items) period = f" _{date_label}_" if date_label else '' lines = [ f"🗂 *{category} 分類業績*{period}", f"{'─' * 26}", f"共 {len(items)} 個子類 合計 `NT$ {total_rev:,.0f}`", "", ] medals = ['🥇','🥈','🥉','④','⑤','⑥','⑦','⑧','⑨','⑩'] for i, item in enumerate(items[:10]): pct = item['revenue'] / total_rev * 100 if total_rev else 0 bar_len = min(8, max(1, int(pct / 12.5))) bar = '█' * bar_len + '░' * (8 - bar_len) medal = medals[i] if i < 10 else str(i + 1) lines.append( f" {medal} *{item['cat']}*\n" f" `{bar}` {pct:.1f}% `NT$ {item['revenue']:,.0f}` {item['products']}商品 {item['qty']}件" ) return "\n".join(lines) def fmt_promo_comparison(data: dict, label: str = '') -> str: """促銷效果比較格式化""" if not data or not data.get('promo'): return "⚠️ *促銷比較*\n\n查無資料,請確認日期範圍" promo = data['promo'] pre = data['pre'] rev_lift = data.get('rev_lift', 0) ord_lift = data.get('ord_lift', 0) lift_emoji = '📈' if rev_lift >= 0 else '📉' lines = [ f"🎉 *促銷活動效益分析*", f"{'─' * 26}", f"活動期間:`{promo['start']}` ~ `{promo['end']}`({promo['days']}天)", f"對比前期:`{pre['start']}` ~ `{pre['end']}`", "", f"{'─' * 26}", f"💰 *業績比較*", f" 活動期:`NT$ {promo['revenue']:,.0f}` 訂單 {promo['orders']} 筆", f" 前同期:`NT$ {pre['revenue']:,.0f}` 訂單 {pre['orders']} 筆", f" {lift_emoji} 業績成長 *{rev_lift:+.1f}%* 訂單成長 *{ord_lift:+.1f}%*", "", ] if promo.get('top_products'): lines.append("🏆 *活動期間熱銷 TOP5*") for i, p in enumerate(promo['top_products'][:5]): medals = ['🥇','🥈','🥉','④','⑤'] lines.append(f" {medals[i]} {p['name'][:22]} `NT$ {p['revenue']:,.0f}`") lines.append("") # 效益評估 if rev_lift >= 20: lines.append("✅ *效益優良!* 建議複製此促銷模式") elif rev_lift >= 5: lines.append("🟡 *效益普通* 可調整折扣力度或品項") else: lines.append("🔴 *效益不佳* 建議檢視促銷定價與商品組合") return "\n".join(lines) def track_competitor_price_changes(results: list) -> list: """追蹤 momo 競品價格變動,回傳降價警報清單""" if not results: return [] try: import redis as _redis r = _redis.Redis(host='localhost', port=6379, db=12, socket_connect_timeout=2) r.ping() changes = [] for item in results: momo_id = str(item.get('momo_icode') or item.get('momo_id') or item.get('id', '')) if not momo_id: continue curr_price = float(item.get('momo_price') or 0) if curr_price <= 0: continue key = f"price_hist:{momo_id}" prev_raw = r.get(key) if prev_raw: prev_price = float(prev_raw) if prev_price > 0: pct = (curr_price - prev_price) / prev_price * 100 if pct <= -5: # momo 降價 ≥5% → PChome 需注意 changes.append({ 'name': item.get('momo_name', '')[:28], 'prev_price': prev_price, 'curr_price': curr_price, 'pct': round(pct, 1), 'momo_id': momo_id, }) # 更新快照(保留 8 天) r.setex(key, 8 * 86400, str(curr_price)) return changes except Exception as _e: sys_log.warning(f"[price_track] Redis 不可用:{_e}") return [] def fmt_monthly(ms: dict) -> str: """月份業績格式化""" if not ms.get('found'): return (f"⚠️ *{ms.get('month','?')} 月份無業績資料*\n\n" f"此月份資料尚未匯入,或超出資料範圍。\n" f"_使用 /history 查看所有可用月份_") month = ms['month'] rev = ms['revenue'] orders = ms['orders'] days = ms['days_with_data'] avg_d = rev / days if days else 0 avg_o = ms['avg_order'] margin = ms['gross_margin'] # 月進度 bar(以最高日為基準) daily = ms.get('daily', []) max_d_rev = max((d['revenue'] for d in daily), default=1) WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] lines = [ f"📅 *{month} 月份業績報告*", f"{'─' * 30}", "", f"💰 *月業績* `NT$ {rev:,.0f}`", f"📦 月訂單 `{orders:,}` 筆 | 有資料 `{days}` 天", f"🛒 日均業績 `NT$ {avg_d:,.0f}`", f"🛒 客單均價 `NT$ {avg_o:,.0f}`", f"📈 整月毛利率 `{margin:.1f}%`", "", ] if daily: lines.append(f"📊 *逐日業績*") for i, d in enumerate(daily): bar_len = max(1, int(d['revenue'] / max_d_rev * 8)) bar = '█' * bar_len + '·' * (8 - bar_len) # 漲跌 if i > 0: prev = daily[i - 1]['revenue'] chg = (d['revenue'] - prev) / prev * 100 if prev else 0 chg_str = f" {'▲' if chg >= 0 else '▼'}{abs(chg):.0f}%" else: chg_str = '' try: from datetime import datetime as dt d_obj = dt.strptime(d['date'].replace('/', '-'), '%Y-%m-%d') wday = WEEKDAYS_ZH[d_obj.weekday()] except Exception: wday = '' lines.append( f" `{d['date']}` {wday} `{bar}` `NT$ {d['revenue']:>10,.0f}`{chg_str}" ) lines.append("") if ms.get('top_products'): lines.append(f"🏆 *月熱銷 TOP10*") total_rev = rev for i, p in enumerate(ms['top_products'][:10]): medal = MEDALS[i] if i < len(MEDALS) else f"{i+1}." pid = p.get('id', '') or '' sid = _short_id(pid) link = _pchome_link(pid, p['name'], 22) pct = p['revenue'] / total_rev * 100 if total_rev else 0 lines.append(f" {medal} {link}") lines.append(f" 🆔 `{sid}` 💰 `NT$ {p['revenue']:,.0f}` 佔 {pct:.1f}% 📦{p.get('qty',0)}件") lines.append("") if ms.get('top_vendors'): lines.append(f"🏭 *月熱銷廠商 TOP10*") for i, v in enumerate(ms['top_vendors'][:10]): medal = MEDALS[i] if i < len(MEDALS) else f"{i+1}." pct = v['revenue'] / rev * 100 if rev else 0 lines.append(f" {medal} {_esc(v['name'][:22])} `NT$ {v['revenue']:,.0f}` {pct:.1f}%") return "\n".join(lines) def fmt_strategy(items, date_str=''): if not items: return "⚠️ *暫無策略資料*\n\n請確認商品業績資料已匯入。" GROUP_META = { '加碼': ('🔥', '加碼爆量', '內外皆熱 → 立刻加大廣告預算、補庫存', '#FF5722'), '機會': ('💡', '把握機會', '外部熱搜 → 快速提升曝光,搶奪市佔', '#2196F3'), '收割': ('⚡', '趁勢收割', '內部增長 → 鎖定高毛利,衝刺轉換率', '#9C27B0'), '觀察': ('⚠️', '謹慎觀察', '內外皆疲 → 停止加碼,評估庫存去化', '#FF9800'), '持穩': ('✅', '穩定經營', '表現平穩 → 維持現狀,小幅優化素材', '#4CAF50'), } ORDER = ['加碼', '機會', '收割', '觀察', '持穩'] groups: dict = {k: [] for k in ORDER} for s in items: key = s.get('strategy', '持穩') if key in groups: groups[key].append(s) else: groups['持穩'].append(s) title = f"🧬 *商品策略矩陣*" if date_str: title += f" _({date_str})_" lines = [title, ""] # 計算各組商品數 & 業績小計 summary_parts = [] for k in ORDER: if groups[k]: meta = GROUP_META[k] summary_parts.append(f"{meta[0]}{k}×{len(groups[k])}") if summary_parts: lines.append("_分佈:" + " ".join(summary_parts) + "_") lines.append("") for key in ORDER: group_items = groups[key] if not group_items: continue meta = GROUP_META[key] emoji, label, advice, _ = meta group_rev = sum(s['revenue'] for s in group_items) lines.append(f"{emoji} *{label}* _{advice}_") lines.append(f" 共 {len(group_items)} 件 | 業績合計 `NT$ {group_rev:,.0f}`") for s in group_items: pid = s.get('id', '') or '' sid = _short_id(pid) link = _pchome_link(pid, s['name'], 20) rev = s['revenue'] growth = s.get('growth', 0) if growth and growth != 0: g_val = abs(growth) * 100 g_str = f" 週{'▲' if growth > 0 else '▼'}{g_val:.0f}%" g_emoji = '📈' if growth > 0 else '📉' else: g_str = '' g_emoji = '' lines.append(f" › {link}") lines.append(f" 🆔 `{sid}` 💰 `NT$ {rev:,.0f}`{g_str} {g_emoji}") lines.append("") lines.append("_💡 策略:內部週環比 × 外部 Google 熱搜雙維度分析_") return "\n".join(lines).rstrip() # ── 簡報生成 ────────────────────────────────────────────────── def _clean_ai_text(text: str) -> str: """清理 AI 輸出:移除 Markdown 語法、去除開場白""" import re # 去除 **bold**、*italic*、`code` text = re.sub(r'\*{1,3}([^*\n]+)\*{1,3}', r'\1', text) text = re.sub(r'`([^`\n]+)`', r'\1', text) # 去除 ## 標題符號 text = re.sub(r'^#{1,4}\s*', '', text, flags=re.MULTILINE) # 去除 AI 慣用開場白 text = re.sub( r'^(好的[,,。]?|以下是.*?[::]|針對.*?分析[如下如下]?[::]?|' r'根據.*?資料[,,]?|以下為.*?[::])\s*\n?', '', text, flags=re.IGNORECASE ) return text.strip() def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: """ 用 NIM DeepSeek 生成簡報 AI 分析文字 (批次任務用 NIM,節省 Gemini 即時對話額度) """ is_monthly = '月報' in report_type is_strategy = '策略' in report_type 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', '季報', '半年報', '年報')) is_category = '品類' in report_type or 'category' in report_type is_customer = '客戶' in report_type or 'customer' in report_type is_forecast = '檔期前瞻' in report_type or 'forecast' in report_type is_promo_cmp = '多活動' in report_type or 'promo_compare' in report_type is_new_prod = '新品' in report_type or 'new_product' in report_type is_market_intel = '市場情報' in report_type or 'market_intel' in report_type is_price_elast = '價格彈性' in report_type or 'price_elasticity' in report_type is_5forces = '五力' in report_type or 'competitor_v4' in report_type or '競業五力' in report_type # ── 格式鐵律(所有 prompt 共用後綴)──────────────────────── FORMAT_RULES = ( "\n\n【輸出格式鐵律 — 絕對遵守】\n" "1. 禁止使用任何 Markdown 語法:禁止 **粗體**、*斜體*、`程式碼`、## 標題\n" "2. 段落標題用【】全形括號,例如:【整體競爭態勢】\n" "3. 開頭直接進入分析內容,禁止「好的」「以下是」「針對您的資料」等 AI 慣用語\n" "4. 每個建議條目以 ✅ 開頭\n" "5. 繁體中文,語氣專業、精準、業績導向" ) # ── 2026 台灣電商市場趨勢脈絡(所有 prompt 共用前綴知識)────── # 任何 AI 分析必須以此知識為背景,不可違反實際市場趨勢。 MARKET_TREND_2026 = ( "\n\n【2026 年台灣電商市場知識基底(必須以此為分析背景)】\n" "── 關鍵檔期與品類拉動幅度 ──\n" " • 母親節(5 月第 2 週):年度大促,美妝保養 +30~50%、母嬰 +20~35%\n" " • 520 情人節:禮品/香氛/輕珠寶 +25~40%\n" " • 618 購物節(年中最大):全品類 +30~50%,主打囤貨型商品(美妝禮盒、母嬰用品)\n" " • 端午節:應景食品 +40~60%、家庭清潔/紙品 +15~25%\n" " • 雙11(年度最強):全品類 +50~80%\n" " • 雙12 / 年末:保健食品 +30~50%、年節禮品 +40~60%\n" "── 2026 熱門賽道 ──\n" " • 永續美妝(無毒/敏弱肌/天然成分)\n" " • 母嬰高端化(NT$2000+ 客單帶、日韓品牌、安全認證)\n" " • 機能性食品(益生菌、葉黃素、銀髮保健)\n" " • IP 聯名(角色聯名增加客單與話題)\n" " • 男性保養(從工具型轉精緻型)\n" " • 寵物經濟(鮮食、保健、玩具高端化)\n" "── 平台競爭態勢 ──\n" " • 蝦皮:免運門檻低(NT$99)、直播帶貨強、年輕族群\n" " • PChome:3C/家電優勢、24h 到貨、會員忠誠度高\n" " • 酷澎:火箭快送、選品精緻、高端客群\n" " • momo:生活百貨/美妝強、電視購物頻道整合、會員訂閱推力\n" ) if is_monthly: sys_instruction = ( "你身兼三職:(1) 資深電商策略顧問(10 年 BCG / 麥肯錫零售諮詢經驗)" "(2) momo 平台行銷總監(熟悉台灣電商平台流量分配、廣告投放 ROI、檔期節奏)" "(3) 品類採購負責人(精通美妝保養、母嬰、個人清潔等品類的供應鏈與選品邏輯)。\n" "你的客戶是 momo 平台的 BU 主管與行銷團隊,他們會用你這份報告做下一步的" "庫存決策、廣告預算分配、檔期商品規劃,因此必須給出可直接執行的決策建議," "而非空泛分析。所有判斷必須符合 2026 年台灣電商市場的實際趨勢與消費行為。\n\n" "請根據以下月報業績數據與外部市場情報,輸出一份完整月度營運報告,結構嚴格如下:\n\n" "【整體業績解讀】(4-5句)\n" "引用月業績、訂單、毛利率、客單價四項指標,評估本月整體表現等級(優/良/普/弱)," "明確點出最顯著亮點(如品類爆發、客單拉升)與最大警訊(如毛利壓縮、成長放緩);" "與台灣電商市場月均表現比較定位(月成長 5~15% 為健康區間,<5% 偏弱、>20% 強勁);" "若毛利率 <10% 必須點明「毛利偏低、需改善 mix 或議價」。\n\n" "【市場趨勢脈絡】(3-4句,本段為新增的關鍵段)\n" "結合 2026 年台灣電商當下趨勢,至少觸及以下三項中兩項:\n" " (a) 節慶檔期:當月與下月的關鍵檔期(母親節 5/2 週、520、618、雙11、雙12)" " 及其對品類拉動的歷史幅度(例如:母親節美妝品類業績通常拉抬 30~50%)\n" " (b) 消費行為趨勢:永續美妝、母嬰高端化、IP 聯名、無毒/敏弱肌、機能性食品、" " 銀髮保健、寵物經濟、男性保養等熱門賽道;點出哪個趨勢與本月業績共振或背離\n" " (c) 平台競爭態勢:與蝦皮、PChome、酷澎的相對位置,特別是「免運門檻」「直播帶貨」" " 「會員訂閱」的策略影響\n" "本段要讓讀者看完知道「當下市場在發生什麼,我們站在哪裡」。\n\n" "【品類結構深度解析】(4-5句)\n" "TOP1~2 主力品類:成因(季節 / 檔期 / 商品力 / 廣告投放)+ 該品類在台灣電商整體中的" "市場份額位階(例如:美妝在 momo 通常佔 18~22%,本月 X% 屬偏高/偏低);" "成長最快或最具潛力的新興品類,建議是否加碼資源;" "若 TOP1 品類佔比 >60% 必須點明「集中度過高、需分散風險」並具體建議下一個應扶植的品類;" "若毛利率高的品類佔比偏低,建議調整品類 mix 提升結構毛利。\n\n" "【熱銷商品洞察】(3-4句)\n" "TOP3 熱銷商品的高業績成因(定價優勢 / 品牌信任 / 廣告推力 / 檔期效應 / 季節剛需);" "說明這些商品對整體客單價與毛利的貢獻或壓力(高業績不等於高毛利);" "識別「新進榜」商品的潛力(值得加碼)與「跌出榜」商品的衰退原因;" "建議哪 1 款商品適合做次月主推 hero SKU。\n\n" "【MCP 市場情報整合】(3-4句)\n" "結合外部市場情報(節日日曆 / 季節情境 / 競品動態),說明當前電商環境對本月業績的" "正面/負面影響;指出下月必須卡位的外部機會(具體到檔期與品類組合,例如「618 主打" "美妝禮盒套組,瞄準 NT$1500~3000 客單帶」)。\n\n" "【行銷與銷售行動建議 — SMART 框架】\n" "每段必須符合 SMART:Specific(具體商品/品類)、Measurable(量化目標 %/NT$)、" "Achievable(可行動、不空泛)、Relevant(與業績痛點直接相關)、Time-bound(時程)。\n\n" "■ 本週立即執行(3 條,以 ✅ 開頭):庫存補貨 / 廣告投放優化 / 定價或滿額門檻調整 / " "下架低毛利長尾。每條須含「商品名/品類 + 量化目標 + 完成期限」。\n" "■ 本月優化重點(3 條,以 ✅ 開頭):品類 mix 調整 / 客單拉升組合 / 毛利改善議價 / " "新進榜商品扶植。每條須含「方法 + 預期效益(毛利率 +X% / 客單 +NT$Y / 轉換率 +Z%)」。\n" "■ 下月預備部署(3 條,以 ✅ 開頭):檔期商品規劃 / 預售活動設計 / 廣告預算分配 / " "競品阻擊。每條須結合下月關鍵檔期與市場趨勢脈絡,給「卡位時機 + 預估收益」。\n\n" "【競爭定位與風險預警】(3-4句)\n" "本月在 momo 平台同類賣家中的相對位置(領先 / 並列 / 落後);" "最大三項潛在風險(品類過度集中 / 毛利持續下滑 / 競品價格戰 / 檔期商品庫存不足等);" "對應的「立即啟動」防禦動作(例如:建立次月安全庫存閾值、與 TOP 3 廠商重新議價)。\n\n" "要求:每段必須引用至少 2 個具體數字(業績/百分比/排名/客單),全文 900~1200 字," "語氣為資深顧問遞交給 BU 主管的決策報告,不要學術化,要落地。" "禁止使用「可能」「也許」「建議考慮」等模糊用詞,要明確「必須做X、目標Y、期限Z」。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 2400 elif is_promo: sys_instruction = ( "你是資深電商活動行銷策略分析師,擁有 10 年以上台灣電商促銷活動策劃與效益評估實戰經驗," "精通促銷活動 ROI 分析、商品選品策略、消費者行為洞察,深度熟悉美妝、保健、母嬰等品類的" "活動規劃模式。\n\n" f"請針對以下{report_type}資料,輸出一份完整專業的促銷效益分析報告,結構如下:\n\n" "【整體活動評估】(3-4句)\n" "以業績成長率、訂單成長率、客單價變化、毛利率表現為核心,綜合評估本次活動效益等級(優良/良好/普通/偏弱/不佳)," "點出最顯著的亮點或警訊,並與業界平均促銷效益(10~30%業績提升為正常區間)作比較。\n\n" "【關鍵發現】(4-5句)\n" "分析活動期間「新晉商品」的表現意義、客單價變化背後的消費行為訊號、" "毛利率壓縮或提升的結構性原因,以及訂單與業績成長率的背離(若有)代表什麼。\n\n" "【市場信號解讀】(3-4句,本段須結合 2026 當下市場趨勢)\n" "(a) 檔期對位:本次活動相對於台灣電商促銷節奏(雙11/母親節/520/618/雙12/年慶)" "的時機優劣,以及該檔期歷史拉抬幅度作為對標基準。\n" "(b) 品類熱度:當前消費者熱搜賽道(永續美妝、敏弱肌、機能性食品、銀髮保健、IP 聯名、" "男性保養、寵物經濟)與本次活動商品的契合度。\n" "(c) 價格敏感度:本次客單帶相對該品類的市場常態(如美妝禮盒 NT$1500~3000、母嬰高端" "NT$2000+),以及與蝦皮、PChome 的相對位置判斷。\n\n" "【方案A:短期優化(活動後1週)】(3條,每條以 ✅ 開頭)\n" "針對本次活動結果的立即行動:庫存管理、廣告預算調整、回購引導、售後溝通。\n" "每條含具體商品/品類名稱或量化目標。\n\n" "【方案B:中期強化(下次活動)】(3條,每條以 ✅ 開頭)\n" "針對下次促銷活動的優化方向:商品組合、滿額門檻設計、新晉商品扶植、" "跨品類搭配促銷策略,含預期效益(轉換率↑/客單↑/毛利改善)。\n\n" "【方案C:長期結構改善(季度層級)】(3條,每條以 ✅ 開頭)\n" "從商業模式角度提升促銷健康度:RFM 分群精準投放、忠誠訂閱制降低促銷依賴、" "促銷效益基準線 KPI 建立、供應鏈協同提升毛利空間。\n\n" "【風險預警】(1-2句)\n" "指出最大潛在風險(毛利侵蝕/庫存積壓/價格形象破壞),提出防禦建議。\n\n" "要求:每段必須引用至少一個具體數字,全文不超過 600 字,語氣如資深顧問報告。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 1400 elif is_strategy: sys_instruction = ( "你身兼 (1) 資深電商策略分析師(10 年 BCG / 麥肯錫零售諮詢經驗)" "(2) 行銷主管(精通台灣電商 RFM 分群、廣告 ROI、檔期商品規劃)。\n" "你的客戶是 momo BU 主管,會用本報告做下週的庫存與行銷預算決策。\n\n" f"請針對以下{report_type}資料,輸出一份繁體中文專業策略報告,結構如下:\n\n" "【業績解讀】(3-4句)總結核心業績表現,量化點出最關鍵亮點與警訊;" "與台灣電商市場月均(5~15% 為健康)作比較定位。\n\n" "【市場趨勢脈絡】(3-4句,新增段)\n" "結合 2026 年台灣電商當下趨勢,至少觸及兩項:\n" " (a) 當期關鍵檔期(母親節 / 520 / 618 / 雙11 / 雙12)對品類拉動幅度\n" " (b) 消費行為熱門賽道:永續美妝、母嬰高端化、IP 聯名、敏弱肌、銀髮保健、男性保養\n" " (c) 平台競爭:與蝦皮 / PChome / 酷澎的免運門檻、直播帶貨、會員訂閱策略對位\n\n" "【策略矩陣分析】(4-5句)解讀加碼/機會/收割/觀察分佈," "點出哪類商品最值得加碼資源(廣告預算、首頁版位、滿額門檻)," "成長動因(檔期 / 品牌力 / 廣告 / 搜尋詞排名)為何。\n\n" "【行銷與銷售行動建議 — SMART 框架】\n" "每條符合 Specific / Measurable / Achievable / Relevant / Time-bound:\n" "■ 立即執行(2 條,✅ 開頭):庫存補貨 / 廣告投放 / 定價或滿額門檻\n" "■ 本期強化(2 條,✅ 開頭):品類 mix / 客單拉升組合 / 新進榜扶植\n" "每條須含「商品名/品類 + 量化目標(毛利+X% / 客單+NT$Y / 轉換率+Z%)+ 期限」。\n\n" "【風險預警】(2-3句)指出 2~3 項潛在風險(集中度 / 毛利下滑 / 競品價格戰)," "對應「立即啟動」防禦動作。\n\n" "要求:每段引用至少 2 個具體數字,全文 600~800 字,禁用「可能/也許/建議考慮」。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 1400 elif is_5forces: sys_instruction = ( "你是資深企業戰略顧問(BCG / 麥肯錫零售諮詢經驗,精通 Porter 五力與競爭定位)。" "已有 6 維度(商品力/價格力/行銷力/服務力/品牌力/財務力)momo vs 競品的 0-10 分評分。\n\n" f"請輸出{report_type}戰略整合報告,結構:\n\n" "【整體競爭態勢】(4-5 句)\n" "解讀 momo 6 維度綜合評分 vs 競品;點出最大優勢力與最大劣勢力;" "與業界整體格局比較定位(蝦皮/酷澎/PChome 多方夾擊下 momo 的位置)。\n\n" "【優勢加碼策略】(3-4 句)\n" "針對最大優勢力(如行銷力 / 品牌力),建議如何持續加碼擴大領先;" "識別第二、第三優勢力是否值得整合形成「組合拳」(如行銷力 + 服務力 = " "「直播帶貨即時下單免運」)。\n\n" "【劣勢補強或避戰】(3-4 句)\n" "針對最大劣勢力,二選一:\n" " (a) 補強:投入資源補上短板(如服務力落後則升級物流)\n" " (b) 避戰:放棄該戰場、強化其他差異化武器(如商品力落後則放棄全品類比拼,聚焦核心品類)\n" "給出明確選擇與理由。\n\n" "【六力整合 SMART 行動】\n" "■ 立即執行(3 條,✅ 開頭):每條對應 1 個力的具體投放/優化\n" "■ 中期強化(3 條,✅ 開頭):跨力組合戰術\n" "■ 長期戰略(2 條,✅ 開頭):護城河建立 / 生態系整合\n" "每條須含「具體武器 + 量化目標 + 期限」。\n\n" "【最大三大競爭風險】(2-3 句)\n" "(a) 競品大檔期入侵(蝦皮直播 / 酷澎補貼)\n" "(b) 平台流量分配變化(Google Search 演算法 / 社群推薦)\n" "(c) 法規/政策變動(電商營業稅 / 跨境法規)\n\n" "要求:每段引用具體數字(評分、業界基準),全文 1000~1300 字," "禁用模糊用詞,要明確戰術與時程。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 2400 elif is_price_elast: sys_instruction = ( "你身兼 (1) 採購主管(精通選品、議價、定價策略)" "(2) 商品 PM(精通商品生命週期、客單價拉升策略)。\n" "你的客戶是 momo 採購與 PM 團隊,會用本報告做新品定價、選品決策、" "高/低毛利商品配置。\n\n" f"請針對以下{report_type}(價位甜蜜點 + 各價位區間銷量分布)" "輸出採購策略建議:\n\n" "【價位甜蜜點解讀】(3-4 句)\n" "點明甜蜜點價位區間、佔總訂單百分比;判斷集中度健康度(>50% 過度集中 / " "30-50% 主流明確 / <30% 分布健康);說明此甜蜜點對應的消費層級" "(學生 / 上班族 / 家庭主婦 / 高端客群)。\n\n" "【高/低價帶結構評估】(3-4 句)\n" "高價帶(>NT$2K)業績佔比,與業界基準(健康 30-50%)比較;" "若 <25% 點明「需引進高客單 SKU 提升結構毛利」;" "若 >50% 點明「需注意中低價市場佈局」;" "識別「斷層」價位區間(SKU 數明顯偏少者,可能是補貨機會)。\n\n" "【選品與定價策略 — SMART 框架】\n" "■ 立即執行(3 條,✅ 開頭):\n" " ✅ 新品定價:對齊甜蜜點區間 ±10%(具體價位範圍)\n" " ✅ 補強斷層:[斷層價位] 開發 N 款新 SKU\n" " ✅ 高價試水:在甜蜜點上一級價位帶試銷 3 款高端品\n" "■ 中期強化(2 條,✅ 開頭):價格分層組合 / 滿額門檻設計\n" "■ 長期佈局(1 條,✅ 開頭):自有品牌進入高毛利價位帶\n" "每條須含「商品名/品類 + 量化目標 + 期限」。\n\n" "【最大風險】(2-3 句)\n" "(a) 過度依賴單一價位 → 競品價格戰時整體業績被打\n" "(b) 高價帶不足 → 結構毛利偏低、無法承擔行銷成本\n" "(c) 低價帶過多 → 拉低品牌形象\n\n" "要求:每段引用具體數字(價位區間、SKU 數、業績佔比)," "全文 700~900 字,禁用模糊用詞。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 1700 elif is_market_intel: sys_instruction = ( "你身兼 (1) 行銷情報分析師(精通競品監控、消費趨勢、社群口碑分析)" "(2) BU 主管(策略決策層級的市場敏感度)。\n" "你的客戶是 momo CEO/BU 主管/行銷主管,會用本報告了解外部市場大局," "做下週的廣告投放、檔期備戰、競品阻擊決策。\n\n" f"請針對以下{report_type}(外部資料彙整:節慶日曆、季節情境、電商新聞、" "Google Trends、Dcard、YouTube、天氣、匯率)輸出戰略洞察:\n\n" "【本週市場大事】(3-4 句)\n" "(1) 即將到來的關鍵檔期與其對 momo 業績的影響預估\n" "(2) 當前最熱門的消費賽道(依 Google Trends + Dcard + YouTube 信號)\n" "(3) 本週外部最大風險(如競品大檔期、不利天氣、匯率波動)。\n\n" "【消費者情緒與口碑解讀】(3-4 句)\n" "Dcard 熱門討論主題反映什麼消費焦慮(價格 / 安全 / 認證 / 永續);" "YouTube 爆紅商品的特徵(顏值 / 解決痛點 / 名人推薦 / IP 聯名);" "建議 momo 在哪些品類加碼選品或行銷投入。\n\n" "【競爭態勢與差異化】(3-4 句)\n" "蝦皮、PChome、酷澎、博客來等競品本週動態(依電商新聞);" "momo 應該強化哪些差異化武器(會員訂閱 / 直播帶貨 / 富邦銀行折扣);" "若競品有大檔期,給出阻擊策略(價格戰避戰 / 服務力差異 / 限時加碼)。\n\n" "【行動建議 — SMART 框架】\n" "■ 本週立即執行(3 條,✅ 開頭):廣告投放調整 / 商品上架 / 競品比價\n" "■ 下週預備(3 條,✅ 開頭):檔期商品規劃 / 行銷檔期協作 / 庫存備援\n" "■ 本月戰略(2 條,✅ 開頭):消費賽道選品 / 行銷主題定位\n" "每條必須 SMART:具體商品/品類 + 量化目標 + 期限。\n\n" "【最大三大外部風險】(2-3 句)\n" "(a) 政策法規變動(如電商營業稅、進口商品法規)\n" "(b) 匯率波動(影響進口商品成本)\n" "(c) 競品大檔期或新平台進入(如 Temu、酷澎激進補貼)\n\n" "要求:每段引用具體外部信號(Trend 關鍵字、Dcard 主題、新聞標題等)," "全文 800~1100 字,禁用模糊用詞。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 2000 elif is_new_prod: sys_instruction = ( "你身兼 (1) PM 商品經理(精通新品上架 / 商品生命週期 / SKU 健康度)" "(2) 採購主管(精通選品、新廠商引進、新品扶植)。\n" "你的客戶是 momo PM 與採購團隊,會用本報告做新品扶植加碼、" "曇花一現新品下架、明星新品行銷加碼的決策。\n\n" f"請針對以下{report_type}資料,輸出新品戰術洞察,結構嚴格如下:\n\n" "【新品力評估】(3-4 句)\n" "引用新品數、新品業績、業績佔比,評估新品力等級(強勁 >8% / 穩健 3-8% / " "偏弱 1-3% / 疲弱 <1%);與業界平均(健康電商 5-10%)比較定位;" "若 <3% 必須點明「新品引進不足,將失去成長動能」並建議下季加碼新品開發。\n\n" "【明星新品識別】(3-4 句)\n" "點名 TOP3 明星新品,分析高業績成因(檔期推力 / 品牌力 / 行銷投放 / " "獨家代理);建議哪 1-2 款適合做次月主推 hero SKU;" "若 TOP1 新品業績 >NT$10 萬則建議升格為「常銷主力」加碼資源。\n\n" "【品類分佈與機會】(3-4 句)\n" "新品依品類分佈是否健康(過度集中於單一品類?);" "建議下季應加碼新品的品類(依 2026 趨勢:永續美妝 / 母嬰高端 / " "機能性食品 / 男性保養 / 銀髮保健等);" "識別「新品荒漠」品類(無新品進駐者),建議優先填補。\n\n" "【新品扶植與淘汰建議 — SMART 框架】\n" "■ 立即執行(3 條,✅ 開頭):\n" " ✅ 加碼:對 TOP3 新品(具體商品名)增加首頁版位/廣告預算 +X%," "預期業績 +Y%,期限:YYYY/MM/DD\n" " ✅ 觀察:對排名 11-30 名新品(具體商品名)2 週後回看," "若週業績 < NT$Z 則啟動下架評估\n" " ✅ 數據追蹤:建立新品 KPI 儀表板(爬榜速度 / 客單 / 復購率)," "每週自動更新\n" "■ 中期強化(2 條,✅ 開頭):開發新廠商 / 跨品類聯名 / 自有品牌 OEM\n" "■ 長期佈局(1 條,✅ 開頭):建立新品引進 SOP(試銷 30 天 → " "達標升常銷 / 不達標下架)\n\n" "【最大風險與防禦】(2-3 句)\n" "(a) 新品試銷失敗率高 → 建議單一品類下架率 >50% 觸發採購復盤\n" "(b) 新品搶食常銷市場 → 觀察常銷商品銷量是否被新品稀釋\n" "(c) 過度依賴單品爆款 → 建議新品 TOP1 佔新品業績 <30% 為健康\n\n" "要求:每段引用至少 2 個具體數字,全文 800~1000 字,禁用模糊用詞。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 1800 elif is_promo_cmp: sys_instruction = ( "你是資深行銷主管(10 年促銷活動策劃實戰經驗)。" f"以下是多場促銷活動的 ROI 對比數據。請輸出{report_type}跨活動洞察:\n\n" "【整體比較解讀】(3-4 句)\n" "點出 N 場活動中業績拉抬最高/最低、毛利最佳/最差、訂單拉抬最強的活動;" "評估整體促銷組合健康度(是否過度依賴單一檔期)。\n\n" "【勝出活動成功要素】(3-4 句)\n" "分析最高拉抬活動的成功因素(檔期 / 商品力 / 行銷投放 / 滿額設計);" "判斷哪些要素可複製到下一場。\n\n" "【失敗活動診斷】(3-4 句)\n" "點出拉抬偏低或負成長活動的問題(時機不對 / 對比期過旺 / 商品選錯 / " "毛利侵蝕過深);給出具體改善方向。\n\n" "【行動建議 — SMART 框架】\n" "■ 立即執行(3 條,✅ 開頭):複製成功要素 / 立即停損失敗格式\n" "■ 中期強化(2 條,✅ 開頭):建立活動 KPI 基準線 / RFM 精準投放\n" "■ 長期佈局(1 條,✅ 開頭):建立年度活動行事曆 + 自動化 ROI 追蹤\n\n" "要求:每段引用具體活動名與數字,全文 700~900 字。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 1600 elif is_forecast: sys_instruction = ( "你身兼 (1) BU 主管(決策檔期備戰策略)" "(2) 行銷投放主管(廣告預算分配)(3) 採購(庫存補貨)。\n" "你的客戶是 momo BU,會用本報告做檔期前 14 天的庫存補貨、廣告投放、" "競品阻擊、滿額門檻等戰術決策。所有判斷必須有量化依據與明確期限。\n\n" f"請針對以下{report_type}資料,輸出檔期戰術前瞻報告,結構嚴格如下:\n\n" "【檔期定位與機會評估】(4-5 句)\n" "引用本檔期歷史拉抬倍數(lift_factor)、預期業績、去年同檔期實績;" "評估本期成長 vs 衰退趨勢;點明:(a) 本檔期主推品類(依 2026 趨勢)" "(b) 客單帶(如母親節美妝禮盒 NT$1500-3000)(c) 競品檔期動態。\n\n" "【準備窗口進度評估】(3-4 句)\n" "已過 X / Y 天的累積業績達成預期 N%;判斷是否達標、需加碼或減碼;" "若進度落後 > 20% 點明「需立即啟動加速方案」並給出具體手段。\n\n" "【庫存戰術建議】(3-4 句)\n" "基於 baseline 期 TOP 商品銷量 × lift_factor 計算預期銷量;" "點名 3 款必補貨商品(含具體數量目標);" "識別 2 款「滯銷風險」(baseline 期低銷量但被列入檔期主推的);" "建議安全庫存閾值(檔期 + 7 天緩衝)。\n\n" "【廣告投放與滿額門檻】(3-4 句)\n" "建議廣告預算(baseline 業績的 X%、目標 ROAS Y);" "鎖定族群(依 2026 賽道:永續美妝/母嬰高端/銀髮保健等);" "滿額門檻設計(依預期客單 × 1.2~1.5 倍)。\n\n" "【行動清單 — SMART 框架】\n" "■ 檔期前 7 天(3 條,✅ 開頭):補貨 / 廣告投放 / 競品價格巡檢\n" "■ 檔期當日 + 3 天(3 條,✅ 開頭):滿額活動 / 直播帶貨 / 即時補刀\n" "■ 檔期後 7 天(2 條,✅ 開頭):回購引導 / 庫存清貨 / 復盤學習\n" "每條須含「商品/品類 + 量化目標(業績 +X% / 庫存 N 組 / 廣告 NT$Y)+ 期限」。\n\n" "【最大三大風險與防禦】(2-3 句)\n" "(a) 缺貨斷鏈 — 啟動次廠商備援\n" "(b) 競品低價 — 滿額贈/品牌力差異化\n" "(c) 廣告 ROAS 失控 — 中途調整素材或暫停 underperformer\n\n" "要求:每段引用至少 2 個具體數字,全文 800~1100 字,禁用模糊用詞。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 2000 elif is_customer: sys_instruction = ( "你是資深行銷主管(10 年電商 RFM/CRM 實戰經驗)。" "因本資料層無 user_id(PII 法規限制),分析以訂單級為主:" "訂單規模分群、消費星期分佈、商品復購率。\n\n" f"請輸出{report_type}行銷洞察,結構:\n\n" "【訂單規模解讀】(3-4 句)\n" "引用總訂單、總業績、平均客單,判斷市場定位(高/中/低客單);" "高客單訂單佔比是否健康(業界 NT$5K+ 佔 5~15% 為合理);" "若高客單 <5% 點明「客群偏低端,需推高客單組合」。\n\n" "【消費熱點與時段】(2-3 句)\n" "識別最熱星期 vs 最冷星期業績差異,建議集中廣告/活動到熱門時段;" "若消費過度集中在週末,建議週間推送提醒;反之亦然。\n\n" "【商品復購信號】(3-4 句)\n" "TOP 復購商品的特徵(消耗品 / 季節剛需 / 訂閱型);" "建議哪些商品適合做「自動訂閱」或「週期回購提醒」;" "點名適合做組合銷售(搭配低客單商品提升 AOV)。\n\n" "【行動建議 — SMART 框架】\n" "■ 立即執行(3 條,✅ 開頭):高客單組合 / 熱門時段廣告 / 復購提醒\n" "■ 中期強化(2 條,✅ 開頭):訂閱制設計 / 跨品類捆綁\n" "■ 長期佈局(1 條,✅ 開頭):建立會員系統取得 user_id 升級完整 RFM\n" "每條須含「具體商品/品類 + 量化目標 + 期限」。\n\n" "要求:每段引用具體數字,全文 600~800 字,禁用模糊用詞。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 1500 elif is_category: sys_instruction = ( "你身兼 (1) 採購主管(精通選品、廠商議價、品類組合)" "(2) PM 商品經理(精通商品生命週期、新品爬榜、SKU 健康度)。\n" "你的客戶是 momo 採購與 PM 團隊,會用本報告做品類選品、新廠商引進、" "下架低毛利長尾、扶植新進榜商品的決策。\n\n" f"請針對以下{report_type}資料,輸出品類深度分析報告,結構嚴格如下:\n\n" "【品類整體解讀】(4-5 句)\n" "引用本品類 90 天業績、訂單、毛利率、SKU 數、廠商數,評估品類定位" "(主力 / 成長 / 長尾);點出最關鍵亮點(高毛利商品爆發、新進榜潛力)" "與最大警訊(毛利下滑、SKU 過度集中、廠商斷供)。\n\n" "【90 天趨勢分析】(3-4 句)\n" "解讀日業績曲線:高低點對應的檔期/季節因素;判斷品類處於上升 / 持平 / " "下降趨勢;對比品類季節性(如美妝在母親節前 30 天通常 +30%)。\n\n" "【子品類結構與機會】(3-4 句)\n" "前 3 大子品類佔比、是否健康分散;子品類間的 mix 健康度;" "建議哪個子品類是下季度應加碼資源的(高毛利 + 成長中)。\n\n" "【SKU 與廠商組合健康度】(4-5 句)\n" "TOP3 商品集中度(前 3 商品佔本品類業績 X%,是否過於依賴);" "新進榜商品(🆕)的潛力評估:誰值得加碼資源、誰只是曇花一現;" "TOP3 廠商議價空間:毛利偏低者、可爭取獨家代理者、可下架者各列名 1-2 家。\n\n" "【行動建議 — SMART 框架】\n" "■ 立即執行(3 條,✅ 開頭):補貨 / 廣告投放 / 下架低毛利長尾\n" "■ 中期強化(3 條,✅ 開頭):新品扶植 / 廠商議價 / 子品類擴張\n" "■ 長期佈局(2 條,✅ 開頭):自有品牌 / 跨品類聯名\n" "每條須含「具體商品名 + 量化目標 + 期限」。\n\n" "【最大風險與防禦】(2-3 句)\n" "點出本品類 2~3 項風險(集中度過高 / 季節性過強 / 競品價格戰),對應防禦動作。\n\n" "要求:每段引用至少 2 個具體數字(商品名/業績/排名)," "全文 800~1000 字,禁用模糊用詞。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 1800 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 年零售/電商採購實戰經驗,精通議價、選品、" "獨家代理談判)(2) 供應鏈管理顧問(精通 Pareto 集中度風險、安全庫存、" "雙源頭策略)。\n" "你的客戶是 momo BU 採購主管,會用本報告做下季度的議價對象、新廠商扶植、" "備援名單、毛利改善決策。\n\n" f"請針對以下{report_type}資料,輸出採購策略視角的分析報告,結構嚴格如下:\n\n" "【整體廠商結構解讀】(4-5 句)\n" "引用廠商總數、合計業績、合計毛利、平均毛利率,評估廠商組合健康度(健康/警訊);" "點出最關鍵亮點(TOP1 廠商業績/毛利、新進榜潛力廠商)與最大警訊" "(集中度過高、毛利持續下滑、長尾過多);" "與業界平均(健康電商前 20% 廠商佔 70~80% 業績為合理區間)作比較定位。\n\n" "【集中度與供應風險評估】(4-5 句)\n" "(a) Pareto 80/20 分析:前 N 家廠商佔 80% 業績的具體比例,是否健康分散\n" "(b) TOP3 廠商斷供風險:若 TOP1 廠商斷供,影響業績幾 %?是否有備援?\n" "(c) 長尾廠商價值:後 50% 廠商是新晉/補充/淘汰候選?毛利是否優於 TOP?\n" "(d) 與上期比較:廠商總數變化、新進榜(🆕)vs 跌出榜的數量\n\n" "【議價優先順序與毛利改善】(4-5 句)\n" "(a) 第一線議價對象:TOP3 中毛利最低的,明確點名 + 議價方向(量價折扣 / " "獨家代理 / 行銷費用協同)\n" "(b) 高毛利廠商扶植:毛利率 >15% 但業績佔比偏低的,建議加碼資源(首頁版位、" "廣告預算)\n" "(c) 低毛利長尾汰換:毛利 <5% 且業績後段,建議下架或重新議價\n\n" "【行動建議 — SMART 框架(必須 SMART)】\n" "■ 立即執行(3 條,✅ 開頭):\n" " ✅ 議價:對 [具體廠商名] 啟動 Q+1 議價,目標毛利 +X pp,期限:YYYY/MM/DD\n" " ✅ 備援:為 TOP3 廠商建立備援名單(同品類至少 1 家替代廠商)\n" " ✅ 汰換:[具體廠商名] 毛利 X%、業績 Y 萬,建議下季度下架或重新議約\n" "■ 中期強化(3 條,✅ 開頭):\n" " ✅ 新廠商開發:針對 [具體品類] 開發 N 家新廠商,下季度預期業績貢獻 NT$X 萬\n" " ✅ 獨家代理談判:[具體廠商] 爭取台灣電商獨家權,預期市佔 +X%\n" " ✅ 廠商分級制度:建立 A/B/C 分級(依業績+毛利+穩定度),每季調整資源分配\n" "■ 長期結構(2 條,✅ 開頭):\n" " ✅ 集中度目標:12 個月內把前 5 家佔比從 X% 降至 Y%(風險分散)\n" " ✅ 自有品牌(OEM/ODM):規劃 [具體品類] 自有品牌,毛利目標 30%+\n\n" "【最大風險與防禦動作】(2-3 句)\n" "指出 2~3 項最大風險(單點供應斷鏈 / 競品挖角獨家廠商 / 進口匯率波動)," "對應「立即啟動」防禦動作(具體至:建立 N 天安全庫存、與 TOP3 簽 N 年協議)。\n\n" "要求:每段引用至少 2 個具體數字(廠商名 / 業績 / 毛利率 / 排名變化)," "全文 800~1000 字,禁用「可能/也許/建議考慮」模糊用詞。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 1800 elif is_competitor: sys_instruction = ( "你是資深電商競品策略分析師,專精美妝(開架/專櫃)、保健食品、母嬰用品," "具備 10 年以上台灣電商市場實戰經驗,深度熟悉 PChome、momo 競爭生態、" "台灣消費者行為與美妝/保健品類購買決策模式。\n\n" "═══════════════════════════════\n" "角色設定(絕對不可違反)\n" " 視角 = EwoooC 商品營運決策,不預設單一平台永遠正確\n" " price_diff = MOMO售價 − PChome售價\n" " 正值 → MOMO較貴 / PChome低價壓力 → 需評估促銷、組合或服務差異化\n" " 負值 → MOMO較便宜 / MOMO具價格優勢 → 可放大 MOMO 價格賣點\n" " 待補資料不可當成成功配對;必須明確列為資料品質風險\n" "═══════════════════════════════\n\n" f"請以 EwoooC 商品營運視角,針對以下{report_type}輸出一份專業競品分析報告:\n\n" "【整體競爭態勢】(3-4句)\n" "引用平均價差、比對成功件數、待補資料件數,指出價格壓力、MOMO 優勢與資料覆蓋風險。\n\n" "【PChome 低價壓力商品深度解析】(4-5句)\n" "點名 PChome 較便宜的具體商品與品類,判斷可能原因(活動定價、組合包、會員回饋或清庫存)," "提出 MOMO 端可採取的促銷、組合、內容曝光或服務差異化做法,避免只用降價犧牲毛利。\n\n" "【MOMO 價格優勢商品放大策略】(4-5句)\n" "點名 MOMO 較便宜的商品,提出可放大的搜尋關鍵字、站內陳列、檔期素材與推薦理由。\n\n" "【美妝/保健/母嬰品類專項洞察】(3-4句)\n" "針對本期出現的具體商品,結合台灣市場趨勢深度分析:\n" "美妝:成分透明化趨勢、敏感肌/無添加需求、社群口碑行銷;\n" "保健:機能性訴求、族群細分(銀髮/運動/女性)、定期訂購轉換;\n" "母嬰:安全認證標章、日韓品牌偏好、媽媽社群影響力。\n\n" "【本期 TOP3 業績導向行動建議】(3條,每條以 ✅ 開頭)\n" "每條包含:具體商品或品類 + 行動方向 + 預期業績效益(轉換率↑/客單價↑/市佔↑)。\n\n" "要求:每段必須引用至少一個具體數字或商品名,不超過 500 字,語氣如資深顧問報告。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 1200 else: # daily / weekly 走這條:精煉版顧問報告(時間範圍小,不需深度市場分析,但仍含趨勢脈絡) sys_instruction = ( "你是資深電商營運顧問(10 年台灣電商實戰經驗),擅長從短期業績抓出可執行的" "戰術建議。客戶是 momo BU 主管,會用本報告做今日/本週的庫存與廣告調整。\n\n" f"請針對以下{report_type}資料,輸出簡潔但專業的分析與行動建議,結構如下:\n\n" "【整體業績解讀】(2-3句)\n" "引用業績、訂單、毛利率、客單價,評估等級(優/良/普/弱),點出最關鍵的亮點與警訊。\n\n" "【市場機會與風險】(2-3句)\n" "結合當前檔期(母親節/520/618/雙11/雙12 等),說明本期業績所受影響" "與下一個應卡位的時機;指出潛在風險(庫存/競品/毛利)。\n\n" "【TOP3 立即行動建議】(3 條,✅ 開頭)\n" "每條符合 SMART:具體商品/品類 + 量化目標(補貨 N 組/廣告 +X%/客單 +NT$Y)+ 期限。\n\n" "要求:每段引用具體數字,全文 350~450 字,禁用「可能/也許」模糊用詞。" + MARKET_TREND_2026 + FORMAT_RULES ) max_tokens = 900 def _call_gemini(prompt: str, tokens: int) -> str: gemini_api_key = _gemini_fallback_api_key('openclaw_ppt_analysis') if not gemini_api_key: return '' r = requests.post( f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_api_key}", headers={'Content-Type': 'application/json'}, json={ 'contents': [{'parts': [{'text': prompt}]}], 'generationConfig': {'maxOutputTokens': tokens, 'temperature': 0.35}, }, timeout=40, ) r.raise_for_status() return (r.json().get('candidates', [{}])[0] .get('content', {}).get('parts', [{}])[0] .get('text', '').strip()) def _call_ollama(prompt: str, tokens: int) -> str: if not _OLLAMA_AVAILABLE: return "" try: # 簡報分析使用 qwen2.5-coder:7b (已升級 GCP) 或 hermes3 model = os.getenv('OPENCLAW_OLLAMA_MODEL', 'qwen2.5-coder:7b') resp = OllamaService(model=model).generate( prompt=prompt, model=model, temperature=0.3, timeout=90, options={'num_predict': tokens}, ) if not resp.success: sys_log.warning(f"[PPT] Ollama cascade failed: {resp.error}") return "" return (resp.content or '').strip() except Exception as e: sys_log.warning(f"[PPT] Ollama error: {e}") return "" # ── Ollama first:GCP-A → GCP-B → 111 三主機級聯 ──────────── if _OLLAMA_AVAILABLE: try: raw = _call_ollama(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens) result_text = _clean_ai_text(raw) if result_text and len(result_text) > 100: if _LEARNING_ENABLED: import threading as _thr _thr.Thread( target=store_insight, kwargs={ 'insight_type': report_type or 'analysis', 'content': result_text, 'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), }, daemon=True ).start() return result_text except Exception as e0: sys_log.warning(f"[PPT] Ollama first path error: {e0}") if not NVIDIA_API_KEY and not _gemini_fallback_allowed('openclaw_ppt_analysis'): return '(AI 分析暫不可用,請確認 Ollama / API Key 設定)' # ── NIM fallback(Ollama 失敗後) ──────────── if NVIDIA_API_KEY: try: r = requests.post( f'{NVIDIA_BASE_URL}/chat/completions', headers={'Authorization': f'Bearer {NVIDIA_API_KEY}', 'Content-Type': 'application/json'}, json={ 'model': CHAT_MODEL, 'messages': [ {'role': 'system', 'content': sys_instruction}, {'role': 'user', 'content': f'--- 資料 ---\n{prompt_data}'}, ], 'max_tokens': max_tokens, 'temperature': 0.35, }, timeout=25, ) r.raise_for_status() result_text = _clean_ai_text(r.json()['choices'][0]['message']['content']) # ── PPT 分析自動入知識庫 ────────────────────────────── if _LEARNING_ENABLED and result_text: import threading as _thr _thr.Thread( target=store_insight, kwargs={ 'insight_type': report_type or 'analysis', 'content': result_text, 'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), }, daemon=True ).start() return result_text except Exception as e: sys_log.warning(f"[PPT] NIM unavailable after Ollama ({type(e).__name__}), fallback Gemini") # ── Gemini final fallback ───────────────────────────────── if _gemini_fallback_allowed('openclaw_ppt_analysis'): try: raw = _call_gemini(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens) result_text = _clean_ai_text(raw) if _LEARNING_ENABLED and result_text: import threading as _thr _thr.Thread( target=store_insight, kwargs={ 'insight_type': report_type or 'analysis', 'content': result_text, 'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), }, daemon=True ).start() return result_text except Exception as e2: sys_log.error(f"[PPT] Gemini fallback error: {e2}") # ── Ollama (GCP/111) Final Fallback ─────────────────────── if _OLLAMA_AVAILABLE: try: sys_log.info("[PPT] Trying local/GCP Ollama as final fallback") raw = _call_ollama(f"{sys_instruction}\n\n--- 資料 ---\n{prompt_data}", max_tokens) result_text = _clean_ai_text(raw) if result_text and len(result_text) > 100: if _LEARNING_ENABLED: import threading as _thr _thr.Thread( target=store_insight, kwargs={ 'insight_type': report_type or 'analysis', 'content': result_text, 'period': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d'), }, daemon=True ).start() return result_text except Exception as e3: sys_log.error(f"[PPT] Ollama final fallback error: {e3}") return '(AI 分析暫時無法使用,請稍後重試)' def _ppt_needs_fallback(ai_text: str) -> bool: if not ai_text: return True weak_markers = ('AI 分析暫', '暫無 AI 分析', '生成中', '請稍後重試') return any(marker in ai_text for marker in weak_markers) or len(ai_text.strip()) < 40 def _ppt_fallback_insight(report_type: str, data_summary: str, mcp_text: str = '') -> str: """模型額度或外部 API 失敗時,仍用既有 DB 摘要補足簡報內容。""" lines = [ f"【{report_type}重點摘要】", data_summary[:1200], ] if mcp_text: lines.extend(["", "【外部情報補充】", mcp_text[:600]]) lines.extend([ "", "【建議動作】", "1. 先檢查高業績商品與高毛利商品是否重疊,優先補強可放大的品項。", "2. 針對低毛利或異常波動商品,回看價格、促銷與庫存狀態。", "3. 若本頁顯示資料不足,請先確認該日期/月份業績資料是否已完成匯入。", ]) return "\n".join(lines) class PPTDataInsufficientError(Exception): """ADR-019 Phase 1:請求的 PPT 期間缺資料。raise 前已主動 inline-keyboard 詢問用戶。""" pass def _ppt_check_data_freshness(report_type: str, chat_id: int, reply_to: int, requested_yr: int = None, requested_mo: int = None, requested_date: str = None) -> None: """ADR-019 Phase 1:PPT 生成前 probe 資料新鮮度。資料缺口時主動 inline keyboard 詢問用戶(自訂日期 / 改看最新 / 取消),並 raise PPTDataInsufficientError 中止流程。 daily / strategy(單日):傳 requested_date='YYYY/MM/DD' monthly:傳 requested_yr, requested_mo """ latest = latest_date() # 'YYYY-MM-DD' 或 None if not latest: return # DB 連不到,原樣繼續,由 caller 處理 try: latest_dt = datetime.strptime(latest.replace('/', '-'), '%Y-%m-%d') except (ValueError, AttributeError): return if report_type in ('monthly', '月報') and requested_yr and requested_mo: has_data = (latest_dt.year > requested_yr or (latest_dt.year == requested_yr and latest_dt.month >= requested_mo)) if not has_data: prev_yr, prev_mo = latest_dt.year, latest_dt.month kb = [ _row((f'📊 改看 {prev_yr}/{prev_mo:02d} 月報', f'cmd:ppt:monthly {prev_yr}/{prev_mo:02d}')), _row(('📅 自訂月份', 'await:date_ppt_monthly')), _row(('❌ 取消', 'menu:reports')), ] send_message( chat_id, f"⚠️ *{requested_yr}/{requested_mo:02d}* 尚無業績資料\n" f"目前最新資料截至:`{latest}`\n\n請選擇:", reply_to, keyboard=kb, parse_mode='Markdown', ) raise PPTDataInsufficientError(f'monthly {requested_yr}/{requested_mo:02d}') elif report_type in ('daily', '日報', 'strategy', '策略', 'weekly', '週報') and requested_date: sales = query_sales(requested_date) if not sales.get('found'): kb = [ _row((f'📊 改看 {latest} 日報', f'cmd:ppt:{report_type or "daily"} {latest}')), _row(('📅 自訂日期', 'await:date_ppt_daily')), _row(('❌ 取消', 'menu:reports')), ] send_message( chat_id, f"⚠️ *{requested_date}* 尚無業績資料\n" f"目前最新資料:`{latest}`\n\n請選擇:", reply_to, keyboard=kb, parse_mode='Markdown', ) raise PPTDataInsufficientError(f'{report_type} {requested_date}') def _normalize_ppt_parameters(parameters: dict) -> str: """將快取參數轉成穩定字串。 自動把當前 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: 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 = True) -> dict: """清理已過期且超過 days_old 天的 PPT 檔案 + DB 紀錄。 執行條件:expires_at < NOW() − days_old 天 → 刪檔 + 刪 row。 保留 days_old 天緩衝避免誤刪剛失效的紀錄。 安全預設:**dry_run=True**(critic 修正 HIGH-2,避免靜默實刪)。 呼叫方必須明確傳 dry_run=False 才會真正刪除。 launchd / cron 排程務必顯式傳: cleanup_expired_ppt_cache(days_old=7, dry_run=False) 回傳統計欄位語意(critic Info-1): dry_run=True 時:deleted_files / deleted_rows / freed_bytes 為「將刪除」預估值 dry_run=False 時:上述為「已實刪」實際值 errors 為過程中發生例外的列表(id + 錯誤訊息) """ 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): """回傳尚未過期的快取紀錄與解析後 payload,若無則回傳 (None, {}).""" from database.manager import DatabaseManager from database.ppt_reports import PPTReport now = datetime.now(TAIPEI_TZ).replace(tzinfo=None) params = _normalize_ppt_parameters(parameters) session = DatabaseManager().get_session() try: cached = ( session.query(PPTReport) .filter( PPTReport.report_type == report_type, PPTReport.parameters == params, or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now), ) .order_by(PPTReport.generated_at.desc()) .first() ) if not cached: return None, {} cached_payload = {} if cached.cached_data: try: cached_payload = json.loads(cached.cached_data) if not isinstance(cached_payload, dict): cached_payload = {} except Exception as e: sys_log.warning(f"[PPT] cached_data 解析失敗: {e}") cached_payload = {} return cached, cached_payload except Exception as e: session.rollback() sys_log.warning(f"[PPT] 讀取快取失敗:{e}") finally: session.close() return None, {} def _load_cached_ppt_path(report_type: str, parameters: dict) -> str | None: """嘗試回傳尚未過期且仍存在的快取檔案。""" path, _ = _load_cached_ppt_path_and_analysis(report_type, parameters) if path: return path return None def _load_cached_ppt_analysis(report_type: str, parameters: dict) -> str | None: """回傳快取中的 AI 分析文字(可直接寫入簡報)。""" _, cached_analysis = _load_cached_ppt_path_and_analysis(report_type, parameters) if not cached_analysis or not isinstance(cached_analysis, str): return None return cached_analysis.strip() if cached_analysis else None def _load_cached_ppt_path_and_analysis(report_type: str, parameters: dict): """回傳快取檔案路徑與 AI 分析文字。""" cached, payload = _load_cached_ppt_entry(report_type, parameters) cached_path = ( cached.file_path if cached and cached.file_path and os.path.exists(cached.file_path) else None ) cached_analysis = payload.get('analysis') if isinstance(payload, dict) else None return cached_path, cached_analysis def _store_ppt_cache(report_type: str, parameters: dict, file_path: str, cached_payload: dict) -> str | None: """儲存 PPT 快取資料,回傳 file_path。""" from database.manager import DatabaseManager from database.ppt_reports import PPTReport from services.ppt_generator import get_template_version now = datetime.now(TAIPEI_TZ) params = _normalize_ppt_parameters(parameters) expire_at = now + timedelta(hours=PPT_CACHE_TTL_HOURS) payload = dict(cached_payload or {}) payload.setdefault('report_type', report_type) payload.setdefault('parameters', parameters) payload.setdefault('template_version', get_template_version(report_type)) payload.setdefault('stored_at', now.strftime('%Y-%m-%d %H:%M:%S')) payload.setdefault('file_path', file_path) try: file_size = os.path.getsize(file_path) payload.setdefault('file_size', file_size) except OSError: file_size = None payload.setdefault('file_size', None) try: cached_data = json.dumps(payload, ensure_ascii=False, default=str) except Exception: cached_data = json.dumps({'error': 'cached_data serialization failed'}, ensure_ascii=False) session = DatabaseManager().get_session() try: cached = ( session.query(PPTReport) .filter(PPTReport.report_type == report_type, PPTReport.parameters == params) .order_by(PPTReport.generated_at.desc()) .first() ) if cached: cached.file_path = file_path cached.file_size = file_size cached.generated_at = now.replace(tzinfo=None) cached.expires_at = expire_at.replace(tzinfo=None) cached.cached_data = cached_data else: session.add(PPTReport( report_type=report_type, parameters=params, file_path=file_path, file_size=file_size, generated_at=now.replace(tzinfo=None), expires_at=expire_at.replace(tzinfo=None), cached_data=cached_data, )) session.commit() return file_path except Exception as e: session.rollback() sys_log.warning(f"[PPT] 快取寫入失敗:{e}") return None finally: session.close() def _is_cached_ppt_file(file_path: str) -> bool: """判斷傳入檔案是否已被快取,避免傳送後刪除。""" if not file_path: return False from database.manager import DatabaseManager from database.ppt_reports import PPTReport now = datetime.now(TAIPEI_TZ) now = now.replace(tzinfo=None) session = DatabaseManager().get_session() try: cached = ( session.query(PPTReport) .filter(PPTReport.file_path == file_path) .filter(or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now)) .first() ) return cached is not None except Exception: return False finally: session.close() def _fetch_mcp_context() -> str: """MCP 失敗時回空字串,避免阻塞。""" try: return build_mcp_context('', []) except Exception: return '' def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, _reply_to: int = None) -> str: """依 sub_type 生成對應 pptx,回傳檔案路徑。 ADR-019 Phase 1:在生成前 probe 資料新鮮度,缺資料時 raise PPTDataInsufficientError(已主動詢問用戶),由 _ppt_background 靜默吞掉。 """ try: from services.ppt_generator import ( generate_daily_ppt, generate_weekly_ppt, generate_monthly_ppt, generate_strategy_ppt, generate_competitor_ppt, generate_promo_ppt, generate_vendor_ppt, generate_period_review_ppt, generate_category_deep_ppt, generate_customer_analytics_ppt, generate_forecast_pre_event_ppt, generate_promo_compare_ppt, generate_new_product_ppt, generate_market_intel_weekly_ppt, generate_price_elasticity_ppt, generate_competitor_v4_ppt, check_pptx_available ) except ImportError: raise RuntimeError("ppt_generator 模組不可用,請確認 python-pptx 已安裝") if not check_pptx_available(): raise RuntimeError("python-pptx 未安裝,請執行:pip install python-pptx") now = datetime.now(TAIPEI_TZ) if sub_type in ('daily', '日報'): if sub_arg and re.fullmatch(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg): date_str = normalize_date(sub_arg) else: date_str = latest_date() or now.strftime('%Y/%m/%d') _ppt_check_data_freshness('daily', _chat_id, _reply_to, requested_date=date_str) params = {'report_type': 'daily', 'date': date_str} cached, cached_ai = _load_cached_ppt_path_and_analysis('daily', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() sales = query_sales(date_str) top_products = query_top_products(date_str, 10) top_vendors = query_top_vendors(date_str, 5) weekly = query_weekly_trend() data_summary = ( f"日期:{date_str}\n" f"業績:NT$ {float(sales.get('revenue', 0)):,.0f} | " f"訂單:{sales.get('orders', '-')}筆 | 毛利率:{sales.get('gross_margin', 0):.1f}%\n" f"熱銷商品:" + " / ".join( f"{p['name']}(NT${p['revenue']:,.0f})" for p in top_products[:5]) + "\n" f"外部情報:{mcp_text[:500]}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '日報') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('日報', data_summary, mcp_text) db_data = { 'sales': sales, 'top_products': top_products, 'top_vendors': top_vendors, 'weekly': weekly, 'mcp': mcp_text, } ppt_path = generate_daily_ppt(date_str, db_data, ai_text) _store_ppt_cache('daily', params, ppt_path, { 'report_type': 'daily', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': db_data, 'mcp': mcp_text, }) return ppt_path elif sub_type in ('weekly', '週報'): end_str = latest_date() or now.strftime('%Y/%m/%d') _ppt_check_data_freshness('weekly', _chat_id, _reply_to, requested_date=end_str) params = {'report_type': 'weekly'} cached, cached_ai = _load_cached_ppt_path_and_analysis('weekly', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() weekly = query_weekly_trend() top_products = query_top_products(target, 10) top_vendors = query_top_vendors(target, 10) strat = analyze_product_strategy(target, 10) data_summary = ( f"週期:{now.strftime('%Y/%m/%d')} 週報\n" f"週業績:NT$ {sum(w['revenue'] for w in weekly):,.0f}\n" f"逐日:" + " | ".join(f"{w['date']}(NT${w['revenue']:,.0f})" for w in weekly) + "\n" f"熱銷:" + " / ".join(f"{p['name']}(NT${p['revenue']:,.0f})" for p in top_products[:5]) + "\n" f"外部情報:{mcp_text[:500]}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '週報') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('週報', data_summary, mcp_text) db_data = { 'weekly': weekly, 'top_products': top_products, 'top_vendors': top_vendors, 'strategy': strat, 'mcp': mcp_text, } ppt_path = generate_weekly_ppt(db_data, ai_text) _store_ppt_cache('weekly', params, ppt_path, { 'report_type': 'weekly', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': db_data, 'mcp': mcp_text, }) return ppt_path elif sub_type in ('monthly', '月報'): if sub_arg: parts = sub_arg.replace('-', '/').split('/') yr, mo = int(parts[0]), int(parts[1]) if len(parts) >= 2 else now.month else: yr, mo = now.year, now.month _ppt_check_data_freshness('monthly', _chat_id, _reply_to, requested_yr=yr, requested_mo=mo) params = {'report_type': 'monthly', 'month': f'{yr}/{mo:02d}'} cached, cached_ai = _load_cached_ppt_path_and_analysis('monthly', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() ms = query_monthly_summary(yr, mo) top_cats = query_category_monthly(yr, mo, lim=8) ms['top_categories'] = top_cats # 月對月 / 年對年 比較資料(市場專業標準必備) prev_yr_mo = (yr if mo > 1 else yr - 1) prev_mo_no = (mo - 1 if mo > 1 else 12) try: prev_mo_data = query_monthly_summary(prev_yr_mo, prev_mo_no) except Exception: prev_mo_data = {'found': False} try: prev_yr_data = query_monthly_summary(yr - 1, mo) except Exception: prev_yr_data = {'found': False} ms['prev_month'] = prev_mo_data if prev_mo_data.get('found') else None ms['prev_year'] = prev_yr_data if prev_yr_data.get('found') else None aov = ms.get('avg_order', ms.get('revenue', 0) / ms.get('orders', 1) if ms.get('orders') else 0) top5_products = ms.get('top_products', [])[:5] top5_cats = top_cats[:5] cat_total = sum(float(c.get('revenue', 0)) for c in top5_cats) cat_breakdown = '\n'.join( f" - {c.get('cat','')}: NT${float(c.get('revenue',0)):,.0f}" f"({float(c.get('revenue',0))/cat_total*100:.0f}%)" if cat_total else f" - {c.get('cat','')}:NT${float(c.get('revenue',0)):,.0f}" for c in top5_cats ) prod_breakdown = '\n'.join( f" {i+1}. {p.get('name','')[:30]} — NT${float(p.get('revenue',0)):,.0f}" for i, p in enumerate(top5_products) ) data_summary = ( f"【月份】{yr}/{mo:02d}\n" f"【月業績】NT${ms.get('revenue', 0):,.0f}\n" f"【月訂單】{ms.get('orders', 0):,} 筆\n" f"【毛利率】{ms.get('gross_margin', 0):.1f}%\n" f"【平均客單價】NT${aov:,.0f}\n\n" f"【品類業績分佈(TOP5)】\n{cat_breakdown}\n\n" f"【熱銷商品 TOP5】\n{prod_breakdown}\n\n" f"【MCP 外部市場情報】\n{mcp_text[:600] if mcp_text else '(無外部情報)'}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '月報') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('月報', data_summary, mcp_text) db_data = {'monthly': ms, 'mcp': mcp_text} ppt_path = generate_monthly_ppt(yr, mo, db_data, ai_text) _store_ppt_cache('monthly', params, ppt_path, { 'report_type': 'monthly', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': db_data, 'mcp': mcp_text, }) return ppt_path elif sub_type in ('strategy', '策略'): period_label = '日報' range_dates = re.findall(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg or '') if len(range_dates) >= 2: start_str = normalize_date(range_dates[0]) end_str = normalize_date(range_dates[1]) date_str = f'{start_str}~{end_str}' try: start_dt = datetime.strptime(start_str.replace('/', '-'), '%Y-%m-%d') end_dt = datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d') if start_dt.year == end_dt.year and start_dt.month == end_dt.month and start_dt.day == 1: period_label = f'{end_dt.year}/{end_dt.month:02d} 月策略(截至 {end_dt.month:02d}/{end_dt.day:02d})' else: period_label = f'{start_str}~{end_str} 策略' except Exception: period_label = f'{start_str}~{end_str} 策略' elif sub_arg and re.fullmatch(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg): date_str = normalize_date(sub_arg) start_str, end_str = date_str, date_str period_label = f'{date_str} 日策略' elif sub_arg in ('weekly', 'week', '週', '週報'): end_str = latest_date() or now.strftime('%Y/%m/%d') start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d') - timedelta(days=6)).strftime('%Y/%m/%d') date_str = f'{start_str}~{end_str}' period_label = '週策略(近7日)' elif sub_arg in ('monthly', 'month', '月', '月報') or ( sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg)): m_parts = sub_arg.replace('-', '/').split('/') if sub_arg and '/' in sub_arg else [] if len(m_parts) == 2: yr_s, mo_s = int(m_parts[0]), int(m_parts[1]) else: yr_s, mo_s = now.year, now.month import calendar last_day = calendar.monthrange(yr_s, mo_s)[1] start_str = f'{yr_s}/{mo_s:02d}/01' end_str = f'{yr_s}/{mo_s:02d}/{last_day:02d}' date_str = f'{yr_s}/{mo_s:02d}' period_label = f'{yr_s}/{mo_s:02d} 月策略' elif sub_arg in ('quarterly', 'quarter', 'q', '季', '季報'): end_str = latest_date() or now.strftime('%Y/%m/%d') start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d') - timedelta(days=89)).strftime('%Y/%m/%d') date_str = f'{start_str}~{end_str}' period_label = '季策略(近90日)' elif sub_arg in ('half', 'h1', 'h2', '半年', '半年報'): end_str = latest_date() or now.strftime('%Y/%m/%d') start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d') - timedelta(days=179)).strftime('%Y/%m/%d') date_str = f'{start_str}~{end_str}' period_label = '半年策略(近180日)' elif sub_arg in ('yearly', 'year', 'annual', '年', '年報'): end_str = latest_date() or now.strftime('%Y/%m/%d') start_str = (datetime.strptime(end_str.replace('/', '-'), '%Y-%m-%d') - timedelta(days=364)).strftime('%Y/%m/%d') date_str = f'{start_str}~{end_str}' period_label = '年度策略(近365日)' else: date_str = latest_date() or now.strftime('%Y/%m/%d') start_str, end_str = date_str, date_str period_label = f'{date_str} 日策略' _ppt_check_data_freshness('strategy', _chat_id, _reply_to, requested_date=end_str) params = {'report_type': 'strategy', 'start': start_str, 'end': end_str, 'label': period_label} cached, cached_ai = _load_cached_ppt_path_and_analysis('strategy', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() if start_str == end_str: sales = query_sales(date_str) top_products = query_top_products(date_str, 15) strat = analyze_product_strategy(date_str, 20) else: rng = query_date_range(start_str, end_str) sales = {'revenue': rng.get('revenue', 0), 'orders': rng.get('orders', 0), 'gross_margin': rng.get('gross_margin', 0), 'avg_order': rng.get('avg_order', 0)} top_products = query_top_products_range(start_str, end_str, 15) strat = _analyze_strategy_range(start_str, end_str, top_products) from collections import Counter strat_cnt = Counter(s['strategy'] for s in strat) strat_detail = "\n".join( f" [{k}×{v}件] " + " / ".join( f"{s['name']}(NT${s['revenue']:,.0f})" for s in strat if s['strategy'] == k)[:3] for k, v in strat_cnt.most_common() ) data_summary = ( f"分析週期:{period_label}\n" f"業績:NT$ {float(sales.get('revenue', 0)):,.0f} | " f"訂單:{sales.get('orders', 0)}筆 | " f"毛利率:{sales.get('gross_margin', 0):.1f}% | " f"客單價:NT$ {float(sales.get('avg_order', 0)):,.0f}\n\n" f"策略矩陣分佈:\n{strat_detail}\n\n" f"TOP5商品:" + " / ".join( f"{p['name']}(NT${p['revenue']:,.0f})" for p in top_products[:5]) + "\n\n" f"外部市場信號:{mcp_text[:600]}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'策略簡報({period_label})') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight(f'策略簡報({period_label})', data_summary, mcp_text) db_data = { 'sales': sales, 'top_products': top_products, 'strategy': strat, 'mcp': mcp_text, 'period_label': period_label, } ppt_path = generate_strategy_ppt(date_str, db_data, ai_text) _store_ppt_cache('strategy', params, ppt_path, { 'report_type': 'strategy', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': db_data, 'mcp': mcp_text, }) return ppt_path elif sub_type in ('competitor', '競品', 'compare'): if not _PCHOME_AVAILABLE: raise RuntimeError("PChome 比價模組不可用") if sub_arg in ('weekly', 'week', '週'): end_d = datetime.strptime( (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d') start_d = end_d - timedelta(days=6) period_label = '週比較(近7日)' date_str_for_query = start_d.strftime('%Y/%m/%d') elif sub_arg in ('monthly', 'month', '月'): end_d = datetime.strptime( (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d') start_d = end_d.replace(day=1) period_label = f'{end_d.year}/{end_d.month:02d} 月比較' date_str_for_query = start_d.strftime('%Y/%m/%d') elif sub_arg in ('quarterly', 'quarter', 'q', '季'): end_d = datetime.strptime( (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d') start_d = end_d - timedelta(days=89) period_label = '季比較(近90日)' date_str_for_query = start_d.strftime('%Y/%m/%d') elif sub_arg in ('half', '半年'): end_d = datetime.strptime( (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d') start_d = end_d - timedelta(days=179) period_label = '半年比較(近180日)' date_str_for_query = start_d.strftime('%Y/%m/%d') elif sub_arg in ('yearly', 'year', '年'): end_d = datetime.strptime( (latest_date() or now.strftime('%Y/%m/%d')).replace('/', '-'), '%Y-%m-%d') start_d = end_d - timedelta(days=364) period_label = '年度比較(近365日)' date_str_for_query = start_d.strftime('%Y/%m/%d') elif sub_arg and re.match(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', sub_arg): d_str = normalize_date(sub_arg) start_d = end_d = datetime.strptime(d_str.replace('/', '-'), '%Y-%m-%d') period_label = f'{d_str} 日比較' date_str_for_query = d_str else: yd = (now - timedelta(days=1)).strftime('%Y/%m/%d') start_d = end_d = datetime.strptime(yd.replace('/', '-'), '%Y-%m-%d') period_label = f'{yd} 日比較' date_str_for_query = yd params = { 'report_type': 'competitor', 'start': start_d.strftime('%Y/%m/%d'), 'end': end_d.strftime('%Y/%m/%d'), 'label': period_label, } cached, cached_ai = _load_cached_ppt_path_and_analysis('competitor', params) if cached: return cached mcp_text_c = '' if not cached_ai: mcp_text_c = _fetch_mcp_context() from services.competitor_intel_repository import ( fetch_competitor_comparison_results, fetch_competitor_review_queue, summarize_review_decision_envelopes, ) competitor_engine = _db() results = fetch_competitor_comparison_results( competitor_engine, start_date=start_d.strftime('%Y-%m-%d'), end_date=end_d.strftime('%Y-%m-%d'), limit=30, ) review_queue = fetch_competitor_review_queue(competitor_engine, limit=5) review_decision_brief = summarize_review_decision_envelopes(review_queue, limit=5) found_c = [r for r in results if r.get('found')] pchome_low_price_c = [r for r in found_c if r.get('price_diff', 0) > 10] momo_low_price_c = [r for r in found_c if r.get('price_diff', 0) < -10] unit_comparable_c = [ r for r in results if not r.get('found') and r.get('match_status') in ('unit_comparable', 'refresh_unit_comparable') ] not_found_c = [r for r in results if not r.get('found') and r not in unit_comparable_c] avg_diff_c = (sum(r.get('price_diff_pct', 0) for r in found_c) / len(found_c) if found_c else 0) data_summary = ( f"【可信資料源=指定期間 competitor_price_history;即時報表才用 competitor_prices,MOMO vs PChome】\n" f"分析週期:{period_label}\n" f"掃描商品:{len(results)} 件 | 高信心比對:{len(found_c)} 件 | 需單位價比較:{len(unit_comparable_c)} 件 | 待補身份/價格:{len(not_found_c)} 件\n" f"PChome 低價壓力(PChome 比 MOMO 便宜):{len(pchome_low_price_c)} 件 | MOMO 價格優勢(MOMO 比 PChome 便宜):{len(momo_low_price_c)} 件\n" f"平均價差:{avg_diff_c:+.1f}%(正值=MOMO較貴、PChome低價壓力;負值=MOMO具價格優勢)\n\n" f"PChome低價壓力 TOP3(需研擬因應):" + " / ".join( f"{r['momo_name'][:15]}(PChome便宜NT${abs(r['price_diff']):,.0f})" for r in pchome_low_price_c[:3]) + "\n" f"MOMO價格優勢 TOP3(可加強曝光):" + " / ".join( f"{r['momo_name'][:15]}(MOMO便宜NT${abs(r['price_diff']):,.0f})" for r in momo_low_price_c[:3]) + "\n" f"單位價覆核樣本:" + " / ".join( f"{r['momo_name'][:12]}({(r.get('unit_comparison') or {}).get('summary') or '候選價需換算'})" for r in unit_comparable_c[:3]) + "\n" f"待補資料樣本:" + " / ".join( f"{r['momo_name'][:12]}({r.get('match_status', 'no_valid_match')})" for r in not_found_c[:3]) + "\n\n" f"覆核決策信封(HITL,不可自動寫正式價差):\n{review_decision_brief.get('text')}\n\n" f"外部情報:{mcp_text_c[:400]}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'競品比較簡報({period_label})') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight(f'競品比較簡報({period_label})', data_summary, mcp_text_c) db_data = { 'results': results, 'period_label': period_label, 'review_queue': review_queue, 'review_decision_brief': review_decision_brief, 'mcp': mcp_text_c, } ppt_path = generate_competitor_ppt(period_label, db_data, ai_text) _store_ppt_cache('competitor', params, ppt_path, { 'report_type': 'competitor', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': db_data, 'mcp': mcp_text_c, }) return ppt_path elif sub_type in ('promo', '促銷'): m_p = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', sub_arg) if len(m_p) < 2: raise ValueError(f"促銷簡報需要日期範圍,例如:promo 2026/04/01-2026/04/07") start_s_p = normalize_date(m_p[0]) end_s_p = normalize_date(m_p[1]) promo_label_p = f'{start_s_p}~{end_s_p}' params = {'report_type': 'promo', 'start': start_s_p, 'end': end_s_p, 'label': promo_label_p} cached, cached_ai = _load_cached_ppt_path_and_analysis('promo', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() data_p = query_promo_comparison(start_s_p, end_s_p) if not data_p: raise ValueError("查無促銷期間資料") pd = data_p.get('promo', {}) prev = data_p.get('pre', {}) tops_str = ' / '.join( f"{p['name'][:12]}(NT${p['revenue']:,.0f})" for p in (pd.get('top_products') or [])[:3] ) data_summary_p = ( f"促銷活動期間:{start_s_p} ~ {end_s_p}({pd.get('days',0)}天)\n" f"對比前期:{prev.get('start','')} ~ {prev.get('end','')}\n\n" f"【活動期業績】NT$ {pd.get('revenue',0):,.0f} 訂單 {pd.get('orders',0):,}筆 " f"毛利率 {pd.get('margin',0):.1f}%\n" f"【對比期業績】NT$ {prev.get('revenue',0):,.0f} 訂單 {prev.get('orders',0):,}筆 " f"毛利率 {prev.get('margin',0):.1f}%\n" f"業績成長:{data_p.get('rev_lift',0):+.1f}% 訂單成長:{data_p.get('ord_lift',0):+.1f}%\n\n" f"活動期熱銷 TOP3:{tops_str or '(無資料)'}\n" f"外部市場情報:{mcp_text[:300]}" ) ai_text_p = cached_ai or _ppt_ai_analysis(data_summary_p, f'促銷效益分析({promo_label_p})') if not cached_ai and _ppt_needs_fallback(ai_text_p): ai_text_p = _ppt_fallback_insight(f'促銷效益分析({promo_label_p})', data_summary_p, mcp_text) ppt_path = generate_promo_ppt(promo_label_p, data_p, ai_text_p) _store_ppt_cache('promo', params, ppt_path, { 'report_type': 'promo', 'parameters': params, 'data_summary': data_summary_p, 'analysis': ai_text_p, 'source_data': data_p, 'mcp': mcp_text, }) return ppt_path elif sub_type in ('vendor', '廠商'): # /ppt vendor [YYYY/MM] 指定月份廠商報告 # /ppt vendor [YYYY/MM/DD-YYYY/MM/DD] 自訂期間 if sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg): yr, mo = [int(x) for x in sub_arg.replace('-', '/').split('/')] import calendar as _cal last_day = _cal.monthrange(yr, mo)[1] start_str = f"{yr}/{mo:02d}/01" end_str = f"{yr}/{mo:02d}/{last_day:02d}" period_lbl = f"{yr}/{mo:02d}" # 上期 prev_yr = yr if mo > 1 else yr - 1 prev_mo = mo - 1 if mo > 1 else 12 prev_last = _cal.monthrange(prev_yr, prev_mo)[1] prev_start = f"{prev_yr}/{prev_mo:02d}/01" prev_end = f"{prev_yr}/{prev_mo:02d}/{prev_last:02d}" elif sub_arg and '-' in sub_arg and len(sub_arg) > 15: parts = sub_arg.split('-') start_str = normalize_date(parts[0]) end_str = normalize_date(parts[1]) period_lbl = f"{start_str} ~ {end_str}" yr, mo = (int(start_str.split('/')[0]), int(start_str.split('/')[1])) # 上期 = 同等天數往前推 from datetime import datetime as _dt from datetime import timedelta as _td s = _dt.strptime(start_str.replace('/', '-'), '%Y-%m-%d').date() e = _dt.strptime(end_str.replace('/', '-'), '%Y-%m-%d').date() days = (e - s).days + 1 prev_end_d = s - _td(days=1) prev_start_d = prev_end_d - _td(days=days - 1) prev_start = prev_start_d.strftime('%Y/%m/%d') prev_end = prev_end_d.strftime('%Y/%m/%d') else: # 預設:當月 yr = now.year mo = now.month import calendar as _cal last_day = _cal.monthrange(yr, mo)[1] start_str = f"{yr}/{mo:02d}/01" end_str = f"{yr}/{mo:02d}/{last_day:02d}" period_lbl = f"{yr}/{mo:02d}" prev_yr = yr if mo > 1 else yr - 1 prev_mo = mo - 1 if mo > 1 else 12 prev_last = _cal.monthrange(prev_yr, prev_mo)[1] prev_start = f"{prev_yr}/{prev_mo:02d}/01" prev_end = f"{prev_yr}/{prev_mo:02d}/{prev_last:02d}" params = {'report_type': 'vendor', 'period': period_lbl} cached, cached_ai = _load_cached_ppt_path_and_analysis('vendor', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() curr = query_vendor_summary(start_str, end_str, lim=30) prev = query_vendor_summary(prev_start, prev_end, lim=30) curr['prev_period'] = prev.get('vendor_ranking', []) curr['period_label'] = period_lbl # data summary 給 AI top5 = curr.get('vendor_ranking', [])[:5] top5_str = '\n'.join( f" {i+1}. {v.get('name','')[:30]} — NT${v.get('sales',0):,.0f}" f" | 毛利 {v.get('margin',0):.1f}%" for i, v in enumerate(top5) ) kpis = curr.get('kpis', {}) data_summary = ( f"【期間】{period_lbl}\n" f"【廠商總數】{kpis.get('vendor_count', 0)} 家\n" f"【合計業績】NT${kpis.get('total_sales', 0):,.0f}\n" f"【合計毛利】NT${kpis.get('total_profit', 0):,.0f}\n" f"【平均毛利率】{kpis.get('avg_margin', 0):.1f}%\n\n" f"【TOP 5 廠商】\n{top5_str}\n\n" f"【上期同等期間業績】NT${prev.get('kpis', {}).get('total_sales', 0):,.0f}" f"({prev.get('kpis', {}).get('vendor_count', 0)} 家)\n\n" f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無外部情報)'}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '廠商業績報告') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('廠商業績報告', data_summary, mcp_text) ppt_path = generate_vendor_ppt(yr, mo, curr, ai_text) _store_ppt_cache('vendor', params, ppt_path, { 'report_type': 'vendor', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': curr, 'mcp': mcp_text, }) return ppt_path elif sub_type in ('competitor_v4', 'competitor5', '五力', '競業五力'): # /ppt competitor_v4 [PChome|蝦皮|酷澎] comp_name = sub_arg.strip() if sub_arg else 'PChome' period_label = '近 30 天' params = {'report_type': 'competitor_v4', 'competitor': comp_name} cached, cached_ai = _load_cached_ppt_path_and_analysis('competitor_v4', params) if cached: return cached c5_data = query_competitor_5forces(competitor=comp_name, period=period_label) # 組 data summary forces = c5_data.get('forces', {}) score_lines = [] for k, label in [('product_power', '商品力'), ('price_power', '價格力'), ('marketing_power', '行銷力'), ('service_power', '服務力'), ('brand_power', '品牌力'), ('financial_power', '財務力')]: f = forces.get(k, {}) score_lines.append( f" {label}: momo {f.get('momo', 0):.1f} / " f"{comp_name} {f.get('competitor', 0):.1f} " f"(差 {f.get('momo', 0) - f.get('competitor', 0):+.1f})" ) data_summary = ( f"【競品】{comp_name}\n" f"【期間】{period_label}\n\n" f"【六力評分】\n" + '\n'.join(score_lines) + "\n\n" f"【綜合評分】\n" f" momo {sum(forces[k].get('momo', 0) for k in forces) / 6:.2f}/10 vs " f"{comp_name} {sum(forces[k].get('competitor', 0) for k in forces) / 6:.2f}/10" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '競業五力分析') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('競業五力', data_summary, '') ppt_path = generate_competitor_v4_ppt(period_label, c5_data, ai_text) _store_ppt_cache('competitor_v4', params, ppt_path, { 'report_type': 'competitor_v4', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': c5_data, 'mcp': '', }) return ppt_path elif sub_type in ('price_elasticity', 'price', '價格彈性', '甜蜜點'): # /ppt price_elasticity 全平台 90 天 # /ppt price_elasticity 美妝保養 單品類 # /ppt price_elasticity 美妝保養 30 自訂天數 cat = None days = 90 if sub_arg: parts = sub_arg.strip().split() if parts: # 第一個參數可能是品類,最後一個若為純數字當天數 if parts[-1].isdigit(): days = int(parts[-1]) parts = parts[:-1] if parts: cat = ' '.join(parts) params = {'report_type': 'price_elasticity', 'category': cat or 'all', 'days': days} cached, cached_ai = _load_cached_ppt_path_and_analysis('price_elasticity', params) if cached: return cached pe_data = query_price_elasticity(category=cat, days=days) if not pe_data.get('found'): raise RuntimeError(f'品類 "{cat or "全平台"}" 近 {days} 天無資料') sweet = pe_data.get('sweet_spot', {}) bucket_str = '\n'.join( f" {b.get('range', '')}: {b.get('sku_count', 0)} SKU / " f"{b.get('total_orders', 0):,} 訂單 / NT${b.get('total_revenue', 0):,.0f}" for b in pe_data.get('buckets', []) ) data_summary = ( f"【品類】{pe_data.get('category', '全平台')}\n" f"【期間】近 {days} 天\n" f"【SKU 數】{pe_data.get('sku_count', 0)}\n" f"【總訂單】{pe_data.get('total_orders', 0):,}\n\n" f"【各價位桶分布】\n{bucket_str}\n\n" f"【價格甜蜜點】{sweet.get('range', '—')} " f"(佔 {sweet.get('ratio', 0):.1f}% 訂單," f"平均售價 NT${sweet.get('avg_price', 0):,.0f})" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '價格彈性報告') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('價格彈性', data_summary, '') ppt_path = generate_price_elasticity_ppt(pe_data, ai_text) _store_ppt_cache('price_elasticity', params, ppt_path, { 'report_type': 'price_elasticity', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': pe_data, 'mcp': '', }) return ppt_path elif sub_type in ('market_intel', 'intel', '市場情報', '情報週報'): # /ppt market_intel 本週市場情報 from datetime import datetime as _dt, timedelta as _td if sub_arg and '起一週' in sub_arg: week_label = sub_arg.strip() else: today = now.date() if hasattr(now, 'date') else now # 對齊週一作為週起點 week_start = today - _td(days=today.weekday()) week_label = f"{week_start.strftime('%Y/%m/%d')} 起一週" params = {'report_type': 'market_intel', 'week': week_label} cached, cached_ai = _load_cached_ppt_path_and_analysis('market_intel', params) if cached: return cached # 抓 mcp_collector 各 API(容錯:失敗段落填預設文字) from services.mcp_collector_service import mcp_collector from services.mcp_context_service import ( get_ecommerce_news, get_taiwan_trends, get_dcard_trends, get_youtube_trending, get_taiwan_weather, get_twbank_exchange_rates, ) def _safe(fn, default='(本次擷取失敗或無資料)'): try: r = fn() return r.strip() if r and r.strip() else default except Exception as e: sys_log.warning(f"[market_intel] {fn.__name__} fail: {e}") return default sections = { 'holiday': mcp_collector.get_holiday_context(), 'seasonal': mcp_collector.get_seasonal_context(), 'ecommerce_news': _safe(get_ecommerce_news), 'google_trends': _safe(get_taiwan_trends), 'dcard': _safe(get_dcard_trends), 'youtube': _safe(get_youtube_trending), 'weather': _safe(get_taiwan_weather), 'exchange': _safe(get_twbank_exchange_rates), } # 組 data summary 給 AI data_summary_parts = [f"【本週】{week_label}"] for k, v in sections.items(): data_summary_parts.append(f"\n【{k}】\n{v[:600]}") data_summary = '\n'.join(data_summary_parts) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '市場情報週報') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('市場情報', data_summary, '') ppt_path = generate_market_intel_weekly_ppt(week_label, sections, ai_text) _store_ppt_cache('market_intel', params, ppt_path, { 'report_type': 'market_intel', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': sections, 'mcp': '', }) return ppt_path elif sub_type in ('new_product', 'newproduct', '新品', '新品追蹤'): # /ppt new_product 預設 30 天追蹤 # /ppt new_product 14 自訂追蹤天數 days = 30 if sub_arg and sub_arg.isdigit(): days = int(sub_arg) baseline_days = 60 params = {'report_type': 'new_product', 'days': days} cached, cached_ai = _load_cached_ppt_path_and_analysis('new_product', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() np_data = query_new_products(days_recent=days, days_baseline=baseline_days) if not np_data.get('found'): raise RuntimeError(f'近 {days} 天無新品(前 {baseline_days} 天無交易但近期有銷售的商品)') kpis = np_data.get('kpis', {}) top5_str = '\n'.join( f" {i+1}. {p.get('name','')[:30]} ({p.get('category','—')}) — " f"NT${p.get('revenue', 0):,.0f}" for i, p in enumerate(np_data.get('new_products', [])[:5]) ) sub_str = '\n'.join( f" - {c.get('name','')}: {c.get('sku_count', 0)} 款 / " f"NT${c.get('revenue', 0):,.0f}" for c in np_data.get('sub_categories', [])[:5] ) data_summary = ( f"【追蹤期間】{np_data.get('period', '')}\n" f"【新品總數】{kpis.get('new_count', 0)} 款\n" f"【新品業績】NT${kpis.get('new_revenue', 0):,.0f}\n" f"【業績佔比】{kpis.get('new_pct', 0):.1f}%(vs 整體 NT${kpis.get('total_revenue', 0):,.0f})\n\n" f"【新品 TOP 5】\n{top5_str}\n\n" f"【新品依品類分佈】\n{sub_str}\n\n" f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無)'}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '新品追蹤報告') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('新品追蹤', data_summary, mcp_text) ppt_path = generate_new_product_ppt(np_data, ai_text) _store_ppt_cache('new_product', params, ppt_path, { 'report_type': 'new_product', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': np_data, 'mcp': mcp_text, }) return ppt_path elif sub_type in ('promo_compare', 'promocompare', '促銷比較', '多活動'): # /ppt promo_compare 母親節:2026/05/05-2026/05/14|520:2026/05/18-2026/05/22|618:2026/06/14-2026/06/22 # 用 | 分隔多場活動,每場用 : 分 label/dates if not sub_arg or '|' not in sub_arg: raise RuntimeError( '格式:/ppt promo_compare 活動1:YYYY/MM/DD-YYYY/MM/DD|活動2:...' ) promos_input = [] for chunk in sub_arg.split('|'): if ':' not in chunk: continue lbl, dates = chunk.split(':', 1) if '-' not in dates: continue s_d, e_d = dates.split('-', 1) try: s_d = normalize_date(s_d.strip()) e_d = normalize_date(e_d.strip()) except Exception: continue promos_input.append({'label': lbl.strip(), 'start': s_d, 'end': e_d}) if len(promos_input) < 2: raise RuntimeError('至少需要 2 場活動才能比較') params = {'report_type': 'promo_compare', 'promos': '|'.join(f"{p['label']}:{p['start']}-{p['end']}" for p in promos_input)} cached, cached_ai = _load_cached_ppt_path_and_analysis('promo_compare', params) if cached: return cached # 用 query_promo_comparison 跑每場 all_promos = [] for pi in promos_input: try: cmp = query_promo_comparison(pi['start'], pi['end']) if cmp and cmp.get('promo'): promo_kpi = cmp['promo'] all_promos.append({ 'label': pi['label'], 'start': pi['start'], 'end': pi['end'], 'days': int(promo_kpi.get('days', 1)), 'revenue': float(promo_kpi.get('revenue', 0)), 'orders': int(promo_kpi.get('orders', 0)), 'margin': float(promo_kpi.get('margin', 0)), 'rev_lift': float(cmp.get('rev_lift', 0)), 'ord_lift': float(cmp.get('ord_lift', 0)), }) except Exception as e: sys_log.warning(f"[promo_compare] {pi['label']} fetch fail: {e}") if not all_promos: raise RuntimeError('無法獲取任何活動資料') rankings = { 'best_revenue': max(all_promos, key=lambda x: x['revenue']), 'best_lift': max(all_promos, key=lambda x: x['rev_lift']), 'worst_lift': min(all_promos, key=lambda x: x['rev_lift']), 'best_margin': max(all_promos, key=lambda x: x['margin']), } promo_summary = '\n'.join( f" {i+1}. {p['label']} ({p['start']}~{p['end']}): " f"NT${p['revenue']:,.0f} / 訂單 {p['orders']} / 毛利 {p['margin']:.1f}% / " f"業績拉抬 {p['rev_lift']:+.1f}%" for i, p in enumerate(all_promos) ) data_summary = ( f"【比較活動數】{len(all_promos)} 場\n\n" f"【各活動明細】\n{promo_summary}\n\n" f"【最高業績】{rankings['best_revenue']['label']} " f"NT${rankings['best_revenue']['revenue']:,.0f}\n" f"【最高拉抬】{rankings['best_lift']['label']} " f"+{rankings['best_lift']['rev_lift']:.1f}%\n" f"【最低拉抬】{rankings['worst_lift']['label']} " f"{rankings['worst_lift']['rev_lift']:+.1f}%\n" f"【最佳毛利】{rankings['best_margin']['label']} " f"{rankings['best_margin']['margin']:.1f}%" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '多活動 ROI 比較') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('多活動比較', data_summary, '') label = f"{len(all_promos)} 場活動比較" ppt_path = generate_promo_compare_ppt( label, {'promos': all_promos, 'rankings': rankings}, ai_text ) _store_ppt_cache('promo_compare', params, ppt_path, { 'report_type': 'promo_compare', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': {'promos': all_promos, 'rankings': rankings}, 'mcp': '', }) return ppt_path elif sub_type in ('forecast', 'forecast_pre_event', '檔期前瞻'): # /ppt forecast 母親節 2026/05/12 # /ppt forecast 618 2026/06/18 if not sub_arg: raise RuntimeError('檔期前瞻需指定:/ppt forecast 母親節 2026/05/12') parts = sub_arg.strip().split() if len(parts) < 2: raise RuntimeError('格式:/ppt forecast 檔期名 YYYY/MM/DD') event_name = parts[0] event_date = parts[1] params = {'report_type': 'forecast_pre_event', 'event': event_name, 'date': event_date} cached, cached_ai = _load_cached_ppt_path_and_analysis('forecast_pre_event', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() fc_data = query_forecast_pre_event(event_name, event_date) if not fc_data.get('found'): raise RuntimeError(f'檔期 {event_name} {event_date} 預測失敗:{fc_data.get("error", "未知")}') baseline = fc_data.get('baseline', {}) ly = fc_data.get('last_year', {}) prep = fc_data.get('prep_window', {}) forecast = fc_data.get('forecast', {}) top5_str = '\n'.join( f" {i+1}. {p.get('name','')[:30]} — baseline 業績 NT${p.get('revenue', 0):,.0f}" for i, p in enumerate(fc_data.get('top_products', [])[:5]) ) data_summary = ( f"【檔期】{event_name}({event_date})\n" f"【準備窗口】{fc_data.get('window_start', '')} ~ {fc_data.get('window_end', '')}\n\n" f"【Baseline 期(檔期前 60-30 天)】\n" f" 業績 NT${baseline.get('revenue', 0):,.0f} / " f"日均 NT${baseline.get('avg_daily_revenue', 0):,.0f} / " f"{baseline.get('days', 0)} 天\n\n" f"【去年同檔期 ± 7 天】\n" f" 業績 NT${ly.get('revenue', 0):,.0f} / 訂單 {ly.get('orders', 0):,} 筆\n\n" f"【本期準備窗口已執行】\n" f" 已過 {prep.get('days_passed', 0)}/{prep.get('days_total', 0)} 天 / " f"業績 NT${prep.get('revenue', 0):,.0f}\n\n" f"【預期業績(baseline × lift_factor)】\n" f" NT${forecast.get('expected_revenue', 0):,.0f} (lift {forecast.get('lift_factor', 1):.2f}x)\n\n" f"【Baseline 期 TOP 5 商品(庫存盤點對象)】\n{top5_str}\n\n" f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無)'}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'檔期前瞻({event_name})') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('檔期前瞻', data_summary, mcp_text) ppt_path = generate_forecast_pre_event_ppt(event_name, event_date, fc_data, ai_text) _store_ppt_cache('forecast_pre_event', params, ppt_path, { 'report_type': 'forecast_pre_event', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': fc_data, 'mcp': mcp_text, }) return ppt_path elif sub_type in ('customer', 'customer_analytics', '客戶'): # /ppt customer [YYYY/MM] 指定月客戶分析 # /ppt customer 預設近 30 天 from datetime import datetime as _dt, timedelta as _td if sub_arg and re.match(r'\d{4}[/-]\d{1,2}$', sub_arg): yr_c, mo_c = [int(x) for x in sub_arg.replace('-', '/').split('/')] import calendar as _cal last_d = _cal.monthrange(yr_c, mo_c)[1] start_str = f"{yr_c}/{mo_c:02d}/01" end_str = f"{yr_c}/{mo_c:02d}/{last_d:02d}" period_label = f"{yr_c}/{mo_c:02d}" else: today_d = now.date() if hasattr(now, 'date') else now start_d = today_d - _td(days=30) start_str = start_d.strftime('%Y/%m/%d') end_str = today_d.strftime('%Y/%m/%d') period_label = f"近 30 天 ({start_str} ~ {end_str})" params = {'report_type': 'customer', 'period': period_label} cached, cached_ai = _load_cached_ppt_path_and_analysis('customer', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() cust_data = query_customer_analytics(start_str, end_str) if not cust_data.get('found'): raise RuntimeError(f'期間 {period_label} 無客戶資料') kpis = cust_data.get('kpis', {}) bucket_str = '\n'.join( f" - {b.get('range','')}: {b.get('count', 0):,} 筆訂單" for b in cust_data.get('aov_buckets', []) ) wd_str = '\n'.join( f" - {w.get('weekday','')}: {w.get('count', 0):,} 訂單 / NT${w.get('revenue', 0):,.0f}" for w in cust_data.get('weekday_dist', []) ) repeat_str = '\n'.join( f" - {p.get('name','')[:25]}: 復購 {p.get('repeat_count', 0)} 次" for p in cust_data.get('repeat_products', [])[:5] ) data_summary = ( f"【期間】{period_label}\n" f"【總訂單】{kpis.get('total_orders', 0):,} 筆\n" f"【總業績】NT${kpis.get('total_revenue', 0):,.0f}\n" f"【平均客單】NT${kpis.get('aov', 0):,.0f}\n\n" f"【客單價分佈】\n{bucket_str}\n\n" f"【星期分佈】\n{wd_str}\n\n" f"【商品復購 TOP 5】\n{repeat_str if repeat_str else '(無)'}\n" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, '客戶/訂單分析') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('客戶分析', data_summary, mcp_text) ppt_path = generate_customer_analytics_ppt(period_label, cust_data, ai_text) _store_ppt_cache('customer', params, ppt_path, { 'report_type': 'customer', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': cust_data, 'mcp': mcp_text, }) return ppt_path elif sub_type in ('category', '品類'): # /ppt category 美妝保養 [days] if not sub_arg: raise RuntimeError('品類深度報告需指定品類名稱:/ppt category 美妝保養') parts = sub_arg.strip().split() cat = parts[0] days = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 90 params = {'report_type': 'category', 'category': cat, 'days': days} cached, cached_ai = _load_cached_ppt_path_and_analysis('category', params) if cached: return cached mcp_text = '' if not cached_ai: mcp_text = _fetch_mcp_context() cat_data = query_category_deep(cat, days=days) if not cat_data.get('found'): raise RuntimeError(f'品類 "{cat}" 最近 {days} 天無資料') kpis = cat_data.get('kpis', {}) top5_str = '\n'.join( f" {i+1}. {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}" for i, p in enumerate(cat_data.get('top_products', [])[:5]) ) sub_str = '\n'.join( f" - {c.get('name','')[:20]}: NT${c.get('revenue', 0):,.0f}" for c in cat_data.get('sub_categories', [])[:5] ) new_str = '\n'.join( f" - {p.get('name','')[:30]} — NT${p.get('revenue', 0):,.0f}" for p in cat_data.get('new_products', [])[:5] ) data_summary = ( f"【品類】{cat}\n" f"【期間】{cat_data.get('period', '')}(最近 {days} 天)\n" f"【業績】NT${kpis.get('revenue', 0):,.0f}\n" f"【訂單】{kpis.get('orders', 0):,} 筆\n" f"【毛利率】{kpis.get('gross_margin', 0):.1f}%\n" f"【SKU 總數】{kpis.get('sku_count', 0)}\n" f"【廠商數】{kpis.get('vendor_count', 0)}\n\n" f"【子品類 TOP 5】\n{sub_str}\n\n" f"【熱銷商品 TOP 5】\n{top5_str}\n\n" f"【近 30 天新進榜】\n{new_str if new_str else '(無)'}\n\n" f"【MCP 外部市場情報】\n{mcp_text[:500] if mcp_text else '(無)'}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'品類深度報告({cat})') if not cached_ai and _ppt_needs_fallback(ai_text): ai_text = _ppt_fallback_insight('品類深度', data_summary, mcp_text) cat_data['mcp'] = mcp_text ppt_path = generate_category_deep_ppt(cat, cat_data, ai_text) _store_ppt_cache('category', params, ppt_path, { 'report_type': 'category', 'parameters': params, 'data_summary': data_summary, 'analysis': ai_text, 'source_data': cat_data, 'mcp': mcp_text, }) 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, 'source_data': db_data_pr, 'mcp': mcp_text, }) return ppt_path else: raise RuntimeError( f'不支援的簡報類型:{sub_type}' f'(支援:daily / weekly / monthly / quarterly / half_yearly / annual / ttm / strategy / competitor / promo / vendor)' ) # ── Telegram Excel 匯入 ────────────────────────────────────────── # 必要欄位定義 _EXCEL_REQUIRED_COLS = ['日期', '商品ID', '商品名稱', '總業績', '數量'] _EXCEL_OPTIONAL_COLS = ['廠商名稱', '總成本', '商品分類L1', '毛%', '折扣活動名稱', '折價券活動名稱'] def _validate_excel_format(filepath: str, filename: str) -> dict: """ 讀取 Excel,回傳格式驗證報告 dict: ok, row_count, col_count, missing_required, present_optional, dates, date_count, rev_total, report_type, warnings """ import pandas as pd import os result = { 'ok': False, 'row_count': 0, 'col_count': 0, 'missing_required': [], 'present_optional': [], 'dates': [], 'date_count': 0, 'rev_total': 0.0, 'report_type': '未知格式', 'warnings': [], } try: df = pd.read_excel(filepath, engine='openpyxl', dtype=str) if df.empty: result['warnings'].append('Excel 檔案為空') return result result['row_count'] = len(df) result['col_count'] = len(df.columns) cols = df.columns.tolist() # 判斷報表類型 if '即時業績' in filename and '當日' in filename: result['report_type'] = '當日業績報表' elif '即時業績' in filename and '全月' in filename: result['report_type'] = '全月業績報表' else: result['report_type'] = '業績報表(自動偵測)' result['warnings'].append('檔名不符合標準格式(建議:即時業績_當日_YYYYMMDD.xlsx)') # 必要欄位檢查 result['missing_required'] = [c for c in _EXCEL_REQUIRED_COLS if c not in cols] result['present_optional'] = [c for c in _EXCEL_OPTIONAL_COLS if c in cols] # 日期分析(找日期欄) date_col = next((c for c in ['日期', '訂單日期', '交易日期'] if c in cols), None) if date_col: import pandas as _pd2 dates_s = _pd2.to_datetime(df[date_col], errors='coerce').dt.strftime('%Y/%m/%d') result['dates'] = sorted(dates_s.dropna().unique().tolist()) result['date_count'] = len(result['dates']) # 業績預覽 if '總業績' in cols: try: result['rev_total'] = df['總業績'].apply( lambda x: float(str(x).replace(',', '').strip() or '0') ).sum() except Exception as _e: result['warnings'].append(f'業績預覽解析失敗:{str(_e)[:80]}') result['ok'] = len(result['missing_required']) == 0 except Exception as _e: result['warnings'].append(f'讀取失敗:{str(_e)[:80]}') return result def _fmt_excel_validation_report(filename: str, v: dict) -> str: """格式化 Excel 驗證報告為 Telegram 訊息""" ok_mark = '✅' if v['ok'] else '❌' lines = [ f"📊 *Excel 格式驗證報告*", f"{'━' * 24}", f"", f"📄 *檔案*:`{filename}`", f"📋 *類型*:{v['report_type']}", f"📦 *資料量*:{v['row_count']:,} 筆 × {v['col_count']} 欄", f"", ] # 必要欄位 if v['missing_required']: missing_str = ' '.join(f'❌ {c}' for c in v['missing_required']) lines.append(f"❌ *必要欄位缺失*:{missing_str}") lines.append(f" _缺少以上欄位,無法匯入_") else: present_str = ' '.join(f'✔ {c}' for c in _EXCEL_REQUIRED_COLS) lines.append(f"✅ *必要欄位*(全部通過)") lines.append(f" {present_str}") if v['present_optional']: opt_str = ' '.join(f'✔ {c}' for c in v['present_optional']) lines.append(f"📌 *附加欄位*:{opt_str}") lines.append("") # 日期範圍 if v['dates']: if v['date_count'] == 1: lines.append(f"📅 *涵蓋日期*:`{v['dates'][0]}`(1 天)") elif v['date_count'] <= 5: date_str = ' / '.join(v['dates']) lines.append(f"📅 *涵蓋日期*:{v['date_count']} 天({date_str})") else: lines.append(f"📅 *涵蓋日期*:{v['dates'][0]} ~ {v['dates'][-1]}(共 {v['date_count']} 天)") if v['rev_total']: lines.append(f"💰 *業績預覽*:`NT$ {v['rev_total']:,.0f}`") if v['warnings']: lines.append("") for w in v['warnings']: lines.append(f"⚠️ {w}") lines.append("") lines.append(f"{'━' * 24}") if v['ok']: lines.append(f"✅ *格式驗證通過,可以匯入!*") lines.append(f"_寫入後將覆蓋相同日期的現有資料_") else: lines.append(f"❌ *格式驗證失敗,無法匯入*") lines.append(f"_請修正上述缺失欄位後重新上傳_") return "\n".join(lines) def _download_telegram_file(file_id: str, suffix: str = '.xlsx') -> str: """從 Telegram 下載檔案到 /tmp,回傳本地路徑;失敗回傳 ''""" try: r = requests.get(f"{BOT_API_URL}/getFile", params={'file_id': file_id}, timeout=10) file_path_tg = r.json().get('result', {}).get('file_path', '') if not file_path_tg: return '' file_url = f"https://api.telegram.org/file/bot{BOT_TOKEN}/{file_path_tg}" file_data = requests.get(file_url, timeout=60).content import tempfile as _tf tmp = _tf.NamedTemporaryFile(suffix=suffix, prefix='ocbot_xl_', delete=False, dir='/tmp') tmp.write(file_data) tmp.close() return tmp.name except Exception as _e: sys_log.error(f"[ExcelImport] download failed: {_e}") return '' def _handle_excel_import(doc: dict, chat_id: int, reply_to: int): """背景執行緒:下載 Excel → 驗證 → 發送驗證報告 + 確認按鈕""" filename = doc.get('file_name', 'upload.xlsx') file_id = doc.get('file_id', '') file_size = doc.get('file_size', 0) # 大小限制 50MB if file_size > 50 * 1024 * 1024: send_message(chat_id, "⚠️ 檔案太大(上限 50MB),請壓縮後重新上傳", reply_to) return send_message(chat_id, f"⏳ 正在下載 Excel 檔案...\n`{filename}`", reply_to, parse_mode='Markdown') suffix = '.xlsx' if filename.lower().endswith('.xlsx') else '.xls' local_path = _download_telegram_file(file_id, suffix) if not local_path: send_message(chat_id, "❌ 檔案下載失敗,請重試", reply_to) return # 驗證格式 v = _validate_excel_format(local_path, filename) report_text = _fmt_excel_validation_report(filename, v) if v['ok']: # 儲存待確認資訊 _excel_pending[chat_id] = { 'file_path': local_path, 'filename': filename, } kb = [ _row(('✅ 確認匯入資料庫', 'cmd:import_confirm'), ('❌ 取消', 'cmd:import_cancel')), ] send_message(chat_id, report_text, reply_to, kb) else: # 驗證失敗 → 直接顯示報告,不提供匯入選項 try: import os os.unlink(local_path) except Exception: pass send_message(chat_id, report_text, reply_to) # ── 排程自動報告 ─────────────────────────────────────────────── def send_morning_report(): """每日 08:30 早報 — P10 升級版:TOP15 + PPT導引按鈕""" try: now = datetime.now(TAIPEI_TZ) td = now.strftime('%Y/%m/%d') yd = (now - timedelta(days=1)).strftime('%Y/%m/%d') wdays = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] sales = query_sales(yd) top15 = query_top_products(yd, 15) weekly = query_weekly_trend() # 計算7日均值做基準 avg7 = 0 if weekly and len(weekly) >= 3: avg7 = sum(w['revenue'] for w in weekly[-7:]) / min(7, len(weekly)) lines = [ f"☀️ *早安!今天是 {now.strftime('%m/%d')}({wdays[now.weekday()]})*", f"{'─' * 30}", "", ] if sales.get('found'): rev = float(sales['revenue']) orders = sales.get('orders', 0) or 0 margin = float(sales.get('gross_margin', 0)) lines.append(f"📅 *昨日業績回顧* _({yd})_") lines.append(f" 💰 業績:`NT$ {rev:,.0f}` 📦 訂單:`{orders:,}` 筆") lines.append(f" 🛒 客單:`NT$ {float(sales.get('avg_order',0)):,.0f}` 📈 毛利率:`{margin:.1f}%`") if avg7 > 0: diff = rev - avg7 pct = diff / avg7 * 100 arrow = '▲' if diff >= 0 else '▼' vs_avg = f"{arrow}{abs(pct):.1f}% vs 7日均 (`NT$ {diff:+,.0f}`)" lines.append(f" 📊 {vs_avg}") else: lines.append(f"📅 _昨日資料尚未匯入({yd})_") lines.append("") if top15: lines.append(f"🏆 *昨日熱銷 TOP15*") lines.append(f"{'─' * 26}") for i, p in enumerate(top15): pid = p.get('id', '') or '' sid = _short_id(pid) link = _pchome_link(pid, p['name'], 20) rank = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`" lines.append(f" {rank} {link}") lines.append(f" 🆔 `{sid}` `NT$ {p['revenue']:,.0f}` 📦 {p.get('qty','-')}件") lines.append("") gs = get_goal_status(td) if gs.get('daily_goal'): dg = gs['daily_goal'] lines.append(f"🎯 *今日目標* `NT$ {dg:,.0f}`") lines.append(f" 💪 加油衝刺!祝業績長紅!") elif gs.get('monthly_goal'): mg = gs['monthly_goal'] lines.append(f"🎯 *月目標* `NT$ {mg:,.0f}`") lines.append(f" 月累計:`NT$ {gs.get('month_rev',0):,.0f}` (`{gs.get('monthly_pct',0) or 0:.1f}%`)") else: lines.append("💪 *今日加油!* 祝業績長紅!") lines.append("") try: from services.mcp_context_service import get_taiwan_weather taipei = get_taiwan_weather().get('weather', {}).get('臺北市', {}) if taipei: wx = taipei.get('Wx', '') pop = taipei.get('PoP', '') temp = taipei.get('temp', '') or f"{taipei.get('MinT','')}-{taipei.get('MaxT','')}°C" weather_line = f"🌤 *台北天氣* {wx}" if temp.strip('-'): weather_line += f" {temp}" if pop: weather_line += f" 💧降雨 {pop}%" lines.append(weather_line) if int(pop or 0) >= 60: lines.append(" ☂️ _建議做室內促銷活動_") except Exception: pass # P10 — 導引按鈕:產出日報PPT + 查看業績數據 morning_kb = [ _row((f'📊 產出 {yd} 日報PPT', f'cmd:ppt:daily {yd}'), ('📈 業績數據', f'cmd:sales:{yd}')), _row(('🏆 完整熱銷排行', f'cmd:top:{yd}'), ('📋 下載 Excel', f'cmd:report:{yd}')), ] send_message(ALLOWED_GROUP, "\n".join(lines), keyboard=morning_kb, parse_mode='Markdown') sys_log.info("[OpenClawBot] 早報已發送") except Exception as e: sys_log.error(f"[OpenClawBot] 早報失敗: {e}") def send_evening_report(): """每日 21:00 晚報""" try: now = datetime.now(TAIPEI_TZ) td = now.strftime('%Y/%m/%d') sales = query_sales(td) weekly = query_weekly_trend() lines = [ f"🌙 *今日業績收盤 {now.strftime('%m/%d')} 21:00*", f"{'─' * 30}", "", ] if sales.get('found'): rev = float(sales['revenue']) orders = sales.get('orders', 0) or 0 prods = sales.get('products', 0) or 0 avg_o = float(sales.get('avg_order', 0)) margin = float(sales.get('gross_margin', 0)) lines.append(f"💰 *今日業績* `NT$ {rev:,.0f}`") lines.append(f"📦 訂單 `{orders:,}` 筆 🛍 商品 `{prods:,}` 件 🛒 客單 `NT$ {avg_o:,.0f}`") lines.append(f"📈 毛利率 `{margin:.1f}%`") lines.append("") gs = get_goal_status(td) if gs.get('daily_goal'): pct = gs.get('daily_pct', 0) or 0 dg = gs['daily_goal'] gap = dg - rev bar_len = min(10, max(0, int(pct // 10))) bar = '█' * bar_len + '░' * (10 - bar_len) result = "🏆 超標達成!" if gap <= 0 else f"差 `NT$ {gap:,.0f}` 達標" lines.append(f"🎯 *日目標達成* `[{bar}]` *{pct:.1f}%* {result}") lines.append("") # vs 昨日比較 if weekly and len(weekly) >= 2: yd_rev = weekly[-2]['revenue'] if len(weekly) >= 2 else 0 if yd_rev: diff = rev - yd_rev pct_d = diff / yd_rev * 100 arrow = '▲' if diff >= 0 else '▼' emoji = '📈' if diff >= 0 else '📉' lines.append(f"{emoji} *vs 昨日* {arrow}`{abs(pct_d):.1f}%` (`NT$ {diff:+,.0f}`)") lines.append("") else: lines.append("⚠️ *今日業績資料尚未匯入*") lines.append("") top15_ev = query_top_products(td, 15) if top15_ev: lines.append(f"🏆 *今日熱銷 TOP15*") lines.append(f"{'─' * 26}") for i, p in enumerate(top15_ev): pid = p.get('id', '') or '' sid = _short_id(pid) link = _pchome_link(pid, p['name'], 20) rank = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`" lines.append(f" {rank} {link}") lines.append(f" 🆔 `{sid}` `NT$ {p['revenue']:,.0f}` 📦 {p.get('qty','-')}件") lines.append("") try: strat = analyze_product_strategy(td, 8) hot_list = [s for s in strat if s['strategy'] == '加碼'][:2] opp_list = [s for s in strat if s['strategy'] == '機會'][:2] if hot_list or opp_list: lines.append(f"🧬 *明日行動建議*") for s in hot_list: pid_s = s.get('id', '') or '' link_s = _pchome_link(pid_s, s['name'], 18) lines.append(f" 🔥 加碼:{link_s} 週▲{abs(s.get('growth',0))*100:.0f}%") for s in opp_list: pid_s = s.get('id', '') or '' link_s = _pchome_link(pid_s, s['name'], 18) lines.append(f" 💡 機會:{link_s}") lines.append("") except Exception: pass # P10 — 晚報導引按鈕 evening_kb = [ _row((f'📊 產出 {td} 日報PPT', f'cmd:ppt:daily {td}'), ('📈 完整業績數據', f'cmd:sales:{td}')), _row(('📋 下載 Excel 報表', f'cmd:report:{td}'), ('🧬 策略矩陣分析', f'cmd:strategy:{td}')), ] send_message(ALLOWED_GROUP, "\n".join(lines), keyboard=evening_kb, parse_mode='Markdown') sys_log.info("[OpenClawBot] 晚報已發送") except Exception as e: sys_log.error(f"[OpenClawBot] 晚報失敗: {e}") def send_weekly_report(): """每週一 09:00 週報""" try: now = datetime.now(TAIPEI_TZ) td = now.strftime('%Y/%m/%d') weekly = query_weekly_trend() WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] lines = [ f"📊 *週報 {now.strftime('%m/%d')}(週一)*", f"{'─' * 30}", "", ] if weekly: total = sum(d.get('revenue', 0) for d in weekly) avg = total / len(weekly) max_r = max(d['revenue'] for d in weekly) min_r = min(d['revenue'] for d in weekly) max_rev = max_r or 1 lines.append(f"📅 *上週業績總覽*") lines.append(f" 合計:`NT$ {total:,.0f}` 日均:`NT$ {avg:,.0f}`") lines.append(f" 最高:`NT$ {max_r:,.0f}` 最低:`NT$ {min_r:,.0f}`") lines.append("") lines.append("*逐日明細*") for i, d in enumerate(weekly[-7:]): rev = d.get('revenue', 0) bar_len = max(1, int(rev / max_rev * 8)) bar = '█' * bar_len + '·' * (8 - bar_len) try: from datetime import datetime as dt d_obj = dt.strptime(d['date'].replace('/', '-'), '%Y-%m-%d') wday = WEEKDAYS_ZH[d_obj.weekday()] except Exception: wday = '' # vs 前日漲跌 if i > 0: prev = weekly[i - 1]['revenue'] pct_chg = (rev - prev) / prev * 100 if prev else 0 chg_str = f" {'▲' if pct_chg >= 0 else '▼'}{abs(pct_chg):.1f}%" else: chg_str = '' lines.append(f" `{d['date']}` {wday} `{bar}` `NT$ {rev:>10,.0f}`{chg_str}") lines.append("") top5 = query_top_products(td, 5) if top5: lines.append(f"🏆 *本週熱銷 TOP5*") for i, p in enumerate(top5[:5]): sid = _short_id(p.get('id', '')) lines.append(f" {MEDALS[i]} {p['name'][:18]} `NT$ {p['revenue']:,.0f}`") lines.append(f" 🆔 `{sid}`") lines.append("") lines.append(f"📋 [報表中心]({MOMO_BASE_URL}/reports)") # 附趨勢圖 chart = gen_trend_chart(14) if chart: send_photo(ALLOWED_GROUP, chart, caption="\n".join(lines)) os.unlink(chart) else: send_message(ALLOWED_GROUP, "\n".join(lines), parse_mode='Markdown') sys_log.info("[OpenClawBot] 週報已發送") except Exception as e: sys_log.error(f"[OpenClawBot] 週報失敗: {e}") def check_anomalies(): """每日 9/12/15/18 點異常偵測(整體業績 + 商品級)""" try: now = datetime.now(TAIPEI_TZ) td = now.strftime('%Y/%m/%d') ts = now.strftime('%H:%M') alerts = [] # ── ① 整體業績異常(今日 vs 7日均) ────────────────── try: weekly = query_weekly_trend() today_data = next((w for w in weekly if w['date'] == td), None) past_data = [w for w in weekly if w['date'] != td] if today_data and len(past_data) >= 3: avg7 = sum(w['revenue'] for w in past_data[-7:]) / len(past_data[-7:]) if avg7 > 0: pct = (today_data['revenue'] - avg7) / avg7 * 100 if abs(pct) >= 25: direction = '📈 急升' if pct > 0 else '📉 急降' tip = '建議加強曝光推廣' if pct > 0 else '⚠️ 請立即確認原因(活動下架?資料延遲?)' alerts.insert(0, f"🔔 *整體業績異常* {direction} {abs(pct):.0f}%\n" f" 今日:`NT$ {today_data['revenue']:,.0f}` " f"7日均:`NT$ {avg7:,.0f}`\n" f" 💡 {tip}" ) except Exception as _e: sys_log.warning(f"[anomaly] 整體業績檢查失敗: {_e}") # ── ② 商品級異常(偏差>30%) ───────────────────────── anomalies = query_anomalies(td) for a in anomalies: pct = a.get('pct', 0) or 0 if abs(pct) < 30: continue direction = '📈 *急升*' if pct > 0 else '📉 *急降*' today_rev = a.get('today', 0) avg7_rev = a.get('avg7', 0) sid = _short_id(a.get('id', '')) if avg7_rev: ratio = today_rev / avg7_rev bar_len = min(10, max(0, int(ratio * 5))) ratio_bar = '█' * bar_len + '░' * (10 - bar_len) alerts.append( f"{direction} {abs(pct):.0f}%\n" f" 商品:{a['name'][:20]}\n" f" 🆔 `{sid}`\n" f" 今日:`NT$ {today_rev:,.0f}` 7日均:`NT$ {avg7_rev:,.0f}`\n" f" 比率:`{ratio_bar}` {ratio:.1f}x" ) else: alerts.append( f"{direction} {abs(pct):.0f}%\n" f" 商品:{a['name'][:20]}\n" f" 🆔 `{sid}`\n" f" 今日:`NT$ {today_rev:,.0f}` 7日均:`NT$ {avg7_rev:,.0f}`" ) if alerts: header = ( f"🚨 *業績異常偵測 {td} {ts}*\n" f"{'─' * 28}\n" f"共發現 {len(alerts)} 件異常\n\n" ) msg = header + "\n\n".join(alerts) ack_kb = [ _row(('✅ 已知悉', 'cmd:ack:anomaly'), ('🔄 追蹤中', 'cmd:ack:tracking')), _row(('📊 查看今日業績', f'cmd:sales:{td}'), ('🏆 熱銷商品', f'cmd:top:{td}')), ] send_message(ALLOWED_GROUP, msg, keyboard=ack_kb, parse_mode='Markdown') sys_log.info(f"[OpenClawBot] 異常告警 {len(alerts)} 筆") else: sys_log.info(f"[OpenClawBot] 異常偵測完成,無告警 ({ts})") except Exception as e: sys_log.error(f"[OpenClawBot] check_anomalies: {e}") def send_competitor_report(): """每日 08:00 推播競品比價日報 + 降價警報""" if not _PCHOME_AVAILABLE: return try: yesterday = (datetime.now(TAIPEI_TZ).date() - timedelta(days=1)).strftime('%Y/%m/%d') sys_log.info(f'[PChome] 每日競品日報開始 {yesterday}') results = pchome_batch(_db(), top_n=30, date_str=yesterday) pchome_save(_db(), results) msg = pchome_fmt_report(results, yesterday) kb = [_row(('🔍 搜尋比價', 'await:search_compare'), ('📄 比價簡報', 'menu:competitor_ppt'))] send_message(ALLOWED_GROUP, msg, None, kb) sys_log.info(f'[PChome] 競品日報已推播 {len(results)} 件商品') # ── 追蹤價格變動,有降價則額外發送警報 ────────────── try: changes = track_competitor_price_changes(results) if changes: alert_lines = [ f"⚡ *momo 降價警報!* 共 {len(changes)} 件", f"{'─' * 26}", f"_momo 降價 ≥5% → PChome 需評估因應策略_", "", ] for c in changes[:8]: alert_lines.append( f"📉 *{c['name']}*\n" f" `NT$ {c['prev_price']:,.0f}` → `NT$ {c['curr_price']:,.0f}` " f"*{c['pct']:+.1f}%*" ) send_message(ALLOWED_GROUP, "\n".join(alert_lines), parse_mode='Markdown') sys_log.info(f"[price_track] 降價警報 {len(changes)} 件") except Exception as _pe: sys_log.warning(f"[price_track] {_pe}") except Exception as e: sys_log.error(f'[PChome] send_competitor_report: {e}', exc_info=True) def send_daily_excel(): """每日 08:45 自動發送昨日 Excel 業績報表""" try: yesterday = (datetime.now(TAIPEI_TZ).date() - timedelta(days=1)).strftime('%Y/%m/%d') sys_log.info(f"[AutoExcel] 開始產生 {yesterday} Excel 報表") path = generate_daily_pdf(yesterday) if not path: sys_log.warning("[AutoExcel] 報表產生失敗") return ext = 'Excel' if path.endswith('.xlsx') else 'CSV' caption = ( f"📊 *{yesterday} 完整業績報表({ext})*\n" f"_自動定時發送 — 每日 08:45_" ) send_document(ALLOWED_GROUP, path, caption=caption) try: import os as _os; _os.unlink(path) except Exception: pass sys_log.info(f"[AutoExcel] {yesterday} 報表已發送") except Exception as e: sys_log.error(f"[AutoExcel] {e}") def start_scheduler(): """啟動排程(Flask app 啟動後呼叫)""" global _scheduler try: if _scheduler is not None and _scheduler.running: sys_log.info("[OpenClawBot] Scheduler 已在執行中,跳過重複啟動") return from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger _scheduler = BackgroundScheduler(timezone='Asia/Taipei') _scheduler.add_job( send_morning_report, CronTrigger(hour=8, minute=30), id="openclaw_send_morning_report", replace_existing=True, ) _scheduler.add_job( send_competitor_report, CronTrigger(hour=8, minute=0), id="openclaw_send_competitor_report", replace_existing=True, ) _scheduler.add_job( send_daily_excel, CronTrigger(hour=8, minute=45), id="openclaw_send_daily_excel", replace_existing=True, ) _scheduler.add_job( send_evening_report, CronTrigger(hour=21, minute=0), id="openclaw_send_evening_report", replace_existing=True, ) _scheduler.add_job( send_weekly_report, CronTrigger(day_of_week='mon', hour=9, minute=0), id="openclaw_send_weekly_report", replace_existing=True, ) _scheduler.add_job( check_anomalies, CronTrigger(hour='9,12,15,18', minute=0), id="openclaw_check_anomalies", replace_existing=True, ) # ADR-019 Phase 6: 每日 09:00 主動巡檢資料新鮮度,缺資料時透過 EventRouter 發警告 try: from services.data_freshness_probe import run_data_freshness_probe _scheduler.add_job( run_data_freshness_probe, CronTrigger(hour=9, minute=5), id="openclaw_data_freshness_probe", replace_existing=True, ) except ImportError as _e: sys_log.warning(f"[OpenClawBot] data_freshness_probe 未安裝,跳過:{_e}") _scheduler.start() sys_log.info("[OpenClawBot] Scheduler started ✓ (competitor/morning/excel/evening/weekly/anomaly/freshness)") except ImportError: sys_log.warning("[OpenClawBot] APScheduler 未安裝 — 排程功能停用") except Exception as e: sys_log.error(f"[OpenClawBot] start_scheduler: {e}") def register_commands(): """Telegram 只支援英文小寫指令""" cmds = [ {'command': 'sales', 'description': '今日業績總覽'}, {'command': 'top', 'description': '熱銷商品 TOP10(含商品ID)'}, {'command': 'vendor', 'description': '熱銷廠商排行'}, {'command': 'trend', 'description': '近7日業績趨勢'}, {'command': 'compare', 'description': '同期比較(vs上週/上月)'}, {'command': 'category', 'description': '分類業績鑽取'}, {'command': 'restock', 'description': '📦 補貨預測(銷售速度分析)'}, {'command': 'promo', 'description': '🎉 促銷效益追蹤 /promo 2026/04/01-2026/04/07'}, {'command': 'goal', 'description': '目標達成率(/goal 200000 設定)'}, {'command': 'chart', 'description': '業績趨勢圖'}, {'command': 'health', 'description': '商品健康分析'}, {'command': 'strategy', 'description': '商品策略矩陣'}, {'command': 'ppt', 'description': '生成業績簡報 /ppt daily|weekly|monthly|strategy'}, {'command': 'report', 'description': '下載完整業績報表 (Excel)'}, {'command': 'history', 'description': '月份業績總覽(/history 2026/03)'}, {'command': 'news', 'description': '即時電商新聞'}, {'command': 'weather', 'description': '今日天氣(行銷參考)'}, {'command': 'menu', 'description': '顯示主選單'}, {'command': 'help', 'description': '使用說明'}, ] return _tg('setMyCommands', {'commands': cmds}) _AWAIT_PROMPTS = { 'date_sales': ('📅 請輸入查詢日期\n格式:`2026/04/15`', '業績日期'), 'date_range_sales': ( '📅 請輸入日期或日期區間\n\n' '📌 單日:`2026/04/15`\n' '📌 區間:`2026/04/01-2026/04/15`\n' '📌 月份:`2026/04`', '日期/區間', ), 'date_top': ('📅 請輸入查詢日期\n格式:`2026/04/15`', '商品日期'), 'date_analysis': ('📅 請輸入分析日期\n格式:`2026/04/15`', '分析日期'), 'date_ppt_daily': ('📅 請輸入日報日期\n格式:`2026/04/15`', 'PPT日期'), 'date_ppt_monthly':('📅 請輸入月報月份\n格式:`2026/03`', 'PPT月份'), 'goal_daily': ('🎯 請輸入每日業績目標金額(NT$)\n例如:`150000`', '日目標'), 'goal_monthly': ('🎯 請輸入每月業績目標金額(NT$)\n例如:`3000000`', '月目標'), 'goal_quarterly': ('🎯 請輸入每季業績目標金額(NT$)\n例如:`9000000`', '季目標'), 'goal_half': ('🎯 請輸入半年業績目標金額(NT$)\n例如:`18000000`', '半年目標'), 'goal_yearly': ('🎯 請輸入全年業績目標金額(NT$)\n例如:`36000000`', '年目標'), 'search_compare': ('🔍 請輸入商品關鍵字\n例如:`休足時間貼片`', '比價關鍵字'), 'date_trend_month': ('📅 請輸入查詢月份\n格式:`2026/03`', '趨勢月份'), 'date_trend_year': ('📅 請輸入查詢年份\n格式:`2026`', '趨勢年份'), 'date_trend_quarter':('📅 請輸入查詢季度\n格式:`2026/Q1`(Q1~Q4)', '趨勢季度'), 'date_competitor': ('📅 請輸入競品分析日期\n格式:`2026/04/15`', '競品日期'), 'promo_range': ('🎉 請輸入促銷日期範圍\n格式:`2026/04/01-2026/04/07`\n(活動開始日-活動結束日)', '促銷範圍'), } def sales_quick_kb(date_str): try: from datetime import datetime as dt d = dt.strptime(date_str.replace('/', '-'), '%Y-%m-%d').date() yesterday = (d - timedelta(days=1)).strftime('%Y/%m/%d') return [ _row(('⬅️ 昨日業績', f'cmd:sales:{yesterday}'), ('🏆 熱銷商品', f'cmd:top:{date_str}')), _row(('📋 完整報表', f'cmd:report:{date_str}')), ] except Exception: return None # ── 資料查詢 ────────────────────────────────────────────────── def _db(): return DatabaseManager().engine def normalize_date(s: str) -> str: return s.replace('-', '/') if s else s def latest_date() -> str: try: with _db().connect() as c: row = c.execute( text('SELECT MAX("日期") FROM realtime_sales_monthly') ).fetchone() return str(row[0]) if row and row[0] else None except Exception: return None configure_menu_keyboards(latest_date_provider=latest_date, goals=_GOALS, taipei_tz=TAIPEI_TZ) def query_sales(d: str) -> dict: try: 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") FROM realtime_sales_monthly WHERE "日期"=:d """), {'d': d}).fetchone() if row and row[0] > 0: rev, cost, orders = row[1], row[2], row[0] return {'found': True, 'date': d, 'orders': orders, 'revenue': rev, 'avg_order': rev/orders if orders else 0, 'gross_margin': (rev-cost)/rev*100 if rev>0 else 0, 'products': row[3]} return {'found': False, 'date': d} except Exception as e: sys_log.error(f"[OpenClawBot] query_sales: {e}") return {'found': False, 'date': d} def query_top_products(d, lim=10): try: with _db().connect() as c: rows = c.execute(text(""" SELECT "商品ID", "商品名稱", SUM(CAST("總業績" AS FLOAT)), SUM(CAST("數量" AS INTEGER)) FROM realtime_sales_monthly WHERE "日期"=:d GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT :lim """), {'d': d, 'lim': lim}).fetchall() return [{'id': r[0], 'name': r[1], 'revenue': r[2], 'qty': r[3]} for r in rows] except Exception: return [] def query_vendor_summary(start_date: str, end_date: str, lim: int = 30) -> dict: """查詢期間廠商業績摘要(vendor PPT 用) 回傳:{ vendor_ranking: [{name, sales, profit, margin, qty, orders}, ...] (TOP lim), kpis: {total_sales, total_profit, avg_margin, vendor_count}, period_label: 'YYYY/MM/DD ~ YYYY/MM/DD' } """ try: with _db().connect() as c: row = c.execute(text(""" SELECT COUNT(DISTINCT "廠商名稱"), COALESCE(SUM(CAST("總業績" AS FLOAT)), 0), COALESCE(SUM(CAST("總成本" AS FLOAT)), 0) FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) AND "廠商名稱" IS NOT NULL AND "廠商名稱" != '' """), {'s': start_date.replace('/', '-'), 'e': end_date.replace('/', '-')}).fetchone() vendor_rows = c.execute(text(""" SELECT "廠商名稱", SUM(CAST("總業績" AS FLOAT)) AS sales, SUM(CAST("總成本" AS FLOAT)) AS cost, 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) AND "廠商名稱" IS NOT NULL AND "廠商名稱" != '' GROUP BY "廠商名稱" ORDER BY sales DESC LIMIT :lim """), {'s': start_date.replace('/', '-'), 'e': end_date.replace('/', '-'), 'lim': lim}).fetchall() vcount, total_sales, total_cost = (row[0] or 0), float(row[1] or 0), float(row[2] or 0) total_profit = total_sales - total_cost avg_margin = total_profit / total_sales * 100 if total_sales else 0 vendor_ranking = [] for r in vendor_rows: sales = float(r[1] or 0) cost = float(r[2] or 0) profit = sales - cost margin = profit / sales * 100 if sales else 0 vendor_ranking.append({ 'name': r[0], 'sales': sales, 'profit': profit, 'margin': margin, 'qty': int(r[3] or 0), 'orders': int(r[4] or 0), }) return { 'vendor_ranking': vendor_ranking, 'kpis': { 'total_sales': total_sales, 'total_profit': total_profit, 'avg_margin': avg_margin, 'vendor_count': vcount, }, 'period_label': f"{start_date} ~ {end_date}", } except Exception as e: sys_log.error(f"[query_vendor_summary] {e}") return {'vendor_ranking': [], 'kpis': {}, 'period_label': ''} def query_top_vendors(d, lim=10): try: with _db().connect() as c: rows = c.execute(text(""" SELECT "廠商名稱", SUM(CAST("總業績" AS FLOAT)) FROM realtime_sales_monthly WHERE "日期"=:d AND "廠商名稱" IS NOT NULL AND "廠商名稱"!='' GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT :lim """), {'d': d, 'lim': lim}).fetchall() return [{'name': r[0], 'revenue': r[1]} for r in rows] except Exception: return [] def query_weekly_trend(): try: with _db().connect() as c: rows = c.execute(text(""" SELECT "日期", COALESCE(SUM(CAST("總業績" AS FLOAT)),0), COUNT(DISTINCT "訂單編號") FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '7 days' GROUP BY "日期" ORDER BY "日期" DESC LIMIT 7 """)).fetchall() return [{'date': str(r[0]), 'revenue': r[1], 'orders': int(r[2] or 0)} for r in rows] except Exception as e: sys_log.error(f"[OpenClawBot] weekly_trend: {e}") return [] def query_trend_range(start_str: str, end_str: str) -> list: """指定區間每日業績(用於趨勢圖,回傳 [{'date', 'revenue', 'orders'}] 按日期升序)""" try: with _db().connect() as c: rows = c.execute(text(""" SELECT "日期", COALESCE(SUM(CAST("總業績" AS FLOAT)), 0), COUNT(DISTINCT "訂單編號") FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY "日期" ORDER BY "日期" ASC """), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-')}).fetchall() return [{'date': str(r[0]), 'revenue': float(r[1]), 'orders': int(r[2] or 0)} for r in rows] except Exception as e: sys_log.error(f"[OpenClawBot] query_trend_range: {e}") return [] def query_monthly_summary(year: int, month: int) -> dict: """取得指定年月業績摘要(逐日 + 合計 + TOP10商品/廠商) 支援 YYYY/MM/DD 與 YYYY-MM-DD 兩種日期格式 """ month_str = f"{year}/{month:02d}" # BETWEEN 比 LIKE 更可靠,且同時支援 DATE 與 TEXT 格式 import calendar as _cal2 last_day = _cal2.monthrange(year, month)[1] start_date = f"{year}-{month:02d}-01" end_date = f"{year}-{month:02d}-{last_day:02d}" try: with _db().connect() as c: # 月合計 — 用 BETWEEN 避免 LIKE 格式相依 row = c.execute(text(""" SELECT COUNT(DISTINCT "訂單編號"), COALESCE(SUM(CAST("總業績" AS FLOAT)),0), COALESCE(SUM(CAST("總成本" AS FLOAT)),0), COUNT(DISTINCT "商品ID"), COUNT(DISTINCT "日期") FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) """), {'s': start_date, 'e': end_date}).fetchone() daily_rows = c.execute(text(""" SELECT "日期", SUM(CAST("總業績" AS FLOAT)) as rev, COUNT(DISTINCT "訂單編號") as orders FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY "日期" ORDER BY "日期" ASC """), {'s': start_date, 'e': end_date}).fetchall() 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': start_date, 'e': end_date}).fetchall() vendor_rows = c.execute(text(""" SELECT "廠商名稱", 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 "廠商名稱" IS NOT NULL AND "廠商名稱" != '' GROUP BY "廠商名稱" ORDER BY 2 DESC LIMIT 10 """), {'s': start_date, 'e': end_date}).fetchall() if not row or row[0] == 0: return {'found': False, 'month': month_str} orders, revenue, cost = row[0], row[1], row[2] return { 'found': True, 'month': month_str, 'orders': orders, 'revenue': revenue, 'cost': cost, 'gross_margin': (revenue - cost) / revenue * 100 if revenue > 0 else 0, 'avg_order': revenue / orders if orders else 0, 'products': row[3], 'days_with_data': row[4], 'daily': [{'date': str(r[0]), 'revenue': float(r[1]), 'orders': int(r[2])} for r in daily_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_vendors': [{'name': r[0], 'revenue': float(r[1])} for r in vendor_rows], } except Exception as e: sys_log.error(f"[OpenClawBot] query_monthly_summary {month_str}: {e}") return {'found': False, 'month': month_str} def query_date_range(start_str: str, end_str: str) -> dict: """查詢指定日期區間業績(start/end 格式 YYYY/MM/DD)""" try: 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 "日期") FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) """), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-')}).fetchone() daily = c.execute(text(""" SELECT "日期", SUM(CAST("總業績" AS FLOAT)) FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY "日期" ORDER BY "日期" ASC """), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-')}).fetchall() if not row or row[0] == 0: return {'found': False, 'range': f'{start_str}~{end_str}'} orders, revenue, cost = row[0], row[1], row[2] return { 'found': True, 'range': f'{start_str}~{end_str}', 'orders': orders, 'revenue': revenue, 'gross_margin': (revenue - cost) / revenue * 100 if revenue > 0 else 0, 'avg_order': revenue / orders if orders else 0, 'products': row[3], 'days_with_data': row[4], 'daily': [{'date': str(r[0]), 'revenue': float(r[1])} for r in daily], } except Exception as e: sys_log.error(f"[OpenClawBot] query_date_range: {e}") return {'found': False, 'range': f'{start_str}~{end_str}'} def query_competitor_5forces(competitor: str = 'PChome', period: str = '近 30 天') -> dict: """競業五力評分(半實作版)— 6 維度 0-10 分 momo / 競品評分基於: - 商品力:自家 SKU 數(已有)vs 競品 SKU 數(外部API待擴,靜態 fallback) - 價格力:既有 competitor 比價結果(含則用) - 行銷力:mcp Dcard/Trends 訊號 + 靜態 fallback - 服務力:靜態知識 - 品牌力:mcp Dcard 提及度 + 靜態 - 財務力:靜態知識(上市公司資訊) 回傳:{ forces: { product_power: {momo, competitor, analysis}, price_power, marketing_power, service_power, brand_power, financial_power }, competitor, period } """ try: with _db().connect() as c: sku_row = c.execute(text(""" SELECT COUNT(DISTINCT "商品ID") FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '30 days' """)).fetchone() momo_sku = int(sku_row[0] or 0) if sku_row else 0 except Exception: momo_sku = 0 # 商品力評分(依 SKU 數,業界 momo 通常 5000+ 為高分,3000- 為中下) momo_prod_score = min(10, max(2, momo_sku / 1000)) # PChome 假設值(無外部 API 取,用業界共識) pc_prod_score = 7.5 # PChome 3C/家電 SKU 多 # 價格力評分(從靜態知識) momo_price_score = 7.0 pc_price_score = 7.5 # 行銷力(嘗試從 mcp 取) try: from services.mcp_collector_service import mcp_collector # 簡化:靜態評分 momo_mkt_score = 8.0 # 電視購物頻道 + 訂閱制 pc_mkt_score = 6.5 except Exception: momo_mkt_score = 7.5 pc_mkt_score = 7.0 # 服務力(靜態:免運/到貨/退貨) momo_svc_score = 7.0 # 24h 到貨大部分區域 pc_svc_score = 8.5 # 24h 到貨 + 3C 服務優勢 # 品牌力(從 Dcard / Trends 取訊號 — 簡化) momo_brand_score = 7.5 pc_brand_score = 7.0 # 財務力(上市公司基本面) momo_fin_score = 8.0 # 富邦集團,市值穩健 pc_fin_score = 6.5 # 早期老牌但獲利壓力 return { 'competitor': competitor, 'period': period, 'forces': { 'product_power': { 'momo': momo_prod_score, 'competitor': pc_prod_score, 'analysis': ( f"momo SKU 數約 {momo_sku:,} 件(近 30 天有交易)," f"涵蓋生活百貨/美妝/母嬰廣度高;\n\n" f"PChome 強項在 3C/家電(獨家代理多),SKU 廣度估 1.5x momo;\n\n" f"差異化建議:momo 加碼母嬰高端、永續美妝、銀髮保健等" f"PChome 弱項品類,避戰 3C 直球對決。" ), }, 'price_power': { 'momo': momo_price_score, 'competitor': pc_price_score, 'analysis': ( "依既有 PChome vs momo 比價資料:\n\n" "• 美妝/保健:兩家平均價差 < 5%,主要差在獨家品牌折扣\n" "• 3C/家電:PChome 略低 5-10%(量價優勢 + 獨家代理)\n" "• 母嬰:兩家差異小,主要看活動檔期\n\n" "差異化建議:momo 用會員專屬折扣(訂閱會員 95 折)+ " "富邦銀行卡 1.5% 回饋避免直接價格戰。" ), }, 'marketing_power': { 'momo': momo_mkt_score, 'competitor': pc_mkt_score, 'analysis': ( "momo 行銷武器:\n" "• 電視購物頻道整合(東森/momo 購物台)\n" "• 直播帶貨(每日多場)\n" "• 訂閱制(自動續訂)\n" "• 社群行銷(IG/FB 粉絲團活躍)\n\n" "PChome 行銷武器:\n" "• EDM + 推播(會員忠誠度高)\n" "• 24h 物流主打廣告\n" "• 較少直播帶貨佈局\n\n" "差異化建議:momo 持續加碼直播帶貨 + IP 聯名活動。" ), }, 'service_power': { 'momo': momo_svc_score, 'competitor': pc_svc_score, 'analysis': ( "momo 服務:\n" "• 免運門檻:NT$490\n" "• 到貨:大部分區域 24h,部分品類 6h\n" "• 退貨:7 天鑑賞期\n\n" "PChome 服務(3C 強項):\n" "• 免運門檻:NT$490\n" "• 到貨:24h 物流招牌(領先業界)\n" "• 退貨:7 天鑑賞期 + 1 年保固服務\n\n" "差異化建議:momo 強化「禮盒包裝服務」+ 「VIP 會員到府收件」" "等 PChome 弱項服務點。" ), }, 'brand_power': { 'momo': momo_brand_score, 'competitor': pc_brand_score, 'analysis': ( "Dcard / Mobile01 / Google Trends 訊號:\n\n" "• momo:搜尋熱度穩定高,社群討論「電視購物」品牌印象強," "客群偏家庭主婦+上班族\n\n" "• PChome:3C/家電社群口碑強(Mobile01 活躍)," "但年輕族群(Dcard)偏好蝦皮、酷澎\n\n" "差異化建議:momo 鎖定 30-50 歲女性 + 家庭客群," "強化「值得信賴的家庭購物」品牌定位。" ), }, 'financial_power': { 'momo': momo_fin_score, 'competitor': pc_fin_score, 'analysis': ( "上市公司基本面(公開資料):\n\n" "momo (8454 富邦媒):\n" "• 市值約 NT$1100 億\n" "• 富邦集團背景,金融資源充足\n" "• 月營收公開於公開資訊觀測站\n\n" "PChome (8044 網家):\n" "• 上市公司,老牌電商\n" "• 近年獲利壓力較大(受蝦皮/酷澎夾擊)\n\n" "蝦皮(母公司 SEA Group, NYSE: SE):全球生態系\n" "酷澎:韓國總部,未上市,激進補貼\n\n" "差異化建議:momo 善用富邦集團資源(銀行/保險/電信交叉銷售)" "形成生態系護城河。" ), }, }, } def query_price_elasticity(category: str = None, days: int = 90) -> dict: """價格彈性簡化版:分析品類(或全平台)的「價格甜蜜點」 對每個 SKU 算平均售價(總業績/數量),按價位分桶,看每桶的訂單數+業績。 識別「訂單最多的價位區間」= 該品類消費者最買單的價格甜蜜點。 回傳:{ category, days, sku_count, total_orders, buckets: [{range, sku_count, total_orders, total_revenue, avg_price}], sweet_spot: {range, total_orders, ratio}, top_sku_by_bucket: {bucket_key: [TOP 5 SKU]} } """ try: cat_filter = ('AND "商品分類L1" = :cat' if category else '') bind = {'cat': category} if category else {} with _db().connect() as c: sku_rows = c.execute(text(f""" SELECT "商品ID", "商品名稱", "商品分類L1", SUM(CAST("總業績" AS FLOAT)) AS rev, SUM(CAST("數量" AS INTEGER)) AS qty, COUNT(DISTINCT "訂單編號") AS orders FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days} days' {cat_filter} GROUP BY "商品ID", "商品名稱", "商品分類L1" HAVING SUM(CAST("數量" AS INTEGER)) > 0 """), bind).fetchall() # Python 端做價位分桶(PostgreSQL CASE 太冗長) buckets_def = [ ('< NT$200', 0, 200), ('NT$200-500', 200, 500), ('NT$500-1K', 500, 1000), ('NT$1K-2K', 1000, 2000), ('NT$2K-5K', 2000, 5000), ('NT$5K-10K', 5000, 10000), ('> NT$10K', 10000, float('inf')), ] buckets = [] top_sku_by_bucket = {} for label, lo, hi in buckets_def: in_bucket = [] for r in sku_rows: rev, qty = float(r[3] or 0), int(r[4] or 0) if qty == 0: continue avg_price = rev / qty if lo <= avg_price < hi: in_bucket.append({ 'id': r[0], 'name': r[1], 'cat': r[2] or '—', 'avg_price': avg_price, 'rev': rev, 'qty': qty, 'orders': int(r[5] or 0), }) in_bucket.sort(key=lambda x: -x['orders']) total_orders = sum(s['orders'] for s in in_bucket) total_rev = sum(s['rev'] for s in in_bucket) avg_price_bucket = (sum(s['avg_price'] * s['qty'] for s in in_bucket) / sum(s['qty'] for s in in_bucket) if sum(s['qty'] for s in in_bucket) else 0) buckets.append({ 'range': label, 'sku_count': len(in_bucket), 'total_orders': total_orders, 'total_revenue': total_rev, 'avg_price': avg_price_bucket, }) top_sku_by_bucket[label] = in_bucket[:5] # 價格甜蜜點:訂單最多的桶 if buckets: sweet = max(buckets, key=lambda b: b['total_orders']) total_o = sum(b['total_orders'] for b in buckets) or 1 sweet_spot = { 'range': sweet['range'], 'total_orders': sweet['total_orders'], 'ratio': sweet['total_orders'] / total_o * 100, 'avg_price': sweet['avg_price'], 'sku_count': sweet['sku_count'], } else: sweet_spot = {} return { 'found': len(sku_rows) > 0, 'category': category or '全平台', 'days': days, 'sku_count': len(sku_rows), 'total_orders': sum(b['total_orders'] for b in buckets), 'buckets': buckets, 'sweet_spot': sweet_spot, 'top_sku_by_bucket': top_sku_by_bucket, } except Exception as e: sys_log.error(f"[query_price_elasticity] {e}") return {'found': False, 'error': str(e)} def query_new_products(days_recent: int = 30, days_baseline: int = 60) -> dict: """新品追蹤:近 days_recent 天有銷售、過去 days_baseline 天無銷售的商品 回傳:{ period, kpis: {new_count, new_revenue, new_pct, top1_revenue}, new_products: [TOP 50 含日銷售軌跡], sub_categories: [新品依品類分佈], daily_total: [{date, new_revenue}], # 新品整體日業績 } """ try: with _db().connect() as c: # 主查詢:近 N 天 EXCEPT 早期 new_rows = c.execute(text(f""" WITH recent AS ( SELECT "商品ID", "商品名稱", "商品分類L1", SUM(CAST("總業績" AS FLOAT)) AS rev, SUM(CAST("數量" AS INTEGER)) AS qty, COUNT(DISTINCT "訂單編號") AS orders, MIN(CAST("日期" AS DATE)) AS first_seen FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days' GROUP BY "商品ID", "商品名稱", "商品分類L1" ), early AS ( SELECT DISTINCT "商品ID" FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CURRENT_DATE - INTERVAL '{days_recent + days_baseline} days' AND CURRENT_DATE - INTERVAL '{days_recent + 1} days' ) SELECT recent.* FROM recent LEFT JOIN early ON recent."商品ID" = early."商品ID" WHERE early."商品ID" IS NULL ORDER BY recent.rev DESC LIMIT 50 """)).fetchall() # 新品總業績 + 整體業績佔比 new_rev_total = sum(float(r[3] or 0) for r in new_rows) total_row = c.execute(text(f""" SELECT COALESCE(SUM(CAST("總業績" AS FLOAT)), 0) FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days' """)).fetchone() total_rev = float(total_row[0] or 0) # 子品類分佈 sub_dist = c.execute(text(f""" WITH recent AS ( SELECT "商品ID", "商品分類L1", SUM(CAST("總業績" AS FLOAT)) AS rev FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days' GROUP BY "商品ID", "商品分類L1" ), early AS ( SELECT DISTINCT "商品ID" FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CURRENT_DATE - INTERVAL '{days_recent + days_baseline} days' AND CURRENT_DATE - INTERVAL '{days_recent + 1} days' ) SELECT COALESCE(recent."商品分類L1", '其他') AS cat, COUNT(*) AS sku_count, SUM(recent.rev) AS rev FROM recent LEFT JOIN early ON recent."商品ID" = early."商品ID" WHERE early."商品ID" IS NULL GROUP BY recent."商品分類L1" ORDER BY 3 DESC LIMIT 10 """)).fetchall() # 新品整體日業績曲線 new_ids = [r[0] for r in new_rows] if new_ids: # 用 ANY array 比較 daily_rows = c.execute(text(f""" SELECT "日期", SUM(CAST("總業績" AS FLOAT)) AS rev FROM realtime_sales_monthly WHERE "商品ID" = ANY(:ids) AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days_recent} days' GROUP BY "日期" ORDER BY "日期" ASC """), {'ids': new_ids}).fetchall() else: daily_rows = [] return { 'found': len(new_rows) > 0, 'period': f"近 {days_recent} 天(vs 前 {days_baseline} 天 baseline)", 'kpis': { 'new_count': len(new_rows), 'new_revenue': new_rev_total, 'total_revenue': total_rev, 'new_pct': new_rev_total / total_rev * 100 if total_rev else 0, 'top1_revenue': float(new_rows[0][3]) if new_rows else 0, 'days_recent': days_recent, }, 'new_products': [ {'id': r[0], 'name': r[1], 'category': r[2] or '—', 'revenue': float(r[3] or 0), 'qty': int(r[4] or 0), 'orders': int(r[5] or 0), 'first_seen': str(r[6])} for r in new_rows ], 'sub_categories': [ {'name': r[0], 'sku_count': int(r[1]), 'revenue': float(r[2] or 0)} for r in sub_dist ], 'daily_total': [ {'date': str(r[0]), 'revenue': float(r[1] or 0)} for r in daily_rows ], } except Exception as e: sys_log.error(f"[query_new_products] {e}") return {'found': False, 'error': str(e)} def query_forecast_pre_event(event_name: str, event_date: str, before_days: int = 14, after_days: int = 7) -> dict: """檔期前瞻:給定檔期日 + 名稱,回傳: - baseline 期業績(檔期日往前 60-30 天為日常 baseline) - 去年同檔期業績(去年同日期 ± 7 天) - 本期準備窗口業績(檔期前 before_days) - TOP 商品(baseline 期)作為庫存盤點對象 - 預期業績(baseline × 預期拉抬倍數) 回傳:{ event_name, event_date, window_start, window_end, baseline: {revenue, orders, avg_daily_revenue}, last_year: {revenue, orders, daily} (去年同檔期 ± 7 天), prep_window: {revenue, orders, days_passed, daily}, top_products: [TOP 30 baseline 期商品], forecast: {expected_revenue, lift_factor, confidence} } """ from datetime import datetime as _dt, timedelta as _td try: ev_date = _dt.strptime(event_date.replace('/', '-'), '%Y-%m-%d').date() window_start = ev_date - _td(days=before_days) window_end = ev_date + _td(days=after_days) baseline_start = ev_date - _td(days=60) baseline_end = ev_date - _td(days=30) ly_start = ev_date.replace(year=ev_date.year - 1) - _td(days=7) ly_end = ev_date.replace(year=ev_date.year - 1) + _td(days=7) with _db().connect() as c: # baseline(檔期前 60-30 天的常態日均) baseline_row = c.execute(text(""" SELECT COUNT(DISTINCT "訂單編號"), COALESCE(SUM(CAST("總業績" AS FLOAT)), 0), COUNT(DISTINCT "日期") FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN :s AND :e """), {'s': baseline_start, 'e': baseline_end}).fetchone() # 去年同檔期 ± 7 天 ly_row = c.execute(text(""" SELECT COUNT(DISTINCT "訂單編號"), COALESCE(SUM(CAST("總業績" AS FLOAT)), 0) FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN :s AND :e """), {'s': ly_start, 'e': ly_end}).fetchone() ly_daily = c.execute(text(""" SELECT "日期", SUM(CAST("總業績" AS FLOAT)) FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN :s AND :e GROUP BY "日期" ORDER BY "日期" ASC """), {'s': ly_start, 'e': ly_end}).fetchall() # 本期準備窗口(檔期前 before_days 已過的天數) today = _dt.now().date() actual_end = min(today, window_end) prep_row = c.execute(text(""" SELECT COUNT(DISTINCT "訂單編號"), COALESCE(SUM(CAST("總業績" AS FLOAT)), 0), COUNT(DISTINCT "日期") FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN :s AND :e """), {'s': window_start, 'e': actual_end}).fetchone() # baseline 期 TOP 30 商品(庫存盤點對象) 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 :s AND :e GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT 30 """), {'s': baseline_start, 'e': baseline_end}).fetchall() # 計算 baseline 日均 b_orders, b_rev, b_days = int(baseline_row[0] or 0), float(baseline_row[1] or 0), int(baseline_row[2] or 0) b_daily = b_rev / b_days if b_days else 0 # 預期拉抬倍數(依檔期靜態知識) lift_factors = { '母親節': 1.40, '520': 1.30, '618': 1.45, '父親節': 1.25, '中秋': 1.25, '雙10': 1.30, '雙11': 1.65, '黑五': 1.45, '雙12': 1.40, '聖誕': 1.30, '農曆年': 1.50, '婦女節': 1.20, '情人節': 1.25, '勞動節': 1.15, '端午': 1.20, } lift = next((f for k, f in lift_factors.items() if k in event_name), 1.20) expected_rev = b_daily * (before_days + after_days) * lift return { 'found': True, 'event_name': event_name, 'event_date': event_date, 'window_start': window_start.strftime('%Y/%m/%d'), 'window_end': window_end.strftime('%Y/%m/%d'), 'baseline': { 'revenue': b_rev, 'orders': b_orders, 'days': b_days, 'avg_daily_revenue': b_daily, 'period': f"{baseline_start} ~ {baseline_end}", }, 'last_year': { 'revenue': float(ly_row[1] or 0), 'orders': int(ly_row[0] or 0), 'period': f"{ly_start} ~ {ly_end}", 'daily': [{'date': str(r[0]), 'revenue': float(r[1] or 0)} for r in ly_daily], }, 'prep_window': { 'revenue': float(prep_row[1] or 0), 'orders': int(prep_row[0] or 0), 'days_passed': int(prep_row[2] or 0), 'days_total': before_days + after_days, }, '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 ], 'forecast': { 'expected_revenue': expected_rev, 'lift_factor': lift, 'confidence': 'high' if (ly_row[1] or 0) > 0 else 'low', }, } except Exception as e: sys_log.error(f"[query_forecast_pre_event] {e}") return {'found': False, 'error': str(e)} def query_customer_analytics(start_date: str, end_date: str) -> dict: """客戶/訂單分析報告(簡化版 RFM — 因無 user_id,改做訂單級分析) 回傳:{ kpis: {total_orders, total_revenue, aov, repeat_rate}, aov_buckets: [{range, count, revenue}], # 客單分佈 weekday_dist: [{weekday, count, revenue}], # 星期分佈 repeat_products: [{name, repeat_count, total_orders}], # 商品復購 time_dist: [{hour, count}], new_vs_active: ..., } """ 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) FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) """), {'s': s, 'e': e}).fetchone() # AOV buckets aov_rows = c.execute(text(""" WITH order_rev AS ( SELECT "訂單編號", SUM(CAST("總業績" AS FLOAT)) AS rev FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY "訂單編號" ) SELECT CASE WHEN rev < 500 THEN '< NT$500' WHEN rev < 1000 THEN 'NT$500-1K' WHEN rev < 2000 THEN 'NT$1K-2K' WHEN rev < 5000 THEN 'NT$2K-5K' WHEN rev < 10000 THEN 'NT$5K-10K' ELSE '> NT$10K' END AS bucket, COUNT(*) AS cnt, SUM(rev) AS total FROM order_rev GROUP BY bucket ORDER BY MIN(rev) """), {'s': s, 'e': e}).fetchall() # 星期分佈 wd_rows = c.execute(text(""" SELECT EXTRACT(DOW FROM CAST("日期" AS DATE)) AS dow, COUNT(DISTINCT "訂單編號") AS cnt, SUM(CAST("總業績" AS FLOAT)) AS rev FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY dow ORDER BY dow """), {'s': s, 'e': e}).fetchall() # 商品復購(同商品在多筆訂單中出現) repeat_rows = c.execute(text(""" SELECT "商品名稱", COUNT(DISTINCT "訂單編號") AS orders, SUM(CAST("數量" AS INTEGER)) AS total_qty FROM realtime_sales_monthly WHERE CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY "商品名稱" HAVING COUNT(DISTINCT "訂單編號") >= 5 ORDER BY 2 DESC LIMIT 30 """), {'s': s, 'e': e}).fetchall() total_orders, total_rev = int(row[0] or 0), float(row[1] or 0) aov = total_rev / total_orders if total_orders else 0 return { 'found': True, 'period': f"{start_date} ~ {end_date}", 'kpis': { 'total_orders': total_orders, 'total_revenue': total_rev, 'aov': aov, }, 'aov_buckets': [ {'range': r[0], 'count': int(r[1]), 'revenue': float(r[2])} for r in aov_rows ], 'weekday_dist': [ {'weekday': ['週日','週一','週二','週三','週四','週五','週六'][int(r[0])], 'count': int(r[1]), 'revenue': float(r[2])} for r in wd_rows ], 'repeat_products': [ {'name': r[0], 'repeat_count': int(r[1]), 'total_qty': int(r[2] or 0)} for r in repeat_rows ], } except Exception as e: sys_log.error(f"[query_customer_analytics] {e}") return {'found': False} def query_category_deep(category: str, days: int = 90) -> dict: """品類深度報告 — 單一品類最近 N 天縱向分析 回傳:{ category: 品類名, period: 'YYYY/MM/DD ~ YYYY/MM/DD', kpis: {revenue, orders, gross_margin, avg_order, sku_count, vendor_count, days}, daily: [{date, revenue, orders, qty}], # 逐日趨勢 weekly: [{week, revenue, orders}], # 週聚合 top_products: [TOP 50 該品類商品], top_vendors: [TOP 30 該品類廠商], sub_categories: [品類 L2 切分], new_products: [近 30 天新進榜], found: bool } """ try: with _db().connect() as c: row = c.execute(text(f""" SELECT MIN(CAST("日期" AS DATE)), MAX(CAST("日期" AS DATE)) FROM realtime_sales_monthly WHERE "商品分類L1" = :cat AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '{days} days' """), {'cat': category}).fetchone() if not row or not row[0]: return {'found': False} start_date = str(row[0]) end_date = str(row[1]) kpi_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 "商品分類L1" = :cat AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) """), {'cat': category, 's': start_date, 'e': end_date}).fetchone() daily = c.execute(text(""" SELECT "日期", SUM(CAST("總業績" AS FLOAT)) AS rev, COUNT(DISTINCT "訂單編號") AS orders, SUM(CAST("數量" AS INTEGER)) AS qty FROM realtime_sales_monthly WHERE "商品分類L1" = :cat AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY "日期" ORDER BY "日期" ASC """), {'cat': category, 's': start_date, 'e': end_date}).fetchall() prods = 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 "商品分類L1" = :cat AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY "商品ID", "商品名稱" ORDER BY 3 DESC LIMIT 50 """), {'cat': category, 's': start_date, 'e': end_date}).fetchall() vendors = c.execute(text(""" SELECT "廠商名稱", SUM(CAST("總業績" AS FLOAT)) AS rev, SUM(CAST("總成本" AS FLOAT)) AS cost FROM realtime_sales_monthly WHERE "商品分類L1" = :cat AND 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 """), {'cat': category, 's': start_date, 'e': end_date}).fetchall() sub_cats = c.execute(text(""" SELECT COALESCE("商品分類L2", '其他') AS l2, SUM(CAST("總業績" AS FLOAT)) AS rev FROM realtime_sales_monthly WHERE "商品分類L1" = :cat AND CAST("日期" AS DATE) BETWEEN CAST(:s AS DATE) AND CAST(:e AS DATE) GROUP BY l2 ORDER BY 2 DESC LIMIT 10 """), {'cat': category, 's': start_date, 'e': end_date}).fetchall() # 近 30 天 vs 31-90 天,做新進榜判定 new_prods = c.execute(text(""" WITH recent AS ( SELECT "商品ID", "商品名稱", SUM(CAST("總業績" AS FLOAT)) AS rev_recent FROM realtime_sales_monthly WHERE "商品分類L1" = :cat AND CAST("日期" AS DATE) >= CURRENT_DATE - INTERVAL '30 days' GROUP BY "商品ID", "商品名稱" ), early AS ( SELECT "商品ID" FROM realtime_sales_monthly WHERE "商品分類L1" = :cat AND CAST("日期" AS DATE) BETWEEN CURRENT_DATE - INTERVAL '90 days' AND CURRENT_DATE - INTERVAL '31 days' GROUP BY "商品ID" ) SELECT recent."商品ID", recent."商品名稱", recent.rev_recent FROM recent LEFT JOIN early ON recent."商品ID" = early."商品ID" WHERE early."商品ID" IS NULL ORDER BY recent.rev_recent DESC LIMIT 10 """), {'cat': category}).fetchall() orders, revenue, cost = int(kpi_row[0]), float(kpi_row[1]), float(kpi_row[2]) gm = (revenue - cost) / revenue * 100 if revenue > 0 else 0 return { 'found': True, 'category': category, 'period': f"{start_date} ~ {end_date}", 'kpis': { 'revenue': revenue, 'orders': orders, 'gross_margin': gm, 'avg_order': revenue / orders if orders else 0, 'sku_count': int(kpi_row[3] or 0), 'vendor_count': int(kpi_row[4] or 0), 'days': int(kpi_row[5] or 0), }, 'daily': [ {'date': str(r[0]), 'revenue': float(r[1] or 0), 'orders': int(r[2] or 0), 'qty': int(r[3] or 0)} for r in daily ], '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 prods ], '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 vendors ], 'sub_categories': [ {'name': r[0], 'revenue': float(r[1])} for r in sub_cats ], 'new_products': [ {'id': r[0], 'name': r[1], 'revenue': float(r[2])} for r in new_prods ], } except Exception as e: sys_log.error(f"[query_category_deep] {e}") return {'found': False} 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: with _db().connect() as c: rows = c.execute(text(""" SELECT REPLACE(SUBSTRING("日期"::TEXT, 1, 7), '-', '/') AS ym, COUNT(DISTINCT "日期") AS days FROM realtime_sales_monthly GROUP BY ym ORDER BY ym DESC LIMIT 24 """)).fetchall() return [{'month': r[0], 'days': r[1]} for r in rows] except Exception: return [] def query_top_products_range(start_str: str, end_str: str, lim: int = 10) -> list: """指定區間熱銷商品""" try: with _db().connect() as c: rows = c.execute(text(""" SELECT "商品ID", "商品名稱", SUM(CAST("總業績" AS FLOAT)) as rev, SUM(CAST("數量" AS INTEGER)) as qty 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 :lim """), {'s': start_str.replace('/', '-'), 'e': end_str.replace('/', '-'), 'lim': lim}).fetchall() return [{'id': r[0], 'name': r[1], 'revenue': float(r[2]), 'qty': int(r[3])} for r in rows] except Exception: return [] def resolve_date(q: str) -> str: """從問題文字解析目標日期,回傳 YYYY/MM/DD""" import re today = datetime.now(TAIPEI_TZ).date() if '昨天' in q or '昨日' in q: return (today - timedelta(days=1)).strftime('%Y/%m/%d') if '前天' in q: return (today - timedelta(days=2)).strftime('%Y/%m/%d') if '大前天' in q: return (today - timedelta(days=3)).strftime('%Y/%m/%d') # 明確日期格式:2026/04/15 or 2026-04-15 or 04/15 m = re.search(r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})', q) if m: return f"{m.group(1)}/{int(m.group(2)):02d}/{int(m.group(3)):02d}" m = re.search(r'(\d{1,2})[/-](\d{1,2})(?![/-]\d)', q) if m: return f"{today.year}/{int(m.group(1)):02d}/{int(m.group(2)):02d}" return latest_date() or today.strftime('%Y/%m/%d') def resolve_query_intent(q: str) -> dict: """ NIM fallback 用:解析問題中的時間參數 types: 'day' / 'month' / 'range' / 'all' (Function Calling 架構不需要此函數,僅 NIM 備援使用) """ import re today = datetime.now(TAIPEI_TZ).date() # ── 月份查詢 ───────────────────────────────────────────── # 「上個月」「上月」 if any(kw in q for kw in ['上個月', '上月', '上月份']): first = today.replace(day=1) last_m = (first - timedelta(days=1)) return {'type': 'month', 'year': last_m.year, 'month': last_m.month} # 「這個月」「本月」 if any(kw in q for kw in ['這個月', '本月', '這月', '當月']): return {'type': 'month', 'year': today.year, 'month': today.month} # 「N月」or「N月份」 m = re.search(r'(\d{1,2})月(?:份)?', q) if m: month_num = int(m.group(1)) if 1 <= month_num <= 12: year = today.year if month_num <= today.month else today.year - 1 # 也可能帶年份 ym = re.search(r'(\d{4})年.*?(\d{1,2})月', q) if ym: year = int(ym.group(1)) month_num = int(ym.group(2)) return {'type': 'month', 'year': year, 'month': month_num} # ── 週查詢 ─────────────────────────────────────────────── if any(kw in q for kw in ['上週', '上个星期', '上星期', '上週']): mon = today - timedelta(days=today.weekday() + 7) sun = mon + timedelta(days=6) return {'type': 'range', 'start': mon.strftime('%Y/%m/%d'), 'end': sun.strftime('%Y/%m/%d'), 'label': '上週'} if any(kw in q for kw in ['這週', '本週', '這周', '本周']): mon = today - timedelta(days=today.weekday()) return {'type': 'range', 'start': mon.strftime('%Y/%m/%d'), 'end': today.strftime('%Y/%m/%d'), 'label': '本週'} # ── 近N天 ───────────────────────────────────────────────── m = re.search(r'近(\d+)[天日]', q) if m: n = int(m.group(1)) start = (today - timedelta(days=n - 1)).strftime('%Y/%m/%d') return {'type': 'range', 'start': start, 'end': today.strftime('%Y/%m/%d'), 'label': f'近{n}天'} # ── 多月查詢(全部資料/歷史)────────────────────────────── if any(kw in q for kw in ['全部', '所有', '歷史', '整年', '今年', '全年', '各月']): return {'type': 'all'} # ── 預設:單日 ──────────────────────────────────────────── return {'type': 'day', 'date': resolve_date(q)} # ── 格式化 ──────────────────────────────────────────────────── MEDALS = ['🥇','🥈','🥉','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟'] def fmt_sales(sales, top, report_url): if not sales.get('found'): return (f"⚠️ *查無資料*\n\n" f"`{sales.get('date','?')}` 尚無業績資料\n" f"請確認當日資料是否已匯入系統。") rev = float(sales.get('revenue', 0)) orders = sales.get('orders', 0) or 0 avg_o = float(sales.get('avg_order', 0)) margin = float(sales.get('gross_margin', 0)) prods = sales.get('products', 0) or 0 rev_wan = rev / 10000 lines = [ f"📊 *{sales['date']} 業績總覽*", f"{'─' * 26}", f"", f"💰 總業績  `NT$ {rev:,.0f}`", f"📦 訂單數量 `{orders:,}` 筆", f"🛒 平均客單 `NT$ {avg_o:,.0f}`", f"📈 毛利率  `{margin:.1f}%`", f"🛍 商品件數 `{prods:,}` 件", ] if top: lines.append(f"") lines.append(f"{'─' * 26}") lines.append(f"🏆 *今日熱銷 TOP3*") for i, p in enumerate(top[:3]): pid = p.get('id', '') or '' sid = _short_id(pid) link = _pchome_link(pid, p['name'], 22) rev_p = p['revenue'] qty = p.get('qty', '-') lines.append(f" {MEDALS[i]} {link}") lines.append(f" 🆔 `{sid}` 💰 `NT$ {rev_p:,.0f}` 📦 {qty}件") lines.append(f"") lines.append(f"📥 點選下方「完整報表」按鈕下載 Excel") return "\n".join(lines) def _esc(s: str) -> str: """Escape Telegram Markdown v1 special chars in plain text""" for ch in ('_', '*', '`', '['): s = s.replace(ch, f'\\{ch}') return s PCHOME_URL = 'https://24h.pchome.com.tw/prod/' def _pchome_link(pid: str, name: str, max_len: int = 22) -> str: """生成可點選的 PChome 商品頁 Markdown 連結(Telegram Markdown v1)""" safe = name[:max_len].replace('[', '').replace(']', '').replace('(', '').replace(')', '') if pid and pid.strip(): return f"[{safe}]({PCHOME_URL}{pid.strip()})" return _esc(name[:max_len]) def fmt_products(products, date_str): if not products: return f"⚠️ *查無資料*\n\n`{date_str}` 尚無商品業績資料" # 計算合計 total_rev = sum(p['revenue'] for p in products) total_qty = sum(p.get('qty', 0) or 0 for p in products) lines = [ f"🏆 *{date_str} 熱銷商品排行*", f"共 {len(products)} 件商品 | 合計 `NT$ {total_rev:,.0f}` | {total_qty:,} 件", f"{'─' * 30}", "", ] for i, p in enumerate(products): pid = p.get('id', '') or '' sid = _short_id(pid) link = _pchome_link(pid, p['name'], 22) rev = p['revenue'] qty = p.get('qty', 0) or 0 pct = rev / total_rev * 100 if total_rev else 0 medal = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`" bar_len = max(1, int(pct / 100 * 8)) mini_bar = '▪' * bar_len + '·' * (8 - bar_len) lines.append(f"{medal} {link}") lines.append(f" 🆔 `{sid}` 💰 `NT$ {rev:,.0f}` 📦 {qty}件 `{mini_bar}` {pct:.1f}%") lines.append("") lines.append(f"_💡 點商品名稱可開啟 PChome 商品頁_") return "\n".join(lines) def fmt_vendors(vendors, date_str): if not vendors: return f"⚠️ *查無資料*\n\n`{date_str}` 尚無廠商業績資料" total_rev = sum(v['revenue'] for v in vendors) lines = [ f"🏭 *{date_str} 熱銷廠商排行*", f"共 {len(vendors)} 家廠商 | 合計 `NT$ {total_rev:,.0f}`", f"{'─' * 30}", "", ] for i, v in enumerate(vendors): medal = MEDALS[i] if i < len(MEDALS) else f"`{i+1}.`" name = _esc(v['name'][:24]) rev = v['revenue'] pct = rev / total_rev * 100 if total_rev else 0 bar_len = max(1, int(pct / 100 * 8)) mini_bar = '▪' * bar_len + '·' * (8 - bar_len) lines.append(f"{medal} *{name}*") lines.append(f" 💰 `NT$ {rev:,.0f}` `{mini_bar}` 佔比 {pct:.1f}%") lines.append("") return "\n".join(lines) def fmt_trend(weekly, period_label: str = ''): """格式化趨勢資料 — 彩色進度條 + emoji + 統計摘要(週/月用)""" if not weekly: return "⚠️ *尚無趨勢資料*\n\n請確認資料是否已匯入。" # 按日期升序排列 data = sorted(weekly, key=lambda x: x['date']) revs = [w['revenue'] for w in data] max_rev = max(revs) if revs else 1 min_rev = min(revs) if revs else 0 avg_rev = sum(revs) / len(revs) if revs else 0 total = sum(revs) BAR_LEN = 6 WEEKDAYS_ZH = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] label = period_label or f'近 {len(data)} 日' lines = [f"📈 *業績趨勢 — {label}*", ""] for i, w in enumerate(data): rev = w['revenue'] filled = max(0, min(BAR_LEN, int(rev / max_rev * BAR_LEN) if max_rev else 0)) empty = BAR_LEN - filled # 台灣慣例:上升=🟥紅, 下降=🟩綠, 初始=🟦藍 if i == 0: bar_str = '🟦' * filled + '▪️' * empty dir_icon = '🔵' pct_str = '' else: diff = rev - data[i - 1]['revenue'] prev = data[i - 1]['revenue'] or 1 pct_chg = diff / prev * 100 if diff > 0: bar_str = '🟥' * filled + '▪️' * empty dir_icon = '📈' pct_str = f" ▲`{pct_chg:.1f}%`" elif diff < 0: bar_str = '🟩' * filled + '▪️' * empty dir_icon = '📉' pct_str = f" ▼`{abs(pct_chg):.1f}%`" else: bar_str = '🟦' * filled + '▪️' * empty dir_icon = '→' pct_str = ' —' # 日期 + 星期 try: from datetime import datetime as _dt2 d_obj = _dt2.strptime(w['date'].replace('/', '-'), '%Y-%m-%d') date_disp = d_obj.strftime('%m/%d') wday = WEEKDAYS_ZH[d_obj.weekday()] except Exception: date_disp = w['date'][-5:] wday = '' rev_wan = rev / 10000 lines.append(f"{dir_icon} `{date_disp}` {wday} {bar_str}{pct_str}") lines.append(f"   💰 `NT$ {rev:>10,.0f}` ({rev_wan:.1f}萬)") # 統計摘要 lines.append("") lines.append("─────────────────────") lines.append("📊 *統計摘要*") lines.append(f" 📦 合計:`NT$ {total:,.0f}` ({total/10000:.1f}萬)") lines.append(f" 📐 日均:`NT$ {avg_rev:,.0f}` ({avg_rev/10000:.1f}萬)") if len(revs) >= 2: up_days = sum(1 for j in range(1, len(revs)) if revs[j] > revs[j - 1]) down_days = sum(1 for j in range(1, len(revs)) if revs[j] < revs[j - 1]) flat_days = len(revs) - 1 - up_days - down_days lines.append(f" 📈 上升 {up_days}天 📉 下降 {down_days}天 → 持平 {flat_days}天") lines.append(f" 🏆 最高:`NT$ {max_rev:,.0f}` ({max_rev/10000:.1f}萬)") lines.append(f" 🔻 最低:`NT$ {min_rev:,.0f}` ({min_rev/10000:.1f}萬)") last_diff = revs[-1] - revs[-2] last_pct = last_diff / (revs[-2] or 1) * 100 icon = '🔴' if last_diff >= 0 else '🟢' arrow = '▲' if last_diff >= 0 else '▼' lines.append(f" {icon} 最新日:{arrow}`{abs(last_pct):.1f}%` (`NT$ {last_diff:+,.0f}`)") return "\n".join(lines) def fmt_trend_summary(data: list, period_label: str) -> str: """長週期趨勢摘要(不顯示逐日列表,只顯示統計,搭配圖表使用)""" if not data: return "⚠️ *尚無趨勢資料*\n\n請確認資料是否已匯入。" data = sorted(data, key=lambda x: x['date']) revs = [w['revenue'] for w in data] total = sum(revs) avg_rev = total / len(revs) if revs else 0 max_rev = max(revs) min_rev = min(revs) max_date = data[revs.index(max_rev)]['date'][-5:] min_date = data[revs.index(min_rev)]['date'][-5:] up_days = sum(1 for j in range(1, len(revs)) if revs[j] > revs[j-1]) dn_days = sum(1 for j in range(1, len(revs)) if revs[j] < revs[j-1]) ft_days = len(revs) - 1 - up_days - dn_days def _fmt_date(d): # 將 YYYY-MM-DD 或 YYYY/MM/DD 轉成 YYYY/MM/DD return str(d).replace('-', '/')[:10] start_d = _fmt_date(data[0]['date']) end_d = _fmt_date(data[-1]['date']) # 前半段 vs 後半段比較 mid = len(revs) // 2 first = sum(revs[:mid]) / mid if mid else 0 last = sum(revs[mid:]) / (len(revs) - mid) if len(revs) > mid else 0 trend_icon = '📈' if last >= first else '📉' trend_pct = (last - first) / first * 100 if first else 0 lines = [ f"📊 *業績趨勢摘要 — {period_label}*", f" {start_d} ~ {end_d} 共 {len(revs)} 天有資料", "", f" 💰 *合計*:`NT$ {total:,.0f}` ({total/10000:.1f}萬)", f" 📐 *日均*:`NT$ {avg_rev:,.0f}` ({avg_rev/10000:.1f}萬)", "", f" 🏆 *最高*:`NT$ {max_rev:,.0f}` ({max_rev/10000:.1f}萬) [{max_date}]", f" 🔻 *最低*:`NT$ {min_rev:,.0f}` ({min_rev/10000:.1f}萬) [{min_date}]", "", f" 📈 上升 {up_days}天 📉 下降 {dn_days}天 → 持平 {ft_days}天", f" {trend_icon} 整體趨勢:後半段日均 vs 前半段 {'+' if trend_pct >= 0 else ''}{trend_pct:.1f}%", "", "_📊 走勢圖如下_", ] return "\n".join(lines) def gen_aggregated_chart(data_points: list, title: str, granularity: str = 'weekly') -> str: """產生聚合柱狀圖 PNG — 週/月粒度,上升紅/下降綠 granularity: 'weekly' → 按週分組, 'monthly' → 按月分組 """ try: import matplotlib matplotlib.use('Agg') _setup_mpl_chinese() import matplotlib.pyplot as plt import matplotlib.patches as mpatches from datetime import datetime as _dt3 import tempfile from collections import defaultdict data = sorted(data_points, key=lambda x: x['date']) if not data: return '' buckets = defaultdict(float) for w in data: try: d_obj = _dt3.strptime(w['date'].replace('/', '-'), '%Y-%m-%d') if granularity == 'monthly': key = d_obj.strftime('%Y/%m') else: # ISO week key: YYYY-Www iso = d_obj.isocalendar() key = f"{iso[0]}/W{iso[1]:02d}" except Exception: continue buckets[key] += w['revenue'] if not buckets: return '' labels = sorted(buckets.keys()) revenues = [buckets[k] / 10000 for k in labels] # 萬元 UP_COLOR = '#E53935' DOWN_COLOR = '#43A047' NEUTRAL = '#1565C0' colors = [] for i, r in enumerate(revenues): if i == 0: colors.append(NEUTRAL) elif r >= revenues[i - 1]: colors.append(UP_COLOR) else: colors.append(DOWN_COLOR) fig, ax = plt.subplots(figsize=(max(10, len(labels) * 0.9), 6)) fig.patch.set_facecolor('#FAFAFA') ax.set_facecolor('#FAFAFA') bars = ax.bar(range(len(labels)), revenues, color=colors, edgecolor='white', linewidth=0.8, width=0.65) # 金額標籤 for i, (bar, r) in enumerate(zip(bars, revenues)): ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + max(revenues) * 0.02, f'{r:.1f}萬', ha='center', va='bottom', fontsize=8.5, color=colors[i], fontweight='bold') # 漲跌百分比標籤 for i in range(1, len(revenues)): pct = (revenues[i] - revenues[i-1]) / revenues[i-1] * 100 if revenues[i-1] else 0 arrow = '▲' if pct >= 0 else '▼' ax.text(i, revenues[i] / 2, f'{arrow}{abs(pct):.1f}%', ha='center', va='center', fontsize=7.5, color='white', fontweight='bold') # 平均線 avg = sum(revenues) / len(revenues) ax.axhline(avg, color='#FF6F00', linestyle='--', linewidth=1.5, alpha=0.8, label=f'平均 {avg:.1f}萬') ax.set_xticks(range(len(labels))) ax.set_xticklabels(labels, fontsize=9, rotation=30 if len(labels) > 8 else 0) ax.set_ylabel('業績(萬元)', fontsize=11) ax.set_title(title, fontsize=13, fontweight='bold', pad=12) ax.grid(True, axis='y', alpha=0.25, linestyle='--', color='#BDBDBD') ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:,.0f}')) ax.legend(fontsize=9, framealpha=0.85) up_p = mpatches.Patch(color=UP_COLOR, label='▲ 上升') dn_p = mpatches.Patch(color=DOWN_COLOR, label='▼ 下降') ax.legend(handles=[up_p, dn_p] + ax.get_legend_handles_labels()[0], loc='upper left', fontsize=9, framealpha=0.85) plt.tight_layout() tmp = tempfile.NamedTemporaryFile( suffix='.png', prefix='ocbot_agg_', delete=False, dir='/tmp') plt.savefig(tmp.name, dpi=130, bbox_inches='tight') plt.close() return tmp.name except ImportError: sys_log.warning('[OpenClawBot] matplotlib not installed') return '' except Exception as e: sys_log.error(f'[OpenClawBot] gen_aggregated_chart: {e}') return '' # ── AI 自然語言 ─────────────────────────────────────────────── # 功能說明關鍵詞(P8:讓使用者問功能時能得到正確答案) _HELP_KEYWORDS = ( '怎麼用', '如何用', '怎麼操作', '教我', '說明', '功能', '按鈕', '怎麼查', '哪個按鈕', '在哪裡', '怎麼產出', '怎麼看', '如何查', '圖片比價', '照片比價', '如何比價', '怎麼比價', '簡報怎麼', '怎麼出簡報', '如何產報告', 'PPT怎麼', 'ppt怎麼', '目標怎麼設', '如何設定目標', '怎麼設目標', '告警', '異常通知', '怎麼知悉', '早報', '晚報', '週報', '自動推播', '使用說明', '操作說明', 'help', '幫助', ) _WAKEUP_KEYWORDS = ( '小o', '小o小龍蝦', '小o_小龍蝦', '小龍蝦', 'openclaw', ) _BUSINESS_KEYWORDS = ( '業績', '營收', '銷售', '銷量', '熱銷', '商品', '廠商', '目標', '報表', '趨勢', '分析', '簡報', '比價', '競品', '補貨', '促銷', '異常', '健康', '分類', '策略', '天氣', '新聞', '匯率', '節慶', '價格', '今日', '昨日', '今天', '本週', '上週', '本月', '上月', '今年', ) _GREETING_KEYWORDS = ( '你好', '嗨', '哈囉', '早安', '午安', '晚安', '在嗎', '你有空', '在不在', 'hello', 'hi', 'hey', ) def _normalize_nl_query(q: str) -> str: """V-Fix:去除 NL 句尾符號與空白,降低誤判率。""" return re.sub(r'[\s_\-,:;,.,。!?!?"“”\(\)\[\]<>]+', '', (q or '').lower()) def _contains_business_signal(q: str) -> bool: """V-Fix:是否包含可直接進 AI 查詢邏輯的業務關鍵字。""" ql = (q or '').lower() return any(kw in ql for kw in _BUSINESS_KEYWORDS) def _looks_like_wakeup_prompt(q: str) -> bool: """V-Fix:防止只叫名字/打招呼時走自由生成。""" ql = (q or '').lower().strip() if not ql: return False compact = _normalize_nl_query(ql) if not compact: return False if compact in _WAKEUP_KEYWORDS: return True if ( any(name in compact for name in _WAKEUP_KEYWORDS) and not _contains_business_signal(ql) and len(compact) <= 18 ): return True if compact in _GREETING_KEYWORDS and len(compact) <= 10: return True return False def _is_help_question(q: str) -> bool: ql = q.lower() return any(kw in ql for kw in _HELP_KEYWORDS) # ══════════════════════════════════════════════════════════════ # Gemini Function Calling 工具定義 # AI 自主決定要呼叫哪些工具,不再靠 if/else 規則 # ══════════════════════════════════════════════════════════════ _FC_TOOLS = [{ "function_declarations": [ { "name": "query_sales", "description": ( "查詢公司內部業績資料庫。" "當用戶詢問自家業績、銷售數字、訂單量、毛利率、熱銷商品、廠商排行等內部數據時使用。" ), "parameters": { "type": "OBJECT", "properties": { "period": { "type": "STRING", "enum": ["today","yesterday","this_week","last_week", "this_month","last_month","date","month","range","all"], "description": "查詢時間範圍" }, "date": {"type": "STRING", "description": "具體日期 YYYY/MM/DD,period=date 時填"}, "year": {"type": "INTEGER", "description": "年份,period=month 時填"}, "month": {"type": "INTEGER", "description": "月份1-12,period=month 時填"}, "start_date": {"type": "STRING", "description": "開始日期 YYYY/MM/DD,period=range 時填"}, "end_date": {"type": "STRING", "description": "結束日期 YYYY/MM/DD,period=range 時填"}, }, "required": ["period"] } }, { "name": "get_market_intel", "description": ( "取得外部市場情報。" "當用戶詢問市場趨勢、電商新聞、消費者討論(PTT/Dcard)、" "熱搜關鍵字、YouTube爆紅商品、匯率、天氣、即將到來的節慶促銷檔期等外部資訊時使用。" ), "parameters": { "type": "OBJECT", "properties": { "sources": { "type": "ARRAY", "items": { "type": "STRING", "enum": ["news","trends","social","youtube","weather","exchange","calendar"] }, "description": "需要哪些情報來源,可多選。全部不確定時選 ['news','trends','calendar']" } }, "required": ["sources"] } }, { "name": "get_knowledge", "description": ( "從歷史知識庫和過去 AI 分析記錄中語義檢索相關資訊。" "當需要參考過去的分析結論、歷史業績模式、或相似問題的回答時使用。" ), "parameters": { "type": "OBJECT", "properties": { "query": {"type": "STRING", "description": "檢索關鍵字或問題描述"} }, "required": ["query"] } }, { "name": "check_data_freshness", "description": ( "查詢內部業績資料的最新可用日期與可用月份清單。" "ADR-019 Phase 2:在回答任何特定時段(本月/本週/某月某日)業績問題前," "先呼叫此工具確認資料是否已涵蓋該期間,避免回覆「業績為零」這類因 ETL " "尚未匯入造成的虛假結論。月初/季初、用戶用『本月/本週』等相對時間詞時務必先呼叫。" ), "parameters": {"type": "OBJECT", "properties": {}, "required": []} } ] }] def _execute_tool(name: str, args: dict) -> dict: """執行 Gemini 指定的工具,回傳結構化結果""" now = datetime.now(TAIPEI_TZ) today = now.strftime("%Y/%m/%d") yd = (now - timedelta(days=1)).strftime("%Y/%m/%d") # ── query_sales ─────────────────────────────────────────── if name == "query_sales": period = args.get("period", "today") result = {} if period in ("today", "date"): d = args.get("date", today) sales = query_sales(d) tops = query_top_products(d, 10) vens = query_top_vendors(d, 5) result = {"date": d, "sales": sales, "top_products": tops, "top_vendors": vens} elif period == "yesterday": sales = query_sales(yd) tops = query_top_products(yd, 10) result = {"date": yd, "sales": sales, "top_products": tops} elif period in ("this_week", "last_week"): trend = query_weekly_trend() result = {"weekly_trend": trend} elif period == "this_month": ms = query_monthly_summary(now.year, now.month) result = {"monthly": ms, "year": now.year, "month": now.month} elif period == "last_month": first = now.replace(day=1) lm = (first - timedelta(days=1)) ms = query_monthly_summary(lm.year, lm.month) result = {"monthly": ms, "year": lm.year, "month": lm.month} elif period == "month": yr = args.get("year", now.year) mo = args.get("month", now.month) ms = query_monthly_summary(yr, mo) result = {"monthly": ms, "year": yr, "month": mo} elif period == "range": s = args.get("start_date", yd) e = args.get("end_date", today) rng = query_date_range(s, e) tops = query_top_products_range(s, e, 10) result = {"range": rng, "start": s, "end": e, "top_products": tops} elif period == "all": history = query_daily_history(30) avail = query_available_months() result = {"history_30d": history, "available_months": avail} # 附上可查詢月份清單 avail = query_available_months() result["available_months"] = [m["month"] for m in avail] if avail else [] result["report_url"] = f"{MOMO_BASE_URL}/reports/daily/{args.get('date', today)}" return result # ── get_market_intel ────────────────────────────────────── elif name == "get_market_intel": sources = args.get("sources", ["news", "trends", "calendar"]) data = {} source_map = { "news": lambda: {**get_tw_media_news(), **{"google": get_ecommerce_news()}}, "trends": get_taiwan_trends, "social": get_dcard_trends, "youtube": get_youtube_trending, "weather": get_taiwan_weather, "exchange": get_twbank_exchange_rates, "calendar": get_upcoming_events, } for src in sources: if src in source_map: try: data[src] = source_map[src]() except Exception as e: data[src] = {"error": str(e)} return data # ── get_knowledge ───────────────────────────────────────── elif name == "get_knowledge": if not _LEARNING_ENABLED: return {"results": [], "note": "知識庫未啟用"} try: items = retrieve_knowledge(args.get("query", ""), top_k=4, min_quality=0.4) return {"results": [ {"category": i["category"], "title": i["title"], "content": i["content"][:400], "score": i["similarity"]} for i in items ]} except Exception as e: return {"results": [], "error": str(e)} # ── check_data_freshness ────────────────────────────────── elif name == "check_data_freshness": # ADR-019 Phase 2:讓 agent 在回答業績問題前 probe 資料缺口 latest = latest_date() # 'YYYY-MM-DD' 或 None avail_months = query_available_months() or [] result = { "latest_data_date": latest, "today": today, "available_months": [m["month"] for m in avail_months], "month_count": len(avail_months), } if latest: try: latest_dt = datetime.strptime(latest.replace('/', '-'), '%Y-%m-%d') gap_days = (now.date() - latest_dt.date()).days result["gap_days"] = gap_days result["current_month_has_data"] = ( latest_dt.year == now.year and latest_dt.month == now.month ) result["data_freshness_warning"] = ( "⚠️ 當月尚無資料,請改詢問上月" if not result["current_month_has_data"] else ("⚠️ 資料落後 " + str(gap_days) + " 天" if gap_days > 2 else None) ) except (ValueError, AttributeError): pass return result return {"error": f"unknown tool: {name}"} def openclaw_answer(question: str, chat_id: int = None): """ Function Calling 架構 — AI 自主決定查什麼、怎麼回答 不再靠 if/else 規則判斷意圖 ADR-019 Phase 4:可選 chat_id 啟用對話 state。傳入後 agent 會看到該 chat 的 最近對話歷史,並把本輪 (question, answer) 寫回 session 供下輪使用。 chat_id 為 None 時行為與舊版完全相同(無 multi-turn 記憶)。 """ from services import openclaw_session now = datetime.now(TAIPEI_TZ) today_str = now.strftime("%Y/%m/%d") history_ctx = openclaw_session.history_as_prompt(chat_id) if chat_id else "" # ── 只叫名 / 問候:先導回主選單,避免題外市場回覆 ─────────────── if _looks_like_wakeup_prompt(question): wakeup_text = ( "👋 *OpenClaw(小O)* 在!\n\n" "你可以直接點下面按鈕,或直接問我:\n" " 「今天業績如何?」\n" " 「怎麼查看熱銷商品?」\n" " 「有什麼市場情報?」" ) return wakeup_text, main_menu_keyboard() # ── 功能說明直接導 help ─────────────────────────────────── if _is_help_question(question): help_text = ( "📖 *OpenClaw 功能說明*\n\n" "你可以直接點下方按鈕進入對應功能,或問我:\n\n" "🔍 *常見問題*\n" " 「今天業績」→ 點 📊業績查詢 → 今日\n" " 「熱銷商品」→ 點 🏆商品廠商 → 熱銷商品\n" " 「出日報PPT」→ 點 📄簡報報表 → 日報\n" " 「促銷分析」→ 點 📈智能分析 → 促銷追蹤\n" " 「設定月目標」→ 點 🎯目標管理 → 月目標\n" " 「圖片比價」→ 直接傳圖片給我\n" " 「競品比較」→ 點 🔍競品日報\n\n" "📌 *完整說明*:點下方「❓使用說明」按鈕\n\n" "_或直接問我任何問題,我會自動找資料回答!_" ) return help_text, quick_menu_keyboard() from services.ollama_service import ollama_service # ── Ollama 首選(使用傳統 prompt 注入上下文)───────────────── try: if ollama_service.check_connection(): intent = resolve_query_intent(question) today = today_str yd = (now - timedelta(days=1)).strftime("%Y/%m/%d") d = intent.get("date", yd) if intent["type"] == "day" else yd db_ctx = "" if intent["type"] == "day": s = query_sales(d) t = query_top_products(d, 8) if s.get("found"): db_ctx = ( f"日期{d} 業績NT${s.get('revenue',0):,.0f} " f"訂單{s.get('orders',0)}筆 毛利{s.get('gross_margin',0):.1f}% " f"TOP5: " + " / ".join( f"[{p.get('id','')}]{p['name']}(NT${p['revenue']:,.0f})" for p in t[:5]) ) elif intent["type"] == "month": ms = query_monthly_summary(intent["year"], intent["month"]) if ms.get("found"): db_ctx = (f"{intent['year']}年{intent['month']:02d}月 " f"業績NT${ms.get('revenue',0):,.0f} " f"訂單{ms.get('orders',0)}筆 " f"毛利{ms.get('gross_margin',0):.1f}%") mcp_ctx = build_mcp_context(question) sys_prompt = ( f"你是 OpenClaw(小O),電商智能助理。今天{today}。\n" + (f"【最近對話】\n{history_ctx}\n\n" if history_ctx else "") + (f"【業績資料】{db_ctx}\n" if db_ctx else "") + (f"【市場情報】{mcp_ctx[:400]}\n" if mcp_ctx else "") + "請用繁體中文直接回答,不要開場白,300字以內。" ) # Phase 1 v5.0: 包 ai_call_logger 追蹤 Bot Q&A 主鏈 Ollama _qa_req_id = f"qa-{chat_id or 0}-{int(_time_mod.time())}" with log_ai_call( caller='openclaw_bot_main', provider='gcp_ollama', model=getattr(ollama_service, 'model', 'llama3.1:8b'), request_id=_qa_req_id, meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'has_db_ctx': bool(db_ctx)}, ) as _ctx: resp = ollama_service.generate(question, system_prompt=sys_prompt, timeout=180) if resp.success and resp.content: if chat_id: openclaw_session.append_turn(chat_id, question, resp.content) if _LEARNING_ENABLED: import threading as _thr _thr.Thread(target=store_conversation, args=(0, 0, question, resp.content, "ollama", []), daemon=True).start() return resp.content, None else: sys_log.warning(f"[Ollama] 生成失敗: {resp.error},fallback 到 Gemini") _ctx.set_error(f"ollama generate failed: {resp.error}") _ctx.fallback_to_caller('openclaw_bot_gemini') except Exception as e: sys_log.warning(f"[Ollama] 例外發生: {e},fallback 到 Gemini") if not _gemini_fallback_allowed('openclaw_bot_fc') and not NVIDIA_API_KEY: return "(AI 引擎未設定,請確認 API Key 或啟動 Ollama 服務)", None # ── Gemini Function Calling (備援 1) ───────────────────────── gemini_fc_api_key = _gemini_fallback_api_key('openclaw_bot_fc') if gemini_fc_api_key: try: sys_msg = ( f"你是 OpenClaw(小O),服務「小龍蝦」電商業務團隊的 AI 助理。\n" f"今天是 {today_str}。\n" + (f"\n【最近對話歷史】\n{history_ctx}\n" if history_ctx else "") + "你有四個工具可以使用:\n" "1. query_sales — 查自家業績資料庫\n" "2. get_market_intel — 取得外部市場情報(新聞/熱搜/PTT口碑/匯率/天氣/節慶)\n" "3. get_knowledge — 查歷史分析知識庫\n" "4. check_data_freshness — 確認業績資料最新可用日期,回答任何特定時段問題前必先呼叫\n\n" "決策規則(ADR-019 Phase 2):\n" "- 用戶用『本月/本週/今天』等相對時間 → 先 check_data_freshness\n" "- 若 data_freshness_warning 顯示當月無資料,禁止編造『業績為零』," "改主動問用戶是否要改看上月(並附 latest_data_date)\n" "- 工具結果回來後用繁體中文自然回答,不要開場白,不要多餘客套話\n" "- 同時呼叫多個工具時請平行送出\n" "- 若【最近對話歷史】顯示用戶剛被詢問某參數(如月份),優先用該答案接續執行" ) # 第一輪:讓 Gemini 判斷需要呼叫哪些工具 conversation = [ {"role": "user", "parts": [{"text": sys_msg + "\n\n用戶問:" + question}]} ] payload = { "contents": conversation, "tools": _FC_TOOLS, "tool_config": {"function_calling_config": {"mode": "AUTO"}}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 600}, } # Phase 1 v5.0: 包 ai_call_logger 追蹤 Gemini FC 第一輪 _qa_gemini_req_id = f"qa-{chat_id or 0}-{int(_time_mod.time())}" with log_ai_call( caller='openclaw_bot_gemini', provider='gemini', model=GEMINI_MODEL, request_id=_qa_gemini_req_id, meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'turn': 1}, ) as _ctx_g1: r1 = requests.post( f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_fc_api_key}", headers={"Content-Type": "application/json"}, json=payload, timeout=30, ) r1.raise_for_status() resp1 = r1.json() # Gemini REST: usageMetadata.{promptTokenCount, candidatesTokenCount} _um = resp1.get("usageMetadata", {}) or {} _ctx_g1.set_tokens( input=_um.get("promptTokenCount", 0), output=_um.get("candidatesTokenCount", 0), ) candidate = resp1.get("candidates", [{}])[0] parts = candidate.get("content", {}).get("parts", []) # 如果沒有 function call,直接回傳文字 tool_calls = [p["functionCall"] for p in parts if "functionCall" in p] if not tool_calls: text = "".join(p.get("text", "") for p in parts if "text" in p).strip() if text: if chat_id: openclaw_session.append_turn(chat_id, question, text) if _LEARNING_ENABLED: import threading as _thr _thr.Thread(target=store_conversation, args=(0, 0, question, text, "direct", []), daemon=True).start() return text, None # 第二輪:執行所有工具,把結果送回 Gemini tool_results = [] used_sources = [] for tc in tool_calls: fn_name = tc["name"] fn_args = tc.get("args", {}) sys_log.info(f"[FC] calling {fn_name}({fn_args})") fn_result = _execute_tool(fn_name, fn_args) used_sources.append(fn_name) tool_results.append({ "functionResponse": { "name": fn_name, "response": fn_result } }) # 組建多輪對話 conversation.append({"role": "model", "parts": parts}) conversation.append({"role": "user", "parts": tool_results}) payload2 = { "contents": conversation, "tools": _FC_TOOLS, "tool_config": {"function_calling_config": {"mode": "NONE"}}, # 第二輪強制回文字 "generationConfig": { "temperature": 0.3, "maxOutputTokens": 600, }, } # Phase 1 v5.0: 包 ai_call_logger 追蹤 Gemini FC 第二輪 with log_ai_call( caller='openclaw_bot_gemini', provider='gemini', model=GEMINI_MODEL, request_id=_qa_gemini_req_id, meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'turn': 2, 'tools_used': used_sources}, ) as _ctx_g2: r2 = requests.post( f"{GEMINI_BASE_URL}/{GEMINI_MODEL}:generateContent?key={gemini_fc_api_key}", headers={"Content-Type": "application/json"}, json=payload2, timeout=35, ) r2.raise_for_status() resp2 = r2.json() _um2 = resp2.get("usageMetadata", {}) or {} _ctx_g2.set_tokens( input=_um2.get("promptTokenCount", 0), output=_um2.get("candidatesTokenCount", 0), ) parts2 = resp2.get("candidates", [{}])[0].get("content", {}).get("parts", []) final = "".join(p.get("text", "") for p in parts2 if "text" in p).strip() if final: sys_log.info(f"[FC] done tools={used_sources} reply={len(final)}chars") if chat_id: openclaw_session.append_turn(chat_id, question, final) if _LEARNING_ENABLED: import threading as _thr _thr.Thread(target=store_conversation, args=(0, 0, question, final, ",".join(used_sources), used_sources), daemon=True).start() return final, None except Exception as e: sys_log.warning(f"[FC] Gemini Function Calling failed: {e}, fallback NIM") # ── NIM 備援(不支援 FC,用傳統 prompt)───────────────── if NVIDIA_API_KEY: try: intent = resolve_query_intent(question) today = today_str yd = (now - timedelta(days=1)).strftime("%Y/%m/%d") d = intent.get("date", yd) if intent["type"] == "day" else yd db_ctx = "" if intent["type"] == "day": s = query_sales(d) t = query_top_products(d, 8) if s.get("found"): db_ctx = ( f"日期{d} 業績NT${s.get('revenue',0):,.0f} " f"訂單{s.get('orders',0)}筆 毛利{s.get('gross_margin',0):.1f}% " f"TOP5: " + " / ".join( f"[{p.get('id','')}]{p['name']}(NT${p['revenue']:,.0f})" for p in t[:5]) ) elif intent["type"] == "month": ms = query_monthly_summary(intent["year"], intent["month"]) if ms.get("found"): db_ctx = (f"{intent['year']}年{intent['month']:02d}月 " f"業績NT${ms.get('revenue',0):,.0f} " f"訂單{ms.get('orders',0)}筆 " f"毛利{ms.get('gross_margin',0):.1f}%") mcp_ctx = build_mcp_context(question) nim_prompt = ( f"你是 OpenClaw(小O),電商智能助理。今天{today}。\n" + (f"【業績資料】{db_ctx}\n" if db_ctx else "") + (f"【市場情報】{mcp_ctx[:400]}\n" if mcp_ctx else "") + f"\n用戶問:{question}\n" "請用繁體中文直接回答,不要開場白,300字以內。" ) # Phase 1 v5.0: 包 ai_call_logger 追蹤 Bot Q&A NIM 三層 fallback _qa_nim_req_id = f"qa-{chat_id or 0}-{int(_time_mod.time())}" with log_ai_call( caller='openclaw_bot_nim', provider='nim', model=CHAT_MODEL, request_id=_qa_nim_req_id, meta={'chat_id_hash': hashlib.sha1(str(chat_id or 0).encode()).hexdigest()[:8], 'has_db_ctx': bool(db_ctx)}, ) as _ctx_nim: r = requests.post( f"{NVIDIA_BASE_URL}/chat/completions", headers={"Authorization": f"Bearer {NVIDIA_API_KEY}", "Content-Type": "application/json"}, json={ "model": CHAT_MODEL, "messages": [{"role": "user", "content": nim_prompt}], "max_tokens": 500, "temperature": 0.3, }, timeout=20, ) r.raise_for_status() _body = r.json() _u = _body.get("usage", {}) or {} _ctx_nim.set_tokens( input=_u.get("prompt_tokens", 0), output=_u.get("completion_tokens", 0), ) return _body["choices"][0]["message"]["content"].strip(), None except Exception as e: sys_log.error(f"[FC] NIM fallback error: {e}") return "(AI 引擎全部異常:Ollama 超時,Gemini/NIM 備援亦無回應或達速率限制,請稍後再試)", None # ── 指令處理 ────────────────────────────────────────────────── _CMD_TO_NL = { 'sales': lambda a: f"請查 {a or '今日'} 的業績數字(包含營收、訂單數、毛利率)", 'top': lambda a: f"請列出 {a or '今日'} 的 TOP10 熱銷商品", 'vendor': lambda a: f"請列出 {a or '今日'} 的 TOP10 熱銷廠商", } def _agent_dispatch_cmd(cmd, arg, chat_id, reply_to) -> bool: """ADR-019 Phase 3: Feature-flagged. 將白名單 cmd 翻成 NL question 交 agent 處理。 Agent 自動 probe 資料新鮮度(透過 Phase 2 的 check_data_freshness tool),缺資料時 主動詢問用戶。回 True 表示已交 agent 處理,handle_cmd 不再走原 dispatch。 回 False 表示維持原行為(含 feature flag 關閉、cmd 不在白名單、agent 失敗等)。 """ if not _OPENCLAW_AGENT_DISPATCH_ENABLED: return False if cmd not in _AGENT_DISPATCH_CMDS: return False if cmd not in _CMD_TO_NL: return False # 翻譯規則尚未建立 → 安全降級 nl_question = _CMD_TO_NL[cmd](arg) try: sys_log.info(f"[AgentDispatch] cmd:{cmd}:{arg or ''} → NL: {nl_question}") txt, kb = openclaw_answer(nl_question, chat_id=chat_id) send_message(chat_id, txt, reply_to, keyboard=kb) return True except Exception as e: sys_log.error(f"[AgentDispatch] cmd:{cmd} agent failed, fallback to direct handler: {e}") return False def handle_cmd(cmd, arg, chat_id, reply_to): ld = latest_date() or datetime.now(TAIPEI_TZ).strftime('%Y/%m/%d') target = normalize_date(arg) if arg else ld # ADR-019 Phase 3: agent dispatch hook(feature flag 預設 OFF) if (not _CMD_FROM_CALLBACK_CTX.get()) and _agent_dispatch_cmd(cmd, arg, chat_id, reply_to): return def _send_mcp_text_result(title: str, data, empty_message: str) -> bool: """相容新版 MCP 文字回傳;已處理則回 True,舊 dict 格式則回 False。""" if isinstance(data, str): text = data.strip() if text: send_message(chat_id, f"{title}\n\n{text[:3600]}", reply_to, parse_mode=None) else: send_message(chat_id, empty_message, reply_to, parse_mode=None) return True return False if cmd in ('sales', '業績'): s = query_sales(target) t = query_top_products(target, 3) send_message(chat_id, fmt_sales(s, t, f"{MOMO_BASE_URL}/reports/daily/{target}"), reply_to, sales_quick_kb(target) if s.get('found') else None) elif cmd in ('top', '熱銷'): p = query_top_products(target, 10) kb = [_row(('📊 查業績', f'cmd:sales:{target}'), ('📋 完整報表', f'cmd:report:{target}'))] send_message(chat_id, fmt_products(p, target), reply_to, kb) elif cmd in ('vendor', '廠商'): v = query_top_vendors(target, 10) kb = [_row(('🏆 熱銷商品', f'cmd:top:{target}'), ('📊 查業績', f'cmd:sales:{target}'))] send_message(chat_id, fmt_vendors(v, target), reply_to, kb) elif cmd in ('trend', '趨勢'): import calendar as _cal from datetime import date as _date now_d = datetime.now(TAIPEI_TZ).date() sub = arg.lower().strip() if arg else '7' # 決定查詢區間 if sub in ('', '7', 'week', 'weekly', '週', '近7日', '七天'): ld_str = latest_date() or now_d.strftime('%Y/%m/%d') end_d = datetime.strptime(ld_str.replace('/', '-'), '%Y-%m-%d').date() start_d = end_d - timedelta(days=6) period_label = '近7日' elif sub in ('month', '30', '月', '本月', '近30日', '近一月'): end_d = now_d start_d = end_d - timedelta(days=29) period_label = '近30日' elif sub in ('quarter', 'q', '季', '近季', '近3月', '近三月'): end_d = now_d start_d = end_d - timedelta(days=89) period_label = '近3個月' elif sub in ('half', '半年', '近半年', '6月', '六個月'): end_d = now_d start_d = end_d - timedelta(days=179) period_label = '近半年' elif sub in ('year', 'yearly', '年', '本年', '近年', '近一年'): end_d = now_d start_d = end_d - timedelta(days=364) period_label = '近一年' elif re.fullmatch(r'\d{4}/\d{1,2}', sub): yr_s, mo_s = sub.split('/') yr, mo = int(yr_s), int(mo_s) start_d = _date(yr, mo, 1) end_d = _date(yr, mo, _cal.monthrange(yr, mo)[1]) period_label = f'{yr}年{mo:02d}月' elif re.fullmatch(r'\d{4}', sub): yr = int(sub) start_d = _date(yr, 1, 1) end_d = _date(yr, 12, 31) period_label = f'{yr}年度' elif re.fullmatch(r'\d{4}/[qQ]([1-4])', sub): m2 = re.fullmatch(r'(\d{4})/[qQ]([1-4])', sub) yr, qn = int(m2.group(1)), int(m2.group(2)) q_months = [(1, 3), (4, 6), (7, 9), (10, 12)] sm, em = q_months[qn - 1] start_d = _date(yr, sm, 1) end_d = _date(yr, em, _cal.monthrange(yr, em)[1]) period_label = f'{yr}年Q{qn}' else: ld_str = latest_date() or now_d.strftime('%Y/%m/%d') end_d = datetime.strptime(ld_str.replace('/', '-'), '%Y-%m-%d').date() start_d = end_d - timedelta(days=6) period_label = '近7日' start_str = start_d.strftime('%Y/%m/%d') end_str = end_d.strftime('%Y/%m/%d') days_count = (end_d - start_d).days + 1 data = query_trend_range(start_str, end_str) if not data: data = query_weekly_trend() chart_title = f'業績趨勢走勢圖 — {period_label}({start_str}~{end_str})' if days_count <= 35: # 週/月:逐日文字 + 折線圖 send_message(chat_id, fmt_trend(data, period_label), reply_to, _submenu_trend()) try: png = gen_trend_chart(data_points=data, title=chart_title) if png: send_photo(chat_id, png, caption=f'📈 {period_label} 趨勢走勢圖') try: os.unlink(png) except Exception: sys_log.exception('[OpenClawBot] trend chart temp cleanup failed: %s', png) except Exception as _te: sys_log.warning(f'[OpenClawBot] trend chart error: {_te}') else: # 季/半年/年:摘要 + 聚合柱狀圖(不洗版面) granularity = 'monthly' if days_count > 100 else 'weekly' send_message(chat_id, fmt_trend_summary(data, period_label), reply_to, _submenu_trend()) try: png = gen_aggregated_chart(data, chart_title, granularity=granularity) if png: unit = '月' if granularity == 'monthly' else '週' send_photo(chat_id, png, caption=f'📊 {period_label} 業績走勢(按{unit})') try: os.unlink(png) except Exception: sys_log.exception('[OpenClawBot] trend agg chart temp cleanup failed: %s', png) except Exception as _te: sys_log.warning(f'[OpenClawBot] trend agg chart error: {_te}') elif cmd in ('report', '報表'): send_message(chat_id, f"⏳ 正在產生 {target} 完整報表...", reply_to, parse_mode=None) try: import os as _os pdf_path = generate_daily_pdf(target) if pdf_path: if pdf_path.endswith('.xlsx'): ext = 'Excel' elif pdf_path.endswith('.csv'): ext = 'CSV' else: ext = 'PDF' send_document( chat_id, pdf_path, caption=f"📊 {target} 業績報表({ext})", reply_to=reply_to ) _os.unlink(pdf_path) else: send_message(chat_id, f"⚠️ 報表產生失敗,請稍後再試", reply_to) except Exception as e: sys_log.error(f"[OpenClawBot] report cmd error: {e}") send_message(chat_id, f"⚠️ 報表產生發生錯誤,請稍後再試", reply_to) elif cmd == 'news': try: from services.mcp_context_service import get_ecommerce_news data = get_ecommerce_news() if _send_mcp_text_result("📰 即時電商新聞", data, "⚠️ 新聞資料暫時無法取得"): return news = data.get('news', []) sys_log.info(f"[OpenClawBot] news: {len(news)} items") if news: lines = ["📰 即時電商新聞"] for n in news[:6]: title = n['title'][:60] url = n.get('url', '') src = n.get('source', '') if url: lines.append(f"• {title}({src})") else: lines.append(f"• {title}({src})") send_message(chat_id, "\n".join(lines), reply_to, parse_mode='HTML') else: send_message(chat_id, "⚠️ 新聞資料暫時無法取得", reply_to, parse_mode=None) except Exception as e: sys_log.error(f"[OpenClawBot] news error: {e}", exc_info=True) send_message(chat_id, "⚠️ 新聞功能暫時異常,請稍後再試", reply_to, parse_mode=None) elif cmd == 'weather': try: from services.mcp_context_service import get_taiwan_weather data = get_taiwan_weather() if _send_mcp_text_result("🌤 台灣天氣預報", data, "⚠️ 天氣資料暫時無法取得"): return weather = data.get('weather', {}) sys_log.info(f"[OpenClawBot] weather: {list(weather.keys())}") if weather: lines = ["🌤 台灣天氣預報"] for city, w in list(weather.items())[:6]: wx = w.get('Wx', w.get('weatherDesc', '')) pop = w.get('PoP', '') tmin = w.get('MinT', '') tmax = w.get('MaxT', '') tmp = w.get('temp', f"{tmin}~{tmax}°C" if tmin else '') lines.append(f"• {city}:{wx} {tmp}" + (f" 降雨{pop}%" if pop else "")) send_message(chat_id, "\n".join(lines), reply_to, parse_mode=None) else: send_message(chat_id, "⚠️ 天氣資料暫時無法取得", reply_to, parse_mode=None) except Exception as e: sys_log.error(f"[OpenClawBot] weather error: {e}", exc_info=True) send_message(chat_id, "⚠️ 天氣功能暫時異常,請稍後再試", reply_to, parse_mode=None) elif cmd == 'trends': try: from services.mcp_context_service import get_taiwan_trends data = get_taiwan_trends() if _send_mcp_text_result("🔥 台灣 Google 熱搜(即時)", data, "⚠️ 熱搜資料暫時無法取得"): return trends = data.get('trends', []) if trends: lines = ["🔥 *台灣 Google 熱搜(即時)*\n"] for i, t in enumerate(trends[:12], 1): lines.append(f"{i}. {t['keyword']}") send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown') else: send_message(chat_id, "⚠️ 熱搜資料暫時無法取得", reply_to) except Exception as e: send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to) elif cmd == 'dcard': try: from services.mcp_context_service import get_dcard_trends data = get_dcard_trends() if _send_mcp_text_result("💬 Dcard 消費者討論熱點", data, "⚠️ Dcard 資料暫時無法取得"): return posts = data.get('posts', []) if posts: lines = ["💬 *Dcard 消費者討論熱點*\n"] for p in posts[:8]: lines.append(f"• [{p['board']}] {p['title'][:50]}") send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown') else: send_message(chat_id, "⚠️ Dcard 資料暫時無法取得", reply_to) except Exception as e: send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to) elif cmd == 'exchange': try: from services.mcp_context_service import get_twbank_exchange_rates data = get_twbank_exchange_rates() if _send_mcp_text_result("💱 台灣銀行即時匯率", data, "⚠️ 匯率資料暫時無法取得"): return rates = data.get('rates', {}) if rates: lines = ["💱 *台灣銀行即時匯率*\n"] for code, info in rates.items(): name = info.get('name', code) if 'buy' in info: lines.append(f"• {name}({code}) 買入 `{info['buy']}` / 賣出 `{info['sell']}`") elif 'mid' in info: lines.append(f"• {name}({code}) `{info['mid']}`") lines.append(f"\n_資料來源:台灣銀行 {data.get('fetched_at','')[:16]}_") send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown') else: send_message(chat_id, "⚠️ 匯率資料暫時無法取得", reply_to) except Exception as e: send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to) elif cmd == 'calendar': try: from services.mcp_context_service import get_upcoming_events data = get_upcoming_events() if _send_mcp_text_result("📅 近期電商節慶行事曆", data, "✅ 近 60 天無重大電商節慶"): return events = data.get('events', []) if events: lines = ["📅 *近期電商節慶行事曆*\n"] for ev in events: days = ev['days_to_event'] day_str = f"還有 {days} 天" if days > 0 else ("今天" if days == 0 else f"進行中(+{-days}天)") lines.append(f"{ev['status']} *{ev['name']}*({ev['date']},{day_str})") lines.append(f" 建議提前 {ev['warmup_days']} 天開始備戰") send_message(chat_id, "\n".join(lines), reply_to, parse_mode='Markdown') else: send_message(chat_id, "✅ 近 60 天無重大電商節慶", reply_to) except Exception as e: send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to) elif cmd == 'youtube': try: from services.mcp_context_service import get_youtube_trending data = get_youtube_trending() if _send_mcp_text_result("▶️ YouTube 熱門開箱/推薦影片", data, "⚠️ YouTube 資料暫時無法取得"): return videos = data.get('videos', []) if videos: lines = ["▶️ *YouTube 熱門開箱/推薦影片*\n"] for v in videos[:6]: title = v['title'][:55] url = v.get('url', '') if url: lines.append(f"• {title}") else: lines.append(f"• {title}") send_message(chat_id, "\n".join(lines), reply_to, parse_mode='HTML') else: send_message(chat_id, "⚠️ YouTube 資料暫時無法取得", reply_to) except Exception as e: send_message(chat_id, f"⚠️ 取得失敗: {e}", reply_to) # ── PChome 比價指令 ─────────────────────────────────────────── elif cmd in ('competitor', '比價', 'price'): # /competitor → 昨日 TOP30 競品比對日報 # /competitor 商品名 → 單品即時比價 if not _PCHOME_AVAILABLE: send_message(chat_id, '⚠️ PChome 比價服務未啟用', reply_to) return keyword = arg.strip() if arg else '' is_date = bool(keyword and re.match(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', keyword)) if keyword and not is_date: # 即時比價(商品關鍵字) send_message(chat_id, f'🔍 正在比價「{keyword}」,請稍候...', reply_to, parse_mode=None) def _compare_bg(_kw, _chat_id, _reply_to): try: from services.pchome_crawler import search_pchome as _sp, find_best_match as _fm # 搜尋 momo products with _db().connect() as c: rows = c.execute(text(""" SELECT p.name, p.i_code, COALESCE(pr.price, 0) FROM products p LEFT JOIN ( SELECT product_id, price, ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY timestamp DESC) as rn FROM price_records ) pr ON p.id=pr.product_id AND pr.rn=1 WHERE p.name ILIKE :kw ORDER BY pr.price DESC NULLS LAST LIMIT 5 """), {'kw': f'%{_kw}%'}).fetchall() results = [] for row in rows: name, icode, price = row[0], row[1], float(row[2] or 0) cmp = pchome_compare(name, price, icode) results.append(cmp) import time; time.sleep(0.6) if not results: # 沒有 momo 商品 → 直接搜尋 PChome pc_results = _sp(_kw, limit=5) if pc_results: lines = [f'🔍 *PChome 商品搜尋:{_kw}*', ''] for i, p in enumerate(pc_results[:5], 1): stk = '✅' if p['in_stock'] else '❌' lines.append(f'{i}. {p["name"][:30]}') lines.append(f' {stk} `NT$ {p["price"]:,.0f}` [查看]({p["url"]})') send_message(_chat_id, '\n'.join(lines), _reply_to) else: send_message(_chat_id, f'⚠️ 在 PChome 找不到「{_kw}」相關商品', _reply_to) return msg = pchome_fmt_compare(results, _kw) kb = [_row(('🔍 重新搜尋', 'await:search_compare'), ('📊 競品日報', 'menu:competitor'))] send_message(_chat_id, msg, _reply_to, kb) except Exception as _e: sys_log.error(f'[PChome] compare_bg: {_e}', exc_info=True) send_message(_chat_id, f'⚠️ 比價失敗:{str(_e)[:80]}', _reply_to) threading.Thread(target=_compare_bg, args=(keyword, chat_id, reply_to), daemon=True).start() else: # 無關鍵字或日期參數 → 昨日(或指定日期)熱銷競品日報(背景執行) if is_date: yesterday = normalize_date(keyword) date_label = yesterday else: yesterday = (datetime.now(TAIPEI_TZ).date() - timedelta(days=1)).strftime('%Y/%m/%d') date_label = f'昨日 ({yesterday[-5:]})' send_message(chat_id, f'📊 正在分析 {date_label} TOP30 熱銷商品 vs PChome 比價,預計 30~60 秒...', reply_to, parse_mode=None) def _daily_report_bg(_date_str, _chat_id, _reply_to): try: results = pchome_batch(_db(), top_n=30, date_str=_date_str) pchome_save(_db(), results) msg = pchome_fmt_report(results, _date_str) kb = [_row(('🔍 搜尋比價', 'await:search_compare'), ('📄 比價簡報', 'menu:competitor_ppt'))] send_message(_chat_id, msg, _reply_to, kb) except Exception as _e: sys_log.error(f'[PChome] daily_report_bg: {_e}', exc_info=True) send_message(_chat_id, f'⚠️ 競品分析失敗:{str(_e)[:80]}', _reply_to) threading.Thread(target=_daily_report_bg, args=(yesterday, chat_id, reply_to), daemon=True).start() # ── v5 新增指令 ────────────────────────────────────────────── elif cmd in ('compare', '同比'): data = query_comparison(target) send_message( chat_id, fmt_comparison(data, target), reply_to, [_row(('📊 今日業績', f'cmd:sales:{target}'), ('🧬 策略矩陣', f'cmd:strategy:{target}'))], ) elif cmd in ('category', '分類'): cats = query_category_sales(target) kb = [_row(('🗂 鑽取分類', 'menu:category'), ('🏆 熱銷商品', f'cmd:top:{target}'))] send_message(chat_id, fmt_category(cats, target), reply_to, kb) elif cmd in ('catdetail', '分類細項'): # arg = "母嬰:2026/04/15" or "母嬰" parts_cd = arg.split(':') if arg else [] cat_name = parts_cd[0].strip() if parts_cd else '' date_cd = parts_cd[1].strip() if len(parts_cd) > 1 else target if not cat_name: send_message(chat_id, "⚠️ 請指定分類名稱", reply_to) else: items = query_category_detail(cat_name, date_cd) d_label = date_cd[-5:] if date_cd else '近7日' msg = fmt_category_detail(cat_name, items, d_label) kb = [_row(('⬅ 分類總覽', 'menu:category'), ('🏆 熱銷商品', f'cmd:top:{target}'))] send_message(chat_id, msg, reply_to, kb) elif cmd in ('restock', '補貨'): send_message(chat_id, "⏳ 正在計算補貨預測...", reply_to, parse_mode=None) items = query_restock_forecast(20) msg = fmt_restock_forecast(items) kb = [ _row(('🏆 熱銷商品', f'cmd:top:{target}'), ('🧬 商品健康', f'cmd:health:{target}')), _row(('🔄 重新計算', 'cmd:restock')), ] send_message(chat_id, msg, reply_to, kb) elif cmd in ('promo', '促銷'): # arg = "2026/04/01-2026/04/07" if arg and '-' in arg and re.search(r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', arg): parts_p = arg.split('-') # 可能是 "2026/04/01-2026/04/07" → split on '-' 中間需要處理日期格式 # 先嘗試用空格分割,再用 '-' 分割最後部分 m = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', arg) if len(m) >= 2: start_s = normalize_date(m[0]) end_s = normalize_date(m[1]) send_message(chat_id, f"⏳ 正在比較 {start_s} ~ {end_s} 促銷效益...", reply_to, parse_mode=None) data = query_promo_comparison(start_s, end_s) msg = fmt_promo_comparison(data) promo_range_arg = f'{start_s}-{end_s}' kb = [ _row(('🎉 再查一個促銷', 'await:promo_range'), ('📊 業績查詢', f'cmd:sales:{start_s}')), _row(('📊 產出促銷簡報', f'cmd:ppt:promo {promo_range_arg}')), ] send_message(chat_id, msg, reply_to, kb) else: send_message(chat_id, "⚠️ 格式錯誤\n請輸入:`2026/04/01-2026/04/07`", reply_to) else: send_message(chat_id, "🎉 *促銷效益追蹤*\n\n請輸入活動日期範圍:\n`/promo 2026/04/01-2026/04/07`\n或點選 🎉 促銷追蹤 按鈕", reply_to, [_row(('🎉 設定促銷範圍', 'await:promo_range'))]) elif cmd in ('goal', '目標'): # /goal 200000 設定日目標 # /goal monthly 5000000 設定月目標 if arg: parts2 = arg.split() if parts2[0].lower() == 'monthly' and len(parts2) > 1: try: _GOALS['monthly'] = float(parts2[1].replace(',', '')) send_message(chat_id, f"✅ 月目標設定為 `NT$ {_GOALS['monthly']:,.0f}`", reply_to, parse_mode='Markdown') except ValueError: send_message(chat_id, "⚠️ 格式:`/goal monthly 5000000`", reply_to, parse_mode='Markdown') else: try: _GOALS['daily'] = float(parts2[0].replace(',', '')) send_message(chat_id, f"✅ 日目標設定為 `NT$ {_GOALS['daily']:,.0f}`", reply_to, parse_mode='Markdown') except ValueError: send_message(chat_id, "⚠️ 格式:`/goal 200000`", reply_to, parse_mode='Markdown') else: status = get_goal_status(target) kb = [_row(('📊 今日業績', f'cmd:sales:{target}'), ('🔄 同期比較', f'cmd:compare:{target}'))] send_message(chat_id, fmt_goal_status(status), reply_to, kb) elif cmd in ('chart', '圖表'): send_message(chat_id, "⏳ 正在產生趨勢圖...", reply_to, parse_mode=None) try: png = gen_trend_chart(14) if png: send_photo(chat_id, png, caption=f"📉 近14日業績趨勢", reply_to=reply_to) os.unlink(png) # 再發熱銷商品圖 png2 = gen_products_chart(target, 10) if png2: send_photo(chat_id, png2, caption=f"🏆 {target} 熱銷商品 TOP10", reply_to=None) os.unlink(png2) else: send_message(chat_id, "⚠️ 圖表功能需安裝 matplotlib(pip install matplotlib)", reply_to, parse_mode=None) except Exception as e: sys_log.error(f"[OpenClawBot] chart cmd error: {e}") send_message(chat_id, "⚠️ 圖表產生失敗", reply_to, parse_mode=None) elif cmd in ('health', '健康'): anomalies = query_anomalies(target) strat = analyze_product_strategy(target, 10) lines = [f"🏥 *商品健康報告* _({target})_", ""] # ── 異常偵測 ── if anomalies: lines.append(f"⚠️ *業績異常商品* _(偏差 > 30%)_") for a in anomalies[:6]: pct = a.get('pct') or 0 icon = '📈' if pct > 0 else '📉' sid = _short_id(a['id']) name = _esc(a['name'][:18]) today_r = a.get('today', 0) avg_r = a.get('avg7', 0) lines.append( f"{icon} *{abs(pct):.0f}%* {'急升' if pct > 0 else '急降'} · {name}" ) lines.append( f" 今 `${today_r:,.0f}` / 7日均 `${avg_r:,.0f}`" f" `{sid}`" ) lines.append("") else: lines.append("✅ *無業績異常商品* _(7日均值偏差均在 30% 以內)_") lines.append("") # ── 策略分佈 ── if strat: from collections import Counter cnt = Counter(s['strategy'] for s in strat) total = len(strat) tag_icon = {'加碼': '🔥', '機會': '💡', '收割': '⚡', '觀察': '⚠️', '持穩': '✅'} lines.append(f"📊 *策略分佈* _(共 {total} 件)_") for k, v in cnt.most_common(): bar = '▓' * v + '░' * (total - v) icon = tag_icon.get(k, '•') lines.append(f" {icon} {k} {v} 件 `{bar}`") kb = [ _row(('🎲 策略矩陣', f'cmd:strategy:{target}'), ('🏆 熱銷商品', f'cmd:top:{target}')), _row(('📊 今日業績', f'cmd:sales:{target}'), ('🔄 同期比較', f'cmd:compare:{target}')), ] send_message(chat_id, "\n".join(lines), reply_to, kb) elif cmd in ('strategy', '策略'): strat = analyze_product_strategy(target, 10) kb = [ _row(('🏥 商品健康', f'cmd:health:{target}'), ('🔄 同期比較', f'cmd:compare:{target}')), _row(('🏆 熱銷商品', f'cmd:top:{target}'), ('📊 今日業績', f'cmd:sales:{target}')), ] send_message(chat_id, fmt_strategy(strat, target), reply_to, kb) elif cmd in ('cache', '快取'): # /cache status 任何已授權用戶可看 # /cache flush admin only — 清除指定/全部 PPT 快取 # /cache cleanup [days] [confirm] admin only(confirm 才實刪)— 清磁碟 sub = (arg or '').strip().lower() # critic Medium-3:破壞性操作前先取當前 user_id 並驗 admin _curr_uid = _CURRENT_USER_ID_CTX.get() if sub.startswith('flush'): # admin guard if not _is_admin(_curr_uid): send_message(chat_id, "⛔ `/cache flush` 限管理員執行。\n" "請聯繫系統管理員,或設定 `OPENCLAW_ADMIN_USER_IDS` 環境變數。", reply_to, parse_mode='Markdown') return 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"下次請求將以新模板重新生成。\n" f"執行者:user_id={_curr_uid}") sys_log.warning(f"[PPT cache flush] admin={_curr_uid} type={target_type or 'ALL'} affected={affected}") 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] 乾跑(任何已授權用戶可預覽) # /cache cleanup [days] confirm admin only — 真正執行刪除 # 注意:days < 1 強制乾跑(critic Medium-3 防呆) parts = sub.split() confirm = 'confirm' in parts or 'real' in parts days = 7 for p in parts[1:]: if p.isdigit(): days = int(p) # 防呆:days < 1 強制乾跑 forced_dry = days < 1 # admin guard:只有實刪需要 admin(乾跑任何人可看) if confirm and not forced_dry and not _is_admin(_curr_uid): send_message(chat_id, "⛔ `/cache cleanup ... confirm` 限管理員執行。\n" "可改用乾跑模式(不加 `confirm`)預覽影響範圍。", reply_to, parse_mode='Markdown') return dry = forced_dry or (not confirm) try: stat = cleanup_expired_ppt_cache(days_old=days, dry_run=dry) if not dry: sys_log.warning(f"[PPT cleanup] admin={_curr_uid} days={days} stat={stat}") tag = "(乾跑—未實刪)" if dry else "(已實刪)" if forced_dry: tag += " [days<1 強制乾跑]" msg = (f"🧹 PPT 磁碟清理 {tag}\n" f"門檻:過期超過 {days} 天\n" f"{'將刪檔' if dry else '刪檔'}:{stat['deleted_files']} 個\n" f"{'將刪 row' if dry else '刪 row'}:{stat['deleted_rows']} 筆\n" f"{'將釋放' if dry else '釋放'}空間:{stat['freed_bytes']/1024:,.0f} KB" + (f"\n錯誤:{len(stat['errors'])} 筆" if stat['errors'] else "") + ("\n\n如要真正刪除,加上 `confirm`:\n" f"`/cache cleanup {days} confirm`" if dry and not forced_dry else "")) except Exception as e: msg = f"❌ 清理失敗:{e}" send_message(chat_id, msg, reply_to, parse_mode='Markdown' if dry and not forced_dry else 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 # /ppt monthly [YYYY/MM] # /ppt strategy [日期] sub_type = arg.lower().strip() if arg else 'daily' sub_arg = '' if ' ' in sub_type: parts = sub_type.split(' ', 1) sub_type = parts[0] sub_arg = parts[1].strip() send_message(chat_id, f"⏳ 正在生成 *{sub_type}* 簡報,請稍候(30~60秒)...", reply_to, parse_mode='Markdown') def _ppt_background(_sub_type, _sub_arg, _chat_id, _target, _reply_to): try: ppt_path = _generate_ppt_cmd(_sub_type, _sub_arg, _chat_id, _target, _reply_to=_reply_to) if ppt_path and os.path.exists(ppt_path): type_labels = { 'daily': '📊 日報', 'weekly': '📈 週報', 'monthly': '📅 月報', 'strategy': '🧬 策略簡報', 'competitor': '🔍 競品比較', 'compare': '🔍 競品比較', '競品': '🔍 競品比較', 'promo': '🎉 促銷效益報告', } label = type_labels.get(_sub_type, '簡報') caption = f"{label} — 由 OpenClaw AI 自動生成\n💡 可用 PowerPoint / Keynote / Google Slides 開啟" send_document(_chat_id, ppt_path, caption=caption, reply_to=_reply_to) if not _is_cached_ppt_file(ppt_path): try: os.unlink(ppt_path) except Exception: pass else: send_message(_chat_id, "⚠️ 簡報生成失敗,請稍後再試", _reply_to) except PPTDataInsufficientError as e: # ADR-019 Phase 1:用戶已收到 inline keyboard 詢問,靜默結束 sys_log.info(f"[OpenClawBot] /ppt skipped due to data gap: {e}") except Exception as e: sys_log.error(f"[OpenClawBot] /ppt bg error: {e}", exc_info=True) send_message(_chat_id, f"⚠️ 簡報生成失敗:{str(e)[:100]}", _reply_to) threading.Thread( target=_ppt_background, args=(sub_type, sub_arg, chat_id, target, reply_to), daemon=True ).start() elif cmd in ('history', 'monthly', '月報', '月份'): # /history [YYYY/MM] — 顯示月份業績,不帶參數時列出所有可用月份 months = query_available_months() if arg and re.match(r'\d{4}/\d{2}', arg): yr, mo_s = arg.split('/') ms = query_monthly_summary(int(yr), int(mo_s)) msg = fmt_monthly(ms) else: # 列出可用月份清單 if months: lines = ["📅 *業績月份索引*", f"共 {len(months)} 個月份有資料", ""] for am in months: lines.append(f" 📊 `{am['month']}` _{am['days']} 天_") lines.append("\n_用法:`/history 2026/03` 查看指定月份業績_") msg = "\n".join(lines) else: msg = "⚠️ 暫無月份資料" # 快捷按鈕:最近3個月 + 月報PPT(僅在查看特定月份時顯示) kb_months = _chunk_rows( [(f"📊 {am['month']}", f"cmd:history:{am['month']}") for am in months[:3]], row_size=2, ) if arg and re.match(r'\d{4}/\d{2}', arg): # 查看特定月份時,額外顯示「產出月報PPT」按鈕 kb = kb_months if kb_months else [] kb.append(_row((f'📊 產出 {arg} 月報', f'cmd:ppt:monthly {arg}'))) else: kb = kb_months if kb_months else None send_message(chat_id, msg, reply_to, kb) # ── 原有指令 ───────────────────────────────────────────────── elif cmd in ('menu', 'start', '選單'): send_message(chat_id, "👋 *OpenClaw(小O)* — 電商智能助理\n\n" "點下方按鈕,或直接用中文跟我說話 👇", reply_to, main_menu_keyboard()) elif cmd in ('help', '幫助', '說明'): reply = ( "📖 *OpenClaw 完整功能指南*\n" "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" "💬 *自然語言對話(直接發訊息)*\n" " 「今天業績多少?」\n" " 「哪個商品最需要加碼?」\n" " 「上個月整體表現如何?」\n" " 「幫我看外部熱搜跟銷售的落差」\n" " 「怎麼用圖片比價?」 ← 也可問功能說明\n\n" "📊 *業績查詢 ▸ 點「業績查詢」按鈕*\n" " 今日/昨日業績 → 業績摘要 + TOP3 商品(可點連結)\n" " 每週/每月/每季/半年 → 趨勢走勢\n" " 同期比較 → vs 上週同日\n" " 分類業績 → 各品類佔比\n" " 日期/區間 → 單日或起迄區間 格式: `2026/04/01-2026/04/15`\n" " 月份總覽 → 列出所有可查月份,點月份查詳情\n\n" "🏆 *商品廠商 ▸ 點「商品廠商」按鈕*\n" " 熱銷商品 TOP10 → 含商品ID + 可點 PChome 連結\n" " 熱銷廠商 TOP10\n" " 商品健康分析 → 偏低/異常/策略分佈\n" " 補貨預測 → 動銷率 × 庫存天數估算\n" " 分類鑽取 → 進分類後再看商品排行\n\n" "🎯 *目標管理 ▸ 點「目標管理」按鈕*\n" " 查看達成率 → 日/週(自動推算)/月/季/半年/年\n" " 設定目標 → 點按鈕輸入金額\n" " 月倒計時 → 顯示每日需達金額\n\n" "📈 *智能分析 ▸ 點「智能分析」按鈕*\n" " 策略矩陣 → 加碼/機會/收割/觀察/持穩\n" " 業績趨勢 → 7日/30日/季/半年/年,含趨勢圖\n" " 商品健康 → 異常商品 + 策略建議\n" " 促銷追蹤 → 輸入活動區間,計算業績增幅\n" " 📊 趨勢圖表 → 直接生成折線圖 + 熱銷橫條圖\n" " 同期比較 → vs 上週/上月同日\n\n" "📄 *簡報報表 ▸ 點「簡報報表」按鈕*\n" " 日報/週報/月報 → 自動生成 .pptx\n" " 策略簡報(日/週/月/季/半年/年)\n" " 促銷效益簡報 → 輸入活動區間\n" " 下載 Excel 報表\n" " 指定日期日報 / 指定月份月報\n\n" "🌐 *市場情報 ▸ 點「市場情報」按鈕*\n" " 電商新聞 → 即時台灣電商資訊\n" " 台北天氣 → 含行銷建議\n" " 關鍵字比價 → 輸入商品名,搜尋 PChome 比價\n" " 📷 圖片比價 → 直接傳商品照片,自動辨識比價\n\n" "🔍 *競品日報 ▸ 點「競品日報」按鈕*\n" " 今日/昨日/本週/本月競品比價簡報\n\n" "⏰ *自動推播(每日無需操作)*\n" " 08:00 競品比價日報\n" " 08:30 每日早報(昨日業績 + TOP15 熱銷)\n" " 21:00 每日晚報(今日業績 + TOP15 + 明日建議)\n" " 週一 09:00 週報\n" " 09/12/15/18 時 異常偵測(偏差>30% 即告警)\n\n" "💡 *日期格式*:`2026/04/10` 或 `2026-04-10` 皆可\n" "💡 *有疑問*:直接用中文問我,我會回答!" ) help_kb = quick_menu_keyboard() send_message(chat_id, reply, reply_to, help_kb) elif cmd == 'ack': # 告警確認(來自 anomaly alert 的 inline button) action_map = {'anomaly': '已知悉', 'tracking': '追蹤中'} label = action_map.get(arg or '', '確認') send_message(chat_id, f"✅ *{label}* — 感謝回覆,繼續監控中。", reply_to) # ── 回饋學習:告警 ack → quality_score +0.1 ──────── if _LEARNING_ENABLED: import threading as _thr feedback_type = 'acknowledged' if arg == 'anomaly' else 'tracking' _thr.Thread( target=update_feedback, args=(feedback_type, msg.get('from', {}).get('id', 0)), daemon=True ).start() elif cmd == 'learn': # ── 學習系統狀態查詢 ────────────────────────────────── if _LEARNING_ENABLED: st = get_learning_stats() msg_text = ( "🧠 *OpenClaw 自主學習狀態*\n\n" f"📚 知識庫:`{st.get('total_knowledge',0):,}` 條\n" f"💬 對話記憶:`{st.get('total_conversations',0):,}` 筆\n" f"📊 市場洞察:`{st.get('total_insights',0):,}` 份\n" f"🛒 商品知識:`{st.get('total_products',0):,}` 個\n" f"👍 用戶回饋:`{st.get('total_feedback',0):,}` 次\n" f"⭐ 平均品質分:`{st.get('avg_quality',0):.2f}`\n" f"📈 本週新增:`{st.get('knowledge_last7d',0)}` 條\n\n" "_每次 AI 回答、PPT 分析、告警確認都會自動加入學習庫_" ) else: msg_text = "⚠️ 學習系統暫未啟用" send_message(chat_id, msg_text, reply_to, parse_mode='Markdown') elif cmd == 'import_confirm': # ── Excel 匯入確認 ──────────────────────────────────── pending = _excel_pending.pop(chat_id, None) if not pending: send_message(chat_id, "⚠️ 匯入已逾期或不存在,請重新上傳 Excel 檔案。", reply_to) return file_path = pending['file_path'] filename = pending['filename'] def _do_import(): try: send_message(chat_id, f"⏳ *正在匯入 Excel 資料...*\n`{filename}`\n\n請稍候,資料量大時需要一點時間。", None, parse_mode='Markdown') from services.import_service import ImportService, Session from database.import_models import ImportJob import pytz as _pytz _TAIPEI = _pytz.timezone('Asia/Taipei') from datetime import datetime as _dt svc = ImportService() # 建立匯入任務 session = Session() job = ImportJob( job_type='telegram_upload', status='pending', drive_file_name=filename, local_file_path=file_path, created_at=_dt.now(_TAIPEI).replace(tzinfo=None) ) session.add(job) session.commit() job_id = job.id session.close() # 執行匯入 success = svc.process_daily_sales_import(job_id, file_path) status = svc.get_job_status(job_id) # 清理暫存檔 try: import os as _os _os.unlink(file_path) except Exception: pass if success and status.get('status') == 'completed': sr = status.get('success_rows', 0) or 0 tr = status.get('total_rows', 0) or 0 summ = status.get('import_summary') or {} if isinstance(summ, str): import json as _json try: summ = _json.loads(summ) except Exception: summ = {} date_min = summ.get('date_min', '') date_max = summ.get('date_max', '') synced = summ.get('synced_to', '') date_range_str = ( f"`{date_min}` ~ `{date_max}`" if date_min and date_max and date_min != date_max else f"`{date_min}`" if date_min else '—' ) result_msg = ( f"✅ *Excel 匯入成功!*\n" f"{'─' * 26}\n\n" f"📄 *檔案*:`{filename}`\n" f"📦 *匯入筆數*:`{sr:,}` / `{tr:,}` 筆\n" f"📅 *涵蓋日期*:{date_range_str}\n" ) if synced: result_msg += f"🔄 *同步至*:`{synced}`\n" result_msg += f"\n_✨ 業績資料已更新,可立即查詢!_" # 取涵蓋日期的 latest date 顯示快速按鈕 quick_date = date_max.replace('-', '/') if date_max else (latest_date() or '') import_kb = [ _row((f'📊 查看 {quick_date} 業績', f'cmd:sales:{quick_date}'), ('🏆 熱銷商品排行', f'cmd:top:{quick_date}')), _row(('📄 產出日報 PPT', f'cmd:ppt:daily {quick_date}'), ('📅 月份總覽', 'cmd:history')), ] if quick_date else None send_message(chat_id, result_msg, None, import_kb) else: err = status.get('error_message', '') or '未知錯誤' send_message(chat_id, f"❌ *匯入失敗*\n\n`{filename}`\n\n錯誤訊息:\n`{err[:200]}`", None, parse_mode='Markdown') except Exception as _ie: sys_log.error(f"[ExcelImport] import_confirm error: {_ie}", exc_info=True) send_message(chat_id, f"❌ 匯入過程發生例外錯誤:`{str(_ie)[:150]}`", None) threading.Thread(target=_do_import, daemon=True).start() elif cmd == 'import_cancel': # ── Excel 匯入取消 ──────────────────────────────────── pending = _excel_pending.pop(chat_id, None) if pending: try: import os as _os _os.unlink(pending['file_path']) except Exception: pass send_message(chat_id, "✖ 已取消匯入,暫存檔案已刪除。", reply_to) elif cmd == 'photo_search_help': photo_help = ( "📷 *圖片比價功能說明*\n\n" "直接在此群組傳送商品圖片,我會自動:\n" " 1️⃣ 辨識圖片中的商品名稱\n" " 2️⃣ 在 PChome 搜尋同款商品\n" " 3️⃣ 回傳比價結果與競品連結\n\n" "*使用方式*\n" " 📤 直接拍照或截圖傳送\n" " 🏷 或輸入「搜尋 商品名稱」(文字比價)\n\n" "*支援商品類型*\n" " ✅ 美妝保養品 ✅ 保健食品\n" " ✅ 母嬰用品 ✅ 個人清潔\n" " ✅ 食品飲料 ✅ 家電用品\n\n" "_💡 圖片越清晰、商品標籤越完整,辨識準確度越高_" ) send_message(chat_id, photo_help, reply_to, _submenu_market()) # ── Phase 38: AI 觀測台 4 個指令(對應 /observability/* 6 頁的 4 個關鍵指標)─── elif cmd == 'obs_ai_calls': # 24h AI 呼叫統計:總次數 / token / cost / RAG 命中 / 錯誤 try: from database.manager import DatabaseManager from sqlalchemy import text as _sa session = DatabaseManager().get_session() row = session.execute( _sa(""" SELECT COUNT(*), COALESCE(SUM(input_tokens + output_tokens), 0), COALESCE(SUM(cost_usd), 0), COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')), COUNT(*) FILTER (WHERE rag_hit), COUNT(*) FILTER (WHERE cache_hit) FROM ai_calls WHERE called_at >= NOW() - INTERVAL '24 hours' """), ).fetchone() top_provider = session.execute( _sa(""" SELECT provider, COUNT(*) AS calls, COALESCE(SUM(cost_usd), 0) AS cost FROM ai_calls WHERE called_at >= NOW() - INTERVAL '24 hours' GROUP BY provider ORDER BY calls DESC LIMIT 5 """), ).fetchall() session.close() calls, tokens, cost, errors, rag, cache = row or (0, 0, 0, 0, 0, 0) err_rate = (errors / calls * 100) if calls else 0 cache_rate = (cache / calls * 100) if calls else 0 rag_rate = (rag / calls * 100) if calls else 0 lines = [ "📊 *AI 呼叫總覽(過去 24 小時)*", "", f"• 總呼叫:*{calls:,}* 次", f"• Token 用量:*{tokens:,}*", f"• 成本:*${cost:.2f} USD*", f"• 錯誤:*{errors}* 次({err_rate:.1f}%)", f"• RAG 命中:*{rag}* 次({rag_rate:.1f}%)", f"• 快取命中:*{cache}* 次({cache_rate:.1f}%)", "", "*依供應商分組(Top 5):*", ] for p, c, ct in top_provider: lines.append(f"• `{p}`:{c} 次 · ${ct:.2f}") lines.append("") lines.append("詳細查詢:mo.wooo.work/observability/ai\\_calls") kb = [_row(('🏥 主機健康', 'cmd:obs_health'), ('💰 預算控管', 'cmd:obs_budget')), _row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main'))] send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown') except Exception as e: send_message(chat_id, f"❌ 查詢 AI 呼叫統計失敗:{e}", reply_to, parse_mode=None) elif cmd == 'obs_health': # 三主機 Ollama 即時 + 24h uptime try: from services.ollama_service import ( OLLAMA_HOST_PRIMARY, OLLAMA_HOST_SECONDARY, OLLAMA_HOST_FALLBACK, _is_unhealthy, ) import requests as _req from database.manager import DatabaseManager from sqlalchemy import text as _sa lines = ["🏥 *主機健康監控*", ""] lines.append("*三主機即時狀態:*") host_states = {} for label, host in [ ('GCP-A (Primary)', OLLAMA_HOST_PRIMARY), ('GCP-B (Secondary)', OLLAMA_HOST_SECONDARY), ('111 (Fallback)', OLLAMA_HOST_FALLBACK), ]: healthy = False try: r = _req.get(f"{host.rstrip('/')}/api/tags", timeout=3) healthy = (r.status_code == 200) except Exception: pass short_label = 'GCP-A' if label.startswith('GCP-A') else 'GCP-B' if label.startswith('GCP-B') else '111' host_states[short_label] = healthy emoji = "✅" if healthy else "❌" mark = " ⚠️標記異常" if _is_unhealthy(host) else "" lines.append(f"{emoji} *{label}*:`{host}`{mark}") # 24h uptime try: session = DatabaseManager().get_session() rows = session.execute( _sa(""" SELECT host_label, COUNT(*) AS total, COUNT(*) FILTER (WHERE healthy) AS up, COALESCE(AVG(response_ms) FILTER (WHERE healthy), 0) AS avg_ms FROM host_health_probes WHERE probed_at >= NOW() - INTERVAL '24 hours' GROUP BY host_label ORDER BY host_label """), ).fetchall() session.close() if rows: lines.append("") lines.append("*過去 24h 在線率:*") for label, total, up, avg_ms in rows: pct = (up / total * 100) if total else 0 lines.append(f"• {label}:*{pct:.1f}%* · {int(avg_ms)}ms 平均") except Exception: pass lines.append("") lines.append("詳細查詢:mo.wooo.work/observability/host\\_health") # Phase 41 E-3: 任一主機標記異常 → 顯示 AutoHeal inline 按鈕 kb_rows = [] try: from services.ollama_service import ( OLLAMA_HOST_PRIMARY as _P, OLLAMA_HOST_SECONDARY as _S, OLLAMA_HOST_FALLBACK as _F, ) heal_buttons = [] for label, host in [('GCP-A', _P), ('GCP-B', _S), ('111', _F)]: if _is_unhealthy(host) or host_states.get(label) is False: heal_buttons.append((f'🩹 修 {label}', f'cmd:obs_heal:{label}')) if heal_buttons: # 兩兩成行 for i in range(0, len(heal_buttons), 2): kb_rows.append(_row(*heal_buttons[i:i+2])) except Exception: pass kb_rows.append(_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('💰 預算控管', 'cmd:obs_budget'))) kb_rows.append(_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main'))) send_message(chat_id, '\n'.join(lines), reply_to, kb_rows, parse_mode='Markdown') except Exception as e: send_message(chat_id, f"❌ 查詢主機健康失敗:{e}", reply_to, parse_mode=None) elif cmd == 'obs_heal': # Phase 41 E-3 (L2):Telegram inline 觸發 AutoHeal # arg 為 'GCP-A' / 'GCP-B' / '111' try: label_map = { 'GCP-A': 'Primary (GCP)', 'GCP-B': 'Secondary (GCP)', '111': 'Fallback (111)', } host_label = label_map.get(arg.strip()) if not host_label: send_message(chat_id, f"❌ 未知主機標籤:{arg}", reply_to, parse_mode=None) return from services.auto_heal_service import auto_heal_service from services.ollama_service import ( _is_unhealthy as _iu, OLLAMA_HOST_PRIMARY as _P2, OLLAMA_HOST_SECONDARY as _S2, OLLAMA_HOST_FALLBACK as _F2, ) host_url = {'Primary (GCP)': _P2, 'Secondary (GCP)': _S2, 'Fallback (111)': _F2}.get(host_label) def _latest_probe_unhealthy(label: str) -> bool: """用 DB 最新探針補足 `_is_unhealthy()` 30 秒記憶體 TTL 的盲點。""" try: from database.manager import DatabaseManager from sqlalchemy import text as _sa _s = DatabaseManager().get_session() try: row = _s.execute(_sa(""" SELECT healthy FROM host_health_probes WHERE host_label = :label AND probed_at >= NOW() - INTERVAL '30 minutes' ORDER BY probed_at DESC LIMIT 1 """), {'label': label}).fetchone() return bool(row is not None and row[0] is False) finally: _s.close() except Exception: return False if not (_iu(host_url) or _latest_probe_unhealthy(host_label)): send_message(chat_id, f"⚠️ {host_label} 目前未標記異常,無需 AutoHeal", reply_to, parse_mode=None) return result = auto_heal_service.handle_exception( error_type='ollama_unhealthy', context={ 'host_label': host_label, 'host_url': host_url, 'error_message': f'Ollama {host_label} marked unhealthy', 'triggered_by': f'telegram_user_{_CURRENT_USER_ID_CTX.get() or "tg_admin"}', }, ) ok = bool(getattr(result, 'success', False)) action = getattr(result, 'action', None) or '—' msg = getattr(result, 'message', '') or '' ack = (f"✅ AutoHeal {host_label}\n動作:{action}\n{msg}" if ok else f"❌ AutoHeal 失敗({action})\n{msg}") send_message(chat_id, ack[:1200], reply_to, parse_mode=None) except Exception as e: send_message(chat_id, f"❌ AutoHeal 觸發異常:{e}", reply_to, parse_mode=None) elif cmd == 'obs_budget': # 當月預算 vs 實際 spent try: from database.manager import DatabaseManager from sqlalchemy import text as _sa from datetime import datetime as _dt today = _dt.now() month_start = _dt(today.year, today.month, 1) session = DatabaseManager().get_session() budgets = session.execute( _sa(""" SELECT period, provider, budget_usd, alert_pct FROM ai_call_budgets ORDER BY period, provider NULLS FIRST """), ).fetchall() spent_rows = session.execute( _sa(""" SELECT provider, COALESCE(SUM(cost_usd), 0) FROM ai_calls WHERE called_at >= :ms GROUP BY provider """), {'ms': month_start}, ).fetchall() session.close() spent_map = {r[0]: float(r[1] or 0) for r in spent_rows} lines = [f"💰 *預算控管({today.year}-{today.month:02d})*", ""] warn = False for period, provider, budget, alert_pct in budgets: spent = spent_map.get(provider, 0.0) if provider else sum(spent_map.values()) ratio = (spent / float(budget)) if float(budget) > 0 else 0 pct = ratio * 100 if ratio >= 1.0: icon = "🚨" warn = True elif ratio >= float(alert_pct or 80) / 100: icon = "⚠️" warn = True else: icon = "✅" lines.append(f"{icon} `{period}` `{provider or '(全部)'}`:${spent:.2f} / ${float(budget):.2f} ({pct:.0f}%)") if not budgets: lines.append("(尚無預算設定,需先跑 migrations/025)") if warn: lines.append("") lines.append("⚠️ *已有供應商超出告警閾值*,請至 Web 介面確認。") lines.append("") lines.append("詳細查詢:mo.wooo.work/observability/budget") # Phase 41 E-3: warn 時顯示 force-throttle inline button (L2) kb_rows = [] if warn: kb_rows.append(_row(('⚡ 立即重算節流狀態', 'cmd:obs_force_throttle'))) kb_rows.append(_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('🏥 主機健康', 'cmd:obs_health'))) kb_rows.append(_row(('💬 反饋趨勢', 'cmd:obs_quality'), ('← 返回主選單', 'menu:main'))) send_message(chat_id, '\n'.join(lines), reply_to, kb_rows, parse_mode='Markdown') except Exception as e: send_message(chat_id, f"❌ 查詢預算失敗:{e}", reply_to, parse_mode=None) elif cmd == 'obs_overview': # Phase 49: 觀測台總覽(一頁式 KPI) try: from database.manager import DatabaseManager from sqlalchemy import text as _sa from datetime import datetime as _dt today = _dt.now() month_start = _dt(today.year, today.month, 1) session = DatabaseManager().get_session() host_rows = session.execute(_sa(""" SELECT host_label, COUNT(*), COUNT(*) FILTER (WHERE healthy) FROM host_health_probes WHERE probed_at >= NOW() - INTERVAL '24 hours' GROUP BY host_label ORDER BY host_label """)).fetchall() ai = session.execute(_sa(""" SELECT COUNT(*), COALESCE(SUM(cost_usd), 0), COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')), COUNT(*) FILTER (WHERE rag_hit) FROM ai_calls WHERE called_at >= NOW() - INTERVAL '24 hours' """)).fetchone() month_cost = session.execute( _sa("SELECT COALESCE(SUM(cost_usd), 0) FROM ai_calls WHERE called_at >= :ms"), {'ms': month_start}, ).fetchone()[0] or 0 ep_pending = session.execute( _sa("SELECT COUNT(*) FROM learning_episodes WHERE promotion_status = 'awaiting_review' AND reviewed_at IS NULL"), ).fetchone()[0] or 0 session.close() ai_total = int(ai[0] or 0) err_rate = (int(ai[2] or 0) / ai_total * 100) if ai_total else 0 rag_rate = (int(ai[3] or 0) / ai_total * 100) if ai_total else 0 lines = ["🛰 *觀測台總覽(24h)*", ""] lines.append("*三主機在線率:*") for label, total, up in host_rows: pct = (float(up) / float(total) * 100) if total else 0 emoji = "✅" if pct >= 99 else "⚠️" if pct >= 90 else "🚨" lines.append(f"{emoji} {label}:*{pct:.1f}%*") lines.append("") lines.append(f"📊 AI 呼叫:*{ai_total:,}* 次(錯誤 {err_rate:.1f}%)") lines.append(f"💰 24h 成本:*${float(ai[1] or 0):.2f}* · 當月 *${float(month_cost):.2f}*") lines.append(f"💡 RAG 命中率:*{rag_rate:.1f}%*") if ep_pending: lines.append(f"📋 待審 episodes:*{ep_pending}* 筆") lines.append("") lines.append("詳細:mo.wooo.work/observability/overview") kb = [_row(('🤖 Agent 編排', 'cmd:obs_orchestration'), ('💼 商業面 AI', 'cmd:obs_business')), _row(('🏥 主機健康', 'cmd:obs_health'), ('📊 AI 呼叫', 'cmd:obs_ai_calls')), _row(('← 返回主選單', 'menu:main'))] send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown') except Exception as e: send_message(chat_id, f"❌ 查詢觀測台總覽失敗:{e}", reply_to, parse_mode=None) elif cmd == 'obs_orchestration': # Phase 49: Agent 編排矩陣(4 Agent × Models) try: from database.manager import DatabaseManager from sqlalchemy import text as _sa agent_groups = [ ('🤖 OpenClaw', ['openclaw_qa', 'openclaw_qa_gemini_fallback', 'openclaw_qa_nim', 'openclaw_daily', 'openclaw_daily_gemini_fallback', 'openclaw_daily_nim', 'openclaw_meta', 'openclaw_meta_gemini_fallback', 'openclaw_meta_nim', 'openclaw_monthly', 'openclaw_monthly_gemini_fallback', 'openclaw_monthly_nim', 'openclaw_weekly', 'openclaw_weekly_gemini_fallback', 'openclaw_weekly_nim', 'openclaw_bot_main', 'openclaw_bot_gemini', 'openclaw_bot_nim', 'sales_copy', 'code_review_openclaw', 'code_review_openclaw_gemini', 'openclaw_daily_insight', 'openclaw_daily_insight_gemini_fallback', 'openclaw_daily_insight_nim']), ('🔍 Hermes', ['hermes_analyst', 'hermes_intent', 'code_review_hermes']), ('🧬 NemoTron', ['nemotron_dispatch']), ('🐘 ElephantAlpha', ['ea_engine', 'code_review_elephant']), ] session = DatabaseManager().get_session() lines = ["🌐 *Agent 編排矩陣(24h)*", ""] for label, callers in agent_groups: row = session.execute(_sa(""" SELECT COUNT(*), COALESCE(SUM(cost_usd), 0), COUNT(*) FILTER (WHERE provider IN ('gcp_ollama','ollama_secondary','ollama_111','ollama_other')), COUNT(*) FILTER (WHERE rag_hit), COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')) FROM ai_calls WHERE called_at >= NOW() - INTERVAL '24 hours' AND caller = ANY(:c) """), {'c': callers}).fetchone() calls = int(row[0] or 0) if calls == 0: lines.append(f"{label}:(無呼叫)") continue cost = float(row[1] or 0) ollama_pct = float(row[2] or 0) / calls * 100 rag_pct = float(row[3] or 0) / calls * 100 err_pct = float(row[4] or 0) / calls * 100 lines.append(f"{label}:*{calls:,}* 次 · ${cost:.2f}") lines.append(f" 本地 Ollama {ollama_pct:.0f}% · RAG {rag_pct:.0f}% · 錯誤 {err_pct:.1f}%") session.close() lines.append("") lines.append("詳細:mo.wooo.work/observability/agent\\_orchestration") kb = [_row(('🛰 觀測台總覽', 'cmd:obs_overview'), ('💼 商業面 AI', 'cmd:obs_business')), _row(('← 返回主選單', 'menu:main'))] send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown') except Exception as e: send_message(chat_id, f"❌ 查詢 Agent 編排失敗:{e}", reply_to, parse_mode=None) elif cmd == 'obs_business': # Phase 49: 商業面 × AI 編排(AI 在做什麼生意) try: from database.manager import DatabaseManager from sqlalchemy import text as _sa session = DatabaseManager().get_session() rec_rows = session.execute(_sa(""" SELECT strategy, COUNT(*), COALESCE(AVG(confidence), 0) FROM ai_price_recommendations WHERE created_at >= NOW() - INTERVAL '7 days' GROUP BY strategy ORDER BY 2 DESC """)).fetchall() verdict_rows = session.execute(_sa(""" SELECT verdict, COUNT(*) FROM action_outcomes WHERE created_at >= NOW() - INTERVAL '30 days' GROUP BY verdict """)).fetchall() unfollowed = session.execute(_sa(""" SELECT COUNT(*) FROM ai_price_recommendations r WHERE r.created_at >= NOW() - INTERVAL '7 days' AND r.confidence >= 0.7 AND NOT EXISTS ( SELECT 1 FROM action_plans p WHERE p.sku = r.sku AND p.created_at >= r.created_at AND p.created_at < r.created_at + INTERVAL '7 days' ) """)).fetchone()[0] or 0 session.close() lines = ["💼 *商業面 × AI 編排*", ""] if rec_rows: lines.append("*AI 價格決策 7d:*") for strategy, cnt, conf in rec_rows: lines.append(f"• {strategy}:*{int(cnt):,}* 筆(信心 {float(conf):.2f})") else: lines.append("(過去 7 日無 AI 價格決策)") if unfollowed > 0: lines.append("") lines.append(f"⚠️ *未跟進機會:{unfollowed} 筆*(high-confidence 卻無 action_plan)") if verdict_rows: lines.append("") lines.append("*Outcomes Verdict 30d:*") for v, c in verdict_rows: icon = "✅" if v == 'effective' else "❌" if v == 'backfired' else "➖" lines.append(f"{icon} {v}:*{int(c):,}*") lines.append("") lines.append("詳細:mo.wooo.work/observability/business\\_intel") kb = [_row(('🛰 觀測台總覽', 'cmd:obs_overview'), ('🌐 Agent 編排', 'cmd:obs_orchestration')), _row(('← 返回主選單', 'menu:main'))] send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown') except Exception as e: send_message(chat_id, f"❌ 查詢商業面失敗:{e}", reply_to, parse_mode=None) elif cmd == 'obs_trigger_review': # Phase 44 (L2):Telegram inline 觸發 Code Review Pipeline try: import subprocess import threading from services.code_review_pipeline_service import CodeReviewPipeline commit_sha = subprocess.check_output( ['git', 'rev-parse', 'HEAD'], stderr=subprocess.DEVNULL, ).decode().strip() changed = subprocess.check_output( ['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit_sha], stderr=subprocess.DEVNULL, ).decode().strip().split('\n') changed = [f for f in changed if f] if not changed: send_message(chat_id, "⚠️ 最新 commit 無變更檔案,無需 Code Review", reply_to, parse_mode=None) return pipeline = CodeReviewPipeline( commit_sha=commit_sha, changed_files=changed, branch='main', deploy_type='telegram_observability', ) threading.Thread(target=pipeline.run, daemon=True).start() ack = ( f"🔬 Code Review Pipeline 已派出\n\n" f"Pipeline ID: {pipeline.pipeline_id}\n" f"Commit: {commit_sha[:8]}\n" f"變更檔案: {len(changed)} 個\n\n" f"5 step 完成後會推 Telegram 通知。" ) send_message(chat_id, ack, reply_to, parse_mode=None) except Exception as e: send_message(chat_id, f"❌ Code Review 觸發失敗:{e}", reply_to, parse_mode=None) elif cmd == 'obs_force_throttle': # Phase 41 E-3 (L2):Telegram inline 觸發立即重算 cost throttle try: from services.cost_throttle_service import ( evaluate_throttle_status, is_cost_throttle_enabled, ) if not is_cost_throttle_enabled(): send_message(chat_id, "⚠️ COST_THROTTLE_ENABLED=false,先設環境變數", reply_to, parse_mode=None) return new_state = evaluate_throttle_status() throttled = [p for p, s in new_state.items() if s.get('throttled')] if throttled: ack = "⚡ 已立即重算 throttle,被節流的 provider:\n" + '\n'.join(f"• {p}" for p in throttled) else: ack = "⚡ 已立即重算 throttle(目前無 provider 被節流)" send_message(chat_id, ack[:1200], reply_to, parse_mode=None) except Exception as e: send_message(chat_id, f"❌ force-throttle 失敗:{e}", reply_to, parse_mode=None) elif cmd == 'obs_quality': # 30d caller 反饋趨勢 try: from services.feedback_quality_tracker import ( compute_caller_quality_trend, get_caller_recommendations, ) trends = compute_caller_quality_trend(days=30) recs = get_caller_recommendations(days=30) sorted_trends = sorted(trends.items(), key=lambda kv: kv[1].get('avg_score', 5))[:8] lines = ["💬 *Caller 反饋趨勢(過去 30 日)*", ""] if not sorted_trends: lines.append("(過去 30 日無反饋資料)") else: lines.append("*平均分數最低 8 名:*") for caller, info in sorted_trends: avg = info.get('avg_score', 0) up = info.get('thumbs_up', 0) dn = info.get('thumbs_down', 0) n = info.get('total_feedback', 0) trend = info.get('trend', 'unknown') icon = {'positive': '📈', 'negative': '📉', 'neutral': '➖'}.get(trend, '❓') lines.append(f"{icon} `{caller}` *{avg:.2f}*/5 · 👍{up} 👎{dn} · N={n}") if recs: lines.append("") lines.append("*智能建議:*") for r in recs[:5]: icon = "⚠️" if r.get('action') == 'review' else "✅" lines.append(f"{icon} `{r.get('caller')}`:{r.get('reason')}") lines.append("") lines.append("詳細查詢:mo.wooo.work/observability/quality\\_trend") kb = [_row(('📊 AI 呼叫', 'cmd:obs_ai_calls'), ('🏥 主機健康', 'cmd:obs_health')), _row(('💰 預算控管', 'cmd:obs_budget'), ('← 返回主選單', 'menu:main'))] send_message(chat_id, '\n'.join(lines), reply_to, kb, parse_mode='Markdown') except Exception as e: send_message(chat_id, f"❌ 查詢反饋趨勢失敗:{e}", reply_to, parse_mode=None) else: # 不認識的指令 → 自然語言 txt, kb = openclaw_answer(cmd + (' ' + arg if arg else ''), chat_id=chat_id) send_message(chat_id, txt, reply_to, kb) def _write_event_ignore_audit(event_id: str, user_label: str, ts_label: str) -> None: """將 EA HITL 忽略決策寫入 ai_insights,供 webhook / polling 共用語意。""" from database.manager import get_session session = get_session() try: session.execute( text(""" INSERT INTO ai_insights (insight_type, content, confidence, created_by, status, metadata_json) VALUES (:type, :content, :conf, :by, :status, :meta) """), { "type": "human_review", "content": f"[EA HITL] 事件 {event_id} 由 {user_label} 忽略", "conf": 1.0, "by": f"telegram:{user_label}", "status": "ignored", "meta": json.dumps({ "event_id": event_id, "decided_by": user_label, "decided_at": ts_label, "decision": "ignored", }, ensure_ascii=False), }, ) session.commit() finally: session.close() def _handle_event_ignore_callback(data: str, cq: dict, chat_id, message_id) -> None: """處理 `momo:eig:` webhook callback,避免 HITL 按鈕無反應。""" from html import escape as _html_escape parts = data.split(':', 2) event_id = parts[2].strip() if len(parts) >= 3 else '' if not event_id: send_message(chat_id, "⚠️ event_id 缺失,忽略動作未生效", None, None, parse_mode=None) sys_log.warning("[EA HITL] empty event_id callback rejected: %r", data) return user = cq.get('from') or {} user_label_raw = ( user.get('username') or user.get('first_name') or str(user.get('id') or '?') ) ts_label_raw = datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M') try: _write_event_ignore_audit(event_id, user_label_raw, ts_label_raw) except Exception as audit_err: sys_log.warning(f"[EA HITL] ai_insights audit 寫入失敗(不阻斷 UI): {audit_err}") user_label_safe = _html_escape(str(user_label_raw)) ts_label_safe = _html_escape(ts_label_raw) original = (cq.get('message') or {}).get('text') or (cq.get('message') or {}).get('caption') or '事件已忽略' text = ( _html_escape(str(original))[:3400] + f"\n\n🛑 已忽略 by {user_label_safe} @ {ts_label_safe}" ) edited = False if message_id: result = edit_message_text(chat_id, message_id, text, keyboard=None, parse_mode="HTML") edited = bool(isinstance(result, dict) and result.get("ok")) if not edited: send_message( chat_id, f"🛑 已忽略事件 {event_id} by {user_label_raw} @ {ts_label_raw}", None, None, parse_mode=None, ) sys_log.info(f"[EA HITL] event_ignore event_id={event_id} by={user_label_raw}") def _clean_vision_product_name(raw: str) -> str: """把 vision 模型回應收斂成可直接丟給比價查詢的商品名稱。""" text = (raw or '').strip() if not text: return '' text = re.sub(r"^```(?:text)?\s*", "", text, flags=re.IGNORECASE).strip() text = re.sub(r"\s*```$", "", text).strip() first_line = next((line.strip() for line in text.splitlines() if line.strip()), '') first_line = re.sub(r"^(商品名稱|品名|辨識結果|結果)\s*[::]\s*", "", first_line).strip() first_line = first_line.strip("`*_ - ") if not first_line: return '' refusal_patterns = ('無法辨識', '看不清', '無法確認', '不確定', 'unknown', 'not sure') lowered = first_line.lower() if any(pattern in lowered for pattern in refusal_patterns): return '' return first_line[:60] def _identify_product_name_with_ollama_vision(img_b64: str, request_id: str) -> str: """圖片比價的主辨識路徑:Ollama vision 三主機級聯。""" if not _OLLAMA_AVAILABLE: return '' prompt = ( "這是一張商品圖片。請辨識商品名稱,包含品牌、型號、規格。" "只回商品名稱,不要解釋,不要 markdown,不超過 30 字;" "如果是多個商品,只取最顯眼的一個。必須使用繁體中文。" ) timeout = int(os.getenv('OPENCLAW_IMAGE_OLLAMA_TIMEOUT', '45')) with log_ai_call( caller='openclaw_bot_image', provider='gcp_ollama', model=IMAGE_VISION_OLLAMA_MODEL, request_id=request_id, meta={'route': 'ollama_first', 'task': 'image_product_recognition'}, ) as ctx: try: resp = OllamaService(model=IMAGE_VISION_OLLAMA_MODEL).generate( prompt=prompt, model=IMAGE_VISION_OLLAMA_MODEL, temperature=0.1, timeout=timeout, options={'num_predict': 64}, images=[img_b64], ) ctx.set_provider(get_provider_tag(resp.host or '')) ctx.set_tokens(input=resp.input_tokens, output=resp.output_tokens) ctx.add_meta('host', resp.host) ctx.add_meta('host_label', get_host_label(resp.host or '')) if not resp.success: ctx.set_error(resp.error or 'ollama vision failed') ctx.fallback_to_caller('openclaw_bot_image_gemini') return '' product_name = _clean_vision_product_name(resp.content) if not product_name: ctx.set_error('empty_or_unusable_vision_response') ctx.fallback_to_caller('openclaw_bot_image_gemini') return product_name except Exception as exc: ctx.set_error(f"{type(exc).__name__}: {exc}") ctx.fallback_to_caller('openclaw_bot_image_gemini') sys_log.warning(f"[VisionSearch] Ollama vision failed: {exc}") return '' def _identify_product_name_with_gemini_vision(img_b64: str, request_id: str) -> str: """圖片比價的雲端備援:只有 Ollama vision 失敗後才呼叫。""" gemini_api_key = _gemini_fallback_api_key('openclaw_image_vision') if not gemini_api_key: return '' vision_payload = { 'contents': [{ 'parts': [ {'text': ( '這是一張商品圖片。請辨識商品名稱(品牌、型號、規格),' '輸出格式:只回商品名稱,不超過 30 字,繁體中文。' '如果是多個商品,只取最顯眼的一個。' )}, {'inline_data': {'mime_type': 'image/jpeg', 'data': img_b64}}, ], }], } with log_ai_call( caller='openclaw_bot_image_gemini', provider='gemini', model=IMAGE_VISION_GEMINI_MODEL, request_id=request_id, meta={'fallback_from': 'openclaw_bot_image', 'task': 'image_product_recognition'}, ) as ctx: try: vis_r = requests.post( f"{GEMINI_BASE_URL}/{IMAGE_VISION_GEMINI_MODEL}:generateContent?key={gemini_api_key}", json=vision_payload, timeout=20, ) vis_r.raise_for_status() body = vis_r.json() usage = body.get('usageMetadata', {}) or {} ctx.set_tokens( input=usage.get('promptTokenCount', 0), output=usage.get('candidatesTokenCount', 0), ) raw = ( body .get('candidates', [{}])[0] .get('content', {}) .get('parts', [{}])[0] .get('text', '') ) product_name = _clean_vision_product_name(raw) if not product_name: ctx.set_error('empty_or_unusable_vision_response') return product_name except Exception as exc: ctx.set_error(f"{type(exc).__name__}: {exc}") sys_log.warning(f"[VisionSearch] Gemini vision fallback failed: {exc}") return '' # ── Webhook ─────────────────────────────────────────────────── @openclaw_bot_bp.route('/bot/telegram/webhook', methods=['POST']) def telegram_webhook(): try: # 每個 webhook request 先清空 ContextVar,避免同 worker thread 延用上一個 user。 _CURRENT_USER_ID_CTX.set(None) update = request.get_json(silent=True) if not update: return jsonify({'ok': True}) # ── Telegram retry 去重 ─────────────────────────────── uid = update.get('update_id') # ── Callback Query(按鈕)───────────────────────────── if 'callback_query' in update: cq = update['callback_query'] cq_id = cq['id'] data = _normalize_callback_data(cq.get('data', '')) chat_id = cq['message']['chat']['id'] chat_type = cq['message']['chat'].get('type', '') cq_from_id = (cq.get('from') or {}).get('id') cq_message_id = cq.get('message', {}).get('message_id') duplicate_key = _build_callback_dedupe_key( update_id=uid, cq_id=cq_id, message_id=cq_message_id, data=data, chat_id=chat_id, user_id=cq_from_id, ) if _is_duplicate_update(duplicate_key): sys_log.debug( f"[OpenClawBot] duplicate callback uid={uid} cq_id={cq_id}, skip" ) answer_callback(cq_id) return jsonify({'ok': True}) sys_log.info(f'[OpenClawBot] CB: chat={chat_id} type={chat_type} data={data} allowed={ALLOWED_GROUP}') # fail-closed:未授權一律安靜拒絕(關閉 loading,不回任何訊息避免偵察) if not _is_authorized(chat_type, chat_id, cq_from_id): sys_log.warning( f'[OpenClawBot] CB rejected: chat={chat_id} type={chat_type} user={cq_from_id}' ) answer_callback(cq_id) return jsonify({'ok': False, 'error': 'forbidden'}) # critic Medium-3:把 user_id 放進 ContextVar 供 handle_cmd 內部 _is_admin 讀 _user_token = _CURRENT_USER_ID_CTX.set(cq_from_id) answer_callback(cq_id) send_typing(chat_id) if data.startswith('momo:eig:'): _handle_event_ignore_callback(data, cq, chat_id, cq_message_id) return jsonify({'ok': True}) # ── Phase 11 RAG 反饋(v5.0 護欄 #1)───────────────────── # rag_fb:{log_id}:{score} → 寫回 rag_query_log.feedback_score # pg_ok:{episode_id} → PromotionGate 人工通過 # pg_no:{episode_id} → PromotionGate 人工駁回 # 早於 menu: / cmd: / await: 處理;命中即 short-circuit。 if data.startswith('rag_fb:'): try: parts = data.split(':') if len(parts) >= 3: log_id = int(parts[1]) score = int(parts[2]) from services.rag_service import rag_service as _rag ok = _rag.feedback(log_id, score) ack_text = "已記錄 👍" if score >= 4 else "已記錄 👎" if not ok: ack_text = "反饋寫入失敗(已 log)" sys_log.info( f"[OpenClawBot] RAG feedback log_id={log_id} score={score} ok={ok}" ) send_message(chat_id, ack_text, None, None) except Exception as exc: sys_log.warning(f"[OpenClawBot] rag_fb 處理失敗: {exc}") return jsonify({'ok': True}) if data.startswith('pg_ok:') or data.startswith('pg_no:'): try: action, _eid = data.split(':', 1) episode_id = int(_eid) from services.learning_pipeline import promotion_gate, hash_human_approver approver_hash = hash_human_approver(str(cq_from_id or '')) if action == 'pg_ok': # 人工通過 → 直接 promote(不重跑 4 stage) insight_id = promotion_gate.promote( episode_id, human_approver=approver_hash, ) ack = ( f"已晉升至 ai_insights #{insight_id}" if insight_id else "晉升失敗(已 log)" ) else: promotion_gate.reject( episode_id, 'rejected_human', detail='human rejected via Telegram', human_approver=approver_hash, ) ack = "已駁回(rejected_human)" sys_log.info( f"[OpenClawBot] PromotionGate {action} episode_id={episode_id} " f"by={approver_hash}" ) send_message(chat_id, ack, None, None) except Exception as exc: sys_log.warning(f"[OpenClawBot] pg_ok/pg_no 處理失敗: {exc}") return jsonify({'ok': True}) if data.startswith('menu:'): # 顯示子選單或返回主選單 key = data[5:] fn = _SUBMENUS.get(key) if fn: kb = fn() titles = { 'main': '👋 *OpenClaw* — 請選擇功能類別', 'sales': '📊 *業績查詢* — 選擇日期或直接輸入', 'products': '🏆 *商品廠商* — 選擇查詢範圍', 'goals': '🎯 *目標管理* — 查看或設定業績目標', 'analysis': '📈 *智能分析* — 選擇分析類型', 'trend': '📈 *業績趨勢* — 選擇時間範圍', 'reports': '📄 *簡報報表* — 選擇報告類型', 'market': '🌐 *市場情報* — 即時資訊', 'competitor': '📊 *競品比價日報* — 選擇分析日期', 'competitor_ppt': '📄 *競品比價簡報* — 選擇時間範圍', 'category': '🗂 *分類業績鑽取* — 點選分類深入分析', 'observability': '🛰 *AI 觀測台* — 系統指標與成本控管', } if cq_message_id: result = edit_message_text( chat_id, cq_message_id, titles.get(key, '請選擇'), kb, ) if _should_fallback_send_message(result): send_message(chat_id, titles.get(key, '請選擇'), None, kb) else: send_message(chat_id, titles.get(key, '請選擇'), None, kb) elif data.startswith('await:'): # 進入輸入等待狀態 action = data[6:] if action in _AWAIT_PROMPTS: prompt_text, label = _AWAIT_PROMPTS[action] _input_pending[chat_id] = {'action': action, 'label': label} cancel_kb = [_row(('✖ 取消', 'menu:main'))] if cq_message_id: result = edit_message_text( chat_id, cq_message_id, f"{prompt_text}\n\n_輸入 `/取消` 可退出_", cancel_kb, parse_mode='Markdown', ) if _should_fallback_send_message(result): send_message( chat_id, f"{prompt_text}\n\n_輸入 `/取消` 可退出_", None, cancel_kb, parse_mode='Markdown', ) else: send_message( chat_id, f"{prompt_text}\n\n_輸入 `/取消` 可退出_", None, cancel_kb, parse_mode='Markdown', ) elif data.startswith('cmd:'): parts = data[4:].split(':', 1) with _run_with_callback_cmd_context(): if cq_message_id: _orig_send_message = send_message def _callback_send_message( _chat_id, _text, _reply_to=None, _keyboard=None, _parse_mode="Markdown", **_kwargs, ): if _reply_to is None and "reply_to" in _kwargs: _reply_to = _kwargs.pop("reply_to") if "keyboard" in _kwargs: _keyboard = _kwargs.pop("keyboard") if "parse_mode" in _kwargs: _parse_mode = _kwargs.pop("parse_mode") if _reply_to == cq_message_id: result = edit_message_text( _chat_id, cq_message_id, _text, _keyboard, _parse_mode, ) if not _should_fallback_send_message(result): return result return _orig_send_message( _chat_id, _text, _reply_to, _keyboard, _parse_mode, **_kwargs, ) with _CALLBACK_SEND_LOCK: try: globals()['send_message'] = _callback_send_message handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id) finally: globals()['send_message'] = _orig_send_message else: handle_cmd(parts[0], parts[1] if len(parts) > 1 else '', chat_id, cq_message_id) return jsonify({'ok': True}) if _is_duplicate_update(uid): sys_log.debug(f"[OpenClawBot] duplicate update_id={uid}, skip") return jsonify({'ok': True}) # ── Message ─────────────────────────────────────────── msg = update.get('message') or update.get('edited_message') if not msg: return jsonify({'ok': True}) chat = msg.get('chat', {}) chat_id = chat.get('id') chat_type = chat.get('type', '') text_raw = (msg.get('text') or '').strip() msg_id = msg.get('message_id') # fail-closed 統一授權檢查(覆蓋 group/supergroup/private/channel/unknown) _uid = (msg.get('from') or {}).get('id') if not _is_authorized(chat_type, chat_id, _uid): sys_log.warning( f'[OpenClawBot] MSG rejected: chat={chat_id} type={chat_type} user={_uid}' ) # 靜默拒絕:不回 Telegram 訊息(避免陌生人偵察 bot 存在與白名單機制) return jsonify({'ok': False, 'error': 'forbidden'}) # critic Medium-3:把 user_id 放進 ContextVar 供 handle_cmd 內部 _is_admin 讀 _CURRENT_USER_ID_CTX.set(_uid) if chat_type in ('group', 'supergroup'): # 移除 @mention(不強制要求,但如有則移除) question = text_raw.replace(BOT_USERNAME, '').strip() else: # 已通過授權的 private chat question = text_raw # ── 圖片訊息:Ollama-first Vision 商品辨識 ───────────────── if not question and msg.get('photo'): send_typing(chat_id) try: photos = msg['photo'] file_id = photos[-1]['file_id'] # 取最大尺寸 # 取得 file path r_file = requests.get( f"{BOT_API_URL}/getFile", params={'file_id': file_id}, timeout=10 ).json() file_path_tg = r_file.get('result', {}).get('file_path', '') if not file_path_tg: send_message(chat_id, "⚠️ 無法取得圖片", msg_id) return jsonify({'ok': True}) # 下載圖片 img_url = f"https://api.telegram.org/file/bot{BOT_TOKEN}/{file_path_tg}" img_data = requests.get(img_url, timeout=15).content import base64 as _b64 img_b64 = _b64.b64encode(img_data).decode() req_id = f"img-{chat_id or 0}-{msg_id or 0}" product_name = _identify_product_name_with_ollama_vision(img_b64, req_id) if not product_name: product_name = _identify_product_name_with_gemini_vision(img_b64, req_id) if product_name: send_message(chat_id, f"🔍 辨識到商品:*{product_name}*\n正在搜尋 momo 比價...", msg_id, parse_mode='Markdown') # 直接執行比價 handle_cmd('competitor', product_name, chat_id, msg_id) else: send_message( chat_id, "⚠️ 無法辨識圖片中的商品,請直接輸入商品名稱搜尋", msg_id, [_row(('🔍 文字搜尋', 'await:search_compare'))], ) except Exception as _img_e: sys_log.error(f"[VisionSearch] {_img_e}") send_message(chat_id, "⚠️ 圖片處理失敗,請直接輸入商品名稱搜尋", msg_id) return jsonify({'ok': True}) # ── Excel 文件匯入(document)──────────────────────────── if not question and msg.get('document'): doc = msg['document'] fname = doc.get('file_name', '') if fname.lower().endswith(('.xlsx', '.xls')): send_typing(chat_id) threading.Thread( target=_handle_excel_import, args=(doc, chat_id, msg_id), daemon=True ).start() else: send_message(chat_id, f"⚠️ 僅支援 `.xlsx` / `.xls` 格式的業績 Excel 檔案\n" f"收到的檔案:`{fname}`", msg_id, parse_mode='Markdown') return jsonify({'ok': True}) if not question: return jsonify({'ok': True}) # ── 頻率限制 ───────────────────────────────────────────── _uid_rl = msg.get('from', {}).get('id', 0) if not _check_rate_limit(_uid_rl): send_message(chat_id, "⚠️ 操作太頻繁,請稍後再試(每分鐘上限 30 次)。", msg_id) return jsonify({'ok': True}) sys_log.info(f"[OpenClawBot] ← chat={chat_id} msg={question[:60]}") send_typing(chat_id) # ── 輸入等待狀態處理 ───────────────────────────────────── if chat_id in _input_pending and question not in ('/取消', '/cancel'): pending = _input_pending.pop(chat_id) action = pending['action'] label = pending.get('label', '') val = question.strip().replace(',', '').replace('NT$', '').replace('$', '').strip() if action.startswith('goal_'): period_map = { 'goal_daily': 'daily', 'goal_monthly': 'monthly', 'goal_quarterly': 'quarterly', 'goal_half': 'half', 'goal_yearly': 'yearly', } period = period_map[action] try: amount = float(val) _GOALS[period] = amount send_message(chat_id, f"✅ *{label}* 已設定為 `NT$ {amount:,.0f}`", msg_id, _submenu_goals(), parse_mode='Markdown') except ValueError: send_message(chat_id, f"⚠️ 格式錯誤,請輸入數字(例如:`150000`)", msg_id, [_row((f'重新設定 {label}', f'await:{action}')), _BACK], parse_mode='Markdown') sys_log.info(f"[OpenClawBot] → replied chat={chat_id}") return jsonify({'ok': True}) elif action == 'search_compare': # PChome 比價關鍵字輸入 handle_cmd('competitor', val, chat_id, msg_id) elif action == 'date_range_sales': # 支援:單日 / 日期區間 / 月份 dates = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', val) month_only = re.match(r'(\d{4})[/\-](\d{1,2})$', val.strip()) if len(dates) >= 2: s = normalize_date(dates[0]) e = normalize_date(dates[1]) if s == e: # 起迄同日 → 單日業績 handle_cmd('sales', s, chat_id, msg_id) else: # 日期區間 → 趨勢 from datetime import datetime as _dt2 s_d = _dt2.strptime(s.replace('/', '-'), '%Y-%m-%d').date() e_d = _dt2.strptime(e.replace('/', '-'), '%Y-%m-%d').date() days_c = (e_d - s_d).days + 1 send_message(chat_id, f"⏳ 查詢 {s} ~ {e}({days_c}天)...", msg_id, parse_mode=None) data_r = query_trend_range(s, e) if data_r: period_label = f'{s} ~ {e}({days_c}天)' send_message(chat_id, fmt_trend(data_r, period_label), msg_id, _submenu_sales()) else: send_message(chat_id, f"⚠️ `{s}` ~ `{e}` 查無業績資料", msg_id, [_row(('重新輸入', 'await:date_range_sales'))], parse_mode='Markdown') elif len(dates) == 1: # 單一日期 handle_cmd('sales', normalize_date(dates[0]), chat_id, msg_id) elif month_only: # 月份格式:2026/04 handle_cmd('history', f"{month_only.group(1)}/{int(month_only.group(2)):02d}", chat_id, msg_id) else: send_message(chat_id, "⚠️ 格式錯誤\n📌 單日:`2026/04/15`\n📌 區間:`2026/04/01-2026/04/15`\n📌 月份:`2026/04`", msg_id, [_row(('重新輸入', 'await:date_range_sales')), _BACK], parse_mode='Markdown') sys_log.info(f"[OpenClawBot] → replied chat={chat_id}") return jsonify({'ok': True}) elif action.startswith('date_trend_'): # 趨勢區間查詢(月份/年份/季度) trend_val = val.replace('-', '/') handle_cmd('trend', trend_val, chat_id, msg_id) elif action.startswith('date_'): # 驗證日期格式 date_val = val.replace('-', '/') if re.match(r'\d{4}/\d{1,2}(/\d{1,2})?$', date_val): if action == 'date_sales': handle_cmd('sales', date_val, chat_id, msg_id) elif action == 'date_top': handle_cmd('top', date_val, chat_id, msg_id) elif action == 'date_analysis': # 分析日期:同時出矩陣 + 健康 handle_cmd('strategy', date_val, chat_id, msg_id) elif action == 'date_ppt_daily': handle_cmd('ppt', f'daily {date_val}', chat_id, msg_id) elif action == 'date_ppt_monthly': handle_cmd('ppt', f'monthly {date_val}', chat_id, msg_id) elif action == 'date_competitor': handle_cmd('ppt', f'competitor {date_val}', chat_id, msg_id) else: send_message(chat_id, f"⚠️ 日期格式錯誤,請重新輸入(例如:`2026/04/15`)", msg_id, [_row(('重新輸入', f'await:{action}')), _BACK], parse_mode='Markdown') elif action == 'promo_range': # 促銷範圍:格式 2026/04/01-2026/04/07 m = re.findall(r'\d{4}[/\-]\d{1,2}[/\-]\d{1,2}', val) if len(m) >= 2: handle_cmd('promo', f'{normalize_date(m[0])}-{normalize_date(m[1])}', chat_id, msg_id) else: send_message(chat_id, "⚠️ 格式錯誤,例如:`2026/04/01-2026/04/07`", msg_id, [_row(('重新輸入', 'await:promo_range'))], parse_mode='Markdown') sys_log.info(f"[OpenClawBot] → replied chat={chat_id}") return jsonify({'ok': True}) # 取消輸入等待 if question in ('/取消', '/cancel') and chat_id in _input_pending: _input_pending.pop(chat_id, None) send_message(chat_id, '已取消', msg_id, main_menu_keyboard()) return jsonify({'ok': True}) # 解析指令(/xxx 或已知指令詞) q = question.lstrip('/') parts = q.split(None, 1) cmd = parts[0].split('@', 1)[0].lower() if parts else '' arg = parts[1] if len(parts) > 1 else '' KNOWN = { 'sales', 'top', 'vendor', 'trend', 'report', 'news', 'weather', 'menu', 'start', 'help', 'compare', 'category', 'catdetail', 'goal', 'chart', 'health', 'strategy', 'competitor', 'price', 'restock', 'promo', 'ppt', '業績', '熱銷', '廠商', '趨勢', '報表', '選單', '幫助', '同比', '分類', '分類細項', '目標', '圖表', '健康', '策略', '比價', '補貨', '促銷', } if question.startswith('/') or cmd in KNOWN: handle_cmd(cmd, arg, chat_id, msg_id) else: txt, kb = openclaw_answer(question, chat_id=chat_id) send_message(chat_id, txt, msg_id, kb) sys_log.info(f"[OpenClawBot] → replied chat={chat_id}") except Exception as e: sys_log.error(f"[OpenClawBot] webhook error: {e}", exc_info=True) return jsonify({'ok': True}) # ── 管理端點 ────────────────────────────────────────────────── @openclaw_bot_bp.route('/bot/telegram/set_webhook', methods=['POST']) def set_webhook(): webhook_url = f"{MOMO_BASE_URL}/bot/telegram/webhook" result = _tg('setWebhook', { 'url': webhook_url, 'allowed_updates': ['message', 'callback_query'], 'drop_pending_updates': True, }) register_commands() sys_log.info(f"[OpenClawBot] setWebhook → {result}") return jsonify({'ok': result.get('ok'), 'webhook_url': webhook_url}) @openclaw_bot_bp.route('/bot/telegram/webhook_info') def webhook_info(): try: r = requests.get(f"{BOT_API_URL}/getWebhookInfo", timeout=10) return jsonify(r.json()) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 @openclaw_bot_bp.record_once def _on_register(_state): """Blueprint 被 app.register_blueprint 時自動啟動排程""" start_scheduler() sys_log.info("[OpenClawBot] Blueprint registered — scheduler boot triggered")