這份筆記記錄了如何將一個 Python 資料報表腳本,打包成 Docker 容器,並透過網頁伺服器提供服務,實現每次刷新網頁都能生成最新報表的功能。
整個專案由三個核心檔案組成:
app.py:主要的 Python 腳本,使用 Flask 框架來建立網頁服務。它會讀取 Excel 資料,並在每次網頁請求時重新生成報表。
Dockerfile:用於建置 Docker 映像檔的設定檔,定義了容器內的環境、安裝依賴,並啟動 app.py 服務。
Noto_Sans_TC/NotoSansTC-VariableFont_wght.ttf:圖表所需的字型檔,用於正確顯示中文字元。
app.py 內容
Python
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from matplotlib import font_manager
import os
from flask import Flask, render_template_string, send_from_directory
import datetime
# ────────────────────────────────────────────────
# 📁 [1] 設定參數與字型
# ────────────────────────────────────────────────
app = Flask(__name__)
font_path = "Noto_Sans_TC/NotoSansTC-VariableFont_wght.ttf"
font_prop = font_manager.FontProperties(fname=font_path)
# 從環境變數獲取 Excel 檔案路徑,如果沒有則使用預設值
excel_file = os.getenv("EXCEL_FILE_PATH", "Incoming_Goods_Inspection_List_02072025.xlsx")
sheet_name = "Complaints"
type_col = "Type of defect"
supplier_col = "Supplier"
date_col = "Date"
output_dir = "defect_reports"
# ────────────────────────────────────────────────
# 📝 [2] 報表生成函數
# ────────────────────────────────────────────────
def generate_reports():
"""負責讀取 Excel、生成所有圖表並儲存為 PNG 檔案。"""
print("🔄 正在重新生成報表...")
# 每次生成前先清空舊的圖表檔案
if os.path.exists(output_dir):
for file in os.listdir(output_dir):
os.remove(os.path.join(output_dir, file))
os.makedirs(output_dir, exist_ok=True)
# 讀取 Excel 資料
try:
df = pd.read_excel(excel_file, sheet_name=sheet_name)
df[date_col] = pd.to_datetime(df[date_col], errors="coerce")
df["Year"] = df[date_col].dt.year
df["Quarter"] = df[date_col].dt.quarter
except FileNotFoundError:
return "<h2>錯誤:找不到 Excel 檔案。請確認檔案路徑是否正確。</h2>"
# 生成各供應商的年度缺陷類型統計圖
sns.set(style="whitegrid")
suppliers = df[supplier_col].dropna().unique()
for supplier in suppliers:
supplier_df = df[df[supplier_col] == supplier]
if supplier_df.empty:
continue
count_df = supplier_df.groupby(["Year", type_col]).size().reset_index(name="Count")
plt.figure(figsize=(max(10, len(count_df[type_col].unique()) * 0.8), 6))
sns.barplot(data=count_df, x=type_col, y="Count", hue="Year", palette="tab10")
plt.title(f"{supplier} 缺陷類型年度統計", fontproperties=font_prop)
plt.xlabel("缺陷類型", fontproperties=font_prop)
plt.ylabel("數量", fontproperties=font_prop)
plt.xticks(rotation=90, fontproperties=font_prop)
plt.legend(title="year", prop=font_prop)
plt.tight_layout()
safe_supplier = supplier.replace(" ", "_").replace("/", "_")
output_path = os.path.join(output_dir, f"{safe_supplier}_defect_type_by_year_chart.png")
plt.savefig(output_path)
plt.close()
# 生成各供應商的年度總次數統計圖
summary_df_year = df.groupby([supplier_col, "Year"]).size().reset_index(name="Total")
plt.figure(figsize=(max(10, len(summary_df_year[supplier_col].unique()) * 0.6), 6))
sns.barplot(data=summary_df_year, x=supplier_col, y="Total", hue="Year", palette="tab10")
plt.title("各供應商年度缺陷總次數統計", fontproperties=font_prop)
plt.xlabel("供應商", fontproperties=font_prop)
plt.ylabel("缺陷次數", fontproperties=font_prop)
plt.xticks(rotation=90, fontproperties=font_prop)
plt.legend(title="year", prop=font_prop)
plt.tight_layout()
plt.savefig(os.path.join(output_dir, "supplier_yearly_summary_chart.png"))
plt.close()
# 生成各供應商的季度總次數統計圖
summary_df_quarter = df.groupby([supplier_col, "Year", "Quarter"]).size().reset_index(name="Total")
summary_df_quarter["Year_Quarter"] = summary_df_quarter["Year"].astype(str) + " Q" + summary_df_quarter["Quarter"].astype(str)
plt.figure(figsize=(max(12, len(summary_df_quarter[supplier_col].unique()) * len(summary_df_quarter["Year_Quarter"].unique()) * 0.2), 6))
sns.barplot(data=summary_df_quarter, x=supplier_col, y="Total", hue="Year_Quarter", palette="tab20")
plt.title("各供應商季度缺陷總次數統計", fontproperties=font_prop)
plt.xlabel("供應商", fontproperties=font_prop)
plt.ylabel("缺陷次數", fontproperties=font_prop)
plt.xticks(rotation=90, fontproperties=font_prop)
plt.legend(title="year-season", prop=font_prop, loc="upper right")
plt.tight_layout()
plt.savefig(os.path.join(output_dir, "supplier_quarterly_summary_chart.png"))
plt.close()
return "Success"
# ────────────────────────────────────────────────
# 🌐 [3] Flask 路由與處理邏輯
# ────────────────────────────────────────────────
@app.route('/')
def index():
"""首頁路由,每次訪問時都會重新生成報表。"""
generate_reports()
# 根據生成的圖表動態建立 HTML 頁面
image_files = [f for f in os.listdir(output_dir) if f.endswith(".png")]
html_content = f"""
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>各供應商缺陷類型年度與季度統計</title>
<style>
body {{ font-family: sans-serif; background: #f9f9f9; padding: 20px; }}
h1 {{ color: #0056b3; }}
h2 {{ color: #333; border-bottom: 1px solid #ccc; padding-bottom: 5px; }}
.chart {{ margin-bottom: 40px; }}
img {{ max-width: 100%; height: auto; border: 1px solid #ddd; box-shadow: 2px 2px 8px rgba(0,0,0,0.1); }}
</style>
</head>
<body>
<h1>📊 各供應商缺陷統計報表</h1>
<p>最後更新時間:{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
<div class="chart">
<h2>📌 各供應商年度缺陷總次數統計</h2>
<img src="/reports/supplier_yearly_summary_chart.png" alt="年度總次數統計圖表">
</div>
<div class="chart">
<h2>📌 各供應商季度缺陷總次數統計</h2>
<img src="/reports/supplier_quarterly_summary_chart.png" alt="季度總次數統計圖表">
</div>
"""
for image_file in sorted(image_files):
if image_file.startswith("supplier_yearly") or image_file.startswith("supplier_quarterly"):
continue
supplier_name = image_file.replace("_defect_type_by_year_chart.png", "").replace("_", " ")
html_content += f"""
<div class="chart">
<h2>{supplier_name}</h2>
<img src="/reports/{image_file}" alt="{supplier_name} 缺陷年度圖表">
</div>
"""
html_content += """
</body>
</html>
"""
return render_template_string(html_content)
@app.route('/reports/<path:filename>')
def serve_images(filename):
"""用於提供靜態圖表檔案的路由。"""
return send_from_directory(output_dir, filename)
if __name__ == '__main__':
# 啟動 Flask 服務,監聽所有 IP
app.run(host='0.0.0.0', port=8000)
Dockerfile 內容
Dockerfile
# 使用官方 Python 3.9 映像檔作為基礎
FROM python:3.9-slim
# 設定工作目錄
WORKDIR /app
# 複製所有需要的檔案到容器中 (app.py, 字型檔)
COPY . /app
# 執行系統套件更新並安裝時區資料
# tzdata 套件用於設定容器時區
RUN apt-get update && apt-get install -y tzdata
# 設定時區為 Asia/Taipei
# 這會影響容器內 Python 程式的時間顯示
ENV TZ="Asia/Taipei"
# 安裝 Python 依賴套件,包括 Flask
RUN pip install --no-cache-dir pandas matplotlib seaborn openpyxl flask
# 暴露一個 Port,讓外部可以存取 (8000 是 Flask 預設 Port)
EXPOSE 8000
# 啟動 Flask 網頁應用程式
# CMD 會在容器啟動時執行這條命令
CMD ["python", "app.py"]
以下是從頭到尾的 Docker 指令執行步驟:
建置 Docker 映像檔:
在包含 app.py、Dockerfile 和字型資料夾的目錄下執行此命令。
Bash
docker build --network host -t defect_report_generator .
docker build: 執行映像檔建置。
--network host: 在建置時使用主機網路,解決容器內無法連線下載套件的問題。
-t defect_report_generator: 為映像檔命名為 defect_report_generator。
.: 表示 Dockerfile 位於當前目錄。
停止並移除舊容器:
如果之前有運行舊的容器,需要先停止並移除,才能運行新的。
Bash
docker stop defect_report_container
docker rm defect_report_container
運行 Docker 容器:
運行新的容器,並設定自動重啟和檔案掛載。
Bash
docker run -d --name defect_report_container \
--restart unless-stopped \
--mount type=bind,source=/mnt/raid0/image/tmp,target=/mnt/raid0/image/tmp \
-e EXCEL_FILE_PATH="/mnt/raid0/image/tmp/Incoming_Goods_Inspection_List_02072025.xlsx" \
-p 8082:8000 \
defect_report_generator
-d: 在背景模式下運行容器。
--name defect_report_container: 為容器命名,方便管理。
--restart unless-stopped: 設定重啟策略,除非手動停止,否則容器會自動重啟。
--mount ...: 將 NAS 上的 Excel 檔案目錄掛載到容器內,讓腳本可以讀取資料。
-e ...: 設定環境變數,將 Excel 檔案路徑傳給腳本。
-p 8082:8000: 將主機的 8082 port 映射到容器的 8000 port。
defect_report_generator: 使用剛剛建置好的映像檔。
在瀏覽器中訪問報表:
現在,每次在瀏覽器中訪問以下網址時,app.py 腳本都會重新執行,生成最新的報表。
http://<您的主機IP地址>:8082