📘 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、輸入數量與進價)。
-
上方:功能按鈕列(檔案操作與產品清單管理)。
⚠️ 注意事項
-
產品清單來源需可連線,若讀取失敗,SKU 搜尋可能無法帶出產品名稱。
-
Excel 格式必須正確,若缺少必要欄位將匯入失敗。
-
編輯完成後記得 手動儲存 JSON 或匯出 Excel,避免資料遺失。
-
子項輸入的數量與價格須為數字,否則計算總金額會失敗。
👨💻 維護資訊
版本:1.1建立日期:2025-08-01負責人:日芳珍饌 技術部