# UpdRule 結構設計規格

> 目的：把 `UPDSTK.PRG` 裡「每發一版就要回來改、重編、再帶去客戶端」的更新動作，
> 全部抽成一份外部 **`UpdRule.json`**，隨更新包出貨。
> 以後發版只要**編輯這份 JSON**（或用 GUI 編輯器），更新程式 **永不重編、永不重帶**。

- 文件版本：v2
- 更新日期：2026-06-16
- 對應原始碼：`source\UpdateByJson.PRG`（新引擎）；資料來源 `source\UPDSTK.PRG`

---

## 1. 設計目標（使用者訴求）

1. **不動更新程式**：未來只改 JSON，不改 PRG、不重編。
2. **累積式更新**：一支十年沒更新的程式，可以**一次補完**這些年所有變更。
3. **三種變動都要涵蓋**：更改資料庫結構、更改資料庫內容、造字檔更新。
4. **徹底分開、單獨執行**：JSON 更新程式與 UpdStk32 完全分離，獨立 EXE。
5. **更新前先檢查**：必要的東西沒備齊就警告、不更新、離開。
6. **程式更新可選**：解壓 DDD.ZIP 做成勾選開關。

---

## 2. 架構：獨立 EXE `UpdJson32.exe`

| 檔案 | 角色 |
|------|------|
| **`UpdStk32.exe`** | 原版 CLASSIC 更新程式，**完全未改、零風險**。 |
| **`UpdJson32.exe`**（新、獨立） | JSON 驅動更新程式。進入點 `UpdStkJson()`，單獨執行。 |
| `source\main_updjson.prg` | UpdJson32 進入點（GTWVT 初始化 → `UpdStkJson()`）。 |
| `source\UpdateByJson.PRG` | JSON 引擎。 |
| `build\updjson32.hbp` | 獨立編譯設定，輸出 `UpdJson32.exe`。 |
| `source\UPDSTK.PRG` | **編譯期**重用其 helper（MessageBox/NetUse/HbWildCopy/HbUnzipAll…）；執行期 UpdJson32 完全獨立。 |

### 2.1 路徑不寫死（讀取路徑同 EXE）

- `stk.ini` / `System.ini`：`hb_DirBase()`（EXE 所在目錄）。
- 更新目錄優先序：**命令列參數 > 環境變數 `loc_upd` > EXE 所在目錄**（`ExeDir()`，無寫死 `c:\update`）。
- `UpdRule.json`、log 皆位於更新目錄（即 EXE 旁）。
- 理由：更新程式在客戶端就放在更新資料夾裡，用 `hb_DirBase()` 等同更新資料夾但不寫死任何磁碟路徑。

---

## 3. 動作類型（6 種，窮舉）

| # | 類型 | 做什麼 | 欄位 |
|---|------|--------|------|
| 1 | **struct** | 資料庫結構變動：用 `STR\別名.STR` 重建 + APPEND 舊資料 + 備份到 BAK | `alias`（或 `{alias,note}`） |
| 2 | **copy** | 複製檔案到客戶端（含造字檔） | `src` / `dest` / `note?` |
| 3 | **prgchk** | 往 PrgChk.DBF 補權限檢查（自動跑 01~04 四層級） | `prg/alias?/order/kind/comment/flags` |
| 4 | **field** | 改 DBF 欄位內容 | `alias/op/...參數/note?` |
| 5 | **reindex** | REINDEX | DBF 別名 |
| 6 | **remind** | 更新後跳訊息提醒人工後續 | 字串（或 `{msg,note}`） |
| 7 | **mergeset** | SET 版面檔逐 record 合併：只 append 客戶沒有的新 record，保留客戶自訂版面 | `alias` / `key`（唯一鍵欄位陣列）/ `note?` |

### 3.5 mergeset（SET 版面檔逐筆合併）

6 個版面檔（MAINFORM/SUBFORM1/MAINWIN/SUBWIN/DBFMWIN/DBFSWIN）使用者會自行調整版面，整檔覆蓋會洗掉。改成**逐 record 比對唯一鍵**，只把客戶沒有的新 record `APPEND` 進去，既有的完全不碰。

