fix(ppt): address critic findings on commit 38967ce (HIGH-1, HIGH-2, Medium-4)
All checks were successful
CD Pipeline / deploy (push) Successful in 2m27s

Critic 審查 38967ce 找出 2 HIGH + 4 medium,本 commit 修最緊急三項:

HIGH-2: cleanup_expired_ppt_cache 預設 dry_run=False 危險
- 函式 default 改 dry_run=True(呼叫方需明確傳 False 才實刪)
- launchd 排程腳本改用 DRY_RUN 環境變數,預設 false→實刪,
  但測試時可 DRY_RUN=true 安全跑
- /cache cleanup Telegram 指令預設乾跑,需加 confirm 才實刪
- /cache cleanup days<1 強制乾跑(防呆)

HIGH-1: matplotlib 三個 helper 缺 try/finally → 渲染失敗洩漏 figure
- _mpl_horiz_bar_png / _mpl_line_chart_png / _mpl_pareto_chart_png
  全部用 try/except/finally 包住,例外時 plt.close() 仍執行
- 同時讓渲染失敗回 None(呼叫端自然 fallback 到原生 chart)

Medium-4: scripts/ppt_cleanup.sh PROJECT_DIR 寫死
- 改用相對路徑解析(cd $SCRIPT_DIR/..),手動執行不再需要設環境變數
- VENV_PY 找不到時 fallback 到系統 python3(容器友善)
- 新增 DRY_RUN 環境變數開關(預設 false)

煙霧測試:
- syntax OK (services/ppt_generator.py + routes/openclaw_bot_routes.py)
- monthly v3.1 重生 10 頁 290KB
- cleanup_expired_ppt_cache dry_run default = True ✓

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-02 17:23:02 +08:00
parent 38967ceea3
commit 3b0b4b3d42
3 changed files with 260 additions and 206 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 卡:含 △% 與紅綠燈 ─────────────────────────────────────────────