feat(ppt): redesign all 6 reports to professional standard + cache versioning
All checks were successful
CD Pipeline / deploy (push) Successful in 13m18s
All checks were successful
CD Pipeline / deploy (push) Successful in 13m18s
PPT 模板全面升版至市場專業標準(McKinsey / BCG 月報級別): Monthly v3.1(10 頁) - 暖紙感封面(去黑底)+ elevator pitch(亮點/警訊/動能徽章) - KPI 卡含 △% vs 上月 + 紅綠燈、YoY 同期對比帶 - matplotlib 業績趨勢折線(本月+上月+日均線+高低點) - 品類分析雙視圖:橫條 + 帕雷托累計(80% 主力線) - TOP 50 商品(自動分頁)+ vs 上月 △ 排名變化 / 🆕 新進榜 - MCP 情報 4 卡片結構化、AI 行動結構化分區、附錄頁 Daily / Weekly / Strategy / Promo / Competitor v3.0 - 統一暖紙封面、AI 頁去黑底改暖紙 + 焦糖橘色條 - 日週改 matplotlib 折線(含日均線、高低點) - 全部加附錄頁(資料來源 / 計算口徑 / 模板版本) Cache 版本控制 - TEMPLATE_VERSIONS 字典自動注入 cache key (tpl_ver) - 模板升版舊快取自動 miss → 重生 - 新增 _invalidate_ppt_cache() 與 cleanup_expired_ppt_cache() helper - 新增 /cache status / flush / cleanup Telegram 指令 字型系統 - _set_run_fonts() 用 lxml 直寫 a:latin/a:ea,中英分軌 - 中文走 Microsoft JhengHei,數字/英文走 Consolas(點陣等寬) - Dockerfile 加 fonts-noto-cjk + fonts-noto-cjk-extra 部署排程 - scripts/install_ppt_cleanup.sh 一鍵安裝 launchd(每日 03:15) - scripts/ppt_cleanup.sh 清 7 天前過期 PPT 檔 + DB row 資料層 - routes monthly: query_monthly_summary LIMIT 10 → 50,補 orders 欄位 - routes monthly: 拉 prev_month / prev_year 比較資料供 KPI △ 與商品 △ 計算 煙霧測試 6/6 全綠: - 日報 v3.0 (5 頁) / 週報 v3.0 (6 頁) / 月報 v3.1 (10 頁) - 策略 v3.0 (6 頁) / 促銷 v3.0 (6 頁) / 競品 v3.0 (5 頁) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ RUN apt-get update && apt-get install -y \
|
||||
libxrandr2 \
|
||||
xdg-utils \
|
||||
fonts-liberation \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-cjk-extra \
|
||||
openssh-client \
|
||||
libappindicator3-1 || true \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -2154,11 +2154,105 @@ def _ppt_check_data_freshness(report_type: str, chat_id: int, reply_to: int,
|
||||
|
||||
|
||||
def _normalize_ppt_parameters(parameters: dict) -> str:
|
||||
"""將快取參數轉成穩定字串,避免 key 順序造成命中錯誤。"""
|
||||
"""將快取參數轉成穩定字串。
|
||||
自動把當前 ppt_generator 的模板版本字串 (tpl_ver) 併入 parameters,
|
||||
任何 report_type 模板升級(bump TEMPLATE_VERSIONS)→ 舊快取全部 miss → 重新生成。
|
||||
"""
|
||||
try:
|
||||
from services.ppt_generator import get_template_version
|
||||
report_type = parameters.get('report_type') if isinstance(parameters, dict) else None
|
||||
if report_type:
|
||||
params_with_ver = dict(parameters)
|
||||
params_with_ver['tpl_ver'] = get_template_version(report_type)
|
||||
return json.dumps(params_with_ver, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
|
||||
return json.dumps(parameters, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
|
||||
except Exception:
|
||||
return json.dumps({}, ensure_ascii=False)
|
||||
try:
|
||||
return json.dumps(parameters, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
|
||||
except Exception:
|
||||
return json.dumps({}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _invalidate_ppt_cache(report_type: str = None) -> int:
|
||||
"""強制失效指定 report_type(或全部)的 PPT 快取。
|
||||
將 expires_at 設為 NOW() − 1 分鐘,下次查詢即 miss → 用最新模板重生。
|
||||
回傳被影響的列數。
|
||||
|
||||
用法(管理員):
|
||||
_invalidate_ppt_cache('monthly') # 只清月報
|
||||
_invalidate_ppt_cache() # 清全部
|
||||
"""
|
||||
from database.manager import DatabaseManager
|
||||
from database.ppt_reports import PPTReport
|
||||
|
||||
now = datetime.now(TAIPEI_TZ).replace(tzinfo=None)
|
||||
expired_at = now - timedelta(minutes=1)
|
||||
session = DatabaseManager().get_session()
|
||||
try:
|
||||
q = session.query(PPTReport).filter(
|
||||
or_(PPTReport.expires_at.is_(None), PPTReport.expires_at > now)
|
||||
)
|
||||
if report_type:
|
||||
q = q.filter(PPTReport.report_type == report_type)
|
||||
affected = q.update({PPTReport.expires_at: expired_at}, synchronize_session=False)
|
||||
session.commit()
|
||||
sys_log.info(f"[PPT] 強制失效快取 type={report_type or 'ALL'} affected={affected}")
|
||||
return int(affected or 0)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"[PPT] 失效快取失敗: {e}")
|
||||
return 0
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def cleanup_expired_ppt_cache(days_old: int = 7, dry_run: bool = False) -> dict:
|
||||
"""清理已過期且超過 days_old 天的 PPT 檔案 + DB 紀錄。
|
||||
執行條件:expires_at < NOW() − days_old 天 → 刪檔 + 刪 row。
|
||||
保留 days_old 天緩衝避免誤刪剛失效的紀錄。
|
||||
可由 launchd / cron 每日呼叫一次:
|
||||
from routes.openclaw_bot_routes import cleanup_expired_ppt_cache
|
||||
cleanup_expired_ppt_cache(days_old=7)
|
||||
|
||||
回傳:{'deleted_files': N, 'deleted_rows': N, 'freed_bytes': N, 'errors': [...]}
|
||||
"""
|
||||
from database.manager import DatabaseManager
|
||||
from database.ppt_reports import PPTReport
|
||||
|
||||
cutoff = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=days_old)
|
||||
stat = {'deleted_files': 0, 'deleted_rows': 0,
|
||||
'freed_bytes': 0, 'errors': [], 'dry_run': dry_run}
|
||||
|
||||
session = DatabaseManager().get_session()
|
||||
try:
|
||||
rows = (session.query(PPTReport)
|
||||
.filter(PPTReport.expires_at.isnot(None),
|
||||
PPTReport.expires_at < cutoff)
|
||||
.all())
|
||||
for r in rows:
|
||||
try:
|
||||
if r.file_path and os.path.exists(r.file_path):
|
||||
size = os.path.getsize(r.file_path)
|
||||
if not dry_run:
|
||||
os.unlink(r.file_path)
|
||||
stat['deleted_files'] += 1
|
||||
stat['freed_bytes'] += size
|
||||
if not dry_run:
|
||||
session.delete(r)
|
||||
stat['deleted_rows'] += 1
|
||||
except Exception as e:
|
||||
stat['errors'].append(f"id={r.id}: {e}")
|
||||
if not dry_run:
|
||||
session.commit()
|
||||
sys_log.info(f"[PPT cleanup] {stat}")
|
||||
return stat
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"[PPT cleanup] 失敗: {e}")
|
||||
stat['errors'].append(str(e))
|
||||
return stat
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def _load_cached_ppt_entry(report_type: str, parameters: dict):
|
||||
@@ -5314,6 +5408,82 @@ def handle_cmd(cmd, arg, chat_id, reply_to):
|
||||
]
|
||||
send_message(chat_id, fmt_strategy(strat, target), reply_to, kb)
|
||||
|
||||
elif cmd in ('cache', '快取'):
|
||||
# /cache flush [type] 清除指定 PPT 快取(或全部)
|
||||
# /cache status 顯示目前快取數量
|
||||
sub = (arg or '').strip().lower()
|
||||
if sub.startswith('flush'):
|
||||
parts = sub.split(maxsplit=1)
|
||||
target_type = parts[1].strip() if len(parts) > 1 else None
|
||||
try:
|
||||
affected = _invalidate_ppt_cache(target_type if target_type and target_type != 'all' else None)
|
||||
from services.ppt_generator import TEMPLATE_VERSIONS
|
||||
ver = TEMPLATE_VERSIONS.get(target_type, '—') if target_type else '—'
|
||||
msg = (f"✅ PPT 快取已強制失效\n"
|
||||
f"類型:{target_type or '全部'}\n"
|
||||
f"當前模板版本:{ver}\n"
|
||||
f"影響筆數:{affected}\n"
|
||||
f"下次請求將以新模板重新生成。")
|
||||
except Exception as e:
|
||||
msg = f"❌ 快取清除失敗:{e}"
|
||||
send_message(chat_id, msg, reply_to, parse_mode=None)
|
||||
elif sub.startswith('status') or sub == '':
|
||||
try:
|
||||
from database.manager import DatabaseManager
|
||||
from database.ppt_reports import PPTReport
|
||||
from sqlalchemy import func as _f
|
||||
session = DatabaseManager().get_session()
|
||||
now_naive = datetime.now(TAIPEI_TZ).replace(tzinfo=None)
|
||||
rows = (session.query(PPTReport.report_type,
|
||||
_f.count(PPTReport.id),
|
||||
_f.sum(PPTReport.file_size))
|
||||
.filter(or_(PPTReport.expires_at.is_(None),
|
||||
PPTReport.expires_at > now_naive))
|
||||
.group_by(PPTReport.report_type).all())
|
||||
session.close()
|
||||
from services.ppt_generator import TEMPLATE_VERSIONS
|
||||
lines = ["📦 *PPT 快取狀態(未過期)*", ""]
|
||||
if not rows:
|
||||
lines.append("(目前無有效快取)")
|
||||
else:
|
||||
for rt, cnt, size in rows:
|
||||
size_kb = (size or 0) / 1024
|
||||
ver = TEMPLATE_VERSIONS.get(rt, '—')
|
||||
lines.append(f"• `{rt}`:{cnt} 筆 · {size_kb:,.0f} KB · 模板 `{ver}`")
|
||||
lines.append("")
|
||||
lines.append("使用 `/cache flush <type>` 強制清除")
|
||||
send_message(chat_id, '\n'.join(lines), reply_to, parse_mode='Markdown')
|
||||
except Exception as e:
|
||||
send_message(chat_id, f"❌ 查詢快取失敗:{e}", reply_to, parse_mode=None)
|
||||
elif sub.startswith('cleanup'):
|
||||
# /cache cleanup [days] 實刪 N 天前過期的檔案 + DB row
|
||||
# /cache cleanup dry 乾跑(只統計不刪)
|
||||
parts = sub.split()
|
||||
dry = 'dry' in parts
|
||||
days = 7
|
||||
for p in parts[1:]:
|
||||
if p.isdigit():
|
||||
days = int(p)
|
||||
try:
|
||||
stat = cleanup_expired_ppt_cache(days_old=days, dry_run=dry)
|
||||
msg = (f"🧹 PPT 磁碟清理{'(乾跑)' if dry else ''}\n"
|
||||
f"門檻:過期超過 {days} 天\n"
|
||||
f"刪檔:{stat['deleted_files']} 個\n"
|
||||
f"刪 row:{stat['deleted_rows']} 筆\n"
|
||||
f"釋放空間:{stat['freed_bytes']/1024:,.0f} KB"
|
||||
+ (f"\n錯誤:{len(stat['errors'])} 筆" if stat['errors'] else ""))
|
||||
except Exception as e:
|
||||
msg = f"❌ 清理失敗:{e}"
|
||||
send_message(chat_id, msg, reply_to, parse_mode=None)
|
||||
else:
|
||||
send_message(chat_id,
|
||||
"用法:\n"
|
||||
"• `/cache status` 查看快取狀態\n"
|
||||
"• `/cache flush monthly` 清月報快取\n"
|
||||
"• `/cache flush all` 清全部\n"
|
||||
"• `/cache cleanup [N天] [dry]` 清磁碟過期檔案",
|
||||
reply_to, parse_mode='Markdown')
|
||||
|
||||
elif cmd in ('ppt', 'slides', '簡報'):
|
||||
# /ppt daily [日期]
|
||||
# /ppt weekly
|
||||
|
||||
53
scripts/com.openclaw.ppt-cleanup.plist
Normal file
53
scripts/com.openclaw.ppt-cleanup.plist
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
com.openclaw.ppt-cleanup.plist
|
||||
每日凌晨 03:15 清理 7 天前過期的 PPT 檔 + DB row。
|
||||
|
||||
安裝(一次性):
|
||||
bash scripts/install_ppt_cleanup.sh
|
||||
|
||||
手動觸發(測試):
|
||||
launchctl start com.openclaw.ppt-cleanup
|
||||
|
||||
停用 / 解除:
|
||||
launchctl unload ~/Library/LaunchAgents/com.openclaw.ppt-cleanup.plist
|
||||
rm ~/Library/LaunchAgents/com.openclaw.ppt-cleanup.plist
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.openclaw.ppt-cleanup</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>__SCRIPT_PATH__</string>
|
||||
</array>
|
||||
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>3</integer>
|
||||
<key>Minute</key>
|
||||
<integer>15</integer>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>__LOG_DIR__/ppt_cleanup.stdout.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>__LOG_DIR__/ppt_cleanup.stderr.log</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PROJECT_DIR</key>
|
||||
<string>__PROJECT_DIR__</string>
|
||||
<key>DAYS_OLD</key>
|
||||
<string>7</string>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
74
scripts/install_ppt_cleanup.sh
Executable file
74
scripts/install_ppt_cleanup.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/install_ppt_cleanup.sh
|
||||
# 一鍵安裝:將 PPT cleanup 排程到 macOS launchd(每日 03:15 執行)。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/install_ppt_cleanup.sh # 安裝
|
||||
# bash scripts/install_ppt_cleanup.sh --uninstall # 解除安裝
|
||||
# bash scripts/install_ppt_cleanup.sh --test # 安裝後立刻跑一次
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── 路徑解析 ────────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOG_DIR="${PROJECT_DIR}/logs"
|
||||
SCRIPT_PATH="${SCRIPT_DIR}/ppt_cleanup.sh"
|
||||
|
||||
PLIST_NAME="com.openclaw.ppt-cleanup"
|
||||
PLIST_SRC="${SCRIPT_DIR}/${PLIST_NAME}.plist"
|
||||
PLIST_DST="${HOME}/Library/LaunchAgents/${PLIST_NAME}.plist"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
mkdir -p "$(dirname "$PLIST_DST")"
|
||||
chmod +x "$SCRIPT_PATH"
|
||||
|
||||
# ── 解除安裝 ────────────────────────────────────────────────────────────
|
||||
if [[ "${1:-}" == "--uninstall" ]]; then
|
||||
if launchctl list | grep -q "$PLIST_NAME"; then
|
||||
launchctl unload "$PLIST_DST" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$PLIST_DST"
|
||||
echo "✅ 已解除安裝 ${PLIST_NAME}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 安裝 ────────────────────────────────────────────────────────────────
|
||||
echo "🔧 安裝 PPT cleanup launchd job..."
|
||||
echo " PROJECT_DIR : $PROJECT_DIR"
|
||||
echo " SCRIPT_PATH : $SCRIPT_PATH"
|
||||
echo " LOG_DIR : $LOG_DIR"
|
||||
echo " PLIST_DST : $PLIST_DST"
|
||||
|
||||
# 替換 plist 中的 placeholder
|
||||
sed -e "s|__SCRIPT_PATH__|${SCRIPT_PATH}|g" \
|
||||
-e "s|__LOG_DIR__|${LOG_DIR}|g" \
|
||||
-e "s|__PROJECT_DIR__|${PROJECT_DIR}|g" \
|
||||
"$PLIST_SRC" > "$PLIST_DST"
|
||||
|
||||
# 重新載入 launchd job
|
||||
launchctl unload "$PLIST_DST" 2>/dev/null || true
|
||||
launchctl load "$PLIST_DST"
|
||||
|
||||
echo "✅ 已安裝 ${PLIST_NAME}(每日 03:15 執行)"
|
||||
echo ""
|
||||
echo "驗證:"
|
||||
launchctl list | grep "$PLIST_NAME" || echo " (job 尚未列出,可重啟 Mac 或執行 launchctl load)"
|
||||
|
||||
# ── 即刻測試 ────────────────────────────────────────────────────────────
|
||||
if [[ "${1:-}" == "--test" ]]; then
|
||||
echo ""
|
||||
echo "🚀 立即觸發一次測試..."
|
||||
launchctl start "$PLIST_NAME"
|
||||
sleep 2
|
||||
echo "📋 stdout 最後 20 行:"
|
||||
tail -20 "${LOG_DIR}/ppt_cleanup.stdout.log" 2>/dev/null || echo " (尚無輸出)"
|
||||
echo "📋 stderr 最後 20 行:"
|
||||
tail -20 "${LOG_DIR}/ppt_cleanup.stderr.log" 2>/dev/null || echo " (無 stderr,正常)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🛠 後續操作:"
|
||||
echo " 立即觸發: launchctl start $PLIST_NAME"
|
||||
echo " 解除安裝: bash $0 --uninstall"
|
||||
echo " 查看日誌: tail -f ${LOG_DIR}/ppt_cleanup.log"
|
||||
27
scripts/ppt_cleanup.sh
Executable file
27
scripts/ppt_cleanup.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/ppt_cleanup.sh
|
||||
# 由 launchd / cron 呼叫 — 清理 7 天前過期的 PPT 檔 + DB row。
|
||||
# 安全執行:失敗時不會 raise,只寫 log。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="${PROJECT_DIR:-/Users/ooo/Documents/momo_pro_system}"
|
||||
VENV_PY="${VENV_PY:-${PROJECT_DIR}/venv/bin/python3}"
|
||||
LOG_FILE="${LOG_FILE:-${PROJECT_DIR}/logs/ppt_cleanup.log}"
|
||||
DAYS_OLD="${DAYS_OLD:-7}"
|
||||
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
{
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] PPT cleanup start (days_old=$DAYS_OLD)"
|
||||
"$VENV_PY" -c "
|
||||
from routes.openclaw_bot_routes import cleanup_expired_ppt_cache
|
||||
import json
|
||||
stat = cleanup_expired_ppt_cache(days_old=$DAYS_OLD)
|
||||
print('result:', json.dumps(stat, ensure_ascii=False))
|
||||
" 2>&1 || echo "[ERROR] cleanup failed"
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] PPT cleanup end"
|
||||
echo "---"
|
||||
} >> "$LOG_FILE"
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user