- **唯一鍵**：MAINFORM=`cAlias,cPriority,cFormat`；SUBFORM1=`+cOrder`；MAINWIN=`cKey,cId`；SUBWIN=`+cOrder`；DBFMWIN=`cAlias,cId`；DBFSWIN=`+cOrder`。
- **新版來源**：更新包 `setnew\<別名>.DBF`（由 `900\MAKEDDD.BAT` 從 `set\` 複製 DBF+CDX）。
- **掃描範圍**：各工作站 `S/A/B/C/D/E/P × 01-20` 的 `\SET\<別名>.DBF`（`DoMergeSet`/`MergeSetOne`，寫 `jsonmerge.txt`）。
- **★解壓排除保護**：DDD.ZIP 仍含這 6 檔，但 `RunProgramUpdate` 用 `UnzipExcept`（取代 HbUnzipAll）+ `CollectMergeAliases` 動態收集所有 mergeset 的 alias 當排除清單，解壓時 basename 在清單就 skip（log: `unzip SKIP (mergeset 保護)`），避免覆蓋掉 merge 結果。
- **僅 milestone 支援**：`RunMilestoneActions` 會派發 mergeset；`RunAlways` 不派發 → mergeset 必須放在 milestone（不可放 always）。
- JSON 形如 `"mergeset":[{"alias":"MainForm","key":["cAlias","cPriority","cFormat"]}]`。

### 3.1 copy 的 `dest`（封閉清單）

`DBF`（cLocDbf）／`SET_ALLSTATION`／`MEM_ALLSTATION`／`FRM_ALLSTATION`／`TMP_ALLSTATION`（各工作站 S/A/B/C/D/E/P×01-20 子目錄）／`SYS_ALLSTATION`（各工作站根目錄，造字檔 `*.??M`/`*.TBL`）。

### 3.2 field 的 `op`

`set`（全表設常數）／`copyzero`（搬欄位並歸零，`from`/`to`）／`addrec`（補記錄，`seek`/`set`）／`builtin`（化外函式，`name`，如 `CusInvMTotal`）。

### 3.3 prgchk 的 `flags`

字母對應布林欄：`E`=lExecute `A`=lAppend `M`=lModify `D`=lDelete `S`=lSeek `P`=lPrint。

### 3.4 改動說明（note）

struct/copy/field/remind 每項可帶 `note`（改動說明，例「新增 nRPDay N(6,0)」）。**引擎執行時忽略 note**，純供維護查閱；prgchk 以既有 `comment` 為說明。

---

## 4. milestone 依據 EXE（STK.EXE / STK32.EXE）

系統由 16-bit `STK.EXE` 遷移到 Harbour `STK32.EXE`，兩支檔案日期不同。每個 milestone 用 `exe` 欄標明它的日期要比對哪一支：

- **2026.05.31 之前** → `STK.EXE`
- **之後** → `STK32.EXE`

引擎啟動時讀兩支主程式日期（`s_dStk` / `s_dStk32`），`BasisDate(m)` 依 milestone 的 `exe` 取對應日期，再判斷 `對應日期 < milestone.date` 才執行。轉換器以 `2026.05.31` 門檻自動標、GUI 提供下拉選擇、新 milestone 預設 `STK32.EXE`。

---

## 5. UpdRule.json 結構

```jsonc
{
  "meta": { "note": "STK 累積更新規則表：發版只在 milestones 加一個物件" },

  "milestones": [
    {
      "date": "2008.03.13",
      "exe":  "STK.EXE",                    // 此日期比對哪支主程式
      "name": "客戶/廠商 帳齡欄位調整",
      "field": [ { "alias":"Customer", "op":"copyzero", "from":"nAccDay", "to":"nRPDay",
                   "note":"Customer.dbf 帳齡欄位調整" } ]
    },
    {
      "date": "2025.07.01",
      "exe":  "STK.EXE",
      "name": "新增名稱備註 + 造字檔",
      "struct": [ { "alias":"NameNote", "note":"新增名稱備註結構" } ],
      "copy":   [ { "src":"dbf\\NameNote.*", "dest":"DBF", "note":"新增名稱備註檔" } ],
      "prgchk": [ { "prg":"P9999","order":"0999","kind":"[Alt-X]","comment":"名稱備註維護","flags":"EAMDSP" } ],
      "remind": [ { "msg":"在每個工作站執行 W:\\FinalUpd.BAT", "note":"安裝後手動步驟" } ]
    }
    // 下次發版 → 在這裡貼一個新物件（exe 預設 STK32.EXE），PRG 永不動
  ],

  "always": {
    "program": { "enabled": true, "zip": "DDD.ZIP" },   // 程式更新開關（勾選才解壓）
    "copy":    [ { "src":"*.??M", "dest":"SYS_ALLSTATION", "note":"複製造字檔到各工作站" } ],
    "field":   [ { "alias":"NoSale", "op":"set", "set":{"cAlias":"Customer"} } ],
    "reindex": [ "PrgChk", "Rights" ]
  }
}
```

> 相容性：`struct` 項目可為字串 `"Customer"` 或物件 `{alias,note}`；`remind` 項目可為字串或 `{msg,note}`。引擎兩種皆吃。

---

## 6. 執行順序

```
0. 讀 stk.ini（EXE 目錄）、取 Stk.exe / Stk32.exe 兩支日期、載入 UpdRule.json
1. PreflightCheck         ← 更新前檢查：缺必要項目就警告、離開（不動資料）
2. CollectStruct + RunStruct   ← 結構重建（必須在欄位遷移之前）
3. RunProgramUpdate       ← 勾選才執行：HbUnzipAll(DDD.ZIP)，不用 16-bit DDD.EXE
4. RunMilestones          ← 各適用 milestone 的 copy / prgchk / field / remind
5. RunAlways              ← always 的 copy / field / reindex
6. ShowRemind             ← 彙整提醒一次顯示
```

「適用」判定：`BasisDate(m) < milestone.date`。十年沒更新 → 對應 EXE 日期很舊 → 該年代所有 milestone 一次補齊。

### 6.1 更新前檢查（PreflightCheck）

動任何資料前，檢查並列出缺漏；**有缺即警告 + 離開、完全不更新**：

1. 必要目錄 `cLocDbf` / `cLocTmp` 存在（`hb_DirExists`）。
2. **程式包 DDD.ZIP**：僅在「程式更新已勾選」時要求。
3. 適用 milestone 所需的 `STR\別名.STR` 結構檔。

> copy 來源缺漏**不封鎖**（`DoCopy` 本就略過並記 log）。

### 6.2 程式檔更新（不靠 16-bit DDD.EXE）

- DDD.EXE 自解壓只能在 16 位元環境執行，32/64 位元無法。
- 改用 Harbour 原生 `HbUnzipAll(DDD.ZIP)`（解壓前移除 Stk.exe/Stk32.exe 唯讀）。
- **可選**：`always.program.enabled`；不勾選則跳過整個程式更新（只做資料/結構更新，且 preflight 不要求 DDD.ZIP）。壓縮檔名由 `always.program.zip` 指定（預設 DDD.ZIP）。

---

## 6.3 auto / user 合併保留機制

`UpdRule.json` 同時容納兩種來源的 milestone，重建 baseline 時不互相覆蓋：

- 每個 milestone 標 `origin`：
  - `"auto"`：由轉換器 `_convert_to_json.py` 從 `UPDSTK.PRG` 機械式產生的歷史 baseline。
  - `"user"`：維護者在編輯器手動新增的（發版用）。
- **轉換器重跑**（dev 修 baseline 用）：讀舊檔 → 保留所有 `origin=="user"` milestone 與既有 `always` 設定 → 只重建 `auto` → 合併排序寫回；**重建前先時間戳備份**。
- **編輯器**：新增 milestone 自動標 `user`；存檔保留 origin、存檔前自動備份；清單以 ★ 標示 user。
- 規則：`UpdRule.json` 是唯一活檔（編輯/出貨）；轉換器是一次性 bootstrap、只動 auto；勿讓多份副本分歧後互相覆蓋。
- 注意：編輯 `auto` milestone 的內容，重跑轉換器會被 baseline 蓋回；要永久保留請改其 `origin` 為 `user`（或新增 user milestone）。

---

## 7. BIG5 編碼鐵律（已用 Harbour 實測）

- `hb_jsonDecode` 逐 byte 處理、非 BIG5-aware。BIG5 第二位元組可能是 `0x5C`（如 `功`=A55C）。
- **序列化時必須把 `0x5C` escape 成 `\\`、`0x22` escape 成 `\"`**，否則 `0x5C` 被當 escape 吃掉 → 解碼失敗（已實測：raw 功失敗、escaped 功正確）。
- 驗證/讀取要 **latin-1 逐 byte parse**（不可先 big5 decode），取得的字串再 `encode('latin-1').decode('big5')` 還原中文。
- 轉換器與 GUI 編輯器皆遵守此規則，使用者只看到正常中文。

