# 📘 SKU Map 編輯器 使用說明

## 📖 簡介

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

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

---

## 🛠 系統需求

- Windows 10 / 11
- Python 3.10 以上版本
- 已安裝模組：
    
    
    - `tkinter`
    - `requests`
    - `openpyxl`

安裝缺少的套件：

```bash
pip install requests openpyxl
```

📂 程式結構

```bash
sku_editor_v1.1.py        # 本程式主檔
```

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

```bash
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 範例

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

### Excel 範例

<div class="_tableContainer_16hzy_1" id="bkmrk-%E5%8E%9F%E5%A7%8Bsku-%E6%96%B0sku-%E7%94%A2%E5%93%81%E5%90%8D%E7%A8%B1-%E6%96%B0%E6%95%B8%E9%87%8F--1"><div class="_tableWrapper_16hzy_14 group flex w-fit flex-col-reverse" tabindex="-1"><table class="w-fit min-w-(--thread-content-width)" data-end="1861" data-start="1672"><thead data-end="1711" data-start="1672"><tr data-end="1711" data-start="1672"><th data-col-size="sm" data-end="1680" data-start="1672">原始SKU</th><th data-col-size="sm" data-end="1687" data-start="1680">新SKU</th><th data-col-size="sm" data-end="1698" data-start="1687">產品名稱</th><th data-col-size="sm" data-end="1704" data-start="1698">新數量</th><th data-col-size="sm" data-end="1711" data-start="1704">新進價</th></tr></thead><tbody data-end="1861" data-start="1764"><tr data-end="1812" data-start="1764"><td data-col-size="sm" data-end="1774" data-start="1764">A123</td><td data-col-size="sm" data-end="1781" data-start="1774">B456</td><td data-col-size="sm" data-end="1793" data-start="1781">牛肉火鍋片(冷凍)</td><td data-col-size="sm" data-end="1802" data-start="1793">10</td><td data-col-size="sm" data-end="1812" data-start="1802">150</td></tr><tr data-end="1861" data-start="1813"><td data-col-size="sm" data-end="1823" data-start="1813">A123</td><td data-col-size="sm" data-end="1830" data-start="1823">C789</td><td data-col-size="sm" data-end="1842" data-start="1830">豬肉火鍋片(冷凍)</td><td data-col-size="sm" data-end="1851" data-start="1842">5</td><td data-col-size="sm" data-end="1861" data-start="1851">200</td></tr></tbody></table>

</div></div>## ⚙️ 程式原始碼重點

```python
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. 子項輸入的數量與價格須為數字，否則計算總金額會失敗。