Skip to main content

📘 SKU Map 編輯器 使用說明

📖 簡介

SKU Map 編輯器 是一個基於 Python Tkinter 開發的桌面應用程式,
主要功能是管理 SKU 對應表 (SKU Map),支援 JSON 檔案Excel 檔案 的匯入匯出,
並可即時從線上取得產品清單(含價格與溫層資訊)。

此工具可協助快速維護多平台商品 SKU 對應關係,並計算總金額。


🛠 系統需求

  • Windows 10 / 11

  • Python 3.10 以上版本

  • 已安裝模組:

    • tkinter

    • requests

    • openpyxl

安裝缺少的套件:

pip install requests openpyxl

📂 程式結構

sku_editor_v1.1.py        # 本程式主檔

程式會自動從以下網址載入產品清單:

https://wu:wu2266228@ec.zfun.com.tw/plist.json

🚀 功能說明

1. 檔案操作

  • 讀取 JSON 檔案:開啟並載入 SKU Map。

  • 儲存 JSON:將當前資料存回已開啟的 JSON 檔。

  • 另存 JSON:將 SKU Map 另存為新的 JSON 檔。

  • 匯入 Excel:讀取 Excel 檔案並轉換成 SKU Map。

  • 匯出 Excel:將 SKU Map 以 Excel 格式匯出,並自動套用底色區隔不同原始 SKU。

  • 重新載入產品清單:從線上重新載入商品資料。


2. SKU 清單管理

  • 搜尋原始 SKU:即時過濾 SKU 清單。

  • 新增原始 SKU:輸入新的 SKU 並加入清單。

  • 修改選取 SKU:將目前選取的 SKU 重新命名。

  • 刪除選取 SKU:刪除當前選取的 SKU 及其子項目。


3. 子項目管理

每個原始 SKU 可對應多個子項目,包含以下欄位:

  • 新SKU

  • 產品名稱(自動從線上清單帶出,含溫層資訊)

  • 新數量

  • 新進價

可執行操作:

  • 新增子項

  • 儲存子項修改

  • 刪除子項


4. 總金額計算

  • 自動計算所有子項 (數量 × 單價) 的合計。

  • 顯示於畫面下方標籤,並四捨五入顯示。


📐 Excel 匯入/匯出格式

必要欄位

  • 原始SKU

  • 新SKU

  • 產品名稱

  • 新數量

  • 新進價

匯出檔案

  • 每個 原始 SKU 的子項會分組顯示。

  • 系統會為不同原始 SKU 的區塊套用不同底色(淺檸檬色、淺青色、淺玫瑰色等)。


📦 資料結構範例

JSON 範例

{
    "A123": [
        {
            "新SKU": "B456",
            "新數量": "10",
            "新進價": "150"
        },
        {
            "新SKU": "C789",
            "新數量": "5",
            "新進價": "200"
        }
    ]
}

Excel 範例

原始SKU 新SKU 產品名稱 新數量 新進價
A123 B456 牛肉火鍋片(冷凍) 10 150
A123 C789 豬肉火鍋片(冷凍) 5 200

⚙️ 程式原始碼重點

import json
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import requests
from openpyxl import Workbook, load_workbook
from openpyxl.styles import PatternFill