---

## 8. 工具與檔案

| 檔案 | 用途 |
|------|------|
| `build\UpdJson32.exe` | 獨立 JSON 更新程式 |
| `build\UpdRule.json` | 更新規則（76 milestones：269 struct / 20 copy / 56 prgchk / 9 field / 11 remind） |
| `build\updrule_editor.py` | PyQt5 編輯介面（BIG5-byte-safe；milestone + 各動作分頁 + 改動說明 + 依據EXE + 程式更新勾選） |
| `build\_convert_to_json.py` | 由 UPDSTK.PRG 機械式產生初始 UpdRule.json（可重跑） |
| `build\_gen_updatebyjson.py` | 由生成器產出 UpdateByJson.PRG（BIG5） |

### 使用編輯器

```
python updrule_editor.py [UpdRule.json]
```
左側 milestone 清單（顯示日期 [16]/[32] 名稱）；右側日期/依據EXE/名稱 + 五個動作分頁；工具列有「更新程式檔(解壓)」勾選與壓縮檔名。發版 = 新增 milestone → 填內容 → 存檔（自動依日期排序、BIG5 寫回）。

---

## 9. 待辦

- HotKey 特殊字 builtin（`HotKeySpecialChars`，含造字位元組）尚未實作，目前 log TODO 不中斷。
- struct 各項「改動說明」源碼無逐項資料（結構在 .STR），需日後於 GUI 補。
- step 7 實機回歸：UpdJson32 對真實舊版資料跑，與 CLASSIC UpdStk32 逐項比對。
- ~~mergeset 四項待辦（MAKEDDD/JSON/GUI/端到端實測）~~ → 2026.06.25 22:33 完成（headless harness 真實引擎實測全 PASS，見 `doc\mergeset_e2e\`、UpdateList）。剩客戶端部署同步（UpdRule.json + setnew + DDD.ZIP → C:\update / \\s001\update）。
