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
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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 卡:含 △% 與紅綠燈 ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user