class SKUEditor:
    def __init__(self, root):
        self.root = root
        self.root.title("SKU Map 編輯器")

        self.file_path = None
        self.sku_map = {}
        self.product_list = []
        self.product_lookup = {}

        self.selected_sku = None
        self.selected_item_index = None

        self.setup_ui()
        self.load_product_list_from_url()  # 程式啟動時自動載入產品列表

    def load_product_list_from_url(self):
        url = "https://wu:wu2266228@ec.zfun.com.tw/plist.json"
        try:
            response = requests.get(url)
            response.raise_for_status()  # 檢查 HTTP 狀態碼
            data = response.json()
            
            self.product_list = []
            self.product_lookup = {}
            for item in data:
                sku = str(item.get("sku", "")).strip()
                name = str(item.get("name", "")).strip()
                price = str(item.get("price", "")).strip()
                temp = str(item.get("temp", "")).strip()  # 取得溫層資訊
                # 如果 temp 不為空,則在產品名稱後加上 (溫層)
                if temp:
                    display = f"{name}({temp})"
                else:
                    display = name
                self.product_list.append((sku, display, price))
                self.product_lookup[sku] = {"name": name, "price": price, "temp": temp}
            self.update_sku_combobox("")
            messagebox.showinfo("成功", "成功從線上載入產品清單!")
        except Exception as e:
            messagebox.showerror("讀取產品清單失敗", f"無法從 {url} 載入資料:{str(e)}")
            self.product_list = []
            self.product_lookup = {}

    def update_sku_combobox(self, keyword):
        keywords = keyword.lower().split()
        filtered = []
        for sku, display, price in self.product_list:
            product_name = display.lower()
            if all(k in product_name for k in keywords):
                filtered.append(display)
        self.sku_combo["values"] = filtered

    def load_json(self, file_path):
        try:
            with open(file_path, "r", encoding="utf-8") as f:
                self.sku_map = json.load(f)
                self.file_path = file_path
                self.refresh_sku_list()
                self.tree.delete(*self.tree.get_children())
                messagebox.showinfo("成功", f"成功讀取:{file_path}")
        except Exception as e:
            messagebox.showerror("錯誤", f"讀取 JSON 檔案失敗:{e}")

    def save_json(self):
        if not self.file_path:
            messagebox.showwarning("未指定檔案", "請先讀取一個 JSON 檔案")
            return
        try:
            with open(self.file_path, "w", encoding="utf-8") as f:
                json.dump(self.sku_map, f, ensure_ascii=False, indent=4)
            messagebox.showinfo("成功", f"資料已儲存至 {self.file_path}")
        except Exception as e:
            messagebox.showerror("儲存失敗", str(e))

    def save_as_json(self):
        file_path = filedialog.asksaveasfilename(
            defaultextension=".json",
            filetypes=[("JSON Files", "*.json")],
            title="另存 JSON 檔案"
        )
        if not file_path:
            return
        try:
            with open(file_path, "w", encoding="utf-8") as f:
                json.dump(self.sku_map, f, ensure_ascii=False, indent=4)
            self.file_path = file_path  # 更新當前檔案路徑
            messagebox.showinfo("成功", f"JSON 檔案已儲存至 {file_path}")
        except Exception as e:
            messagebox.showerror("儲存失敗", f"無法儲存 JSON 檔案:{str(e)}")

    def import_from_xlsx(self):
        file_path = filedialog.askopenfilename(
            filetypes=[("Excel Files", "*.xlsx")],
            title="選擇 Excel 檔案"
        )
        if not file_path:
            return
        try:
            wb = load_workbook(file_path)
            ws = wb.active
            headers = [cell.value for cell in ws[1]]
            expected_headers = ["原始SKU", "新SKU", "產品名稱", "新數量", "新進價"]
            if not all(h in headers for h in expected_headers):
                messagebox.showerror("格式錯誤", "Excel 檔案缺少必要的欄位:原始SKU, 新SKU, 產品名稱, 新數量, 新進價")
                return
            self.sku_map = {}
            for row in ws.iter_rows(min_row=2, values_only=True):
                row_dict = dict(zip(headers, row))
                original_sku = str(row_dict["原始SKU"]).strip()
                new_sku = str(row_dict["新SKU"]).strip()
                quantity = str(row_dict["新數量"]).strip()
                price = str(row_dict["新進價"]).strip()
                # 驗證新SKU是否存在於 product_lookup
                if new_sku not in self.product_lookup:
                    messagebox.showwarning("警告", f"SKU {new_sku} 不存在於產品清單中,將被忽略")
                    continue
                # 建立資料結構,不包含產品名稱
                item = {
                    "新SKU": new_sku,
                    "新數量": quantity,
                    "新進價": price
                }
                if original_sku not in self.sku_map:
                    self.sku_map[original_sku] = []
                self.sku_map[original_sku].append(item)
            self.file_path = None  # 重置檔案路徑,因為這是新匯入的資料
            self.refresh_sku_list()
            self.tree.delete(*self.tree.get_children())
            messagebox.showinfo("成功", f"成功從 {file_path} 匯入資料")
        except Exception as e:
            messagebox.showerror("匯入失敗", f"無法匯入 Excel 檔案:{str(e)}")

    def export_to_xlsx(self):
        file_path = filedialog.asksaveasfilename(
            defaultextension=".xlsx",
            filetypes=[("Excel Files", "*.xlsx")],
            title="儲存 Excel 檔案"
        )
        if not file_path:
            return
        try:
            wb = Workbook()
            ws = wb.active
            ws.title = "SKU Map"
            # 寫入標頭
            headers = ["原始SKU", "新SKU", "產品名稱", "新數量", "新進價"]
            ws.append(headers)
            # 定義顏色列表 (淺色調,16進位格式,無需前綴"0x")
            colors = [
                "FFFACD",  # 淺檸檬色
                "E0FFFF",  # 淺青色
                "FFE4E1",  # 淺玫瑰色
                "E6E6FA",  # 淺薰衣草色
                "F0FFF0",  # 蜂蜜露色
            ]
            # 收集所有資料並按原始SKU排序
            data_rows = []
            for original_sku, items in sorted(self.sku_map.items()):
                for item in items:
                    new_sku = item["新SKU"]
                    product_info = self.product_lookup.get(new_sku, {})
                    product_name = product_info.get("name", "未知產品")
                    temp = product_info.get("temp", "")
                    display_name = f"{product_name}({temp})" if temp else product_name
                    quantity = item["新數量"]
                    price = item["新進價"]
                    data_rows.append([original_sku, new_sku, display_name, quantity, price])
            # 追蹤當前原始SKU和顏色索引
            current_sku = None
            color_index = 0
            for row_idx, row_data in enumerate(data_rows, start=2):  # 從第2行開始 (第1行是標頭)
                original_sku = row_data[0]
                # 如果原始SKU改變,切換到下一個顏色
                if original_sku != current_sku:
                    current_sku = original_sku
                    color_index = (color_index + 1) % len(colors)  # 循環使用顏色
                # 寫入資料行
                ws.append(row_data)
                # 應用顏色到整行
                fill = PatternFill(start_color=colors[color_index], end_color=colors[color_index], fill_type="solid")
                for col_idx in range(1, len(headers) + 1):
                    ws.cell(row=row_idx, column=col_idx).fill = fill
            # 儲存檔案
            wb.save(file_path)
            messagebox.showinfo("成功", f"Excel 檔案已匯出至 {file_path}")
        except Exception as e:
            messagebox.showerror("匯出失敗", f"無法匯出 Excel 檔案:{str(e)}")

    def setup_ui(self):
        top_frame = tk.Frame(self.root)
        top_frame.pack(fill=tk.X, pady=5)
        tk.Button(top_frame, text="讀取 JSON 檔案", command=self.select_json_file).pack(side=tk.LEFT, padx=5)
        tk.Button(top_frame, text="儲存 JSON", command=self.save_json).pack(side=tk.LEFT, padx=5)
        tk.Button(top_frame, text="另存 JSON", command=self.save_as_json).pack(side=tk.LEFT, padx=5)
        tk.Button(top_frame, text="匯入 Excel", command=self.import_from_xlsx).pack(side=tk.LEFT, padx=5)
        tk.Button(top_frame, text="匯出 Excel", command=self.export_to_xlsx).pack(side=tk.LEFT, padx=5)
        tk.Button(top_frame, text="重新載入產品清單", command=self.load_product_list_from_url).pack(side=tk.LEFT, padx=5)

        # 使用 PanedWindow 來讓左邊框架可以左右拖曳調整大小
        self.paned_window = tk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        self.paned_window.pack(fill=tk.BOTH, expand=True)

        self.left_frame = tk.Frame(self.paned_window)
        self.center_frame = tk.Frame(self.paned_window)
        self.right_frame = tk.Frame(self.paned_window)

        self.paned_window.add(self.left_frame, minsize=150)
        self.paned_window.add(self.center_frame, minsize=300)
        self.paned_window.add(self.right_frame, minsize=150)

        tk.Label(self.left_frame, text="搜尋原始 SKU").pack()
        self.sku_search_var = tk.StringVar()
        self.sku_search_entry = tk.Entry(self.left_frame, textvariable=self.sku_search_var)
        self.sku_search_entry.pack()
        self.sku_search_var.trace("w", lambda *args: self.search_sku())

        tk.Label(self.left_frame, text="原始 SKU").pack()
        self.sku_listbox = tk.Listbox(self.left_frame)
        self.sku_listbox.pack(fill=tk.BOTH, expand=True)
        self.sku_listbox.bind("<<ListboxSelect>>", self.on_sku_select)

        tk.Label(self.left_frame, text="➕ 新增原始 SKU").pack(pady=2)
        self.new_sku_entry = tk.Entry(self.left_frame)
        self.new_sku_entry.pack()
        tk.Button(self.left_frame, text="新增 SKU", command=self.add_new_sku).pack(pady=2)

        tk.Label(self.left_frame, text="✏️ 修改選取 SKU").pack(pady=2)
        self.rename_sku_entry = tk.Entry(self.left_frame)
        self.rename_sku_entry.pack()
        tk.Button(self.left_frame, text="修改 SKU 名稱", command=self.rename_sku).pack(pady=2)
        tk.Button(self.left_frame, text="❌ 刪除選取 SKU", command=self.delete_sku).pack(pady=5)

        tk.Label(self.center_frame, text="子項目").pack()
        self.tree = ttk.Treeview(self.center_frame, columns=("新SKU", "產品名稱", "新數量", "新進價"), show="headings")
        for col in ("新SKU", "產品名稱", "新數量", "新進價"):
            self.tree.heading(col, text=col)
            self.tree.column(col, width=100)  # 設定欄位寬度
        self.tree.pack(fill=tk.BOTH, expand=True)
        self.tree.bind("<<TreeviewSelect>>", self.on_item_select)

        # 總金額總和標籤
        self.total_label = tk.Label(self.center_frame, text="總金額總和:0")
        self.total_label.pack(pady=5)

        tk.Label(self.right_frame, text="編輯子項目").pack(pady=5)
        self.entries = {}

        tk.Label(self.right_frame, text="搜尋 SKU").pack()
        self.search_var = tk.StringVar()
        self.search_entry = tk.Entry(self.right_frame, textvariable=self.search_var)
        self.search_entry.pack()
        self.search_var.trace("w", lambda *args: self.update_sku_combobox(self.search_var.get()))

        self.sku_var = tk.StringVar()
        self.sku_combo = ttk.Combobox(self.right_frame, textvariable=self.sku_var)
        self.sku_combo.bind("<<ComboboxSelected>>", self.on_sku_selected)
        tk.Label(self.right_frame, text="新SKU").pack()
        self.sku_combo.pack()
        self.entries["新SKU"] = self.sku_combo

        for field in ("新數量", "新進價"):
            tk.Label(self.right_frame, text=field).pack()
            entry = tk.Entry(self.right_frame)
            entry.pack()
            self.entries[field] = entry

        tk.Button(self.right_frame, text="儲存子項修改", command=self.save_item).pack(pady=5)
        tk.Button(self.right_frame, text="新增子項", command=self.add_item).pack(pady=5)
        tk.Button(self.right_frame, text="刪除子項", command=self.delete_item).pack(pady=5)

    def search_sku(self):
        keyword = self.sku_search_var.get().lower()
        self.sku_listbox.delete(0, tk.END)
        for sku in self.sku_map.keys():
            if keyword in sku.lower():
                self.sku_listbox.insert(tk.END, sku)

    def select_json_file(self):
        file_path = filedialog.askopenfilename(
            filetypes=[("JSON Files", "*.json")],
            title="選擇 JSON 檔案"
        )
        if file_path:
            self.load_json(file_path)

    def refresh_sku_list(self):
        self.search_sku()  # Use search_sku to populate list with current search term

    def refresh_items(self):
        self.tree.delete(*self.tree.get_children())
        total_sum = 0.0  # 用浮點數計算總金額總和
        if self.selected_sku:
            for item in self.sku_map[self.selected_sku]:
                new_sku = item["新SKU"]
                # 從 product_lookup 中獲取產品名稱和溫層
                product_info = self.product_lookup.get(new_sku, {})
                product_name = product_info.get("name", "未知產品")
                temp = product_info.get("temp", "")
                # 如果 temp 不為空,則在產品名稱後加上 (溫層)
                if temp:
                    display_name = f"{product_name}({temp})"
                else:
                    display_name = product_name
                quantity = item["新數量"]
                price = item["新進價"]
                # 計算總金額,使用浮點數
                try:
                    total = float(quantity) * float(price)
                    total_sum += total
                except (ValueError, TypeError):
                    total = 0.0
                self.tree.insert("", tk.END, values=(new_sku, display_name, quantity, price))
        # 四捨五入總金額總和
        total_sum = round(total_sum)
        self.total_label.config(text=f"總金額總和:{total_sum}")

    def on_sku_select(self, event):
        try:
            index = self.sku_listbox.curselection()[0]
            self.selected_sku = self.sku_listbox.get(index)
            self.refresh_items()
        except IndexError:
            return

    def on_item_select(self, event):
        selected = self.tree.selection()
        if selected:
            self.selected_item_index = self.tree.index(selected[0])
            values = self.tree.item(selected[0], "values")
            self.sku_var.set(values[0])
            self.entries["新數量"].delete(0, tk.END)
            self.entries["新數量"].insert(0, values[2])
            self.entries["新進價"].delete(0, tk.END)
            self.entries["新進價"].insert(0, values[3])

    def on_sku_selected(self, event):
        selected = self.sku_var.get()
        # 從顯示格式中提取 SKU
        for sku, display, price in self.product_list:
            if display == selected:
                if sku in self.product_lookup:
                    price = self.product_lookup[sku]["price"]
                    self.entries["新進價"].delete(0, tk.END)
                    self.entries["新進價"].insert(0, price)
                break

    def get_clean_entry(self, key):
        if key == "新SKU":
            selected = self.entries[key].get()
            for sku, display, _ in self.product_list:
                if display == selected:
                    return sku
            return selected
        else:
            return self.entries[key].get()

    def save_item(self):
        if self.selected_sku is None or self.selected_item_index is None:
            return
        new_data = {key: self.get_clean_entry(key) for key in self.entries}
        self.sku_map[self.selected_sku][self.selected_item_index] = new_data
        self.refresh_items()

    def add_item(self):
        if self.selected_sku is None:
            return
        new_data = {key: self.get_clean_entry(key) for key in self.entries}
        self.sku_map[self.selected_sku].append(new_data)
        self.refresh_items()

    def delete_item(self):
        if self.selected_sku is None or self.selected_item_index is None:
            return
        del self.sku_map[self.selected_sku][self.selected_item_index]
        self.refresh_items()

    def add_new_sku(self):
        new_sku = self.new_sku_entry.get().strip()
        if not new_sku:
            return
        if new_sku in self.sku_map:
            messagebox.showwarning("已存在", "該 SKU 已存在")
            return
        self.sku_map[new_sku] = []
        self.refresh_sku_list()
        self.new_sku_entry.delete(0, tk.END)

    def rename_sku(self):
        new_name = self.rename_sku_entry.get().strip()
        if not self.selected_sku or not new_name:
            return
        if new_name in self.sku_map:
            messagebox.showwarning("名稱衝突", "新的 SKU 名稱已存在")
            return
        self.sku_map[new_name] = self.sku_map.pop(self.selected_sku)
        self.selected_sku = new_name
        self.refresh_sku_list()
        self.rename_sku_entry.delete(0, tk.END)

    def delete_sku(self):
        if not self.selected_sku:
            return
        del self.sku_map[self.selected_sku]
        self.selected_sku = None
        self.refresh_sku_list()
        self.tree.delete(*self.tree.get_children())


if __name__ == "__main__":
    root = tk.Tk()
    app = SKUEditor(root)
    root.mainloop()

📷 介面說明

  • 左側:原始 SKU 清單(可搜尋/新增/刪除/修改)。

  • 中間:子項列表(支援排序與檢視)。

  • 右側:編輯區(可選取 SKU、輸入數量與進價)。

  • 上方:功能按鈕列(檔案操作與產品清單管理)。


⚠️ 注意事項

  1. 產品清單來源需可連線,若讀取失敗,SKU 搜尋可能無法帶出產品名稱。

  2. Excel 格式必須正確,若缺少必要欄位將匯入失敗。

  3. 編輯完成後記得 手動儲存 JSON 或匯出 Excel,避免資料遺失。

  4. 子項輸入的數量與價格須為數字,否則計算總金額會失敗。


👨‍💻 維護資訊

  • 版本:1.1

  • 建立日期:2025-08-01

  • 負責人:日芳珍饌 技術部