Python 報表自動化與 Docker 部署學習筆記

這份筆記記錄了如何將一個 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 指令執行步驟:

  1. 建置 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 位於當前目錄。

  2. 停止並移除舊容器:

    如果之前有運行舊的容器,需要先停止並移除,才能運行新的。

    Bash

    docker stop defect_report_container
    docker rm defect_report_container
    
    
  3. 運行 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: 使用剛剛建置好的映像檔。

  4. 在瀏覽器中訪問報表:

    現在,每次在瀏覽器中訪問以下網址時,app.py 腳本都會重新執行,生成最新的報表。

    http://<您的主機IP地址>:8082