diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index a15380b..b68dbea 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -2206,15 +2206,18 @@ def _invalidate_ppt_cache(report_type: str = None) -> int: session.close() -def cleanup_expired_ppt_cache(days_old: int = 7, dry_run: bool = False) -> dict: +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 天緩衝避免誤刪剛失效的紀錄。 - 可由 launchd / cron 每日呼叫一次: - from routes.openclaw_bot_routes import cleanup_expired_ppt_cache - cleanup_expired_ppt_cache(days_old=7) + + 安全預設:**dry_run=True**(critic 修正 HIGH-2,避免靜默實刪)。 + 呼叫方必須明確傳 dry_run=False 才會真正刪除。 + launchd / cron 排程務必顯式傳: + cleanup_expired_ppt_cache(days_old=7, dry_run=False) 回傳:{'deleted_files': N, 'deleted_rows': N, 'freed_bytes': N, 'errors': [...]} + dry_run=True 時 deleted_* 為「將刪除」預估值。 """ from database.manager import DatabaseManager from database.ppt_reports import PPTReport @@ -5456,25 +5459,34 @@ def handle_cmd(cmd, arg, chat_id, reply_to): 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 乾跑(只統計不刪) + # /cache cleanup [days] 乾跑(預設安全)— 只統計不刪 + # /cache cleanup [days] confirm 真正執行刪除 + # 注意:days < 1 視為高風險,無條件強制乾跑(critic Medium-3 防呆) parts = sub.split() - dry = 'dry' in parts + 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 + dry = forced_dry or (not confirm) try: stat = cleanup_expired_ppt_cache(days_old=days, dry_run=dry) - msg = (f"🧹 PPT 磁碟清理{'(乾跑)' if dry else ''}\n" + tag = "(乾跑—未實刪)" if dry else "(已實刪)" + if forced_dry: + tag += " [days<1 強制乾跑]" + msg = (f"🧹 PPT 磁碟清理 {tag}\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 "")) + 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=None) + send_message(chat_id, msg, reply_to, parse_mode='Markdown' if dry and not forced_dry else None) else: send_message(chat_id, "用法:\n" diff --git a/scripts/ppt_cleanup.sh b/scripts/ppt_cleanup.sh index 0c77b84..5ef074d 100755 --- a/scripts/ppt_cleanup.sh +++ b/scripts/ppt_cleanup.sh @@ -1,25 +1,48 @@ #!/usr/bin/env bash # scripts/ppt_cleanup.sh -# 由 launchd / cron 呼叫 — 清理 7 天前過期的 PPT 檔 + DB row。 +# 由 launchd / cron 呼叫 — 清理 N 天前過期的 PPT 檔 + DB row。 # 安全執行:失敗時不會 raise,只寫 log。 +# +# 環境變數(皆可在 launchd plist / 命令列覆寫): +# PROJECT_DIR source code 根目錄(預設:相對路徑解析至此腳本上層) +# VENV_PY python 直譯器(預設:${PROJECT_DIR}/venv/bin/python3,找不到則 fallback python3) +# DAYS_OLD 過期門檻天數(預設 7) +# DRY_RUN true/false(預設 false—launchd 排程實刪;手動測試請傳 DRY_RUN=true) set -euo pipefail -PROJECT_DIR="${PROJECT_DIR:-/Users/ooo/Documents/momo_pro_system}" -VENV_PY="${VENV_PY:-${PROJECT_DIR}/venv/bin/python3}" +# 動態解析路徑(critic Medium-4 修正):手動執行不再需要設環境變數 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +# venv python,找不到時 fallback 到系統 python3 +DEFAULT_VENV_PY="${PROJECT_DIR}/venv/bin/python3" +if [[ -x "$DEFAULT_VENV_PY" ]]; then + VENV_PY="${VENV_PY:-$DEFAULT_VENV_PY}" +else + VENV_PY="${VENV_PY:-python3}" +fi + LOG_FILE="${LOG_FILE:-${PROJECT_DIR}/logs/ppt_cleanup.log}" DAYS_OLD="${DAYS_OLD:-7}" +DRY_RUN="${DRY_RUN:-false}" + +# 將字串轉為 Python bool literal +if [[ "$DRY_RUN" =~ ^(true|TRUE|1|yes|YES)$ ]]; then + PY_DRY_RUN="True" +else + PY_DRY_RUN="False" +fi mkdir -p "$(dirname "$LOG_FILE")" - cd "$PROJECT_DIR" { - echo "[$(date +'%Y-%m-%d %H:%M:%S')] PPT cleanup start (days_old=$DAYS_OLD)" + echo "[$(date +'%Y-%m-%d %H:%M:%S')] PPT cleanup start (days_old=$DAYS_OLD, dry_run=$PY_DRY_RUN, py=$VENV_PY)" "$VENV_PY" -c " from routes.openclaw_bot_routes import cleanup_expired_ppt_cache import json -stat = cleanup_expired_ppt_cache(days_old=$DAYS_OLD) +stat = cleanup_expired_ppt_cache(days_old=$DAYS_OLD, dry_run=$PY_DRY_RUN) 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" diff --git a/services/ppt_generator.py b/services/ppt_generator.py index b7cb055..9c2ca1a 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -434,6 +434,7 @@ def _mpl_horiz_bar_png(categories, values, total_width_cm=18.5, total_height_cm= - 漸層暖色(焦糖橘 → 暖黃 → 米色),TOP3 用最深焦糖橘 - 條右側標註 NT$X.X萬 + 佔比 % - 軸線弱化、無上邊框、底色透明 — 適合貼進米色 PPT 頁 + critic HIGH-1:渲染失敗時保證 plt.close() — 避免 figure 洩漏吃光 worker 記憶體。 """ import io try: @@ -460,72 +461,77 @@ def _mpl_horiz_bar_png(categories, values, total_width_cm=18.5, total_height_cm= plt.rcParams["font.family"] = [chosen_cjk, "DejaVu Sans"] plt.rcParams["axes.unicode_minus"] = False - # 數值轉萬元 - vals_wan = [float(v) / 10000.0 for v in values] - total = sum(vals_wan) or 1.0 - pcts = [v / total * 100 for v in vals_wan] + fig = None + try: + # 數值轉萬元 + vals_wan = [float(v) / 10000.0 for v in values] + total = sum(vals_wan) or 1.0 + pcts = [v / total * 100 for v in vals_wan] - # 由低到高反轉,讓最高排在最上 - cats_rev = list(reversed(categories)) - vals_rev = list(reversed(vals_wan)) - pcts_rev = list(reversed(pcts)) + # 由低到高反轉,讓最高排在最上 + cats_rev = list(reversed(categories)) + vals_rev = list(reversed(vals_wan)) + pcts_rev = list(reversed(pcts)) - # 暖色階:TOP3 焦糖橘 #C96442、4-6 蜂蜜金 #B88416、7+ 米暖 #C4BAA8 - colors = [] - n = len(cats_rev) - for i, _ in enumerate(cats_rev): - rank_from_top = n - i - if rank_from_top <= highlight_top_n: - colors.append("#C96442") - elif rank_from_top <= 6: - colors.append("#B88416") - else: - colors.append("#8A5A2B") + # 暖色階:TOP3 焦糖橘、4-6 蜂蜜金、7+ 焦土 + colors = [] + n = len(cats_rev) + for i, _ in enumerate(cats_rev): + rank_from_top = n - i + if rank_from_top <= highlight_top_n: + colors.append("#C96442") + elif rank_from_top <= 6: + colors.append("#B88416") + else: + colors.append("#8A5A2B") - # 動態圖表尺寸(cm → inch) - fig_w = total_width_cm / 2.54 - fig_h = total_height_cm / 2.54 - fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150) - fig.patch.set_facecolor("#F3EEE2") # 暖紙感底(與 PPT 對齊) - ax.set_facecolor("#F3EEE2") + fig_w = total_width_cm / 2.54 + fig_h = total_height_cm / 2.54 + fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150) + fig.patch.set_facecolor("#F3EEE2") + ax.set_facecolor("#F3EEE2") - bars = ax.barh(cats_rev, vals_rev, color=colors, edgecolor="none", height=0.62) + bars = ax.barh(cats_rev, vals_rev, color=colors, edgecolor="none", height=0.62) - # 數值標註(條右):NT$X.X萬 + 佔比% - max_val = max(vals_rev) if vals_rev else 1 - for i, (bar, v, p) in enumerate(zip(bars, vals_rev, pcts_rev)): - bw = bar.get_width() - ax.text(bw + max_val * 0.012, - bar.get_y() + bar.get_height() / 2, - f"NT${v:,.1f}{value_unit} · {p:.0f}%", - va="center", ha="left", - fontsize=10, color="#2A2520", - fontweight="bold") + max_val = max(vals_rev) if vals_rev else 1 + for i, (bar, v, p) in enumerate(zip(bars, vals_rev, pcts_rev)): + bw = bar.get_width() + ax.text(bw + max_val * 0.012, + bar.get_y() + bar.get_height() / 2, + f"NT${v:,.1f}{value_unit} · {p:.0f}%", + va="center", ha="left", + fontsize=10, color="#2A2520", + fontweight="bold") - # 軸線美化 - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.spines["left"].set_color("#9B9081") - ax.spines["bottom"].set_color("#9B9081") - ax.tick_params(colors="#645C52", labelsize=11) - ax.xaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6) - ax.yaxis.grid(False) - ax.set_axisbelow(True) - # 預留右側空間給數值標註 - ax.set_xlim(0, max_val * 1.32) - ax.set_xlabel("業績(萬元)", fontsize=10, color="#645C52", labelpad=8) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color("#9B9081") + ax.spines["bottom"].set_color("#9B9081") + ax.tick_params(colors="#645C52", labelsize=11) + ax.xaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6) + ax.yaxis.grid(False) + ax.set_axisbelow(True) + ax.set_xlim(0, max_val * 1.32) + ax.set_xlabel("業績(萬元)", fontsize=10, color="#645C52", labelpad=8) - if title: - ax.set_title(title, fontsize=13, color="#2A2520", loc="left", - pad=12, fontweight="bold") + if title: + ax.set_title(title, fontsize=13, color="#2A2520", loc="left", + pad=12, fontweight="bold") - plt.tight_layout() - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=150, - facecolor=fig.get_facecolor(), bbox_inches="tight") - plt.close(fig) - buf.seek(0) - return buf + plt.tight_layout() + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=150, + facecolor=fig.get_facecolor(), bbox_inches="tight") + buf.seek(0) + return buf + except Exception: + return None + finally: + if fig is not None: + try: + plt.close(fig) + except Exception: + pass def _add_image_from_buf(slide, buf, l, t, w, h): @@ -572,160 +578,173 @@ def _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=None, if plt is None or not curr_dates or not curr_vals: return None - curr_wan = [float(v) / 10000.0 for v in curr_vals] - fig_w = total_width_cm / 2.54 - fig_h = total_height_cm / 2.54 - fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150) - fig.patch.set_facecolor("#F3EEE2") - ax.set_facecolor("#F3EEE2") + fig = None + try: + curr_wan = [float(v) / 10000.0 for v in curr_vals] + fig_w = total_width_cm / 2.54 + fig_h = total_height_cm / 2.54 + fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150) + fig.patch.set_facecolor("#F3EEE2") + ax.set_facecolor("#F3EEE2") - x = list(range(len(curr_dates))) - line_curr, = ax.plot(x, curr_wan, color="#C96442", linewidth=2.4, - marker="o", markersize=5, - markerfacecolor="#C96442", markeredgecolor="#FAF7F0", - label=curr_label, zorder=3) - ax.fill_between(x, curr_wan, alpha=0.12, color="#C96442", zorder=1) + x = list(range(len(curr_dates))) + line_curr, = ax.plot(x, curr_wan, color="#C96442", linewidth=2.4, + marker="o", markersize=5, + markerfacecolor="#C96442", markeredgecolor="#FAF7F0", + label=curr_label, zorder=3) + ax.fill_between(x, curr_wan, alpha=0.12, color="#C96442", zorder=1) - if prev_vals and len(prev_vals) >= 1: - prev_wan = [float(v) / 10000.0 for v in prev_vals[:len(curr_vals)]] - # 對齊長度 - while len(prev_wan) < len(curr_wan): - prev_wan.append(None) - ax.plot(x, prev_wan, color="#B88416", linewidth=1.6, - linestyle="--", marker=None, - label=prev_label, zorder=2, alpha=0.85) + if prev_vals and len(prev_vals) >= 1: + prev_wan = [float(v) / 10000.0 for v in prev_vals[:len(curr_vals)]] + while len(prev_wan) < len(curr_wan): + prev_wan.append(None) + ax.plot(x, prev_wan, color="#B88416", linewidth=1.6, + linestyle="--", marker=None, + label=prev_label, zorder=2, alpha=0.85) - # 平均線 - avg_v = sum(curr_wan) / len(curr_wan) if curr_wan else 0 - ax.axhline(avg_v, color="#8F4530", linewidth=1.0, linestyle=":", - alpha=0.7, label=f"本月日均 {avg_v:.1f}萬") + avg_v = sum(curr_wan) / len(curr_wan) if curr_wan else 0 + ax.axhline(avg_v, color="#8F4530", linewidth=1.0, linestyle=":", + alpha=0.7, label=f"本月日均 {avg_v:.1f}萬") - # 標註高點 / 低點 - if curr_wan: - max_i = curr_wan.index(max(curr_wan)) - min_i = curr_wan.index(min(curr_wan)) - ax.annotate(f"高 {curr_wan[max_i]:.1f}萬", - xy=(max_i, curr_wan[max_i]), - xytext=(0, 12), textcoords="offset points", - ha="center", fontsize=9, color="#8F4530", fontweight="bold") - if min_i != max_i: - ax.annotate(f"低 {curr_wan[min_i]:.1f}萬", - xy=(min_i, curr_wan[min_i]), - xytext=(0, -16), textcoords="offset points", - ha="center", fontsize=9, color="#B5342F", fontweight="bold") + if curr_wan: + max_i = curr_wan.index(max(curr_wan)) + min_i = curr_wan.index(min(curr_wan)) + ax.annotate(f"高 {curr_wan[max_i]:.1f}萬", + xy=(max_i, curr_wan[max_i]), + xytext=(0, 12), textcoords="offset points", + ha="center", fontsize=9, color="#8F4530", fontweight="bold") + if min_i != max_i: + ax.annotate(f"低 {curr_wan[min_i]:.1f}萬", + xy=(min_i, curr_wan[min_i]), + xytext=(0, -16), textcoords="offset points", + ha="center", fontsize=9, color="#B5342F", fontweight="bold") - ax.set_xticks(x) - ax.set_xticklabels([str(d)[-5:] for d in curr_dates], rotation=0, fontsize=9) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.spines["left"].set_color("#9B9081") - ax.spines["bottom"].set_color("#9B9081") - ax.tick_params(colors="#645C52", labelsize=10) - ax.yaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6) - ax.xaxis.grid(False) - ax.set_axisbelow(True) - ax.set_ylabel("日業績(萬元)", fontsize=10, color="#645C52", labelpad=8) + ax.set_xticks(x) + ax.set_xticklabels([str(d)[-5:] for d in curr_dates], rotation=0, fontsize=9) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color("#9B9081") + ax.spines["bottom"].set_color("#9B9081") + ax.tick_params(colors="#645C52", labelsize=10) + ax.yaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6) + ax.xaxis.grid(False) + ax.set_axisbelow(True) + ax.set_ylabel("日業績(萬元)", fontsize=10, color="#645C52", labelpad=8) - if title: - ax.set_title(title, fontsize=13, color="#2A2520", loc="left", - pad=12, fontweight="bold") + if title: + ax.set_title(title, fontsize=13, color="#2A2520", loc="left", + pad=12, fontweight="bold") - ax.legend(loc="upper left", frameon=False, fontsize=10, ncol=3, - bbox_to_anchor=(0, 1.10)) + ax.legend(loc="upper left", frameon=False, fontsize=10, ncol=3, + bbox_to_anchor=(0, 1.10)) - plt.tight_layout() - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=150, - facecolor=fig.get_facecolor(), bbox_inches="tight") - plt.close(fig) - buf.seek(0) - return buf + plt.tight_layout() + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=150, + facecolor=fig.get_facecolor(), bbox_inches="tight") + buf.seek(0) + return buf + except Exception: + return None + finally: + if fig is not None: + try: + plt.close(fig) + except Exception: + pass def _mpl_pareto_chart_png(categories, values, total_width_cm=18.5, total_height_cm=11.0, title="") -> "io.BytesIO": - """品類帕雷托:暖色橫條 + 累計貢獻折線(80/20 標線)""" + """品類帕雷托:暖色橫條 + 累計貢獻折線(80/20 標線)。 + critic HIGH-1:渲染失敗時保證 plt.close()。 + """ import io matplotlib, plt = _mpl_setup() if plt is None or not categories or not values: return None - vals_wan = [float(v) / 10000.0 for v in values] - total = sum(vals_wan) or 1.0 - # 由大到小排 - pairs = sorted(zip(categories, vals_wan), key=lambda p: -p[1]) - cats = [p[0] for p in pairs] - vals = [p[1] for p in pairs] - cum_pct = [] - running = 0 - for v in vals: - running += v - cum_pct.append(running / total * 100) + fig = None + try: + vals_wan = [float(v) / 10000.0 for v in values] + total = sum(vals_wan) or 1.0 + pairs = sorted(zip(categories, vals_wan), key=lambda p: -p[1]) + cats = [p[0] for p in pairs] + vals = [p[1] for p in pairs] + cum_pct = [] + running = 0 + for v in vals: + running += v + cum_pct.append(running / total * 100) - fig_w = total_width_cm / 2.54 - fig_h = total_height_cm / 2.54 - fig, ax1 = plt.subplots(figsize=(fig_w, fig_h), dpi=150) - fig.patch.set_facecolor("#F3EEE2") - ax1.set_facecolor("#F3EEE2") + fig_w = total_width_cm / 2.54 + fig_h = total_height_cm / 2.54 + fig, ax1 = plt.subplots(figsize=(fig_w, fig_h), dpi=150) + fig.patch.set_facecolor("#F3EEE2") + ax1.set_facecolor("#F3EEE2") - n = len(cats) - bar_colors = [] - for i in range(n): - if cum_pct[i] <= 80: - bar_colors.append("#C96442") # 80% 內=主力 - else: - bar_colors.append("#C4BAA8") # 80% 外=長尾 - bars = ax1.bar(range(n), vals, color=bar_colors, width=0.6, edgecolor="none") + n = len(cats) + bar_colors = [] + for i in range(n): + if cum_pct[i] <= 80: + bar_colors.append("#C96442") + else: + bar_colors.append("#C4BAA8") + bars = ax1.bar(range(n), vals, color=bar_colors, width=0.6, edgecolor="none") - # 條上標 NT$X.X萬 - max_val = max(vals) if vals else 1 - for i, (bar, v) in enumerate(zip(bars, vals)): - ax1.text(bar.get_x() + bar.get_width() / 2, - v + max_val * 0.02, - f"{v:.1f}", - ha="center", va="bottom", - fontsize=9, color="#2A2520", fontweight="bold") + max_val = max(vals) if vals else 1 + for i, (bar, v) in enumerate(zip(bars, vals)): + ax1.text(bar.get_x() + bar.get_width() / 2, + v + max_val * 0.02, + f"{v:.1f}", + ha="center", va="bottom", + fontsize=9, color="#2A2520", fontweight="bold") - ax1.set_xticks(range(n)) - ax1.set_xticklabels([c[:8] for c in cats], rotation=20, ha="right", fontsize=9) - ax1.spines["top"].set_visible(False) - ax1.spines["right"].set_visible(False) - ax1.spines["left"].set_color("#9B9081") - ax1.spines["bottom"].set_color("#9B9081") - ax1.tick_params(colors="#645C52", labelsize=9) - ax1.set_ylabel("業績(萬元)", fontsize=10, color="#645C52", labelpad=8) - ax1.yaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6) - ax1.set_axisbelow(True) - ax1.set_ylim(0, max_val * 1.18) + ax1.set_xticks(range(n)) + ax1.set_xticklabels([c[:8] for c in cats], rotation=20, ha="right", fontsize=9) + ax1.spines["top"].set_visible(False) + ax1.spines["right"].set_visible(False) + ax1.spines["left"].set_color("#9B9081") + ax1.spines["bottom"].set_color("#9B9081") + ax1.tick_params(colors="#645C52", labelsize=9) + ax1.set_ylabel("業績(萬元)", fontsize=10, color="#645C52", labelpad=8) + ax1.yaxis.grid(True, linestyle="--", alpha=0.35, color="#9B9081", linewidth=0.6) + ax1.set_axisbelow(True) + ax1.set_ylim(0, max_val * 1.18) - # 右軸:累計 % - ax2 = ax1.twinx() - ax2.plot(range(n), cum_pct, color="#8F4530", linewidth=2.0, - marker="o", markersize=5, - markerfacecolor="#8F4530", markeredgecolor="#FAF7F0", zorder=4) - ax2.set_ylim(0, 110) - ax2.set_ylabel("累計貢獻(%)", fontsize=10, color="#8F4530", labelpad=8) - ax2.tick_params(colors="#8F4530", labelsize=9) - ax2.spines["top"].set_visible(False) - ax2.spines["right"].set_color("#8F4530") - ax2.spines["left"].set_visible(False) + ax2 = ax1.twinx() + ax2.plot(range(n), cum_pct, color="#8F4530", linewidth=2.0, + marker="o", markersize=5, + markerfacecolor="#8F4530", markeredgecolor="#FAF7F0", zorder=4) + ax2.set_ylim(0, 110) + ax2.set_ylabel("累計貢獻(%)", fontsize=10, color="#8F4530", labelpad=8) + ax2.tick_params(colors="#8F4530", labelsize=9) + ax2.spines["top"].set_visible(False) + ax2.spines["right"].set_color("#8F4530") + ax2.spines["left"].set_visible(False) - # 80% 參考線 - ax2.axhline(80, color="#B5342F", linewidth=1.0, linestyle=":", alpha=0.7) - ax2.text(n - 0.5, 82, "80% 主力線", color="#B5342F", fontsize=9, - ha="right", va="bottom", fontweight="bold") + ax2.axhline(80, color="#B5342F", linewidth=1.0, linestyle=":", alpha=0.7) + ax2.text(n - 0.5, 82, "80% 主力線", color="#B5342F", fontsize=9, + ha="right", va="bottom", fontweight="bold") - if title: - ax1.set_title(title, fontsize=13, color="#2A2520", loc="left", - pad=12, fontweight="bold") + if title: + ax1.set_title(title, fontsize=13, color="#2A2520", loc="left", + pad=12, fontweight="bold") - plt.tight_layout() - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=150, - facecolor=fig.get_facecolor(), bbox_inches="tight") - plt.close(fig) - buf.seek(0) - return buf + plt.tight_layout() + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=150, + facecolor=fig.get_facecolor(), bbox_inches="tight") + buf.seek(0) + return buf + except Exception: + return None + finally: + if fig is not None: + try: + plt.close(fig) + except Exception: + pass # ── 升級版 KPI 卡:含 △% 與紅綠燈 ─────────────────────────────────────────────