Compare commits

...

606 Commits

Author SHA1 Message Date
twsystem1004
777ae82833 [RELEASE] demo -> main: 銷售紀錄付款狀態預設「已完成」+ 新增「待領取」
1. 付款狀態下拉預設改為「已完成」,避免客戶把 failed(付款失敗/逾時) 誤會成刷卡失敗;
   「所有付款狀態」改為明確選項 value=all,選它才顯示全部。
2. 新增 awaiting_pickup(待領取) 至下拉選項與各狀態顯示(桌機/手機徽章、明細面板、匯出標籤)。
2026-07-17 01:21:27 +00:00
twsystem1004
2bdad21810 [PROMOTE] dev -> demo: 銷售紀錄付款狀態預設「已完成」+ 新增「待領取」
1. 付款狀態預設「已完成」(避免 failed 被誤會成刷卡失敗);「所有付款狀態」改為 value=all 明確選項
2. 新增 awaiting_pickup(待領取) 至下拉與各狀態顯示(徽章/明細面板/匯出標籤)
2026-07-17 01:16:39 +00:00
twsystem1004
42ec18188e feat(銷售紀錄): 付款狀態預設「已完成」+ 新增「待領取」狀態
1. 付款狀態下拉預設改為「已完成」,避免客戶把 failed(付款失敗/逾時) 誤會成刷卡失敗;
   「所有付款狀態」改為明確選項 value=all(非空值,AJAX 才不會被當空值丟掉),選它才顯示全部。
2. 新增 awaiting_pickup(待領取,領藥單已生成未領取) 至下拉選項與各狀態顯示:
   桌機/手機徽章(amber,原本無對應會顯示原始字串)、訂單明細面板、匯出狀態標籤。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:16:14 +00:00
twsystem1004
c4d34926a6 [RELEASE] demo -> main: 機台庫存匯出 Excel 加「商品ID」欄
1. 機台庫存概覽批次匯出與單機台詳細匯出,欄位新增「商品ID」(貨道號後、商品名稱前)
2026-07-14 05:20:15 +00:00
twsystem1004
acb888c2d1 [PROMOTE] dev -> demo: 機台庫存匯出 Excel 加「商品ID」欄
1. 機台庫存概覽批次匯出與單機台詳細匯出,欄位新增「商品ID」(貨道號後、商品名稱前)
2026-07-14 05:16:51 +00:00
twsystem1004
168a990a7e feat(倉庫): 機台庫存匯出 Excel 加「商品ID」欄
機台庫存概覽批次匯出(多分頁)與單機台詳細匯出的欄位,於貨道號後、商品名稱前
新增「商品ID」欄;空貨道商品ID留空。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 05:16:29 +00:00
twsystem1004
07684bbb90 [RELEASE] demo -> main: 機台貨道明細匯出 + 出貨失敗補償取貨碼 + 機台庫存概覽批次匯出
1. 機台貨道明細匯出(單機台):機台詳細視圖矩陣/表格旁「匯出」下拉(CSV/Excel)
2. 出貨失敗補償取貨碼:銷售紀錄「取貨碼」欄,付款成功但出貨失敗的單「+」→確認→產碼(七天有效)→待使用
3. 機台庫存概覽批次匯出:列表頁「匯出」鈕→全選/勾選→Excel 多分頁(每台一分頁)
2026-07-14 03:10:38 +00:00
twsystem1004
3e2069e099 [PROMOTE] dev -> demo: 機台庫存概覽批次匯出 Excel(多分頁)
1. 機台庫存概覽列表頁「匯出」鈕→全選/逐台勾選→匯出 xlsx,每台一分頁、內容為該機台每貨道明細(CSV 前端先隱藏、後端保留)
2026-07-14 02:46:30 +00:00
twsystem1004
34b0a7faf6 feat(倉庫): 機台庫存概覽批次匯出 Excel(多分頁)
機台庫存概覽列表頁新增「匯出」鈕,跳出可全選帳號下全部機台或逐台勾選的視窗,
匯出成 Excel(xlsx)——每台機台一個分頁(sheet),內容為該機台每貨道明細
(貨道號/商品/條碼/類型/庫存/容量/庫存率/效期/批號/啟用/鎖定)。
用 OpenSpout 寫多分頁、動態 <a download> 觸發(不卡霧面)。
CSV 後端保留(每台一檔打包 zip)但前端按鈕先隱藏。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 02:46:08 +00:00
twsystem1004
4494dc60fb [PROMOTE] dev -> demo: 機台貨道明細匯出 + 出貨失敗補償取貨碼
1. 倉庫→機台庫存概覽:機台詳細視圖矩陣/表格旁加「匯出」下拉(CSV/Excel),匯出該機台每貨道明細
2. 銷售紀錄新增「取貨碼」欄:付款成功但出貨失敗的單顯示「+」,確認後依缺量產生取貨碼(七天有效、待使用標示)
2026-07-13 08:03:31 +00:00
twsystem1004
83e1beb7e2 feat(倉庫/銷售): 機台貨道明細匯出 + 出貨失敗補償取貨碼
1. 倉庫→機台庫存概覽:點進機台後,矩陣/表格視圖旁加「匯出」下拉(CSV/Excel),
   內容為該機台每個貨道明細(貨道號/商品/庫存/容量/庫存率/效期/批號等)。
2. 銷售紀錄新增「取貨碼」欄位:對「付款成功但出貨失敗/部分」的單顯示「+」,
   點擊確認「是否產生取貨碼(七天內有效)」後,依缺量產生取貨碼(綁機台+商品、
   usage_limit=缺量、冪等 batch_no=COMP{id}、不設 order_id),顯示碼+「待使用」。
   消費者於機台輸入碼走現成 B660 核銷出貨,app 端不需改。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:00:50 +00:00
twsystem1004
09f39af923 [RELEASE] demo -> main: 商品管理匯出(CSV/Excel)
1. feat(商品管理): 資料設定→商品管理新增「匯出」下拉(CSV/Excel),套當前篩選、欄位對齊匯入範本;匯入/匯出移至查詢/重設右邊;匯出連結加 download 屬性修正下載後畫面卡霧面
2026-07-10 02:12:13 +00:00
twsystem1004
650e2412da [PROMOTE] dev -> demo: 商品管理匯出(CSV/Excel)
1. feat(商品管理): 資料設定→商品管理新增「匯出」下拉(CSV/Excel),套當前篩選、欄位對齊匯入範本;匯入/匯出移至查詢/重設右邊;匯出連結加 download 屬性修正下載後畫面卡霧面
2026-07-10 02:04:33 +00:00
twsystem1004
1d745c5722 feat(商品管理): 新增商品匯出(CSV/Excel)
資料設定→商品管理新增「匯出」下拉(CSV/Excel),套用當前篩選(搜尋/分類/公司),
欄位順序對齊匯入範本方便回匯入。匯入鈕與匯出下拉移至查詢/重設右邊。
匯出連結加 download 屬性,避免觸發全域導覽遮罩導致下載後畫面卡霧面。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 02:04:11 +00:00
twsystem1004
c08fa55e05 [RELEASE] demo -> main: 機台設定金流配置防呆
1. feat(機台設定): 金流配置未選(維持「不使用」)時,機台編輯頁按「儲存變更」跳原生 confirm 二次確認;純前端提醒,後端 payment_config_id 仍 nullable
2026-07-09 02:26:48 +00:00
twsystem1004
cb8a805a21 [PROMOTE] dev -> demo: 機台設定金流配置防呆
1. feat(機台設定): 金流配置未選(維持「不使用」)時,機台編輯頁按「儲存變更」跳原生 confirm 二次確認;純前端提醒,後端 payment_config_id 仍 nullable
2026-07-08 05:52:15 +00:00
twsystem1004
d8f549d390 feat(機台設定): 金流配置防呆確認
機台編輯頁「金流與發票」區塊,若金流配置維持「不使用」(未選),
按「儲存變更」時跳出原生 confirm 提示,確認才送出。純前端提醒,
後端 payment_config_id 仍為 nullable。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:51:25 +00:00
twsystem
d1a82a40fa chore: 重新觸發正式部署(已修好 runner node PATH) 2026-07-06 02:50:38 +00:00
twsystem
c2d289c92a [RELEASE] demo -> main: 取貨碼/通行碼/銷售紀錄後台 + 現金支付面額明細(收各面額張數+找零) 2026-07-06 02:40:58 +00:00
twsystem1004
09ebf911ed [PROMOTE] dev -> demo: 現金支付面額明細(收各面額張數+找零)回傳並於銷售紀錄顯示(含 dev 累積:取貨碼/通行碼銷售紀錄等) 2026-07-03 04:44:17 +00:00
twsystem1004
be73e7c7a0 feat(sales): 現金支付面額明細(收各面額張數+找零)回傳並於銷售紀錄顯示
- orders 新增 cash_detail JSON 欄(migration)
- Order model fillable/casts + cash_received_summary accessor
  (顯示「收:總額(只列有收到幣別明細) 找:x」)
- TransactionService create/transition 兩處寫入 cash_detail
- 銷售列表 blade 桌機/手機各加一列現金明細

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 04:35:15 +00:00
terrylee
dc9421e482 取貨碼/通行碼/銷售紀錄 後台功能
- 取貨碼使用紀錄記實際出貨貨道(PickupCodeLog.raw_data.slot)
- 銷售紀錄訂單列(支付金額欄)顯示取貨碼序號(member_barcode)
- 遠端指令中心「遠端出貨」→「遠端開櫃」(zh_TW.json Remote Dispense)
- 訂單品項記消費者選的貨道(order_items.slot_no + migration),失敗交易也看得到;
  訂單明細顯示 Slot;OrderItem/TransactionService 對應
- 通行碼「只能使用一次」:pass_codes.usage_limit/usage_count + migration;
  建立表單加勾選;B670(verifyPassCode)驗證時消耗、達上限標 used;PassCode.isValid 判斷

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 03:11:24 +00:00
1602895d77 chore: 重新觸發正式部署(demo→main 內容已於 6ed11bf 合併,惟 deploy-production 未自動觸發)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:02:28 +08:00
6ed11bf858 [RELEASE] demo -> main: 取貨碼/通行碼批次新增(≤1000)+CSV下載+角色數量分級+cache表+取貨頁/下載修正 2026-06-29 15:40:43 +08:00
e2281fa425 [PROMOTE] dev -> demo: 取貨碼商品下拉/公開取貨頁顯示商品 + 批次下載不卡畫面(修正) 2026-06-29 15:34:28 +08:00
ce9e52efd9 fix(sales): 批次下載 CSV 連結加 download 屬性,避免觸發全域導航遮罩卡住畫面
全域連結攔截器(layouts/admin)點 <a href> 會顯示導航載入遮罩,但檔案下載不換頁、
遮罩不會關閉,導致畫面卡在模糊+進度條停在~95%。加 download 屬性即可被攔截器豁免。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:27:38 +08:00
d4d61461fe fix(pickup): 取貨碼商品下拉 placeholder 改「選擇商品」+ 公開取貨頁正確顯示綁定商品
1. 取貨碼新增彈窗的商品下拉沿用了貨道設定的 placeholder(顯示「選擇貨道」),
   改為「選擇商品」並補 Search Product... 多語系。
2. 公開取貨憑證頁(/p/{slug})原本只用 slot_no 對應商品,綁商品的碼 slot_no 為
   null 導致顯示「未知商品」;改為優先用 product_id 綁定的商品,貨道欄無值顯示「—」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:18:40 +08:00
d90621f8c7 [PROMOTE] dev -> demo: 取貨碼/通行碼批次新增(≤1000)+CSV下載+角色數量分級+cache表migration 2026-06-29 14:41:02 +08:00
330b81ab7b [FEAT] 合併 feat/code-batch-create:取貨碼/通行碼批次新增(≤1000)+CSV下載+角色數量分級+cache表migration 2026-06-29 14:37:32 +08:00
3785026749 chore(db): 補上 cache / cache_locks 表 migration
dev 的修正 55cf6b8 將 CACHE_STORE 正確解析為 database,但 repo 未含 cache 表
migration。補上標準 Laravel cache 表(含 hasTable 防呆),確保各環境 pull 後
database 快取可用,避免 ProcessHeartbeat 的 Cache::put 因缺表崩潰。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:33:43 +08:00
179348ad66 feat(sales): 批次產碼數量上限依角色分級(客戶端最多20、管理員最多1000)
- 平台管理員(isSystemAdmin):數量上限 1000,快捷 10/50/100/1000
- 客戶端(租戶):數量上限 20,快捷 10/20,欄位輸入超過自動夾回 20
- 後端 store 以角色動態 max 驗證,防止前端被繞過送出大量產碼
- 取貨碼/通行碼兩表單同步;前端 max 與快捷選項由後端依角色下發

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:24:47 +08:00
9dbde50f56 fix(sales): 取貨碼新增彈窗「產生數量」改為獨立整列,避免與使用次數上限重疊
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:10:13 +08:00
6bb08a13ce feat(sales): 取貨碼/通行碼批次新增(最多1000筆)+ 批次清單 CSV 下載
- 取貨碼沿用「商品版」綁定(product_id,相容舊 slot_no)批次產碼
- 通行碼批次產碼;名稱自動加 #1..#N 流水
- 新增 batch_no 欄位標記同一批,提供批次下載路由與 UTF-8 BOM CSV
- 數量>1 時忽略自訂碼、系統產生不重複隨機碼;單筆行為不變
- 產生後顯示可重複下載清單橫幅;新增 zh_TW/en/ja 翻譯鍵

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:00:57 +08:00
sky121113
65f8ce2d37 [RELEASE] demo -> main: 取貨碼改綁商品
1. [FEAT] 取貨碼改綁「商品」而非「貨道」:發碼時指定機台+商品,刷碼當下由 App 依即時庫存挑可出貨道。
2. 新增 pickup_codes.product_id 欄位 migration(nullable + index,向後相容綁貨道碼)。
3. 產碼端點驗證商品屬機台所屬公司,新增公司商品清單 AJAX 與「機台未上架」前端提示。
4. B660 刷碼新增綁商品擋關(無貨道回 409 並寫 log),回傳補 product_id 供 App 自挑貨道。
5. 取貨碼列表改以商品為主顯示:綁商品碼直接顯示商品名、不再顯示空貨道;綁貨道碼(舊)維持反查。
6. 多語系三語系同步新增詞彙。

⚠️ 部署後須於正式站執行 migrate 建立 product_id 欄位。
2026-06-26 17:49:36 +08:00
sky121113
1ace7da140 [PROMOTE] dev -> demo: 取貨碼列表以商品為主顯示
1. [FIX] 取貨碼列表改以商品為主:綁商品碼直接顯示商品名、不再顯示空貨道;綁貨道碼(舊)維持反查商品名並保留貨道號。
2. pickupCodes 查詢補 product eager load 避免 N+1,標題改為「商品」。
2026-06-26 17:42:47 +08:00
sky121113
2dd14fd1b4 [FIX] 取貨碼列表改以商品為主、綁商品碼不再顯示空貨道
1. pickupCodes 查詢補 product eager load,避免 N+1。
2. 列表商品欄改依綁定型態顯示:綁商品碼直接顯示商品名且不顯示貨道;綁貨道碼(舊)維持反查商品名並保留貨道號。
3. 桌面表格標題「商品 / 貨道」改為「商品」,手機卡片同步調整。
2026-06-26 17:42:21 +08:00
sky121113
1404d40f71 [PROMOTE] dev -> demo: 取貨碼改綁商品
1. [FEAT] 取貨碼改綁「商品」而非「貨道」:發碼時指定機台+商品,刷碼當下由 App 依即時庫存挑貨道。
2. 新增 pickup_codes.product_id 欄位 migration(nullable + index,向後相容綁貨道碼)。
3. 產碼端點驗證商品屬機台所屬公司,新增公司商品清單 AJAX 與「機台未上架」前端提示。
4. B660 刷碼新增綁商品擋關(無貨道回 409 並寫 log)。
5. 多語系三語系同步新增 4 組詞彙。

⚠️ 部署後需執行 migrate 建立 product_id 欄位。
2026-06-26 17:32:45 +08:00
sky121113
0c29cdae97 [FEAT] 取貨碼改綁「商品」而非「貨道」
1. 新增 migration 為 pickup_codes 增加 product_id 欄位(nullable + index),既有綁貨道碼向後相容。
2. PickupCode Model 補上 product_id fillable 與 product() 關聯。
3. 產碼 storePickupCode 改收 product_id(與 slot_no 二擇一,required_without),並驗證商品屬於機台所屬公司;機台未上架仍可產碼。
4. 新增 pickupCompanyProducts AJAX 端點與路由,回傳公司商品清單與機台已上架 product_id,供前端標示「此機台未上架」提示。
5. B660 刷碼驗證新增綁商品擋關:機台無此商品貨道時回 409 並寫 PickupCodeLog;回傳資料補上 product_id 供 App 自挑貨道。
6. 產碼介面(pickup-codes/index)由「選擇貨道」改為「選擇商品」,加入未上架警告提示。
7. 多語系同步新增 4 組詞彙(zh_TW/en/ja 對齊排序)。
2026-06-26 17:31:22 +08:00
sky121113
aa4244b16d [RELEASE] demo -> main: 銷售紀錄手動補單功能上正式站
1. 手動補單(機台斷線/漏報時人工補登銷售紀錄):複用扣庫存與後台開發票邏輯,訂單標記 manual、flow_id 以 MAN 前綴與機台上報區隔。
2. 補單彈窗採站內元件樣式(搜尋式下拉 + +/- 計數器);扣庫存與開立電子發票皆為選配,開發票受機台電子發票閘門限制。
3. 權限限系統管理員(前端隱藏 + 後端把關),全程寫入操作審計日誌;訂單列表顯示「手動」標籤;三語系字串補齊。
4. 夾帶既有修正:config/cache.php cache driver 設定 key 不一致導致 fallback 成 file。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:37:59 +08:00
sky121113
ef1bdfe1d5 [PROMOTE] dev -> demo: 銷售紀錄手動補單功能
1. 手動補單(機台斷線/漏報時人工補登銷售紀錄):複用扣庫存與後台開發票邏輯,訂單標記 manual、flow_id 以 MAN 前綴區隔。
2. 補單彈窗採站內元件樣式(搜尋式下拉 + +/- 計數器),扣庫存與開立電子發票皆為選配;開發票受機台電子發票閘門限制。
3. 權限限系統管理員(前端隱藏 + 後端把關),全程寫入操作審計日誌。
4. 訂單列表顯示「手動」標籤;三語系字串補齊對齊。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:19:47 +08:00
sky121113
8a92e1c1ff [FEAT] 銷售紀錄手動補單功能(機台斷線/漏報時人工補登)
1. 新增 TransactionService::createManualOrder():複用既有 recordDispense()(扣庫存)與 recordInvoice()+IssueInvoiceJob(後台開發票),訂單標記 order_type=manual、created_by 操作者,flow_id 以 MAN 前綴與機台上報區隔並天然避開唯一鍵衝突。
2. Order 新增 TYPE_MANUAL 常數與 is_manual 存取器;created_at/machine_time 對齊實際發生時間,使銷售報表落在正確日期。
3. SalesController 新增 storeManualOrder()(建單)與 manualMachineSlots()(機台貨道 AJAX);發票類型支援統編/手機條碼/自然人憑證/捐贈/一般 B2C,並寫入 SystemOperationLog 審計留痕。
4. 權限:整個補單功能限系統管理員(按鈕、彈窗、AJAX、store 四處把關);開立電子發票受機台 tax_invoice_enabled 閘門限制,前端隱藏選項 + 後端再驗一次。
5. 新增路由 admin.sales.manual.store 與 admin.sales.manual.machine-slots。
6. 前端:銷售訂單頁籤新增「手動補單」按鈕(僅系統管理員),slide 彈窗全面採用站內元件樣式——下拉用搜尋式 x-searchable-select(機台/貨道/付款/發票類型)、價格與數量改用「新增取貨碼」同款 +/- 計數器、去除原生 number spinner。
7. 訂單列表(桌面與行動卡片)為手動補單顯示「手動」標籤。
8. 三語系(zh_TW/en/ja)補齊 35 組字串並對齊排序、斜線不逸出。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:17:54 +08:00
sky121113
7499c42878 [PROMOTE] dev -> demo: cache driver 設定修正
1. [FIX] config/cache.php cache driver 設定 key 不一致修正:default 改為 env('CACHE_STORE', env('CACHE_DRIVER','database')),避免 fallback 成 file driver 導致 ProcessHeartbeat 在 Cache::put 崩潰、機台版本/溫度/心跳寫不進 DB
2. [FIX] 同步 hotfix:銷售紀錄機台權限隔離(內容與 demo 對齊)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:45:22 +08:00
sky121113
55cf6b8331 [FIX] 修正 cache driver 設定 key 不一致導致 fallback 成 file
1. config/cache.php 的 default 由 env('CACHE_DRIVER','file') 改為 env('CACHE_STORE', env('CACHE_DRIVER','database')),優先採用 Laravel 11+ 的 CACHE_STORE 並相容舊 key
2. 修正主因:舊 key 與 .env 的 CACHE_STORE 對不上 → cache 退回 file driver;file cache 寫入失敗時 ProcessHeartbeat 會在 Cache::put 崩潰,導致機台韌體版本/溫度/心跳寫不進 DB 且 queue worker 連鎖重啟

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:14:16 +08:00
sky121113
5a4d681d13 [FIX] 同步 hotfix:銷售紀錄機台權限隔離
訂單/發票/出貨清單依使用者可存取機台過濾。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:06:17 +08:00
sky121113
1722a192a8 [FIX] 同步 hotfix:銷售紀錄機台權限隔離
訂單/發票/出貨清單依使用者可存取機台過濾。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:06:03 +08:00
sky121113
1aef703882 [HOTFIX] prod: 銷售紀錄機台權限隔離修復
1. 訂單/發票/出貨清單依使用者可存取機台過濾,修正無機台權限帳號仍可見其交易的資料隔離漏洞
2. 純 controller 邏輯,無 migration

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:05:49 +08:00
sky121113
5e0bfeb544 [FIX] 銷售紀錄機台權限隔離:訂單/發票/出貨清單依使用者可存取機台過濾
1. SalesController index 先前 orders/invoices/dispense 查詢只靠 TenantScoped 做 company 隔離,未依機台授權過濾;導致無該機台權限的子帳號仍能看到其交易訂單(機台名稱因 machine_access scope 顯示 unknown,但訂單仍列出)
2. 修正:非系統管理員時,三個查詢一律限制 machine_id 在「可存取機台集合」(沿用 Machine 的 machine_access 全域 scope,與機台下拉清單一致);export 共用同一 query 亦一併涵蓋
3. 新增 SalesMachineAccessTest(3 案,Admin 套件 58 測試 0 回歸)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:03:57 +08:00
sky121113
be9c70d524 [RELEASE] demo -> main: 帳號與角色多層級隔離上正式站
1. 帳號與角色多層級隔離:主/子帳號樹狀 (parent_id/level)、主帳號唯一性 (is_admin 補位/禁刪最後主帳號)、角色權限與機台授權子集把關、角色層級隔離 (created_by)
2. 含 3 支具 backfill 的 migration(is_company_admin / parent_id+level / created_by):部署後須跑 migrate;會將既有非主帳號設為主帳號之下層、軟刪除帳號清 is_admin、公司角色歸屬主帳號
3. 機台日誌層級篩選 + 銷售紀錄付款/出貨狀態篩選

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:30:24 +08:00
sky121113
146dac4493 [PROMOTE] dev -> demo: 帳號與角色多層級隔離 + 機台日誌/銷售篩選
1. 帳號與角色多層級隔離:主/子帳號樹狀 (parent_id/level)、主帳號唯一性 (is_admin 補位/禁刪最後主帳號)、角色權限與機台授權子集把關、角色層級隔離 (created_by)。含 3 支 migration(is_company_admin / parent_id+level / created_by,皆具 backfill,部署後須跑 migrate)
2. 機台日誌層級篩選 + 銷售紀錄付款/出貨狀態篩選 + TransactionService 金額修正

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:11:31 +08:00
sky121113
697c52eba3 [FEAT] 合併帳號與角色多層級隔離 (feat/account-hierarchy-rbac)
主/子帳號樹狀層級、角色權限與機台授權子集把關、角色層級隔離。
詳見 feature 分支 commit 2016637。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:04:40 +08:00
sky121113
201663741d [FEAT] 帳號與角色多層級隔離(主/子帳號樹狀 + 角色權限/機台授權子集把關)
1. 主帳號不變量:storeAccount 的 is_admin 改補位(每家公司恰一主帳號)、destroyAccount 禁刪最後主帳號、roles 新增 is_company_admin 旗標保護公司管理員角色不被刪除
2. 帳號樹狀層級:users 新增 parent_id/level(主帳號 level 0、子帳號最多兩層),新增 visibleTo/canManageAccount,帳號清單與管理端點(accounts/update/destroy/toggle、機台授權選帳號、補貨指派)一律套層級可見範圍與越權防護
3. 角色指派子集:storeAccount/updateAccount 指派角色與帳號 Modal 下拉一律限制為「權限為操作者子集」,杜絕權限提升
4. 機台授權子集:MachinePermissionController 僅允許在操作者本身可授權機台範圍內增刪,保留範圍外既有授權,並修正 machine_access 全域 scope 對讀寫的干擾
5. 角色層級隔離:roles 新增 created_by,子帳號只看/只管/只能指派自己建立的角色(主帳號看全公司),edit/update/destroy 加 canBeManagedBy 守衛
6. 補強:MachineSetting 機台權限人員清單補租戶隔離、修正角色下拉 N+1、三語系新增提示字串
7. 測試:新增 AccountHierarchyTest/AccountSubsetGuardTest/RoleHierarchyTest,Admin 套件 55 測試 0 回歸

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:03:43 +08:00
terrylee
b77165d7b3 [RELEASE] demo -> main:副櫃系統授權+交易total_amount修正+demo累積功能上正式 2026-06-25 09:01:10 +00:00
terrylee
41e9b0d57c [PROMOTE] dev -> demo: 副櫃系統授權開關 + 交易total_amount修正(含dev累積變更) 2026-06-25 08:24:05 +00:00
twsystem1004
ee22ff9e6d [FIX] finalize 轉移未更新 total_amount 導致銷售金額顯示0
transitionOrder(pending→completed) 原只更新 pay_amount,補上 total_amount/original_amount/discount_amount,避免後台銷售紀錄金額停在建立時的0。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:11:36 +00:00
twsystem1004
7ba6d9ebe6 [FEAT] 副櫃系統(格子櫃功能)授權開關
機台設定-基礎版新增「副櫃系統/格子櫃功能」開關,存 settings.subcabinet_enabled(僅basic);B014 FunctionSet 新增 Subcabinet 下發給 app 控制副櫃分頁/鎖控板設定顯示;啟用功能摘要列出副櫃系統。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:11:36 +00:00
sky121113
16a8ee7986 [FEAT] 合併機台日誌層級篩選與銷售紀錄付款/出貨狀態篩選
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:07:05 +08:00
sky121113
06d68a2783 [FEAT] 合併機台日誌層級篩選與銷售紀錄付款/出貨狀態篩選
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:06:42 +08:00
sky121113
7739d6016f [FEAT] 合併機台日誌層級篩選與銷售紀錄付款/出貨狀態篩選
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:06:15 +08:00
sky121113
f571f99084 [FEAT] 機台日誌層級篩選與銷售紀錄付款/出貨狀態篩選
1. 機台日誌新增「層級」篩選下拉(所有層級/Info/Warning/Error),採 x-searchable-select 元件,選擇即時重撈,清除篩選一併重置
2. 銷售紀錄訂單分頁新增「付款狀態」篩選(對應 status 欄)
3. 銷售紀錄訂單分頁新增「出貨狀態」篩選(delivery_status),比照列表顯示邏輯限定 completed/paid 訂單,避免未出貨單因預設值誤篩
4. 補齊三語系 key:All Payment Statuses、All Dispense Statuses、Paid,並對齊排序

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:05:45 +08:00
sky121113
a5d9aace56 [FIX] 合併取貨碼 status fillable 修復 2026-06-24 16:03:23 +08:00
sky121113
fb71ecddaf [FIX] 合併取貨碼 status fillable 修復 2026-06-24 16:03:18 +08:00
sky121113
2736c382a2 [FIX] 合併取貨碼 status fillable 修復 2026-06-24 16:03:14 +08:00
sky121113
de2f098309 [FIX] 取貨碼無法刪除:status 未列入 fillable 導致取消無效
1. PickupCode $fillable 補上 status,原本 destroyPickupCode 的 update(['status'=>'cancelled']) 因 mass-assignment 保護被靜默忽略,導致取貨碼按刪除(取消)後狀態不變、永遠刪不掉(超級管理員亦然)
2. PassCode/WelcomeGift 的 fillable 本就含 status 不受影響,故僅取貨碼有此問題

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:03:01 +08:00
sky121113
7044d4cefd [FIX] 合併取貨碼/通行碼/來店禮租戶機台授權修復 2026-06-24 15:40:33 +08:00
sky121113
a65f4cd45c [FIX] 合併取貨碼/通行碼/來店禮租戶機台授權修復 2026-06-24 15:40:26 +08:00
sky121113
4e9d21d1d6 [FIX] 合併取貨碼/通行碼/來店禮租戶機台授權修復 2026-06-24 15:40:05 +08:00
sky121113
38f5071975 [FIX] 取貨碼/通行碼/來店禮:客戶帳號跨未授權機台導致頁面崩潰與越權
1. 列表與操作紀錄查詢加上 whereHas('machine'),依 machine_user 授權過濾,客戶帳號只看得到可存取機台的資料(系統管理員不受影響)
2. pickup_code_logs.machine_id 可為 null,該紀錄查詢改為「machine_id 為 null 或機台可存取」以保留非機台紀錄
3. destroyPickupCode/destroyPassCode/destroyWelcomeGift 加上機台授權守衛,非系統管理員越權刪除回傳 403
4. tab-list blade 將 $x->machine->name 等改為 null-safe,作為縱深防禦

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:39:41 +08:00
sky121113
6e5307933f [RELEASE] feat/slot-lock-expiry-bidirectional-sync → main:貨道效期+鎖定 App↔後台雙向同步上線
1. 貨道效期/鎖定 App↔後台雙向同步(LWW)
2. 鎖定操作紀錄顯示與多語系補齊
3. 庫存清單與機台概覽顯示貨道鎖定狀態

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:57:42 +08:00
sky121113
61825e706a [PROMOTE] feat/slot-lock-expiry-bidirectional-sync → demo:庫存清單與機台概覽顯示貨道鎖定狀態
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:50:10 +08:00
sky121113
1bd8971310 [FEAT] 庫存清單與機台概覽顯示貨道鎖定狀態
1. 庫存與效期管理頁(列表/矩陣/手機卡片)商品旁顯示鎖定徽章,使用者可在清單直接辨識暫停販售貨道
2. 機台庫存概覽頁(格狀/表格/手機卡片)同步顯示鎖定徽章
3. 新增 Sale Paused 語系 key 三語系對齊(暫停販售 / Sale Paused / 販売停止)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:49:59 +08:00
sky121113
7f4e67129f [PROMOTE] feat/slot-lock-expiry-bidirectional-sync → demo:貨道鎖定操作紀錄與多語系修正
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:36:27 +08:00
sky121113
6ce4c2c8ed [FIX] 貨道鎖定操作紀錄顯示與多語系補齊
1. 操作紀錄徽章補上鎖定/解鎖切換顯示,修正僅 is_locked 變動時出現空白「貨道 N:」
2. 補齊鎖定彈窗 4 個語系 key(Sale Status / On Sale / Locked (Sale Paused) / Lock (Pause Sale))三語系對齊

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:36:20 +08:00
sky121113
3007bfb254 Merge branch 'feat/slot-lock-expiry-bidirectional-sync' into demo 2026-06-24 14:25:51 +08:00
sky121113
af78c48d8e [FEAT] 貨道效期+鎖定 App↔後台雙向同步(LWW)
1. machine_slots 新增 is_locked / last_app_lock_rev / last_app_expiry_rev(migration);MachineSlot model fillable+casts。
2. syncSlots(B009):新增 per-field 常態 LWW(App rev 較大才採用);換商品(product_id 變更)時清空舊 expiry/batch 安全網;空值一律歸一化為 null。
3. updateSlot:下行 update_inventory 一律帶 is_locked + lock_rev + expiry_rev,後台編輯只更新「帶上的欄位」(鎖定切換不會清效期),對應 rev +1 成為權威值。
4. 新增 admin slots/lock endpoint(toggleSlotLock)+ route;remote/stock.blade.php 貨道編輯彈窗加「販售狀態/鎖定·解鎖」toggle。
5. 不含 app_uuid/重灌/B017 概念(精簡版);既有現場鎖定靠 App 端升級遷移(lock_rev=1)走常態 LWW 上雲。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:25:33 +08:00
twsystem1004
aecb2f17d1 [PROMOTE] dev → demo:領藥單核銷與批價單號掃碼修正晉升驗收
1. 領藥單核銷流程:改由出貨 finalize 處理,B660 回傳批價單號(pricing_slip_no)。
2. 領藥單建立頁批價單號欄位攔截 Enter,避免掃碼器掃完 QR 自動送出誤觸建立。
2026-06-24 03:13:24 +00:00
twsystem1004
f8c6da3acc [FIX] 領藥單建立頁批價單號欄位攔截 Enter
1. 批價單號 input 加上 @keydown.enter.prevent,避免掃碼器掃完批價單 QR 後自動送出的 Enter 誤觸「建立並產生 QR」、跳出「請至少勾選一項藥品並填寫數量」提示。
2. 單號仍正常自動帶入,攔截僅作用於此欄位,不影響其他欄位與按鈕送出。
2026-06-24 03:11:19 +00:00
twsystem1004
9a6036d184 [FIX] 領藥單:核銷改由出貨finalize、B660回傳批價單號
- handlePickupVerify:移除掃碼驗證當下核銷領藥碼(usage_count++/used),
  改由實際出貨 MQTT finalize 核銷;未取貨(逾時/取消)時領藥碼維持 active 可再掃,
  修正「掃了未確定取貨、再掃顯示已領」問題。
- B660 回應新增 pricing_slip_no(批價單號),供機台確認領藥畫面備註欄顯示。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 03:11:19 +00:00
sky121113
1a1518c611 [FIX] 合併 feat/inventory-mqtt-resend:庫存效期 MQTT 逾時自動解鎖 + timeout 多語系 2026-06-24 09:06:32 +08:00
sky121113
47073f0a96 [RELEASE] feat/inventory-mqtt-resend → main:庫存效期管理 MQTT 下發逾時自動解鎖上線
1. slotsAjax 的 is_pending 僅計入 1 分鐘內的 pending reload_stock,逾時自動解鎖,避免機台未回 ACK 時介面永久卡住
2. updateSlot 重新下發時逾時舊指令標記 timeout、否則 superseded,與 dispense 邏輯一致
3. stock.blade.php 遠端指令紀錄補上 timeout 狀態多語系與徽章樣式
2026-06-24 09:06:19 +08:00
sky121113
bec02ac546 [FIX] 合併 timeout 狀態多語系修正至 demo 2026-06-24 09:03:33 +08:00
sky121113
a2faace4bd [FIX] 遠端指令庫存效期紀錄補上 timeout 狀態多語系與徽章樣式
1. stock.blade.php getCommandStatus 補 timeout 對照,避免直接顯示原字串 TIMEOUT
2. getCommandBadgeClass 補 timeout 徽章樣式 (rose,與 failed 一致)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:03:27 +08:00
sky121113
fcff9a0fc6 [FIX] 合併 feat/inventory-mqtt-resend:庫存效期管理 MQTT 下發逾時自動解鎖
1. slotsAjax 的 is_pending 僅計入 1 分鐘內的 pending reload_stock,逾時自動解鎖
2. updateSlot 重新下發時逾時舊指令標記 timeout、否則 superseded
2026-06-24 08:58:20 +08:00
sky121113
3293a76c91 [FIX] 庫存效期管理補上 MQTT 下發逾時自動解鎖
1. slotsAjax 的 is_pending 僅計入 1 分鐘內的 pending reload_stock 指令,超過視為逾時自動解鎖,避免機台未回 ACK 時介面永久卡住
2. updateSlot 重新下發時,逾時舊指令標記為 timeout、未逾時標記為 superseded,與 dispense 逾時邏輯一致

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:57:56 +08:00
sky121113
d1c7940db9 [PROMOTE] feat/expiry-clear-on-sellout → demo:售完清效期測試驗收
1. recordDispense 售完(stock=0)即清空貨道 expiry_date 與 batch_no,避免舊批效期殘留誤鎖新批貨
2. 新增 ExpiryClearOnSelloutMysqlTest 驗收測試
3. 同步併入 main 既有提交:每機台商品定價 + 補貨單/機台列表 RWD 修正(fcd0588、c5a3f99)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	app/Services/Transaction/TransactionService.php
2026-06-23 17:24:12 +08:00
sky121113
e0532a539f [FEAT] 售完即清空貨道效期與批號
1. recordDispense 扣庫存後,若 stock 歸 0 則一併清空 expiry_date 與 batch_no,避免舊批效期殘留:日後僅補 stock 未改效期時,update_inventory 不會把過期日下發、誤將新批貨鎖成「暫不販售」,與機台端「賣完清效期」對稱
2. 新增 ExpiryClearOnSelloutMysqlTest 驗證售完清效期行為

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:23:02 +08:00
sky121113
94a941e100 [FIX] backport 至 dev:每機台商品定價 + 機台列表 RWD + 多語系對齊 2026-06-23 16:43:46 +08:00
sky121113
fcd0588686 [RELEASE] feat/per-machine-product-pricing → main:每機台商品定價 + 補貨單/機台列表 RWD 修正上線 2026-06-23 16:36:31 +08:00
sky121113
c5a3f99113 [FIX] 每機台定價多語系三語對齊
1. en.json、ja.json 補齊定價功能 15 個新 key(原本只有 zh_TW 有)
2. zh_TW.json 依 ksort 重排,三語系 key 集合 100% 對齊(各 2620)
3. 斜線不逸出,JSON_UNESCAPED_SLASHES/UNICODE 格式

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:23:29 +08:00
sky121113
f37f41c5c3 [PROMOTE] feat/per-machine-product-pricing → demo:機台列表行動版補上硬體狀態 2026-06-23 16:15:32 +08:00
sky121113
8d951a64e6 [FIX] 機台列表行動版補上硬體狀態,與桌機資料對齊
1. 行動/平板卡片 Info Grid 新增「硬體狀態」欄位(原本僅桌機表格有,RWD 漏顯示)
2. 沿用桌機相同邏輯:下位機 hardware_status,啟用刷卡機時顯示 card_terminal_status,燈號配色一致
3. 欄位排於 Status 旁填滿兩欄列,標籤樣式遵循 Mobile Card Info Grid 規範

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:15:24 +08:00
sky121113
cfbe16c519 [PROMOTE] feat/per-machine-product-pricing → demo:定價頁麵包屑標籤 2026-06-23 16:07:48 +08:00
sky121113
3c1d5b73ed [FEAT] 定價頁新增麵包屑標籤
1. breadcrumbs.blade.php 機台子頁對照新增 pricing => Machine Pricing,定價頁麵包屑正確顯示

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:07:42 +08:00
sky121113
5621cad1bf [PROMOTE] feat/per-machine-product-pricing → demo:每機台商品定價 (WIP) 上 demo 測試 2026-06-23 15:55:14 +08:00
sky121113
3a793978b4 [REFACTOR] 每機台定價改置於機台主模組(自基本設定移出)
1. 路由 /{machine}/pricing、/{machine}/pricing (POST) 由 BasicSettings\MachineSettingController 移至 MachineController
2. pricing / updatePricing 方法搬遷至 Admin\MachineController
3. 視圖由 basic-settings/machines/pricing.blade.php 移至 machines/pricing.blade.php
4. 機台列表頁 index.blade.php 新增定價入口;移除基本設定 tab-machines.blade.php 內的舊入口

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:54:16 +08:00
sky121113
1685b858f9 [FEAT] 每機台商品定價 WIP 暫存
1. 新增 MachineProductPrice 模型與 machine_product_prices 資料表 migration
2. 機台設定頁新增定價分頁 pricing.blade.php
3. 調整 ProductCatalogService、MachineController、Machine 模型支援每機台價格
4. 補貨單修正前的進度暫存,待後續完成

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:38:14 +08:00
sky121113
0da3887aa6 [FIX] backport 至 dev:補貨單歸屬改以機台所綁公司為準 2026-06-23 15:34:22 +08:00
sky121113
8e0a30eb98 [PROMOTE] fix/replenishment-company-by-machine → demo:補貨單歸屬改以機台所綁公司為準 2026-06-23 15:34:14 +08:00
sky121113
db9769b209 [RELEASE] fix/replenishment-company-by-machine → main:補貨單歸屬改以機台所綁公司為準上線 2026-06-23 15:27:50 +08:00
sky121113
62d3242db5 [FIX] 補貨單歸屬改以機台所綁公司為準
1. storeReplenishment / autoReplenishment 建單時 company_id 由「建立者」改為「機台的 company_id」
2. 修正系統管理員為他公司機台建單時 company_id 被寫成 null,導致篩選列選公司及租戶帳號登入皆看不到補貨單
3. 新增 migration 依機台回填既有舊單據的 company_id

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:27:31 +08:00
sky121113
5a501eb199 [FIX] backport 至 dev:遠端強制重啟 APP 指令 2026-06-23 14:13:56 +08:00
sky121113
9e9ed4bc9e [RELEASE] fix/remote-dispense → main:遠端強制重啟 APP 指令正式上線 2026-06-23 14:13:20 +08:00
sky121113
ecfbc904a7 [PROMOTE] fix/remote-dispense → demo:遠端強制重啟 APP 指令上 demo 測試 2026-06-23 14:06:07 +08:00
sky121113
871b223440 [FEAT] 遠端控制新增「強制重啟 APP」(reboot_force) 指令
1. RemoteController storeCommand 驗證白名單新增 reboot_force
2. ProcessCommandAck 將 reboot_force 納入維護類指令日誌記錄
3. 遠端控制頁新增強制重啟按鈕(琥珀色,提示卡死時使用,無視忙碌狀態)
4. 指令歷史篩選與庫存頁指令名稱對應補上 reboot_force
5. 三語系新增 Machine Force Reboot / command.reboot_force / 忙碌狀態提示,並 ksort 對齊

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:05:52 +08:00
sky121113
9856a68d84 [PROMOTE] dev → demo:領藥單 UI 改版、機台報表手機支付標籤、員工卡 hotfix
1. [FEAT] 領藥單頁面改套極簡奢華風 UI(對齊取貨碼):x-page-header、Alpine 建立彈窗、x-searchable-select 機台下拉、+/- 數量計數器、桌面表格+手機卡片、取消確認彈窗、luxury 分頁;新增 productsAjax 端點與路由;修正麵包屑顯示「領藥單」層級。
2. [FEAT] 機台報表付款類型對照表新增代碼 10「手機支付」(NFC 手機感應)標籤。
3. [FIX] 員工卡編輯彈窗回填所屬公司並防止 company_id 被洗為 null(backport hotfix 至 dev)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:36:13 +08:00
sky121113
f5cb9a8f7d [FEAT] 領藥單頁面改套極簡奢華風 UI(對齊取貨碼)並修正麵包屑
1. 重寫 pharmacy-pickup/index.blade.php:改用 x-page-header、Alpine 建立彈窗、x-searchable-select 機台下拉、每項藥品 +/- 數量計數器、桌面表格+手機卡片、x-confirm-modal 取消確認、luxury 分頁,全面對齊取貨碼 UI。
2. PharmacyPickupController 新增 productsAjax():依機台 AJAX 回傳可領藥品清單(對齊取貨碼 slots-ajax 模式),並維持取物單模式+領藥開關雙閘驗證。
3. routes/web.php 新增 pharmacy-pickup/{machine}/products-ajax 路由(受 menu.sales.pharmacy-pickup 權限控管)。
4. breadcrumbs.blade.php 補上 pharmacy-pickup 對照,修正麵包屑斷層,正確顯示「領藥單」層級。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:33:44 +08:00
sky121113
eb5b75cf78 [FEAT] 機台報表付款類型新增「手機支付」標籤
1. AnalysisController::machineReports 付款類型對照表新增代碼 10 = Mobile Pay(NFC 手機感應)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:33:31 +08:00
sky121113
6b430bdd88 [FIX] backport hotfix 至 dev:修正員工卡編輯彈窗公司未回填 (Preline getInstance) 2026-06-23 11:40:30 +08:00
sky121113
a5291545a5 [FIX] backport hotfix 至 demo:修正員工卡編輯彈窗公司未回填 (Preline getInstance) 2026-06-23 11:40:26 +08:00
sky121113
7b6dc3cb8e [FIX] 合併 hotfix:修正員工卡編輯彈窗 Preline getInstance 取錯物件導致公司未回填 2026-06-23 11:40:09 +08:00
sky121113
76409c5f21 [FIX] 員工卡編輯彈窗所屬公司仍未回填:修正 Preline getInstance 取錯物件
1. 接續前一個 hotfix:員工卡編輯彈窗的「所屬公司」下拉雖已部署字串轉型,仍顯示空白「選擇公司」。根因為 Preline v3 的 HSSelect.getInstance(target, true) 回傳的是 collection 包裝物件({id, element})而非元件實例,其上沒有 setValue,呼叫即丟 TypeError 並讓 $nextTick 回填中斷。
2. 移除錯誤的第二個參數 true,改以 window.HSSelect.getInstance(el) 取得真正實例(對齊全站 ads / welcome-gifts / pickup-codes 既有可運作寫法)。
3. 一併沿用 welcome-gifts 的穩健流程:先設原生 <select>.value 再呼叫 setValue,並保留數字轉字串、空值以 ' ' 對應 placeholder 的處理。
4. 影響範圍:僅 resources/views/admin/data-config/staff-cards/index.blade.php 之 openStaffModal 回填邏輯,無後端與資料異動。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:39:56 +08:00
sky121113
f415e138da [FIX] backport hotfix 至 dev:員工卡編輯回填公司 + company_id 寫入保護 2026-06-23 11:27:01 +08:00
sky121113
ca3d3df398 [FIX] backport hotfix 至 demo:員工卡編輯回填公司 + company_id 寫入保護 2026-06-23 11:26:28 +08:00
sky121113
09d669cc7c [FIX] 合併 hotfix:員工卡編輯彈窗回填公司並防止 company_id 被洗為 null 2026-06-23 11:25:11 +08:00
sky121113
99debbf259 [FIX] 員工卡編輯彈窗未回填所屬公司,並防止儲存時洗掉 company_id
1. 修正系統管理員開啟「編輯員工識別卡」時,所屬公司下拉顯示為「選擇公司」空值的問題:Preline 的 option value 為字串,原本直接以數字型別的 company_id 呼叫 setValue,嚴格比對失配導致無法回填,現統一轉為字串 (對齊 ads / welcome-gifts / pickup-codes 既有寫法)。
2. 強化 StaffCardController@update 的寫入安全 (RBAC 1.3):非系統管理員一律強制綁定自身 company_id;系統管理員若未帶入公司則保留原本歸屬,避免在上述空值狀態按下儲存時把 company_id 洗成 null,造成機台刷卡「查無此員工卡」的孤兒卡。
3. 影響範圍:資料設定 → 員工識別管理之新增/編輯彈窗 (resources/views/admin/data-config/staff-cards/index.blade.php) 與 App\Http\Controllers\Admin\StaffCardController;僅前端回填與後端寫入保護,不更動資料表結構與既有正常卡片資料。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:18:13 +08:00
root
2cecdba73a [PROMOTE] dev → demo:領藥單(Pharmacy Pickup / Rx)功能晉升驗收
1. 領藥單後台模組:列表/建立/作廢/列印 QR,受 menu.sales.pharmacy-pickup 權限控管。
2. 機台「領藥單」開關(僅取物單模式可啟用);B660 取貨核銷擴充多貨道出貨清單、庫存不足嚴格擋關。
3. TransactionService 領藥單獨立冪等出貨流程;recordDispense 負庫存防呆、syncSlots 空清單防全量刪除。
4. 資料庫:orders 新增領藥單欄位、pickup_codes 之 slot_no 改 nullable(2 個 migration)。
5. 新增權限 menu.sales.pharmacy-pickup;三語系(zh_TW/en/ja)對齊補譯領藥單詞彙。
2026-06-23 02:32:43 +00:00
root
003ba9b1ad [FEAT] 新增領藥單(Pharmacy Pickup / Rx)功能並強化出貨防呆
1. 領藥單後台:新增 PharmacyPickupController 與 PharmacyPickupService,提供領藥單列表/建立/作廢/列印 QR;routes/web.php 新增受 menu.sales.pharmacy-pickup 權限控管的路由群組,RoleSeeder 補該權限、sidebar 新增選單。
2. 訂單模型:Order 新增 order_type(sale/pharmacy_pickup)、pricing_slip_no、created_by 欄位與 AWAITING_PICKUP 狀態,新增 sales/pharmacyPickup scope 及 creator/pickupCode 關聯。
3. 機台設定:系統設定新增「領藥單」開關,僅在取物單(pickup_sheet)模式下可啟用、其他模式強制關閉;含後端防呆、toggle UI 與啟用功能標籤顯示。
4. 機台 API:getSettings FunctionSet 增加 PharmacyPickup 旗標;B660 取貨核銷擴充領藥單多貨道出貨清單回傳,庫存不足/無可用貨道時嚴格擋關且不消耗領藥碼。
5. 出貨流程:TransactionService finalizeTransaction 以 order_no(RX) 對應預建領藥單,走 finalizePharmacyDispense 獨立冪等出貨流程,並標記領藥碼已領與核銷日誌。
6. 防呆修正:recordDispense 庫存<=0 時不再扣減以避免負庫存;syncSlots 上報清單為空時不執行全量刪除,避免誤清貨道。
7. 資料庫:新增 2 個 migration(orders 增領藥單欄位、pickup_codes 之 slot_no 改 nullable);PickupCode 開放 status 可填、isValid 容許 null expires_at。
8. 多語系/文件:zh_TW/en/ja 三語系對齊補齊領藥單相關詞彙(27 個);config/api-docs.php 補 PharmacyPickup 旗標說明。
2026-06-23 02:19:17 +00:00
sky121113
e3593a4ba8 [RELEASE] demo → main:銷售紀錄流水號顯示優化上線
1. [FEAT] 銷售紀錄流水號顯示剝除機台序號前綴 — 新增 HasDisplayFlowId trait,訂單詳情與發票匯出改顯示乾淨流水號;DB 仍存完整 serial+flow_id 唯一值供去重/冪等/對帳判斷,含防禦性處理與測試。

備註:純顯示層變更,無資料庫遷移、無設定檔變更、無 RBAC 權限調整。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:32:41 +08:00
sky121113
d8495766e1 [PROMOTE] dev → demo:銷售紀錄流水號顯示優化
1. [FEAT] 銷售紀錄流水號顯示剝除機台序號前綴 — 新增 HasDisplayFlowId trait,訂單詳情與發票匯出改顯示乾淨流水號,DB 仍存完整 serial+flow_id 唯一值;含防禦性處理與測試。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:20:55 +08:00
sky121113
ae4556584f [FEAT] 銷售紀錄流水號顯示剝除機台序號前綴
1. 新增 HasDisplayFlowId trait,提供 display_flow_id accessor:僅顯示時剝除 flow_id 開頭的機台序號前綴,DB 仍存完整 serial+flow_id 唯一值供去重/冪等/對帳判斷。
2. Order 與 Invoice 兩 Model 套用此 trait。
3. 訂單詳情面板 (order-detail-panel) 與發票匯出 (SalesController) 改顯示 display_flow_id,呈現乾淨易讀的機台流水號。
4. accessor 具防禦性:機台關聯為 null 或舊的未前綴資料皆原樣顯示,不誤剝。
5. 新增測試驗證 display_flow_id 正確剝除序號前綴且 DB 值維持完整。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:16:13 +08:00
sky121113
8a7cb4fcf7 [RELEASE] demo → main:交易冪等強化與銷售紀錄未付款單顯示修正正式上線
1. [FEAT] 交易結算冪等性強化與 flow_id 跨機台命名空間:finalizeTransaction 加機台序號前綴使全域唯一、終態檢查移入交易並加排他鎖防重複出貨/扣庫存/核銷、取貨碼與來店禮核銷防並發超量、含 FinalizeIdempotency/FinalizeStockMysql 測試。
2. [FIX] 銷售紀錄未付款單顯示:付款狀態「已棄單」改為「未支付」、非付款成功(未完成/支付失敗/未支付)的單出貨狀態統一顯示「--」,避免 delivery_status 預設值誤顯示為出貨成功;判斷收斂至 Order::hasDeliveryOutcome(),三語系對齊。
2026-06-18 17:29:21 +08:00
sky121113
eeb9a67577 [PROMOTE] dev → demo:交易冪等強化與銷售紀錄未付款單顯示修正晉升驗收
1. 交易結算冪等性強化與 flow_id 跨機台命名空間(含並發排他鎖、核銷防超量、測試)。
2. 銷售紀錄未付款單顯示:已棄單改未支付、未出貨統一顯示 --。
2026-06-18 17:24:24 +08:00
sky121113
b7e8401a99 [FIX] 銷售紀錄未付款單顯示:已棄單改未支付、未出貨顯示 --
1. 付款狀態「已棄單(abandoned)」顯示文字改為「未支付」;新增 Unpaid 語系、移除孤兒 Abandoned key,三語系(zh_TW/en/ja)對齊排序。
2. 出貨狀態:非付款成功(未完成 pending/支付失敗 failed/未支付 abandoned)的單從未出貨,一律顯示「--」,避免 delivery_status 欄位 DB 預設值 1 誤顯示為「出貨成功」。
3. 判斷邏輯收斂至 Order::hasDeliveryOutcome(),桌機表格、手機卡片、明細面板與匯出 delivery_status_label accessor 共用同一基準。
4. 補齊匯出訂單狀態對照表原本缺漏的 failed/abandoned 標籤。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:14:29 +08:00
sky121113
ddac862cee [FEAT] 交易結算冪等性強化與 flow_id 跨機台命名空間
1. finalizeTransaction 將 flow_id 前綴機台序號(serial+rawFlowId)使其全域唯一,解決跨機台單機 flow_id 撞號問題;ACK 仍回傳原始 flow_id,App/outbox 對帳無感。
2. 終態冪等檢查移入 DB transaction 並對既有 order 加 lockForUpdate 排他鎖,防止兩個 worker 同時穿透 pending 而重複出貨/扣庫存/核銷。
3. 取貨碼(PickupCode)與來店禮(WelcomeGift)核銷加排他鎖,防並發 lost-update 使 usage_count 突破 usage_limit。
4. recordInvoice 的 RelateNumber 推導改為直接取用已前綴序號的 flow_id,與線上既有 serial+rawFlowId 逐字等價,不重複加序號。
5. 新增 FinalizeIdempotencyTest、FinalizeStockMysqlTest 與 phpunit.mysql.xml(MySQL-only DDL 測試設定)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:14:19 +08:00
sky121113
95ac9cc10d [RELEASE] demo → main:電子發票強化與機台刷卡機健康狀態上線
1. [FEAT] 電子發票作廢權限控管、訂單/發票備註、作廢發票差異顯示
   - 發票作廢限平台系統管理員(後端 403 守衛 + 前端隱藏)。
   - orders/invoices 新增 remark 欄位,列表與訂單詳情、作廢視窗皆可編輯。
   - 訂單列表已作廢發票以刪除線加「已作廢」標記。
2. [FEAT] 交易訂單與電子發票列表新增可編輯備註欄位(x-remark-cell 共用元件)。
3. [FIX] 電子發票列印限縮為僅統編發票,避免載具/捐贈列印失敗。
4. [FEAT] 機台刷卡機 (Nexsys 終端機) 健康狀態燈號與失敗日誌
   - 信用卡/電子票證/手機支付共用同一台刷卡機,以交易結果維護燈號。
   - finalize 成功消警、失敗解析回應碼寫 card_terminal 日誌。
   - 僅基礎版且啟用刷卡機的機台顯示燈號與日誌分頁。
5. [STYLE] 銷售中心與 admin header 手機版響應式調整。

資料庫 migration(首次於正式站執行):
- add_remark_to_orders_and_invoices_tables(orders/invoices 加 nullable remark)。
- add_status_lookup_index_to_machine_logs(machine_logs 加複合索引,資料量大會短暫鎖表)。
- change_products_spec_to_text 已存在不重跑。
2026-06-18 14:06:23 +08:00
sky121113
e7acea8ffc [PROMOTE] dev → demo:機台刷卡機健康狀態燈號功能晉升驗收
1. [FEAT] 機台刷卡機 (Nexsys 終端機) 健康狀態燈號與失敗日誌(df3c72e)
   - 信用卡/電子票證/手機支付共用同一台刷卡機,以交易結果維護燈號。
   - finalize 成功消警、失敗解析回應碼寫 card_terminal 日誌。
   - 新增 NCCC 回應碼對照表與多語訊息、機台列表燈號與刷卡機日誌分頁。
2. [FEAT] 刷卡機狀態顯示限基礎版啟用機台,並加機台日誌查詢索引(fd954c8)
   - show_card_terminal:僅 shopping_mode=basic 且啟用刷卡機者顯示燈號/分頁。
   - 列表狀態欄文案調整(Sub-machine Status → Hardware Status)。
   - machine_logs 加複合索引加速狀態查詢。

備註:
- migration change_products_spec_to_text 僅加 sqlite 守衛,demo 已執行過不會重跑、無實際影響。
- 新增 machine_logs 索引 migration 於 demo 部署執行;正式站資料量大建議離峰。
2026-06-18 13:49:05 +08:00
sky121113
fd954c80a0 [FEAT] 刷卡機狀態顯示限基礎版啟用機台,並加機台日誌查詢索引
1. Machine 新增 show_card_terminal 取值器:僅 shopping_mode=basic 且 card_terminal_enabled 的機台才呈現刷卡機燈號與日誌分頁(購物車模式或未啟用刷卡機者隱藏)。
2. 機台列表頁:刷卡機狀態燈號與「刷卡機日誌」分頁改以 show_card_terminal 控制顯示;openLogPanel 多帶 cardTerminalEnabled 參數,分頁不可見時自動退回機台狀態分頁。
3. 列表狀態欄文案調整:欄位標頭由「Sub-machine Status」改為「Hardware Status」,下位機燈號標籤改顯示「Sub-machine」,與新增的刷卡機燈號並列更清楚。
4. 新增 migration:machine_logs 加複合索引 (machine_id, type, is_resolved, level),加速列表頁逐台的狀態未解決日誌查詢(正式環境資料量大,建議離峰執行)。
5. CardTerminalStatusTest 新增 show_card_terminal 顯示條件測試(基礎版啟用才顯示、未啟用或非基礎版皆不顯示)。
6. 新增三語系 key(Hardware Status、Sub-machine),zh_TW/en/ja 對齊並 ksort 排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:44:22 +08:00
sky121113
df3c72e927 [FEAT] 機台刷卡機 (Nexsys 終端機) 健康狀態燈號與失敗日誌
1. 刷卡機共用判定:信用卡(1)、電子票證(2)、手機支付(10) 走同一台實體刷卡機,以交易結果維護燈號(刷卡機無斷線心跳)。
2. ProcessTransactionFinalized 新增 syncCardTerminalStatus:成功交易自動消除該機台未解決的 card_terminal 警告;失敗交易解析 payment_response 的 "| code=XXXX" 回應碼,寫一筆 type='card_terminal' 的 warning 日誌(warning 級不觸發 Discord)。
3. MachineService 新增 CARD_TERMINAL_CODE_MAP(NCCC/聯卡中心回應碼對照,值為英文翻譯鍵);MachineLog 依 card_code 重組多語可讀失敗原因。
4. Machine 模型新增 card_terminal_status 與 latest_card_terminal_log_time 取值器;MachineController 列表以 withCount + 相關子查詢預載彙總,避免 N+1。
5. 機台列表頁新增「刷卡機」狀態燈號(綠/琥珀/紅 + 最近異常時間)與「刷卡機日誌」分頁。
6. 文件更新:config/api-docs.php 與 api-technical-specs SKILL.md 補充 payment_type 說明(2/10 與 1 共用同一台刷卡機、補列 42 取物單與 100 遠端出貨)。
7. 既有 migration change_products_spec_to_text 加 sqlite 守衛,讓測試環境可跑(VARCHAR/TEXT 在 sqlite 等價且不支援 MODIFY)。
8. 新增 CardTerminalStatusTest 功能測試(18 斷言通過,涵蓋成功消警、失敗寫日誌、掃碼排除、多語訊息、N+1 預載路徑)。
9. 新增 25 組三語系 key(刷卡機狀態與回應碼),zh_TW/en/ja 對齊並 ksort 排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:09:05 +08:00
sky121113
1ec149a624 [PROMOTE] dev → demo:發票列印限縮統編 + 銷售中心 RWD 調整
1. [FIX] 電子發票列印限縮為僅統編發票,避免載具/捐贈列印失敗(69a1d02)
   - printInvoice() 守衛與前端列印鈕改用 metadata['business_tax_id'] 判定。
   - 非統編發票顯示「此發票無可列印紙本」。
   - 新增兩組三語系 key。
2. [STYLE] 銷售中心與 admin header 手機版響應式調整(840052d)
   - 三個 Tab 容器圓角/內距行動優先、搜尋欄手機滿版。
   - admin layout 防水平溢出與行動版 header 間距調整。

備註:本次無 migration 變更。
2026-06-18 11:42:10 +08:00
sky121113
840052dd5a [STYLE] 銷售中心與 admin header 手機版響應式調整
1. 銷售中心三個 Tab 容器圓角與內距改為行動優先(rounded-2xl sm:rounded-3xl、p-4 sm:p-6 lg:p-8)。
2. 訂單/發票/出貨三個列表的搜尋欄寬度改為手機滿版、桌面才套 min-w-[280px](w-full min-w-0 lg:min-w-[280px])。
3. admin layout:body 加 overflow-x-clip 防止水平溢出;行動版品牌字在窄螢幕隱藏、自動刷新與 logo 區塊調整間距與 shrink-0 避免擠壓。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:41:37 +08:00
sky121113
69a1d02fc7 [FIX] 電子發票列印限縮為僅統編發票,避免載具/捐贈列印失敗
1. 釐清綠界規則:InvoicePrint 僅能列印開立時 Print=1 的發票;本系統開立邏輯為「有統編才 Print=1」,故載具、捐贈、一般無統編 B2C 皆 Print=0,列印會回「取得發票列印網址失敗」。
2. 後端 printInvoice() 守衛改為檢查 metadata['business_tax_id'],非統編發票直接回「Only invoices with a tax ID can be printed」,取代原本僅擋捐贈(love_code)的舊邏輯。
3. 訂單詳情面板列印鈕顯示條件改為 business_tax_id 有值才顯示,非統編發票改顯示「此發票無可列印紙本」。
4. 新增三語系 key(Only invoices with a tax ID can be printed、This invoice has no printable copy),zh_TW/en/ja 對齊排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:41:26 +08:00
sky121113
f8a56f2b4e [PROMOTE] dev → demo:交易訂單與發票列表備註欄位晉升驗收
1. [FEAT] 交易訂單與電子發票列表新增可編輯備註欄位(ae4c9fb)
   - 新增 x-remark-cell 共用元件,列表內嵌備註編輯(AJAX PATCH + toast,不重整)。
   - 交易訂單列表(桌面+手機)新增備註欄,接 order.remark。
   - 電子發票列表(桌面+手機)新增備註欄,接 invoice.remark。
   - 列表與訂單詳情面板共用同一 remark 欄位。
   - 新增三語系 key「Add Remark」並對齊排序。

備註:本次無 migration 變更(remark 欄位已於前次晉升建立)。
2026-06-18 11:14:10 +08:00
sky121113
ae4c9fb93a [FEAT] 交易訂單與電子發票列表新增可編輯備註欄位
1. 新增共用元件 x-remark-cell:列表內嵌備註編輯,點擊文字進入編輯,儲存走 AJAX PATCH 並以 toast 提示,不重整頁面;空白時顯示淡色「新增備註」提示。
2. 交易訂單列表(tab-orders)桌面表格於「發票號碼」與「操作」間新增備註欄,手機卡片於操作鈕上方新增備註區塊,接 order.remark,並同步調整空狀態 colspan。
3. 電子發票列表(tab-invoices)桌面表格於「關聯訂單」與「操作」間新增備註欄,手機卡片新增備註區塊,接 invoice.remark,並同步調整空狀態 colspan。
4. 列表與訂單詳情面板共用同一 remark 欄位,任一處編輯皆同步。
5. 新增三語系 key「Add Remark(新增備註)」,zh_TW/en/ja 對齊排序至 2573 鍵。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:13:41 +08:00
sky121113
80300dfc67 [PROMOTE] dev → demo:電子發票作廢權限與備註功能晉升驗收
1. [FEAT] 電子發票作廢權限控管、交易訂單與發票備註、作廢發票差異顯示(4766ec6)
   - 發票作廢限平台系統管理員(後端 403 守衛 + 前端按鈕隱藏)。
   - orders/invoices 新增 remark 欄位與更新端點,訂單詳情面板內嵌備註編輯。
   - 作廢確認視窗加備註輸入框,寫入 invoice.remark。
   - 訂單列表已作廢發票以刪除線加「已作廢」標記顯示。
   - 新增 5 組三語系 key 並對齊排序。

備註:本次含新增 migration(orders.remark、invoices.remark,皆 nullable,非破壞性),demo 部署將自動套用。
2026-06-18 10:53:53 +08:00
sky121113
4766ec6939 [FEAT] 電子發票作廢權限控管、交易訂單與發票備註、作廢發票差異顯示
1. 發票作廢改為僅限平台系統管理員:voidInvoice() 加 isSystemAdmin() 403 守衛,訂單詳情面板與 tab-invoices 手機卡片作廢按鈕同步加 isSystemAdmin 顯示條件(列印鈕維持所有人可用)。
2. 新增 orders、invoices 各自獨立的 remark 欄位(migration + Model fillable),供管理者編輯內部備註。
3. 新增 updateOrderRemark / updateInvoiceRemark 端點與 PATCH 路由,AJAX 回傳 JSON、租戶隔離沿用 TenantScoped。
4. 訂單詳情面板加入訂單與發票備註的 Alpine 內嵌編輯(fetch + toast,不重整頁面),所有可進銷售中心者皆可編輯。
5. confirm-modal 加可選 slot;作廢確認視窗加備註輸入框,submitInvoiceAction() 送出前注入隱藏 remark,寫入 invoice.remark(不影響送綠界的 void_reason)。
6. 訂單列表(桌面與手機)已作廢發票改以灰色刪除線加「已作廢」標記顯示,index eager load 補載 invoice.status。
7. 新增 5 組三語系 key(Voided、Enter remark (optional)、Remark updated、No remark、Only system administrators can void invoices),zh_TW/en/ja 對齊排序至 2572 鍵。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:48:04 +08:00
sky121113
f01bffecd9 [RELEASE] demo → main:OTA 更新進度 ACK 處理修復上線
1. [FIX] OTA 更新進度 ACK 不應視為指令終態:update_app 指令收到 result="progress" 時維持 pending,僅將進度訊息寫入 note,避免 APK 下載/安裝過程被誤標為完成、與機台實際版本不符。
2026-06-17 20:03:17 +08:00
sky121113
3dc5bd6ee1 [PROMOTE] dev → demo:OTA 更新進度 ACK 處理修復
1. [FIX] OTA 更新進度 ACK 不應視為指令終態:update_app 指令收到 result="progress" 時維持 pending,僅將進度訊息寫入 note,避免下載過程被誤標為完成。
2026-06-17 20:00:45 +08:00
sky121113
bfaf0e7317 [FIX] OTA 更新進度 ACK 不應視為指令終態
1. ProcessCommandAck 新增 OTA 例外處理:當 command_type 為 update_app 且 result 為 "progress" 時,視為過程回報而非終態。
2. 進度訊息(如「Downloading 45%」「Installing...」)僅寫入 note 供後台檢視,指令維持 pending 狀態。
3. 避免 APK 下載/安裝長時程作業在「剛開始下載」即被誤標為完成,與機台實際版本不符。
4. 保留等待最終 success/failed ACK 的判定流程,並補上對應的 Log 記錄。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:59:15 +08:00
sky121113
159fa38fc0 [RELEASE] demo → main 正式上線:遠端管理租戶 500 修復與電子發票調整
1. [FIX] 遠端管理歷史紀錄套用機台授權過濾(whereHas('machine')),修復租戶帳號進入遠端管理與庫存歷史時 machine 為 null 導致的 500,並對 Blade 加上 null-safe 防禦;系統管理員不受影響。
2. [FIX] 開立電子發票前檢查機台電子發票開關(tax_invoice_enabled),未開啟者不開立。
3. [STYLE] 發票操作按鈕配置調整,作廢確認框改用自製 UI。
2026-06-17 11:17:38 +08:00
sky121113
338bd2914f [PROMOTE] dev → demo:遠端管理租戶 500 修復
1. [FIX] 遠端管理歷史紀錄套用機台授權過濾(whereHas('machine')),修復租戶帳號(晟崴/中國醫)進入遠端管理與庫存歷史 tab 時因 machine 關聯為 null 導致的 500 錯誤;系統管理員不受影響仍可看全部。
2026-06-17 11:15:55 +08:00
sky121113
63219a31fb [FIX] 遠端管理歷史紀錄套用機台授權過濾,修復租戶帳號 500
1. RemoteController index() 與 stock() 的歷史查詢加上 whereHas('machine'),透過 Machine 的 machine_access 全域作用域,使租戶僅能看到自己被授權機台的指令/補貨紀錄;系統管理員不受限制仍可看全部,符合 RBAC 機台以帳號授權原則。
2. 修復根因:先前重構將 Blade 改為對 machine 關聯直接呼叫 only(),租戶遇到未授權機台時 machine 為 null 導致整頁 500。
3. tab-history 與 tab-history-index 三處 $item->machine->only() 改為 null-safe(?->...?? null)作為防禦,避免未來其他查詢路徑漏過濾再次崩潰。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:15:05 +08:00
sky121113
04195caf9b [PROMOTE] dev 晉升至 demo:電子發票開關閘門與發票操作 UI 調整
1. [FIX] 開立電子發票前檢查機台電子發票開關(tax_invoice_enabled),關閉時略過開立並記 log。
2. [STYLE] 發票操作按鈕配置調整,作廢確認框改用自製 UI。
2026-06-16 16:30:59 +08:00
sky121113
cbc4ae3749 [FIX] 開立電子發票前檢查機台電子發票開關
1. finalizeTransaction() 開立發票條件改為「機台 tax_invoice_enabled 開啟 AND 上報帶 invoice」,兩者皆成立才開立。
2. 機台關閉電子發票時:不建發票、不送綠界 IssueInvoiceJob,僅記 warning log(machine_id / flow_id / order_id)供追查。
3. 修正原本只判斷上報是否帶 invoice、完全忽略機台開關而可能誤開發票的問題。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:30:17 +08:00
sky121113
83015b841b [RELEASE] 晉升 demo 至正式 main:商品匯入圖片、電子發票、多語系與支付類型
1. 商品匯入支援圖片:Excel 圖片檔名欄 + 圖片壓縮檔上傳,並改為全背景處理避免請求逾時。
2. 電子發票後台開立/對帳/補開/作廢流程,訂單 pending 狀態轉移與逾時交易自動清除。
3. 商品與機台多語系架構,商品詳情多行排版與欄位標題調整。
4. 新增 B016 機台系統設定回寫端點、B000 登入回應 identity 身分欄位。
5. 新增手機支付與 LINE Pay 官方直連支付類型,銷售掃碼支付方式細分(玉山/TayPay)。
6. 多項修正:取貨碼有效期限、訪客取貨 QR Code 公開路由、商品分類頁 Alpine 範圍、捐贈發票排除列印等。
7. 含 4 個資料庫遷移(spec 字典鍵、機台顯示語系回填、spec 改 TEXT、發票對帳狀態欄位)。
2026-06-16 14:54:14 +08:00
sky121113
7a90e8c6de [STYLE] 發票操作按鈕配置調整與作廢確認框改用自製 UI
1. 發票列表「操作」欄:查詢、補開改為 icon 按鈕呈現,與檢視 icon 風格一致;補開改用自製確認框 UI(取代瀏覽器原生 confirm)。
2. 訂單詳情發票區塊:列印、作廢維持文字按鈕樣式(不改 icon);作廢改接自製確認框 UI,捐贈發票維持隱藏列印並顯示提示。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:32:43 +08:00
sky121113
33d3eba998 [PROMOTE] dev → demo:商品匯入改為全背景處理
1. [REFACTOR] 商品匯入改為全背景處理:Controller 存檔後派發 ProcessProductImportJob 並立即回應,背景一條龍完成商品與圖片匯入,避免大批匯入受 Cloudflare 100 秒等請求逾時限制;結果寫入操作日誌查詢。
2026-06-16 13:58:43 +08:00
sky121113
f1115825b2 [REFACTOR] 商品匯入改為全背景處理避免請求逾時
1. 新增 ProcessProductImportJob:背景一條龍處理解壓圖片、匯入商品(含圖片轉 WebP 入庫)、重建目錄快取、寫入結果摘要日誌並清除暫存。
2. ProductController::import 改為僅存檔(xlsx/圖片 zip)後派發背景 Job 並立即回應 202,避免大批匯入受 Cloudflare 100 秒等請求逾時限制。
3. 移除 AttachProductImagesJob,邏輯折入單一 ProcessProductImportJob。
4. 匯入 Modal 改為提示「已排入背景處理,完成後重新整理列表查看」,匯入結果改寫入操作日誌查詢。
5. 三語系新增背景處理提示文案並對齊排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:58:13 +08:00
sky121113
db0099f8c9 [PROMOTE] dev → demo:商品匯入圖片功能與銷售發票調整
1. [FEAT] 商品匯入支援圖片:Excel 新增「圖片檔名」欄,搭配圖片壓縮檔(上限 60MB)一併上傳,背景 Job 轉 WebP 入庫;含安全解壓防護與 retry_after 調整。
2. [FIX] 銷售掃碼支付方式細分為玉山/TayPay,並調整訂單詳情發票操作按鈕與相關分頁顯示。
2026-06-16 13:44:50 +08:00
sky121113
5875fe380e [FIX] 銷售掃碼支付方式細分與訂單詳情發票操作調整
1. AnalysisController 與 Order 將付款方式 3 由「掃碼支付」細分為「掃碼支付-玉山」,並新增 11「掃碼支付-TayPay」。
2. 訂單詳情面板(order-detail-panel)發票操作按鈕(列印/作廢/勾稽/補開)改為一致的圖示按鈕樣式,並補上捐贈發票不可列印提示。
3. 銷售列表(sales/index)與發票分頁(tab-invoices)配合調整顯示。
4. 三語系新增 QR Code Payment - E.SUN/TayPay 文案並對齊排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:41:51 +08:00
sky121113
4a86be6cbf [FEAT] 商品匯入支援圖片:Excel 圖片檔名欄 + 圖片壓縮檔背景處理
1. ProductImportService 新增 deferImages 模式與第 18 欄「圖片檔名」,匯入時收集 pending_images 供背景處理,並保留同步轉檔路徑。
2. 新增 AttachProductImagesJob:背景逐筆將圖片轉為 WebP 入庫、更新時刪除舊圖、重建目錄快取、清除暫存資料夾並寫入操作日誌。
3. ProductController::import 新增圖片 zip 上傳(上限 60MB)與安全解壓 extractImageZip:副檔名白名單、getimagesize 內容驗證、basename 防路徑穿越、項目數與單檔及總量上限防 zip bomb,解壓後派發背景 Job。
4. 匯入 Modal 新增選填的圖片 .zip 上傳欄位與背景處理提示文字。
5. config/queue.php 的 redis retry_after 提高至 1830(可由 REDIS_QUEUE_RETRY_AFTER 覆寫),避免長時間圖片任務未完成即被重複派發造成重複處理。
6. 三語系新增圖片匯入相關文案並對齊排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:41:22 +08:00
sky121113
372f2fc55d [PROMOTE] 晉升捐贈發票列印修正至 demo
1. 捐贈發票排除列印按鈕並補上不可列印提示,後端加守衛防偽造 POST。
2026-06-16 11:41:56 +08:00
sky121113
02e4e99308 [FIX] 捐贈發票排除列印並補上不可列印提示
1. 訂單詳情發票區塊:新增可列印判斷(love_code 為空才可列印),捐贈發票不顯示列印按鈕,改顯示「捐贈發票無法列印」提示。
2. SalesController::printInvoice() 加入後端守衛,捐贈發票直接回傳明確錯誤訊息,防偽造 POST 呼叫綠界 InvoicePrint 取得查無資料。
3. 三語系新增 key「Donation invoices cannot be printed」,完成 zh_TW/en/ja 對齊排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:41:17 +08:00
sky121113
8256fcb623 [PROMOTE] dev -> demo:綠界電子發票設定條件式必填驗證
1. [FEAT] 綠界電子發票設定整組選填一旦啟用即全欄必填:store/update 加入 required_with 群組驗證、email 格式驗證、三語系訊息與表單動態必填提示。
2026-06-16 10:42:40 +08:00
sky121113
e81756833c [FEAT] 綠界電子發票設定整組選填一旦啟用即全欄必填
1. PaymentConfigController 的 store/update 新增 ecpay_invoice 群組條件式驗證:四欄全空時整組選填,只要填寫任一欄(store_id/hash_key/hash_iv/email)即透過 required_with 強制其餘全部必填。
2. email 欄位加上格式驗證,從源頭避免空值導致綠界開立失敗(綠界規定 CustomerPhone 與 CustomerEmail 至少擇一,本系統 email 為唯一來源)。
3. 新增三語系驗證訊息與表單提示 key(zh_TW/en/ja),維持字母排序與斜線不逸出。
4. create/edit 表單加入動態必填提示:填入任一欄時 label 顯示紅色星號,並於各欄下方顯示 @error 驗證訊息。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:30:06 +08:00
sky121113
c0f6cb5ab6 [PROMOTE] 晉升銷售與發票模組修正至 demo
1. 修正訂單詳情發票狀態勾稽:徽章改為動態讀取狀態,新增列印發票功能並接上作廢按鈕。
2. 補齊銷售紀錄訂單狀態多語系並調整電子票證顯示。
2026-06-16 10:00:39 +08:00
sky121113
d3e455fc82 [FIX] 修正訂單詳情發票狀態勾稽與列印作廢按鈕
1. 訂單詳情發票徽章改為動態讀取 invoice->status,依 pending/issued/failed/void 顯示對應狀態與顏色,不再寫死「有效」。
2. 移除不存在的 invoice_time 欄位引用,開立時間改以 invoice_date 格式化顯示。
3. 作廢按鈕接上既有 admin.sales.invoices.void 路由,僅於 issued 狀態顯示並加上確認對話框。
4. 新增列印發票功能:EcpayInvoiceService::invoicePrint() 呼叫綠界 B2CInvoice/InvoicePrint 取得列印頁網址,SalesController::printInvoice() 加守衛後 302 導向,並新增 admin.sales.invoices.print 路由,按鈕以新分頁開啟。
5. 補齊三語系 key(Failed to get invoice print URL、Only issued invoices can be printed),完成 zh_TW/en/ja 對齊排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:57:37 +08:00
sky121113
07e8a6887c [FIX] 補齊銷售紀錄訂單狀態多語系並調整電子票證顯示
1. 修正 resources/views/admin/sales/partials/tab-orders.blade.php:兩處 statusMap 補上 failed、abandoned 狀態(含 __() 多語系與徽章配色),解決訂單狀態改版後 failed/abandoned 顯示原始英文字串、未經多語系處理的問題。
2. 修正 resources/views/admin/sales/partials/order-detail-panel.blade.php:statusMap 與 labelMap 同步補上 failed、abandoned 狀態對應。
3. 更新 lang/zh_TW.json、en.json、ja.json:新增 Abandoned(已棄單 / Abandoned / 放棄済み);依需求將 E-Ticket (EasyCard/iPass) 的繁中顯示由「電子票證 (悠遊卡/一卡通)」改為「電子票證」;並清除 zh_TW.json 重複且無效的 Pending key,三語系完成對齊與字母排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:41:46 +08:00
sky121113
a32572facb [PROMOTE] 晉升 dev 變更至 demo 環境
1. 實作訂單 pending 狀態轉移與逾時交易自動清除機制。
2. 實作電子發票後台開立、對帳、補開與作廢流程。
2026-06-16 08:52:49 +08:00
sky121113
b506d64dd3 [FEAT] 實作訂單 pending 狀態轉移與逾時交易自動清除機制
1. 修改 app/Models/Transaction/Order.php:新增訂單生命週期狀態常數與 TERMINAL_STATUSES 終態定義。
2. 修改 app/Services/Transaction/TransactionService.php:優化 processTransaction 與 finalizeTransaction 的冪等性邏輯,支援 pending 狀態訂單的狀態轉移,並拆分輔助函式 transitionOrder 與 createOrderItems。
3. 新增 app/Console/Commands/SweepAbandonedOrdersCommand.php:建立 orders:sweep-abandoned 指令,將停留在 pending 狀態超過 15 分鐘的逾時訂單標記為 abandoned。
4. 修改 app/Console/Kernel.php:將 orders:sweep-abandoned 定期清理指令註冊至 Schedule 排程中,設定每 5 分鐘執行一次。
2026-06-16 08:32:06 +08:00
sky121113
d9edeb0d5d [FEAT] 電子發票後台開立、對帳、補開與作廢流程
1. 新增發票狀態機(pending/issued/failed/void)與冪等鍵 relate_number,並補上 last_checked_at、retry_count、voided_at、void_reason 欄位(migration 全欄位 nullable/有預設,對既有資料零影響並回填既有狀態)。
2. finalize 帶入發票輸入時改為先建 pending 發票,commit 後派發 IssueInvoiceJob 非同步向綠界開立,避免在交易內等待 ECPay 造成長交易。
3. 新增 EcpayInvoiceService 封裝綠界 B2C API(GetIssue 查詢、Issue 補開、Invalid 作廢),金鑰仍取自各機台 payment_configs。
4. 新增 invoices:reconcile 排程指令,每 5 分鐘對 pending 發票以 RelateNumber 向綠界 GetIssue 查證,補登 issued 或標記 failed 待補開。
5. SalesController 新增 reconcileInvoice/reissueInvoice/voidInvoice 三個後台操作端點與對應路由。
6. 發票列表改以 created_at 篩選(pending/failed 尚無 invoice_date 也能顯示),新增狀態過濾器與查詢/補開/作廢操作按鈕,狀態徽章改以狀態機顯示。
7. recordInvoice 改以 flow_id updateOrCreate 收斂,避免重送產生重複發票列;查詢機台/訂單時加上 withoutGlobalScopes。
8. 同步新增相關三語系字串並加入 ecpay_invoice base_url 設定。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:18:33 +08:00
sky121113
b8135acca9 [PROMOTE] 晉升手機支付與 LINE Pay 官方直連支付類型至 Demo
1. [FEAT] 新增支付碼 10(手機支付/NFC 行動支付)與 70(LINE Pay 官方直連),同步更新 Order.php、api-docs.php、api-technical-specs 規格與三語系。
2026-06-15 14:02:03 +08:00
sky121113
3d35218d6b [FEAT] 新增手機支付與 LINE Pay 官方直連支付類型
1. Order.php getPaymentTypeLabels() 新增支付碼 10(手機支付/NFC 行動支付,走信用卡軌道)與 70(LINE Pay 官方直連,非 TapPay 底下的 30)。
2. config/api-docs.php B600 order.payment_type 說明字串補上 10 與 70 兩碼定義。
3. api-technical-specs SKILL.md Appendix A 支付類型對照表同步補上 10、70,並將 30 備註明確為「TapPay 底下的 LINE Pay」以區分兩條 LINE Pay 路線。
4. 三語系 (zh_TW/en/ja) 新增 "Mobile Pay"、"LINE Pay (Official)" 詞條,並重新 ksort 對齊字母排序。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:55:08 +08:00
sky121113
a44b981059 [PROMOTE] dev → demo:商品詳情多行排版
1. [FEAT] 商品詳情支援多行排版並原樣下發 APP(textarea 輸入、放寬字數上限至 2000、products.spec 改 TEXT)。
2026-06-15 11:44:22 +08:00
sky121113
9511ebe71f [FEAT] 商品詳情支援多行排版並原樣下發 APP
1. 多語系輸入元件 product-locale-tabs 新增 multiline 參數,啟用時改用 textarea(保留換行、可垂直拖曳),名稱欄位維持單行 input。
2. 商品新增/編輯頁的「詳情」欄位改為多行輸入。
3. ProductController store/update 將 specs.* 與 spec 驗證上限由 255 放寬至 2000。
4. 新增 migration 將 products.spec 由 VARCHAR(255) 改為 TEXT,避免較長 zh_TW 詳情被截斷(translations.value 本即 TEXT)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:42:06 +08:00
sky121113
d84b2de1b7 [PROMOTE] dev → demo:商品多語系欄位標題改為「詳情」
1. [STYLE] 商品建立/編輯頁多語系區塊標題由「規格(多語系)」改為「詳情(多語系)」,三語系 JSON 同步換 key (Details (Multilingual));底層 spec 欄位與 B012 t060v03 不變。
2026-06-15 11:06:15 +08:00
sky121113
7f700df418 [STYLE] 商品多語系欄位標題由「規格」改為「詳情」
1. 商品建立/編輯頁多語系區塊標題改用 key Details (Multilingual),顯示為「詳情(多語系)」。
2. 三語系 JSON 以 Details (Multilingual) 取代 Specification (Multilingual)(zh_TW=詳情(多語系)/ en=Details (Multilingual) / ja=詳細(多言語)),維持字母排序與三語對齊。
3. 底層欄位 spec 與 B012 t060v03 不變,僅調整前台顯示標籤。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:22:20 +08:00
sky121113
7a97792b67 [PROMOTE] dev → demo:顯示語系選中態樣式調整
1. [STYLE] 機台系統設定彈窗「顯示語系」勾選按鈕與商品多語系 Tab:選中態由整顆深底反白改為淡 cyan 強調 (bg-cyan-500/10 + cyan 邊框/文字),降低對比、與 toggle 開關一致。
2026-06-15 09:31:17 +08:00
sky121113
a6caf0ded5 [STYLE] 調整顯示語系選中態為淡 cyan 強調
1. 機台系統設定彈窗「顯示語系」勾選按鈕:選中態由整顆深底反白 (bg-slate-900/dark:bg-white) 改為淡 cyan 強調 (bg-cyan-500/10 + border-cyan-500/40 + text-cyan-600/dark:text-cyan-300),降低對比、與 toggle 開關同一 cyan 語彙。
2. 商品名稱/規格多語系 Tab 元件 (product-locale-tabs):選中態比照改為相同淡 cyan 強調。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:24:30 +08:00
sky121113
6fe2ef4349 [PROMOTE] dev → demo:商品與機台多語系架構
1. [FEAT] 商品與機台多語系架構:config/locales.php 11 種語系白名單、公司機台語系聯集 (Company::activeLocalesFor)、商品名稱與規格多語系 (translations + spec_dictionary_key)、B012 新增 t060v01_i18n/t060v03_i18n (保留舊欄相容)、B014 新增 LangSet、機台設定顯示語系勾選 UI、商品編輯多語 Tab。
2. [FEAT] 系統設定同步:sync-settings 路由與 update_settings 指令類型、遠端歷史標籤、機台設定頁同步按鈕、B016 回寫測試。
3. [FIX] 修正多語系升級導致既有環境商品編輯器只剩繁中 Tab:回填 migration 補機台 languages=[zh_TW,en,ja]、商品編輯器 Tab 併入已有翻譯語系。
4. [DOCS] 同步更新 product_multilingual_spec 規格書、api-technical-specs SKILL、config/api-docs。
2026-06-15 09:10:07 +08:00
sky121113
1f1eb7714e [FIX] 修正多語系升級導致既有環境商品編輯器只剩繁中 Tab
1. 新增回填 migration:將既有機台 settings.languages 補為 ["zh_TW","en","ja"](僅補缺鍵/空值者,已設定者不動,以 DB 更新避免觸發機台事件),使 demo/prod 升級後公司語系聯集維持中英日、商品編輯器照常顯示三 Tab。
2. 商品編輯頁加保險:語系 Tab = 公司機台語系聯集 ∪「此商品已有翻譯的語系」,並依白名單排序,避免日後公司聯集縮減時藏掉既有翻譯而無法編輯。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:07:41 +08:00
sky121113
779c59138f [FEAT] 商品與機台多語系架構,並補上系統設定同步操作
1. 新增 config/locales.php:定義 11 種系統支援語系白名單(原文+中文註記)、每台機台上限 5 種與 fallback (zh_TW)。
2. Company 新增 activeLocalesFor()/activeLocales():計算公司所有機台已開語系的聯集(去重、依白名單排序、保底 zh_TW),加快取與 forgetActiveLocales() 失效機制。
3. products 新增 spec_dictionary_key 欄位(migration);Product 新增 localized_spec accessor 與 specTranslations 關聯,商品規格比照名稱支援多語系。
4. ProductController 商品建立/更新改收 names 與 specs 物件,寫入 translations(group=product/product_spec),既有商品首次編輯補建 spec key,刪除時一併清除規格翻譯。
5. ProductCatalogService(B012)保留既有 t060v01_en/_jp 欄位,新增 t060v01_i18n/t060v03_i18n locale map,依公司語系聯集輸出並回退 zh_TW,確保線上舊 App 相容。
6. 機台系統設定(updateSystemSettings)新增 languages 顯示語系驗證與寫入(最多 5 種、白名單、去重,僅系統管理員),異動時失效語系聯集快取並重建商品目錄。
7. B014(getSettings)新增 LangSet 下發機台顯示語系(Languages 有序清單+Default 預設)。
8. 商品建立/編輯頁名稱與規格改為多語系 Tab(讀公司機台語系聯集、完成度標記),新增共用元件 product-locale-tabs;機台系統設定彈窗新增顯示語系勾選 UI(限 5 選、預設標記)。
9. 三語系 JSON(zh_TW/en/ja)新增 5 組對齊鍵並維持字母排序。
10. 同步更新文件:docs/API/product_multilingual_spec.md(規格書)、api-technical-specs SKILL.md(B012 i18n/B014 LangSet)、config/api-docs.php(B012/B014 範例與欄位)。
11. 系統設定同步:新增 sync-settings 路由與 update_settings 指令類型,ProcessCommandAck 與遠端指令歷史(remote index/tab-history)支援 update_settings 標籤,機台系統設定頁新增「同步設定」操作按鈕,並補 B016 回寫測試。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:00:10 +08:00
sky121113
e0c4da0933 [PROMOTE] dev → demo:補齊 B690 來店禮錯誤回應文件
1. [DOCS] B690 來店禮驗證新增 400 / 404 / 429 錯誤回應 (SKILL.md、api-docs.php、api-docs.blade.php)。
2026-06-11 15:03:17 +08:00
sky121113
a5e5390cd1 [DOCS] 補齊 B690 來店禮驗證的錯誤回應文件
1. SKILL.md B690 章節新增 400 (格式錯誤) 與 429 (已鎖定) 兩種錯誤回應表格,與 verifyWelcomeGift 實際回傳一致。
2. config/api-docs.php B690 條目新增 error_responses 陣列,列出 400 / 404 / 429 錯誤碼與訊息。
3. api-docs.blade.php 新增通用「錯誤回應 (Error Responses)」渲染區塊,其他端點可沿用 error_responses 欄位。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:00:50 +08:00
sky121113
a969d2c4cc [PROMOTE] dev → demo 晉升取貨碼有效期限修正
1. [FIX] 修正取貨碼有效期限固定為 24 小時的問題:時數模式與自訂日期模式互斥停用未使用欄位,避免隱藏的 expires_at 預填值覆蓋使用者設定的時數。
2026-06-11 14:28:38 +08:00
sky121113
6ca3fa8a6b [FIX] 修正取貨碼有效期限固定為 24 小時的問題
1. 建立取貨碼時,隱藏的「自訂日期」欄位會被預填為明天並隨表單送出,導致後端 expires_at 永遠為 filled,固定套用約 24 小時。
2. 時數模式時 disabled 掉 expires_at 欄位,自訂日期模式時 disabled 掉 expires_hours 欄位,使停用欄位不被提交。
3. 修正後時數滑桿與自訂日期可各自正確生效,取貨碼有效期限依使用者設定產生。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:28:03 +08:00
sky121113
4e5f952f28 [PROMOTE] dev → demo:B016 設定回寫與 B000 identity
1. [FEAT] B016 機台系統設定回寫端點:系統方回寫 machines.settings 與貨道欄位,含路由、SKILL.md §3.8 與 api-docs.php 文件
2. [FEAT] B000 登入回應新增 identity 身分欄位 (system/company/staff),並補 MachineAuthB000Test 共 5 案例

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:23:35 +08:00
sky121113
231cff9574 [FEAT] 新增 B016 機台系統設定回寫端點與文件
1. MachineController 新增 updateSettings (B016):機台主控台系統設定由系統方編輯後回寫雲端,僅系統管理員可操作,白名單布林開關合併進 machines.settings JSON 與 is_spring_slot_* 實體欄位,記錄 updater_id
2. routes/api.php 新增 PATCH /api/app/machine/setting/B016 路由,採 auth:sanctum (B000 User Token) 與 throttle:60,1
3. api-technical-specs SKILL.md 新增 §3.8 B016 規格:認證、白名單鍵、403/404/422 錯誤碼,並標注與 B014 下載的 PascalCase 命名方向不對稱
4. config/api-docs.php 新增 B016 條目,對齊既有 entry 格式

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:21:17 +08:00
sky121113
339b33d09b [FEAT] B000 登入回應新增 identity 身分欄位
1. MachineAuthController loginB000 於 RBAC 判斷時記錄登入者身分,成功回應加入 identity 欄位 (system/company/staff)
2. 未授權與帳密錯誤回應維持不含 identity,避免洩漏身分
3. 同步更新 api-technical-specs SKILL.md 與 config/api-docs.php 回應參數文件
4. 新增 MachineAuthB000Test 涵蓋三身分成功與 401/403 失敗共 5 案例

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:09:44 +08:00
sky121113
a1695789dd [PROMOTE] dev → demo:修正商品分類頁 Alpine 範圍崩壞
1. [FIX] 移除 tab-categories.blade.php 多餘的 </div>,修復因 x-data 範圍提前關閉導致操作紀錄 tab、編輯分類 modal、匯入 modal 失效的問題。
2026-06-10 16:14:04 +08:00
sky121113
2b25c5bebf [FIX] 修正商品分類頁多餘 </div> 導致 Alpine 範圍崩壞
1. 移除 tab-categories.blade.php 手機版卡片區塊多出的一個 </div>(原本 20 個 <div 卻有 21 個 </div>)。
2. 該多餘結束標籤在 @forelse 迴圈內提前關閉外層容器,連鎖將整個 x-data="productManager" 元件 div 提前關閉。
3. 修正後操作紀錄 (logs) tab、編輯分類 modal (openCategoryModal)、匯入 modal (isImportModalOpen) 重新回到 Alpine 元件範圍內,消除 activeTab/tabLoading/isImportModalOpen is not defined 等錯誤。
4. 後端邏輯未變動,原本操作紀錄已正常寫入資料庫,僅前端因範圍崩壞無法切換 tab 與開啟編輯。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:11:27 +08:00
sky121113
cfa824f905 [PROMOTE] dev → demo:APK 機台風味欄位自由輸入
1. [FEAT] APK 版本建立頁「機台風味」欄位改為自由輸入 (text input + datalist 建議值),新增必填驗證、檔名解析填入調整,並同步 zh_TW / en / ja 三語系。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:12:06 +08:00
sky121113
378332be25 [FEAT] APK 機台風味欄位改為自由輸入並補驗證與三語系
1. APK 版本建立頁「機台風味」欄位由固定下拉選單 (x-searchable-select) 改為自由輸入 text input,搭配 datalist 提供常用 flavor 建議值,允許填入任意 flavor。
2. 表單送出新增 flavor 必填驗證,未填時跳出 toast 提示。
3. APK 檔名自動解析填入邏輯改為直接寫入 x-model flavor,移除原 Preline HSSelect 實例操作。
4. 新增多語系 key「Please enter machine flavor」並同步 zh_TW / en / ja。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:10:36 +08:00
sky121113
1a004a4d08 [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FEAT] 金流配置與機台系統設定整合進 B014,補完機台端設定下發(DevSet/CashSet/FunctionSet/ShoppingMode/OperationSet/HardwareSet、Line官方支付、卡片支付細項簡化、編輯機台區塊調整、API 文件同步)。
2. [FIX] 訪客取貨/通行頁 QR Code 改用公開路由,修正手機開啟失效。
3. [DOCS] 新增 CLAUDE.md 共用專案開發規範。
4. [CHORE] 將 Claude Code 個人設定排除於版控外。
5. [REFACTOR] 機台系統設定 settings JSON 化與編輯介面多語系/UI 優化。
2026-06-10 13:04:30 +08:00
sky121113
ed79a6ab32 [FEAT] 金流配置與機台系統設定整合進 B014,補完機台端設定下發
1. 金流配置表單語意分組與多語系:綠界發票/玉山掃碼/Line官方支付/Tappay 四區 label 改用無歧義 key 並補齊 en/ja/zh_TW,發票區新增「發票通知 Email」欄位。
2. B014 密鑰讀取對齊金流配置巢狀結構(esun_scan/tappay/ecpay_invoice),發票改讀 payment_configs(不再讀 companies 表),統一以 data_get 安全取值。
3. B014 機台系統設定全面改以機台端 *Set + PascalCase 風格下發:DevSet/CashSet/FunctionSet/ShoppingMode/OperationSet(運作參數)/HardwareSet(硬體與貨道),移除原始 key 雜燴的 MachineSettings 物件。
4. 釐清 LINE Pay 兩條路線:DevLinePay 對應「Line官方支付」官方直連(scan_pay_linepay_enabled)、TapPay30 對應 TapPay 底下 LINE Pay(tappay_linepay)。
5. 機台系統設定 modal 新增「Line官方支付」開關(置於掃碼支付下方),並納入支付方式全選;機台清單「啟用功能」同步顯示。
6. 移除卡片支付子項(悠遊卡/一卡通/icash),卡片支付開啟即視為全支援。
7. 編輯機台頁移除「會員與外部系統」區塊與「發票開立狀態」欄位(會員/來店禮改由機台系統設定管理)。
8. 同步更新 API 文件(api-technical-specs SKILL.md 與 config/api-docs.php),逐項標註回應參數對應來源與機台既有/新定義結構。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:01:52 +08:00
sky121113
722993a078 [FIX] 訪客取貨/通行頁 QR Code 改用公開路由,修正手機開啟失效
1. 新增公開路由 qr-code.public(throttle:120,1),不需管理員登入即可產生 QR 圖片,供訪客頁渲染使用。
2. qr-code Blade 元件 dynamic=false 分支改指向 qr-code.public,原本指向需登入的 admin.basic-settings.qr-code,導致手機開連結時因無 session 被導向登入頁、圖片失效。
3. 後台列表頁(dynamic=true,登入狀態)維持原 admin 路由不變。
2026-06-10 11:15:57 +08:00
sky121113
14cef19d7d [DOCS] 新增 CLAUDE.md 共用專案開發規範
1. 於專案根目錄新增 CLAUDE.md,透過 @ import 載入既有 .agents/rules/(framework、api-rules、rbac-rules、skill-trigger),讓 Claude Code 與 Antigravity 共用同一份規範來源,不重複維護。
2. 補充 Claude 專屬說明:view_file 對應 Read,以及 /now-push 等自訂指令對應的提交與環境晉升流程。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:46:15 +08:00
sky121113
74c5570661 [CHORE] 將 Claude Code 個人設定排除於版控外
1. .gitignore 新增 /.claude/settings.json 與 /.claude/settings.local.json,避免 session 中途授權產生的個人權限設定被誤提交。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:38:18 +08:00
sky121113
17f565bded [REFACTOR] 機台系統設定 settings JSON 化與編輯介面多語系/UI 優化
1. Machine 模型:新增 settings JSON 的 accessors/mutators(shopping_mode、tax_invoice_enabled、card_terminal_enabled、scan_pay_esun/linepay_enabled、shopping_cart_enabled 等),讀寫統一收斂到 settings 欄位並同步既有資料表欄位。
2. MachineSettingController:system_settings 儲存邏輯補上 pickup_module_enabled(納入 allowedFields,非基礎版模式時強制關閉)。
3. 多語系補完:新增 Edit Machine System Settings、Basic Mode Features、Bill、Coin、Search machine name or code... 等 key(en/ja/zh_TW),並把寫死的「鈔/幣」面額前綴與中文搜尋框 placeholder 改為 __() 翻譯。
4. 編輯 modal UI:各支付區塊「全選/取消全選」移至標題旁;取貨模組新增區塊總開關與全選;購物車/來店禮改為卡片版型、開關靠右移除重複標籤。
5. 基礎版專屬功能以容器外框+標籤分組,與會員系統/稅務系統/硬體周邊(永遠顯示)做出視覺區隔;「支付方式」全選一併納入取貨模組。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:34:11 +08:00
sky121113
fa50d39020 [RELEASE] 晉升 demo 變更至正式 main 環境
1. 晉升補貨單完成與機台庫存去耦邏輯,避免因先上報後點確認造成的重複累加 Bug,並防範時間差空轉出貨之客訴風險。
2026-06-09 14:41:03 +08:00
sky121113
4e0f4dd747 [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FIX] 補貨單完成邏輯與機台庫存去耦,防範重複累加 Bug
2. [FIX] 熱修復 Flatpickr 初始化中 Blade 大括號內不必要的反斜線轉義
2026-06-09 13:54:42 +08:00
sky121113
f34a7bf9bc [FIX] 補貨單完成邏輯與機台庫存去耦,防範重複累加 Bug
1. 移除補貨單狀態進入已完成 (STATUS_COMPLETED) 時自動對機台貨道庫存進行累加的舊邏輯。
2. 移除伴隨上述更新所產生的機台補貨流水帳紀錄 (MachineStockMovement)。
3. 使機台實體庫存回歸以機台端上報 (B009/B018) 及手動貨道校正為唯一事實來源,防範因先上報後結案產生的重複累加及時間差空轉售罄客訴風險。
2026-06-09 11:11:09 +08:00
sky121113
964c62efb6 [RELEASE] 同步 prod deploy 安全流程 2026-06-05 22:10:22 +08:00
sky121113
af6c234cdc [PROMOTE] 同步 prod deploy 安全流程 2026-06-05 22:09:42 +08:00
sky121113
4efff257eb ci: keep prod deploy from recreating containers 2026-06-05 22:09:00 +08:00
sky121113
954e2ceedd [RELEASE] 晉升 demo 到 main 正式上線
1. [FEAT] 重構商品與機台報表統計邏輯,對齊實際成功出貨件數
2. [FIX] 修正機台報表因 joinSub (INNER JOIN) 漏掉無付費營業額機台出貨數量的問題
3. [FIX] 修正商品與機台報表因 order_items 與 dispense_records 聯結產生笛卡爾積導致件數重複加總的問題
4. [PROMOTE] 同步部署工作流 .gitea/workflows/deploy-prod.yaml
2026-06-05 22:02:19 +08:00
sky121113
b8fea39e2b [PROMOTE] 晉升 dev 到 demo
1. [FIX] 修正商品與機台報表因 order_items 與 dispense_records 聯結產生笛卡爾積導致件數重複加總的問題
2026-06-05 21:57:02 +08:00
sky121113
b6c7142677 [FIX] 修正商品與機台報表因 order_items 與 dispense_records 聯結產生笛卡爾積導致件數重複加總的問題 2026-06-05 21:56:41 +08:00
sky121113
c9fa5ea5e2 [PROMOTE] 晉升 dev 到 demo
1. [FIX] 修正機台報表因 joinSub 漏掉無付費營業額機台出貨數量的問題
2026-06-05 21:50:37 +08:00
sky121113
e29a01f611 [FIX] 修正機台報表因 joinSub 漏掉無付費營業額機台出貨數量的問題 2026-06-05 21:50:23 +08:00
sky121113
36c0763106 [PROMOTE] 同步部署工作流 .gitea/workflows/deploy-prod.yaml 2026-06-05 17:38:13 +08:00
sky121113
01984139c5 [PROMOTE] 晉升 dev 到 demo
1. [FEAT] 重構商品與機台報表統計邏輯,對齊實際出貨成功件數
2026-06-05 17:35:38 +08:00
sky121113
6173851920 [FEAT] 重構商品與機台報表統計邏輯,對齊實際出貨成功件數 2026-06-05 17:34:55 +08:00
sky121113
098ab23e47 [RELEASE] 晉升 demo 至 main - 簡化商品 ID 顯示為 ID
1. 簡化「庫存與效期」與「機台庫存概覽」中商品 ID 的多語系標記,顯示文字改為「ID: {id}」
2026-06-05 16:58:02 +08:00
sky121113
86564b5add [PROMOTE] 晉升 dev 至 demo - 簡化商品 ID 顯示為 ID
1. 簡化「庫存與效期」與「機台庫存概覽」中商品 ID 的多語系標記,顯示文字改為「ID: {id}」
2026-06-05 16:57:22 +08:00
sky121113
66416b14ed [STYLE] 簡化庫存概覽與庫存效期的商品 ID 標記為 ID 2026-06-05 16:56:48 +08:00
sky121113
d457e2095d [RELEASE] 晉升 demo 至 main - 統一庫存與效期商品規格
1. 統一「庫存與效期」與「機台庫存概覽」中商品 ID 的多語系顯示為「商品 ID」
2. 移除「庫存與效期」桌面列表商品欄位中重複的效期顯示
3. 縮小「機台庫存概覽」中商品儲存格的效期文字至 text-xs,與商品 ID 保持大小一致
2026-06-05 16:55:17 +08:00
sky121113
deee087f6f [PROMOTE] 晉升 dev 至 demo - 統一庫存與效期商品規格
1. 統一「庫存與效期」與「機台庫存概覽」中商品 ID 的多語系顯示為「商品 ID」
2. 移除「庫存與效期」桌面列表商品欄位中重複的效期顯示
3. 縮小「機台庫存概覽」中商品儲存格的效期文字至 text-xs,與商品 ID 保持大小一致
2026-06-05 16:54:23 +08:00
sky121113
eab9b90b88 [STYLE] 統一機台庫存概覽與庫存效期的商品 ID 與效期文字規格 2026-06-05 16:53:27 +08:00
sky121113
d326697417 [STYLE] 統一機台庫存概覽與庫存效期的商品 ID 多語系名稱
1. 將寫死的「ID:」改為多語系「{{ __('Product ID') }}:」。
2. 修改 zh_TW.json,將 Product ID 的繁體中文翻譯由「商品編號」改為「商品 ID」,與列表上的「效期」在中文語境下完全對齊統一。
2026-06-05 16:49:16 +08:00
sky121113
acd2041043 [RELEASE] 將 demo 分支合併至 main 生產環境
1. 實作機台報表分析功能與商品報表篩選列優化,包含多租戶資料隔離與帳號授權限制。
2. 於機台庫存概覽與庫存效期管理頁面中加顯示商品 ID,覆蓋 Table View、Grid View、Mobile Card View 以及調整庫存彈窗。
2026-06-05 16:44:38 +08:00
sky121113
5673856ca3 [PROMOTE] 將 dev 分支晉升至 demo
1. 實作機台報表分析功能與商品報表篩選列優化,包含多租戶資料隔離與帳號授權限制。
2. 於機台庫存概覽與庫存效期管理頁面中加顯示商品 ID,覆蓋 Table View、Grid View、Mobile Card View 以及調整庫存彈窗。
2026-06-05 16:43:24 +08:00
sky121113
6a600bb009 [FEAT] 在機台庫存概覽與庫存效期管理頁面加顯示商品 ID
1. 修改 resources/views/admin/warehouses/machine-inventory.blade.php,於 Table View、Grid View 和 Mobile Card View 中加顯示商品 ID (ID: {id})。
2. 修改 resources/views/admin/remote/stock.blade.php,於 Table View、Grid View、Mobile Card View 和 Edit Modal 中加顯示商品 ID (ID: {id})。
2026-06-05 16:36:45 +08:00
sky121113
8f2dd1375e [FEAT] 實作機台報表分析功能與商品報表篩選列優化
1. 實作 AnalysisController@machineReports 後端邏輯,支援租戶隔離與帳號機台授權過濾。
2. 建立 machine-reports.blade.php 視圖,加入極簡奢華風統計卡片、ApexCharts 趨勢圖與自訂多選支付工具篩選器,並停用圖表縮放優化滾輪滾動體驗。
3. 優化 product-reports.blade.php,新增機台下拉選單過濾,並啟用寬度優化與響應式自動換行,且同步停用圖表縮放。
4. 在 Machine model 全域 Scope 中排除單元測試阻擋,並更新 RoleSeeder 播種機台報表權限。
5. 新增 AnalysisReportsTest 測試,並更新 AnalysisPermissionTest 權限測試,且在 Sail 中驗證為 100% 通過。
6. 同步更新繁中、英文、日文翻譯 JSON 檔案,以 zh_TW 為基準完美對齊、字母排序且還原斜線。
2026-06-05 16:21:05 +08:00
sky121113
c04159405f [RELEASE] 將測試驗收分支 demo 變更發布至生產環境 main
1. 遠端庫存與效期管理:實作列表與網格視圖切換、支援 slot 行與卡片點擊彈出編輯 Modal 進行庫存/效期修改。
2. 遠端庫存樣式優化:對齊 Table View 卡片邊框、列懸停背景色、列分隔線顏色與 Expiry 日期橫線格式。
3. 多租戶權限過濾:於機台管理與報修管理頁面新增僅系統管理員可見的 Company Filter 公司選單。
4. 更新相關翻譯語系與 CI/CD 設定檔。
2026-06-05 14:22:48 +08:00
sky121113
d7486a38f6 [PROMOTE] 將開發環境 dev 變更晉升至 demo 環境
1. 新增遠端管理「庫存與效期」之列表/網格視圖切換與 Slot 庫存編輯修改功能。
2. 對齊遠端管理 Table View 樣式、hover 背景色、列分隔線顏色與 Expiry 日期格式,與庫存概覽保持一致。
3. 實作機台管理與報修管理頁面在 SaaS 多租戶環境下的 Company Filter 公司篩選隔離功能。
4. 更新相關語系翻譯檔案與 CI/CD 設定檔。
2026-06-05 14:21:14 +08:00
sky121113
857198bbba [FEAT] 新增遠端庫存列表視圖與機台管理租戶公司過濾功能
1. 在遠端管理「庫存與效期」頁面實作列表視圖 (Table View) 與手機版卡片視圖 (Mobile Card View),並支援點擊 Slot 行/卡片彈出編輯 Modal 進行庫存與效期修改。
2. 調整遠端庫存 Table 的 border、hover 背景色、divide 分隔線顏色與 Expiry 日期格式,使其與庫存概覽列表視圖完美對齊。
3. 在機台管理 (Machine Management) 與報修管理 (Maintenance Management) 等頁面新增僅系統管理員可見的 Company Filter 租戶過濾下拉選單。
4. 更新 zh_TW.json、en.json、ja.json 翻譯檔案與相關說明文件。
2026-06-05 14:20:35 +08:00
sky121113
e414396b0c [FEAT] 於機台管理與機台設定頁面新增公司篩選功能
1. 於機台管理 (MachineController 與 index.blade.php) 為系統管理員新增公司篩選下拉選單,並支援分頁參數與 AJAX 查詢。
2. 於機台設定頁面中的機台列表 (tab-machines.blade.php) 與系統設定列表 (tab-system-settings.blade.php) 新增公司篩選器。
3. 在 MachineSettingController 傳遞公司列表資料至視圖,以便支援公司篩選。
4. 修正重置按鈕,使其能正確清除 Preline HSSelect 的選取狀態並重新載入資料。
2026-06-05 13:01:52 +08:00
sky121113
81aae1261c [PROMOTE] 晉升 demo 變更至 main 生產環境
1. 實作自訂品牌樣式設定與專屬登入頁面預覽功能。
2. 於基本設定的機台管理新增鑰匙編號欄位與位置調整。
3. 於登入頁面新增防止重複提交與載入動畫效果。
2026-06-05 12:03:10 +08:00
sky121113
5b5e0133e0 [PROMOTE] 晉升 dev 變更至 demo 環境
1. 於 `login.blade.php` 登入頁面引入 Alpine.js,新增防止重複提交邏輯及 Spinner 載入動畫。
2026-06-05 11:57:59 +08:00
sky121113
9a59186cd4 [FEAT] 登入頁面新增防止重複提交與載入動畫效果
1. 於 `resources/views/auth/login.blade.php` 登入表單引入 Alpine.js 狀態管理,新增 `isSubmitting` 控制。
2. 登入按鈕於提交時自動處於 disabled 狀態,避免使用者重複點擊。
3. 於登入按鈕內新增 Spinner 載入動畫與「登入中」文字狀態提示,提升使用者互動體驗。
2026-06-05 11:56:22 +08:00
sky121113
a39db64eac [PROMOTE] 晉升 dev 變更至 demo 環境
1. 於機台新增/詳情 Modal 及清單中新增鑰匙編號 (Key No) 展示與儲存。
2. 調整機台編輯畫面中的「鑰匙編號」至「客戶公司」旁邊以優化排版。
3. 補齊鑰匙編號輸入框的繁中、英文、日文多語系翻譯。
4. 新增基本設定機台管理 Feature Test 案例並通過驗證。
2026-06-05 11:52:23 +08:00
sky121113
0964981097 [FEAT] 基本設定的機台管理新增鑰匙編號欄位與位置調整
1. 於 `MachineSettingController.php` 的 `store` 驗證規則中新增 `key_no` 欄位。
2. 於 `index.blade.php` 的新增機台 Modal 及詳情 Drawer 中,加入「鑰匙編號」欄位與展示。
3. 於機台列表 `tab-machines.blade.php` (桌面表格與行動版卡片) 新增「鑰匙編號」顯示,並同步更新 Detail Drawer 參數。
4. 於 `edit.blade.php` 將「鑰匙編號」欄位移至「基本資訊」區塊並與「客戶公司」並排顯示。
5. 補齊並自動對齊繁中、英文、日文翻譯。
6. 撰寫 `MachineSettingTest.php` 測試案例,驗證基本設定中新增與更新機台鑰匙編號之邏輯。
2026-06-05 11:50:00 +08:00
sky121113
750108fa83 [PROMOTE] 晉升 dev 變更至 demo 分支
1. 實作客戶自訂品牌樣式 (Custom Branding) 設定(支援 Logo、登入頁大背景、卡片背景上傳)。
2. 提供專屬登入頁面與預覽功能 (/c/{company_code}/preview),補全相關安全驗證邏輯與測試。
3. 優化 .gitea/workflows/deploy-prod.yaml 的 Docker 容器建置與健康檢查部署流程。
2026-06-05 09:02:49 +08:00
sky121113
11eca730f7 [FEAT] 實作自訂品牌樣式設定與專屬登入頁面預覽功能
1. 實作自訂品牌樣式設定,支援上傳 Logo、登入大背景圖與左側卡片背景圖。
2. 整合 ImageHandler 處理上傳圖片,自動壓縮並轉為優質 webp 格式以提升載入速度。
3. 將品牌設定自 Modal 拆分,新增獨立的 branding.blade.php 設定頁面以優化操作體驗。
4. 於 TenantLoginController 新增登入頁預覽 (preview) 與專屬首頁重導向 (indexRedirect) 路由邏輯。
5. 調整 auth/login.blade.php 以支援動態呈現自訂背景圖、Logo、主副標題,且在預覽模式下阻擋登入並顯示頂部橫條。
6. 修正租戶登入安全性比對,加強 (int) 型態校驗並豐富 Log 警示資訊。
7. 更新並對齊 zh_TW.json、en.json 與 ja.json 三語系檔之品牌客製化相關辭彙。
8. 補充完整 Feature Test (CompanySettingsTest),全面涵蓋物理刪除、上傳限制、預覽渲染、跳轉與登入安全性限制之測試。
2026-06-05 08:44:38 +08:00
sky121113
c6ce58f14a [PROMOTE] demo -> main 2026-06-04 13:33:41 +08:00
sky121113
5124217636 [PROMOTE] dev -> demo 2026-06-04 13:13:37 +08:00
sky121113
06d93889b3 [FEAT] 依據環境溫度監測啟用狀態動態顯示日誌 Tab
1. 在 machines/index.blade.php 的 Alpine x-data 中新增 currentMachineAmbientTempEnabled 屬性。
2. 修改 openLogPanel 方法以傳入機台的環境溫度監測啟用狀態。
3. 於機台列表所有呼叫 openLogPanel 處帶入 ambient_temp_monitoring_enabled。
4. 在「環境溫度回傳」Tab 的按鈕上加入 x-show 與 x-cloak,使該 Tab 僅在機台啟用監測時顯示,避免非必要 Tab 的干擾與界面閃爍。
2026-06-04 13:13:20 +08:00
sky121113
a12b6aac19 [PROMOTE] 晉升 dev 到 demo 環境
1. 統一基本設定權限機制,將 DC通知 與 APK版本 權限移出客戶管理員預設範本,比照金流與機台設定,避免部署被強行還原。
2. 刪除 menu.special-permission.apk-versions 與 menu.special-permission.discord-notifications 冗餘特殊權限,並清理多語系檔對應 Key。
3. 修正 DiscordNotificationSecurityTest.php 安全驗證測試以適應新權限規則。
2026-06-04 13:05:45 +08:00
sky121113
7ceb7644f0 [FIX] 統一基本設定權限機制並清除冗餘特殊權限
1. 修正 RoleSeeder.php:移除了預設分配給客戶管理員角色模板的 menu.basic.discord-notifications 與 menu.basic.apk-versions 權限,使其運作機制與機台設定、金流設定完全一致,預設不分配且避免部署時被強制重灌。
2. 清除冗餘權限:從 Seeder 權限宣告中移除專案未引用的 menu.special-permission.apk-versions 與 menu.special-permission.discord-notifications。
3. 語系檔對齊整理:自 zh_TW.json, en.json, ja.json 中刪除冗餘的特殊權限翻譯 Key,並重新按字母排序(ksort)且還原斜線。
4. 修正單元測試:調整 DiscordNotificationSecurityTest.php,在 setUp 中為測試角色手動指派基本通知權限以通過 Gate 檢查,成功修正測試案例。
2026-06-04 13:01:49 +08:00
sky121113
9286d4a387 [PROMOTE] dev -> demo 2026-06-04 12:01:13 +08:00
sky121113
7579a0b5be [FEAT] 調整環境溫度上報文檔順序與機台硬體事件日誌歸類
1. 文件調整:將 config/api-docs.php 中的「機台環境溫度上報 (mqtt-ambient-temp-report)」MQTT API 區塊位置向上移至「機台硬體事件上報 (mqtt-event)」上方。
2. 日誌歸類:修改 ProcessMachineEvent.php,將機台硬體事件 (event) 日誌寫入資料庫時的 type 從 'status' (機台狀態) 改為 'ambient_temp' (環境溫度回傳),以直接顯示於「環境溫度回傳」Tab。
2026-06-04 12:00:54 +08:00
sky121113
ccdf2b4070 [PROMOTE] dev -> demo 2026-06-04 11:53:11 +08:00
sky121113
cacae84a85 [DOCS] 補上機台環境溫度上報的 MQTT API 文檔
1. 在 config/api-docs.php 新增 machine/{serial_no}/ambient_temp Topic 的說明與參數定義。
2026-06-04 11:52:58 +08:00
sky121113
025432eefd [PROMOTE] dev -> demo 2026-06-04 11:48:44 +08:00
sky121113
953581db38 [FEAT] 新增環境溫度上限監測與遠端風扇指令控制功能
1. 資料庫移轉:新增機台環境溫度、環境溫度上限設定,以及是否啟用環境溫度監測等欄位。
2. 後端核心:更新 Machine Model 與 MachineController,實作機台環境溫度設定的更新與歷史日誌 Ajax API。
3. MQTT 佇列:於 ListenMqttQueue 指令與 ProcessAmbientTemp Job 實作機台上報環境溫度的接收與寫入,並加入 Redis 快取防止重複日誌洗版。
4. 遠端指令:擴充 RemoteController 並於指令中心實作 'fanon'、'fanoff'、'fanauto' 與 'ambient_temp_limit' (環境溫度上限) 指令下發。
5. 前端頁面:於機台列表加入「環境溫度上限」微調計數器 UI,並整合 ApexCharts 折線圖展示溫度趨勢;更新遠端控制中心按鈕與歷史篩選器。
6. 多語系:同步更新並完美對齊 'zh_TW.json'、'en.json' 與 'ja.json',遵循字母 ksort 排序與斜線不逸出規範。
2026-06-04 11:45:48 +08:00
sky121113
9185382565 ci: make prod deploy avoid container recreate 2026-06-04 09:27:49 +08:00
sky121113
8d504c8b33 ci: make prod deploy avoid container recreate 2026-06-04 09:14:50 +08:00
sky121113
6fb9c9c7ec [PROMOTE] 晉升 demo 至 main
1. [FEAT] 調整部署工作流以保留 Docker 建置快取
2. [FEAT] 調整生產環境部署流程的容器健康檢查等待邏輯與清理策略
2026-06-04 08:27:52 +08:00
sky121113
68242fd22a [PROMOTE] 晉升 dev 至 demo
1. [FEAT] 調整生產環境部署流程的容器健康檢查等待邏輯與清理策略
2026-06-03 17:45:41 +08:00
sky121113
6a251df81b [FEAT] 調整生產環境部署流程的容器健康檢查等待邏輯與清理策略
1. 修改 .gitea/workflows/deploy-prod.yaml 的 Step 1,將 docker-compose --wait 改為手動 shell 迴圈,動態輪詢 star-cloud-mysql、star-cloud-redis、star-cloud-emqx 的健康狀態,並在超時 (120秒) 時輸出日誌以提升部署強固性。
2. 調整 Step 5 - Cleanup,將 docker image prune -f 限制為只清理超過一週 (168h) 的過期映像檔,以保留 Docker 建置快取。
2026-06-03 17:36:57 +08:00
sky121113
3b6b999d75 ci: verify demo cache after conservative prune 2026-06-03 17:17:22 +08:00
sky121113
1864df0fc2 [FEAT] 調整 demo 部署流程的 Docker 映像檔清理策略
1. 修改 .gitea/workflows/deploy-demo.yaml,將 Step 5 - Cleanup 步驟從跳過清理改為執行 docker image prune -f --filter "until=168h",自動清理超過 168 小時(一週)的過期 Docker 映像檔,釋放主機磁碟空間。
2026-06-03 17:12:08 +08:00
sky121113
f8594fede2 ci: verify demo build cache 2026-06-03 16:58:12 +08:00
sky121113
d5a138f6c2 [PROMOTE] 晉升 dev 變更至 demo 環境
1. 修改 .gitea/workflows/deploy-demo.yaml,將 Step 5 - Cleanup 的 prune 動作替換為 echo 跳過,以保留 Docker 建置快取。
2026-06-03 16:42:46 +08:00
sky121113
e55fade6d7 [FEAT] 調整部署工作流以保留 Docker 建置快取
1. 修改 .gitea/workflows/deploy-demo.yaml,將 Step 5 - Cleanup 的 prune 動作替換為 echo 跳過,以保留 Docker 建置快取。
2026-06-03 16:41:07 +08:00
sky121113
7f9958ad7e [RELEASE] 晉升 demo 到 main 正式環境
1. 實作機台貨道類型與商品容量上限的雙向連動同步機制。

2. 整理 .gitea/workflows/deploy-demo.yaml 部署工作流,移除多餘的空白行。
2026-06-03 16:25:36 +08:00
sky121113
8bd9096bc4 [PROMOTE] 晉升 dev 到 demo 環境
1. 移除 .gitea/workflows/deploy-demo.yaml 檔案中 Step 1 的多餘空白行。
2026-06-03 16:03:01 +08:00
sky121113
2cad7290e5 [STYLE] 移除部署工作流中的多餘空行
1. 移除 .gitea/workflows/deploy-demo.yaml 檔案中 Step 1 - Build & Deploy Containers 前方的多餘空白行。
2026-06-03 16:02:19 +08:00
sky121113
97fb828a9b [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FEAT] 實作機台貨道類型與商品容量上限的雙向連動同步機制,包含 Product、Machine 模型更新監聽與 MachineService 重構。
2. 刪除根目錄的 4 月報告暫存檔案 (Star_Cloud_April_Report.pdf/pptx)。
2026-06-03 15:35:25 +08:00
sky121113
c5238ccfbc [FEAT] 實作機台貨道類型與商品容量上限的雙向連動同步機制
1. 於 app/Models/Product/Product.php 實作更新事件監聽器,當商品的履帶/彈簧上限變更時,自動更新所有關聯貨道之最大容量與類型。
2. 於 app/Models/Machine/Machine.php 實作更新事件監聽器,當機台的彈簧貨道區間設定變更時,自動更新該機台所有貨道之最大容量與類型。
3. 於 app/Services/Machine/MachineService.php 重構貨道同步 (syncSlots) 與類型容量判定邏輯,加入雙向連動同步、貨道類型判斷等輔助方法,並優化庫存異動紀錄 (StockMovement) 的觸發機制,避免無謂的調整日誌。
4. 移除專案根目錄下的 4 月工作報告暫存檔 (Star_Cloud_April_Report.pdf, Star_Cloud_April_Report.pptx)。
2026-06-03 15:33:49 +08:00
sky121113
de8477bc7e [PROMOTE] 晉升 demo 變更至 main 生產環境
1. [FIX] 補全下位機 Command(0x02) 狀態代碼與對齊三語系資源檔。
2. [FEAT] 新增溫控感測器離散事件接收處理機制與文件同步。
3. [FIX] 修正商品管理分頁排序不唯一導致的重複商品問題。
2026-06-02 16:41:56 +08:00
sky121113
e42aec1354 [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FIX] 修正商品管理分頁排序不唯一導致的重複商品與漏資料問題。
2026-06-02 16:41:09 +08:00
sky121113
9de3044e46 [FIX] 修正商品管理分頁排序不唯一導致的重複商品問題
1. 於 ProductController.php 的商品列表分頁中,將 latest() 改為明確的雙重排序 orderBy('created_at', 'desc')->orderBy('id', 'desc')。
2. 於商品分類列表分頁中,加入 orderBy('id', 'desc') 作為次要排序鍵,確保分頁的唯一性。
3. 於系統操作日誌分頁中,同步加入次要排序鍵優化分頁結果。
2026-06-02 16:39:30 +08:00
sky121113
7485b36b2f [PROMOTE] 晉升 dev 變更至 demo 測試驗收環境
1. [FEAT] 新增溫控感測器物理事件上報專屬接收機制(ProcessMachineEvent Job & ListenMqttQueue),並實踐收到什麼就存什麼的扁平極簡架構。
2. [FEAT] 優化 api-docs.php 規格文件,將風扇開關說明移去 status 主題,並新增獨立的 event 事件主題文件區塊。
3. [FIX] 補全與修正 MachineService.php 中的 02xx 系列下位機官方協議錯誤代碼(0201~0223)。
4. [FIX] 完美對齊並依字母升冪順序排序 zh_TW.json、en.json、ja.json 三語系語系檔,並進行斜線去逸出處理。
2026-06-02 14:24:34 +08:00
sky121113
9e3eb8944b [FEAT] 新增溫控感測器離散事件接收處理機制與文件同步
1. [FEAT] 新增 App/Jobs/Machine/ProcessMachineEvent.php 用以非同步處理 machine/{serial_no}/event 事件,將收到的風扇狀態(fanon/fanoff)記錄在機台狀態日誌中。
2. [FEAT] 修改 ListenMqttQueue.php 支援 event 類型的 MQTT 事件分派路由。
3. [FEAT] 更新 config/api-docs.php 規格文件,將原本在 status 主題的風扇說明移去,並新增獨立的 event (機台硬體事件上報) 主題文件。
4. [FIX] 更新多語系資源檔,新增 fanon 與 fanoff 扁平鍵對譯並進行 ksort 排序與去逸出美化。
2026-06-02 14:19:48 +08:00
sky121113
dcf6494e82 [PROMOTE] 晉升 demo 變更至 main 生產環境
1. [FEAT] 新增多租戶自訂登入功能,支援公司全域與機台專屬自訂歡迎副標題與背景圖。
2. [FEAT] 新增品牌樣式客製化功能,允許公司設定自訂樣式(Logo、主題顏色)。
3. [FEAT] 調整 APK 版本管理介面,加入上傳時間與 APK 來源顯示。
2026-06-02 13:55:44 +08:00
sky121113
fba0dc6bf9 [FIX] 補全下位機 Command(0x02) 狀態代碼與對齊三語系資源檔
1. 修正 app/Services/Machine/MachineService.php 中的 02xx 系列錯誤對譯表,移除原本殘缺、衝突的舊佔位符,完整補足下位機 0x01 到 0x23 的 28 個狀態代碼與 label 定義。
2. 同步將新增的 28 個狀態標籤繁中翻譯更新至 lang/zh_TW.json。
3. 透過自動對齊與排序腳本,完美同步更新 lang/en.json 與 lang/ja.json,並將三語系檔案依英文字母順序 (ksort) 重新排序且執行反逸出斜線處理。
2026-06-02 13:37:01 +08:00
sky121113
791c326edb [PROMOTE] 晉升 dev 到 demo
1. 晉升 [FEAT] 新增多租戶自訂登入、視覺設定與 APK 版本上傳時間 相關功能至測試驗收環境。
2026-06-01 17:17:38 +08:00
sky121113
5dd3b0a005 [FEAT] 新增多租戶自訂登入、視覺設定與 APK 版本上傳時間
1. 實作多租戶專屬自訂登入頁面:支援 `/c/{company_code}/login` 特殊入口,自動載入租戶專屬 Logo 網址、名稱與歡迎詞。
2. 支援客製化品牌視覺設定:於客戶管理清單中,提供 SaaS 管理者可自訂租戶 Logo 圖片與歡迎副標題,且系統後台 Header/Sidebar 與登入頁能動態依該配置載入租戶專屬商標。
3. 擴充多語系字典並完成三檔對齊:新增 "Upload Time" 欄位與翻譯,執行 `ksort` 以維持 zh_TW、en 與 ja 語系檔對齊。
4. APK 版本清單增加上傳時間欄位:於 APK 版本表格中呈現上傳時間(created_at),使用 font-mono 奢華風小尺寸樣式呈現。
2026-06-01 17:07:20 +08:00
sky121113
821957c363 [RELEASE] 釋出 demo 變更至 main
1. 優化 APK 版本列表更新說明顯示:使用 Alpine.js 實作點擊切換展開/收起功能,方便檢視完整字數。
2. 增加 title 屬性提供 hover tooltip 顯示完整更新說明。
3. 移除 select-none 樣式,允許使用者反白複製更新說明內容。
2026-06-01 12:03:11 +08:00
sky121113
7ea0858375 [PROMOTE] 晉升 dev 變更至 demo
1. 優化 APK 版本列表更新說明顯示:使用 Alpine.js 實作點擊切換展開/收起功能,方便檢視完整字數。
2. 增加 title 屬性提供 hover tooltip 顯示完整更新說明。
3. 移除 select-none 樣式,允許使用者反白複製更新說明內容。
2026-06-01 12:02:20 +08:00
sky121113
b1bebf6ae4 [STYLE] 優化 APK 版本列表更新說明顯示與複製功能
1. 使用 Alpine.js 實作更新說明點擊切換展開/收起功能,方便檢視完整字數。
2. 增加 title 屬性提供 hover tooltip 顯示完整更新說明。
3. 移除 select-none 樣式,允許使用者反白複製更新說明內容。
2026-06-01 12:01:46 +08:00
sky121113
e4287ec5f7 [RELEASE] 釋出後台頁面轉場與登場 Loading 微動畫優化
1. 晉升提交 a5ed5ca/5db796b:再度提升後台頁面轉場與登場 Loading 微動畫體驗。
   - 將 loading 狀態設為預設開啟,搭配雙重幀與微延遲,實現極流暢的登場動畫。
   - 當點擊跳轉時,使主內容(.admin-content-shell)漸變為半透明與微模糊,提供 SPA 級別的跳轉微過渡體驗。
   - 已在 Demo 環境完成完整驗收,於標準發布時段正式上線。
2026-06-01 10:13:21 +08:00
sky121113
a5ed5ca098 [PROMOTE] 晉升 dev 動畫體驗優化變更至 demo 環境
1. 晉升提交 5db796b:再度提升後台頁面轉場與登場 Loading 微動畫體驗。
   - loading 設為預設開啟,利用 requestAnimationFrame 與微延遲實現流暢的登場淡出。
   - 點擊跳轉時使主內容漸變為半透明與微模糊,提供 SPA 級別的跳轉微過渡體驗。
   - 防止重複點擊並解除事件衝突。
2026-06-01 10:12:28 +08:00
sky121113
5db796bf64 [STYLE] 再度提升後台頁面轉場與登場 Loading 微動畫體驗
1. 優化 components/loading-screen.blade.php:將 loading 狀態設為預設開啟,並利用 requestAnimationFrame 與延遲在 x-init 中自動淡出,實現更流暢的頁面進場登場動畫。
2. 優化 layouts/admin.blade.php:引入 .admin-content-shell 過渡容器與 .admin-page-transitioning 狀態,在點擊跳轉時使主內容漸變為半透明與微模糊,提供 SPA 級別的跳轉微過渡體驗。
3. 重構 layouts/admin.blade.php 跳轉載入命名:將全螢幕載入整合至頁面登場與內容過渡機制,防止重複點擊並解除事件衝突。
2026-06-01 10:11:08 +08:00
sky121113
611553ecb3 [RELEASE] 釋出優化之側邊欄佈局與全局頁面跳轉載入機制
1. 晉升提交 6885b1a/610899d:優化後台管理系統側邊欄佈局與全局頁面跳轉載入機制。
   - 解決 Alpine.js 載入前的排版跳動 (CLS/FOUC)。
   - 精確過濾站內實際跳轉,避免 Loading 畫面卡死與誤觸。
   - 增加平滑的淡入淡出 Transition 與高斯模糊效果。
   - 已在 Demo 環境驗收通過,於標準發布窗口正式上線。
2026-06-01 09:53:13 +08:00
sky121113
6885b1a99f [PROMOTE] 晉升 dev 變更至 demo 環境
1. 晉升提交 610899d:優化後台管理系統側邊欄佈局與全局頁面跳轉載入機制。
   - 解決 Alpine.js 載入前的排版跳動 (CLS/FOUC)。
   - 精確過濾站內實際跳轉,避免 Loading 畫面卡死與誤觸。
   - 增加平滑的淡入淡出 Transition 與高斯模糊效果。
2026-06-01 09:51:51 +08:00
sky121113
610899d898 [STYLE] 優化後台管理系統側邊欄佈局與全局頁面跳轉載入機制
1. 優化 layouts/admin.blade.php:引入 CSS 變數動態計算側邊欄寬度與內容偏移量,在 <head> 中即時應用,避免 Alpine.js 載入前的排版跳動 (CLS/FOUC)。
2. 重構 layouts/admin.blade.php 的頁面跳轉載入機制:設計 shouldShowPageLoading 嚴謹過濾 Hash 導航、新分頁、Ctrl+點擊、非同源等情境,僅在站內頁面實際跳轉時觸發 Loading。
3. 優化 components/loading-screen.blade.php:改為監聽 show-global-loading 與 hide-global-loading 全局事件,並新增 x-transition 動畫以實現平滑的淡入淡出體驗。
2026-06-01 09:49:38 +08:00
sky121113
077baf785f [RELEASE] 晉升 demo 變更至 main 生產環境
1. 修正登入頁面密碼顯示眼睛圖示大小失效問題。
2. 新增 APK 下載連結複製功能與優化相關 UI。
3. 更新多語系對齊資源 (lang/*.json)。
2026-06-01 08:50:18 +08:00
sky121113
d2b40dae3b [PROMOTE] 晉升 dev 變更至 demo 驗收環境
1. 合併登入頁面密碼顯示眼睛圖示大小的修復項 ([FIX] 修正登入密碼輸入框眼睛圖示大小失效問題)。
2026-06-01 08:43:18 +08:00
sky121113
a5ead47874 [FIX] 修正登入密碼輸入框眼睛圖示大小失效問題
1. 修正 resources/views/auth/login.blade.php 中密碼切換按鈕的 SVG 圖示大小類名。
2. 將無效的 w-4.5 h-4.5 改為 Tailwind CSS 標準的 w-5 h-5,解決圖示尺寸坍塌導致完全隱形看不到眼睛的問題。
2026-06-01 08:42:46 +08:00
sky121113
d889a7e075 [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FEAT] 新增 APK 下載連結複製功能與優化相關 UI (#d9e9c5e)
2026-05-29 17:43:27 +08:00
sky121113
d9e9c5e5db [FEAT] 新增 APK 下載連結複製功能與優化相關 UI
1. 在 APK 版本管理列表新增「複製下載連結」按鈕,並以 Alpine.js 結合 navigator.clipboard 實作複製成功 toast 通知。
2. 同步更新繁中、英文、日文多語系檔,補上複製下載連結相關語彙。
3. 修正 APK 新增頁面中外部連結按鈕 SVG 圖示閉合不全的問題。
2026-05-29 17:42:44 +08:00
sky121113
0555d0c6ba [RELEASE] 晉升變更至 main 生產環境
1. [FIX] 修正機台管理頁面的表單提交行為與導向邏輯 (#23b3ea1)
2026-05-29 16:55:51 +08:00
sky121113
89f29e6d52 [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FIX] 修正機台管理頁面的表單提交行為與導向邏輯 (#23b3ea1)
2026-05-29 16:54:41 +08:00
sky121113
23b3ea16ae [FIX] 修正機台管理頁面的表單提交行為與導向邏輯
1. 將原本一律攔截的 @submit.prevent 改為動態 @submit。
2. 僅在表單使用 GET 方法進行搜尋過濾時,才執行 $event.preventDefault() 並以非同步方式載入頁面,確保 POST/PUT 等非 GET 類型的表單提交能正常運作。
2026-05-29 16:54:01 +08:00
sky121113
52da77a88f [RELEASE] 晉升交易交付狀態與收件人姓名遮罩功能至正式環境
1. 增加訂單出貨交付狀態(delivery_status)與多語系支援
2. 新增訂單收件人姓名遮罩與商品詞彙通用化調整
2026-05-29 15:44:47 +08:00
sky121113
b4a9bfae86 [PROMOTE] 合併 dev 分支至 demo 環境進行驗收
1. 於 Order Model 新增收件人姓名遮罩邏輯 (masked_pickup_recipient),完成去識別化處理以符合隱私保護規範。
2. 新增 OrderPickupRecipientMaskTest 驗證姓名遮罩邏輯。
3. 修正後台銷售管理介面 (order-detail-panel、tab-orders) 改以遮罩屬性顯示。
4. 通用化機台通訊代碼與語系檔,將「便當 (Bento)」字詞替換為「商品 (Product)」。
2026-05-29 14:38:19 +08:00
sky121113
e01e8077f9 [FEAT] 新增訂單收件人姓名遮罩與商品詞彙通用化調整
1. 於 Order Model 新增收件人姓名遮罩邏輯 (masked_pickup_recipient),將 member_barcode 進行去識別化處理以保護隱私,並保留括號/換行等後綴資訊。
2. 新增 OrderPickupRecipientMaskTest 單元測試,驗證中文字元及包含換行/括號的後綴遮罩正確性。
3. 更新後台銷售管理介面 (order-detail-panel.blade.php、tab-orders.blade.php),將原先顯示明文的 member_barcode 改用遮罩後的屬性展示。
4. 將機器通訊協定與狀態代碼中的「便當 (Bento)」字詞統一調整為「商品 (Product)」,以符合各機型通用語意。
5. 更新相關語系翻譯檔案 (zh_TW.json, en.json, ja.json) 及 MQTT 通訊規範文件中的詞彙對應。
6. 修改 MachineLog 中的翻譯取得邏輯,確保能透過錯誤代碼表對應到最新的商品語意。
2026-05-29 14:37:28 +08:00
sky121113
19ad440d34 [PROMOTE] 晉升 dev 交易出貨交付狀態與多語系支援至 demo
1. 新增 orders.delivery_status 欄位以記錄整筆交易之最終出貨結果(相容舊機台)。
2. 整合 TransactionService 出貨判定輔助方法(自動由個別貨道上報計算整體狀態)。
3. 升級後台銷售紀錄清單、側邊滑出明細面板與 Excel/CSV 報表匯出功能,區分「付款狀態」與「出貨狀態」。
4. 完成繁、英、日三語系完美排序對齊。
2026-05-29 12:18:43 +08:00
sky121113
8d380e4902 [FEAT] 增加訂單出貨交付狀態(delivery_status)與多語系支援
1. 新增資料庫遷移:在  資料表增加 (出貨狀態:0 失敗、1 成功、2 部分成功),預設值為 1 並允許 NULL 以完美向下相容舊機台。
2. 升級  Model:加入  到可填充與轉型,並宣告常量與  取得本地化文字的 Accessor。
3. 更新  邏輯:在  中自動解析並記錄 。新增  輔助函式,當 Payload 未指定訂單狀態時,可動態從個別貨道出貨紀錄 ( 陣列) 計算出整筆訂單的出貨交付結果。
4. 銷售紀錄列表 (tab-orders.blade.php) UI 優化:將原先「狀態」欄位重命名為「付款狀態」,並在右側新增「出貨狀態」欄位,使用極簡奢華風的彩色 Badge 呈現出貨結果;桌機版空狀態  修正為 8 防止破版;手機版卡片同步支援。
5. 側邊明細面板 (order-detail-panel.blade.php) UI 優化:將基本狀態卡片升級為 3 欄網格,將付款狀態、支付類型與新增的出貨狀態 Badge 完美並排。
6. 報表匯出功能 (SalesController.php) 整合:在 Excel 及 CSV 匯出中同步修正「付款狀態」欄位標題與轉換標籤,並新增「出貨狀態」欄位以匯出 。
7. 多語系精準對齊:新增 , , ,  及  的繁、英、日翻譯對照,並進行字母升冪排序 (ksort)。
2026-05-29 12:17:36 +08:00
sky121113
577a0b739e [RELEASE] 晉升 demo 至 main 正式環境:修復 APK 部署準備中預約排程機台統計 2026-05-29 11:09:02 +08:00
sky121113
cd8e1d638a [PROMOTE] 晉升 dev 至 demo:修復未執行預約排程之部署狀態機台統計 2026-05-29 11:08:49 +08:00
sky121113
6daeadc452 [FIX] 統計 APK 部署狀態時將尚未執行的預約排程機台也納入準備中統計 2026-05-29 11:08:34 +08:00
sky121113
a2a9f9e0c1 [RELEASE] 晉升 demo 至 main 正式環境:修復 APK 部署準備中機台統計與遠端指令多語系 2026-05-29 11:06:08 +08:00
sky121113
b5636e7b99 [PROMOTE] 晉升 dev 至 demo:修復 OTA 部署準備中狀態統計與指令多語系支援 2026-05-29 11:05:59 +08:00
sky121113
ba609719cc [FIX] 統計 APK 部署狀態時將 sent (已送出) 納入準備中統計 2026-05-29 11:05:45 +08:00
sky121113
fce59eb19b [RELEASE] 晉升 demo 到 main 正式上線環境
1. 新增 OTA 預約排程功能:建立 `ota_schedules` 資料表、`OtaSchedule` 模型與 `ota:process-schedules` 定時背景指令。
2. 實現機台部署狀態顯示 (Tooltip):新增「部署狀態」一欄,在加載與刷新時以原生 Tooltip 100% 穩定展示待執行與已完成之機台清單。
3. 優合 Flatpickr 時間選擇器 UI:整合日曆圖標並支援動態 locale 語言切換,統一顯示標籤為「選擇更新時間」。
4. 移除更新模式 (強制更新) 限制:自上傳與控制中完全拔除 `is_forced` 的開關與顯示。
5. 完備三語系同步:新增與對齊中、英、日三語系 `lang/*.json` 語系檔,並完成字母排序。
2026-05-29 10:58:30 +08:00
sky121113
642e9dad97 [PROMOTE] 晉升 dev 到 demo 環境
1. 新增 OTA 預約排程功能:建立 `ota_schedules` 資料表、`OtaSchedule` 模型與 `ota:process-schedules` 定時背景指令。
2. 實現機台部署狀態顯示 (Tooltip):新增「部署狀態」一欄,在加載與刷新時以原生 Tooltip 100% 穩定展示待執行與已完成之機台清單。
3. 優合 Flatpickr 時間選擇器 UI:整合日曆圖標並支援動態 locale 語言切換,統一顯示標籤為「選擇更新時間」。
4. 移除更新模式 (強制更新) 限制:自上傳與控制中完全拔除 `is_forced` 的開關與顯示。
5. 完備三語系同步:新增與對齊中、英、日三語系 `lang/*.json` 語系檔,並完成字母排序。
2026-05-29 10:56:04 +08:00
sky121113
fb1bcf49e1 [FEAT] 新增 OTA 預約排程與部署狀態展示,並優化版本排序與 UI
1. 建立 `ota_schedules` 資料表與 `OtaSchedule` Model,提供 OTA 預約排程基礎儲存結構。
2. 建立 `ota:process-schedules` 背景 Artisan Command 並註冊至 Console Kernel,每分鐘自動執行到期的預約並透過 MQTT 發送。
3. 擴充 `ApkVersionController` index 方法:加入加載列表時自動觸發排程的防呆機制,並在記憶體中快速統計 pending 與 completed 的機台。
4. 擴充 `ApkVersionController` store 與 push 方法,移除已用不到的 `is_forced` (更新模式) 限制,並支援 `scheduled_at` 預約時間的寫入。
5. 調整 `create.blade.php` 頁面,移除「強制更新」的 Switch 欄位,僅保留注意事項防錯提示。
6. 調整 `index.blade.php` 頁面,將「更新模式」改為「部署狀態」一欄,整合系統內建的 `.tooltip` 與原生 `title` 屬性,在加載與刷新時 100% 穩定展示 Hover 機台名單。
7. 優化 `index.blade.php` 彈窗,將原生時間輸入框改為 Flatpickr 樣式,加入日曆圖標並支援動態 locale 語言切換(中/英/日),將標籤統一命名為「選擇更新時間」。
8. 同步更新並對齊中、英、日三語系 `lang/*.json` 語系檔,並完成字母排序與反逸出斜線處理。
2026-05-29 10:55:23 +08:00
sky121113
ea23feb45b [PROMOTE] 晉升 dev 變更至 demo 環境
1. 重構 B013 硬體狀態碼(0424 標籤改為 Purchase successful 購買成功,優化判斷邏輯)。
2. 統一由 MachineService ERROR_CODE_MAP 作為 Discord 告警與系統狀態的單一真實來源。
3. 同步更新 MQTT 即時通訊規格文件中的 0424 對照與出貨結果判定。
4. 全面補齊並對齊 en/zh_TW/ja 三語系檔中 53 種硬體狀態翻譯(ksort 字母排序且反逸出斜線)。
2026-05-29 09:15:26 +08:00
sky121113
d9bf2bbe98 [FEAT] 依廠商規格全面重構 B013 硬體狀態碼並補齊三語系翻譯
1. 修改 MachineService 中的 0424 代碼標籤為 Purchase successful(購買成功)並改寫成功判定邏輯。
2. 移除 DiscordWebhookService 中重複且過時的 errorCodes 硬編碼對照,統一由 MachineService 作為單一真實來源。
3. 同步更新 MQTT 即時通訊規格文件中有關 0424 的定義與出貨結果判定規則。
4. 新增並以自動對齊腳本完美補齊 en/zh_TW/ja 三語系檔中所有 53 種硬體代碼翻譯,完成排序(ksort)與斜線不逸出處理。
2026-05-29 09:13:16 +08:00
sky121113
3edcbc58c0 [PROMOTE] dev -> demo 環境晉升 (2026-05-28)
1. [FEAT] 新增全域 workflow 指令入口 (docs/workflow-commands.md, workflow)
2. [FIX] sales: 統一 payment_type 42 取貨人資訊的多語系顯示文字
2026-05-28 16:25:31 +08:00
sky121113
7766fce1d4 [FEAT] 新增全域 workflow 指令入口
1. 新增 ./workflow 統一執行器,支援 load-ids、daily-work-log、hotfix-prod、demo-day-pptx、start-task、promote-env、monthly-report、now-push。
2. 補充 docs/workflow-commands.md 使用說明與參數範例,便於團隊直接操作。
2026-05-28 16:20:27 +08:00
sky121113
da11241f72 sales: localize pickup recipient wording for payment_type 42 2026-05-28 16:14:02 +08:00
sky121113
0994b6dd6c sales: localize pickup recipient wording for payment_type 42 2026-05-28 16:13:52 +08:00
sky121113
7d8b342a85 sales: unify pickup recipient wording for payment_type 42 2026-05-28 16:08:35 +08:00
sky121113
1d0181ff95 sales: unify pickup recipient wording for payment_type 42 2026-05-28 16:08:24 +08:00
sky121113
55494fe5ba [PROMOTE] dev -> demo
1. sales: 交易記錄 UI 顯示 payment_type 42 (取物單) 的 member_barcode 欄位
2. chore: 同步 Order Model、API 文件 (api-docs.php) 與三語系 (zh_TW/en/ja) 對齊更新
2026-05-28 15:56:43 +08:00
sky121113
5c66a01261 chore: sync pending model/docs/i18n updates 2026-05-28 15:55:38 +08:00
sky121113
ea8c277ee9 sales: show member_barcode for payment_type 42 in records UI 2026-05-28 15:54:53 +08:00
sky121113
978faf004c [RELEASE] 晉升 demo 變更至 main 生產環境
1. [FIX] 修正下位機 Discord 告警發送條件與層級對等(過濾 info 層級日誌如 0401, 0410,僅發送 warning/error;並依嚴重程度動態調整標題 Emoji)。
2. [FIX] 解決 HTTPS 環境下 AJAX 分頁請求 http 混合內容 (Mixed Content) 攔截 Bug。
2026-05-28 15:02:39 +08:00
sky121113
d1a14ebee6 [PROMOTE] 晉升 dev 變更至 demo 分支
1. [FIX] 修正下位機 Discord 告警發送條件與層級對等(過濾 info 層級日誌,僅發送 warning/error;且 error 顯示 🚨,warning 顯示 ⚠️)。
2026-05-28 14:55:02 +08:00
sky121113
acde99e383 [FIX] 修正下位機 Discord 告警發送條件與層級對等
1. 調整 MachineLog 的 created 監聽器,使 submachine 類型日誌僅在 level 為 warning 或 error 時才派發 Discord 通知任務,避免 info 狀態日誌(如出貨中、平台上升中)洗板告警。
2. 優化 DiscordWebhookService 的下位機異常渲染,根據日誌嚴重程度 (level) 動態調整標題前面的 Emoji 圖示,error 級別顯示 🚨,warning 級別顯示 ⚠️
2026-05-28 14:53:06 +08:00
sky121113
73936a5c5f [PROMOTE] 晉升 dev HTTPS 協定混合內容修正至 demo 環境
1. 晉升 ads/index.blade.php 的 AJAX 分頁請求同域協定校正。
2. 晉升 products/index.blade.php 的 AJAX 分頁請求同域協定校正。
2026-05-28 11:43:51 +08:00
sky121113
dcdfe1382e [FIX] 解決 HTTPS 環境下 AJAX 分頁請求 http 混合內容 (Mixed Content) 攔截 Bug
1. 在 ads/index.blade.php 的 fetchPage 分頁異步請求中,新增主機與協議對齊邏輯 (urlObj.protocol = window.location.protocol),防止在 HTTPS 網頁中請求同域 http 分頁而被瀏覽器攔截。
2. 同步在 products/index.blade.php 的 AJAX 分頁切換重組中,對同域的請求協議進行 HTTPS 同步校正,解決混合內容載入失敗的問題。
2026-05-28 11:42:57 +08:00
sky121113
db4ad9de4d [RELEASE] 釋出最新功能與系統優化至正式生產環境
1. 新增商品 Excel 批量匯入與模板下載功能 (包括 ProductImportService, ProductController, 以及 products/index.blade.php 的極簡奢華風匯入 Modal UI)。
2. 新增 APK 上傳時的檔名自動解析功能 (自動填充 Version Name, 計算推導 Version Code 並透過 Alpine.js 雙向綁定更新 Flavor 下拉選單)。
3. 修正「中國醫客製」機台風味選項規格,由原先 S12 主板規格 (S_12_XY_U_Cmuh) 改為 S9 主板規格 (S_9_XY_U_Cmuh)。
4. 修正 APK 版本強制更新開關無法正常關閉的邏輯 Bug。
5. 優化 APK 新增與版本管理頁面 UI,統一 Toast 與交互微調。
2026-05-28 11:33:46 +08:00
sky121113
3de99322bc [PROMOTE] 晉升 dev 商品批量匯入功能至 demo 環境
1. 晉升 ProductImportService 服務,用於 Excel 商品檔案解析、驗證與多租戶寫入邏輯。
2. 晉升 ProductController 匯入與模板下載動作路由與控制器邏輯。
3. 晉升商品頁面 index.blade.php 的極簡奢華風匯入彈窗 UI 與 Alpine.js 非同步上傳邏輯。
4. 晉升語系檔 lang/*.json 新增商品匯入等相關翻譯。
2026-05-28 11:19:44 +08:00
sky121113
c3e60b8746 [FEAT] 新增商品 Excel 批量匯入與模板下載功能
1. 新增 ProductImportService 服務,實作 Excel 商品檔案解析、欄位驗證、多租戶 company_id 歸屬處理及資料批量寫入/更新邏輯。
2. 在 ProductController 新增 import 與 downloadTemplate 動作,處理檔案接收驗證與匯出,並在匯入後自動重建該公司商品目錄快取。
3. routes/web.php 新增商品下載模板與 Excel 匯入的專用路由。
4. 重構 products/index.blade.php,新增極簡奢華風商品匯入 Modal、檔案拖放/選擇區,並採用 Alpine.js 進行彈窗開啟與非同步上傳 API 交互。
5. 擴充 components/page-header.blade.php,使頁面主標頭支援渲染多個操作按鈕 (actions 插槽),以配合新增的「匯入商品」按鈕。
6. 同步更新 lang/zh_TW.json 與 lang/en.json,對齊「商品匯入成功」、「請選擇公司」等相關語系翻譯。
7. 微調 AdvertisementController,維持全站 Controller 進度對齊。
2026-05-28 11:19:00 +08:00
sky121113
ca2003155e [PROMOTE] 晉升 dev 變更至 demo 環境
1. 晉升 APK 新增上傳檔名自動解析與欄位填充功能 (Version Name, Version Code, Flavor)。
2. 晉升「中國醫客製」風味選項由 S_12_XY_U_Cmuh 修正為 S_9_XY_U_Cmuh。
3. 晉升 create.blade.php 表單重構(JS 邏輯抽離至 script 區塊),徹底解決 HTML 屬性與 JS 引號衝突。
2026-05-28 09:36:07 +08:00
sky121113
b0fc0e6520 [FEAT] 新增 APK 上傳檔名自動解析與優化中國醫風味選項
1. 新增 APK 上傳時的檔名自動解析功能(可從 app-[flavor]-[version_name] 格式自動解析並填入版本名稱與機台風味)。
2. 新增版本代碼的自動數學推導,將 Major_Minor_Patch 轉換為整數(例如 21_01_6_R 轉換為 210106)並自動填入。
3. 將原本「中國醫客製」風味選項由 S_12_XY_U_Cmuh 修正為 S_9_XY_U_Cmuh(S9 主板)。
4. 重構 create.blade.php 表單,將原本位於 HTML x-data 屬性內部的複雜 JS 邏輯抽離至獨立的 <script> 區塊(apkVersionForm 函數),解決 HTML 屬性與 JS 引號/括號衝突導致的渲染問題。
5. 引入 Alpine.js 的 x-model 機制對版本名稱(versionName)與版本代碼進行雙向綁定,確保自動解析賦值的強健性。
2026-05-28 09:35:31 +08:00
sky121113
9f39240b56 [PROMOTE] 合併 dev 至 demo
1. 修正 APK 版本強制更新開關永遠為開啟的 Bug
2026-05-27 17:18:57 +08:00
sky121113
dd5e78a9ff [FIX] 修正 APK 版本強制更新開關永遠為開啟的 Bug
1. 修正 ApkVersionController::store() 中 is_forced 欄位的判斷邏輯
2. 原邏輯使用 $request->has('is_forced') 判斷,但因表單含 hidden input (value=0),導致 key 永遠存在,has() 永遠回傳 true
3. 改用 $request->boolean('is_forced') 直接讀取欄位值,正確將字串 '0' 轉為 false、'1' 轉為 true
2026-05-27 17:17:26 +08:00
sky121113
dae4c292f8 [PROMOTE] dev → demo:APK 新增頁面 UI 優化與 Toast 統一
1. 版本代碼欄位改為 ±1 Stepper 風格,移除瀏覽器原生上下箭頭
2. 機台風味下拉改為 x-searchable-select 搜尋式選單
3. 移除 index/create 頁面的畫面 flash/error 區塊,統一由全域 toast 處理
2026-05-27 15:47:35 +08:00
sky121113
72f166eb14 [STYLE] 改善 APK 版本新增頁面 UI 與提示統一化
1. 版本代碼欄位改為 ±1 Stepper 風格(`type="text" inputmode="numeric"`),移除瀏覽器原生上下箭頭
2. 機台風味下拉選單改為 `x-searchable-select` 搜尋式選單,支援關鍵字篩選並統一 UI 風格
3. 移除 index 頁面的 session success 畫面提示框,改由全域 toast 統一處理
4. 移除 create 頁面的 $errors 畫面錯誤列表,改由全域 toast 統一顯示錯誤訊息
2026-05-27 15:46:31 +08:00
sky121113
51d007d84e [RELEASE] 晉升 demo 變更至 main 生產環境
1. 實作調撥單與機台補貨單的 A4 列印功能與多租戶防護。
2. 新增與優化來店禮、通行碼、取貨碼行銷促銷模組之多語系翻譯與 UI 排版。
3. 實作 OTA APK 版本管理與更新部署功能(含 API 與後台介面)。
4. 微調側邊欄選單順序,將 APK版本 移至基本設定選單最下方。
5. 從角色權限設定中徹底移除已廢棄的特殊權限 (menu.special-permission) 模組。
2026-05-27 10:08:54 +08:00
sky121113
3746eae199 [PROMOTE] 移除已廢棄的特殊權限權限模組 2026-05-27 10:03:30 +08:00
sky121113
62f54499ce [FIX] 移除角色權限設定中已廢棄的特殊權限模組
1. 修改 PermissionController 中的 activeModules 清單,將已不存在的「特殊權限 (menu.special-permission)」模組移除,修正權限篩選邏輯。
2. 同步修改 role-card.blade.php 與 role-list.blade.php 視圖,將特殊權限從模組列表陣列中剔除,確保角色詳情卡片的權限計數與標籤顯示精確無誤。
2026-05-27 10:03:14 +08:00
sky121113
8aae09d083 [PROMOTE] 調整側邊欄選單順序,將 APK版本 移至基本設定最下方
1. 調整 sidebar-menu.blade.php,將原本排在首項的 APK版本 (apk-versions) 移動至「基本設定」選單列表的最下方,符合日常營運的使用偏好。
2026-05-27 10:01:32 +08:00
sky121113
1cfc4092d2 [STYLE] 調整側邊欄基本設定選單,將 APK 版本移動至選單最下方
1. 修改 sidebar-menu.blade.php 中的基本設定列表順序,將原本排在首項的「APK版本 (apk-versions)」選單移動至基本設定選單的最底部,提升選單直覺度。
2026-05-27 10:01:07 +08:00
sky121113
0a7922301e [PROMOTE] 將 dev 變更晉升至 demo 環境
1. 修正 APK 列表與新增頁面的自定義 Header 與間距,使用系統標準 <x-page-header> 並將外層垂直間距統一調整為 space-y-4,完全對齊麵包屑。
2. 修正前端 Alpine.js x-text 屬性中誤用 PHP 運算子 ?: 導致機台 APP 版本號渲染空白的 Bug。
3. 修正 Modal 表格清單的 thead 背景誤用不存在的 slate-850 導致深色模式下顯示突兀純白的問題。
4. 補齊並自動排序 Version Info、Forced Update 與 Optional Update 等三語系翻譯鍵值,完美對齊 zh_TW.json、en.json 與 ja.json。
5. 優化強制更新與重要通知的 UX 文案及樣式,加入啟用時的淡玫瑰色 (rose) 背景,並放大注意事項的字體。
2026-05-27 09:59:17 +08:00
sky121113
93845c990e [FIX] 修正 APK 版本管理 UI 間距、深色模式 Bug 與三語系翻譯對齊
1. 將 APK 列表與新增頁面的自定義 Header 改用系統標準 <x-page-header> 元件,外層容器垂直間距統一調整為 space-y-4,完美對齊麵包屑與後台標準比例。
2. 修正前端 Alpine.js x-text 指令中誤用 PHP 二元運算子 ?: 導致機台 APP 版本號渲染空白的 JavaScript 語法 Bug。
3. 修正 Modal 表格清單的 thead 背景誤用不存在的 slate-850 導致深色模式下顯示突兀純白的問題,調整為更具層次感的 bg-slate-50/50 dark:bg-slate-950/40。
4. 補齊 Version Info、Forced Update 與 Optional Update 等三語系翻譯,並移除舊文案,採用 ksort 自動完成三語系 Perfect Align 字母排序對齊。
5. 優化強制更新與重要注意事項的文案及視覺樣式,調整強制更新啟用時的玫瑰紅 (rose) 警示背景,並放大重要通知的字體以提高可讀性。
2026-05-27 09:54:43 +08:00
sky121113
c9faaa5744 [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FEAT] 新增 OTA APK 版本管理與更新部署功能
2026-05-22 16:52:34 +08:00
sky121113
7dee52a00a [FEAT] 新增 OTA APK 版本管理與更新部署功能
1. 新增 `ApkVersion` Model 與對應的資料表遷移檔 (`create_apk_versions_table`),用以記錄 APK 版本、代碼、風味、更新說明、下載連結與更新模式等欄位。
2. 實作 `ApkVersionController` 控制器,提供 APK 版本之 CRUD(列表、建立、刪除)以及 OTA 推送功能。
3. 移除了 `SpecialPermissionController` 中舊有的 `apkVersions` 占位路由方法,並於 `routes/web.php` 的基本設定 (`basic-settings`) 下新增完整的 APK 版本管理與推送路由。
4. 於 `RoleSeeder.php` 角色種子資料中為 Super Admin 與系統管理員新增 `menu.basic.apk-versions` 權限。
5. 於 `sidebar-menu.blade.php` 基本設定選單中,加入「APK版本管理」之子選單選項,並以對應權限進行防護。
6. 實作 APK 版本列表與上傳頁面 Blade 視圖,採用 Preline UI 與 Alpine.js 提供極簡奢華風的 UI 互動,包含檔案上傳拖曳與 OTA 設備選擇部署彈窗。
7. 更新繁體中文語系檔 `zh_TW.json`,對齊所有新增之 OTA 韌體與版本管理翻譯字詞。
2026-05-22 16:51:21 +08:00
sky121113
55179b1066 [PROMOTE] 晉升 dev 變更至 demo 分支
1. 實作全新來店禮(Welcome Gifts)模組及關聯的資料庫遷移。
2. 徹底修復來店禮、通行碼與取貨碼主列表的重置按鈕異步時序 Bug。
3. 修復三個 modules 使用紀錄(Logs)中搜尋框放大鏡 SVG 參數歪斜與裁切問題。
4. 全面優化來店禮使用紀錄(Logs)桌面表格、無資料狀態、手機卡片中難以辨識的怪異禮物盒圖標為標準 Gift 禮物盒圖示。
5. 多語系 I18n 管理最佳化:對齊三語系 zh_TW.json、en.json 與 ja.json 的 Keys 集合,簡化來店禮翻譯並補齊 Modal 更新按鈕翻譯。
6. 取貨碼與通行碼使用紀錄欄位優化,包含新增狀態篩選及移除操作人員欄位。
2026-05-22 15:57:08 +08:00
sky121113
1d54aefcb5 [FIX] 新增來店禮更新按鈕的英文、中文與日文翻譯
1. 同步在 lang/zh_TW.json、lang/en.json 與 lang/ja.json 中新增 "Update Welcome Gift" 的翻譯鍵。
2. 進行字母排序 (ksort) 並確保語系間的 Key 集合完全對齊,解決 Modal 更新按鈕上大寫英文 "UPDATE WELCOME GIFT" 顯示的缺漏。
2026-05-22 15:39:14 +08:00
sky121113
47b553ae8c [FIX] 修正來店禮使用紀錄頁面中難以辨識的怪異禮物盒圖標
1. 修正 resources/views/admin/sales/welcome-gifts/partials/tab-logs.blade.php 的桌面表格、無資料狀態、手機卡片中的來店禮圖示,全面替換為標準且極具質感的 Gift 禮物盒圖標。
2026-05-22 15:30:24 +08:00
sky121113
5c970f4134 [FIX] 修正通行碼與來店禮主列表搜尋放大鏡與來店禮手機卡片圖示
1. 修正 resources/views/admin/sales/pass-codes/partials/tab-list.blade.php 的搜尋框放大鏡 SVG 圖示,使其完美置中不被裁切。
2. 修正 resources/views/admin/sales/welcome-gifts/partials/tab-list.blade.php 的搜尋框放大鏡 SVG 圖示。
3. 替換 resources/views/admin/sales/welcome-gifts/partials/tab-list.blade.php 手機版卡片中的來店禮圖示為標準且高辨識度的 Gift 禮物盒圖標,提升 UI 質感與一致性。
2026-05-22 15:29:56 +08:00
sky121113
145c515292 [FIX] 修正行銷使用紀錄搜尋框放大鏡 SVG 參數,並更換來店禮圖示以提升辨識度
1. 修正「來店禮」、「通行碼」與「取貨碼」使用紀錄搜尋框內 SVG 放大鏡缺少 y1="21" 導致圖示被截斷且偏左上角的顯示 Bug。
2. 汰換來店禮使用紀錄(Logs 表格與卡片)中原先難以辨識且怪異的圓圈十字圖示,更換為精緻高辨識度的標準 Heroicons Gift 禮物盒圖示。
2026-05-22 15:28:23 +08:00
sky121113
9b439814bd [STYLE] 優化行銷促銷模組視覺體驗、語系對齊與修復下拉選單重置 Bug
1. 簡化「來店禮」於繁中與日文語系檔的顯示文字,避免後台介面過長擠壓。
2. 修復來店禮、通行碼、取貨碼列表重置按鈕之時序競態 Bug,確保下拉選單確實清空後方送出 Ajax。
3. 來店禮與通行碼使用紀錄的類型選單新增 OnChange 自動 AJAX 提交,提升篩選操作體驗。
4. 補齊取貨碼使用紀錄的狀態篩選選單,並支援重置按鈕一鍵清空。
5. 移除通行碼與取貨碼使用紀錄中的「操作人員」欄位,配合極簡奢華風微調時間欄位靠右對齊,並修正表格空狀態的 colspan 為 5。
2026-05-22 15:20:19 +08:00
sky121113
f59efc6dc7 [FIX] 補齊與優化來店禮、通行碼、取貨碼之多語系與介面欄位呈現
1. 移除非必要之 Log 備註:從來店禮使用記錄 (Logs) 的電腦版與行動版畫面中,徹底移除了無業務參考價值的技術備註 (Remark) 欄位,優化排版空間以符合極簡奢華風 UI 設計。
2. 補齊與對齊三語系檔案:補齊來店禮、通行碼與商品領取所缺少的多語系 Key,三個語系 JSON 檔 (zh_TW, en, ja) 的 Key 與行數已 100% 完美對齊 (共 2,255 行) 且完成升冪字母排序 (ksort)。
3. 簡化 Guest 票券大標題:簡化 Guest 端三種票券頁面 (通行碼、取貨碼、來店禮) 的大標題多語系拼接結構,改為單一乾淨的翻譯 Key 呼叫。
4. 口語化翻譯微調:將 Pickup Code 在語系檔中的中文翻譯由「領取碼」微調為更口語、直覺的「取貨碼」。
5. 優化 Guest 頁面欄位:將取貨碼頁面的「機台序號」替換為更具溫度的「機台名稱」;通行碼與來店禮頁面則徹底隱藏機台序號,優化用戶體驗。
2026-05-22 14:46:26 +08:00
sky121113
cdbf5b51bd [PROMOTE] 晉升 dev 變更至 demo 環境 (包含 A4 列印功能與多租戶防護) 2026-05-22 10:24:45 +08:00
sky121113
016cb571c4 feat: 合併 A4 極簡奢華風列印功能與多租戶防護 2026-05-22 10:24:30 +08:00
sky121113
2a3d59054f [RELEASE] 晉升 demo 變更至 main 生產環境
1. 修正台灣地址解析經緯度失敗問題,支援瑞光路特定二樓格式
2. 新增 Discord 通知設定的繁體中文、英文、日文多語系翻譯
3. 修正租戶管理員無基本設定權限時側邊欄仍顯示空選單的問題
2026-05-22 10:04:09 +08:00
sky121113
e724ec05eb [PROMOTE] 晉升 dev 變更至 demo 環境
1. 修正台灣地址解析經緯度失敗問題,支援瑞光路特定二樓格式
2. 新增 Discord 通知設定的繁體中文、英文、日文多語系翻譯
3. 修正租戶管理員無基本設定權限時側邊欄仍顯示空選單的問題
2026-05-22 10:01:49 +08:00
sky121113
51f489c496 feat(sidebar): 修正租戶管理員無基本設定權限時仍顯示空選單的問題 2026-05-22 09:59:22 +08:00
sky121113
44d0499b53 feat(i18n): 新增 Discord 通知設定的繁中、英文、日文多語系翻譯 2026-05-22 09:55:23 +08:00
sky121113
4dad775568 [FIX] 修正台灣地址解析經緯度失敗問題
1. 新增 cleanFloorInfo 方法,過濾地址末尾的樓層、室、地下室等非門牌雜訊,防範 Nominatim 解析失敗。
2. 重構 parseTaiwanAddress 的路名拆分正則表達式,導入層級感知台灣地址結構,避免含有「巷、弄」的地址被提前截斷。
2026-05-22 09:51:05 +08:00
sky121113
178c0d8ac9 [RELEASE] 晉升 demo 至 main 生產環境
1. [FEAT] 實作調撥與補貨單商品快照機制,並修復商品無圖片時的預設圖示 fallback 顯示。
2. [FEAT] 重構 Discord 雙 Tab 告警設定與機台自適應溫控三層繼承鏈,並導入 Modal 內拉式複製 UX。
3. [FIX] 優化溫度告警狀態轉移邏輯與完善機台專屬通知設定 UI。
4. [FIX] 修正機台設定中公司篩選下拉選單的 AJAX 後選中狀態及誤顯示繼承公司設定徽章之問題。
5. [MIGRATION] 新增機台型號設定、機台設定與訂單快照等 3 個資料庫遷移檔案。
2026-05-22 09:13:40 +08:00
sky121113
a76f6323a2 [PROMOTE] 晉升 dev 至 demo 環境
1. [STYLE] 將 Discord 溫度過低告警側邊條顏色從預設藍色 (cyan-600) 調整為警示紅色 (rose-600),以與溫度過高告警顏色保持警示一致性。
2026-05-21 17:09:10 +08:00
sky121113
b6e5b825a5 [STYLE] 將溫度過低告警側邊條顏色調整為警示紅色 2026-05-21 17:09:00 +08:00
sky121113
43989ada2d [PROMOTE] 晉升 dev 至 demo 環境
1. [FIX] 修正機台設定公司篩選下拉選單兩項問題:選取後自動觸發 AJAX 篩選、重設按鈕透過 HSSelect.setValue() 正確清除 Preline 下拉選單 UI、調整 Preline autoInit 與 Alpine initTree 初始化順序以 requestAnimationFrame 確保 DOM 更新完畢後才執行 Alpine。
2. [FIX] 補強公司篩選下拉選單 AJAX 後選中狀態顯示:option 元素補上 server-side selected 屬性、fetchTabContent 完成後主動呼叫 HSSelect.setValue() 強制同步顯示文字。
2026-05-21 17:05:49 +08:00
sky121113
e88d68d3b1 Merge branch 'feat/discord-notification' into dev 2026-05-21 17:02:03 +08:00
sky121113
18162dfa4c [FIX] 補強公司篩選下拉選單的 AJAX 後選中狀態顯示
1. 為 option 元素補上 server-side selected 屬性,確保 Preline autoInit 重建時能正確讀取已選中的公司選項。
2. 在 fetchTabContent AJAX 完成後,主動呼叫 HSSelect.setValue() 強制同步下拉選單的顯示文字,消除選中狀態消失的視覺問題。
2026-05-21 17:01:47 +08:00
sky121113
90192dbed9 [FIX] 修正機台設定公司篩選下拉選單兩項問題
1. 選取公司後自動觸發 AJAX 篩選,無需手動按搜尋按鈕
2. 修正重設按鈕邏輯,透過 HSSelect.setValue() 正確清除 Preline 下拉選單 UI 顯示
3. 調整 fetchTabContent 的 Preline autoInit 與 Alpine initTree 初始化順序,以 requestAnimationFrame 確保 DOM 更新完畢後才執行 Alpine,解決 AJAX 後下拉選單選中狀態遺失的問題
2026-05-21 16:58:14 +08:00
sky121113
3db5a7aeec [PROMOTE] 晉升 dev 至 demo 環境
1. [FIX] 修正機台告警設定列表中,當公司告警類型尚未設定時,機台列表誤顯示「繼承公司設定」徽章之問題。
2026-05-21 16:51:42 +08:00
sky121113
905d2bf459 Merge branch 'feat/discord-notification' into dev 2026-05-21 16:49:30 +08:00
sky121113
ac48546c76 [FIX] 修正當公司告警類型尚未設定時,機台列表誤顯示繼承公司設定徽章之問題 2026-05-21 16:48:20 +08:00
sky121113
07e6d56fd3 [PROMOTE] 晉升 dev 至 demo 環境
1. 實作調撥與補貨單商品快照機制,修復商品被刪除時的名稱、barcode、圖片 Fallback,並解決無圖片時的立方體預設圖示 fallback 顯示。
2. 重構 Discord Webhook 告警設定與機台自適應溫控三層繼承鏈,補齊多語系 Key,完善機台型號與通知設定 UI。
3. 修正 Discord 通知頁面的麵包屑層級為 儀表板 > 基本設定 > Discord通知。
2026-05-21 16:42:01 +08:00
sky121113
95ed1494e2 Merge branch 'feat/discord-notification' into dev
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-05-21 16:39:32 +08:00
sky121113
4ac25f476b [STYLE] 修正 Discord 通知頁面的麵包屑層級 2026-05-21 16:35:22 +08:00
sky121113
a3a00b1403 [FEAT] 實作調撥與補貨單商品快照機制,並修復商品無圖片或載入失敗時的立方體預設圖示顯示
1. 資料庫結構擴充:為 transfer_order_items 與 replenishment_order_items 表新增商品快照欄位,完成遷移。
2. Model 調整與防禦:重構 TransferOrderItem 與 ReplenishmentOrderItem Model,實現商品軟刪除/物理刪除的 name、barcode、image_url 安全 Fallback 及多語系字典動態解析,並防禦性過濾 /storage 等無效圖片路徑。
3. Controller 效能優化:重構寫入與詳情加載邏輯,預載入關聯以防範 N+1 查詢,優化批量寫入效率。
4. 前端圖片載入錯誤處理:在調撥與補貨單明細中,利用 Alpine.js 增加圖片 x-on:error 錯誤監聽,在圖片加載失敗時自動 fallback 顯示與訂單詳情一致的灰色立方體 icon。
2026-05-21 16:31:23 +08:00
sky121113
14ecfdcc8d feat(warehouse): 實作調撥與補貨單商品快照機制,並修復商品無圖片時的預設圖示 fallback 顯示 2026-05-21 16:25:48 +08:00
sky121113
88a58d33c0 fix: 修正 A4 列印表格在商品名稱過長時被過度擠壓換行的排版 bug 2026-05-21 15:56:51 +08:00
sky121113
99e6e37953 feat: 補足調撥單與機台補貨單列印功能之多語系翻譯與測試優化 2026-05-21 15:55:21 +08:00
sky121113
dac00291a7 feat: 實作調撥單與機台補貨單的 A4 極簡奢華風列印功能與多租戶雙重防護 2026-05-21 15:43:43 +08:00
sky121113
205da14e94 [FEAT] 優化溫度告警狀態轉移邏輯與完善機台專屬通知設定 UI
1. 修正 `ProcessHeartbeat.php` 溫度告警判定邏輯,避免溫度在「高低溫異常狀態」直接轉換時,誤發含有異常溫度的「恢復正常」通知。
2. 實作機台層級自訂通知類型 (`notify_types_mode`) 功能,支援繼承全域設定或獨立指派通知類型(連線、溫度、硬體異常)。
3. 優化前端 UI 邏輯,當選擇自訂通知且包含「溫度告警」時,方才顯示溫度上下限輸入區塊。
4. 修正複製機台設定功能 (copy-machine) 的行為,確保能正確複製 `discord_notify_types` 並重設選擇器狀態。
5. 新增與更新中、英、日語系翻譯字串。
2026-05-21 14:34:55 +08:00
sky121113
2e3d6e5572 [STYLE] 優化機台型號與通知設定介面並調整溫度為整數告警
1. 調整 Discord 通知設定介面的間距與對齊,使其與基本設定中的機台設定保持一致。
2. 重構機台型號設定 Modal,將溫度上下限的輸入框改為「極簡奢華風」的加減按鈕元件。
3. 更新機台型號溫度相關的設定與顯示,強制限制與轉換為整數 (移除小數點)。
4. 將機台型號的溫度限制相關介面文字更新為「告警」相關字眼 (預設溫度告警上限、預設溫度告警下限等)。
5. 於中、英、日語系檔案中新增溫度告警相關之翻譯鍵值。
2026-05-21 13:25:28 +08:00
sky121113
cde2718d2a i18n: 補齊 Discord 告警設定與型號溫控相關的繁中/英/日多語系 Key 2026-05-21 10:18:50 +08:00
sky121113
b3d00a9bef feat: 重構 Discord 雙 Tab 告警設定與機台自適應溫控三層繼承鏈,並導入 Modal 內拉式複製 UX 2026-05-21 10:05:36 +08:00
sky121113
c8577ee377 [FIX] 熱修復 Flatpickr 初始化中 Blade 大括號內不必要的反斜線轉義 2026-05-20 14:20:30 +08:00
sky121113
059b25f438 [FIX] 熱修復 Flatpickr 初始化中 Blade 大括號內不必要的反斜線轉義 2026-05-20 14:19:47 +08:00
sky121113
1678fd1775 [FIX] 修正銷售紀錄與出貨紀錄 Flatpickr 初始化中 Blade 大括號內不必要的反斜線轉義以防 PHP 解析錯誤 2026-05-20 14:19:32 +08:00
sky121113
d505ecfbae [RELEASE] 晉升 demo 變更至 main 生產環境
本次晉升包含以下修復與更新項目:
1. [FIX] 全站 Flatpickr 日期時間選擇器在行動端統一啟用 disableMobile 確保 24 小時制與極簡奢華風 UI 一致,解決行動端被強制轉為 12 小時制的問題。
2. [FIX] 修正庫存與校期管理頁面 Alpine.js 變數未定義報錯。
3. [DOCS] 更新 .gitignore 以忽略 Antigravity CLI 本地端設定。
2026-05-20 14:14:38 +08:00
sky121113
55f5a4f72a [PROMOTE] 晉升 dev 變更至 demo
1. [FIX] 修正庫存與校期管理頁面 Alpine.js 變數未定義報錯
2. [DOCS] 更新 .gitignore 以忽略 Antigravity CLI 本地端設定
2026-05-20 13:31:46 +08:00
sky121113
42c882aeb8 [DOCS] 更新 .gitignore 以忽略 Antigravity CLI 本地端設定
1. 在 .gitignore 中加上 /.antigravitycli 以忽略本地 AI 工作區檔案。
2026-05-20 13:29:34 +08:00
sky121113
a92a2500c4 [FIX] 修正庫存與校期管理頁面 Alpine.js 變數未定義報錯
1. 在 resources/views/admin/remote/stock.blade.php 的 stockApp() 初始化狀態中,補上 selectedSlot: null 宣告。
2. 解決因 selectedSlot 變數未在 data scope 聲明,導致 Alpine.js 首次解析 DOM 範本時拋出 ReferenceError 的錯誤。
2026-05-20 13:23:56 +08:00
sky121113
d15bf133ce Merge branch 'dev' into demo 2026-05-20 13:03:42 +08:00
sky121113
775d7bc967 fix(views): 全站 Flatpickr 日期時間選擇器在行動端統一啟用 disableMobile 確保 24 小時制與極簡奢華風 UI 一致 2026-05-20 13:02:34 +08:00
sky121113
76ec9be6ef [PROMOTE] 晉升 dev 變更至 demo 環境
1. 修復行動端時間篩選器強制轉為 12 小時制的問題 (be127ba)
2026-05-20 11:53:19 +08:00
sky121113
be127bab76 [FIX] 修復行動端時間篩選器強制轉為 12 小時制的問題
1. 修正取貨碼使用紀錄篩選器 (tab-logs.blade.php):為 Flatpickr 開始/結束時間初始化配置補上 disableMobile: true,避免手機系統 12 小時制覆蓋。
2. 修正遠端指令歷史紀錄篩選器 (tab-history-index.blade.php):為 Flatpickr 起迄時間初始化配置補上 disableMobile: true,防止行動端原生選擇器轉換。
3. 修正遠端庫存操作紀錄篩選器 (tab-history.blade.php):為 Flatpickr 起迄時間初始化配置補上 disableMobile: true,確保與電腦版一致維持 24 小時制顯示。
2026-05-20 11:52:14 +08:00
sky121113
0f020afa6c [RELEASE] 晉升 demo 到 main 正式上線商品軟刪除與圖片破圖優化
1. 於 DispenseRecord、OrderItem 及 MachineStockMovement 模型之 product 關聯中加入 withTrashed(),使軟刪除商品的歷史快照仍可正常讀取。
2. 將列表、側邊詳情面板與報表導出中的 product->name 統一修改為 product->localized_name ?? __('Unknown Product'),支援多語系切換與翻譯並更正為 '未知商品'。
3. 在銷售明細的商品圖片區塊整合 Alpine.js x-on:error 動態防呆,一旦破圖自動無縫退回 3D 箱子預設占位 SVG,解決與 Blade 內建 @error 衝突引起的編譯錯誤。
2026-05-19 17:27:20 +08:00
sky121113
8b290ff297 [PROMOTE] 晉升 dev 到 demo 進行商品軟刪除與圖片破圖優化驗收
1. 於 DispenseRecord、OrderItem 及 MachineStockMovement 模型之 product 關聯中加入 withTrashed(),使軟刪除商品的歷史快照仍可正常讀取。
2. 將列表、側邊詳情面板與報表導出中的 product->name 統一修改為 product->localized_name ?? __('Unknown Product'),支援多語系切換與翻譯並更正為 '未知商品'。
3. 在銷售明細的商品圖片區塊整合 Alpine.js x-on:error 動態防呆,一旦破圖自動無縫退回 3D 箱子預設占位 SVG,解決與 Blade 內建 @error 衝突引起的編譯錯誤。
2026-05-19 17:25:50 +08:00
sky121113
39ea74a52e [FIX] 修正商品軟刪除之出貨歷史關聯與圖片破圖優化
1. 於 DispenseRecord、OrderItem 及 MachineStockMovement 模型之 product 關聯中加入 withTrashed(),確保被軟刪除商品的歷史快照仍可正常讀取。
2. 將 tab-dispense.blade.php、order-detail-panel.blade.php 及 SalesController 報表導出中的 product->name 統一修改為 product->localized_name ?? __('Unknown Product'),支援多語系切換與翻譯並替換 'Unknown' 為 '未知商品'。
3. 在銷售明細的商品圖片區塊整合 Alpine.js x-on:error 動態破圖防呆機制,一旦偵測到破圖或刪除實體圖,自動無縫退回精緻的 3D 箱子預設占位 SVG,消除破圖小圖標以維護全站極簡奢華風的視覺美感。
2026-05-19 17:25:10 +08:00
sky121113
4b6b431a91 [RELEASE] demo 晉升至 main
本次生產環境發布合併了 demo 分支的已驗收變更,主要包含以下功能:
1. 實作 Web 管理後台「單一帳號僅能單一設備登入」的安全防禦機制。
2. 於 '/dashboard' 及 '/admin' 路由群組中套用 'auth.session' 中極簡安全校驗。
3. 確保機台端 (IoT) 使用的 Stateless Bearer Token API 完全不受 Session 機制干擾。
2026-05-19 16:55:19 +08:00
sky121113
3d1a876c60 [PROMOTE] dev 晉升至 demo
本次晉升合併了 dev 分支上的最新變更,主要包含以下功能開發:
1. 實作 Web 管理後台「單一帳號僅能單一設備登入」的安全防禦機制。
2. 於 '/dashboard' 及 '/admin' 路由群組中套用 'auth.session' 中間件,保障 Session 即時一致性。
3. 確保機台端 (IoT) 使用的 Stateless Bearer Token API 完全不受 Session 機制干擾。
2026-05-19 16:52:18 +08:00
sky121113
1ee57f3e88 feat(auth): 實作後台單一設備登入,並套用 auth.session 中間件 2026-05-19 16:51:43 +08:00
sky121113
b3dcdb3104 [RELEASE] 釋出銷售中心與導出體驗優化至生產環境
本次釋出包含以下功能與修復項目:
1. 升級銷售紀錄(交易紀錄、電子發票、遠端出貨紀錄)高奢翡翠綠導出下拉選單,支援 CSV、Excel、複製至剪貼簿、高規列印與 PDF 五大管道。
2. 透過隱藏的 iframe 下載技術修復 CSV/Excel 導出時主視窗進度條卡死在 95% 的經典問題。
3. 新增員工卡下載範本 download 屬性與 Alpine.js 重置機制,解決全域進度條卡死。
4. 比照商品報表新增「CSV/Excel 報表導出成功」的高奢 Toast 提示。
5. 修正 zh_TW.json 中 Machine Serial No 翻譯重複鍵。
2026-05-19 12:38:46 +08:00
sky121113
bd7682c418 [PROMOTE] 合併 dev 變更至 demo 環境
本次晉升包含以下功能與修復:
1. 升級銷售紀錄(交易紀錄、電子發票、遠端出貨紀錄)高奢翡翠綠導出下拉選單,支援 CSV、Excel、複製至剪貼簿、高規列印與 PDF 五大管道。
2. 透過隱藏的 iframe 下載技術修復 CSV/Excel 導出時主視窗進度條卡死在 95% 的經典問題。
3. 新增員工卡下載範本 download 屬性與 Alpine.js 重置機制,解決全域進度條卡死。
4. 比照商品報表新增「CSV/Excel 報表導出成功」的高奢 Toast 提示。
5. 修正 zh_TW.json 中 Machine Serial No 翻譯重複鍵。
2026-05-19 12:35:12 +08:00
sky121113
1aa5aed72e feat(sales): 升級銷售紀錄高奢翡翠綠導出下拉選單與修復進度條卡死 2026-05-19 12:33:06 +08:00
sky121113
8170234d70 [RELEASE] 晉升庫存過低動態比例一致性優化至正式生產環境
1. 將所有視圖(遠端管理、機台列表庫存抽屜、倉庫機台庫存)原本寫死的庫存過低判斷值改為統一的動態比例 (stock <= max_stock * 0.2)。
2026-05-19 12:03:02 +08:00
sky121113
a8d93293dd [PROMOTE] 晉升庫存過低動態比例一致性優化至 demo 環境
1. 將所有視圖(遠端管理、機台列表庫存抽屜、倉庫機台庫存)原本寫死的庫存過低判斷值改為統一的動態比例 (stock <= max_stock * 0.2)。
2026-05-19 11:56:16 +08:00
sky121113
b5259a5b28 style: align low stock logic to stock <= max_stock * 0.2 across all views 2026-05-19 11:55:26 +08:00
sky121113
5d7d094974 [RELEASE] 晉升員工卡匯入空白字元過濾修復至正式生產環境
1. 於 `StaffCardImportService` 中,將讀取自 Excel 的員工編號(employee_id)與卡片 UID(card_uid)使用 `trim()` 自動過濾,剔除前後空白,防止因貼上夾帶空格導致後續比對與搜尋失效。
2026-05-19 11:46:58 +08:00
sky121113
2c9b75c871 [PROMOTE] 晉升員工卡匯入空白字元過濾修復至 demo 環境
1. 於 `StaffCardImportService` 中,將讀取自 Excel 的所有欄位(尤其是 employee_id 與 card_uid)前後空白使用 `trim()` 自動過濾,提升匯入容錯率。
2026-05-19 11:45:56 +08:00
sky121113
1778d48a00 [FIX] 員工卡匯入時自動清除欄位前後空白字元
1. 於 `StaffCardImportService` 中,將讀取自 Excel 的員工編號(employee_id)與卡片 UID(card_uid)使用 `trim()` 去除前後空白字元,防止因複製貼上夾帶空格導致後續比對與搜尋失效。
2. 同步將姓名(name)與備註(notes)也進行去空白處理,提升資料的一致性與品質。
2026-05-19 11:40:59 +08:00
sky121113
cfbe4655da [RELEASE] 晉升銷售紀錄員工卡資訊功能至正式生產環境
1. 新增 Order 模型中的 `staffCardLog` 一對一關聯關係。
2. 於 SalesController 列表與詳情查詢中進行 Eager Loading 欄位限定優化,完全避免 N+1 查詢。
3. 擴充銷售紀錄模糊搜尋,支援依員工姓名、工號(Employee ID)與感應卡號(Card UID)進行即時篩選。
4. 調整桌面版表格、手機 RWD 卡片,於員工卡支付之訂單下方呈現感應卡圖示與 `姓名 (工號)`。
5. 於訂單詳情側滑面板中新增專屬「員工卡資訊」網格區塊。
6. 補齊 Traditional Chinese(zh_TW)、English(en)、Japanese(ja)三語系之相關翻譯與搜尋框 Placeholder 提示詞。
2026-05-19 11:10:13 +08:00
sky121113
5a0c2953aa [PROMOTE] 晉升 dev 變更至 demo 環境
1. 補齊交易訂單員工卡資訊與搜尋框的英文與日文翻譯(lang/en.json, lang/ja.json)。
2026-05-19 11:06:18 +08:00
sky121113
a4d681e266 [FIX] 補齊交易訂單員工卡資訊與搜尋框的英文與日文翻譯
1. 於 `lang/en.json` 中補齊 `Search Order No / Cards & Codes...` 與 `Staff Card Info` 的英文鍵值對。
2. 於 `lang/ja.json` 中補齊對應的日文翻譯:`注文番号 / カード・コードを検索...` 與 `スタッフカード情報`。
2026-05-19 11:05:55 +08:00
sky121113
4a48b8c806 [PROMOTE] 簡化銷售紀錄搜尋框提示詞至 demo 環境
1. 銷售紀錄搜尋框提示詞更新為「搜尋 訂單號 / 卡號與代碼...」,兼顧未來通行碼與取貨碼擴充性並提升版面潔淨度。
2026-05-19 11:04:02 +08:00
sky121113
6cddc5b1b8 [STYLE] 簡化銷售紀錄搜尋框提示詞,優化版面潔淨度
1. 修改 tab-orders.blade.php,將搜尋提示詞簡化為「Search Order No / Cards & Codes...」,移除冗長且非核心的流水號與發票提示。
2. 更新 zh_TW.json 翻譯,對應為「搜尋 訂單號 / 卡號與代碼...」,兼顧未來的通行碼與取貨碼擴充性。
2026-05-19 11:03:47 +08:00
sky121113
99f49ced0c [PROMOTE] 晉升搜尋框提示詞優化至 demo 環境
1. 銷售紀錄搜尋框提示詞更新為「搜尋 訂單號 / 流水號 / 發票號碼 / 員工資訊...」,提示管理員可進行員工資訊查詢。
2026-05-19 11:01:42 +08:00
sky121113
d9423a3fb1 [STYLE] 更新銷售紀錄搜尋框提示詞,支援員工資訊提示
1. 修改 tab-orders.blade.php,將搜尋框提示詞更新為「Search Order No / Flow ID / Invoice / Staff Info...」。
2. 更新 zh_TW.json 翻譯,對應為「搜尋 訂單號 / 流水號 / 發票號碼 / 員工資訊...」。
2026-05-19 11:01:27 +08:00
sky121113
307b98f86e [PROMOTE] 晉升 dev 變更至 demo 測試環境
1. 銷售紀錄(Orders)與員工卡交易資訊進行全關聯。
2. 新增支援員工姓名、工號與感應卡號的模糊搜尋與過濾條件。
3. 桌面版與手機 RWD 版銷售列表,新增刷卡資訊與 icon 精美徽章展示。
4. 銷售詳情側滑面板,新增專屬的「員工卡資訊」網格區塊。
2026-05-19 10:54:59 +08:00
sky121113
433714d9b0 [FEAT] 銷售紀錄新增員工卡交易資訊關聯、搜尋與介面展示
1. 於 Order 模型中定義 staffCardLog 關係以關聯 StaffCardLog。
2. 於 SalesController 列表查詢(index)與單筆詳情(show)中進行極致效能優化,限定 Eager Loading 僅撈取必要欄位(id, employee_id, name, card_uid),避免 N+1 查詢與記憶體開銷。
3. 擴充 SalesController 的銷售紀錄搜尋過濾邏輯,新增支援員工姓名、工號與感應卡號的模糊搜尋。
4. 修改 tab-orders 桌面版表格與手機版卡片視圖,為員工卡支付的訂單加上精美的員工卡 icon 與「姓名 (工號)」徽章。
5. 修改 order-detail-panel 側滑詳情面板,新增「員工卡資訊」網格區塊,展示員工姓名、工號、卡片 UID 與備註欄位。
6. 更新 zh_TW.json 補齊「員工卡資訊」的繁體中文翻譯,確保全站語言一致。
2026-05-19 10:54:18 +08:00
sky121113
5c3e4538ee [RELEASE] 晉升 demo 變更至 main 生產環境
1. [FEAT] 新增分析管理與其子項目的角色權限配置與選單防禦,限制租戶管理員預設僅有「商品報表」權限。
2. [FEAT] 補齊與優化商品銷售排行、商品利潤統計報表功能,新增尊榮徽章與排行數據匯出。
3. [FIX] 修正排行欄位空白、商品報表篩選 UI 邏輯。
4. [STYLE] 修復 Flatpickr 日期選擇器寬度與 padding,防止右側月曆裁剪.
5. [STYLE] 修正歷史 migration 的 SQLite 相容性,新增 AnalysisPermissionTest 自動化 Feature 測試並全數綠燈通過。
6. [FIX] 補齊廣告與機台管理相關語系翻譯,修正多語系標記換行問題。
2026-05-18 17:34:43 +08:00
sky121113
8725c276cb [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FEAT] 新增分析管理子項目的角色權限配置與路由選單防護,限制租戶管理員僅擁有「商品報表」權限。
2. [FIX] 補齊廣告、機台管理等翻譯文字,修復多語系標記換行問題。
3. [STYLE] 修復歷史 migration 的 SQLite 相容性,新增 AnalysisPermissionTest 自動化 Feature 測試並全數綠燈通過。
2026-05-18 17:26:05 +08:00
sky121113
916d0891f1 [FEAT] 新增分析管理與子項目的角色權限配置
1. 在 RoleSeeder 中註冊分析管理的 4 個子權限(零錢庫存、機台報表、商品報表、問卷分析),並僅指派「商品報表」權限給租戶管理員模板。
2. 在 PermissionController 與視圖 (role-list, role-card) 的 activeModules 白名單中加入 menu.analysis,使數據分析大項能正常被撈取與顯示。
3. 修改 sidebar-menu.blade.php,用 @can 防護數據分析子選單;修改 web.php 為 4 個分析路由掛載 middleware('can:...') 路由防護。
4. 補全 zh_TW.json、en.json、ja.json 翻譯檔案中關於 4 個新分析權限的金鑰與翻譯文字。
5. 修復 2026_05_11_163543_add_missing_types_to_machine_stock_movements_table.php 歷史 migration 以相容 SQLite 測試。
6. 新增 AnalysisPermissionTest.php 測試案例並於本地測試通過,保證多租戶與 RBAC 架構之權限隔離與防越權機制 100% 正確運作。
2026-05-18 17:22:45 +08:00
sky121113
ecb3750abe [FIX] 補齊廣告與機台管理相關語系翻譯並修正多語系標記換行問題
1. 補齊 'Target Position' 於 zh_TW.json、ja.json 及 en.json 的多語系翻譯字串,並維持字母排序。
2. 補齊 'Order updated successfully.' 於 zh_TW.json、ja.json 及 en.json 的多語系翻譯字串,並維持字母排序。
3. 修復多個視圖檔案(包含機台建立、分析儀表板、商品管理與遠端監控等 Blade views)中 `__()` 語系標記跨行問題,合併為單行以避免翻譯匹配失效。
2026-05-18 17:15:39 +08:00
sky121113
7991a9b662 Merge branch 'main' into dev 2026-05-18 17:02:18 +08:00
sky121113
4c2b1e7a54 Merge branch 'main' into demo 2026-05-18 17:02:02 +08:00
sky121113
cc8a594f1b Merge branch 'hotfix/fix-machine-ad-delete-confirm' into main 2026-05-18 17:01:43 +08:00
sky121113
91021021d9 [FIX] 修復刪除機台廣告設置時缺少自訂確認視窗與非同步載入圖示
1. 將原本使用瀏覽器原生 confirm 的解除綁定功能,重構為自訂極奢風確認彈窗 x-confirm-modal。
2. 擴充通用確認彈窗 confirm-modal.blade.php 支援 isSubmittingVar 屬性,在非同步提交中顯示按鈕 Spinner 並禁用操作。
3. 優化新增廣告設置彈窗 assign-modal.blade.php 支援 isSubmitting 狀態,點擊後按鈕出現 Spinner 並鎖定彈窗。
4. 整合 AJAX 載入狀態,在 fetchMachineAds() 載入資料期間顯示卡片層級雙環 x-luxury-spinner 載入圖示。
5. 補齊繁體中文、英文與日文的解除綁定多語系翻譯。
2026-05-18 17:01:17 +08:00
sky121113
f375d86189 [PROMOTE] 晉升 dev 至 demo
本次晉升包含以下功能與修復:
1. 重構與優化商品利潤統計報表功能
2. 新增商品銷售排行欄位與尊榮徽章,並更新資料匯出引擎
3. 修復商品報表日期篩選 UI 邏輯與選項選取狀態
4. 優化 Flatpickr 日期選擇器視覺樣式與間距調整
5. 更新多語系支援 (en, ja, zh_TW) 與側邊欄選單項目
2026-05-18 16:29:54 +08:00
sky121113
fb1bf68cc5 Merge branch 'feat/analysis-report' into dev 2026-05-18 16:21:38 +08:00
sky121113
46e8bdfaae [FIX] 修復商品報表篩選 UI 邏輯與選項選取狀態
1. 調整商品報表篩選區塊順序,將「公司篩選」移至「商品篩選」前方,符合依公司過濾商品的操作邏輯。
2. 修復 <x-searchable-select> 在透過 slot 傳入選項時,無法正確綁定 selected 狀態的問題,手動於 <option> 標籤中補上選取狀態判斷。
2026-05-18 15:51:45 +08:00
sky121113
bb675a538a [FEAT] 合併商品統計報表與日期選擇器視覺修復
1. 重構並優化商品統計報表功能,新增利潤分析走勢圖表與大額數據處理。
2. 新增商品銷售排行前 5 名展示,並搭配精緻的尊榮徽章(Gold, Silver, Bronze)設計。
3. 優化資料導出引擎,支援多租戶隔離之 Excel/CSV 商品銷售數據匯出。
4. 全面修正日期選擇器(Flatpickr)排版,拓寬容器防水平滾動,對齊輸入框與下拉選單高度。
5. 重構日曆區間選擇樣式,實現區間中段無縫圓角拼接,並解決最右側「週六」欄位被裁切截斷的問題。
2026-05-18 15:09:09 +08:00
sky121113
0cef0133a9 style: expand flatpickr calendar width to accommodate padding and prevent right column clipping 2026-05-18 15:05:54 +08:00
sky121113
676e91fd66 style: optimize flatpickr calendar styling, input dimensions, and icon alignment 2026-05-18 15:04:31 +08:00
sky121113
e4c63bbf38 [FIX] 修正排行欄位顯示空白問題
1. 將 product-reports.blade.php 中 13 處誤植為 pageSize 的變數,統一修正為 Alpine.js component data 物件所定義之 perPage 屬性。
2. 徹底解決因 pageSize 未定義而導致計算出 NaN、從而造成排行列內容空白未顯示的 Bug。
2026-05-18 14:57:26 +08:00
sky121113
90a77404fe [FEAT] 新增商品銷售排行欄位與尊榮徽章,並更新資料匯出引擎
1. 於商品成本分析報表(電腦版 Table)最左側新增「排行」欄位,完美對齊圓角卡片樣式。
2. 針對前三名(Top 3)銷售商品設計尊榮徽章:第 1 名黃金徽章、第 2 名白銀徽章、第 3 名青銅徽章,其餘名次採用精緻 Mono 灰字。
3. 行動版 Card 移除原先通用包裹圖示,替換為智能偵測之獎牌徽章(🥇🥈🥉)或排行數字。
4. 全面升級資料複製(TSV)、CSV、Excel 與 PDF 列印等四大導出引擎,將「排行」業務指標同步注入首欄,並處理 colspan 對齊。
5. 補強多語系翻譯,於 zh_TW.json、en.json 與 ja.json 中完美新增「Rank / 排行 / 順位」鍵值。
2026-05-18 14:55:23 +08:00
sky121113
1dc79f2cec [STYLE] 修正商品報表日期篩選之 Icon 與文字左間距,對齊銷貨紀錄規格 2026-05-18 14:49:20 +08:00
sky121113
154a2e0553 [FEAT] 重構與優化商品利潤統計報表功能
1. 【後端優化】重構 AnalysisController.php,以高效單次 SQL 統計計算銷量、銷額、總成本與淨利,套用 company_id 進行多租戶隔離,支援超級管理員跨租戶查詢,並解決 MySQL only_full_group_by 嚴格模式的 group by 語法相容問題。
2. 【前端介面】全新實作 product-reports.blade.php 極簡奢華風視圖,整合 Flatpickr 雙向日期區間選擇與「近 7 日、今日、本週、上週、本月、上月」快速切換快捷按鈕。
3. 【圖表自適應】整合 ApexCharts 銷售走勢曲線與圓餅圖,設計 PHP 端走勢數據日補零機制,並導入 ResizeObserver 進行圖表寬度百分之百響應式重繪。
4. 【尊榮數據導出】提供複製 (tsv)、CSV、Excel 與 PDF/列印的多維度導出引擎控制項。
5. 【高級前端分頁】對齊銷售紀錄規格,實作 Alpine.js 純前端高級分頁與 Show 10/25/50/100 筆數選擇器,並修復 select 動態 option 渲染時 ES6 template string 逸出問題。
6. 【細節齊平與遮擋修正】將日期 Input 寬度由 sm:w-56 拓寬至 sm:w-72,將日曆 Icon 左邊距從 pl-3.5 改進為 pl-5,Input 文字內左距調整為 pl-13,達到與 searchable-select 的 100% 齊平對齊,並配置跨卡片遞降 z-index 層級(z-40 -> z-30 -> z-20)徹底解決下拉選單在電腦版被下方走勢圖 Card 遮擋/截斷的 Bug。
7. 【多語系支援】於 zh_TW.json, en.json, ja.json 補強指標中英日翻譯。
8. 【選單整合】於 sidebar-menu.blade.php 的數據分析模組下正式啟用「商品報表」選單項目。
2026-05-18 14:46:17 +08:00
sky121113
115a4f0263 [RELEASE] 釋出 demo 分支修復內容至正式生產環境 main 分支
本次正式生產環境發布包含以下修復:
1. 修復員工識別卡「最後使用」資料無法更新之問題 (透過 StaffCardLog 模型事件監聽器自動回寫,並排除租戶 Scope)。
2. 修正員工識別卡刪除/解除關聯時網頁直接渲染顯示 JSON 字串之錯誤,改為判斷 AJAX 請求並執行正常的重導向。
3. 補齊多語系語系檔(zh_TW.json, en.json, ja.json)中有關「刪除員工識別卡」的翻譯對照。
2026-05-18 13:38:36 +08:00
sky121113
7b6c6f0998 [PROMOTE] 晉升 dev 分支變更至 demo 分支
本次晉升包含了以下修復與功能變更項目:
1. 修復員工識別卡「最後使用」資料無法更新之問題 (透過 StaffCardLog 模型事件監聽器自動回寫,並排除租戶 Scope)。
2. 修正員工識別卡刪除/解除關聯時網頁直接渲染顯示 JSON 字串之錯誤,改為判斷 AJAX 請求並執行正常的重導向。
3. 補齊多語系語系檔(zh_TW.json, en.json, ja.json)中有關「刪除員工識別卡」的翻譯對照。
2026-05-18 13:37:50 +08:00
sky121113
17c7a26ef4 [FIX] 修復員工識別卡最後使用狀態更新、刪除頁面顯示 JSON 與語系遺漏問題
1. 在 StaffCardLog 模型中實作 booted() 事件監聽器,於刷卡日誌建立後自動更新關聯員工識別卡的「最後使用時間」與「最後使用機台」,並使用 withoutGlobalScopes 確保寫入安全。
2. 修正 StaffCardController 刪除方法(destroy),判斷 AJAX 請求以回傳 JSON,其餘一般表單刪除後則正常重導向回列表頁,解決刪除時跳轉至 JSON 純文字的問題。
3. 補齊多語系語系檔(zh_TW.json, en.json, ja.json)中有關「刪除員工識別卡」的翻譯對照,確保 UI 刪除警告在不同語言環境下均能完美呈現。
2026-05-18 13:37:21 +08:00
sky121113
f374f4a6d6 [PROMOTE] 晉升 demo 變更至 main 生產環境
1. [STYLE] 優化機台補貨語意,將「一鍵補貨」修正為「自動補貨單」。
2026-05-18 13:16:28 +08:00
sky121113
ae2b089422 [PROMOTE] 晉升 dev 變更至 demo 分支
1. [STYLE] 優化機台補貨語意,將「一鍵補貨」修正為「自動補貨單」。
2026-05-18 13:15:05 +08:00
sky121113
e29826a0a6 [STYLE] 優化機台補貨語意,將「一鍵補貨」修正為「自動補貨單」
1. 修改 lang/zh_TW.json 的翻譯鍵 "Auto Replenishment" 對照值為「自動補貨單」,使其與手動「新增補貨單」在視覺與語意上形成直覺的對比,實事求是,消除「一鍵」但仍需彈窗選倉庫的 UX 誤導。
2. 同步將 app/Http/Controllers/Admin/WarehouseController.php 中的 autoReplenishment PHPDoc 註解替換為「自動補貨單」。
3. 同步修改 resources/views/admin/warehouses/replenishments.blade.php 中的 JS 代碼註解,將「一鍵補貨」修正為「自動補貨單」。
4. 同步更新 resources/views/admin/warehouses/partials/modal-replenishment-auto.blade.php 模板首行的 Blade 註解。
2026-05-18 13:14:29 +08:00
sky121113
973330a816 [PROMOTE] 晉升 demo 變更至 main 生產環境
1. 修正 utilization.blade.php 中的自訂 Tooltip,解決圖表動態數據加載時小時標籤顯示 undefined 的 Bug。

2. 於 ApexCharts 配置中新增 accessibility.enabled = false 屬性,完全關閉圖表 SVG 的 ARIA 標籤生成,避免瀏覽器或輔助擴充套件在游標懸停時彈出無障礙輔助提示框。
2026-05-18 10:27:16 +08:00
sky121113
4600912051 [PROMOTE] 晉升 dev 變更至 demo 環境
1. 修正 utilization.blade.php 中的自訂 Tooltip,解決圖表動態數據加載時小時標籤顯示 undefined 的 Bug。

2. 於 ApexCharts 配置中新增 accessibility.enabled = false 屬性,完全關閉圖表 SVG 的 ARIA 標籤生成,避免瀏覽器或輔助擴充套件顯示無障礙浮動提示框。
2026-05-18 10:26:28 +08:00
sky121113
7bf8dc173d [FIX] 修正機台稼動率圖表 tooltip 顯示 undefined 與 accessibility 提示框問題
1. 修正 utilization.blade.php 自訂 Tooltip 中獲取小時標籤的寫法,改為直接使用 scope 中的 labels 變數,解決動態數據加載時顯示 undefined 的問題。

2. 於 ApexCharts 的 chart 配置中設定 accessibility.enabled = false,防止瀏覽器或輔助擴充套件顯示 ARIA 輔助提示框。
2026-05-18 10:25:10 +08:00
sky121113
47b942ce06 [RELEASE] 晉升測試驗收分支 (demo) 到正式生產環境 (main)
1. [FEAT] 實作智慧型機台登入異常告警與自動自我修復機制:包含連續 3 次錯誤門檻與登入成功自動歸零(Self-healing)。
2026-05-18 09:56:29 +08:00
sky121113
a40aa7977c [PROMOTE] 晉升開發分支 (dev) 到測試驗收環境 (demo)
1. [FEAT] 實作智慧型機台登入異常告警與自動自我修復機制:包含連續 3 次錯誤門檻與登入成功自動歸零(Self-healing)。
2026-05-18 09:52:07 +08:00
sky121113
86e895a4ae [FEAT] 實作智慧型機台登入異常告警與自動自我修復機制
1. [Models] 修改 Machine.php,增加 has_login_warning 動態屬性,設定未解決之登入失敗日誌須達 3 筆以上才亮起「登入異常」警報。
2. [Models] 重構 scopeHasAnyAlert 查詢,確保當未解決之登入失敗日誌未達 3 筆時,不會計入儀表板的「待處理告警 (Alerts Pending)」統計。
3. [API] 修改 MachineAuthController.php,在 B000 登入成功時加入自動自我修復 (Self-healing) 邏輯,一鍵將先前該機台的所有未解決登入失敗日誌標記為已解決 (is_resolved = true)。
4. [Views] 修改 admin.dashboard 及 admin.machines.index,新增登入異常警告 (Login Alert) 的琥珀色徽章與動態提示,並將儀表板上的機台卡片標題改為點擊可跳轉至對應機台之篩選列表。
5. [Lang] 更新 zh_TW.json、en.json 與 ja.json,新增多語系翻譯對照詞彙。
2026-05-18 09:50:51 +08:00
sky121113
da7fd0799b [RELEASE] 釋出修復與機台補貨單重構至正式環境
1. 修正側邊欄選單權限防禦:將倉庫管理、銷售管理、遠端管理所有子選單包裹於對應的 Spatie @can 門檻中,完美隱藏未授權子功能。

2. 重構機台補貨單按鈕:將機台庫存概覽的「一鍵補貨」按鈕更名為「產生補貨單」,並補齊中、英、日語系 JSON 翻譯。

3. 實作按鈕點擊攔截:若當前使用者不具備 menu.warehouses.replenishments 權限,將會透過 Alpine.js 阻擋跳轉,並發布 CustomEvent 彈出紅色 Error Toast 警告。
2026-05-18 09:26:00 +08:00
sky121113
33e3dbe3bf [PROMOTE] 晉升 dev 變更至 demo 環境
1. 修復側邊欄選單中倉庫、銷售、遠端管理子選單未套用 Spatie 權限包裹的 UI 異常,完美依據權限顯示/隱藏。

2. 重構機台庫存概覽頁面的「一鍵補貨」按鈕為「產生補貨單」,並補齊中、英、日語系翻譯。

3. 實作補貨按鈕 Alpine.js 權限阻擋防護,未授權用戶點擊時將攔截並彈出紅色 Error Toast 警告。
2026-05-18 09:16:09 +08:00
sky121113
536d10234d [FIX] 修復角色選單側邊欄顯示異常與重構機台補貨按鈕權限防禦
1. 修復 resources/views/layouts/partials/sidebar-menu.blade.php 中倉庫、銷售、遠端管理子選單未被 Spatie 權限包裹的 UI 異常,完美隱藏未授權子項目。

2. 重構 resources/views/admin/warehouses/machine-inventory.blade.php 的補貨按鈕文字,將「一鍵補貨」變更為「產生補貨單」。

3. 於 machine-inventory.blade.php 的 Alpine.js 控制器中動態宣告 hasReplenishPermission 權限判斷,若當前使用者不具備 menu.warehouses.replenishments 權限,點擊按鈕時將攔截跳轉並透過 dispatch 發送全域紅色 Error Toast 警告。

4. 擴充 zh_TW.json、en.json、ja.json 多語系翻譯檔,補齊「產生補貨單」與「無權限存取補貨單」之三語系翻譯。
2026-05-18 09:15:11 +08:00
sky121113
0067c5c706 [RELEASE] 晉升 demo 至 main 正式環境 (登入 UI 重構、管理員切換帳號與效能體驗優化)
1. 實作系統管理員切換租戶帳號功能 (Impersonate),便於跨公司偵錯。
2. 重構登入頁面,改用白金極簡懸浮卡片 (方案 B),搭配 20% 透明度販賣機浮水印與高動態漸變背景。
3. 全站品牌標誌更換,包含 Favicon、側邊欄 Icon 及 Loading 珍珠白底與 Cache Busting 快取更新機制。
4. 調升個人頭像上傳限制額度至 5MB,並自動於後端轉換為高效能 WebP 格式。
5. 解決新增帳號與子帳號表單 Modal 的 HSSelect 搜尋下拉選單 z-index 遮擋衝突 (導入 focus-within 動態堆疊)。
2026-05-17 16:27:43 +08:00
sky121113
84015414b3 [PROMOTE] 整合新增帳號與子帳號下拉選單 z-index 遮擋修正至 demo 2026-05-17 13:24:57 +08:00
sky121113
223fcddbc5 fix(ui): apply dynamic z-index pattern to resolve searchable select dropdown overlap in user modals 2026-05-17 13:24:32 +08:00
sky121113
ed1560f216 [PROMOTE] 整合 favicon 快取清除 (Cache Busting) 修正至 demo 2026-05-17 13:20:59 +08:00
sky121113
4e65aca18e style(favicon): add cache busting query parameter to favicon.ico link to bypass browser caching 2026-05-17 13:20:43 +08:00
sky121113
d48bb6f32b [PROMOTE] 晉升 dev 至 demo 環境 (登入 UI 重構與帳號切換功能)
1. 實作系統管理員切換帳號 (Impersonate) 懸浮視窗與後端模擬登入功能。
2. 重新設計登入頁面,採用方案 B 白金極簡懸浮卡片,調大 Star Cloud 品牌 Logo,並提升智慧販賣機 IoT 浮水印至 20% 透明度。
3. 全面更新全站 Favicon 與側邊欄品牌標誌,最佳化高奢載入畫面中央 Logo 為珍珠白底。
4. 將個人檔案圖片上傳前端限制由 1MB 提升至 5MB,並確認頭像、商品管理、廣告管理圖片皆已轉換為高品質 WebP 格式。
2026-05-17 13:15:47 +08:00
sky121113
eb11ff0a93 Merge branch 'feat/login-ui-redesign' into dev 2026-05-17 13:14:36 +08:00
sky121113
1b63ec6c56 feat(profile): increase profile photo upload limit to 5MB and verify webp storage 2026-05-17 13:09:39 +08:00
sky121113
3857abe2e5 Merge branch 'feat/login-ui-redesign' into dev (珍珠白底載入 Logo) 2026-05-17 12:46:57 +08:00
sky121113
a77236bda8 [STYLE] 將全螢幕載入畫面中央 Logo 的背景框更改為珍珠白底
1. 將 loading-screen.blade.php 中央 Logo 的背景樣式 bg-slate-900/80 改為 bg-white,大幅提升深藍色品牌圖標的清晰度與對比度。
2. 微調內邊距 p-2 與邊框 border-slate-100,讓品牌 Icon 在旋轉霓虹光環中央更加醒目與精緻。
2026-05-17 12:46:42 +08:00
sky121113
b4c6ddc225 Merge branch 'feat/login-ui-redesign' into dev 2026-05-17 12:43:37 +08:00
sky121113
9eb314f0e2 [STYLE] 更新 Favicon 與側邊欄品牌標誌
1. 使用 Pillow 將新品牌 Logo 自適應裁切並生成 512x512 PNG 格式的 starcloud_icon.png。
2. 生成包含多種主流尺寸(16x16 至 256x256)的全新 favicon.ico。
3. 於後台管理主模板 (admin.blade.php) 與登入視圖 (login.blade.php) 中加入 Favicon 宣告。
4. 替換後台主模板 (admin.blade.php)、全螢幕載入畫面 (loading-screen.blade.php) 以及 Logo 元件 (application-logo.blade.php) 中所有舊的 S1_cropped_transparent.png 為全新星雲品牌 icon。
2026-05-17 12:43:22 +08:00
sky121113
b8aeef4262 feat: 重新設計登入頁面,採用方案 B 白金極簡懸浮卡片,移除點點底紋與表單背景,調大品牌 Logo,並提升販賣機浮水印至 20% 透明度 2026-05-17 12:38:46 +08:00
sky121113
ea438d55d1 [FEAT] 實作系統管理員切換帳號 (Impersonate) 功能
1. 新增 `ImpersonateController` 處理帳號切換與退出邏輯,並提供公司與使用者的動態下拉選單 API (包含 username 支援)。
2. 於 `admin.blade.php` 的 Header 個人選單中加入「切換帳號」入口,並支援退出切換狀態的顯示。
3. 實作 `modal.blade.php` Alpine.js 彈窗,使用專案規範的 `x-searchable-select` 動態重建模式 (Dynamic Rebuild Pattern) 與 `focus-within:z-[60]` 防護。
4. 註冊相關 Web Routes。
5. 更新 `ui-minimal-luxury/SKILL.md`,新增 Alpine.js Modal 實作規範,並禁止使用 `hs-overlay` 避免層級堆疊問題。
2026-05-17 11:07:41 +08:00
sky121113
d94bb6d0f5 [RELEASE] Demo to Main
1. 修正通行碼載入失敗問題
2. 標準化所有單據模組日期篩選器 UI
3. 優化操作紀錄查詢效能 (新增複合索引)
2026-05-16 23:45:02 +08:00
sky121113
b33f91896f [PROMOTE] Dev to Demo
1. 修正通行碼載入失敗問題
2. 標準化所有單據模組日期篩選器 UI
3. 優化操作紀錄查詢效能 (新增複合索引)
2026-05-16 23:42:37 +08:00
sky121113
cf2c2f6e39 [FIX] 修正通行碼載入失敗問題,並標準化所有單據模組的日期篩選器 UI 2026-05-16 23:41:23 +08:00
sky121113
cbc477d95a [RELEASE] 正式發布:機台管理 UI 選單修正
1. [FIX] 修正機台管理下拉選單遮擋問題,導入 focus-within:z-index 動態層級模式
2026-05-16 22:14:47 +08:00
sky121113
0adef38523 [PROMOTE] 晉升機台管理 UI 選單修正至 Demo 環境
1. [FIX] 修正機台管理下拉選單遮擋問題,導入 focus-within:z-index 動態層級模式
2026-05-16 22:11:51 +08:00
sky121113
88f522c60d [FIX] 修正機台管理下拉選單遮擋問題,導入 focus-within:z-index 動態層級模式 2026-05-16 22:11:14 +08:00
sky121113
aa154fb78e [RELEASE] 晉升 demo 變更至 main 生產環境
1. 修正 ProcessHeartbeat 在快取失效時會誤報韌體與溫度的問題。
2. 引入 Database Fallback 機制:當 Redis 無紀錄時自動比對資料庫現有值。
3. 統一韌體與溫度的偵測邏輯,確保兩者皆為「比對舊值 -> 異動才記 -> 補回快取」。
2026-05-16 21:46:45 +08:00
sky121113
1017a5cc8c [PROMOTE] 晉升 dev 變更至 demo 環境
1. 修正 ProcessHeartbeat 在快取失效時會誤報韌體與溫度的問題。
2. 引入 Database Fallback 機制:當 Redis 無紀錄時自動比對資料庫現有值。
3. 統一韌體與溫度的偵測邏輯,確保兩者皆為「比對舊值 -> 異動才記 -> 補回快取」。
2026-05-16 21:46:08 +08:00
sky121113
c014d7431c [FIX] 修正重啟後產生重複機台日誌的問題並重構邏輯一致性
1. 修正 ProcessHeartbeat 在快取失效時會誤報韌體與溫度的問題。
2. 引入 Database Fallback 機制:當 Redis 無紀錄時自動比對資料庫現有值。
3. 統一韌體與溫度的偵測邏輯,確保兩者皆為「比對舊值 -> 異動才記 -> 補回快取」。
4. 提升系統對 CI/CD 重啟或快取清理的容錯能力。
2026-05-16 21:45:18 +08:00
sky121113
33e0c0cbef [RELEASE] 晉升 demo 變更至 main 環境 (正式上線)
1. 包含:優化遠端管理與庫存歷史紀錄之日期篩選介面 (feat/remote-date-ui-opt)
2026-05-16 21:29:16 +08:00
sky121113
f6a406459a [PROMOTE] 晉升 dev 變更至 demo 環境
1. 包含:優化遠端管理與庫存歷史紀錄之日期篩選介面 (feat/remote-date-ui-opt)
2026-05-16 21:27:43 +08:00
sky121113
080412d2b3 [FEAT] 優化遠端管理與庫存歷史紀錄之日期篩選介面
1. 將單一日期區間選擇器拆分為獨立的「開始時間」與「結束時間」輸入框,提升手機版適應性。
2. 修正後端解析邏輯,移除強制 startOfDay() 以支援使用者選擇的精確分鐘數。
3. 修復庫存管理頁面中「截止日」欄位的 HTML 標籤閉合錯誤 (r">" 亂碼)。
4. 導入 Flatpickr defaultDate 與 position 參數,解決時間選擇器 UI 不對齊與邊緣裁切問題。
5. 更新標籤翻譯為「開始時間」與「結束時間」,並補齊庫存分頁的 Alpine.js 預設日期狀態。
2026-05-16 21:25:47 +08:00
sky121113
bca22ffb99 [RELEASE] 遠端指令與庫存管理效能優化正式上線
1. 全面優化指令中心與庫存管理頁面之 JSON 資料結構,大幅降低渲染延遲。
2. 針對 remote_commands 資料庫表補齊 command_type, status, created_at 索引。
2026-05-16 20:40:31 +08:00
sky121113
903f471d51 [PROMOTE] 優化遠端指令中心與庫存管理效能
1. 優化指令中心與庫存管理頁面之 JSON 傳遞,減輕 DOM 渲染負擔。
2. 針對 remote_commands 資料表新增 command_type, status, created_at 索引。
2026-05-16 20:39:12 +08:00
sky121113
9fce030f5c merge: remote performance optimization 2026-05-16 20:39:01 +08:00
sky121113
51d5ac87c7 refactor: optimize command center and stock management performance with lightweight JSON and DB indexes 2026-05-16 20:38:05 +08:00
sky121113
6fcb197eb5 [RELEASE] Production Update: Optimized inventory warning logic
1. fix: update low stock warning threshold to 20% of max_stock
2. merge: integrate inventory threshold 20% fix
2026-05-16 20:09:52 +08:00
sky121113
8fa2421978 [PROMOTE] Dev to Demo: Updated low stock warning logic to 20% threshold
1. fix: update low stock warning threshold to 20% of max_stock
2. merge: integrate inventory threshold 20% fix
2026-05-16 20:08:37 +08:00
sky121113
5574f845ab merge: integrate inventory threshold 20% fix 2026-05-16 20:08:20 +08:00
sky121113
9bf670b951 fix: update low stock warning threshold to 20% of max_stock
1. 移除硬編碼的門檻值 5。
2. 改用 stock <= (max_stock * 0.2) 進行動態判斷。
3. 增加 max_stock > 0 過濾,避免空貨道產生錯誤警示。
2026-05-16 20:07:12 +08:00
sky121113
7b80400a09 [RELEASE] Production Update: Optimized temperature logging behavior
1. fix: 移除冗餘的 4 小時溫度保底日誌,大幅減少生產環境日誌噪音。
2026-05-16 16:57:18 +08:00
sky121113
e9f04958af [PROMOTE] Dev to Demo: Optimized temperature logging behavior
1. fix: 移除冗餘的 4 小時溫度保底日誌,減少無意義噪音。
2026-05-16 16:51:29 +08:00
sky121113
88dde0dd25 fix: remove redundant 4-hour temperature fallback log to reduce noise 2026-05-16 16:49:38 +08:00
sky121113
f04d9ba31f [RELEASE] 晉升 demo 變更至 main 環境 (2026-05-15)
1. [FEAT] 金流配置優化:公司名稱改為選填並優化列表顯示。
2. [DB] 執行資料庫遷移,將 payment_configs.company_id 設為 nullable。
2026-05-15 17:33:23 +08:00
sky121113
d1c7b48ce6 [PROMOTE] 晉升 dev 變更至 demo 環境 (2026-05-15)
1. [FEAT] 金流配置優化:公司名稱改為選填並優化列表顯示 (Commit: 450e6db)
2. [DB] 執行資料庫遷移,將 payment_configs.company_id 設為 nullable。
2026-05-15 17:31:58 +08:00
sky121113
450e6dbe67 [FEAT] 金流配置優化:公司名稱改為選填並優化列表顯示
1. 新增資料庫遷移腳本,將 payment_configs 資料表的 company_id 欄位改為可為空 (nullable)。
2. 修改 PaymentConfigController 的驗證邏輯,允許 company_id 為空值。
3. 調整金流配置的新增與編輯頁面,移除公司名稱的必填限制及相關 JavaScript 攔截邏輯。
4. 優化金流配置列表,當配置未綁定公司時,將顯示標籤改為「系統」。
2026-05-15 17:28:33 +08:00
sky121113
bfd12b9ca8 [RELEASE] Promote demo to main (2026-05-15)
1. [FEAT] 客戶管理欄位非必填化:調整 companies.code 為 nullable 並擴張長度至 100。
2. [FIX] 部署流程穩定性優化:修正標籤映射、解決部署衝突並優化生產環境本地 Runner 設定。
2026-05-15 17:05:35 +08:00
sky121113
ea4dbbdedf [PROMOTE] dev to demo
1. [FEAT] 客戶管理欄位(客戶代號、起始日)非必填化:調整 companies.code 為 nullable 並增加長度。
2. [FIX] 優化 CI/CD 流程:修正標籤映射與 Runner 設定,確保生產環境部署穩定性。
2026-05-15 17:04:22 +08:00
sky121113
2f550839cc [FEAT] 客戶管理欄位(客戶代號、起始日)非必填化
1. 新增遷移檔將 companies.code 改為 nullable 並增加長度至 100,解決刪除時字串過長的問題。
2. 修改 CompanyController 驗證邏輯,允許 code 與 start_date 為空。
3. 更新前端公司管理頁面,移除 code 與 start_date 的必填限制與 UI 標記。
2026-05-15 17:02:59 +08:00
sky121113
cfaa8dfe54 [FEAT] 同步生產環境部署流程至與 Demo 站一致的嚴謹架構 2026-05-15 16:22:03 +08:00
sky121113
21d1a0e3c1 [FIX] 修正標籤映射以支援 Node.js 環境 2026-05-15 16:19:18 +08:00
sky121113
9471332d49 [FIX] 解決部署流程衝突並應用生產環境本地 Runner 優化 2026-05-15 16:15:49 +08:00
sky121113
c412b9c43d [FEAT] 優化生產環境部署流程與 Runner 設定
1. 在生產環境主機 (star-cloud-prod) 安裝並啟動獨立的 act_runner (標籤: prod)。
2. 修改 .gitea/workflows/deploy-prod.yaml,將部署模式從 SSH/Rsync 改為本地 Docker 模式。
3. 確保 demo 與 prod 環境隔離,提升部署效率與安全性。
2026-05-15 16:15:16 +08:00
sky121113
74de9abdac Merge branch 'demo' 2026-05-15 15:53:49 +08:00
sky121113
bc7c94ca0d Merge branch 'dev' into demo 2026-05-15 15:53:36 +08:00
sky121113
b2e6e2567a [FIX] 實施生產環境 Runner 隔離\n\n1. 修改 deploy-prod.yaml 將 runs-on 設定為 [ubuntu-latest, prod]\n2. 確保生產環境任務只由 gitea_work 專用機器執行 2026-05-15 15:53:32 +08:00
sky121113
8262637454 Merge branch 'dev' into demo 2026-05-15 15:52:14 +08:00
sky121113
928a1a8370 [STYLE] 調整 Demo 部署標籤為 ubuntu-latest 搭配 demo\n\n1. 將 runs-on 修改為 [ubuntu-latest, demo]\n2. 此舉能確保使用 Runner 預設的 ubuntu 環境標籤並精確鎖定至 demo 專用機 2026-05-15 15:52:06 +08:00
sky121113
8998de9179 Merge branch 'dev' into demo 2026-05-15 15:50:12 +08:00
sky121113
7662b20598 [FIX] 修正 Runner 標籤配置以支援 Docker 環境執行\n\n1. 將 self-hosted 標籤從 host 模式改為 docker 模式\n2. 確保 Actions 可以在容器內找到 node 執行環境 2026-05-15 15:50:12 +08:00
sky121113
cec0ed6394 Merge branch 'dev' into demo 2026-05-15 15:44:36 +08:00
sky121113
46dbeb59fb [CI] Update demo runner label to [self-hosted, demo] 2026-05-15 15:44:36 +08:00
sky121113
c15f28eaa7 [RELEASE] Promote demo to main (2026-05-15) - UI Cleanup and Admin Fix 2026-05-15 15:32:31 +08:00
sky121113
b40257f676 [SYNC] Update local main from remote 2026-05-15 15:32:31 +08:00
sky121113
c705e01f4f [SYNC] Merge main back to demo to sync CI fixes 2026-05-15 15:28:40 +08:00
sky121113
0e2f2d8ed4 [FEAT] 隱藏機台列表的刷卡機/掃碼機顯示與日誌中的設備狀態紀錄分頁 2026-05-15 15:15:31 +08:00
sky121113
239ab7fafd [FIX] 修正新增客戶時初始帳號未標記為管理員的問題
1. 在 CompanyController 新增客戶時,明確為初始管理員帳號加上 is_admin = true,確保其在各權限管理介面能正確顯示。
2026-05-15 15:05:43 +08:00
sky121113
320af5c0c6 refactor(ci): use native ssh instead of appleboy/ssh-action to avoid download issues 2026-05-15 14:09:21 +08:00
sky121113
ff6cb13308 fix(ci): add apt lock waiting and check for rsync/ssh in deploy-prod 2026-05-15 14:06:12 +08:00
sky121113
2290622765 [RELEASE] demo to main - 2026-05-15
1. Consolidate changes from demo branch including advertisement notification fixes and accumulated features.
2026-05-15 14:04:36 +08:00
sky121113
871e7b0b8b [PROMOTE] dev to demo - 2026-05-15
1. fix: improve advertisement notification feedback and error handling
2026-05-15 14:02:51 +08:00
sky121113
143050a5c6 fix: improve advertisement notification feedback and error handling 2026-05-15 14:01:50 +08:00
sky121113
313ab72056 [FIX] 補回上傳成功後的 session flash 訊息 2026-05-15 13:53:24 +08:00
sky121113
1d07a90e51 [PROMOTE] 晉升 dev 到 demo (包含廣告上傳修復) 2026-05-15 13:52:08 +08:00
sky121113
0685c71141 [FIX] 修復廣告圖片上傳失敗與優化錯誤提示
1. 修正正式環境 Nginx client_max_body_size 限制導致的上傳失敗問題(已於 Host 端同步修正)。
2. 在 AdvertisementController@store 加入 try-catch 區塊與 Log 紀錄,確保圖片處理異常時回傳 JSON 錯誤。
3. 優化前端上傳 AJAX 邏輯,當發生 413 (檔案太大) 或 500 錯誤時提供明確的提示訊息。
2026-05-15 13:50:33 +08:00
sky121113
12226dd7d0 [STYLE] 微調部署腳本格式
1. 刪除 deploy-prod.yaml 第 35 行的多餘空行。
2026-05-15 12:03:32 +08:00
sky121113
cb9b8b072d [FIX] 修正正式環境 CI/CD 卡在 apt-get 互動提示的問題
1. 加入 DEBIAN_FRONTEND=noninteractive 環境變數。
2. 增加 Dpkg::Options 強制自動處理設定檔衝突,避免部署掛起。
2026-05-15 11:57:51 +08:00
sky121113
c92ced4150 [FIX] 觸發正式環境 CI/CD 重新部署
1. 在 deploy-prod.yaml 新增註解以產生異動,藉此重新觸發 CI/CD 以驗證 SSH 金鑰修復後的連線狀況。
2026-05-15 11:57:18 +08:00
sky121113
e96e9dcb93 [STYLE] 調整正式站部署腳本格式
1. 微調 .gitea/workflows/deploy-prod.yaml 檔案內的空行排版。
2026-05-15 11:54:06 +08:00
sky121113
0a1109ec34 [REFACTOR] 移除舊有部署腳本範本
1. 刪除 .gitea/workflows/deploy-remote-template.yaml,因其相關邏輯已完整整合至 deploy-prod.yaml 與 deploy-demo.yaml 腳本中。
2026-05-15 11:41:16 +08:00
sky121113
24d7602f5a [FIX] 以 appleboy/ssh-action 重構正式環境部署腳本
1. 改用 appleboy/ssh-action 取代 ssh 字串指令,對齊 deploy-remote-template 的架構。
2. Step 1 改回用 rsync 同步程式碼,SSH 金鑰由 runner 自行注入,移除 shimataro/ssh-key-action 依賴。
3. 加入智慧重建判斷(Step 2):僅在 Dockerfile/compose.yaml 有變更時才執行 --build,加速一般部署。
4. 完整對齊 Step 3 的 chown / composer / npm / artisan 初始化邏輯,確保與 demo 部署行為一致。
2026-05-15 11:14:49 +08:00
sky121113
1401912059 [FIX] 重構正式環境部署腳本,改用 SSH+tar 方式同步程式碼
1. 棄用 rsync 直接同步方案,改用 tar 打包傳送再遠端解壓,與 demo 部署邏輯一致。
2. 將 docker compose 換成 docker-compose,修正 GCP 主機僅有舊版 v1 CLI 的相容問題。
3. 拆分 Step 步驟結構,對齊 demo workflow 的 Step 0~5 風格。
4. docker exec 加入 -u 1001:1001 確保產出檔案不會被 root 佔用。
2026-05-15 11:12:05 +08:00
sky121113
9031f5fd4e [FIX] 修復正式環境 CI/CD rsync 權限與 sudo 問題
1. 將 twsystem1003 加入 docker 群組,移除腳本中的 sudo 需求。
2. 在 docker exec 指令中加入 -u 1001:1001,避免產生 root 權限的編譯檔案導致 rsync 同步失敗。
2026-05-15 11:07:39 +08:00
sky121113
66bb6006e6 Merge main into dev, keeping dev's newer workflow files 2026-05-15 11:04:44 +08:00
sky121113
0278438241 [FEAT] 新增正式環境 CI/CD 自動化部署流程
1. 實作 Gitea Actions 透過 SSH 與 rsync 同步程式碼至正式環境主機 (34.84.169.132)。
2. 整合遠端 Docker 容器重啟、Composer 與 NPM 依賴安裝、前端編譯及資料庫遷移等自動化指令。
3. 加入 RoleSeeder 與 AdminUserSeeder 預設權限與帳號資料填充。
2026-05-15 11:03:04 +08:00
sky121113
3db353a9f0 [FEAT] 開啟 EMQX 8883 (MQTTS) 埠位
1. 在 compose.yaml 中新增 8883 埠位映射,支援加密的 MQTT 通訊 (MQTTS)。
2. 同步更新 .env.example 新增 MQTT_SSL_PORT 變數。
3. 此變更將觸發 Demo 環境重啟 EMQX 容器以套用新埠位。
2026-05-15 09:26:40 +08:00
sky121113
e004ecbc43 [FEAT] 更新英文語系檔
1. 在 lang/en.json 中新增 「System restarting」 的翻譯對應,完善重啟狀態的各語系支援。
2026-05-14 18:08:28 +08:00
sky121113
752d149940 [FEAT] 支援機台計畫性重啟狀態與語系更新
1. 修改 ProcessStatus Job,支援處理 'restarting' 狀態,並將其記錄為 INFO 層級而非警告。
2. 更新繁體中文與日文語系檔,新增「重啟中 / 再起動中」的翻譯。
3. 確保計畫性重啟不會觸發異常離線警報。
2026-05-14 18:07:53 +08:00
sky121113
dd641a2fc1 [STYLE] 優化機台日誌溫度圖表顯示
1. 修改機台溫度數據 API,改為傳送毫秒級時間戳記,以支援 ApexCharts 時間軸。
2. 將圖表橫軸類型改為 datetime,解決時間標籤重疊擁擠的問題。
3. 隱藏 ApexCharts 預設的無障礙輔助文字。
4. 優化提示框 (Tooltip) 顯示格式,改為顯示完整年月日時分秒,並自定義標題。
5. 關閉圖表動畫以提升側邊欄載入效能。
2026-05-14 17:56:42 +08:00
sky121113
2f455cbd45 [FIX] 修復機台日誌面板 ApexCharts 未定義錯誤
1. 修改 resources/views/admin/machines/index.blade.php,將 @push('scripts') 更改為 @section('scripts')。
2. 確保 ApexCharts 函式庫可以正確被 layouts/admin.blade.php 的 @yield('scripts') 載入,解決 Uncaught ReferenceError 問題。
2026-05-14 17:28:57 +08:00
sky121113
a16b0363a8 [FEAT] 新增機台日誌溫度趨勢圖與同步指令日誌補全
1. 修改 ProcessCommandAck.php,將「同步商品」與「同步廣告」指令加入日誌紀錄白名單,確保同步結果正確寫入機台日誌。
2. 在 MachineController 新增 temperatureAjax API,從機台日誌中提取歷史溫度數據供圖表使用。
3. 在 web.php 註冊溫度數據 AJAX 查詢路由。
4. 在機台管理頁面 (index.blade.php) 的日誌面板中整合 ApexCharts,並於「機台狀態」分頁內嵌入溫度趨勢曲線圖。
5. 更新 Alpine.js 邏輯,實現圖表與日期篩選的同步連動,並優化面板展開時的圖表渲染。
6. 新增繁體中文、英文、日文的「溫度趨勢」翻譯標籤,並修復語言檔中的重複 key 與翻譯內容。
2026-05-14 17:17:55 +08:00
sky121113
db2fd734ee [FIX] 修復機台列表日誌面板中 Alpine.js 變數未定義錯誤
1. 在 machineApp 初始化時新增 totalLogs 變數。
2. 在 fetchLogs 成功取得資料後同步更新 totalLogs。
2026-05-14 16:56:26 +08:00
sky121113
a684628dad [STYLE] 優化儀表板機台列表互動體驗與庫存導航邏輯
1. 移除儀表板機台狀態表格中不直觀的「操作」(眼睛圖標) 欄位,簡化介面。
2. 將「運行狀態」與「下位機狀態」文字區域改為可點擊連結,導向至機台列表並自動帶入序號篩選。
3. 將「目前庫存」進度條改為可點擊連結,並優化跳轉邏輯改用 machine_id 參數。
4. 優化行動版介面,移除冗餘的「Inventory Overview」按鈕,改為直接點擊庫存進度條即可跳轉。
2026-05-14 16:51:21 +08:00
sky121113
3d1ddd5b91 [FEAT] 實作機台智慧連線監控與儀表板告警同步優化
1. [Machine Model] 實作智慧抖動偵測邏輯 (Jitter Detection),判定 1 小時內斷線 3 次以上為不穩定機台。
2. [Machine Model] 優化 calculated_status,機台在線時自動忽略歷史斷線警告,並修正 scopeHasAnyAlert 排除連線類告警。
3. [ProcessStatus Job] 實作自動消警機制 (Auto-resolve),機台恢復連線時自動處理舊的斷線告警。
4. [Dashboard] 同步儀表板「待處理告警」統計,確保與機台列表狀態一致。
5. [UI] 於機台列表與儀表板同步新增琥珀色閃電圖示 () 與不穩定提示 Tooltip。
6. [i18n] 新增連線不穩定相關之繁體中文翻譯詞條。
2026-05-14 16:20:59 +08:00
sky121113
61b5e422ee [STYLE] 優化機台列表頁面 UI 佈局與組件格式
1. 調整機台列表 (admin/machines/index) 的 Offcanvas 佈局與邊距設定。
2. 統一 x-status-badge 與 x-confirm-modal 組件的撰寫格式,提升程式碼可讀性。
3. 優化深色模式下的邊框與陰影視覺效果。
2026-05-14 15:02:47 +08:00
sky121113
11671f3a2b [REFACTOR] 修改員工識別卡匯入邏輯:改以員工編號作為比對基準
1. 修改 StaffCardImportService,將 updateOrCreate 的比對欄位從 card_uid 改為 employee_id。
2. 這樣當員工補發新卡時,透過 Excel 匯入相同的員工編號即可直接更新其卡號。
2026-05-14 14:33:07 +08:00
sky121113
cc03572dd9 [FEAT] 實作員工識別卡 Excel 批量匯入功能與範例下載
1. 新增 StaffCardImportService,整合 OpenSpout 5.x 處理解析與批量更新邏輯。
2. 在 StaffCardController 加入匯入與範例下載 API 端點。
3. 更新員工識別卡 index 視圖,新增「匯入 Excel」按鈕與 Alpine.js 異步匯入彈窗。
4. 註冊相關 Web 路由。
5. 補全繁中、日文、英文多語系翻譯,並修復重複鍵值。
6. 修復 OpenSpout 5.x 相容性問題 (Row::getCells -> Row::toArray)。
2026-05-14 14:26:35 +08:00
sky121113
87c4c281a1 [FEAT] 新增廣告狀態切換功能與優化商品操作紀錄介面
1. 新增廣告管理狀態切換 (Toggle Status) 功能,包含後端 API、路由與前端 UI 實作。
2. 優化商品操作紀錄 (Audit Logs) 的過濾介面,統一使用 x-searchable-select 並修正搜尋圖示樣式。
3. 擴充 SystemOperationLog 模型,支援自動解析操作目標名稱 (含 code 欄位備援)。
4. 優化商品操作紀錄查詢效能,預載入 (Eager Load) target 關聯資料。
5. 更新多語系檔案 (zh_TW, en, ja),新增廣告狀態切換相關文字。
2026-05-14 13:02:08 +08:00
sky121113
aedc0e3921 [FIX] 修復遠端指令中心 Modal 排版異常
1. 補回 Confirm Modal 內「操作備註」遺失的 HTML 標籤(<label> 與 <div>),解決 UI 渲染破損的問題。
2. 補回「選擇目標貨道」遺失的 <label> 標籤,確保排版樣式正確。
2026-05-14 12:07:40 +08:00
sky121113
b87fae8eb1 [FEAT] 優化商品操作紀錄清單與多語系支援,並修復全域快取錯誤
1. 修復 ProductCatalogService 在處理全域商品(company_id 為 null)時引發的型別錯誤
2. 在操作紀錄清單新增「操作人員」欄位,並優化手機版顯示
3. 將「MODULE / 目標」欄位更名為更直覺的「操作目標」
4. 於 status-badge 元件加入操作紀錄(CREATE, UPDATE, DELETE)的專屬顏色與多語系對應
5. 補齊 zh_TW.json 缺漏的機台權限、操作動作與商品相關欄位多語系翻譯
2026-05-14 11:52:01 +08:00
sky121113
256d996918 [STYLE] 商品管理介面多語系標準化與側邊欄 UI 優化
1. 完成商品詳細資訊側邊欄 (Slide-over) 的全面多語系化,修復部分標題顯示英文的問題。
2. 重構 Alpine.js 的 getCategoryName 函式,優先顯示 localized_name。
3. 更新 zh_TW.json 補齊缺失的翻譯鍵值 (包含同步指令提示、側邊欄區塊標題等)。
4. 優化分頁與分頁大小切換邏輯,支援新增的日誌 (Logs) 分頁。
5. 調整同步指令錯誤訊息為通用語氣,提升不同角色下的使用者體驗。
2026-05-14 11:25:43 +08:00
sky121113
87d3369792 [STYLE] 調整機台管理列表順序與優化商品分類顯示
1. 調整「基本設定 > 機台設定」清單的欄位順序,將「持有者(公司名稱)」移動到「狀態」之前。
2. 優化機台手機版卡片佈局,將公司名稱移至標題區域並與狀態標籤並列顯示。
3. 優化商品管理介面的分類名稱顯示邏輯,優先使用本地化名稱 (localized_name)。
2026-05-14 10:56:19 +08:00
sky121113
a9d44de98b [STYLE] 調整商品管理與遠端控制頁面之 UI 佈局與格式
1. 修正商品管理頁面 (Products Index) 中多處語系標籤的換行問題。
2. 調整遠端控制頁面 (Remote Index) 中 Slot No 標籤的顯示格式。
3. 優化 UI 組件的 HTML 縮排與可讀性。
4. 補齊同步商品機制相關之繁體中文翻譯 (Connection error, Failed to send command)。
2026-05-14 10:32:07 +08:00
sky121113
9ff9abef89 [FEAT] 實作「同步到所有機台」功能與商品資料同步機制
1. 在商品管理介面新增「同步到所有機台」按鈕與 Alpine.js 確認彈窗。
2. 實作後端 syncToAllMachines 控制器邏輯,並加入同一公司 1 分鐘內禁止重複發送的限制。
3. 建立 SyncProductsToMachinesJob 與 SendProductSyncCommandJob 非同步隊列任務,支援舊指令覆蓋機制。
4. 補全繁體中文、英文與日文的多語系翻譯字串。
5. 更新遠端指令日誌視圖,支援展示商品同步指令及其備註。
6. 更新 MQTT 通訊規範與 API 技術規格文檔,納入 update_products 指令。
7. 整合 update_ads 與 update_products 指令至 config/api-docs.php 的系統指令規格中。
2026-05-14 09:33:43 +08:00
sky121113
2c8a2de81c [FEAT] 實作商品主檔 B012 Redis 快取機制 (Phase 1)
1. 新增 ProductCatalogService 負責商品清單轉換與 Redis 快取邏輯。
2. 新增 WarmProductsCacheCommand (artisan products:warm-cache) 用於預熱快取。
3. 修改 ProductController 在商品異動時自動重建快取。
4. 修改 MachineController@getProducts (B012) 改為 Cache-First 讀取。
5. 在 Company 模型補上 products 關聯以支援暖機指令。
2026-05-13 18:51:35 +08:00
sky121113
f9d3217099 [FEAT] 優化廣告素材上傳體驗與進度顯示
1. 在廣告建立與編輯彈窗中實作基於 XMLHttpRequest 的 AJAX 上傳機制。
2. 新增即時上傳進度條顯示,並整合全域頂部載入條。
3. 實作上傳期間禁用提交按鈕與顯示「上傳中...」狀態,提升使用者反饋感。
4. 更新 AdvertisementController 確保 JSON 回應後能正確快閃成功訊息至 Session。
2026-05-13 17:37:18 +08:00
sky121113
c482fb3c79 [FEAT] 在商品清單中顯示商品 ID
1. 修改 resources/views/admin/products/partials/tab-products.blade.php,在桌面版表格與行動版卡片中新增 ID 顯示。
2. 更新語系檔案 (zh_TW, en, ja),新增 ID 欄位的翻譯對應。
2026-05-13 17:22:24 +08:00
sky121113
69e825aeb3 [STYLE] 調整 IoT API 回應格式與程式碼風格
1. 移除 B005, B012, B014, B017 回應中的 message 欄位,以相容機台端的解析邏輯。
2. 統一 MachineController 內的型別轉換格式與程式碼縮排。
3. 同步更新 api-docs.php 文件範例,移除多餘的 message 欄位。
2026-05-13 14:17:24 +08:00
sky121113
0e6f5b703c [FIX] 標準化 IoT API 回應格式 (B005, B012, B014, B017)
1. 修正 B005 (廣告同步) API,將數值欄位轉為整數並補齊 message 欄位,以符合 Java APP 預期格式。
2. 標準化 B012, B014, B017 等 IoT 端點回應結構,統一加入 "message": "OK" 欄位。
2026-05-13 14:11:26 +08:00
sky121113
ce87f453a2 [FEAT] 優化廣告同步與指令中心 UI
1. 廣告管理:將原生 confirm 替換為極簡奢華風 Alpine.js 彈窗,並新增「操作備註」欄位與「目標機台」顯示。
2. 指令中心:在指令確認彈窗中新增「目標機台」顯示,並優化 UI 佈局。
3. 多語系支援:新增 Sync Ads (同步廣告) 至 zh_TW, en, ja 語系檔,並應用於指令中心與篩選列。
4. 後端邏輯:更新 AdvertisementController,將操作備註正確存入 RemoteCommand 的 remark 欄位。
5. 指令規範:同步更新 MQTT 通訊規範 (SKILL.md) 與 API 文件 (api-docs.php),加入 update_ads 指令說明。
2026-05-13 13:51:24 +08:00
sky121113
7d3e25d090 [FIX] 優化庫存輸入邏輯與數值處理
1. 在 MachineController 中加入庫存數值強制轉型邏輯,確保後端驗證前已處理字串數值(如 "01" 轉為 1)。
2. 在遠端庫存調整介面中,為 Alpine.js 的 x-model 加入 .number 修飾符,強化前端資料類型一致性。
2026-05-13 10:25:21 +08:00
sky121113
2fd6419129 [FEAT] 商品圖片尺寸標準化與自動裁切功能實作
1. 增強 ImageHandler trait,支援居中裁切(Center Crop)與指定尺寸縮放功能。
2. 更新 ProductController 的 store 與 update 方法,強制將商品圖片處理為 320x320 像素之 WebP 格式。
3. 優化 create 與 edit 商品頁面,新增 320x320 建議尺寸與自動裁切之提示文字。
4. 新增繁體中文、英文與日文的多語系翻譯對應。
2026-05-13 09:46:24 +08:00
sky121113
426253b1c3 [FEAT] 強化遠端指令中心的操作備註功能
1. 新增 remote_commands 資料表的 remark 欄位以儲存操作備註。
2. 更新 RemoteCommand 模型,將 note 與 remark 加入可批量賦值名單。
3. 更新 MachineService 的 dispatchDispense 函式,支援接收與記錄操作備註。
4. 更新 RemoteController 的 storeCommand 邏輯,將前端輸入的備註存入 remark 並支援遠端出貨指令。
5. 優化操作紀錄搜尋邏輯,支援搜尋備註欄位。
6. 重構指令中心操作紀錄介面,桌面版新增「操作備註」欄位,並優化手機版卡片顯示。
7. 調整表格欄位寬度與標頭樣式,解決「指令類型」寬度過大的問題。
2026-05-12 17:15:53 +08:00
sky121113
ad921011f0 [STYLE] 優化機台日誌顯示與多語系支援
1. 將員工卡與取貨碼驗證失敗的日誌層級由 WARNING 調降為 INFO。
2. 將日誌訊息字串改為支援多語系翻譯的佔位符格式。
3. 新增員工卡與取貨碼驗證失敗的繁中、英文、日文翻譯。
2026-05-12 16:00:40 +08:00
sky121113
029cdb1d26 [STYLE] 更新側邊欄選單與多語系翻譯
1. 更新銷售管理選單語系:新增購買單、通行碼、取貨碼,並重命名促銷時段與來店禮。
2. 更新庫存管理選單語系:調撥單更名為調撥單管理。
3. 調整側邊欄選單佈局,調整特殊權限選單顯示邏輯。
2026-05-12 15:30:11 +08:00
sky121113
a290d5b306 [FIX] 優化 MQTT 錯誤代碼翻譯與交易核銷邏輯
1. 更新 MQTT 錯誤代碼 0205 的標籤為 Slot sensor blocked,並將繁體中文翻譯修正為「貨道口有物」,同步更新多語系檔 (zh_TW, en, ja)。
2. 修改 TransactionService,移除遠端出貨 (payment_type: 100) 在交易結案時的 code_id 自動核銷邏輯。
3. 更新 SKILL.md 與 api-docs.php 規格文件,將「通行碼 (5)」納入 code_id 核銷範圍,並移除遠端出貨的說明。
2026-05-12 14:27:00 +08:00
sky121113
2cf33cfbb4 [FIX] 優化遠端指令鎖定機制與補全 API 文件
1. 實作庫存更新 (reload_stock) 的「直接覆蓋 (Supersede)」模式,允許管理員即時覆寫待執行指令,提升操作流暢度。
2. 為遠端出貨 (dispense) 增加 1 分鐘自動逾時機制,解決因未收到 ACK 導致貨道永久鎖定的問題。
3. 拆分庫存更新與遠端出貨的併行檢查邏輯,確保兩者互不干擾。
4. 更新 API 技術文件,補全遠端出貨 (payment_type: 100) 與閉環勾稽參數 (code_id) 的規格說明。
2026-05-12 10:17:51 +08:00
sky121113
83f4178790 [FIX] 補全遠端指令中心歷史紀錄頁面的逾時 (Timeout) 樣式與多語系
1. 更新 index.blade.php 的 Alpine.js translations 物件,新增 'Timeout' 與詳細備註的翻譯映射。
2. 修正 index.blade.php 的 getCommandBadgeClass 與 getCommandStatus,補全 timeout 狀態的樣式 (rose) 與顯示文字。
3. 於 tab-history-index.blade.php 補全逾時狀態的小圓點視覺引導 (dot logic)。
4. 在歷史紀錄篩選下拉選單中加入「逾時 (Timeout)」選項。
2026-05-12 09:03:32 +08:00
sky121113
3bb5d8cfcc [FIX] 修正遠端出貨指令逾時處理機制與補全多語系支援
1. 建立資料庫遷移,於 remote_commands 的 status ENUM 中新增 timeout 狀態,修復 Data truncated 錯誤。
2. 更新 MachineService::dispatchDispense 邏輯,當指令超過 1 分鐘未回報時自動標記為 timeout 並記錄原因。
3. 於 zh_TW.json 與 en.json 補全 timeout、superseded 狀態及相關備註的翻譯。
4. 更新 status-badge 元件預設值,支援遠端指令相關狀態(sent, success, failed, timeout, superseded)並套用視覺樣式。
2026-05-12 08:57:27 +08:00
sky121113
53ced5ca5b [FEAT] 遠端出貨指令支援 1 分鐘逾時覆蓋機制
1. 修改 MachineService::dispatchDispense:若同貨道有 pending 指令,超過 1 分鐘未回報則視為逾時,自動將舊指令標記為 timeout 並允許新指令覆蓋下發。
2. 1 分鐘內仍有 pending 指令則維持拒絕,防止短時間重複出貨。
2026-05-11 18:04:42 +08:00
sky121113
34f1e823f0 [FIX] 修正庫存異動類型缺失與統一交易扣庫邏輯
1. 補全 machine_stock_movements 資料表 type 欄位缺失的 sale 與 decommission 列舉值,修復 SQL 寫入失敗問題。
2. 重構 TransactionService:移除過時的 remote_dispense 判斷,將所有經由交易流程(含支付代碼 100)的出貨統一記錄為 sale 異動。
3. 更新 MachineService:將 B009 貨道同步時的移除動作類型由 adjustment 改為正確的 decommission。
4. 語系更新:於 zh_TW.json 與 en.json 補全硬體狀態碼 0205 (貨道有物) 的翻譯。
2026-05-11 17:13:41 +08:00
sky121113
ee47f65523 [FEAT] 優化機台重啟狀態處理邏輯
1. 在 ProcessStatus Job 中新增對 restarting 狀態的判斷。
2. 若機台處於計畫性重啟狀態,則不寫入狀態變更日誌,避免後台誤報。
2026-05-11 14:15:26 +08:00
sky121113
ce09120483 [STYLE] 調整遠端指令中心按鈕順序
1. 將「機台 APP 鎖定」與「機台 APP 解鎖」按鈕位置互換以符合操作直覺。
2026-05-11 13:28:03 +08:00
sky121113
350cc46824 [STYLE] 調整產品管理頁面佈局與同步最新開發進度
1. [STYLE] 產品管理頁面代碼格式微調。
2. [REFACTOR] 同步最新的支付類型多語系翻譯與 UI 響應式優化。
3. [DELETE] 確認移除 B710 冗餘代碼。
2026-05-11 11:59:12 +08:00
sky121113
0d8e81fb80 [STYLE] 調整銷售紀錄分頁顯示樣式
1. [STYLE] tab-orders.blade.php:調整行動裝置網格配置。
2. [STYLE] tab-orders.blade.php:調整日期時間顯示格式。
2026-05-11 11:39:59 +08:00
sky121113
284d56a544 [REFACTOR] 支付方式多語系化與 UI 響應式優化
1. [REFACTOR] Order 模型:將支付類型標籤改為多語系轉譯,支援繁中、英、日。
2. [FEAT] 補全 lang/*.json 中的支付類型翻譯。
3. [STYLE] layouts/admin.blade.php:優化電腦版與手機版邊距佈局。
4. [STYLE] 銷售紀錄分頁:優化手機版 info grid 響應式顯示。
5. [FIX] 銷售紀錄:恢復手機版完整日期時間格式。
6. [DELETE] 移除 B710 (Timer Sync) 冗餘代碼與路由。
7. [DOCS] 更新 API 文件配置。
2026-05-11 11:01:48 +08:00
sky121113
c774d46243 [FEAT] 新增 Header 全域自動更新功能並優化行動端 UI
1. 在管理後台 Header 新增自動更新組件,支援 10s, 30s, 60s 及手動刷新。
2. 實作 SVG 環形進度條動畫與倒數計時邏輯。
3. 整合 localStorage 持久化儲存使用者更新頻率偏好。
4. 新增 zh_TW, en, ja 三種語系的對應字串。
5. 優化行動端顯示:在手機版隱藏秒數標籤以節省空間,並調整 Header 元素間距與圖示排列。
6. 電腦版 Header 移除左側 Padding,使自動更新組件完美貼齊側邊欄邊界。
2026-05-11 09:13:42 +08:00
sky121113
41b31ffb77 fix(i18n): sync Remote Reboot fix for EN and JA 2026-05-07 17:41:45 +08:00
sky121113
82d5350153 fix(i18n): fix Remote Reboot inconsistency in zh_TW.json 2026-05-07 17:40:12 +08:00
sky121113
9648e7741f fix(i18n): update Remote Settlement to Card Reader Settlement 2026-05-07 17:37:11 +08:00
sky121113
9c94552268 fix(i18n): exhaustive fix for remote command labels and translations 2026-05-07 17:25:30 +08:00
sky121113
2e181886aa fix(i18n): correct remote command labels and sync translations 2026-05-07 17:18:14 +08:00
sky121113
e1cac003f0 [FIX] 更新機台操作術語、本地化指令確認視窗與修正錯字
1. 將「機台重啟」更新為「機台 APP 重啟」,準確反映操作本質(繁、英、日)。
2. 將「鎖定頁鎖定」更新為「機台 APP 鎖定」(繁、英、日)。
3. 本地化遠端指令確認視窗中的「Please confirm the details below」文字(繁、日)。
4. 修正 B009 同步流水帳備註中的錯字(廢道 -> 貨道)。
2026-05-07 17:05:49 +08:00
sky121113
6070cdd7b8 [FIX] 修正庫存流水帳篩選選單與 B009 錯字
1. 解決庫存流水帳貨道下拉選單中「所有貨道」選項被組件隱藏的問題。
2. 修正 zh_TW.json 中 B009 同步備註的錯字(廢道 -> 貨道)。
2026-05-07 15:19:48 +08:00
sky121113
6decba6cb6 [FIX] 優化 B009 同步機制、庫存排序與修正管理後台 UI 遮擋問題
1. 實作 B009 貨道全量同步與拆除 (decommission) 紀錄機制。
2. 優化機台庫存概覽排序邏輯,優先顯示低填充率 (需補貨) 之機台。
3. 修正新增機台 Modal 的 z-index 層級與 overflow 限制,解決下拉選單被蓋住的問題。
4. 修復機台庫存分頁中的 JS 語法錯誤並新增貨道拆除狀態標籤。
2026-05-07 15:08:01 +08:00
sky121113
1739dc6625 fix(ui): resolve overflow in machine creation modal and optimize inventory sorting/filters 2026-05-07 14:46:34 +08:00
sky121113
f06353d542 feat: change default machine inventory sorting to stock fill rate (ascending) 2026-05-07 14:40:48 +08:00
sky121113
053c41d642 feat: optimize B009 stock sync with full-sync logic and historical slot filtering 2026-05-07 14:38:19 +08:00
sky121113
eb55a25ca8 [FEAT] B009 改為全量同步模式,硬刪除不在清單的貨道
1. 修改 MachineService@syncSlots 實作 B009 全量同步邏輯:同步完成後,找出資料庫中不在 B009 回報清單的舊貨道,先記流水帳再硬刪除,確保後台狀態與機台實體完全一致。
2. 新增 MachineStockMovement::TYPE_DECOMMISSION 常數,用於標識因 B009 全量同步而移除的貨道異動紀錄。
3. 新增多語系翻譯 key(zh_TW / en / ja):movement.note.slot_decommissioned_by_b009、movement.note.initial_sync_report、movement.note.product_changed_and_adjusted 與 movement.type.decommission。
2026-05-07 14:28:07 +08:00
sky121113
e735e5a8c5 [REFACTOR] 優化 B009 庫存同步比對邏輯
1. 優化 MachineService@syncSlots,加入庫存與商品 ID 的實質變動檢查。
2. 解決在庫存與商品皆無變動時,仍會執行資料庫更新並產生冗餘流水帳的問題。
3. 強化商品變更判斷,確保更換商品時能正確記錄流水帳與對應備註。
2026-05-07 14:12:42 +08:00
sky121113
9c6f5923f3 [FIX] 規格對齊與 B009 庫存同步邏輯優化
1. 更新 B009 (貨道庫存回報) API 規格文件與 config/api-docs.php,對齊參數型別 (tid: String, num: Integer) 並移除 legacy status 欄位。
2. 修正 MachineController 接收參數時的強製型別轉型,防止因 APP 傳入字串格式導致的庫存異動判斷錯誤。
3. 優化 MachineService@syncSlots 邏輯,確保庫存變動比對準確,並補齊新建立貨道時的初始庫存流水帳紀錄。
4. 移除 B009 回應中的 status 欄位,統一使用 success 進行成功判定。
2026-05-07 14:07:26 +08:00
sky121113
bb955fa3de [STYLE] 現代化機台稼動率儀表板與語系優化
1. 更新機台稼動率頁面日期選擇器為專案標準 Luxury UI,並整合 Flatpickr 新增「今日」與「清除」按鈕。
2. 修正稼動率頁面中因字串換行與多餘空格導致的語系翻譯失效問題。
3. 更新稼動率副標題,移除過時的 OEE 描述並修正為「全機台即時狀態與營收監控」。
4. 同步更新 zh_TW, en, ja 語系 JSON 檔以包含最新的介面文字翻譯。
5. 重構 MachineService.php 移除冗餘的計算邏輯,提升稼動率數據查詢效能。
6. 更新 API 技術規格文件以反映最新的通訊協議規範。
2026-05-07 13:34:22 +08:00
sky121113
c52cf2900f [FIX] 統一搜尋欄位命名、更新語系檔與 IoT 規範文件
1. 修改 ProductController.php,將搜尋參數統一改為 search 以匹配前端表單欄位。
2. 更新 lang/zh_TW.json 語系檔,修正部分翻譯詞彙 (如 Inactive, Standby, Confirm Status Change)。
3. 更新 .agents/skills/mqtt-communication-specs/SKILL.md 規範文件。
4. 包含 Machine Model 與 MachineService 的優化變更。
2026-05-07 11:54:47 +08:00
sky121113
577bf136de [REFACTOR] 遷移商品狀態切換邏輯至全頁重新整理模式
1. 重構 index.blade.php 中的 productManager Alpine 元件,將狀態切換由 AJAX 非同步更新改為原生表單提交。
2. 移除 index.blade.php 中不再使用的 deleteItem 函式與重複定義的 confirmDelete 函式。
3. 更新 tab-products.blade.php 的按鈕行為:商品「啟用」改為點擊後直接提交表單刷新;商品「停用」維持確認彈窗,但確認後執行全頁重新整理。
4. 同步更新桌機版表格與手機版卡片視圖中的按鈕邏輯。
2026-05-07 11:52:08 +08:00
sky121113
4bf53db3be [FIX] 優化機台連線規範與上線日誌邏輯
1. 更新 SKILL.md 與 Java 範例,將 MQTT Client ID 格式由隨機後綴改為固定格式 SC_{serial_no},以確保單機連線唯一性。
2. 同步更新 config/api-docs.php,在 API 文件中加入 MQTT 連線參數規範說明。
3. 修正 ProcessHeartbeat.php 邏輯,當機台狀態從 offline 轉為 online 時,自動寫入「Machine is online」日誌,完善連線紀錄回溯。
2026-05-07 10:16:29 +08:00
sky121113
09bbd61c89 [FIX] 導正 MQTT LWT 遺囑訊息架構與文件規範
1. 修改 SKILL.md 規範,將 LWT (遺囑訊息) Topic 從 heartbeat 改為 status,並移除過時的  訂閱說明。
2. 更新 SKILL.md 內 Android Java 範例,同步改用 status Topic。
3. 移除 SKILL.md 中關於 Client ID 解析邏輯的舊文件說明。
4. 重構 ProcessStatus.php,移除過時的 SC_ 前綴與隨機碼解析邏輯,直接使用傳入的純淨機台編號。
5. 更新 ProcessHeartbeat.php 註解,反映連線狀態改由 LWT 機制與 status Topic 處理。
2026-05-07 09:22:09 +08:00
sky121113
938aabc6c1 [FIX] 實作儀表板真實營收統計與多語系修正
1. 修復儀表板營收統計,將模擬數據替換為從 Order 模型撈取的真實資料。
2. 修正 DashboardController 中的支付狀態篩選邏輯,從字串 'paid' 改為正確的 tinyInteger 狀態碼 1。
3. 在 Order 模型中新增 PAYMENT_STATUS_SUCCESS 與 PAYMENT_STATUS_FAILED 常數。
4. 新增「本月累計營收」的多語系翻譯(中、英、日)。
5. 優化儀表板前端介面,包含動態顯示昨日增長趨勢百分比與顏色標籤。
6. 修正儀表板機台列表中的「今日銷售額」統計邏輯。
2026-05-06 17:55:18 +08:00
sky121113
86449ea36a [FIX] 修復取貨碼新增表單的 Alpine.js 錯誤
1. 為新增取貨碼表單補上 x-ref="createForm",解決無法提交表單的 ReferenceError。
2. 修正貨道加載器的 isLoadingTable 未定義錯誤,改用正確的 loadingSlots 狀態。
2026-05-06 16:36:15 +08:00
sky121113
fa6406b9ad [FEAT] 優化取貨碼管理介面與功能
1. 新增取貨碼使用次數上限功能(限制 1-20 次)。
2. 優化取貨碼管理介面,採用極簡奢華風計數器並調整寬度與間距。
3. 整合 Flatpickr 24小時制日期時間選擇器,並實作模式切換時的時間自動帶入邏輯。
4. 取貨碼清單新增「剩餘使用次數 / 總次數」顯示。
5. 完善相關中文化翻譯(如「小時」、「自定義日期」等)。
6. 新增資料庫欄位 migration 並更新相關模型與 Service 邏輯。
7. 更新 API 技術規格文檔以符合最新取貨碼邏輯。
2026-05-06 16:33:15 +08:00
sky121113
d740fc5ab8 [FEAT] 實作機台物理刪除功能與資料庫結構優化
1. 於機台設定頁面新增刪除功能,並限制僅系統管理員 (Super Admin) 可操作。
2. 移除機台模型 (Machine) 的 SoftDeletes Trait,將刪除邏輯改為物理刪除。
3. 建立並執行 Migration 移除 machines 資料表的 deleted_at 欄位。
4. 清除資料庫中原有的 51 筆機台軟刪除紀錄。
5. 更新繁中、英文、日文語系檔,加入機台刪除成功與確認視窗之翻譯。
6. 更新 B000 管理員登入 API 之回應格式,統一包含 success 與 code 欄位。
7. 同步更新 API 技術規格文件 (SKILL.md) 與 api-docs.php 配置。
8. 微調商品新增/編輯頁面之 UI 元件 z-index 層級。
2026-05-06 15:47:58 +08:00
sky121113
5f102fbd7a [STYLE] 實作側邊欄啟動時自動捲動至選取項目
1. [STYLE] 在 admin.blade.php 的側邊欄新增 x-init 邏輯。
2. [STYLE] 確保頁面載入時,側邊欄會自動捲動至當前啟動 (active) 的選單項目,優化導航體驗。
2026-05-06 14:36:36 +08:00
sky121113
4348a88697 [DOCS] 規範化 B000 認證機制並移除冗餘 API
1. [DOCS] 更新 B000 登入 API 規格,統一參數名為 Su_Account/Su_Password 並要求帶入機台 API Token。
2. [REFACTOR] 將 B000 路由移入 iot.auth 中間件,強化 API 安全性。
3. [DOCS] 移除廢棄的 B710 (時間同步) API 說明。
4. [DOCS] 調整 B014 認證與安全規範描述,使其符合現行機台引導流程。
2026-05-06 14:25:40 +08:00
sky121113
5e63370983 [DOCS] 更新 B014 API 技術規格說明與文案調整
1. [DOCS] 更新 SKILL.md 中 B014 介面的認證規範,改為透過 machine 參數識別。
2. [DOCS] 移除 SKILL.md 中關於 B014 的冗餘安全性規範說明。
3. [DOCS] 調整 api-docs.php 中 B014 的描述文案並修正行尾空白。
2026-05-06 14:13:02 +08:00
sky121113
cab30b1683 [FIX] 修正支付設定圖示與優化多語系字串
1. 修正支付設定列表頁面中「新增設定」按鈕的圖示,由返回箭頭改為加號圖示,以符合語意。
2. 優化多語系語系檔 (zh_TW, en, ja),新增金鑰重產確認文字「Yes, regenerate」,並移除部分冗餘重複的翻譯項目。
2026-05-06 14:04:16 +08:00
sky121113
41c75b16e8 [FEAT] 實作機台序號變更自動重新產生 API 金鑰
1. 於 MachineSettingController@update 實作序號變更偵測,若序號有變動則自動重新產生 api_token。
2. 更新編輯成功的通知訊息,明確提示使用者金鑰已因序號變動而更新。
3. 補齊中、英、日三語系之通知訊息翻譯字串。
2026-05-06 13:56:56 +08:00
sky121113
ad80fa8121 [FEAT] 整合員工識別紀錄與訂單關聯並優化 UI 顯示
1. [FEAT] 在 StaffCardLog 模型中新增 order 關聯,並在 StaffCardController 預載此關聯。
2. [STYLE] 優化員工識別使用紀錄的 UI 佈局,採用更具質感的顯示方式並使用 status-badge 組件。
3. [STYLE] 擴充 status-badge 組件,支援 verified 與 completed 狀態標籤。
4. [DOCS] 更新 zh_TW.json 語系檔,新增 Staff、Verified、Verified Only 等翻譯。
2026-05-06 13:10:25 +08:00
sky121113
4295257e5f [STYLE] 移除暫存檔 status.txt 2026-05-05 17:50:43 +08:00
sky121113
fc93a0fb8c [FEAT] 實作封閉迴圈交易審核與 API 規格標準化
1. 新增 B680 員工卡驗證 API 並更新相關技術文件 (SKILL.md, api-docs.php)。
2. 重構取貨碼與通行碼之日誌追蹤系統,新增 PickupCodeLog 與 PassCodeLog 模型及資料表遷移。
3. 修正 B660 API 路由方法,統一使用 POST 進行驗證。
4. 優化後台銷售中心介面,將「紀錄」頁籤統一為「使用紀錄」並優化狀態顯示組件。
5. 更新多語系 zh_TW.json 以符合新的 UI 命名規範。
6. 調整 Order 模型與 TransactionService 以支援更精確的封閉迴圈交易審核。
2026-05-05 17:50:26 +08:00
sky121113
15b975efac [FEAT] 新增員工卡管理模組與 B012 API 文檔完善
1. 新增員工卡 (staff_cards) 與員工卡感應日誌 (staff_card_logs) 模型與遷移檔。
2. 實作 StaffCardController 與相關管理介面,支援狀態切換與 CRUD。
3. 擴充 routes/web.php 加入員工卡管理路由。
4. 更新 api-docs.php 文檔,補全 B012 (Unified Sync) 的商品同步回應範例。
5. 實作 PaymentTypeSeeder 並整合至 DatabaseSeeder 以標準化支付類型。
6. 優化銷售中心訂單標籤與倉庫機台庫存顯示邏輯。
7. 側邊欄選單加入員工卡管理入口。
8. 重構 TransactionService 以支援員工卡工作階段 (session_token) 驗證。
2026-05-05 14:06:43 +08:00
sky121113
81314c20b3 [DOCS] 微調 UI 規範文件格式
1. 移除 SKILL.md 中重複的空白行,維持文件結構整潔。
2026-05-05 08:51:36 +08:00
sky121113
023f639fb4 [STYLE] 銷售中心 UI 優化與出貨紀錄邏輯校正
1. 優化「出貨紀錄」介面,重新排列欄位優先級並移除冗餘的機台流水號。
2. 統一出貨紀錄中的商品與貨道樣式,與取貨碼模組視覺語言同步。
3. 修正出貨紀錄搜尋邏輯,支援商品名稱與貨道編號模糊搜尋。
4. 修正「訂單詳情」側滑面板中的金額顯示(修正 price/subtotal 映射)。
5. 補強訂單詳情的出貨狀態 (dispense_status) 標籤顯示邏輯。
6. 將出貨紀錄中的「數量」表頭修正為「金額」,確保與 MQTT 協議回傳資料語意一致。
7. 更新多語系檔,新增搜尋欄位 placeholder 與相關 UI 標籤翻譯。
8. 新增發票資料表 machine_time 欄位遷移檔,以完整記錄機台開立時間。
2026-05-04 17:20:43 +08:00
sky121113
e779880f9b [FEAT] 整合 MQTT 交易完成 (Finalize) 流程並更新 API 技術規格文件
1. 整合交易流程:將 B600 (交易紀錄)、B601 (發票紀錄) 與 B602 (出貨紀錄) 整合為單一原子化的 MQTT 'finalize' 操作,確保資料一致性。
2. 實作非同步 Job:新增 ProcessTransactionFinalized Job 負責解析複合式 Payload 並寫入訂單、發票與出貨日誌。
3. 重構交易服務:更新 TransactionService 以支援同時處理訂單與發票資料,並修正產品 ID 驗證邏輯以避免外鍵衝突。
4. 更新技術文件:同步更新 api-docs.php 與 .agents/skills 中的 MQTT 規格,定義詳細的 finalize payload 結構與範例。
5. 優化文件 UI:改善 api-docs.blade.php 的描述渲染,支援換行符號以提升閱讀體驗。
6. 更新監聽指令:在 ListenMqttQueue 中加入 finalize 動作的處理分支並對接至新實作的 Job。
2026-05-04 10:18:30 +08:00
sky121113
8feca33f13 [FEAT] 優化機台日誌管理、新增庫存變動紀錄與取貨碼/通行碼介面改善
1. 新增機台異常日誌手動解除功能,包含資料庫欄位更新與 UI 操作按鈕。
2. 實作機台庫存變動紀錄系統 (Machine Stock Movements),支援追蹤補貨與銷售產生的庫存異動。
3. 重構取貨碼 (Pickup Code) 與通行碼 (Pass Code) 管理介面,採用極簡奢華風 UI 並拆分 Partial Views 提升可維護性。
4. 優化取貨操作日誌,明確區分「取貨成功」與「取貨失敗」,並加入顏色標籤增強辨識度。
5. 擴充多語系支援 (繁中、英文、日文),確保所有新功能與操作狀態均有對應翻譯。
6. 更新 IoT API 規格文件與相關 Service 邏輯,加強指令確認 (ACK) 處理機制。
2026-05-04 08:43:35 +08:00
sky121113
cb5f8f8336 [FIX] 修正 SystemOperationLog 中的 User 模型路徑錯誤 2026-04-30 10:29:25 +08:00
sky121113
820e1c87db [FIX] 取貨碼操作紀錄顯示異常修正
1. 修正 index.blade.php 的 HTML 標籤嵌套錯誤,解決操作紀錄分頁空白問題。
2. 移除暫時的除錯訊息。
3. 整合 SystemOperationLog 機制於取貨碼模組。
2026-04-30 10:28:04 +08:00
sky121113
84bf7de750 [FEAT] 實作通行碼編輯功能與 UI 組件優化
1. 實作通行碼 (Pass Code) 編輯功能,包含後端 Controller 邏輯與前端 Alpine.js 編輯彈窗。
2. 優化 searchable-select 組件的空值處理與樣式。
3. 更新繁體中文語系檔,統一通行碼相關操作之回傳訊息翻譯。
4. 修正通行碼更新邏輯,支援 nullable 狀態與到期日空值處理。
5. 在桌機版與手機版介面加入編輯操作入口。
2026-04-30 09:28:07 +08:00
sky121113
b42695c4b8 [FEAT] 優化通行碼與取貨碼模組、補齊多國語系系統及介面風格調整
1. 通行碼與取貨碼:新增代碼預覽、重新產生功能與到期時間預覽邏輯。
2. 通行碼:允許天數設為 0 (永久),並改用搜尋式下拉選單選取機台。
3. 取貨碼:加強表單檢核並整合自訂 Toast 提示。
4. 語系優化:補齊繁中、英文鍵值,並完整建立日文語系目錄 (lang/ja) 與 JSON 檔案。
5. 組件優化:更新 PageHeader 與 TabNav 組件,提升 UI 互動性與一致性。
6. 訪客功能:新增通行碼訪客端查詢介面與控制器。
7. 其他模組:同步調整機台、遠端與倉庫模組視圖以符合極簡奢華風。
2026-04-29 16:54:21 +08:00
sky121113
7356304d6f [STYLE] 機台分佈地圖行動端優化與補貨管理邏輯修正
1. 機台分佈頁面行動端優化:新增懸浮標題切換按鈕、抽屜式列表與毛玻璃視覺效果。
2. 修正補貨單指派視窗:優化下拉選單初始化邏輯,解決二次開啟時的選擇殘留問題。
3. 更新多語系檔 (zh_TW, en, ja):補齊機台分佈與補貨管理相關詞彙。
4. 移除暫存的簡報檔案 (star-cloud-demo-day.pptx)。
2026-04-29 11:29:42 +08:00
sky121113
6b9b577978 [FEAT] 取貨碼功能強化與 UI 重構
1. 取貨碼模組新增 Slug 機制與公開憑證頁面,支援外部連結訪問。
2. 狀態篩選下拉選單重構為極簡奢華風組件 (x-searchable-select),並優化後端空值處理。
3. 機台分佈地圖新增「依型號分類」功能,提升管理便利性。
4. 新增 B660 取貨碼驗證 API 端點供機台通訊使用。
5. 更新多語言語系檔 (zh_TW.json) 確保介面一致性。
2026-04-29 10:39:35 +08:00
sky121113
78321ad693 [FEAT] 取貨碼功能優化與 QR Code 生成標準化
1. 修復 PickupCode 與 PassCode 模型中 User 命名空間錯誤。
2. 建立統一的 x-qr-code 組件,取代外部 API 並收斂至系統後端路由。
3. 優化取貨碼管理介面:將機台與商品貨道資訊拆分欄位,並動態顯示關聯商品名稱。
4. 補全 status-badge 組件中已使用、已過期與已取消的狀態樣式。
5. 修復取貨碼產生過程中的 Carbon 型別錯誤與 company_id 缺失問題。
2026-04-28 17:25:59 +08:00
sky121113
0332eff9bc [FIX] 修正角色權限顯示邏輯與 UI 組件重構
1. 修正角色編輯頁面、列表頁面與卡片視圖中的權限數量計算邏輯,確保過濾掉無效或隱藏權限。
2. 重構角色權限顯示 UI,將彈窗由不穩定的 Popover 改為基於 Alpine.js x-teleport 的全螢幕置中模窗,解決容器遮擋問題。
3. 抽離並組件化角色清單 (role-list) 與卡片 (role-card) 視圖,提升程式碼複用性。
4. 統一子帳號管理頁面的搜尋與重設按鈕佈局,確保全站視覺一致性。
5. 更新多語系翻譯,修正『租戶』為『客戶』,並新增『全部權限』翻譯項。
6. 完成取貨碼與密碼管理模組的極簡奢華風 UI 遷移。
2026-04-28 16:13:57 +08:00
sky121113
d00444840f [REFACTOR] 重構廣告管理模組 UI 並優化多語系支援
1. 重構廣告管理模組 (Ads Management) UI,完全對齊「極簡奢華風 (Minimal Luxury)」設計規範。
2. 新增 x-luxury-datetime-input 標準化組件,統一全站日期時間選擇器介面,並解決手機版顯示問題。
3. 修復 ad-modal.blade.php 內多語系標籤斷行與空格導致的翻譯失效問題,確保中文正確顯示。
4. 優化廣告列表與卡片視圖,移除冗餘時間資訊,強化資訊層級與行動裝置適應性。
5. 更新相關頁面導覽按鈕邏輯,確保在分頁切換時按鈕渲染與佈局穩定。
2026-04-28 13:26:15 +08:00
sky121113
9686f37fbd [STYLE] 優化商品管理與客戶管理頁面之間距與按鈕樣式
1. 調整商品管理與客戶管理主容器間距為 space-y-2,縮小標題與頁籤之空隙。
2. 統一頁籤 (Tabs) 與內容區域之頂部間距為 mt-6,標準化視覺節奏。
3. 修正商品與分類管理頁面之「新增」按鈕顏色不一致問題,統一使用奢華藍漸層。
4. 在各管理頁面主要按鈕加入 whitespace-nowrap,確保文字在窄屏下不換行。
5. 優化商品管理 RWD 佈局,使手機與平板裝置下之篩選列與標題區域對齊基準模組。
2026-04-28 09:44:01 +08:00
sky121113
10bbfef2a4 [STYLE] 移除導覽列中無功能的搜尋圖示
1. 移除 resources/views/layouts/admin.blade.php 中 mobile 版與 desktop 版的搜尋圖示預留區塊。
2. 優化頂部導覽列視覺,減少冗餘的 UI 元件。
2026-04-28 08:38:47 +08:00
sky121113
c1fdf6bc9f [STYLE] 優化機台庫存概覽介面與多語系支援
1. 移動「一鍵補貨」按鈕至總庫存量進度卡片下方,提升操作便利性。
2. 實作機台詳情頁面貨道列表的響應式卡片設計 (Mobile Card View)。
3. 新增貨道卡片動態底色與進度條顏色,根據庫存狀態 (充足、偏低、缺貨、過期) 顯示對應色調。
4. 修正 Stock Level > 50% 多語系翻譯失效問題,並同步更新中英日語系檔。
5. 統一貨道列表卡片樣式,與主機台清單達成視覺一致性 (極簡奢華風)。
2026-04-27 17:35:37 +08:00
sky121113
772c7a62e8 [STYLE] 庫存管理響應式 UI 優化與卡片顯示格式修正
1. 優化「目前庫存」分頁:修復平板模式下卡片展開導致的高度拉伸問題,並確保 Alpine.js 變數 (localCardOpen) 作用域隔離。
2. 優化「異動紀錄」分頁:將時間戳記從獨立行移入 2x2 內容網格,補上「時間」標題並修正字體顏色。
3. 優化「進貨單管理」分頁:卡片日期顯示格式修正為完整時分秒 (Y-m-d H:i:s)。
4. 統一搜尋佈局:將庫存管理各分頁與機台補貨單的「搜尋」與「重置」按鈕群組化,確保在窄螢幕換行時能保持連動。
5. 修正進貨單詳情:優化側邊欄 (Slide-over) 的資料載入邏輯與多語系顯示。
2026-04-27 15:40:57 +08:00
sky121113
9d7fdc9c7f [STYLE] 優化維修紀錄與倉儲管理模組 RWD 響應式佈局
1. 維修紀錄 (Maintenance Records):優化標題區域與按鈕在行動裝置上的顯示,新增「系統 (System)」維修類型支援。
2. 倉儲管理 (Warehouse Management):全面優化總覽、庫存、調撥與補貨介面,確保在手機版自動切換為優化的卡片或折疊佈局。
3. 機台庫存 (Machine Inventory):修正表格寬度與字體大小,提升小螢幕下的閱讀性。
4. 補貨流程 (Replenishment Workflow):優化一鍵補貨 Modal 與詳情面板的 RWD 表現。
5. 全站佈局:調整 admin.blade.php 與主容器間距,修復部分區域在窄螢幕下的破圖問題。
2026-04-27 14:24:37 +08:00
sky121113
de538a0162 [FIX] 優化側邊欄縮收介面與維修紀錄多租戶相容性
1. 優化側邊欄 (Sidebar) 縮收邏輯:新增子選單與文字標籤在縮放時的過渡動畫與顯示控制。
2. 修正維修紀錄 (Maintenance Records) 結構:透過 Migration 將 company_id 設為 nullable,支援系統層級的維修日誌。
3. 新增補貨單狀態更新 Modal:實作智慧補貨流程中的狀態切換確認視窗。
4. 簡化登入紀錄 UI:移除複雜的 Timeline 線條與點狀裝飾,回歸極簡奢華風視覺核心。
2026-04-27 14:16:53 +08:00
sky121113
8e854841d3 [FIX] 強制更新路由表以解決 Demo 站 RouteNotFound 錯誤
1. 修改 web.php 觸發部署腳本重新整理路由快取。
2. 確認 admin.warehouses.machine-inventory 路由定義完整性。
2026-04-24 17:41:10 +08:00
sky121113
9017869d0f [FIX] 修正 Migration 欄位重複導致的 CI/CD 錯誤
1. 增加防禦性檢查:在新增欄位前先透過 Schema::hasColumn 確認欄位是否已存在。
2. 解決 welcome_gift_enabled 欄位重複定義導致的 SQL 錯誤。
2026-04-24 17:37:31 +08:00
sky121113
e9dd1a28ef [STYLE] 將 scratch 腳本資料夾加入 .gitignore 2026-04-24 17:35:44 +08:00
sky121113
aa656feecf [STYLE] 統一全站管理介面圖示與 UI 規範
1. 機台列表:更換管理與日誌圖示為標準鉛筆與眼睛圖示,移除多餘按鈕。
2. 儀表板:優化機台庫存即時顯示邏輯。
3. 商品與廣告管理:優化分頁與表格操作列寬度,提升視覺一致性。
4. 權限管理:修正機台權限指派介面的 RWD 表現與多選組件樣式。
5. 帳號配置:優化表格操作區域與按鈕懸停狀態。
2026-04-24 17:34:09 +08:00
sky121113
2012c5a321 [FEAT] 優化一鍵補貨 UI 與 RWD 響應式佈局
1. 實作一鍵補貨智慧重新計算邏輯:切換倉庫或機台時自動更新預覽。
2. 新增多語系支援:補齊繁中、日文、英文語系檔中的庫存/上限、重新計算等關鍵字。
3. 優化 RWD 響應式佈局:在手機版自動切換為卡片式 (Card) 佈局,提升行動裝置操作體驗。
4. 新增刪除功能:預覽清單中每一行貨道皆可獨立移除,並配合唯一辨識碼 (slot_no) 修正 state 殘留 bug。
5. 完善一鍵補貨 Modal 腳底控制項:新增手動「重新計算」按鈕與樣式優化。
6. 完成補貨單管理功能:包含新增、取消、分配人員等後台邏輯與多租戶隔離。
7. 優化庫存轉移 (Transfers) 介面與詳情面板 RWD 表現。
2026-04-24 17:32:11 +08:00
sky121113
b0e523067f [STYLE] 優化機台設定與倉庫管理介面規範化
1. 機台管理模組優化:
   - 移除機台設定內部的「機台分布」標籤頁,改為標題旁獨立的極簡白色連結入口。
   - 優化機台列表欄位,將「公司名稱」獨立顯示。
   - 實作機台系統設定非同步更新功能 (update-system-settings)。
2. 倉庫管理模組規範化:
   - 統一庫存分頁的視覺色調,使用一致的青色 (Cyan) 強調色。
   - 優化日期篩選器,支援精確時間 (H:i) 選擇並預設 00:00。
   - 實作自定義 multi-select 組件以解決 Preline 下拉衝突。
   - 確保所有篩選操作觸發立即的 AJAX 資料同步與狀態更新。
3. 系統基礎優化:
   - 更新繁體中文、日文、英文語系檔,確保標籤顯示正確。
   - 清理 Controller 多餘的地圖資料擷取邏輯。
2026-04-24 10:54:24 +08:00
sky121113
4bf4898e6a [FIX] 在預設分支同步工作流配置以修正 Gitea 介面顯示問題 2026-03-06 17:13:48 +08:00
346 changed files with 59873 additions and 8891 deletions

View File

@ -72,8 +72,8 @@ trigger: always_on
### 5.1 MQTT 通訊端點 (高頻與事件驅動)
以下高頻或即時事件,未來將**全面改採 MQTT 協議**,透過 EMQX 與 Go Gateway 橋接:
1. **心跳事件**:機台發布至 `machine/{serial_no}/heartbeat`
2. **B013 (錯誤與狀態)**:機台發布至 `machine/{serial_no}/error`
3. **B600 / B602 (交易紀錄)**:機台發布至 `machine/{serial_no}/transaction`
2. **異常與狀態 (B013)**:機台發布至 `machine/{serial_no}/error`
3. **交易紀錄 (B600/B602)**:機台發布至 `machine/{serial_no}/transaction`,推薦使用 `action: finalize` 整合上報
處理管線:
`機台 ➜ EMQX ➜ Go Gateway ➜ Redis List (mqtt_incoming_jobs) ➜ Laravel daemon (mqtt:listen) ➜ Job 異步寫入 DB`

View File

@ -38,7 +38,7 @@ trigger: always_on
* **樣式與組件**:全面使用 Tailwind CSS utility classes遵循 Preline UI 的 HTML 結構進行開發。**避免撰寫自訂 CSS**。
* **互動邏輯**:優先使用輕量級 **Alpine.js** (`x-data`, `x-show`, `@click`) 於 Blade 內完成,**禁用 React / Vue / Inertia.js**。
* **【警告Preline 冗餘】**Preline 官方範例常有多餘控制項。必須確保介面佈局極簡,嚴格遵守「極簡奢華風 UI 實作規範 (SKILL.md)」。
* **多語系 (I18n)**:所有 UI 可見文字必須使用 `@lang('key')``__('key')` 處理。Key 統一採用**英文原始詞彙**,並同步更新 `zh_TW.json`, `en.json`, `ja.json`
* **多語系 (I18n)**:所有 UI 可見文字必須使用 `@lang('key')``__('key')` 處理。Key 統一採用**英文原始詞彙**,並同步更新 `zh_TW.json`, `en.json`, `ja.json`必須嚴格遵守「多語系管理與自動對齊規範 (SKILL.md)」,以 `zh_TW.json` 為標準對齊三語系,所有 JSON 檔案須按字母排序 (ksort),且斜線一律使用 `JSON_UNESCAPED_SLASHES` 還原為 `/`,嚴禁逸出為 `\/`
## 5. 運行與測試機制
* **啟動環境**`./vendor/bin/sail up -d`

View File

@ -19,6 +19,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
| 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
| RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` |
| 多語系, 翻譯, 語系檔, lang, json, zh_TW, en, ja, I18n | **多語系管理與自動對齊規範** | `.agents/skills/multilingual-specs/SKILL.md` |
---
@ -42,4 +43,8 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
必須讀取:
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
### 🔴 新增或修改語系 JSON 檔案lang/*.json或執行多語系任務時
必須讀取:
1. **multilingual-specs** — 確保遵守三語系對齊、字母排序、斜線不逸出規範

View File

@ -16,7 +16,6 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
### 2.1 維運人員認證 (User Authentication)
- **核發端點**B000 (登入)。
- **使用端點**B014 (參數下載)。
- **方式**:使用 Laravel Sanctum 核發之 **User Token**
- **Header**`Authorization: Bearer <user_token>`。
@ -30,27 +29,30 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
## 3. 機台核心 API (IoT Endpoints)
### 3.1 B000: 機台本地管理員同步登入
用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點
用於機台 Android 端維護人員登入與進入設定頁。此 API 需帶入機台 API Token 進行認證
- **URL**: POST /api/v1/app/admin/login/B000
- **Authentication**: Bearer Token (Header)
- **Headers**:
- `Content-Type: application/json`
- `Authorization: Bearer <api_token>`
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
| Su_Account | String | 是 | 系統管理員或公司管理員帳號 | admin |
| Su_Password | String | 是 | 密碼 | password123 |
| ip | String | 否 | 用戶端 IP (相容舊版) | 192.168.1.100 |
| type | String | 否 | 裝置類型代碼 (相容舊版) | 2 |
| ip | String | 否 | 機台本地 IP | 192.168.1.100 |
- **Response Body:**
> [!IMPORTANT]
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true |
| code | Integer | 業務狀態碼 | 200 |
| message | String | 驗證結果 (Success 或 Failed) | Success |
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
| identity | String | **登入者身分**`system` 系統登入者 / `company` 公司帳號 / `staff` 一般人員。僅驗證成功時回傳 | system |
| token | String | **臨時身份認證 Token** (可選,供未來擴充使用) | 1\|abcdefg... |
---
@ -80,21 +82,20 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
- **URL**: PUT /api/v1/app/products/supplementary/B009
- **Authentication**: Bearer Token (Header)
- **Request Body:** 無 (由 Token 自動識別機台)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
| account | String | 是 | 執行補貨的人員帳號 (Username 或 Email),用於權限驗證 | admin |
| data | Array/Object | 是 | 貨道數據陣列 (若傳單一物件會自動相容為陣列) | [{"tid": "1", "t060v00": "1", "num": 10, "type": 1}] |
- **data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| tid | Integer | 貨道編號 (Slot No) | 1 |
| tid | String | 貨道編號 (Slot No) | "1" |
| t060v00 | String | 商品資料庫 ID (或是 Barcode) | "1" |
| num | Integer | 實體剩餘庫存數量 | 10 |
| type | Integer | 貨道物理類型 (1: 履帶, 2: 彈簧)。若未提供,預設為 1。 | 1 |
@ -110,7 +111,14 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| success | Boolean | 同步是否成功 | true |
| code | Integer | 內部業務狀態碼 | 200 |
| message | String | 回應訊息 | Slot report synchronized success |
| status | String | 固定回傳 49 代表同步完成 | "49" |
- **Response Body (Failed 403 - 權限不足):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | false |
| code | Integer | HTTP 狀態碼 | 403 |
| message | String | 失敗原因 | Unauthorized: Account not found |
---
@ -129,10 +137,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| 欄位 | 型別 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| t060v00 | String | 商品資料庫 ID | "1" |
| t060v01 | String | 商品名稱 | 可口可樂 330ml |
| t060v01_en | String | 英文名稱 | Coca Cola |
| t060v01_jp | String | 日文名稱 | コカコーラ |
| t060v03 | String | 商品規格/簡述 | Cold Drink |
| t060v01 | String | 商品名稱(預設語系 zh_TW | 可口可樂 330ml |
| t060v01_en | String | 英文名稱(**既有欄位,保留相容** | Coca Cola |
| t060v01_jp | String | 日文名稱(**既有欄位,保留相容** | コカコーラ |
| t060v01_i18n | Object | **【新增】名稱多語系 map**locale → 名稱key 集合 = 公司機台語系聯集;缺翻譯回退 zh_TW | {"zh_TW":"可口可樂","en":"Coca Cola","ja":"コカコーラ"} |
| t060v03 | String | 商品規格/簡述(預設語系 zh_TW | Cold Drink |
| t060v03_i18n | Object | **【新增】規格多語系 map**locale → 規格),同 `t060v01_i18n` 邏輯 | {"zh_TW":"330ml","en":"330ml","ja":"330ml"} |
| t060v06 | String | 圖片 URL | https://.../coke.png |
| t060v09 | Float | 標準零售價 | 25.0 |
| t060v11 | Integer | **貨道庫存上限** (預設履帶) | 10 |
@ -143,6 +153,12 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| spring_limit | Integer | **彈簧貨道上限** (建議使用此欄位) | 10 |
| track_limit | Integer | **履帶貨道上限** (建議使用此欄位) | 15 |
> **多語系 (i18n) 說明**
> - **向下相容鐵則**`t060v01` / `t060v01_en` / `t060v01_jp` / `t060v03` 等既有欄位一律**保留**,本次僅**新增** `t060v01_i18n`、`t060v03_i18n` 兩個 map。線上舊 App 忽略新欄位、照舊運作。
> - **`*_i18n` 的 key 集合 = 公司所有機台已開語系的聯集**(後台於機台系統設定勾選,最多 5 種/台;可選池子見 `config/locales.php`)。缺翻譯時回退 zh_TW不下發空字串。
> - **App 改版方向**:新版 App 改讀 `t060v01_i18n`/`t060v03_i18n` 以支援多語切換,並依 B014 的 `LangSet` 決定要顯示哪些語系。改版前現行 App 僅顯示 zh_TW/en/ja。
> - 完整設計見 [商品多語系與機台顯示語系技術規範](file:///home/mama/projects/star-cloud/docs/API/product_multilingual_spec.md)。
---
### 3.6 B013: [DEPRECATED / REMOVED] 機台故障與異常狀態上報
@ -150,118 +166,226 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
> **此 REST API 已廢棄並移除**。機台異常上報已全面遷移至 **MQTT** 即時通訊架構。
> 詳細錯誤代碼請參閱:[MQTT 通訊規範 - B013 硬體代碼對照表](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#32-異常與錯誤上報-b013)
### 3.12 B010: [DEPRECATED / REMOVED] 心跳上報與狀態同步
> [!WARNING]
> **此 REST API 已廢棄並移除**。機台心跳上報與指令下發已全面遷移至 **MQTT** 架構。
> (註:溫度數據已標準化為 **Integer**,且雲端採「變動即記錄」規則)。
> 詳細格式請參閱:[MQTT 通訊規範 - B010 心跳上報](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#31-心跳上報-b010)
---
### 3.7 B014: 機台參數與金鑰下載 (Config Download)
用於機台引導階段 (Provisioning),向雲端請求支付金鑰、發票設定及機台正式 API Token。
- **URL**: GET /api/v1/app/machine/setting/B014
- **Authentication**: **User Token** (Sanctum Header)
- **Request Body:** (由 Query String 帶入 `machine` 參數)
- **Authentication**: None (由 Query String 帶入 `machine` 參數識別)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
- **Response Body (Success 200):**
- **資料來源**: 所有金流密鑰與電子發票設定,均取自機台關聯的「金流配置 (`payment_configs.settings`)」巢狀結構;機台須先在後台關聯一組金流配置 (`machines.payment_config_id`),未設值欄位回傳空字串 `""`
- **Response Body (Success 200 - Object):**
| 欄位 (Key) | 說明 | 備註 |
| 欄位 (Key) | 說明 | 來源 (payment_configs.settings) |
| :--- | :--- | :--- |
| **t050v01** | 機台序號 | 即 machine_id |
| **api_token** | **機台正式 Token** | 初始化後應存於本地,後續 API 認證用 |
| **t050v41** | 玉山特店編號 | ESUN Merchant ID |
| **t050v42** | 玉山終端編號 | ESUN Terminal ID |
| **t050v43** | 玉山 Hash Key | ESUN Hash |
| **t050v34** | 發票特店 ID | Invoice Merchant ID |
| **t050v35** | 發票 Hash Key | Invoice Key |
| **t050v36** | 發票 Hash IV | Invoice IV |
| **TP_APP_ID** | 趨勢支付 AppID | TrendPay ID |
| **TP_APP_KEY** | 趨勢支付 Key | TrendPay Key |
| **t050v01** | 機台序號 | machines.serial_no |
| **api_token** | **機台正式 Token** | machines.api_token初始化後存本地後續認證用|
| **t050v41** | 玉山特店編號 | `esun_scan.store_id` |
| **t050v42** | 玉山終端編號 | `esun_scan.term_id` |
| **t050v43** | 玉山 Hash Key | `esun_scan.key` |
| **t050v34** | 發票特店 ID | `ecpay_invoice.store_id` |
| **t050v35** | 發票 Hash Key | `ecpay_invoice.hash_key` |
| **t050v36** | 發票 Hash IV | `ecpay_invoice.hash_iv` |
| **t050v38** | 發票通知 Email | `ecpay_invoice.email` |
| **TP_APP_ID** | 趨勢/TapPay AppID | `tappay.app_id` |
| **TP_APP_KEY** | 趨勢/TapPay Key | `tappay.app_key` |
| **TP_PARTNER_KEY** | TapPay Partner Key | `tappay.partner_key` |
| **TP_LINE_MERCHANT_ID** | LINE Pay 特店 ID | `tappay.line_merchant_id` |
| **TP_JKO_MERCHANT_ID** | 街口支付 特店 ID | `tappay.jko_merchant_id` |
| **TP_PI_MERCHANT_ID** | Pi 拍錢包 特店 ID | `tappay.pi_merchant_id` |
| **TP_PS_MERCHANT_ID** | 全盈+Pay 特店 ID | `tappay.ps_merchant_id` |
| **TP_EASY_MERCHANT_ID** | 悠遊付 特店 ID | `tappay.easy_merchant_id` |
| **DevSet** | 支付旗標物件(命名沿用機台 `DevSetStructure`| 機台既有:`ShoppingCar`(shopping_cart_enabled)/`Invoice`(tax_invoice_enabled)/`DevNFCPay`(card_terminal_enabled)/`DevEsunPay`(scan_pay_esun_enabled)/`DevTapPay`(scan_pay_tappay_enabled)/`DevCash`(cash_module_enabled)/`DevLinePay`(scan_pay_linepay_enabled LINE Pay 官方直連)/`TapPay30`(tappay_linepay TapPay 底下 LINE Pay)/`TapPay31`(tappay_jkopay 街口)/`TapPay32`(tappay_easywallet 悠遊付)/`TapPay33`(tappay_pipay)/`TapPay34`(tappay_pluspay 全盈+)。**新定義**(機台待新增欄位)`DevCreditCard`(credit_card_enabled)/`DevMobilePay`(mobile_pay_enabled)/`DevCardPay`(card_pay_enabled)/`DevScanPay`(scan_pay_enabled)。`VMC`/`Electic` 雲端不下發。註LINE Pay 有兩條路線 —— `DevLinePay`=官方直連、`TapPay30`=經 TapPay兩者來源不同|
| **CashSet** | 現金面額旗標物件(對齊 `CashSetStructure`| machines.settings → `BillF1000`/`BillE500`/`BillD100`/`CoinF50`/`CoinE10`/`CoinD5`/`CoinC1` |
| **FunctionSet** | 非支付功能模組旗標物件(**新定義**,機台待實作)| machines.settings → `PickupModule`(pickup_module_enabled)/`PickupCode`(pickup_code_enabled)/`PassCode`(pass_code_enabled)/`WelcomeGift`(welcome_gift_enabled)/`MemberSystem`(member_system_enabled)/`AmbientTemp`(ambient_temp_monitoring_enabled) |
| **ShoppingMode** | 購物方式(字串,**新定義**| machines.settings.shopping_mode`basic` / `employee_card` / `pickup_sheet` |
| **LangSet** | 機台顯示語系(**新定義**,最多 5 種)| machines.settings.languages → `Languages`(有序語系陣列,如 `["zh_TW","en","ja"]`,順序即切換順序)`Default`(預設語系=清單第一個)。未設定時退化為 `[fallback]`。機台據此渲染語系切換 UI。可選語系池子見 `config/locales.php` |
| **OperationSet** | 運作參數(**新定義**,來源 machines 實體欄位)| `CardReaderSeconds`(card_reader_seconds 整數)/`PaymentBufferSeconds`(payment_buffer_seconds 整數)/`CheckoutTime1`(card_reader_checkout_time_1 時間字串)/`CheckoutTime2`(card_reader_checkout_time_2)/`HeatingStartTime`(heating_start_time)/`HeatingEndTime`(heating_end_time) |
| **HardwareSet** | 硬體與貨道(**新定義**,來源 machines 實體欄位)| `CardReaderNo`(card_reader_no 字串)/`SpringSlot1_10`~`SpringSlot51_60`(is_spring_slot_* 布林true=彈簧 / false=履帶) |
> [!CAUTION]
> **安全性規範**B014 會回傳敏感金鑰與正式 Token背景必須強制進行 RBAC 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。
> 註:
> - 金流密鑰/發票來源已由「公司設定 (companies.settings)」改為「金流配置 (payment_configs.settings)」,並新增 `t050v38` 發票通知 Email。
> - 機台系統設定全部以機台端 `*Set` + PascalCase 風格下發(`DevSet`/`CashSet`/`FunctionSet`/`ShoppingMode`/`OperationSet`/`HardwareSet`),不再有原始 key 雜燴物件。`DevSet`/`CashSet` 對齊機台既有結構;其餘(含 `DevCreditCard/DevMobilePay/DevCardPay/DevScanPay`、`FunctionSet`、`ShoppingMode`、`OperationSet`、`HardwareSet`)為新定義,機台 App 端需另案新增對應結構與消費邏輯。旗標/設定來源為 `machines.settings`,運作參數與硬體貨道來源為 machines 實體欄位。
---
### 3.8 B017: 貨道庫存同步 (Slot Synchronization)
用於機台端**主動**向遠端後台拉取目前所有貨道的最新庫存、效期與配置狀態。通常發生於機台開機初始化、補貨/維護模式結束,或是機台需要確保本地端庫存資料與雲端一致時主動請求。
### 3.8 B016: 機台系統設定回寫 (Settings Write-back)
機台主控台由**系統方**設定硬體貨道類型後,將貨道開關狀態回寫雲端。為 B014 下載的反向操作
- **URL**: GET /api/v1/app/machine/reload_msg/B017
- **URL**: PATCH /api/v1/app/machine/setting/B016
- **Authentication**: **User Token (Sanctum)** — 帶入 B000 核發之 `Authorization: Bearer <user_token>`,且**僅系統管理員 (`identity=system`)** 可操作;非系統方一律 403。
- **Headers**:
- `Content-Type: application/json`
- `Authorization: Bearer <user_token>`
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台序號 (serial_no) | SN202604130001 |
| settings | Object | 是 | 貨道類型鍵值物件,鍵名採**後端原始 snake_case**。只覆寫有送來的鍵,其餘保留 | {"is_spring_slot_1_10": true} |
> [!IMPORTANT]
> **僅硬體貨道**:機台端 B016 現在**只回寫硬體貨道類型** (`is_spring_slot_*`)。支付旗標、現金面額、功能模組等系統設定純由**後台單向決定**B016 **不再接收**,未列入下列白名單者一律忽略。
- **可回寫鍵settings 之下,皆為布林):**
- 貨道類型machines 實體欄位):`is_spring_slot_1_10`、`is_spring_slot_11_20`、`is_spring_slot_21_30`、`is_spring_slot_31_40`、`is_spring_slot_41_50`、`is_spring_slot_51_60`true=彈簧 / false=履帶)
- **Response Body (Success 200):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 是否成功 | true |
| code | Integer | 業務狀態碼 | 200 |
| message | String | 回應訊息 | Settings updated successfully. |
- **錯誤回應:**
| HTTP | 情境 | message |
| :--- | :--- | :--- |
| 403 | 非系統管理員 / 未登入 | Forbidden |
| 404 | 查無該序號機台 | Machine not found |
| 422 | `settings` 非物件/陣列,或未含任何有效貨道鍵 | Invalid settings payload |
> 註:寫回時更新 `is_spring_slot_*` 實體欄位並記錄 `updater_id`。為 system-admin 專用,套用 `withoutGlobalScopes` 跨租戶定位機台。
---
### 3.9 B660: 取貨碼驗證 (Pickup Code)
處理機台端的 8 位數代碼取貨流程。
- **URL**: POST /api/v1/app/machine/pickup/verify/B660
- **Authentication**: Bearer Token (Header)
- **Request Body:** 無 (由 Token 自動識別機台)
- **運作模式**:
- **POST (驗證)**: 提交 `code` 驗證。成功後回傳 `pickup_code_id` 與建議 `slot_no`
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息,並在 `payment_type` 帶入 `6` (取貨碼) 完成扣減。
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| code | String | 8 位數取貨碼 | "12345678" |
---
### 3.10 B670: 通行碼驗證 (Pass Code)
專供維修或工程人員現場測試使用的通行碼。僅做身分確認,不涉及庫存消耗。
- **URL**: POST /api/v1/app/machine/passcode/verify/B670
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 8 位數通行碼 | "88888888" |
**Response (Success 200):**
- `name`: 持有者姓名 (如:維護部 A 員)。
- `status`: "active"。
---
### 3.11 B680: 員工卡身分驗證 (Staff Card Verification)
機台端感應員工卡 (RFID/NFC) 後驗證卡片 UID。
- **URL**: POST /api/v1/app/machine/staff/verify/B680
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 卡片 UID 碼 | "A1B2C3D4" |
**Response (Success 200):**
- `name`: 員工姓名。
- `code_id`: 員工卡 ID (用於 MQTT 交易關聯)。
- `status`: "active"。
> [!WARNING]
> **鎖定機制**B660, B670, B680 與 B690 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
---
### 3.12 B690: 來店禮驗證 (Welcome Gift Verify)
處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊供機台端套用折扣。
- **URL**: POST /api/v1/app/machine/welcome-gift/verify/B690
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 8 位數來店禮代碼 | "12345678" |
**Response (Success 200):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true |
| code | Integer | 200 | 200 |
| data | Array | 貨道數據陣列 (依 slot_no 排序) | 見下表 |
| code | Integer | 業務狀態碼 | 200 |
| data.name | String | 來店禮名稱 | 新客八五折 |
| data.code_id | Integer | 來店禮 ID (用於 MQTT 交易關聯) | 12 |
| data.discount_type | String | 折扣類型 (percentage / amount) | percentage |
| data.discount_value | Integer | 折扣趴數 (如 15 代表 15% off) 或折扣金額 | 15 |
| data.discount_label | String | 台灣中文折數標籤 (如「八五折」、「折 50 元」) | 八五折 |
| data.usage_type | String | 使用類型 (once / unlimited) | once |
| data.status | String | 狀態 | active |
**data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| tid | String | 貨道編號 | "1" |
| num | Integer | 當前庫存數量 | 10 |
| expiry_date | String | 商品效期 | "2026-12-31" |
| batch_no | String | 批號 | "B202604" |
| product_id | Integer | 商品 ID | 1 |
| capacity | Integer | 貨道容量上限 | 15 |
| status | String | 貨道狀態 ("1": 啟用, "0": 停用) | "1" |
---
### 3.9 B024: 取貨碼/通行碼驗證與消耗回報 (Access Code Verify & Report)
用於處理機台端的代碼取貨流程。包含驗證代碼有效性(驗證階段)與確認出貨完成後的狀態消耗(回報階段)。
- **URL**: POST|PUT /api/v1/app/sell/access-code/B024
- **Authentication**: Bearer Token (Header)
- **Request Body (POST - 驗證階段):**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| passCode | String | 是 | 使用者輸入的取貨碼/通行碼 | "12345678" |
- **Response Body (POST - 驗證成功 200):**
雲端將回傳該碼對應的權限或待出貨商品資訊。
**Response (Failed 404):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| res1 | String | 該代碼在雲端的關聯 ID | "99" |
| res2 | String | 操作模式 (1: 出貨, 2: 僅驗證) | "1" |
| res3 | String | 預計出貨商品 ID | "5" |
| res4 | String | 折扣金額或活動標籤 | "0.0" |
| success | Boolean | 請求是否成功 | false |
| message | String | 失敗原因 | Invalid or expired code |
| code | Integer | 業務狀態碼 | 404 |
| remaining_attempts | Integer | 剩餘嘗試次數 | 3 |
- **Request Body (PUT - 回報階段):**
當機台端確認實體出貨成功後,必須發送此請求以耗刷該代碼。
**Response (Failed 400 — 格式錯誤):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | false |
| message | String | 失敗原因 (code 非 8 位數) | Invalid format |
| code | Integer | 業務狀態碼 | 400 |
**Response (Failed 429 — 已鎖定):**
連續錯誤達 5 次後,該機台於鎖定期間 (60 秒) 內的所有驗證請求皆回傳此回應。
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | false |
| message | String | 失敗原因 | Too many attempts. Locked. |
| code | Integer | 業務狀態碼 | 429 |
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息,並在 `payment_type` 帶入 `7` (來店禮) 完成扣減。
---
### 3.14 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
- **URL**: POST /api/v1/app/sell/free-gift/B027
- **Authentication**: Bearer Token (Header)
- **運作模式**:
- **POST**: 提交 `passCode` 驗證。成功後雲端會回傳對應活動 ID 與商品配置。
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息進行消耗。
---
### 3.15 B650: 會員身分驗證 (Member Verification)
機台端掃碼後驗證會員身分。
- **URL**: POST /api/v1/app/machine/member/verify/B650
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| accessCodeId | String | 是 | 驗證階段取得的 res1 (ID) | "99" |
| status | String | 是 | 出貨結果 (1: 成功, 0: 失敗) | "1" |
| code | String | 是 | 會員代碼 | "MEMBER12345" |
---
### 3.10 B027: 贈品碼/優惠券驗證與消耗回報 (Free Gift Verify & Report)
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
- **URL**: POST|PUT /api/v1/app/sell/free-gift/B027
- **Authentication**: Bearer Token (Header)
- **運作模式**:
- **POST**: 提交 `passCode` 驗證該贈品券是否有效。成功後雲端會回傳對應活動 ID 與商品配置。
- **PUT**: 當該贈品0 元商品)出貨完成後,向雲端回報消耗,確保優惠券不會重複核銷。
> [!NOTE]
> B027 與 B024 的邏輯具備高度對稱性,區別在於 B027 通常綁定的是特定的行銷活動 (Campaign) 與 0 元出貨邏輯。
---
## 4. 已遷移至 MQTT 的端點索引
@ -270,9 +394,47 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| 原 API 代碼 | 名稱 | MQTT Topic | 備註 |
| :--- | :--- | :--- | :--- |
| B010 | 心跳上報 | `machine/{serial_no}/heartbeat` | [廢棄](#312-b010-deprecated--removed-心跳上報與狀態同步) |
| B013 | 異常上報 | `machine/{serial_no}/error` | [廢棄](#36-b013-deprecated--removed-機台故障與異常狀態上報) |
| B010 | 心跳上報 | `machine/{serial_no}/heartbeat` | [遷移](#312-b010-deprecated--removed-心跳上報與狀態同步) |
| B013 | 異常上報 | `machine/{serial_no}/error` | [遷移](#36-b013-deprecated--removed-機台故障與異常狀態上報) |
| B600 | 交易建立 | `machine/{serial_no}/transaction` | `action: create` |
| B601 | 發票紀錄 | `machine/{serial_no}/transaction` | `action: invoice` |
| B602 | 出貨結果 | `machine/{serial_no}/transaction` | `action: dispense` |
| B600+ | 交易完成(統整) | `machine/{serial_no}/transaction` | `action: finalize` |
| [New] | 商品同步指令 | `machine/{serial_no}/command` | `command: update_products` |
| B055 (PUT) | 遠端出貨回報 | `machine/{serial_no}/command/ack` | GET 查詢路由保留 |
---
## 附錄 (Appendix)
### Appendix A: B600 支付類型對照表 (`payment_type`)
此對照表依據機台 B600 規格定義,用於解析 `order.payment_type`
| 代碼 | 支付方式 | 備註 / 說明 |
| :--- | :--- | :--- |
| `1` | 信用卡 | 一般實體信用卡刷卡 |
| `2` | 電子票證 | 包含悠遊卡、一卡通、iCash 等(與信用卡、手機支付共用同一台實體刷卡機)|
| `3` | 掃碼支付 | 一般掃碼支付 |
| `4` | 投幣機支付 | 實體紙鈔機 |
| `5` | 通行碼 | 現場維護人員測試使用 |
| `6` | 取貨碼 | 包含 8 位數取貨碼與 0 元贈品碼 |
| `7` | 來店禮 | |
| `8` | 問卷 | |
| `9` | 零錢 | 硬幣投幣機 |
| `10` | 手機支付 | NFC 手機感應(行動支付,走信用卡軌道,依使用者按鈕分類) |
| `21` ~ `25` | 線下付款 + X | `21`=線下+1, `22`=線下+2... `25`=線下+9 |
| `30` | TapPay - LINE Pay | TapPay 底下的 LINE Pay |
| `31` | TapPay - 街口支付 | |
| `32` | TapPay - 悠遊付 | |
| `33` | TapPay - Pi 拍錢包 | |
| `34` | TapPay - 全盈+PAY | |
| `40` | 會員驗證取貨商品 | |
| `41` | 員工卡 | RFID / NFC 刷卡 |
| `42` | 取物單 | |
| `50` ~ `54` | 線下付款 + TapPay | `50`=線下+30, `51`=線下+31... `54`=線下+34 |
| `70` | LINE Pay官方直連 | LINE Pay 官方直連,非 TapPay 底下的 `30` |
| `60` | 點數 / 優惠卷全額折抵 | 點數全額折抵出貨 或 優惠卷全額折抵出貨 |
| `61` ~ `69` | 會員 + X | `61`=會員+1, `62`=會員+2... `69`=會員+9 |
| `90` ~ `94` | 會員 + TapPay | `90`=會員+30, `91`=會員+31... `94`=會員+34 |
| `100` | 遠端出貨 | 後台遠端觸發出貨 |

View File

@ -19,7 +19,7 @@ description: 規範智能販賣機與 Cloud 平台間的高頻通訊處理流程
4. **Model (儲存層)**:執行資料存取。
### 1.2 MQTT 管線 (高頻/即時操作)
適用於 B010 (心跳), B013 (異常), B600/B601/B602 (交易生命週期) 等高頻或即時性端點:
適用於 `heartbeat` (心跳), `error` (異常), `transaction` (交易生命週期: `create`, `finalize`) 等高頻或即時性端點:
1. **Go Gateway (接收層)**:訂閱 EMQX Topic提取 `serial_no`,包裝成標準 JSON。
2. **Redis List (橋接層)**Go 執行 `RPUSH starcloud_database_mqtt_incoming_jobs {json}`
3. **Laravel `mqtt:listen` (消費層)**:常駐指令 `BLPOP` 取出 JSON根據 `type` 分派至對應 Job。
@ -90,9 +90,10 @@ public function handle(MachineService $service): void
### 常見端點處理模式
1. **$SYS Events**:由 Broker 自動觸發,走 **MQTT 管線**。用於毫秒級更新機台的 `online/offline` 狀態與 `last_heartbeat_at`
2. **B010 (心跳)**:高頻點,走 **MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heartbeat_at` 與感測器快照。
3. **B013 (異常)**:事件驅動點,走 **MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警。
4. **B600/B601/B602 (交易生命週期)**:高價值點,走 **MQTT 管線** (`machine/+/transaction`)。透過 `action` 欄位區分 `create` (交易建立) / `invoice` (發票) / `dispense` (出貨結果)。
2. **heartbeat (心跳)**:原 B010。高頻點**MQTT 管線** (`machine/+/heartbeat`)。更新 `last_heartbeat_at` 與感測器快照。
3. **error (異常)**:原 B013。事件驅動點**MQTT 管線** (`machine/+/error`)。寫入 `machine_logs` 並觸發告警。
4. **transaction (交易生命週期)**:原 B600/B601/B602。高價值點**MQTT 管線** (`machine/+/transaction`)。透過 `action` 欄位區分 `create` (建立) / `finalize` (完成統整) / `dispense` (出貨結果)。
- **員工卡/通行碼**:應透過 `action: finalize``amount: 0` 並帶入對應 `payment_type` (如 41: 員工卡) 進行交易錄入。
5. **B012 (商品同步)**:大資料量,走 **HTTP 管線**。應確保 Service 層具備緩存 (Cache) 機制。
6. **B055 (遠端出貨)**:雲端下發指令,走 **MQTT 下行管線** (`machine/{id}/command`)。

View File

@ -26,9 +26,7 @@ description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊
機台連線時必須提供以下憑據:
- **Username**:機台編號 (`serial_no`),例如 `SN00001`
- **Password**:機台 `api_token` 的 SHA256 雜湊值,由 B014 API 取得。
- **Client ID**:格式建議為 **`SC_{serial_no}_{random_suffix}`**。
- **解析邏輯**:系統接收連線事件時,會自動移除 `SC_` 前綴,並移除第一個底線後的所有隨機字串,還原出純淨的 `serial_no`
- **範例**`SC_M0408_pndk` ➜ 解析為 `M0408`
- **Client ID**:格式建議為 **`SC_{serial_no}`**(確保同一機台僅能有一個連線,新連線將踢除舊連線)。
**EMQX 認證機制**
- **資料結構**Redis Hash (`HSET`)。Key 格式為 `{prefix}machine_auth:{username}`,包含 `password` 欄位。
@ -38,14 +36,7 @@ description: 定義 Star Cloud 與終端機台 (IoT) 之間的 MQTT 全域通訊
> [!CAUTION]
> **嚴禁使用 Redis String (`SET`) 儲存認證資料**。EMQX 5.x 的 Redis Auth Plugin 僅支援 `HMGET` 讀取 Hash 結構。若誤用 String認證將永遠失敗。
### 1.3 連線狀態即時同步 ($SYS Events)
系統透過訂閱 EMQX 的系統主題來實現「毫秒級」的連線狀態更新,不需等待心跳包:
- **訂閱主題**
- `$SYS/brokers/+/clients/+/connected` ➜ 標記為 `online`
- `$SYS/brokers/+/clients/+/disconnected` ➜ 標記為 `offline`
- **處理流程**Go Gateway 接收 ➜ 解析 Client ID 為 Serial No ➜ 推送 `type: status` 至 Redis ➜ Laravel `ProcessStatus` Job 更新資料庫與 `last_heartbeat_at`
### 1.4 Go Gateway 認證
### 1.3 Go Gateway 認證
- **Username**`star-cloud-gateway` (固定值)。
- **Password**:與 Redis Hash `machine_auth:star-cloud-gateway` 中的 `password` 欄位對應。
- Go Gateway 的認證資料須透過 Laravel 側寫入 Redis。
@ -74,23 +65,22 @@ App (Android) 透過 **WSS** 穿越公網連線 Broker。路徑`App → Cloud
```java
Mqtt3Client client = MqttClient.builder()
.useMqttVersion3()
.identifier("SC_" + machineID + "_" + randomSuffix)
.identifier("SC_" + machineID)
.serverHost("mqtt.setdomains.work")
.serverPort(443)
.sslWithDefaultConfig()
.webSocketConfig().serverPath("/mqtt").applyWebSocketConfig()
.simpleAuth().username(machineID).password(apiToken.getBytes()).applySimpleAuth()
.willPublish()
.topic("machine/" + machineID + "/heartbeat")
.topic("machine/" + machineID + "/status")
.payload("{\"status\":\"offline\"}".getBytes())
.applyWillPublish()
.buildBlocking();
```
### Fallback 策略
- **指數退避重連**1s → 2s → 4s → ... → 最大 60s。
- WSS 斷線超過 **3 分鐘** → 退回 HTTP 輪詢 (B010),背景每 60 秒嘗試重連 WSS。
- WSS 恢復 → 立即切回 MQTT停止 HTTP 輪詢。
### 重連策略 (Reconnection Strategy)
- **指數退避重連**斷線後採指數退避1s → 2s → 4s → ... → 最大 60s嘗試重連 WSS。
- **無 HTTP Fallback**:不再退回 HTTP 輪詢 (B010),斷線期間的狀態與交易將依賴 App 本地暫存(詳見第 6 節「斷線保障機制」)。
---
@ -123,7 +113,7 @@ Mqtt3Client client = MqttClient.builder()
```
> [!NOTE]
> 為了降低傳輸開銷,目前僅要求上報 **韌體版本****溫度** (整數型態)。其餘狀態如連線/斷線已透過 LWT 與 $SYS 事件處理。
> 為了降低傳輸開銷,目前僅要求上報 **韌體版本****溫度** (整數型態)。其餘狀態如連線/斷線已透過 LWT 機制處理。
> 雲端對於溫度的日誌記錄規則:**只要有變動 (current !== last) 即寫入日誌**,並維持每 4 小時一次的保底記錄。
### 3.2 異常與錯誤上報 (B013)
@ -139,17 +129,90 @@ Mqtt3Client client = MqttClient.builder()
```
#### B013 硬體代碼對照表 (由 MachineService 自動翻譯)
> [!IMPORTANT]
> **出貨結果判定**:僅 `0402` (出貨成功) 與 `0424` (購買成功) 視為成功,其餘出貨結果碼均應觸發退款。
> 完整代碼定義於 `MachineService::ERROR_CODE_MAP`
**基本出貨流程:**
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0402** | Dispense successful | info | 出貨成功 |
| **0401** | Dispensing in progress | info | 正在出貨 |
| **0402** | Dispense successful | info | ✅ 出貨成功 |
| **0403** | Slot jammed | error | **貨道卡貨** (重大異常) |
| **0202** | Product empty | warning | 貨道缺貨 |
| **0412** | Elevator rise error | error | 昇降機上升異常 |
| **0415** | Pickup door error | error | 取貨門異常 |
| **5402** | Pickup door not closed | warning | **取貨門未關** (警告) |
| **5403** | Elevator failure | error | 昇降系統故障 |
| **0404** | Motor not stopped | warning | 電機未正常停止 |
| **0405** | Motor not found | error | 電機不存在 |
| **0406** | Motor not found (0406) | error | 電機不存在 (同 0405) |
| **0407** | Elevator failure | error | 昇降機故障 |
*(註:`error_code` 應遵循此表定義,雲端將根據 `i18n` 自動轉換。`tid` 為貨道或任務 ID。)*
**昇降機系統:**
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **040A** | Elevator belt self-check error | error | 昇降機皮帶自檢錯誤 |
| **0410** | Elevator rising | info | 昇降機正在上升 |
| **0411** | Elevator descending | info | 昇降機正在下降 |
| **0412** | Elevator rise error | error | 昇降機上升錯誤 |
| **0413** | Elevator descent error | error | 昇降機下降錯誤 |
| **0425** | Elevator support rod return error | error | 昇降台撐桿回位錯誤 |
| **0428** | Elevator support rod push error | error | 昇降台撐桿推出錯誤 |
| **0429** | Elevator enter microwave error | error | 昇降台進微波爐錯誤 |
| **0430** | Elevator exit microwave error | error | 昇降台出微波爐錯誤 |
**微波爐取貨口門:**
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0414** | Microwave pickup door closing | info | 正在關閉微波爐取貨口門 |
| **0415** | Microwave pickup door close error | error | 微波爐取貨口門關閉錯誤 |
| **0426** | Microwave pickup door opening | info | 正在打開微波爐取貨口門 |
| **0427** | Microwave pickup door open error | error | 微波爐取貨口門打開錯誤 |
**微波爐進貨口門:**
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0416** | Microwave delivery door opening | info | 正在打開微波爐進貨口門 |
| **0417** | Microwave delivery door open error | error | 微波爐進貨口門打開錯誤 |
| **0418** | Pushing product into microwave | info | 正在將商品推入微波爐 |
| **0419** | Microwave delivery door closing | info | 正在關閉微波爐進貨口門 |
| **0420** | Microwave delivery door close error | error | 微波爐進貨口門關閉錯誤 |
**微波爐內部:**
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0421** | Product not detected in microwave | error | 微波爐內未檢測到商品 |
| **0422** | Product heating | info | 商品正在加熱 |
| **0423** | Product heating remaining time | info | 商品加熱剩餘時間秒 |
| **0424** | Purchase successful | info | ✅ 購買成功 |
| **0431** | Microwave push rod push error | error | 微波爐內推桿推出錯誤 |
| **0432** | Microwave push rod retract error | error | 微波爐內推桿收回錯誤 |
**鮮奶機專用:**
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **040B** | Milk dispense timeout | error | 按鍵出奶超時 |
| **040C** | Cup dropper failure | error | 漏杯器漏杯失敗 |
| **040D** | Cup drop not detected | error | 未檢測到杯子掉落 |
**手環機專用:**
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **040E** | Bracelet scan failed | error | 沒掃碼到手環 |
| **040F** | Bracelet drop not detected | error | 沒檢測到手環掉入取貨斗 |
**取貨口門 / 購物車模式 / 液體機:**
| 代碼 | 英文 Key (i18n) | 級別 | 範例繁中翻譯 |
| :--- | :--- | :--- | :--- |
| **0433** | Pickup door opened (cart mode) | info | 取貨口門打開成功 (購物車) |
| **0434** | Pickup door closed (cart mode) | info | 取貨口門關閉成功 (購物車) |
| **0435** | Liquid dispense paused | warning | 暫停出液 (液體機) |
| **0441** | Pickup door unlocked | info | 取貨口門開鎖上報 |
| **04FF** | Purchase terminated | error | 購買終止 |
### 3.3 交易生命週期上報 (Transaction) - `machine/{serial_no}/transaction`
@ -167,7 +230,6 @@ Mqtt3Client client = MqttClient.builder()
"items": [
{
"product_id": "5",
"product_name": "Coca Cola",
"price": 25.0,
"quantity": 1
}
@ -182,6 +244,7 @@ Mqtt3Client client = MqttClient.builder()
"flow_id": "T202604140001",
"invoice_no": "AB12345678",
"invoice_date": "2026-04-22",
"machine_time": "2026-04-22 14:30:15",
"amount": 25.0,
"random_no": "1234",
"love_code": ""
@ -202,9 +265,69 @@ Mqtt3Client client = MqttClient.builder()
}
```
#### B600+ 交易完成統整 (`action: finalize`)
這是目前推薦的標準流程,將交易、發票與出貨結果一次性上報,確保資料完整性。
```json
{
"action": "finalize",
"order": {
"order_no": "T202508080001",
"flow_id": "CLIENT_UID_123",
"total_amount": 100,
"pay_amount": 100,
"original_amount": 100,
"discount_amount": 0,
"change_amount": 0,
"payment_type": 1,
"payment_status": 1,
"code_id": "對應指令的ID或取貨碼(如適用)",
"payment_request": "{}",
"payment_response": "{}",
"member_barcode": "MBR999",
"invoice_info": "12345678-0912345678",
"machine_time": "2025-08-08 10:00:00",
"points_used": 0,
"items": [
{
"product_id": 1,
"price": 100,
"quantity": 1
}
]
},
"invoice": {
"invoice_no": "AB12345678",
"invoice_date": "2025-08-08",
"machine_time": "2025-08-08 10:01:30",
"amount": 100,
"random_number": "1234",
"love_code": "",
"carrier_id": "/ABC1234",
"carrier_type": "3J0002",
"business_tax_id": "12345678",
"rtn_code": "1",
"rtn_msg": "成功"
},
"dispense": [
{
"slot_no": "1",
"product_id": 1,
"amount": 100,
"dispense_status": 1,
"remaining_stock": 9,
"member_barcode": "MBR999",
"machine_time": "2025-08-08 10:01:00",
"points_used": 0
}
]
}
```
> [!NOTE]
> - `serial_no` 不需放在 Payload 中Go Gateway 自動從 Topic 路徑提取並注入。
> - 若 Payload 不含 `action` 欄位,系統預設視為 `create`(向後相容既有 B600 格式)。
> - **閉環勾稽參數**:當使用特定支付方式 (如 `payment_type: 6`=取貨碼, `5`=通行碼, `41`=員工卡) 時,請務必在 `order` 中帶入 `code_id` 欄位。後端會根據此 ID 完成對應的狀態更新與核銷。
### 3.4 雲端指令 (Downstream Commands) - `machine/{serial_no}/command`
這是雲端主動下發給機台的訊息,取代原本 B010 Response 的輪詢等待。
@ -229,6 +352,8 @@ Mqtt3Client client = MqttClient.builder()
- `change`: 遠端找零(夾帶 amount 參數)。
- `update_inventory`: 遠端即時修改庫存。
- `dispense`: 遠端出貨指令。
- `update_ads`: 遠端廣告同步指令。
- `update_products`: 遠端商品資料同步指令(觸發機台調用 B012
**`update_inventory` Payload 範例:**
```json
@ -236,14 +361,21 @@ Mqtt3Client client = MqttClient.builder()
"command": "update_inventory",
"command_id": "3",
"payload": {
"data": [
{
"slot_no": "3",
"stock": 6,
"expiry_date": null,
"batch_no": "B2026042201"
}
]
"slot_no": "3",
"stock": 6,
"expiry_date": null,
"batch_no": "B2026042201"
}
}
```
**`update_products` Payload 範例:**
```json
{
"command": "update_products",
"command_id": "123",
"payload": {
"remark": "手動同步商品資料"
}
}
```
@ -294,7 +426,7 @@ Mqtt3Client client = MqttClient.builder()
- **QoS 0**:適用於高頻率心跳,即使掉一兩次包也不影響系統判斷。
- **QoS 1**適用於交易與指令確保「至少送達一次」。App 端收到指令後應回覆回執。
3. **遺囑訊息 (Last Will and Testament)**
機台 Connect 時應設定 Last Will 於 `machine/{serial_no}/heartbeat`Payload 為 `{"status": "offline"}`。當連線異常中斷時,雲端能立刻得知。
機台 Connect 時應設定 Last Will 於 `machine/{serial_no}/status`Payload 為 `{"status": "offline"}`。當連線異常中斷時,雲端能立刻得知。
4. **傳輸加密**
- **App 端 (公網)**:強制使用 WSS (`wss://`)TLS 由 NPM / Cloudflare 終止。
- **Go Gateway (內網)**:預設走 TCP (`tcp://emqx:1883`),區網環境可免 TLS。正式環境建議升級至 `ssl://emqx:8883`

View File

@ -0,0 +1,161 @@
# 多語系管理與自動對齊規範 (Multilingual Specs)
本技能規範定義了 Star Cloud 系統中所有多語系 JSON 檔案(位於 `lang/`)的維護標準。當專案需要新增、修改或校對多語系時,必須嚴格遵守此規範。
---
## 核心三大原則 (CRITICAL RULES)
### 1. 三語系完美對齊 (Master-Align)
- **以 `zh_TW.json` 為唯一標準**:不論任何時候新增或修改翻譯 Key都必須以 `lang/zh_TW.json` 的鍵值Keys集合為絕對標準。
- **三檔對齊**`lang/en.json` 與 `lang/ja.json` 的鍵值集合必須與 `zh_TW.json` 百分之百相同(行數、數量與 Key 的拼寫必須完全一致)。
### 2. 字母順序排序 (ksort)
- 為避免 Git 版本控制產生大範圍的亂序 Diff三個 JSON 檔案中的所有鍵值對,必須一律按 **Key 的英文字母升冪順序** 進行排序(類似 PHP 的 `ksort()`)。
### 3. 斜線不逸出與美化 (No Escaped Slashes)
- **禁止使用預設 `json_encode` 逸出斜線**JSON 檔案中的網址或路徑斜線一律使用 `/`,嚴禁產生 `\/`
- **儲存格式化要求**
在寫入語系 JSON 檔案時,必須使用以下 JSON 編碼參數:
```php
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
```
---
## 自動化對齊工具 (I18n Alignment Tool)
當處理語系變更時AI 助手應在專案的 `scratch/` 目錄中建立一個臨時 PHP 腳本(例如 `scratch/align_lang.php`),執行對齊並排序後,**立即將其刪除**。
### 🔧 臨時對齊腳本範本 (`scratch/align_lang.php`)
```php
<?php
/**
* Star Cloud - 多語系自動對齊與排序工具
* 執行指令php scratch/align_lang.php
*/
$langDir = __DIR__ . '/../lang/';
$zhPath = $langDir . 'zh_TW.json';
$enPath = $langDir . 'en.json';
$jaPath = $langDir . 'ja.json';
if (!file_exists($zhPath)) {
die("❌ 找不到 zh_TW.json 主語系檔!\n");
}
$zhData = json_decode(file_get_contents($zhPath), true);
$enData = file_exists($enPath) ? json_decode(file_get_contents($enPath), true) : [];
$jaData = file_exists($jaPath) ? json_decode(file_get_contents($jaPath), true) : [];
// 1. 精準翻譯對照字典 (用於自動補齊常見系統/機台特有詞彙)
$translationDict = [
// 通行碼/機台/權限相關
"Channel Limits Config" => [
"en" => "Channel Limits Config",
"ja" => "チャンネル制限設定"
],
"Connection error" => [
"en" => "Connection error",
"ja" => "接続エラー"
],
"Passcode settings" => [
"en" => "Passcode settings",
"ja" => "パスコード設定"
],
"Reset Passcode" => [
"en" => "Reset Passcode",
"ja" => "パスコードをリセット"
],
"Update Passcode" => [
"en" => "Update Passcode",
"ja" => "パスコードを更新"
],
"Machine Authorization" => [
"en" => "Machine Authorization",
"ja" => "デバイス授権"
],
"Status" => [
"en" => "Status",
"ja" => "ステータス"
],
"Actions" => [
"en" => "Actions",
"ja" => "操作"
]
];
$missingEnCount = 0;
$missingJaCount = 0;
$newEnData = [];
$newJaData = [];
// 2. 以 zh_TW 的 keys 為基準對齊 en 與 ja
foreach ($zhData as $key => $zhVal) {
// 處理英文
if (isset($enData[$key]) && trim($enData[$key]) !== '') {
$newEnData[$key] = $enData[$key];
} else {
// 嘗試從精準字典查找
if (isset($translationDict[$key]["en"])) {
$newEnData[$key] = $translationDict[$key]["en"];
} else {
// fallback: 使用 key 原始名稱作為英文
$newEnData[$key] = $key;
}
$missingEnCount++;
}
// 處理日文
if (isset($jaData[$key]) && trim($jaData[$key]) !== '') {
$newJaData[$key] = $jaData[$key];
} else {
// 嘗試從精準字典查找
if (isset($translationDict[$key]["ja"])) {
$newJaData[$key] = $translationDict[$key]["ja"];
} else {
// fallback: 如果 key 與英文相同,且非中文,可先 fallback 英文,後續再由 LLM 補譯
$newJaData[$key] = $key;
}
$missingJaCount++;
}
}
// 3. 字母排序 (ksort)
ksort($zhData);
ksort($newEnData);
ksort($newJaData);
// 4. 寫回檔案 (反逸出斜線 + Unicode)
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
file_put_contents($zhPath, json_encode($zhData, $jsonFlags) . "\n");
file_put_contents($enPath, json_encode($newEnData, $jsonFlags) . "\n");
file_put_contents($jaPath, json_encode($newJaData, $jsonFlags) . "\n");
echo "✅ 多語系處理完畢!\n";
echo "📊 統計結果:\n";
echo " - 總共鍵數: " . count($zhData) . " 個\n";
echo " - 補齊英文空缺: {$missingEnCount} 個\n";
echo " - 補齊日文空缺: {$missingJaCount} 個\n";
```
---
## 💡 AI 助手處理語系之 SOP 流程
當使用者要求修改或補齊語系時AI 必須:
1. **同步更新/新增 `zh_TW.json`**:將新的詞彙加入 `zh_TW.json` 中。
2. **利用 LLM 翻譯補足空缺**
- 針對新加入的詞彙,由 AI 運用其 LLM 能力將高品質的英文(`en`)與日文(`ja`)翻譯出來。
3. **擴充對齊腳本的暫存字典**
- 將這些新翻譯的詞彙暫時寫入臨時對齊腳本 `scratch/align_lang.php` 中的 `$translationDict` 中。
4. **執行一鍵對齊與字母排序**
- 在終端機執行 `php scratch/align_lang.php`,確保三個語系 JSON 檔案行數百分之百對齊、全部排序、斜線清空。
5. **清理暫存**
- 刪除 `scratch/align_lang.php`
6. **Git 檢查與提交**
- 執行 `git diff` 確認修改範疇僅限於新增的語系,無亂序與無關的語系被刪除。

View File

@ -5,367 +5,483 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
# 極簡奢華風 UI 實作規範 (Minimal Luxury UI)
本文件定義了 Star Cloud 專案的核心視覺語言。所有新頁面與組件開發必須嚴格遵守此規範。
> [!IMPORTANT]
> **開始寫任何頁面前,先對齊本規範。所有規範以此文件為最終依據。**
> 黃金範本頁面Type A → `admin/machines/index.blade.php`Type B → `admin/products/index.blade.php`
## 1. 核心設計令牌 (Design Tokens)
---
### 色彩系統 (CSS Variables)
位於 `resources/css/app.css`
- `--color-luxury-deep`: `#0f172a` (深色背景)
- `--color-luxury-card`: `#1e293b` (卡片背景)
- `--color-accent`: `#06b6d4` (青色點綴,適用於按鈕與標示)
## 1. 全局速查卡 (Quick Reference)
### 字體 (Typography)
- **內文字體**: `Plus Jakarta Sans`
- **標題/顯示字體**: `Outfit`
- **特性**: 標題需帶有 `letter-spacing: -0.02em` 以增強精密感。
### 1.1 字型規則
## 2. 核心組件樣式
| 使用場景 | Tailwind Class |
|---------|---------------|
| 頁面主標題 (H1)、大數字 | `font-display`Outfit |
| 所有內文、按鈕、標籤 | `font-sans`Plus Jakarta Sans預設不需加 |
| SN 碼、版本號、Barcode、時間戳 | `font-mono`(僅限純英數代碼) |
### 豪華卡片 (Luxury Card)
```html
<div class="luxury-card p-6 rounded-2xl animate-luxury-in">
</div>
```
- **特效**: 懸停時帶有 Y 軸平移與深度投影。
### 1.2 文字大小階層
### 側邊導覽項 (Luxury Nav Item)
```html
<a href="#" class="luxury-nav-item active">
<i class="lucide-icon"></i>
<span>節點名稱</span>
</a>
```
- **啟用狀態**: 左側帶有圓角直條指示器,並輔以青色發光陰影。
| 元素 | 大小 | 字重 | 顏色 | 其他 |
|------|------|------|------|------|
| 頁面 H1 | `text-2xl sm:text-3xl` | `font-black` | `slate-800 dark:white` | `tracking-tight font-display` |
| 頁面副標題 | `text-sm` | `font-bold` | `slate-500 dark:slate-400` | `uppercase tracking-widest mt-1` |
| 表格標頭 (th) | `text-xs` | `font-bold` | `slate-500 dark:slate-400` | `uppercase tracking-[0.15em]` |
| 表格主要資料 | `text-base` | `font-extrabold` | `slate-800 dark:slate-100` | `tracking-tight` |
| 表格次要/描述 | `text-xs` | `font-bold` | `slate-500 dark:slate-400` | `tracking-wide` |
| 技術碼SN/ID/條碼)| `text-xs` | `font-black` | `slate-400 dark:slate-500` | `font-mono uppercase tracking-widest` |
| 時間戳 | `text-xs` | `font-black` | `slate-400` | `font-mono tracking-widest` |
| 狀態 Badge | `text-[10px]` | `font-black` | 依狀態色系 | `uppercase tracking-widest` |
| Modal 標題 | `text-xl sm:text-2xl` | `font-black` | `slate-800 dark:white` | `font-display tracking-tight` |
| Mobile Card 標籤 | `text-[10px]` | `font-black` | `slate-400 dark:slate-500` | `uppercase tracking-widest mb-1` |
| Mobile Card 主標題 | `text-base` | `font-extrabold` | `slate-800 dark:slate-100` | `tracking-tight` |
| Mobile Card 副標(技術碼)| `text-xs` | `font-bold font-mono` | `slate-400 dark:slate-500` | `uppercase tracking-widest` |
### 按鈕組件 (Buttons)
- **Primary**: `.btn-luxury-primary` (青色漸層,適用於建立、儲存)
- **Secondary**: `.btn-luxury-secondary` (白色/深色背景,帶邊框,適用於編輯、篩選)
- **Ghost**: `.btn-luxury-ghost` (無背景,適用於取消、查看更多)
> ❌ 嚴禁使用 `italic`|❌ Mobile Card 標題禁用 `text-lg`
### 1.3 顏色速查
| 用途 | Class |
|------|-------|
| 品牌強調/按鈕 | `cyan-500` / `bg-cyan-500` |
| 成功/上線 | `emerald-500` / `bg-emerald-500/10` |
| 警告/等待 | `amber-500` / `bg-amber-500/10` |
| 錯誤/危險 | `rose-500` / `bg-rose-500/10` |
| 進階/系統 | `indigo-500` / `bg-indigo-500/10` |
| 停用/次要 | `slate-400` / `bg-slate-500/10` |
> Badge 背景:`bg-{color}/10 border border-{color}/20 text-{color}-500`
### 1.4 互動效果
| 元素 | Hover | Transition |
|------|-------|-----------|
| Primary 按鈕 | `hover:bg-cyan-600` + `active:scale-95` | `transition-all duration-300` |
| 表格列 (tr) | `hover:bg-slate-50/50 dark:hover:bg-white/[0.02]` | `transition-colors duration-200` |
| 表格操作按鈕 | `hover:bg-cyan-500/5 hover:text-cyan-500` | `transition-all duration-200` |
| 危險操作按鈕 | `hover:bg-rose-500/5 hover:text-rose-500` | `transition-all duration-200` |
| Mobile Card 操作 | `hover:bg-cyan-500 hover:text-white` | `transition-all duration-300` |
### 1.5 圓角規則
| 元素 | Class |
|------|-------|
| 主要內容卡片(桌面)| `rounded-3xl` |
| Mobile Card | `rounded-[2rem]`(比桌面更大)|
| 按鈕、輸入框 | `rounded-xl` |
| 狀態 Badge | `rounded-full` |
| 縮圖卡片、圖示框 | `rounded-2xl` |
### 1.6 進場動畫
| 場景 | 做法 |
|------|------|
| 頁面卡片、主內容區 | `animate-luxury-in` |
| 多卡片依序 | `style="animation-delay: 100ms"` 遞增 |
| Loading Overlay | `<x-luxury-spinner>`(禁止手寫)|
| Slide-over 面板 | `translate-x-full → translate-x-0` (x-transition) |
---
## 2. 強制使用的組件
> [!IMPORTANT]
> 以下 HTML 結構**嚴禁手寫**,一律使用組件:
| 禁止手寫 | 改用組件 |
|---------|---------|
| `<h1>` 標題 + 副標題 + Action 按鈕 | `<x-page-header>` |
| Tab 導覽列 | `<x-tab-nav>` + `<x-tab-nav-item>` |
| Loading Overlay | `<x-luxury-spinner>` |
| 搜尋欄 + 重置按鈕Type A 純列表) | `<x-search-bar>` |
| 狀態標籤 | `<x-status-badge>` |
| 統計卡片 | `<x-stat-card>` |
| 空狀態提示 | `<x-empty-state>` |
| 刪除確認 | `<x-delete-confirm-modal>` |
### x-luxury-spinner 傳參規則
> [!CAUTION]
> - ✅ 正確:`show="isLoadingTable"`
> - ✅ 正確:`show="tabLoading === 'products'"`
> - ❌ 錯誤:`:show="\"tabLoading === 'products'\""` → **Blade ParseError**
> - ❌ 錯誤:`:show="'isLoadingTable'"` → 語意不清
### x-status-badge 用法
```html
<button class="btn-luxury-primary">
<i class="lucide-plus w-4 h-4"></i>
<span>建立新機台</span>
</button>
{{-- 靜態 --}}
<x-status-badge :status="$machine->is_online ? 'online' : 'offline'" />
<button class="btn-luxury-ghost">取消</button>
{{-- Mobile Card 用小尺寸 --}}
<x-status-badge :status="$item->status" size="sm" />
```
## 3. 動畫與互動
內建狀態:`active`, `inactive`, `online`, `offline`, `enabled`, `disabled`, `pending`, `error`
### 進場動畫
- **`.animate-luxury-in`**: 所有的主內容區域或卡片在頁面載入時,應具備由下而上的淡入效果。
### x-empty-state 用法
### 互動過渡 (Transitions)
- **標準時間**: 所有的懸停、色彩變換等過渡效果,統一建議使用 **`duration-300`** (300ms)。
- **例外**: 極其細微的透明度變化可縮短至 `150ms`,但涉及背景色與位移的互動一律以 `300ms` 為準。
```html
{{-- 表格 @empty 中 --}}
<x-empty-state mode="table" :colspan="6" :message="__('No data found')" />
### Alpine.js 互動模式 (以時間選擇器為例)
- **互動原則**: 點擊觸發下拉選單時,必須使用 `x-transition` 且帶有 `scale` 偏移。
- **樣式要求**: 選單背景需使用玻璃擬態 (Glassmorphism) 或帶透明度的深色背景。
{{-- Card Grid @empty 中 --}}
<x-empty-state :message="__('No records found')" />
```
## 4. 實作檢查清單 (Checklist)
### x-stat-card 用法
- [x] **列表佈局**: 是否採用「整合式卡片」結構且內距設為 `p-8`
- [ ] **分頁與總數**: 列表底部是否正確召喚 `vendor.pagination.luxury`
- [ ] **刪除動作**: 是否已全面使用 `<x-delete-confirm-modal />` 封裝執行路徑?
- [ ] **文字色階**: 符合標題 `slate-900/white` 與標籤 `slate-500` 的對比度。
- [ ] **可讀性檢查**: 二級資訊是否達到 `text-xs` (12px) 且權重不超過 `font-bold`
```html
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<x-stat-card :label="__('Total')" :value="$stats['total']" />
<x-stat-card :label="__('Online')" :value="$stats['online']" color="cyan" delay="100ms" />
<x-stat-card :label="__('Error')" :value="$stats['error']" color="rose" delay="200ms" />
<x-stat-card :label="__('Idle')" :value="$stats['idle']" color="amber" delay="300ms" />
</div>
```
## 5. 開發注意事項 (Important Notes)
---
### 技術限制備忘
- **CSS 編譯**: 複雜的 `box-shadow` 或漸層應直接寫原生 CSS 屬性,避免在 `@apply` 中使用帶空格的數值導致編譯失敗。
- **深色模式**: 互動式按鈕在深色模式下必須強化文字亮度(`dark:text-white`),並輔以青色發光效果。
## 3. 頁面佈局範本 (Page Templates)
### 即時動態呈現規範
- **格式**: `#機台編號 動作內容` (例如 `#V-001 執行出貨`)。
- **脈絡**: 必須呈現相對時間與機台位置。
## 6. 頁面佈局規範 (Page Layout)
### 佈局決策規則 (Layout Decision Rules)
根據篩選條件的複雜程度,選擇適當的清單頁面佈局:
#### 1. 整合式佈局 (Integrated Layout) - 【預設推薦】
- **適用場景**: 絕大多數 CRUD 列表。
- **實作方式**: 篩選器、工具列與資料表格全部封裝在同一個 `luxury-card` 中。
- **內距規範**: 強制使用 `p-8` 以獲得最佳空氣感。
- **元件間距**: 篩選區與表格之間固定使用 `mb-10`
- **範例**: 帳號管理、角色設定、機台日誌。
#### 2. 分離式佈局 (Split Layout)
- **適用場景**: 複雜查詢 (Filtered Fields >= 3 或多行篩選)。
- **實作方式**: 篩選區獨立為一個 `luxury-card`,下方間隔 `mb-6` 後再放置資料清單卡片。
- **樣式規範**: 篩選卡片通常使用 `p-6`(緊湊式),清單卡片使用 `p-8`(寬鬆式)。
- **範例**: 交易紀錄、機台日誌。
### 標準寬版佈局 (Wide Layout)
### Type A — 純列表黃金範本machines/index.blade.php
```html
@section('content')
<div class="space-y-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div>
<h1 class="text-3xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Title') }}</h1>
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Subtitle') }}</p>
</div>
<div class="flex items-center gap-3">
<button class="btn-luxury-primary">
<i class="lucide-plus w-4 h-4"></i>
<span>新增</span>
</button>
</div>
</div>
<div class="space-y-4 pb-20" x-data="machineApp()">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<div class="flex items-center justify-between mb-10">
<form class="relative group">
<input type="text" class="luxury-input pl-12 pr-6 w-64" placeholder="{{ __('Search...') }}">
</form>
</div>
<x-page-header
:title="__('Module Name')"
:subtitle="__('Description')"
>
<button @click="openCreateModal()" class="btn-luxury-primary flex items-center gap-2">
<svg class="w-4 h-4">...</svg>
<span class="text-sm sm:text-base">{{ __('Add') }}</span>
</button>
</x-page-header>
<div class="overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800">{{ __('Name') }}</th>
<th class="px-6 py-4 text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 font-extrabold text-slate-800 dark:text-slate-100">Example Name</td>
<td class="px-6 py-6 text-right"> </td>
</tr>
</tbody>
</table>
</div>
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="isLoadingTable" />
<div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }">
<x-search-bar
:action="route('admin.xxx.index')"
:value="request('search')"
:placeholder="__('Search...')"
/>
{{-- 桌面表格 --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Column') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($items as $item)
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6">...</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="5" :message="__('No records found')" />
@endforelse
</tbody>
</table>
</div>
{{-- Mobile/Tablet Card Grid --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@foreach($items as $item)
{{-- 見 Section 4Mobile Card 黃金範本 --}}
@endforeach
</div>
{{-- 分頁 --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $items->appends(request()->query())->links('vendor.pagination.luxury') }}
</div>
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $items->links('vendor.pagination.luxury') }}
</div>
</div>
</div>
@endsection
```
### 佈局核心原則:
1. **移除重複內距**: 根容器 `div` 應**禁止**使用 `p-6``p-10`,因為佈局基底已提供基礎間距。
2. **區塊間隙**: 建議使用 `space-y-6``space-y-8` 以獲得最佳空氣感。但在「高密度資料管理」或使用者有特殊緊湊需求的情境下,容許縮減至 **`space-y-2`**。
3. **主容器樣式**: 強制對齊為 `luxury-card rounded-3xl p-8`
3. **標題排版**:
- 主標題需應用 `font-display` (Outfit)。
- 描述文字需應用 `uppercase tracking-widest font-bold` 以呈現高級設計感。
## 7. 表單元件規範 (Form Elements)
針對輸入框與下拉選單,強制使用以下類別以確保深色模式質感。
### 輸入框與選單
- **類別**: `.luxury-input`, `.luxury-select`
- **特性**:
- 深色模式下具備半透明背景與背景模糊效果。
- 統一的 `rounded-xl` 圓角與 `font-bold` 字體。
- 聚焦時帶有青色 (`Cyan`) 發光邊框。
```html
<input type="text" class="luxury-input" placeholder="請輸入內容">
<select class="luxury-select">
<option value="1">啟用</option>
<option value="0">禁用</option>
</select>
### 搜尋式下拉選單 (Searchable Select) - 【進階推薦】
- **組件**: `<x-searchable-select />`
- **適用場景**: 選項大於 10 筆或具備層級關聯的篩選器(如:公司名稱、機台編號)。
- **奢華特徵**:
- **動態旋轉箭頭**: 透過 `::after` 偽元素實作,選單展開時箭頭執行 `300ms` 的 180 度旋轉動畫。
- **即時過濾**: 輸入關鍵字即時隱藏不匹配項。
- **選取標示**: 選取的項目右側帶有青色 (`Cyan`) 的勾選小圖標。
- **全部選項修復 (Space Fix)**: 若用於篩選(如公司篩選),組件內部已實作「空格佔位符」機制。若選單中的「全部」選項在選取後消失,請確保該選項的值為單個空格 (`value=" "`)。這能繞過 Preline 對空標記的隱藏邏輯,並同步觸發 Laravel 的 `blank()` 判定。
```html
<x-searchable-select
name="company_id"
:options="$companies"
:selected="request('company_id')"
:placeholder="__('All Companies')"
onchange="this.form.submit()"
/>
```
```
## 8. 編輯與詳情頁規範 (Detail & Edit Views)
為了讓分層資訊更具視覺引導,各個區塊 (Section) 的圖示應採用不同的顏色意象。
### 區塊圖示色彩意象 (Section Icon Palette)
- **基本資訊 (Basic Info)**: **翠綠色 (`Emerald`)**。代表核心、穩定與起點。
- 樣式: `bg-emerald-500/10 text-emerald-500`
- **硬體/插槽設定**: **琥珀色 (`Amber/Orange`)**。代表動作、物理連接與硬體警告。
- 樣式: `bg-amber-500/10 text-amber-500`
- **系統/進階設定**: **靛藍色 (`Indigo`)**。代表邏輯、權限與深層配置。
- 樣式: `bg-indigo-500/10 text-indigo-500`
- **危險/移除動作**: **玫瑰紅 (`Rose`)**。代表破壞性操作。
- 樣式: `bg-rose-500/10 text-rose-500`
## 9. 資料表格規範 (Data Tables)
為了確保管理後台資料的可讀性與精密感,表格內的所有文字級別必須對齊以下規範:
### 文字大小與權重 (Typography Hierarchy)
- **表頭 (Table Header)**:
- 類別: `text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em]`
- 作用: 提供清晰的欄位定義而不奪取資料視覺焦點。具備足夠對比度。
- **主標題 (Primary Item)**:
- 類別: `text-base font-extrabold text-slate-800 dark:text-slate-100`
- 範例: 公司名稱、機體名稱。
- **次要資訊 (Secondary Info)**:
- 類別: `text-xs font-bold text-slate-500 dark:text-slate-400 tracking-wide`
- 範例: 使用者帳號、備註、權限名稱。
- **狀態標籤 (Status Badge)**:
- 範例: 啟用 (`emerald`)、禁用 (`rose`) / 角色名稱 (`sky`/`indigo`)。
- 特性: `px-2.5 py-1 rounded-lg text-xs font-bold border tracking-wider`
### 空間與反應 (Spacing & Interaction)
- **單元格內距**: 統一使用 `px-6 py-6`
- **懸停反應**: 必須在 `tr` 套用 `group` 且子<E4B894>### 9.4 標竿刪除確認模式 (Luxury Delete Modal Pattern)
當執行刪除或具備破壞性的操作時,**禁止**使用瀏覽器原生 `confirm()` 或簡易的 `x-modal`。全站統一使用 **`<x-delete-confirm-modal />`** Blade 組件進行二次確認。
1. **參數配置**:
- `title`: (選填) 預設為「確認刪除」。
- `message`: (選填) 定義具體的刪除警告訊息(例如「您確定要永久刪除此帳號嗎?」)。
2. **視覺特徵**:
- **背景**: `bg-slate-900/60 backdrop-blur-sm`
- **容器**: `rounded-3xl shadow-2xl animate-luxury-in`
- **圖示**: 警告圖示使用 `bg-amber-100/10 text-amber-600`
- **按鈕**: 刪除按鈕使用 `bg-rose-500` 搭配 `shadow-rose-200` 投影,取消按鈕使用 `bg-slate-100`
3. **交互規範**:
- **禁止斜體 (No Italics)**: 彈窗標題與按鈕文字嚴禁使用 `italic`,保持直挺專業感。
```html
<!-- 使用範例 -->
<x-delete-confirm-modal :message="__('Are you sure you want to delete this account?')" />
```
## 10. 系統兼容性與標準化 (Compatibility & Standardization)
為了確保在不同版本的開發環境中(如目前專案使用的 Tailwind CSS v3.1UI 都能正確呈現,並維持全站操作感一致,必須遵守以下額外規範。
### Tailwind CSS 版本兼容性 (v3.1)
- **禁止使用 `size-` 屬性**: 舊版不支援 `size-4` 等語法,請一律分拆寫作 `w-4 h-4`
- **避免非標準間距**: 避免使用 `4.5` (`18px`) 等任意值,優先使用標準等級如 `4` (`16px`) 或 `5` (`20px`)。
## 11. 字體與技術資訊規範 (Typography & Technical Data)
為了確保全站「次要資訊」具備極一致的高級感,必須遵守以下「機台標竿」規範:
### 核心樣式級別 (Core Typography Scale)
| 資訊類型 | 客戶/配置名稱 (標題) | 技術代碼 (ID, SN, Code) | 清單時間 (Timestamps) | 分隔符號 (•) |
| :--- | :--- | :--- | :--- | :--- |
| **字體族** | `font-sans` (Plus Jakarta Sans) | `font-mono` (微縮型單雙格) | `font-mono` (或 `sans` 視場景) | `font-sans` |
| **尺寸** | `text-base` | `text-xs` (不可使用 10px) | `text-xs` | `text-xs` |
| **字重** | `font-extrabold` (800) | `font-bold` (700) | `font-black` (900) | `font-bold` |
| **字距** | `tracking-tight` (-0.02em) | `tracking-widest` (最寬) | `tracking-widest` | `tracking-normal` |
| **格式** | 保持原始名稱 | `uppercase` (強制大寫) | `uppercase` | N/A |
| **色彩** | `slate-900` / `slate-100` | `slate-500` / `slate-400` | `slate-400` / `slate-400/80` | `slate-300` / `slate-700` |
### 實作禁忌與準則
- **禁止斜體 (No Italics)**: 名稱欄位嚴禁附帶 `italic`(特別是標題或配置名稱),保持直挺專業感。
- **作用範圍 (Mono Scoping)**: `font-mono` 僅限作用於「純英文/數字」的代碼。Email 或分隔點必須回歸 `font-sans` 以確保圓潤。
- **權重載入 (Font Weights)**: 確保 HTML Header 載入了 `800``900` 權重,避免瀏覽器模擬出的假粗體。
- **清單資訊密度**: 對於高密度清單中的時間資訊,應優先使用 `font-black``tracking-widest` 來建立明確的「標籤感」,而非僅僅是「微縮文字」。
---
## 12. 提示與告警規範 (Alerts & Notifications)
為了確保全站操作回饋的一致性與專業感,所有系統內部的提示(成功、錯誤、警告)必須遵循以下規範。
### 1. 懸浮式自動消失提示 (Auto-hiding Toasts)
- **視覺樣式**:
- 位置: 固定於畫面上方中央 (`fixed top-8 left-1/2 -translate-x-1/2`)。
- 特效: 毛玻璃背景 (`backdrop-blur-xl`)、圓角 (`rounded-2xl`)、軟陰影。
- 動畫: 滑入 (`translate-y-0`) / 滑出 (`-translate-y-4`),配合 `opacity` 變化。
- **型態定義**:
- **Success (成功)**: 使用 `emerald` 色系。
- **Error (錯誤)**: 使用 `rose` 色系。
- **時長規範**:
- 成功提示: 3 秒後消失。
- 錯誤提示: 5 秒後消失(提供使用者更多閱讀錯誤原因的時間)。
- **組件實作**: 統一調用 `<x-toast />`
### 2. 視窗內操作警告 (Inline Action Warnings)
- **適用場景**: 在 Modal 或編輯頁面中,提示可能導致風險的操作(如編輯自身角色)。
- **視覺樣式**:
- 背景: `bg-amber-500/10` (琥珀色)。
- 邊框: `border-amber-500/20`
- 進場動畫: `animate-luxury-in`
- **實作範例**:
```html
<div class="p-5 bg-amber-500/10 border border-amber-500/20 text-amber-600 rounded-2xl flex items-start gap-4 animate-luxury-in font-bold">
<!-- Icon & Text -->
</div>
```
### 3. 通用豪華確認與告警視窗 (General Luxury Modals)
**統一準則**: 所有的系統確認 (Confirm) 或重要告警 (Alert/Warning) **必須** 捨棄 `x-modal` 組件,改用 Section 9.4 定義的自定義 Div 結構。
- **警告模式 (Warning/Alert)**:
- 僅提供「關閉/確定」一個按鈕。
- 樣式同 9.4,但隱藏刪除 Form 與相關色彩。
- **確認模式 (Confirm)**:
- 提供「取消」與「執行」兩個按鈕。
- 執行按鈕顏色視操作性質而定 (Delete: `rose`, Save/Action: `cyan`)。
---
> [!IMPORTANT]
> **開發新功能前,必須確認 `app.css` 中的 `.btn-luxury-*` 系列組件是否滿足需求。**
> 嚴禁在 Blade 中寫入大量重複的 `bg-indigo-600` 等舊式類別。
---
> [!TIP]
> 當遇到未定義的 UI 區塊時,優先參考 `admin.dashboard.blade.php` 的卡片與即時動態實作方式進行衍生。
## 13. AJAX 交互與加載規範 (AJAX Interactions & Loaders)
為了提供無刷新的 SPA 式流暢體驗,所有管理後台模組必須遵循統一的 AJAX 交互模式。
### 13.1 極奢華多環加載器 (Premium Multi-Ring Loader)
用於表格遮罩、局部更新或狀態切換。具備雙環旋轉與脈衝核心圖示。
### Type B — Tab + 列表黃金範本products/index.blade.php
```html
<!-- Loading Overlay Pattern -->
<div x-show="isLoadingTable"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] rounded-3xl" x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<!-- 外圈:快速旋轉 -->
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
<!-- 內圈:反向慢速旋轉 -->
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
<!-- 核心:脈衝圖示 -->
<div class="relative w-8 h-8 flex items-center justify-center">
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
@section('content')
<div class="space-y-2 pb-20" x-data="{ activeTab: 'main', tabLoading: null }">
<x-page-header :title="__('Module Name')" :subtitle="__('Description')">
<template x-if="activeTab === 'main'">
<a href="..." class="btn-luxury-primary flex items-center gap-2">...</a>
</template>
</x-page-header>
<x-tab-nav>
<x-tab-nav-item value="main" :label="__('Main List')" />
<x-tab-nav-item value="secondary" :label="__('Secondary')" />
</x-tab-nav>
<div x-show="activeTab === 'main'" class="luxury-card rounded-3xl p-6 animate-luxury-in relative min-h-[300px]" x-cloak>
<x-luxury-spinner show="tabLoading === 'main'" z-index="z-20" />
<div id="tab-main-container">
@include('admin.xxx.partials.tab-main')
</div>
</div>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">{{ __('Loading Data') }}...</p>
<div x-show="activeTab === 'secondary'" class="luxury-card rounded-3xl p-6 animate-luxury-in relative min-h-[300px]" x-cloak>
<x-luxury-spinner show="tabLoading === 'secondary'" z-index="z-20" />
<div id="tab-secondary-container">
@include('admin.xxx.partials.tab-secondary')
</div>
</div>
</div>
@endsection
```
> ⚠️ **Tab Partial 搜尋欄**Tab 頁面的 `@submit.prevent` 需呼叫 `handleFilterSubmit('tab_name')`,而非 `fetchPage()`。因此 Tab Partial **不使用** `<x-search-bar>`,須自行手寫 `<form @submit.prevent="handleFilterSubmit('...')">` 結構。
---
## 4. Mobile Card 黃金範本
> [!IMPORTANT]
> 所有 Mobile Card 必須嚴格遵守以下三區結構。禁止自行發明新結構。
### 卡片容器
```html
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800
bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
```
### 區域一Card Header
```html
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
{{-- 圖示 56x56hover 變 cyan --}}
<div class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800
flex items-center justify-center text-slate-400
border border-slate-200 dark:border-slate-700
group-hover:bg-cyan-500 group-hover:text-white
transition-all duration-300 overflow-hidden shadow-sm shrink-0">
{{-- img or SVG icon --}}
</div>
<div class="min-w-0">
{{-- 主標題text-base font-extrabold絕對不用 text-lg --}}
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100
truncate hover:text-cyan-600 dark:hover:text-cyan-400
transition-colors tracking-tight cursor-pointer">
主要名稱
</h3>
{{-- 副標SN/Barcode--}}
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500
uppercase tracking-widest truncate">
副標
</p>
</div>
</div>
{{-- 右側:強制用 x-status-badge禁止用小圓點 --}}
<x-status-badge :status="$item->status" size="sm" />
</div>
```
### 13.2 標準 fetchPage 實作模式
所有清單頁面應在 `x-data` 中實作 `fetchPage` 方法,並將動態內容包裹於 `#ajax-content-container` 中。
### 區域二Info Grid
```html
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Label') }}
</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300"></p>
</div>
{{-- 跨欄(如時間戳)--}}
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Last Updated') }}
</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono">
{{ $item->updated_at->format('Y-m-d H:i:s') }}
</p>
</div>
</div>
```
### 區域三Action Buttons
```html
<div class="flex items-center gap-3">
{{-- 一般動作 --}}
<button type="button" @click="..."
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl
bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400
font-bold text-xs hover:bg-cyan-500 hover:text-white
transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5">...</svg>
{{ __('Edit') }}
</button>
{{-- 危險動作 --}}
<button type="button" @click="..."
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl
bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400
font-bold text-xs hover:bg-rose-500 hover:text-white
transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5">...</svg>
{{ __('Delete') }}
</button>
</div>
```
---
## 5. RWD 斷點規則
| 元素 | 規則 |
|------|------|
| 桌面表格顯示 | `hidden xl:block` |
| Mobile Card 顯示 | `xl:hidden` |
| Card Grid 欄數 | `grid-cols-1 md:grid-cols-2 gap-6` |
| 切換點 | 統一 `xl`1280px**禁止用 `lg``2xl`** |
| Page Header 排列 | 全斷點橫向排列 (flex-row) | 組件已處理,標題與按鈕並列 |
| H1 字大小 | `text-2xl sm:text-3xl` | 組件已處理,帶有 truncate 避免擠壓 |
| Tab 列寬 | `w-full sm:w-fit`(組件已處理)|
| Tab 按鈕 | `flex-1 sm:flex-none`(組件已處理)|
### RWD 禁止事項
> [!CAUTION]
> - ❌ Mobile Card 標題用 `text-lg`(必須 `text-base font-extrabold`
> - ❌ Mobile Card 右上角用小圓點代替 `x-status-badge`
> - ❌ 操作按鈕不加 `flex-1`
> - ❌ Mobile Card 圓角用 `rounded-3xl`(應為 `rounded-[2rem]`
> - ❌ Info Grid 欄位標籤用 `text-xs`(應為 `text-[10px]`
> - ❌ 桌面/手機切換用 `lg` 斷點
---
## 6. 表單元件
```html
{{-- 輸入框 --}}
<input type="text" class="luxury-input py-2.5 pl-11 pr-4 block w-full sm:w-72 text-sm">
{{-- 可搜尋下拉(選項 > 10 筆時)--}}
<x-searchable-select
name="company_id"
:options="$companies"
:selected="request('company_id')"
:placeholder="__('All Companies')"
/>
{{-- 下拉選單遮擋處理範例 --}}
<div class="relative focus-within:z-20">
<label>...</label>
<x-searchable-select ... />
</div>
```
---
## 7. 提示與告警
| 類型 | 組件 |
|------|------|
| 成功/錯誤 Toast | `<x-toast />` |
| 刪除確認 Modal | `<x-delete-confirm-modal />` |
| 頁面內警告(琥珀色)| `<div class="p-5 bg-amber-500/10 border border-amber-500/20 text-amber-600 rounded-2xl flex items-start gap-4 font-bold">` |
### 7.1 Modal 實作規範(強制)
> [!CAUTION]
> **嚴禁使用 Preline 的 `hs-overlay` 系統**來實作 Modal。
> 本專案全部 Modal 統一使用 **Alpine.js** (`x-show` + `x-transition`) 實作。
> 使用 `hs-overlay` class 會導致 Modal 出現但完全透明(`opacity: 0`),因為 Preline overlay 的動畫 class 在此專案中未被正確初始化。
**正確Alpine.js Modal 標準結構(參考 `modal-replenishment-create.blade.php`**
```html
{{-- 1. 觸發按鈕:用 $dispatch 發送 window 事件 --}}
<button type="button" @click="$dispatch('open-xxx-modal')">開啟</button>
{{-- 2. Modal 主體:放在 layout 底部x-data 宣告 Alpine state --}}
<div x-data="{ showModal: false }" @open-xxx-modal.window="showModal = true" x-cloak>
<div x-show="showModal" class="fixed inset-0 z-[110] overflow-y-auto" style="display: none;">
<div class="flex items-center justify-center min-h-screen px-4 ...">
{{-- Backdrop --}}
<div x-show="showModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"
@click="showModal = false"></div>
{{-- Modal 內容 --}}
<div x-show="showModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="relative inline-flex flex-col align-bottom bg-white dark:bg-slate-900
rounded-[2.5rem] text-left shadow-2xl transform
sm:my-8 sm:align-middle sm:max-w-lg w-full
border border-slate-100 dark:border-slate-800 z-10"
@click.stop>
{{-- Header --}}
<div class="px-10 py-8 pb-4 flex items-center justify-between">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">
標題
</h3>
<button @click="showModal = false"
class="p-2.5 rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-slate-600 transition-all border border-slate-100 dark:border-slate-700">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{-- Body / Footer --}}
<div class="px-10 py-6">...</div>
<div class="px-10 py-6 border-t border-slate-100 dark:border-slate-800/50 flex justify-end gap-4">
<button @click="showModal = false" class="btn-luxury-ghost px-8">取消</button>
<button type="submit" class="btn-luxury-primary px-12">確認</button>
</div>
</div>
</div>
</div>
</div>
```
**跨元件開啟 Modaldropdown 內的按鈕):**
- 按鈕在 Alpine.js dropdown 內時,需先關閉 dropdown 再發送事件:
```html
<button @click="open = false; $dispatch('open-xxx-modal')">切換帳號</button>
```
- Modal 透過 `@open-xxx-modal.window` 接收(`window` 層級才能跨 Alpine scope 傳遞)
## 8. AJAX fetchPage 標準實作
```javascript
async fetchPage(url) {
@ -374,12 +490,12 @@ async fetchPage(url) {
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContent = doc.querySelector('#ajax-content-container');
if (newContent) {
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
window.history.pushState({}, '', url);
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
}
} catch (e) {
console.error('AJAX Load Failed:', e);
@ -389,49 +505,46 @@ async fetchPage(url) {
}
```
### 13.3 觸發 AJAX 導航
- **搜尋表單**: 使用 `@submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())"`
- **下拉選單**: 使用 `onchange="this.dispatchEvent(new CustomEvent('ajax:navigate', { detail: { url: ... }, bubbles: true }))"`
- **分頁/連結**: 在容器上使用代理監聽,或直接調用 `fetchPage(url)`
> **分頁**`{{ $items->appends(request()->query())->links('vendor.pagination.luxury') }}`
## 14. 頁面佈局與頁籤間距規範 (Page Layout & Tab Spacing)
---
為了確保管理後台全站視覺的「高度整合感」與「緊湊性」,頁面頂部結構與頁籤導覽必須遵循以下間距規範。
## 9. 區塊圖示色彩(詳情/編輯頁)
### 14.1 核心佈局間距 (Core Spacing Scale)
- **根容器間距 (Root Space)**: 頂部 Header、Tabs 與內容區域之間的垂直間距統一使用 **`space-y-2`**。
- **標題與描述 (Title & Subtitle)**: 描述文字應緊貼標題,使用 `mt-1`
- **頁籤外層間距 (Tabs Outer)**: 頁籤容器與 Header 之間應由根容器的 `space-y-2` 自然推開,不額外加 `mt`
| 區塊 | 色系 | Class |
|------|------|-------|
| 基本資訊 | 翠綠 Emerald | `bg-emerald-500/10 text-emerald-500` |
| 硬體/插槽 | 琥珀 Amber | `bg-amber-500/10 text-amber-500` |
| 系統/進階 | 靛藍 Indigo | `bg-indigo-500/10 text-indigo-500` |
| 危險/刪除 | 玫瑰 Rose | `bg-rose-500/10 text-rose-500` |
---
### 14.2 頁籤導覽樣式 (Tab Navigation Styles)
- **容器內距**: 使用 `p-1.5` 的圓角膠囊背景。
- **按鈕內距**: 頁籤按鈕統一使用 **`px-8 py-3`**。
- **內容間隙**: 頁籤下方內容區與頁籤列之間,建議不額外添加 `mt-6` 等大間距。若內容區域內部需要層次感,使用 `space-y-3` 即可。
## 10. Z-Index 堆疊與遮擋處理 (Z-Index Stacking)
### 14.3 實作範例結構 (Reference Structure)
> [!IMPORTANT]
> 當頁面或 Modal 中存在多個垂直排列的 x-searchable-select 時,為防止選單開展時被下一個欄位遮擋,嚴禁使用固定的 z-index。
### 10.1 動態層級提升模式
必須在欄位容器上使用 focus-within:z-[index],確保正在操作的欄位永遠處於堆疊最前方。
| 使用場景 | 推薦 Class |
|---------|------------|
| 一般表單頁面 | relative focus-within:z-[20] |
| 模態框 (Modal) 內 | relative focus-within:z-[60] |
### 10.2 代碼範例
```html
<div class="space-y-2">
<!-- Header Area -->
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-black">標題</h1>
<p class="text-sm mt-1">描述文字</p>
</div>
<div class="space-y-4">
<!-- 型號選單 -->
<div class="relative focus-within:z-[60]">
<label>型號</label>
<x-searchable-select ... />
</div>
<!-- Tabs Container -->
<div class="flex items-center p-1.5 bg-slate-100 dark:bg-slate-900/40 rounded-2xl w-fit border border-slate-200/50 dark:border-white/5">
<button class="px-8 py-3 rounded-xl text-sm font-black active">Tab A</button>
<button class="px-8 py-3 rounded-xl text-sm font-black">Tab B</button>
</div>
<!-- Content Area (AJAX Container) -->
<div class="space-y-3">
<!-- 內容卡片 -->
<!-- 公司選單 -->
<div class="relative focus-within:z-[60]">
<label>公司</label>
<x-searchable-select ... />
</div>
</div>
```
### 14.4 統計卡片間距 (Stats Grid Spacing)
- **網格間距**: 頂部統計卡片網格 (Grid) 建議使用 **`gap-4`** (16px),避免使用過大的 `gap-6``gap-8` 導致版面鬆散。
- **卡片內距**: 統計卡片內部統一使用 `p-6`

View File

@ -84,4 +84,5 @@ MQTT_OUTGOING_QUEUE=mqtt_outgoing_commands
# EMQX Ports (for local dev)
MQTT_PORT=1883
MQTT_SSL_PORT=8883
EMQX_DASHBOARD_PORT=18083

View File

@ -7,7 +7,7 @@ on:
jobs:
deploy-demo:
runs-on: ubuntu-latest # 使用機器人預設宣告的標籤
runs-on: [ubuntu-latest, demo]
steps:
- name: Checkout Code
uses: actions/checkout@v3
@ -34,7 +34,6 @@ jobs:
docker run --rm -v /workspace/mama/star-cloud:/target busybox mkdir -p /target/docker/emqx
cat docker/emqx/acl.conf | docker run -i --rm -v /workspace/mama/star-cloud:/target busybox sh -c "cat > /target/docker/emqx/acl.conf"
- name: Step 1 - Build & Deploy Containers
run: |
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml up -d --build --wait
@ -81,4 +80,4 @@ jobs:
docker restart star-cloud-mqtt-worker
- name: Step 5 - Cleanup
run: docker image prune -f
run: docker image prune -f --filter "until=168h"

View File

@ -7,15 +7,120 @@ on:
jobs:
deploy-production:
runs-on: ubuntu-latest
runs-on: [ubuntu-latest, prod]
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
github-server-url: https://gitea.setdomains.work
repository: ${{ github.repository }}
- name: Step 1 - Push Code to Production
- name: Step 0 - Prepare Environment & Sync Configs
run: |
echo "Production deployment is currently in preparation..."
# 待正式環境資料確定後,再補上 rsync 與 SSH 邏輯
# 1. 確保結構正確
mkdir -p docker/emqx
# 2. 檢查並清理宿主機 (Host) 上被 Docker 誤建為目錄的檔案
# 生產環境路徑為 ~/star-cloud (對應絕對路徑 /home/twsystem1003/star-cloud)
docker run --rm -v /home/twsystem1003/star-cloud:/target busybox sh -c "
if [ -d /target/.env ]; then echo 'Removing .env directory on host'; rm -rf /target/.env; fi
if [ -d /target/docker/emqx/acl.conf ]; then echo 'Removing acl.conf directory on host'; rm -rf /target/docker/emqx/acl.conf; fi
"
# 3. 從宿主機載入持久化的 .env 檔案
docker run --rm -v /home/twsystem1003/star-cloud:/data:ro busybox cat /data/.env > .env || cp .env.example .env
# 4. 將 Runner 最新 Checkout 的設定檔同步至宿主機持久區
docker run --rm -v /home/twsystem1003/star-cloud:/target busybox mkdir -p /target/docker/emqx
cat docker/emqx/acl.conf | docker run -i --rm -v /home/twsystem1003/star-cloud:/target busybox sh -c "cat > /target/docker/emqx/acl.conf"
- name: Step 1 - Ensure Existing Containers Are Running
run: |
for container in \
star-cloud-mysql \
star-cloud-redis \
star-cloud-emqx
do
if ! docker inspect "${container}" >/dev/null 2>&1; then
echo ">>> Required container is missing: ${container}"
exit 1
fi
echo ">>> Ensuring ${container} is running..."
docker start "${container}" >/dev/null
done
for container in star-cloud-mysql star-cloud-redis star-cloud-emqx; do
echo ">>> Waiting for ${container} to become healthy..."
for i in $(seq 1 60); do
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container}" 2>/dev/null || echo "missing")
if [ "${status}" = "healthy" ]; then
echo ">>> ${container} is healthy."
break
fi
if [ "${i}" -eq 60 ]; then
echo ">>> ${container} did not become healthy. Current status: ${status}"
docker logs --tail 80 "${container}" || true
exit 1
fi
sleep 2
done
done
for container in \
star-cloud-laravel \
star-cloud-queue \
star-cloud-mqtt-worker \
star-cloud-mqtt-gateway
do
if ! docker inspect "${container}" >/dev/null 2>&1; then
echo ">>> Required container is missing: ${container}"
exit 1
fi
echo ">>> Ensuring ${container} is running..."
docker start "${container}" >/dev/null
done
- name: Step 2 - Deploy Code
run: |
echo ">>> Syncing code to star-cloud-laravel..."
tar --exclude='.git' --exclude='node_modules' --exclude='vendor' \
--exclude='storage' --exclude='bootstrap/cache' \
-cf /tmp/deploy.tar . || [ $? -le 1 ]
docker exec -i star-cloud-laravel tar -xf - -C /var/www/html/ < /tmp/deploy.tar
rm -f /tmp/deploy.tar
# 生產環境使用 1001:1001
docker exec star-cloud-laravel sh -c "
chown -R 1001:1001 /var/www/html &&
mkdir -p /.npm && chown -R 1001:1001 /.npm &&
rm -rf /var/www/html/node_modules
"
- name: Step 3 - Composer & NPM & Initialization
run: |
docker exec -u 1001:1001 -w /var/www/html star-cloud-laravel sh -c "
composer install --no-dev --optimize-autoloader --no-interaction &&
npm install &&
npm run build &&
php artisan migrate --force &&
php artisan storage:link &&
php artisan optimize:clear &&
php artisan optimize &&
php artisan view:cache &&
php artisan queue:restart &&
php artisan db:seed --class=RoleSeeder --force &&
php artisan db:seed --class=AdminUserSeeder --force
"
docker exec star-cloud-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
- name: Step 4 - Restart Long-running Workers
run: |
docker restart star-cloud-mqtt-worker
echo "✅ MQTT Worker 已重啟"
- name: Step 5 - Cleanup
run: docker image prune -f --filter "until=168h"

View File

@ -1,114 +0,0 @@
name: star-cloud-deploy-demo
on:
workflow_dispatch:
jobs:
deploy-demo:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
github-server-url: https://gitea.setdomains.work
repository: ${{ github.repository }}
- name: Step 1 - Push Code to Demo
run: |
apt-get update && apt-get install -y rsync openssh-client
mkdir -p ~/.ssh
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
chmod 600 ~/.ssh/id_rsa_demo
rsync -avz --delete \
--exclude='/.git' \
--exclude='/node_modules' \
--exclude='/vendor' \
--exclude='/storage' \
--exclude='/.env' \
--exclude='/public/build' \
-e "ssh -p 22 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
./ mama@demo-sc-103:/workspace/mama/star-cloud/
rm ~/.ssh/id_rsa_demo
- name: Step 2 - Check if Rebuild Needed
id: check_rebuild
uses: appleboy/ssh-action@master
with:
host: demo-sc-103
port: 22
username: mama
key: ${{ secrets.DEMO_SSH_KEY }}
script: |
cd /workspace/mama/star-cloud
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
echo "REBUILD_NEEDED=true"
else
echo "REBUILD_NEEDED=false"
fi
- name: Step 3 - Container Up & Health Check
uses: appleboy/ssh-action@master
with:
host: demo-sc-103
port: 22
username: mama
key: ${{ secrets.DEMO_SSH_KEY }}
script: |
cd /workspace/mama/star-cloud
chown -R 1000:1000 .
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --build --wait
else
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
if ! docker ps --format '{{.Names}}' | grep -q 'star-cloud-demo-laravel'; then
echo "容器未運行,正在啟動..."
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --wait
else
echo "容器已運行,跳過 docker compose直接進行程式碼部署..."
fi
fi
echo "容器狀態:" && docker ps --filter "name=star-cloud-demo"
- name: Step 4 - Composer & NPM Build
uses: appleboy/ssh-action@master
with:
host: demo-sc-103
port: 22
username: mama
key: ${{ secrets.DEMO_SSH_KEY }}
script: |
docker exec -u 1000:1000 -w /var/www/html star-cloud-demo-laravel sh -c "
# 1. 後端依賴
composer install --no-dev --optimize-autoloader --no-interaction &&
# 2. 前端編譯
npm install &&
npm run build &&
# 3. Laravel 初始化與優化
php artisan migrate --force &&
php artisan storage:link &&
php artisan optimize:clear &&
php artisan optimize &&
php artisan view:cache &&
php artisan queue:restart &&
php artisan db:seed --class=RoleSeeder --force &&
php artisan db:seed --class=AdminUserSeeder --force
"
docker exec star-cloud-demo-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
- name: Step 5 - Auto Sync workflows to main
run: |
git config --global user.email "bot@setdomains.work"
git config --global user.name "CICD Bot"
git fetch origin main
git checkout main
git checkout ${{ github.ref_name }} -- .gitea/workflows/
if ! git diff --cached --quiet; then
git commit -m "[AUTO] Sync workflows from ${{ github.ref_name }} to main"
GIT_SSH_COMMAND="ssh -p 3222 -o StrictHostKeyChecking=no" git push origin main
fi
git checkout ${{ github.ref_name }}

6
.gitignore vendored
View File

@ -20,3 +20,9 @@ yarn-error.log
/docs/API
/docs/*.xlsx
/docs/pptx
/scratch
/.antigravitycli
# Claude Code 個人設定(權限授權等,不進版控)
/.claude/settings.json
/.claude/settings.local.json

16
CLAUDE.md Normal file
View File

@ -0,0 +1,16 @@
# star-cloud — 專案開發規範Claude Code
> 本專案的開發規範與 Antigravity 共用**同一份來源**`.agents/rules/`。
> 下方以 `@` import 載入,避免重複維護。**要修改規範請直接編輯 `.agents/rules/` 內的檔案**,不要改這裡。
## 一律遵循的專案規範always-on
@.agents/rules/framework.md
@.agents/rules/api-rules.md
@.agents/rules/rbac-rules.md
@.agents/rules/skill-trigger.md
## Claude 專屬補充
- `.agents/rules/``skill-trigger.md` 原為 Antigravity 撰寫,其中提到的 `view_file` 工具,在 Claude 對應為 `Read``.agents/skills/` 的 SKILL.md 可用 `Read` 讀取後依其指引作業。
- 提交訊息規範(繁體中文、`[FEAT]/[FIX]/[DOCS]/[STYLE]/[REFACTOR]` 前綴 + 數字列表與環境晉升dev → demo → main流程已封裝為使用者層自訂指令 `/now-push`、`/start-task`、`/promote-env`、`/hotfix-prod`,需要時可直接呼叫。

View File

@ -9,6 +9,7 @@ use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessTransaction;
use App\Jobs\Machine\ProcessStatus;
use App\Jobs\Machine\ProcessMachineEvent;
use App\Jobs\Transaction\ProcessInvoice;
use App\Jobs\Transaction\ProcessDispenseRecord;
use App\Jobs\Machine\ProcessCommandAck;
@ -71,6 +72,14 @@ class ListenMqttQueue extends Command
$serialNo = $data['serial_no'] ?? '';
$payload = $data['payload'] ?? [];
// 如果 payload 是 JSON 字串,則解析它
if (is_string($payload)) {
$decoded = json_decode($payload, true);
if (json_last_error() === JSON_ERROR_NONE) {
$payload = $decoded;
}
}
if (empty($serialNo)) {
Log::warning("MQTT Listen: Missing serialNo in message", [
'type' => $type,
@ -86,9 +95,16 @@ class ListenMqttQueue extends Command
$finalPayload = is_array($payload) ? $payload : ['raw_data' => $payload];
ProcessHeartbeat::dispatch($serialNo, $finalPayload);
break;
case 'ambient_temp':
$finalPayload = is_array($payload) ? $payload : ['temperature' => $payload];
\App\Jobs\Machine\ProcessAmbientTemp::dispatch($serialNo, $finalPayload);
break;
case 'status':
ProcessStatus::dispatch($serialNo, $payload);
break;
case 'event':
ProcessMachineEvent::dispatch($serialNo, $payload);
break;
case 'error':
ProcessMachineError::dispatch($serialNo, $payload);
break;
@ -113,7 +129,22 @@ class ListenMqttQueue extends Command
*/
protected function dispatchTransactionByAction(string $serialNo, array $payload): void
{
$action = $payload['action'] ?? 'create';
Log::debug("MQTT Transaction incoming payload", [
'serial_no' => $serialNo,
'payload' => $payload
]);
$rawAction = $payload['action'] ?? 'create';
$action = strtolower(trim($rawAction));
// 超級偵錯:檢查 action 字串的每一個位元組
$hex = bin2hex($rawAction);
Log::debug("MQTT Action Debug", [
'raw' => $rawAction,
'hex' => $hex,
'processed' => $action,
'match_finalize' => ($action === 'finalize' ? 'YES' : 'NO')
]);
switch ($action) {
case 'invoice':
@ -130,6 +161,12 @@ class ListenMqttQueue extends Command
Log::info("MQTT Transaction [dispense] dispatched", ['serial_no' => $serialNo]);
break;
case 'finalize':
// 新增B600/B601/B602 統整上報
\App\Jobs\Transaction\ProcessTransactionFinalized::dispatch($serialNo, $payload);
Log::info("MQTT Transaction [finalize] dispatched", ['serial_no' => $serialNo]);
break;
case 'create':
default:
// B600: 交易建立(既有邏輯,向後相容無 action 欄位的舊格式)

View File

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands\Machine;
use Illuminate\Console\Command;
use App\Models\Machine\OtaSchedule;
use App\Models\Machine\Machine;
use App\Models\Machine\RemoteCommand;
use App\Services\Machine\MqttService;
use Exception;
use Illuminate\Support\Facades\Log;
class ProcessOtaSchedules extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ota:process-schedules';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Process scheduled OTA firmware updates and send MQTT commands to target machines';
/**
* Execute the console command.
*/
public function handle(MqttService $mqttService)
{
$this->info('[' . now()->toDateTimeString() . '] Starting to process scheduled OTA updates...');
// 撈取狀態為待發送且時間已到的排程
$schedules = OtaSchedule::where('status', 'pending')
->where('scheduled_at', '<=', now())
->with('apkVersion')
->get();
if ($schedules->isEmpty()) {
$this->info('No pending OTA schedules to process.');
return 0;
}
$this->info("Found {$schedules->count()} pending OTA schedule(s) to process.");
foreach ($schedules as $schedule) {
$this->info("Processing OTA Schedule ID: {$schedule->id} (APK: {$schedule->apkVersion->version_name})");
// 標記為處理中,防止重複執行
$schedule->update(['status' => 'processing']);
$apkVersion = $schedule->apkVersion;
try {
// 根據 target_type 撈取目標機台
if ($schedule->target_type === 'all') {
$machines = Machine::all();
} else {
$machineIds = $schedule->target_value ?? [];
$machines = Machine::whereIn('id', $machineIds)->get();
}
if ($machines->isEmpty()) {
$this->warn("No target machines found for OTA Schedule ID: {$schedule->id}. Skipping.");
$schedule->update(['status' => 'completed']);
continue;
}
$successCount = 0;
foreach ($machines as $machine) {
// 1. 先建立 RemoteCommand 紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => $schedule->user_id,
'command_type' => 'update_app',
'status' => 'pending',
'payload' => [
'url' => $apkVersion->url,
'version' => $apkVersion->version_name,
'version_code' => $apkVersion->version_code,
'is_forced' => $apkVersion->is_forced,
],
]);
// 2. 推播 MQTT 更新指令
$pushed = $mqttService->pushCommand(
$machine->serial_no,
'update_app',
$command->payload,
(string) $command->id
);
if ($pushed) {
$successCount++;
} else {
$command->update(['status' => 'failed']);
$this->error("Failed to push MQTT OTA command to machine: {$machine->serial_no}");
}
}
$schedule->update(['status' => 'completed']);
$this->info("Successfully processed OTA Schedule ID: {$schedule->id}. MQTT deployed to {$successCount}/{$machines->count()} machines.");
} catch (Exception $e) {
Log::error("Error processing OTA Schedule ID {$schedule->id}: " . $e->getMessage(), [
'exception' => $e
]);
$schedule->update(['status' => 'failed']);
$this->error("Error processing OTA Schedule ID {$schedule->id}: " . $e->getMessage());
}
}
$this->info('OTA schedules processing completed.');
return 0;
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use App\Models\Transaction\Invoice;
use App\Services\Invoice\EcpayInvoiceService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 電子發票對帳:掃 pending 發票,去綠界 GetIssue 查真實狀態。
*
* 機台開票回應掉包時 status 會停在 pending不能當失敗否則重開會重複
* 本命令以 RelateNumber 向綠界查詢確認:已開→補登 issued確認未開→標 failed待補開
* 僅處理機台有綠界發票設定者;未設定者一律略過,未開發票的機台零接觸。
*/
class ReconcileInvoicesCommand extends Command
{
protected $signature = 'invoices:reconcile {--limit=100 : 單次處理筆數上限} {--max-retry=12 : 單張最多查詢次數}';
protected $description = '對 pending 電子發票向綠界 GetIssue 查詢並補登/標記狀態';
public function handle(EcpayInvoiceService $ecpay): int
{
$limit = (int) $this->option('limit');
$maxRetry = (int) $this->option('max-retry');
$invoices = Invoice::with(['machine.paymentConfig', 'order'])
->where('status', Invoice::STATUS_PENDING)
->whereNotNull('relate_number')
->where('retry_count', '<', $maxRetry)
->orderBy('id')
->limit($limit)
->get();
if ($invoices->isEmpty()) {
$this->info('No pending invoices to reconcile.');
return self::SUCCESS;
}
$issued = 0;
$failed = 0;
$skipped = 0;
foreach ($invoices as $invoice) {
$machine = $invoice->machine;
if (!$machine || !$ecpay->configForMachine($machine)) {
// 機台無綠界設定 → 略過,不動狀態(不可能誤觸未開發票機台)
$skipped++;
continue;
}
$result = $ecpay->getIssue($machine, $invoice->relate_number);
// 連線/解密失敗 → 本輪略過,僅累加重試次數待下輪
if ($result === null) {
$invoice->update([
'retry_count' => $invoice->retry_count + 1,
'last_checked_at' => now(),
]);
$skipped++;
continue;
}
$rtnCode = (string) ($result['RtnCode'] ?? '');
$invoiceNo = $result['IIS_Number'] ?? ($result['InvoiceNumber'] ?? '');
if ($rtnCode === '1' && !empty($invoiceNo)) {
// 綠界確認已開立 → 補登
$invoice->update([
'status' => Invoice::STATUS_ISSUED,
'invoice_no' => $invoiceNo,
'invoice_date' => $this->parseDate($result['IIS_Create_Date'] ?? null),
'random_number' => $result['IIS_Random_Number'] ?? $invoice->random_number,
'rtn_code' => $rtnCode,
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
'retry_count' => $invoice->retry_count + 1,
'last_checked_at' => now(),
]);
$issued++;
Log::info('Invoice reconciled as issued', [
'invoice_id' => $invoice->id,
'invoice_no' => $invoiceNo,
]);
} else {
// 綠界查無此發票 → 確認未開立,標 failed 待補開
$invoice->update([
'status' => Invoice::STATUS_FAILED,
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
'retry_count' => $invoice->retry_count + 1,
'last_checked_at' => now(),
]);
$failed++;
}
}
$this->info("Reconcile done. issued={$issued} failed={$failed} skipped={$skipped}");
return self::SUCCESS;
}
private function parseDate(?string $raw): ?string
{
if (empty($raw)) {
return null;
}
try {
return \Carbon\Carbon::parse($raw)->toDateString();
} catch (\Throwable $e) {
return null;
}
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Models\Transaction\Order;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 把「停留在 pending 太久」的交易標記為 abandoned感應前取消 / 付款未完成)。
*
* 設計重點(過渡期安全):
* - 只動 status = 'pending' 的單。線上 main 機台(晟崴/中國醫)從不送 pending
* 後台也就不會有它們的 pending 本命令永遠掃不到它們,零接觸。
* - 付款對話框逾時約 40 秒,預設門檻給到 15 分鐘,遠大於正常付款耗時;
* 超過此門檻仍 pending代表機台沒回成功/失敗取消、斷線、App 重啟),視為 abandoned。
* - 不碰庫存、不碰金流pending 本來就還沒出貨/扣庫存。
*/
class SweepAbandonedOrdersCommand extends Command
{
protected $signature = 'orders:sweep-abandoned {--minutes=15 : 停留在 pending 超過幾分鐘視為 abandoned} {--limit=500 : 單次處理筆數上限}';
protected $description = '將逾時停留在 pending 的交易標記為 abandoned';
public function handle(): int
{
$minutes = max(1, (int) $this->option('minutes'));
$limit = max(1, (int) $this->option('limit'));
$threshold = now()->subMinutes($minutes);
$orders = Order::withoutGlobalScopes()
->where('status', Order::STATUS_PENDING)
->where('created_at', '<', $threshold)
->orderBy('id')
->limit($limit)
->get();
if ($orders->isEmpty()) {
$this->info('No stale pending orders to sweep.');
return self::SUCCESS;
}
$swept = 0;
foreach ($orders as $order) {
$order->update(['status' => Order::STATUS_ABANDONED]);
$swept++;
}
Log::info('Swept abandoned orders', ['count' => $swept, 'older_than_minutes' => $minutes]);
$this->info("Marked {$swept} pending order(s) as abandoned (older than {$minutes} min).");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Models\System\Company;
use App\Services\Product\ProductCatalogService;
use Illuminate\Console\Command;
class WarmProductsCacheCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'products:warm-cache {--company= : ID of the specific company to warm cache for}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Pre-warm the Redis cache for product catalogs of all or a specific company.';
/**
* Execute the console command.
*
* @param ProductCatalogService $catalogService
* @return int
*/
public function handle(ProductCatalogService $catalogService): int
{
$companyId = $this->option('company');
if ($companyId) {
$companies = Company::where('id', $companyId)->pluck('id');
} else {
// Only warm companies that actually have products to avoid empty cache entries
$companies = Company::whereHas('products')->pluck('id');
}
if ($companies->isEmpty()) {
$this->warn('No companies found with products to warm cache for.');
return self::SUCCESS;
}
$this->info("Starting to warm product catalog cache for {$companies->count()} companies...");
$bar = $this->output->createProgressBar($companies->count());
$bar->start();
foreach ($companies as $id) {
$catalogService->rebuildCache($id);
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info('✅ Product catalog cache warming completed!');
return self::SUCCESS;
}
}

View File

@ -13,6 +13,11 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
$schedule->command('ota:process-schedules')->everyMinute();
// 電子發票對帳:對 pending 發票去綠界查證(補登 issued / 標 failed 待補開)
$schedule->command('invoices:reconcile')->everyFiveMinutes()->withoutOverlapping();
// 交易結案掃描:把逾時停留在 pending 的單標記為 abandoned只動 pending碰不到線上 main 機台)
$schedule->command('orders:sweep-abandoned')->everyFiveMinutes()->withoutOverlapping();
}
/**

View File

@ -6,6 +6,7 @@ use App\Models\Machine\Machine;
use App\Models\Machine\MachineAdvertisement;
use App\Models\System\Advertisement;
use App\Models\System\Company;
use App\Models\System\SystemOperationLog;
use App\Traits\ImageHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -18,6 +19,9 @@ class AdvertisementController extends AdminController
{
$user = auth()->user();
$tab = $request->input('tab', 'list');
$perPage = in_array((int) $request->input('per_page', 10), [10, 25, 50, 100], true)
? (int) $request->input('per_page', 10)
: 10;
// Tab 1: 廣告列表
$query = Advertisement::with('company');
@ -35,7 +39,7 @@ class AdvertisementController extends AdminController
$query->where('company_id', $request->company_id);
}
$advertisements = $query->latest()->paginate(10);
$advertisements = $query->latest()->paginate($perPage)->withQueryString();
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
$allAds = Advertisement::playing()->get();
@ -75,41 +79,60 @@ class AdvertisementController extends AdminController
'end_at' => 'nullable|date|after_or_equal:start_at',
]);
$user = auth()->user();
$file = $request->file('file');
if ($request->type === 'image') {
$path = $this->storeAsWebp($file, 'ads');
} else {
$path = $file->store('ads', 'public');
}
try {
$user = auth()->user();
$file = $request->file('file');
if ($request->type === 'image') {
$path = $this->storeAsWebp($file, 'ads');
} else {
$path = $file->store('ads', 'public');
}
if ($user->isSystemAdmin()) {
$companyId = $request->filled('company_id') ? $request->company_id : null;
} else {
$companyId = $user->company_id;
}
if ($user->isSystemAdmin()) {
$companyId = $request->filled('company_id') ? $request->company_id : null;
} else {
$companyId = $user->company_id;
}
$advertisement = Advertisement::create([
'company_id' => $companyId,
'name' => $request->name,
'type' => $request->type,
'duration' => (int) $request->duration,
'url' => Storage::disk('public')->url($path),
'is_active' => true,
'start_at' => $request->start_at,
'end_at' => $request->end_at,
]);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Advertisement created successfully.'),
'data' => $advertisement
$advertisement = Advertisement::create([
'company_id' => $companyId,
'name' => $request->name,
'type' => $request->type,
'duration' => (int) $request->duration,
'url' => Storage::disk('public')->url($path),
'is_active' => true,
'start_at' => $request->start_at,
'end_at' => $request->end_at,
]);
}
return redirect()->back()->with('success', __('Advertisement created successfully.'));
if ($request->wantsJson()) {
session()->flash('success', __('Advertisement created successfully.'));
return response()->json([
'success' => true,
'message' => __('Advertisement created successfully.'),
'data' => $advertisement
]);
}
return redirect()->back()->with('success', __('Advertisement created successfully.'));
} catch (\Exception $e) {
\Log::error('Advertisement Upload Error: ' . $e->getMessage(), [
'user_id' => auth()->id(),
'file_name' => $request->file('file')?->getClientOriginalName(),
'trace' => $e->getTraceAsString()
]);
if ($request->wantsJson()) {
return response()->json([
'success' => false,
'message' => __('Failed to process advertisement file: ') . $e->getMessage()
], 500);
}
return redirect()->back()->with('error', __('Failed to process advertisement file.'));
}
}
public function update(Request $request, Advertisement $advertisement)
@ -163,6 +186,7 @@ class AdvertisementController extends AdminController
$advertisement->update($data);
if ($request->wantsJson()) {
session()->flash('success', __('Advertisement updated successfully.'));
return response()->json([
'success' => true,
'message' => __('Advertisement updated successfully.'),
@ -186,6 +210,7 @@ class AdvertisementController extends AdminController
$advertisement->delete();
if ($request->wantsJson()) {
session()->flash('success', __('Advertisement deleted successfully.'));
return response()->json([
'success' => true,
'message' => __('Advertisement deleted successfully.')
@ -282,4 +307,108 @@ class AdvertisementController extends AdminController
'message' => __('Assignment removed successfully.')
]);
}
/**
* 同步廣告至機台 (透過 MQTT 發送 update_ads 指令)
*/
public function syncToMachine(Request $request, Machine $machine, \App\Services\Machine\MqttService $mqttService)
{
// 併行檢查:若有 pending 指令,超過 1 分鐘視為逾時可覆蓋
$pendingCommand = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'update_ads')
->where('status', 'pending')
->latest()
->first();
if ($pendingCommand) {
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
if (!$isTimedOut) {
return response()->json([
'success' => false,
'message' => __('This machine has a pending command. Please wait.')
], 422);
}
// 超過 1 分鐘:視為逾時,自動標記舊指令
$pendingCommand->update([
'status' => 'timeout',
'note' => __('Superseded by new command (Timeout)'),
]);
}
$command = \App\Models\Machine\RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => auth()->id(),
'command_type' => 'update_ads',
'payload' => [],
'status' => 'pending',
'remark' => $request->filled('note') ? $request->input('note') : __('Manual Sync Ads'),
]);
$success = $mqttService->pushCommand(
$machine->serial_no,
'update_ads',
[],
(string) $command->id
);
if ($success) {
return response()->json([
'success' => true,
'message' => __('Sync command sent successfully.')
]);
}
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
return response()->json([
'success' => false,
'message' => __('Failed to send sync command.')
], 500);
}
public function toggleStatus($id)
{
try {
$advertisement = Advertisement::findOrFail($id);
$oldValues = $advertisement->toArray();
$advertisement->is_active = !$advertisement->is_active;
$advertisement->save();
$newValues = $advertisement->fresh()->toArray();
SystemOperationLog::create([
'company_id' => $advertisement->company_id,
'user_id' => auth()->id(),
'module' => 'advertisement',
'action' => 'update',
'target_id' => $advertisement->id,
'target_type' => Advertisement::class,
'note' => 'advertisement_status_toggled',
'old_values' => $oldValues,
'new_values' => $newValues,
]);
$status = $advertisement->is_active ? __('Enabled') : __('Disabled');
if (request()->ajax()) {
session()->flash('success', __('Advertisement status updated to :status', ['status' => $status]));
return response()->json([
'success' => true,
'message' => __('Advertisement status updated to :status', ['status' => $status]),
'is_active' => $advertisement->is_active
]);
}
return redirect()->back()->with('success', __('Advertisement status updated to :status', ['status' => $status]));
} catch (\Exception $e) {
if (request()->ajax()) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
return redirect()->back()->with('error', $e->getMessage());
}
}
}

View File

@ -16,21 +16,634 @@ class AnalysisController extends Controller
]);
}
// 機台報表分析
public function machineReports()
public function machineReports(Request $request)
{
return view('admin.placeholder', [
$user = auth()->user();
$companyId = $user->company_id;
// 1. 取得篩選參數
$selectedCompanyId = $request->input('company_id');
$selectedMachineId = $request->input('machine_id', 'ALL');
$selectedProductId = $request->input('product_id', 'ALL');
$paymentTypes = $request->input('payment_types', []);
$dateRange = $request->input('date_range');
if (!is_array($paymentTypes)) {
$paymentTypes = $paymentTypes ? explode(',', $paymentTypes) : [];
}
$paymentTypes = array_filter(array_map('intval', $paymentTypes));
// 2. 解析日期區間
if ($dateRange) {
$dates = explode(' to ', $dateRange);
if (count($dates) === 2) {
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
$endDate = \Illuminate\Support\Carbon::parse($dates[1])->endOfDay();
} else {
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
$endDate = \Illuminate\Support\Carbon::parse($dates[0])->endOfDay();
}
} else {
// 預設為最近 7 天 (含今日)
$startDate = now()->subDays(6)->startOfDay();
$endDate = now()->endOfDay();
$dateRange = $startDate->format('Y-m-d') . ' to ' . $endDate->format('Y-m-d');
}
// 3. 租戶隔離:非系統管理員強制限制為其所屬公司
if (!$user->isSystemAdmin()) {
$effectiveCompanyId = $companyId;
} else {
$effectiveCompanyId = $selectedCompanyId;
}
// 取得使用者授權的機台 ID 範圍 (一般用戶)
$allowedMachineIds = null;
if (!$user->isSystemAdmin()) {
$allowedMachineIds = \App\Models\Machine\Machine::pluck('id')->toArray();
}
// 4. 加載下拉選單選項
// 公司選單 (僅限 Super Admin)
$companies = [];
if ($user->isSystemAdmin()) {
$companies = \App\Models\System\Company::active()->get(['id', 'name']);
}
// 機台選單 (自動套用多租戶與授權隔離)
$machinesQuery = \App\Models\Machine\Machine::query();
if ($effectiveCompanyId) {
$machinesQuery->where('company_id', $effectiveCompanyId);
}
$machines = $machinesQuery->get(['id', 'name', 'serial_no']);
// 商品選單
$productsQuery = \App\Models\Product\Product::query();
if ($effectiveCompanyId) {
$productsQuery->where('company_id', $effectiveCompanyId);
}
$products = $productsQuery->active()->get(['id', 'name', 'barcode']);
// 5. 支付工具選項 (使用者指定之 11 種)
$availablePaymentTypes = [
1 => __('Credit Card'),
2 => __('E-Ticket (EasyCard/iPass)'),
3 => __('QR Code Payment - E.SUN'), // 掃碼支付-玉山
4 => __('Bill Acceptor'),
5 => __('Pass Code'),
6 => __('Pickup Code'),
7 => __('Welcome Gift'),
8 => __('Questionnaire'),
9 => __('Coin Acceptor'),
10 => __('Mobile Pay'), // NFC 手機感應(手機支付)
11 => __('QR Code Payment - TayPay'), // 掃碼支付-TayPay
41 => __('Staff Card'),
42 => __('Pickup Voucher'),
];
// 子查詢:取得每個 order_id + product_id 的銷售單價
$itemPriceQuery = \Illuminate\Support\Facades\DB::table('order_items')
->select([
'order_id',
'product_id',
\Illuminate\Support\Facades\DB::raw('MIN(price) as unit_price')
])
->groupBy('order_id', 'product_id');
// 6. 執行主報表統計 SQL (按機台分組)
// 子查詢 A營業額 (計算機台實收)
$salesQuery = \Illuminate\Support\Facades\DB::table('orders as o')
->select([
'o.machine_id',
\Illuminate\Support\Facades\DB::raw('SUM(o.pay_amount) as total_sales')
])
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
// 套用篩選條件到營業額子查詢
if ($effectiveCompanyId) {
$salesQuery->where('o.company_id', $effectiveCompanyId);
}
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$salesQuery->whereRaw('1 = 0');
} else {
$salesQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$salesQuery->where('o.machine_id', $selectedMachineId);
}
if (!empty($paymentTypes)) {
$salesQuery->whereIn('o.payment_type', $paymentTypes);
}
// 如果篩選特定商品,營業額只計算有成功出貨的該商品金額
if ($selectedProductId && $selectedProductId !== 'ALL') {
$salesQuery->join('dispense_records as dr', function($join) {
$join->on('dr.order_id', '=', 'o.id')
->where('dr.dispense_status', '=', 1);
})
->leftJoinSub($itemPriceQuery, 'oi', function($join) {
$join->on('oi.order_id', '=', 'dr.order_id')
->on('oi.product_id', '=', 'dr.product_id');
})
->where('dr.product_id', $selectedProductId)
->select([
'o.machine_id',
\Illuminate\Support\Facades\DB::raw('SUM(COALESCE(oi.unit_price, 0)) as total_sales')
]);
}
$salesQuery->groupBy('o.machine_id');
// 子查詢 B實際出貨件數與成本 (僅統計出貨成功且在範圍內)
$dispenseQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr')
->join('orders as o', 'o.id', '=', 'dr.order_id')
->leftJoin('products as p', 'p.id', '=', 'dr.product_id')
->select([
'dr.machine_id',
\Illuminate\Support\Facades\DB::raw('COUNT(dr.id) as total_quantity'),
\Illuminate\Support\Facades\DB::raw('SUM(COALESCE(p.cost, 0)) as total_cost')
])
->where('dr.dispense_status', 1)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
// 套用篩選條件到出貨子查詢
if ($effectiveCompanyId) {
$dispenseQuery->where('o.company_id', $effectiveCompanyId);
}
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$dispenseQuery->whereRaw('1 = 0');
} else {
$dispenseQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$dispenseQuery->where('o.machine_id', $selectedMachineId);
}
if ($selectedProductId && $selectedProductId !== 'ALL') {
$dispenseQuery->where('dr.product_id', $selectedProductId);
}
if (!empty($paymentTypes)) {
$dispenseQuery->whereIn('o.payment_type', $paymentTypes);
}
$dispenseQuery->groupBy('dr.machine_id');
// 聯結機台與子查詢,取得最終統計資料
$mainQuery = \Illuminate\Support\Facades\DB::table('machines as m')
->leftJoinSub($salesQuery, 'sq', 'sq.machine_id', '=', 'm.id')
->leftJoinSub($dispenseQuery, 'dq', 'dq.machine_id', '=', 'm.id')
->select([
'm.id as machine_id',
\Illuminate\Support\Facades\DB::raw('COALESCE(m.serial_no, "UNKNOWN") as serial_no'),
\Illuminate\Support\Facades\DB::raw('COALESCE(m.name, "未知機台") as name'),
\Illuminate\Support\Facades\DB::raw('CAST(COALESCE(dq.total_quantity, 0) as SIGNED) as total_quantity'),
\Illuminate\Support\Facades\DB::raw('CAST(COALESCE(sq.total_sales, 0) as DECIMAL(10,2)) as total_sales'),
\Illuminate\Support\Facades\DB::raw('CAST(COALESCE(dq.total_cost, 0) as DECIMAL(10,2)) as total_cost'),
\Illuminate\Support\Facades\DB::raw('CAST((COALESCE(sq.total_sales, 0) - COALESCE(dq.total_cost, 0)) as DECIMAL(10,2)) as total_profit')
])
->where(function($query) {
$query->whereNotNull('sq.total_sales')
->orWhereNotNull('dq.total_quantity');
});
if ($effectiveCompanyId) {
$mainQuery->where('m.company_id', $effectiveCompanyId);
}
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$mainQuery->whereRaw('1 = 0');
} else {
$mainQuery->whereIn('m.id', $allowedMachineIds);
}
}
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$mainQuery->where('m.id', $selectedMachineId);
}
$tableData = $mainQuery->get();
// 7. 計算總計 Summary
$totalQuantity = 0;
$totalSales = 0.0;
$totalCost = 0.0;
$totalProfit = 0.0;
foreach ($tableData as $row) {
$row->total_quantity = (int)$row->total_quantity;
$row->total_sales = (float)$row->total_sales;
$row->total_cost = (float)$row->total_cost;
$row->total_profit = (float)$row->total_profit;
$totalQuantity += $row->total_quantity;
$totalSales += $row->total_sales;
$totalCost += $row->total_cost;
$totalProfit += $row->total_profit;
}
$summary = [
'total_quantity' => $totalQuantity,
'total_sales' => round($totalSales, 2),
'total_cost' => round($totalCost, 2),
'total_profit' => round($totalProfit, 2),
];
// 8. 圖表日走勢統計
// 每日營業額
$trendSalesQuery = \Illuminate\Support\Facades\DB::table('orders as o')
->select([
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
\Illuminate\Support\Facades\DB::raw('SUM(o.pay_amount) as total_sales')
])
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
// 套用篩選到走勢營業額
if ($effectiveCompanyId) {
$trendSalesQuery->where('o.company_id', $effectiveCompanyId);
}
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$trendSalesQuery->whereRaw('1 = 0');
} else {
$trendSalesQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$trendSalesQuery->where('o.machine_id', $selectedMachineId);
}
if (!empty($paymentTypes)) {
$trendSalesQuery->whereIn('o.payment_type', $paymentTypes);
}
// 如果篩選特定商品,營業額只計算有成功出貨的商品價格
if ($selectedProductId && $selectedProductId !== 'ALL') {
$trendSalesQuery->join('dispense_records as dr', function($join) {
$join->on('dr.order_id', '=', 'o.id')
->where('dr.dispense_status', '=', 1);
})
->leftJoinSub($itemPriceQuery, 'oi', function($join) {
$join->on('oi.order_id', '=', 'dr.order_id')
->on('oi.product_id', '=', 'dr.product_id');
})
->where('dr.product_id', $selectedProductId)
->select([
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
\Illuminate\Support\Facades\DB::raw('SUM(COALESCE(oi.unit_price, 0)) as total_sales')
]);
}
$trendSalesQuery->groupBy('date');
$trendSalesData = $trendSalesQuery->get();
// 每日出貨件數與成本
$trendDispenseQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr')
->join('orders as o', 'o.id', '=', 'dr.order_id')
->leftJoin('products as p', 'p.id', '=', 'dr.product_id')
->select([
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
\Illuminate\Support\Facades\DB::raw('COUNT(dr.id) as total_quantity'),
\Illuminate\Support\Facades\DB::raw('SUM(COALESCE(p.cost, 0)) as total_cost')
])
->where('dr.dispense_status', 1)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
// 套用篩選到走勢出貨
if ($effectiveCompanyId) {
$trendDispenseQuery->where('o.company_id', $effectiveCompanyId);
}
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$trendDispenseQuery->whereRaw('1 = 0');
} else {
$trendDispenseQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$trendDispenseQuery->where('o.machine_id', $selectedMachineId);
}
if ($selectedProductId && $selectedProductId !== 'ALL') {
$trendDispenseQuery->where('dr.product_id', $selectedProductId);
}
if (!empty($paymentTypes)) {
$trendDispenseQuery->whereIn('o.payment_type', $paymentTypes);
}
$trendDispenseQuery->groupBy('date');
$trendDispenseData = $trendDispenseQuery->get();
// 將子查詢結果轉換為 PHP Map 對照
$salesMap = [];
foreach ($trendSalesData as $row) {
$salesMap[$row->date] = $row->total_sales;
}
$dispenseMap = [];
foreach ($trendDispenseData as $row) {
$dispenseMap[$row->date] = [
'quantity' => $row->total_quantity,
'cost' => $row->total_cost
];
}
// 補齊日期區間內所有空缺日走勢
$period = new \DatePeriod(
$startDate->copy()->startOfDay(),
new \DateInterval('P1D'),
$endDate->copy()->endOfDay()
);
$chartData = [];
foreach ($period as $date) {
$dateStr = $date->format('Y-m-d');
$hasSales = isset($salesMap[$dateStr]);
$hasDispense = isset($dispenseMap[$dateStr]);
$chartData[$dateStr] = [
'date' => $dateStr,
'quantity' => $hasDispense ? (int)$dispenseMap[$dateStr]['quantity'] : 0,
'sales' => $hasSales ? round((float)$salesMap[$dateStr], 2) : 0.0,
'cost' => $hasDispense ? round((float)$dispenseMap[$dateStr]['cost'], 2) : 0.0,
];
}
$chartData = array_values($chartData);
// 9. 判斷是否為 AJAX 請求,回傳 JSON 格式
if ($request->ajax() || $request->wantsJson()) {
return response()->json([
'success' => true,
'summary' => $summary,
'chart_data' => $chartData,
'table_data' => $tableData,
]);
}
// 10. 一般 GET 載入視圖
return view('admin.analysis.machine-reports', [
'title' => '機台報表分析',
'description' => '機台運營數據分析報表',
'companies' => $companies,
'machines' => $machines,
'products' => $products,
'availablePaymentTypes' => $availablePaymentTypes,
'selectedCompanyId' => $selectedCompanyId,
'selectedMachineId' => $selectedMachineId,
'selectedProductId' => $selectedProductId,
'selectedPaymentTypes' => $paymentTypes,
'dateRange' => $dateRange,
'summary' => $summary,
'chartData' => $chartData,
'tableData' => $tableData,
]);
}
// 商品報表分析
public function productReports()
public function productReports(Request $request)
{
return view('admin.placeholder', [
$user = auth()->user();
$companyId = $user->company_id;
// 1. 取得篩選參數
$selectedCompanyId = $request->input('company_id');
$selectedProductId = $request->input('product_id', 'ALL');
$selectedMachineId = $request->input('machine_id', 'ALL');
$dateRange = $request->input('date_range');
// 2. 解析日期區間
if ($dateRange) {
$dates = explode(' to ', $dateRange);
if (count($dates) === 2) {
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
$endDate = \Illuminate\Support\Carbon::parse($dates[1])->endOfDay();
} else {
$startDate = \Illuminate\Support\Carbon::parse($dates[0])->startOfDay();
$endDate = \Illuminate\Support\Carbon::parse($dates[0])->endOfDay();
}
} else {
// 預設為最近 7 天 (含今日)
$startDate = now()->subDays(6)->startOfDay();
$endDate = now()->endOfDay();
$dateRange = $startDate->format('Y-m-d') . ' to ' . $endDate->format('Y-m-d');
}
// 3. 租戶隔離:非系統管理員強制限制為其所屬公司
if (!$user->isSystemAdmin()) {
$effectiveCompanyId = $companyId;
} else {
$effectiveCompanyId = $selectedCompanyId;
}
// 取得使用者授權的機台 ID 範圍 (一般用戶)
$allowedMachineIds = null;
if (!$user->isSystemAdmin()) {
$allowedMachineIds = \App\Models\Machine\Machine::pluck('id')->toArray();
}
// 4. 加載下拉選單選項
// 公司選單 (僅限 Super Admin)
$companies = [];
if ($user->isSystemAdmin()) {
$companies = \App\Models\System\Company::active()->get(['id', 'name']);
}
// 機台選單 (自動套用多租戶與授權隔離)
$machinesQuery = \App\Models\Machine\Machine::query();
if ($effectiveCompanyId) {
$machinesQuery->where('company_id', $effectiveCompanyId);
}
$machines = $machinesQuery->get(['id', 'name', 'serial_no']);
// 商品選單
$productsQuery = \App\Models\Product\Product::query();
if ($effectiveCompanyId) {
$productsQuery->where('company_id', $effectiveCompanyId);
}
$products = $productsQuery->active()->get(['id', 'name', 'barcode']);
// 5. 執行主報表統計 SQL
// 子查詢:取得每個 order_id + product_id 的銷售單價與品名
$itemPriceQuery = \Illuminate\Support\Facades\DB::table('order_items')
->select([
'order_id',
'product_id',
\Illuminate\Support\Facades\DB::raw('MIN(price) as unit_price'),
\Illuminate\Support\Facades\DB::raw('MIN(barcode) as barcode'),
\Illuminate\Support\Facades\DB::raw('MIN(product_name) as product_name')
])
->groupBy('order_id', 'product_id');
// 計算各商品的銷售數量、銷售額、總成本、利潤 (以出貨成功為主體)
$mainQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr')
->join('orders as o', 'o.id', '=', 'dr.order_id')
->leftJoinSub($itemPriceQuery, 'oi', function($join) {
$join->on('oi.order_id', '=', 'dr.order_id')
->on('oi.product_id', '=', 'dr.product_id');
})
->leftJoin('products as p', 'p.id', '=', 'dr.product_id')
->select([
'dr.product_id',
\Illuminate\Support\Facades\DB::raw('COALESCE(p.barcode, oi.barcode) as barcode'),
\Illuminate\Support\Facades\DB::raw('COALESCE(p.name, oi.product_name) as name'),
\Illuminate\Support\Facades\DB::raw('CAST(COUNT(dr.id) as SIGNED) as total_quantity'),
\Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(oi.unit_price, 0)) as DECIMAL(10,2)) as total_sales'),
\Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(p.cost, 0)) as DECIMAL(10,2)) as total_cost'),
\Illuminate\Support\Facades\DB::raw('CAST((SUM(COALESCE(oi.unit_price, 0)) - SUM(COALESCE(p.cost, 0))) as DECIMAL(10,2)) as total_profit')
])
->where('dr.dispense_status', 1)
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
// 套用公司篩選
if ($effectiveCompanyId) {
$mainQuery->where('o.company_id', $effectiveCompanyId);
}
// 套用機台權限限制
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$mainQuery->whereRaw('1 = 0');
} else {
$mainQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
// 套用特定機台篩選
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$mainQuery->where('o.machine_id', $selectedMachineId);
}
// 套用特定商品篩選
if ($selectedProductId && $selectedProductId !== 'ALL') {
$mainQuery->where('dr.product_id', $selectedProductId);
}
$mainQuery->groupBy('dr.product_id')
->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(p.barcode, oi.barcode)'))
->groupBy(\Illuminate\Support\Facades\DB::raw('COALESCE(p.name, oi.product_name)'));
$tableData = $mainQuery->get();
// 6. 計算總計 Summary
$totalQuantity = 0;
$totalSales = 0.0;
$totalCost = 0.0;
$totalProfit = 0.0;
foreach ($tableData as $row) {
$row->total_quantity = (int)$row->total_quantity;
$row->total_sales = (float)$row->total_sales;
$row->total_cost = (float)$row->total_cost;
$row->total_profit = (float)$row->total_profit;
$totalQuantity += $row->total_quantity;
$totalSales += $row->total_sales;
$totalCost += $row->total_cost;
$totalProfit += $row->total_profit;
}
$summary = [
'total_quantity' => $totalQuantity,
'total_sales' => round($totalSales, 2),
'total_cost' => round($totalCost, 2),
'total_profit' => round($totalProfit, 2),
];
// 7. 圖表日走勢統計 SQL
$trendQuery = \Illuminate\Support\Facades\DB::table('dispense_records as dr')
->join('orders as o', 'o.id', '=', 'dr.order_id')
->leftJoinSub($itemPriceQuery, 'oi', function($join) {
$join->on('oi.order_id', '=', 'dr.order_id')
->on('oi.product_id', '=', 'dr.product_id');
})
->leftJoin('products as p', 'p.id', '=', 'dr.product_id')
->select([
\Illuminate\Support\Facades\DB::raw("DATE_FORMAT(o.created_at, '%Y-%m-%d') as date"),
\Illuminate\Support\Facades\DB::raw('CAST(COUNT(dr.id) as SIGNED) as total_quantity'),
\Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(oi.unit_price, 0)) as DECIMAL(10,2)) as total_sales'),
\Illuminate\Support\Facades\DB::raw('CAST(SUM(COALESCE(p.cost, 0)) as DECIMAL(10,2)) as total_cost')
])
->where('dr.dispense_status', 1)
->where('o.payment_status', \App\Models\Transaction\Order::PAYMENT_STATUS_SUCCESS)
->whereNull('o.deleted_at')
->whereBetween('o.created_at', [$startDate, $endDate]);
if ($effectiveCompanyId) {
$trendQuery->where('o.company_id', $effectiveCompanyId);
}
if ($allowedMachineIds !== null) {
if (empty($allowedMachineIds)) {
$trendQuery->whereRaw('1 = 0');
} else {
$trendQuery->whereIn('o.machine_id', $allowedMachineIds);
}
}
if ($selectedMachineId && $selectedMachineId !== 'ALL') {
$trendQuery->where('o.machine_id', $selectedMachineId);
}
if ($selectedProductId && $selectedProductId !== 'ALL') {
$trendQuery->where('dr.product_id', $selectedProductId);
}
$trendQuery->groupBy('date')->orderBy('date', 'asc');
$trendData = $trendQuery->get();
// 補齊日期區間內所有空缺日走勢
$period = new \DatePeriod(
$startDate->copy()->startOfDay(),
new \DateInterval('P1D'),
$endDate->copy()->endOfDay()
);
$chartData = [];
foreach ($period as $date) {
$dateStr = $date->format('Y-m-d');
$chartData[$dateStr] = [
'date' => $dateStr,
'quantity' => 0,
'sales' => 0.0,
'cost' => 0.0,
];
}
foreach ($trendData as $row) {
if (isset($chartData[$row->date])) {
$chartData[$row->date]['quantity'] = (int)$row->total_quantity;
$chartData[$row->date]['sales'] = round((float)$row->total_sales, 2);
$chartData[$row->date]['cost'] = round((float)$row->total_cost, 2);
}
}
$chartData = array_values($chartData);
// 8. 判斷是否為 AJAX 請求,回傳 JSON 格式
if ($request->ajax() || $request->wantsJson()) {
return response()->json([
'success' => true,
'summary' => $summary,
'chart_data' => $chartData,
'table_data' => $tableData,
]);
}
// 9. 一般 GET 載入視圖
return view('admin.analysis.product-reports', [
'title' => '商品報表分析',
'description' => '商品銷售數據分析',
'description' => '商品銷售數據 analysis',
'companies' => $companies,
'machines' => $machines,
'products' => $products,
'selectedCompanyId' => $selectedCompanyId,
'selectedMachineId' => $selectedMachineId,
'selectedProductId' => $selectedProductId,
'dateRange' => $dateRange,
'summary' => $summary,
'chartData' => $chartData,
'tableData' => $tableData,
]);
}

View File

@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Admin\BasicSettings;
use App\Http\Controllers\Admin\AdminController;
use App\Models\Machine\ApkVersion;
use App\Models\Machine\Machine;
use App\Services\Machine\MqttService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class ApkVersionController extends AdminController
{
public function index(): View
{
// 在進入 APK 版本清單時,同步觸發已到期排程的執行(確保無 cron 的開發/測試環境也能 100% 執行)
try {
\Illuminate\Support\Facades\Artisan::call('ota:process-schedules');
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('Auto-trigger scheduled OTA failed: ' . $e->getMessage());
}
$versions = ApkVersion::latest()->paginate(10);
// 統計並附屬機台部署狀態
$commands = \App\Models\Machine\RemoteCommand::where('command_type', 'update_app')
->with('machine')
->get();
// 撈取所有還沒執行的 OTA 預約排程
$pendingSchedules = \App\Models\Machine\OtaSchedule::where('status', 'pending')->get();
// 蒐集所有預約機台 ID一次性撈出以避免 N+1
$allScheduledMachineIds = [];
foreach ($pendingSchedules as $sch) {
if (is_array($sch->target_value)) {
$allScheduledMachineIds = array_merge($allScheduledMachineIds, $sch->target_value);
}
}
$allScheduledMachineIds = array_unique(array_filter($allScheduledMachineIds));
$scheduledMachinesMap = [];
if (count($allScheduledMachineIds) > 0) {
$scheduledMachinesMap = \App\Models\Machine\Machine::whereIn('id', $allScheduledMachineIds)
->get()
->keyBy('id');
}
foreach ($versions as $version) {
$pending = [];
$completed = [];
// 1. 已排入指令(已發送或執行中)的 RemoteCommand
foreach ($commands as $cmd) {
$cmdVersionCode = $cmd->payload['version_code'] ?? null;
if ($cmdVersionCode == $version->version_code) {
if ($cmd->status === 'completed' || $cmd->status === 'success') {
$completed[] = $cmd->machine;
} elseif ($cmd->status === 'pending' || $cmd->status === 'processing' || $cmd->status === 'sent') {
$pending[] = $cmd->machine;
}
}
}
// 2. 還沒有執行的預約排程中的機台
foreach ($pendingSchedules as $sch) {
if ($sch->apk_version_id == $version->id) {
$machineIds = $sch->target_value;
if (is_array($machineIds)) {
foreach ($machineIds as $mId) {
if (isset($scheduledMachinesMap[$mId])) {
$pending[] = $scheduledMachinesMap[$mId];
}
}
}
}
}
$version->pending_machines = collect($pending)->filter()->unique('id');
$version->completed_machines = collect($completed)->filter()->unique('id');
}
// 用於 OTA 下發時讓使用者選擇的機台列表
$machines = Machine::all();
return view('admin.basic-settings.apk-versions.index', compact('versions', 'machines'));
}
public function create(): View
{
return view('admin.basic-settings.apk-versions.create');
}
public function store(Request $request)
{
$request->validate([
'version_name' => 'required|string|max:100',
'version_code' => 'required|integer|unique:apk_versions,version_code',
'flavor' => 'required|string|max:100',
'apk_file' => 'required_without:download_url|nullable|file|max:102400', // 上傳限制 100MB
'download_url' => 'required_without:apk_file|nullable|url|max:500',
'release_notes' => 'nullable|string',
]);
$filePath = null;
if ($request->hasFile('apk_file')) {
$filePath = $request->file('apk_file')->store('apks', 'public');
}
ApkVersion::create([
'version_name' => $request->version_name,
'version_code' => $request->version_code,
'flavor' => $request->flavor,
'file_path' => $filePath,
'download_url' => $request->download_url,
'release_notes' => $request->release_notes,
'is_forced' => false,
]);
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', __('APK version uploaded successfully.'));
}
public function destroy(ApkVersion $apkVersion)
{
if ($apkVersion->file_path) {
Storage::disk('public')->delete($apkVersion->file_path);
}
$apkVersion->delete();
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', __('APK version deleted successfully.'));
}
/**
* 推送 OTA 更新指令給指定機台
*/
public function push(Request $request, ApkVersion $apkVersion, MqttService $mqttService)
{
$request->validate([
'machine_ids' => 'required|array',
'machine_ids.*' => 'exists:machines,id',
'scheduled_at' => 'nullable|date|after:now',
]);
// 如果設定了預約發送時間
if ($request->filled('scheduled_at')) {
\App\Models\Machine\OtaSchedule::create([
'apk_version_id' => $apkVersion->id,
'scheduled_at' => $request->scheduled_at,
'target_type' => 'custom',
'target_value' => $request->machine_ids,
'status' => 'pending',
'user_id' => auth()->id(),
]);
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', __('OTA update scheduled successfully.'));
}
$machines = Machine::whereIn('id', $request->machine_ids)->get();
$successCount = 0;
foreach ($machines as $machine) {
// 1. 先建立 RemoteCommand 紀錄,以取得自增 ID 作為 command_id與系統慣例一致
$command = \App\Models\Machine\RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => auth()->id(),
'command_type' => 'update_app',
'status' => 'pending',
'payload' => [
'url' => $apkVersion->url,
'version' => $apkVersion->version_name,
'version_code' => $apkVersion->version_code,
'is_forced' => $apkVersion->is_forced,
],
]);
// 2. 推播 MQTTcommand_id 使用資料庫自增流水號
$pushed = $mqttService->pushCommand(
$machine->serial_no,
'update_app',
$command->payload,
(string) $command->id
);
if ($pushed) {
$successCount++;
} else {
// 推播失敗,將紀錄標記為失敗
$command->update(['status' => 'failed']);
}
}
return redirect()->route('admin.basic-settings.apk-versions.index')
->with('success', sprintf(__('OTA command sent to %d devices successfully.'), $successCount));
}
}

View File

@ -0,0 +1,203 @@
<?php
namespace App\Http\Controllers\Admin\BasicSettings;
use App\Http\Controllers\Admin\AdminController;
use App\Models\System\Company;
use App\Services\Notification\DiscordWebhookService;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Http\JsonResponse;
class DiscordNotificationController extends AdminController
{
/**
* 顯示 Discord 告警設定頁面 ( Tab 支援)
*/
public function index(Request $request): View
{
$user = auth()->user();
$activeTab = $request->input('tab', 'company');
// ── Tab 1: 公司全域設定 ──
$companyQuery = Company::query();
if ($user->isSystemAdmin()) {
if ($searchCompany = $request->input('search_company')) {
$companyQuery->where(function ($q) use ($searchCompany) {
$q->where('name', 'like', "%{$searchCompany}%")
->orWhere('code', 'like', "%{$searchCompany}%");
});
}
} else {
$companyQuery->where('id', $user->company_id);
}
$companies = $companyQuery->paginate($request->input('company_per_page', 10), ['*'], 'company_page')
->withQueryString();
// ── Tab 2: 機台個別設定 ──
$machineQuery = \App\Models\Machine\Machine::query()->with(['company', 'machineModel']);
if ($user->isSystemAdmin()) {
if ($filterCompanyId = trim($request->input('filter_company_id', ''))) {
$machineQuery->where('company_id', $filterCompanyId);
}
if ($searchMachine = $request->input('search_machine')) {
$machineQuery->where(function ($q) use ($searchMachine) {
$q->where('name', 'like', "%{$searchMachine}%")
->orWhere('serial_no', 'like', "%{$searchMachine}%");
});
}
} else {
$machineQuery->where('company_id', $user->company_id);
if ($searchMachine = $request->input('search_machine')) {
$machineQuery->where(function ($q) use ($searchMachine) {
$q->where('name', 'like', "%{$searchMachine}%")
->orWhere('serial_no', 'like', "%{$searchMachine}%");
});
}
}
$machines = $machineQuery->paginate($request->input('machine_per_page', 10), ['*'], 'machine_page')
->withQueryString();
// ── 輔助資料 ──
// 所有公司列表 (僅 System Admin 用於 Tab 2 篩選下拉)
$allCompanies = [];
if ($user->isSystemAdmin()) {
$allCompanies = Company::select('id', 'name')->get();
}
// 用於前端 Modal「拉式複製」的同公司機台 settings 列表
$copyableQuery = \App\Models\Machine\Machine::select('id', 'name', 'serial_no', 'company_id', 'settings');
if (!$user->isSystemAdmin()) {
$copyableQuery->where('company_id', $user->company_id);
}
$copyableMachines = $copyableQuery->get();
return view('admin.basic-settings.discord-notifications.index', [
'companies' => $companies,
'machines' => $machines,
'allCompanies' => $allCompanies,
'copyableMachines' => $copyableMachines,
'activeTab' => $activeTab,
]);
}
/**
* 更新公司 Discord 告警設定
*/
public function updateCompany(Request $request, $id)
{
$user = auth()->user();
if (!$user->isSystemAdmin() && (int)$id !== (int)$user->company_id) {
abort(403, __('Unauthorized action.'));
}
$company = Company::findOrFail($id);
$request->validate([
'discord_webhook_url' => 'nullable|url|max:500',
'discord_notify_enabled' => 'boolean',
'discord_notify_types' => 'array',
'discord_notify_types.*' => 'string|in:submachine,status,temperature',
'discord_notify_locale' => 'required|string|in:zh_TW,en,ja',
]);
$settings = $company->settings ?? [];
$settings['discord_webhook_url'] = $request->input('discord_webhook_url');
$settings['discord_notify_enabled'] = (bool) $request->input('discord_notify_enabled', false);
$settings['discord_notify_types'] = $request->input('discord_notify_types', []);
$settings['locale'] = $request->input('discord_notify_locale', 'zh_TW');
$company->settings = $settings;
$company->save();
return redirect()->route('admin.basic-settings.discord-notifications.index', ['tab' => 'company'])
->with('success', __('Company Discord notification settings updated successfully.'));
}
/**
* 更新機台 Discord 與溫度告警設定
*/
public function updateMachine(Request $request, $id)
{
$user = auth()->user();
$machine = \App\Models\Machine\Machine::findOrFail($id);
if (!$user->isSystemAdmin() && (int)$machine->company_id !== (int)$user->company_id) {
abort(403, __('Unauthorized action.'));
}
$request->validate([
'discord_webhook_url' => 'nullable|url|max:500',
'notify_types_mode' => 'required|string|in:inherit,custom',
'discord_notify_types' => 'nullable|array',
'discord_notify_types.*' => 'string|in:submachine,status,temperature',
'temp_upper_limit' => 'nullable|numeric|min:-50|max:100',
'temp_lower_limit' => 'nullable|numeric|min:-50|max:100',
]);
$settings = $machine->settings ?? [];
$settings['discord_webhook_url'] = $request->input('discord_webhook_url');
$notifyTypesMode = $request->input('notify_types_mode', 'inherit');
$notifyTypes = $request->input('discord_notify_types', []);
$hasTemperature = in_array('temperature', $notifyTypes, true);
if ($notifyTypesMode === 'custom') {
$settings['discord_notify_types'] = array_values($notifyTypes);
if ($hasTemperature) {
$settings['temp_alert_enabled'] = 'enabled';
$settings['temp_upper_limit'] = $request->filled('temp_upper_limit') ? (float)$request->temp_upper_limit : null;
$settings['temp_lower_limit'] = $request->filled('temp_lower_limit') ? (float)$request->temp_lower_limit : null;
} else {
$settings['temp_alert_enabled'] = 'disabled';
$settings['temp_upper_limit'] = null;
$settings['temp_lower_limit'] = null;
}
} else {
unset($settings['discord_notify_types']);
$settings['temp_alert_enabled'] = 'inherit';
$settings['temp_upper_limit'] = null;
$settings['temp_lower_limit'] = null;
}
$machine->settings = $settings;
$machine->save();
return redirect()->route('admin.basic-settings.discord-notifications.index', ['tab' => 'machine'])
->with('success', __('Machine Discord notification settings updated successfully.'));
}
/**
* 測試發送 Discord Webhook
*/
public function test(Request $request): JsonResponse
{
$request->validate([
'discord_webhook_url' => 'required|url|max:500',
]);
$webhookUrl = $request->input('discord_webhook_url');
try {
$service = app(DiscordWebhookService::class);
$success = $service->sendTestAlert($webhookUrl);
if ($success) {
return response()->json([
'success' => true,
'message' => __('Test notification sent successfully.'),
]);
} else {
return response()->json([
'success' => false,
'message' => __('Failed to send test notification. Please verify the Webhook URL.'),
], 400);
}
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => __('An error occurred: ') . $e->getMessage(),
], 500);
}
}
}

View File

@ -22,13 +22,22 @@ class MachineModelController extends AdminController
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'temp_upper_limit' => 'nullable|integer|min:-50|max:100',
'temp_lower_limit' => 'nullable|integer|min:-50|max:100',
]);
MachineModel::create(array_merge($validated, [
$settings = [
'temp_upper_limit' => $request->filled('temp_upper_limit') ? (int)$request->temp_upper_limit : null,
'temp_lower_limit' => $request->filled('temp_lower_limit') ? (int)$request->temp_lower_limit : null,
];
MachineModel::create([
'name' => $validated['name'],
'settings' => $settings,
'company_id' => auth()->user()->company_id,
'creator_id' => auth()->id(),
'updater_id' => auth()->id(),
]));
]);
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
->with('success', __('Machine model created successfully.'));
@ -47,11 +56,20 @@ class MachineModelController extends AdminController
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'temp_upper_limit' => 'nullable|integer|min:-50|max:100',
'temp_lower_limit' => 'nullable|integer|min:-50|max:100',
]);
$machine_model->update(array_merge($validated, [
$settings = [
'temp_upper_limit' => $request->filled('temp_upper_limit') ? (int)$request->temp_upper_limit : null,
'temp_lower_limit' => $request->filled('temp_lower_limit') ? (int)$request->temp_lower_limit : null,
];
$machine_model->update([
'name' => $validated['name'],
'settings' => $settings,
'updater_id' => auth()->id(),
]));
]);
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
->with('success', __('Machine model updated successfully.'));

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin\BasicSettings;
use App\Http\Controllers\Admin\AdminController;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineModel;
use App\Models\System\Company;
use App\Models\System\PaymentConfig;
use App\Traits\ImageHandler;
use Illuminate\Http\Request;
@ -28,6 +29,23 @@ class MachineSettingController extends AdminController
$per_page = $request->input('per_page', 10);
$search = $request->input('search');
$isAjax = $request->boolean('_ajax');
$currentUser = auth()->user();
$companyId = trim((string) $request->input('company_id', ''));
$applyMachineFilters = function ($query) use ($search, $currentUser, $companyId) {
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%");
});
}
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
return $query;
};
// AJAX 模式:只查當前 Tab 的搜尋/分頁結果
if ($isAjax) {
@ -38,12 +56,13 @@ class MachineSettingController extends AdminController
switch ($tab) {
case 'machines':
$machineQuery = Machine::query()->with(['machineModel', 'paymentConfig', 'company']);
if ($search) {
$machineQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%");
});
}
$applyMachineFilters($machineQuery);
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
break;
case 'system_settings':
$machineQuery = Machine::query()->with(['company']);
$applyMachineFilters($machineQuery);
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
break;
@ -56,8 +75,10 @@ class MachineSettingController extends AdminController
break;
case 'permissions':
// 套用層級可見範圍:系統管理員看全部;租戶僅看可見範圍內帳號(避免跨租戶洩漏)。
$userQuery = \App\Models\System\User::query()
->where('is_admin', true)
->visibleTo($currentUser)
->with(['company', 'machines']);
if ($search) {
$userQuery->where(function($q) use ($search) {
@ -65,18 +86,20 @@ class MachineSettingController extends AdminController
->orWhere('username', 'like', "%{$search}%");
});
}
if ($request->filled('company_id')) {
$userQuery->where('company_id', $request->company_id);
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$userQuery->where('company_id', $companyId);
}
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
break;
}
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
$companies = Company::select('id', 'name', 'code')->orderBy('name')->get();
$tabView = match($tab) {
'models' => 'admin.basic-settings.machines.partials.tab-models',
'permissions' => 'admin.basic-settings.machines.partials.tab-permissions',
'system_settings' => 'admin.basic-settings.machines.partials.tab-system-settings',
default => 'admin.basic-settings.machines.partials.tab-machines',
};
return response()->view($tabView, compact(
@ -85,9 +108,14 @@ class MachineSettingController extends AdminController
}
// SSR 模式:一次查好全部三個 Tab 的首頁資料(供 x-show 即時切換)
$machines = Machine::query()
->with(['machineModel', 'paymentConfig', 'company'])
->latest()->paginate($per_page)->withQueryString();
$machineQuery = Machine::query()
->with(['machineModel', 'paymentConfig', 'company']);
if (in_array($tab, ['machines', 'system_settings'], true)) {
$applyMachineFilters($machineQuery);
}
$machines = $machineQuery->latest()->paginate($per_page)->withQueryString();
$models_list = MachineModel::query()
->withCount('machines')
@ -95,21 +123,26 @@ class MachineSettingController extends AdminController
$userQuery = \App\Models\System\User::query()
->where('is_admin', true)
->visibleTo($currentUser)
->with(['company', 'machines']);
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString();
// 基礎下拉資料 (用於新增/編輯機台的彈窗)
$models = MachineModel::select('id', 'name')->get();
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
$companies = Company::select('id', 'name', 'code')->orderBy('name')->get();
// 同步系統設定彈窗的機台選擇器清單 (依 TenantScoped 自動租戶隔離)
$allMachines = Machine::select('id', 'name', 'serial_no')->orderBy('name')->get();
return view('admin.basic-settings.machines.index', compact(
'machines',
'models_list',
'machines',
'models_list',
'users_list',
'models',
'paymentConfigs',
'models',
'paymentConfigs',
'companies',
'allMachines',
'tab'
));
}
@ -125,6 +158,7 @@ class MachineSettingController extends AdminController
'company_id' => 'nullable|exists:companies,id',
'machine_model_id' => 'required|exists:machine_models,id',
'payment_config_id' => 'nullable|exists:payment_configs,id',
'key_no' => 'nullable|string|max:255',
'location' => 'nullable|string|max:255',
'address' => 'nullable|string|max:255',
'latitude' => 'nullable|numeric|between:-90,90',
@ -185,15 +219,19 @@ class MachineSettingController extends AdminController
'heating_end_time' => 'nullable|string',
'card_reader_no' => 'nullable|string|max:255',
'key_no' => 'nullable|string|max:255',
'invoice_status' => 'required|integer|in:0,1,2',
'welcome_gift_enabled' => 'boolean',
'is_spring_slot_1_10' => 'boolean',
'is_spring_slot_11_20' => 'boolean',
'is_spring_slot_21_30' => 'boolean',
'is_spring_slot_31_40' => 'boolean',
'is_spring_slot_41_50' => 'boolean',
'is_spring_slot_51_60' => 'boolean',
'member_system_enabled' => 'boolean',
'tax_invoice_enabled' => 'boolean',
'card_terminal_enabled' => 'boolean',
'scan_pay_esun_enabled' => 'boolean',
'scan_pay_linepay_enabled' => 'boolean',
'shopping_cart_enabled' => 'boolean',
'cash_module_enabled' => 'boolean',
'ambient_temp_monitoring_enabled' => 'boolean',
'machine_model_id' => 'required|exists:machine_models,id',
'payment_config_id' => 'nullable|exists:payment_configs,id',
'location' => 'nullable|string|max:255',
@ -227,6 +265,13 @@ class MachineSettingController extends AdminController
'remove_image_0', 'remove_image_1', 'remove_image_2'
]);
// 偵測序號是否變動,若變動則自動重新產生 API Token
$tokenRegenerated = false;
if (isset($dataToUpdate['serial_no']) && $dataToUpdate['serial_no'] !== $machine->serial_no) {
$dataToUpdate['api_token'] = Str::random(60);
$tokenRegenerated = true;
}
$machine->update(array_merge($dataToUpdate, [
'updater_id' => auth()->id(),
]));
@ -264,8 +309,13 @@ class MachineSettingController extends AdminController
$machine->update(['images' => array_values($currentImages)]);
}
$successMessage = __('Machine updated successfully.');
if ($tokenRegenerated) {
$successMessage .= ' ' . __('API Token has been regenerated due to serial number change.');
}
return redirect()->route('admin.basic-settings.machines.index')
->with('success', __('Machine settings updated successfully.'));
->with('success', $successMessage);
}
public function regenerateToken(Request $request, $serial): \Illuminate\Http\JsonResponse
@ -274,6 +324,7 @@ class MachineSettingController extends AdminController
$newToken = \Illuminate\Support\Str::random(60);
$machine->update(['api_token' => $newToken]);
Log::info('Machine API Token Regenerated', [
'machine_id' => $machine->id,
'serial_no' => $machine->serial_no,
@ -287,6 +338,268 @@ class MachineSettingController extends AdminController
]);
}
/**
* 系統設定錯誤回應:依請求型態回 JSON redirect。
*/
private function settingsError(Request $request, string $message, int $code): \Illuminate\Http\JsonResponse|RedirectResponse
{
if ($request->expectsJson()) {
return response()->json(['success' => false, 'message' => $message], $code);
}
return redirect()->back()->with('error', $message)->withInput();
}
public function updateSystemSettings(Request $request, Machine $machine): \Illuminate\Http\JsonResponse|RedirectResponse
{
\Log::info('Update System Settings Request:', $request->all());
if ($request->has('settings')) {
$settings = $request->input('settings');
$allowedFields = [
'tax_invoice_enabled',
'card_terminal_enabled',
'scan_pay_esun_enabled',
'scan_pay_linepay_enabled',
'shopping_cart_enabled',
'welcome_gift_enabled',
'cash_module_enabled',
'pickup_module_enabled',
'member_system_enabled',
'ambient_temp_monitoring_enabled',
];
$allowedShoppingModes = ['basic', 'employee_card', 'pickup_sheet'];
$shoppingMode = $settings['shopping_mode'] ?? 'basic';
if (!in_array($shoppingMode, $allowedShoppingModes, true)) {
$shoppingMode = 'basic';
}
$data = [];
foreach ($allowedFields as $field) {
$data[$field] = isset($settings[$field]) ? (bool)$settings[$field] : false;
}
$jsonSettings = $machine->settings ?? [];
$jsonSettings['shopping_mode'] = $shoppingMode;
if ($shoppingMode !== 'basic') {
$data['card_terminal_enabled'] = false;
$data['scan_pay_esun_enabled'] = false;
$data['scan_pay_linepay_enabled'] = false;
$data['shopping_cart_enabled'] = false;
$data['welcome_gift_enabled'] = false;
$data['cash_module_enabled'] = false;
$data['pickup_module_enabled'] = false;
$jsonSettings['credit_card_enabled'] = false;
$jsonSettings['mobile_pay_enabled'] = false;
$jsonSettings['card_pay_enabled'] = false;
$jsonSettings['scan_pay_enabled'] = false;
$jsonSettings['scan_pay_tappay_enabled'] = false;
$jsonSettings['tappay_linepay'] = false;
$jsonSettings['tappay_jkopay'] = false;
$jsonSettings['tappay_pipay'] = false;
$jsonSettings['tappay_pluspay'] = false;
$jsonSettings['tappay_easywallet'] = false;
$jsonSettings['cash_bill_1000'] = false;
$jsonSettings['cash_bill_500'] = false;
$jsonSettings['cash_bill_100'] = false;
$jsonSettings['cash_coin_50'] = false;
$jsonSettings['cash_coin_10'] = false;
$jsonSettings['cash_coin_5'] = false;
$jsonSettings['cash_coin_1'] = false;
$jsonSettings['pickup_code_enabled'] = false;
$jsonSettings['pass_code_enabled'] = false;
// 副櫃系統(格子櫃功能)僅基礎版可用,非基礎版一律關閉
$jsonSettings['subcabinet_enabled'] = false;
} else {
$jsonSettings['credit_card_enabled'] = isset($settings['credit_card_enabled']) ? (bool)$settings['credit_card_enabled'] : false;
$jsonSettings['mobile_pay_enabled'] = isset($settings['mobile_pay_enabled']) ? (bool)$settings['mobile_pay_enabled'] : false;
$jsonSettings['card_pay_enabled'] = isset($settings['card_pay_enabled']) ? (bool)$settings['card_pay_enabled'] : false;
$jsonSettings['scan_pay_enabled'] = isset($settings['scan_pay_enabled']) ? (bool)$settings['scan_pay_enabled'] : false;
$jsonSettings['scan_pay_tappay_enabled'] = isset($settings['scan_pay_tappay_enabled']) ? (bool)$settings['scan_pay_tappay_enabled'] : false;
$jsonSettings['tappay_linepay'] = isset($settings['tappay_linepay']) ? (bool)$settings['tappay_linepay'] : false;
$jsonSettings['tappay_jkopay'] = isset($settings['tappay_jkopay']) ? (bool)$settings['tappay_jkopay'] : false;
$jsonSettings['tappay_pipay'] = isset($settings['tappay_pipay']) ? (bool)$settings['tappay_pipay'] : false;
$jsonSettings['tappay_pluspay'] = isset($settings['tappay_pluspay']) ? (bool)$settings['tappay_pluspay'] : false;
$jsonSettings['tappay_easywallet'] = isset($settings['tappay_easywallet']) ? (bool)$settings['tappay_easywallet'] : false;
$jsonSettings['cash_bill_1000'] = isset($settings['cash_bill_1000']) ? (bool)$settings['cash_bill_1000'] : false;
$jsonSettings['cash_bill_500'] = isset($settings['cash_bill_500']) ? (bool)$settings['cash_bill_500'] : false;
$jsonSettings['cash_bill_100'] = isset($settings['cash_bill_100']) ? (bool)$settings['cash_bill_100'] : false;
$jsonSettings['cash_coin_50'] = isset($settings['cash_coin_50']) ? (bool)$settings['cash_coin_50'] : false;
$jsonSettings['cash_coin_10'] = isset($settings['cash_coin_10']) ? (bool)$settings['cash_coin_10'] : false;
$jsonSettings['cash_coin_5'] = isset($settings['cash_coin_5']) ? (bool)$settings['cash_coin_5'] : false;
$jsonSettings['cash_coin_1'] = isset($settings['cash_coin_1']) ? (bool)$settings['cash_coin_1'] : false;
$jsonSettings['pickup_code_enabled'] = isset($settings['pickup_code_enabled']) ? (bool)$settings['pickup_code_enabled'] : false;
$jsonSettings['pass_code_enabled'] = isset($settings['pass_code_enabled']) ? (bool)$settings['pass_code_enabled'] : false;
// 副櫃系統(格子櫃功能):基礎版可設定
$jsonSettings['subcabinet_enabled'] = isset($settings['subcabinet_enabled']) ? (bool)$settings['subcabinet_enabled'] : false;
}
foreach ($allowedFields as $field) {
$jsonSettings[$field] = $data[$field];
}
// 領藥單模組:僅在「取物單(pickup_sheet)」模式下可啟用,其他模式一律關閉
$jsonSettings['pharmacy_pickup_enabled'] = ($shoppingMode === 'pickup_sheet')
? (bool) ($settings['pharmacy_pickup_enabled'] ?? false)
: false;
// 顯示語系 (languages):機台螢幕可切換的語系,最多 N 種,僅系統管理員可設定。
// 商品翻譯範圍由「公司機台語系聯集」反推,故此處異動需失效聯集快取並重建商品目錄。
// 非系統管理員即使送出 languages 亦一律忽略(不報錯,讓其仍可儲存其他設定)。
$languagesChanged = false;
if (array_key_exists('languages', $settings) && auth()->user()->isSystemAdmin()) {
$whitelist = array_keys(config('locales.supported', []));
$max = (int) config('locales.max_per_machine', 5);
// 去重、保留順序、僅留白名單內語系
$languages = array_values(array_unique(array_filter(
(array) $settings['languages'],
fn ($l) => in_array($l, $whitelist, true)
)));
if (count((array) $settings['languages']) > $max || count($languages) > $max) {
return $this->settingsError($request, __('You can select at most :max languages.', ['max' => $max]), 422);
}
$jsonSettings['languages'] = $languages;
$languagesChanged = true;
}
$machine->update([
'settings' => $jsonSettings,
'updater_id' => auth()->id()
]);
// 同時更新 model 的屬性欄位 (以防 mutator 同步,以及確保 dirty check 正確)
$machine->update(array_merge($data, ['updater_id' => auth()->id()]));
// 語系異動:失效公司語系聯集快取並重建商品目錄 i18nB012 下發內容隨之更新)
if ($languagesChanged) {
\App\Models\System\Company::forgetActiveLocales($machine->company_id);
app(\App\Services\Product\ProductCatalogService::class)->rebuildCache($machine->company_id);
}
if ($request->expectsJson()) {
return response()->json([
'success' => true,
'message' => __('Settings updated successfully.')
]);
}
return redirect()->back()->with('success', __('Settings updated successfully.'));
}
$field = $request->input('field');
$value = $request->boolean('value');
$allowedFields = [
'tax_invoice_enabled',
'card_terminal_enabled',
'scan_pay_esun_enabled',
'scan_pay_linepay_enabled',
'shopping_cart_enabled',
'welcome_gift_enabled',
'cash_module_enabled',
'member_system_enabled',
'ambient_temp_monitoring_enabled',
];
if (!in_array($field, $allowedFields)) {
return response()->json(['success' => false, 'message' => 'Invalid field'], 400);
}
$machine->update([$field => $value, 'updater_id' => auth()->id()]);
return response()->json([
'success' => true,
'message' => __('Setting updated successfully.')
]);
}
/**
* 同步系統設定至機台:透過 MQTT 下發 update_settings 指令,
* 通知機台 APP 主動回抓最新系統設定 (B014)
* 參考廣告同步 (AdvertisementController::syncToMachine) 的併行控制模式。
*/
public function syncSettings(Request $request, Machine $machine, \App\Services\Machine\MqttService $mqttService): \Illuminate\Http\JsonResponse
{
// 併行檢查:若有 pending 指令,超過 1 分鐘視為逾時可覆蓋
$pendingCommand = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'update_settings')
->where('status', 'pending')
->latest()
->first();
if ($pendingCommand) {
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
if (!$isTimedOut) {
return response()->json([
'success' => false,
'message' => __('This machine has a pending command. Please wait.')
], 422);
}
$pendingCommand->update([
'status' => 'timeout',
'note' => __('Superseded by new command (Timeout)'),
]);
}
$command = \App\Models\Machine\RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => auth()->id(),
'command_type' => 'update_settings',
'payload' => [],
'status' => 'pending',
'remark' => $request->filled('note') ? $request->input('note') : __('Manual Sync Settings'),
]);
$success = $mqttService->pushCommand(
$machine->serial_no,
'update_settings',
[],
(string) $command->id
);
if ($success) {
return response()->json([
'success' => true,
'message' => __('Sync command sent successfully.')
]);
}
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
return response()->json([
'success' => false,
'message' => __('Failed to send sync command.')
], 500);
}
/**
* 刪除機台 (僅限系統管理員)
*/
public function destroy(Machine $machine): RedirectResponse
{
if (!auth()->user()->isSystemAdmin()) {
abort(403);
}
$machine->delete();
return redirect()->route('admin.basic-settings.machines.index')
->with('success', __('Machine deleted successfully.'));
}
/**
* 公開機台分布地圖
*/
@ -294,7 +607,8 @@ class MachineSettingController extends AdminController
{
$machines = Machine::whereNotNull('latitude')
->whereNotNull('longitude')
->get(['id', 'name', 'serial_no', 'address', 'location', 'latitude', 'longitude', 'status']);
->with('machineModel')
->get(['id', 'name', 'serial_no', 'address', 'location', 'latitude', 'longitude', 'status', 'machine_model_id']);
return view('admin.basic-settings.machines.distribution', compact('machines'));
}

View File

@ -49,9 +49,9 @@ class PaymentConfigController extends AdminController
{
$request->validate([
'name' => 'required|string|max:255',
'company_id' => 'required|exists:companies,id',
'company_id' => 'nullable|exists:companies,id',
'settings' => 'required|array',
]);
] + $this->ecpayInvoiceRules(), $this->ecpayInvoiceMessages());
PaymentConfig::create([
'name' => $request->name,
@ -81,9 +81,9 @@ class PaymentConfigController extends AdminController
{
$request->validate([
'name' => 'required|string|max:255',
'company_id' => 'required|exists:companies,id',
'company_id' => 'nullable|exists:companies,id',
'settings' => 'required|array',
]);
] + $this->ecpayInvoiceRules(), $this->ecpayInvoiceMessages());
$paymentConfig->update([
'name' => $request->name,
@ -105,4 +105,30 @@ class PaymentConfigController extends AdminController
return redirect()->route('admin.basic-settings.payment-configs.index')
->with('success', __('Payment Configuration deleted successfully.'));
}
/**
* 綠界電子發票群組驗證規則:整組選填,但只要填了任一欄,
* store_id / hash_key / hash_iv / email 即全部必填(綠界開立發票需金鑰齊備,
* email 為「電話/信箱擇一」的唯一來源,缺一即無法開立)。
* 依賴 ConvertEmptyStringsToNull middleware空欄送出為 nullrequired_with 不會誤觸發。
*/
private function ecpayInvoiceRules(): array
{
$g = 'settings.ecpay_invoice';
return [
"{$g}.store_id" => "nullable|string|max:20|required_with:{$g}.hash_key,{$g}.hash_iv,{$g}.email",
"{$g}.hash_key" => "nullable|string|max:64|required_with:{$g}.store_id,{$g}.hash_iv,{$g}.email",
"{$g}.hash_iv" => "nullable|string|max:64|required_with:{$g}.store_id,{$g}.hash_key,{$g}.email",
"{$g}.email" => "nullable|email|max:100|required_with:{$g}.store_id,{$g}.hash_key,{$g}.hash_iv",
];
}
/** 綠界電子發票群組的驗證訊息(三語系)。 */
private function ecpayInvoiceMessages(): array
{
return [
'settings.ecpay_invoice.*.required_with' => __('ECPay e-invoice is optional, but once any field is filled, Store ID / Hash Key / Hash IV / Email are all required.'),
'settings.ecpay_invoice.email.email' => __('Please enter a valid ECPay e-invoice notification email.'),
];
}
}

View File

@ -6,9 +6,11 @@ use App\Http\Controllers\Controller;
use App\Models\System\Company;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Traits\ImageHandler;
class CompanyController extends Controller
{
use ImageHandler;
/**
* Display a listing of the resource.
*/
@ -38,7 +40,10 @@ class CompanyController extends Controller
->where('name', '!=', 'super-admin')
->get();
return view('admin.companies.index', compact('companies', 'template_roles'));
// 取得發生驗證錯誤的公司資訊 (用於維持 Modal 開啟)
$error_company = session('error_company_id') ? Company::find(session('error_company_id')) : null;
return view('admin.companies.index', compact('companies', 'template_roles', 'error_company'));
}
/**
@ -48,13 +53,13 @@ class CompanyController extends Controller
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'required|string|max:50|unique:companies,code',
'code' => 'nullable|string|max:50|unique:companies,code',
'original_type' => 'required|string|in:buyout,lease',
'tax_id' => 'nullable|string|max:50',
'contact_name' => 'nullable|string|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_email' => 'nullable|email|max:255',
'start_date' => 'required|date',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date',
'warranty_start_date' => 'nullable|date',
'warranty_end_date' => 'nullable|date',
@ -74,13 +79,6 @@ class CompanyController extends Controller
if (isset($validated['settings'])) {
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['tax_invoice'] = filter_var($validated['settings']['tax_invoice'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['card_terminal'] = filter_var($validated['settings']['card_terminal'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['scan_pay_esun'] = filter_var($validated['settings']['scan_pay_esun'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['scan_pay_linepay'] = filter_var($validated['settings']['scan_pay_linepay'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['shopping_cart'] = filter_var($validated['settings']['shopping_cart'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['welcome_gift'] = filter_var($validated['settings']['welcome_gift'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['cash_module'] = filter_var($validated['settings']['cash_module'] ?? false, FILTER_VALIDATE_BOOLEAN);
}
DB::transaction(function () use ($validated) {
@ -125,6 +123,7 @@ class CompanyController extends Controller
'password' => \Illuminate\Support\Facades\Hash::make($validated['admin_password']),
'name' => $validated['admin_name'] ?: ($validated['contact_name'] ?: $validated['name']),
'status' => 1,
'is_admin' => true,
]);
// 角色初始化與克隆邏輯 (優先使用選擇的角色,否則使用預設)
@ -164,13 +163,13 @@ class CompanyController extends Controller
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'code' => 'required|string|max:50|unique:companies,code,' . $company->id,
'code' => 'nullable|string|max:50|unique:companies,code,' . $company->id,
'current_type' => 'required|string|in:buyout,lease',
'tax_id' => 'nullable|string|max:50',
'contact_name' => 'nullable|string|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_email' => 'nullable|email|max:255',
'start_date' => 'required|date',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date',
'warranty_start_date' => 'nullable|date',
'warranty_end_date' => 'nullable|date',
@ -185,13 +184,6 @@ class CompanyController extends Controller
if (isset($validated['settings'])) {
$validated['settings']['enable_material_code'] = filter_var($validated['settings']['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['enable_points'] = filter_var($validated['settings']['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['tax_invoice'] = filter_var($validated['settings']['tax_invoice'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['card_terminal'] = filter_var($validated['settings']['card_terminal'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['scan_pay_esun'] = filter_var($validated['settings']['scan_pay_esun'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['scan_pay_linepay'] = filter_var($validated['settings']['scan_pay_linepay'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['shopping_cart'] = filter_var($validated['settings']['shopping_cart'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['welcome_gift'] = filter_var($validated['settings']['welcome_gift'] ?? false, FILTER_VALIDATE_BOOLEAN);
$validated['settings']['cash_module'] = filter_var($validated['settings']['cash_module'] ?? false, FILTER_VALIDATE_BOOLEAN);
}
DB::transaction(function () use ($validated, $company) {
@ -226,18 +218,14 @@ class CompanyController extends Controller
{
\Log::info('updateSettings payload:', $request->all());
$settings = $request->input('settings', []);
$currentSettings = $company->settings ?? [];
$formattedSettings = [
// 採用合併方式,防止覆蓋品牌設定或其他未包含的欄位
$formattedSettings = array_merge($currentSettings, [
'enable_material_code' => filter_var($settings['enable_material_code'] ?? false, FILTER_VALIDATE_BOOLEAN),
'enable_points' => filter_var($settings['enable_points'] ?? false, FILTER_VALIDATE_BOOLEAN),
'tax_invoice' => filter_var($settings['tax_invoice'] ?? false, FILTER_VALIDATE_BOOLEAN),
'card_terminal' => filter_var($settings['card_terminal'] ?? false, FILTER_VALIDATE_BOOLEAN),
'scan_pay_esun' => filter_var($settings['scan_pay_esun'] ?? false, FILTER_VALIDATE_BOOLEAN),
'scan_pay_linepay' => filter_var($settings['scan_pay_linepay'] ?? false, FILTER_VALIDATE_BOOLEAN),
'shopping_cart' => filter_var($settings['shopping_cart'] ?? false, FILTER_VALIDATE_BOOLEAN),
'welcome_gift' => filter_var($settings['welcome_gift'] ?? false, FILTER_VALIDATE_BOOLEAN),
'cash_module' => filter_var($settings['cash_module'] ?? false, FILTER_VALIDATE_BOOLEAN),
];
'enable_custom_branding' => filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN),
]);
// Ensure we force save the JSON cast properly
$company->settings = $formattedSettings;
@ -247,6 +235,122 @@ class CompanyController extends Controller
->with('success', __('System settings updated successfully.'));
}
/**
* 顯示客製化品牌樣式設定頁面
*/
public function editBranding(Company $company)
{
$settings = $company->settings ?? [];
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$enableCustomBranding) {
return redirect()->route('admin.permission.companies.index')
->with('error', __('Custom branding function is not enabled for this company.'));
}
return view('admin.companies.branding', compact('company'));
}
/**
* 更新公司品牌客製化樣式設定 (自訂 Logo 與歡迎詞)
*/
public function updateBranding(Request $request, Company $company)
{
\Log::info('updateBranding payload:', [
'company_id' => $company->id,
'request_all' => $request->all(),
'files' => array_keys($request->allFiles())
]);
$settings = $company->settings ?? [];
// 1. 安全檢驗:如果未授權此功能,則拒絕操作
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$enableCustomBranding) {
return redirect()->back()->with('error', __('Custom branding function is not enabled for this company.'));
}
// 2. 欄位驗證
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
'logo_file' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg,webp|max:10240', // 限制最大 10MB
'login_bg_file' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg,webp|max:10240', // 限制最大 10MB
'login_card_bg_file' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg,webp|max:10240', // 限制最大 10MB
'login_title' => 'nullable|string|max:100',
'login_main_title' => 'nullable|string|max:100',
'login_sub_title' => 'nullable|string|max:100',
'clear_logo' => 'nullable|boolean', // 用於提供清除 Logo 功能
'clear_login_bg' => 'nullable|boolean', // 清除大背景
'clear_login_card_bg' => 'nullable|boolean', // 清除卡片背景
]);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput()
->with('error', __('Validation failed. Please check the styling configurations.'));
}
$logoPath = $settings['logo_path'] ?? null;
$loginBgPath = $settings['login_bg_path'] ?? null;
$loginCardBgPath = $settings['login_card_bg_path'] ?? null;
// 3. 處理清除圖片的需求
if ($request->boolean('clear_logo')) {
if ($logoPath) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($logoPath);
$logoPath = null;
}
}
if ($request->boolean('clear_login_bg')) {
if ($loginBgPath) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($loginBgPath);
$loginBgPath = null;
}
}
if ($request->boolean('clear_login_card_bg')) {
if ($loginCardBgPath) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($loginCardBgPath);
$loginCardBgPath = null;
}
}
// 4. 處理圖片檔案上傳 (轉為高品質 webp)
if ($request->hasFile('logo_file')) {
if ($logoPath) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($logoPath);
}
$logoPath = $this->storeAsWebp($request->file('logo_file'), 'company_logos', 80);
}
if ($request->hasFile('login_bg_file')) {
if ($loginBgPath) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($loginBgPath);
}
$loginBgPath = $this->storeAsWebp($request->file('login_bg_file'), 'company_backgrounds', 85);
}
if ($request->hasFile('login_card_bg_file')) {
if ($loginCardBgPath) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($loginCardBgPath);
}
$loginCardBgPath = $this->storeAsWebp($request->file('login_card_bg_file'), 'company_backgrounds', 85);
}
// 5. 合併寫入 settings JSON 中,防止覆寫其他功能開關
$company->settings = array_merge($settings, [
'logo_path' => $logoPath,
'login_bg_path' => $loginBgPath,
'login_card_bg_path' => $loginCardBgPath,
'login_title' => $request->input('login_title'),
'login_main_title' => $request->input('login_main_title'),
'login_sub_title' => $request->input('login_sub_title'),
]);
$company->save();
return redirect()->route('admin.permission.companies.branding.edit', $company->id)
->with('success', __('Brand styling updated successfully.'));
}
/**
* 切換客戶狀態
*/

View File

@ -3,8 +3,10 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Transaction\Order;
use App\Models\Machine\Machine;
use Illuminate\Http\Request;
use Carbon\Carbon;
class DashboardController extends Controller
{
@ -16,12 +18,37 @@ class DashboardController extends Controller
$perPage = 10;
// 從資料庫獲取真實統計數據
$totalRevenue = \App\Models\Member\MemberWallet::sum('balance');
$activeMachines = Machine::online()->count();
$offlineMachines = Machine::offline()->count();
$alertsPending = Machine::hasError()->count();
$alertsPending = Machine::hasAnyAlert()->count();
$memberCount = \App\Models\Member\Member::count();
// 交易營收統計 (真實數據)
$todayRevenue = Order::where('payment_status', Order::PAYMENT_STATUS_SUCCESS)
->whereDate('payment_at', Carbon::today())
->sum('pay_amount');
$yesterdayRevenue = Order::where('payment_status', Order::PAYMENT_STATUS_SUCCESS)
->whereDate('payment_at', Carbon::yesterday())
->sum('pay_amount');
$dayBeforeRevenue = Order::where('payment_status', Order::PAYMENT_STATUS_SUCCESS)
->whereDate('payment_at', Carbon::yesterday()->subDay())
->sum('pay_amount');
$monthlyRevenue = Order::where('payment_status', Order::PAYMENT_STATUS_SUCCESS)
->whereMonth('payment_at', Carbon::now()->month)
->whereYear('payment_at', Carbon::now()->year)
->sum('pay_amount');
// 計算昨日增長趨勢 (百分比)
$yesterdayTrend = 0;
if ($yesterdayRevenue > 0) {
$yesterdayTrend = (($todayRevenue - $yesterdayRevenue) / $yesterdayRevenue) * 100;
} elseif ($todayRevenue > 0) {
$yesterdayTrend = 100;
}
// 獲取機台列表 (分頁)
$machines = Machine::when($request->search, function ($query, $search) {
$query->where(function ($q) use ($search) {
@ -29,16 +56,26 @@ class DashboardController extends Controller
->orWhere('serial_no', 'like', "%{$search}%");
});
})
->withSum('slots', 'stock')
->withSum('slots', 'max_stock')
->withSum(['orders as today_sales_sum' => function($query) {
$query->whereDate('payment_at', now()->toDateString())
->where('payment_status', Order::PAYMENT_STATUS_SUCCESS);
}], 'pay_amount')
->orderByDesc('last_heartbeat_at')
->paginate($perPage)
->withQueryString();
return view('admin.dashboard', compact(
'totalRevenue',
'activeMachines',
'offlineMachines',
'alertsPending',
'memberCount',
'todayRevenue',
'yesterdayRevenue',
'dayBeforeRevenue',
'monthlyRevenue',
'yesterdayTrend',
'machines'
));
}

View File

@ -25,6 +25,9 @@ class GeocodingController extends Controller
$address = $request->input('address');
// 1. 清洗台灣地址中的樓層、室、地下室與括號備註,避免干擾地理編碼解析
$address = $this->cleanFloorInfo($address);
// 策略 1結構化搜尋台灣地址精確度最高
$parsed = $this->parseTaiwanAddress($address);
@ -114,8 +117,8 @@ class GeocodingController extends Controller
$streetPart = $address;
}
// 拆分路名與門牌號碼
preg_match('/^(.+?(?:路|街|大道|巷|弄)(?:[一二三四五六七八九十]+段)?)\s*(.*)/u', $streetPart, $roadMatch);
// 拆分路名與門牌號碼(層級感知台灣地址結構,完整保留巷弄)
preg_match('/^(.+?(?:路|街|大道)(?:[一二三四五六七八九十]+段)?(?:[0-9一二三四五六七八九十百]+巷)?(?:[0-9一二三四五六七八九十百]+弄)?)\s*(.*)/u', $streetPart, $roadMatch);
$street = '';
$houseNumber = '';
@ -134,4 +137,21 @@ class GeocodingController extends Controller
'street' => $fullStreet,
];
}
/**
* 清洗台灣地址中的樓層、室、地下室與括號備註,避免干擾 OpenStreetMap 解析
*/
private function cleanFloorInfo(string $address): string
{
// 1. 移除常見的樓層與地下室資訊,以及其後方的所有贅字:例如 "2樓", "12樓之3", "89F", "B1", "地下1樓" 等
$address = preg_replace('/(?:地下一?|地下)?(?:[0-9一二三四五六七八九十百]+樓|[0-9a-zA-Z]+[fF]|[bB][0-9]+)(?:之[0-9一二三四五六七八九十百]+)?.*/u', '', $address);
// 2. 移除 "x室", "x房", "x戶" 等室內資訊
$address = preg_replace('/(?:[0-9a-zA-Z一二三四五六七八九十百]+(?:室|房|戶)).*/u', '', $address);
// 3. 移除常見 the 括號備註,例如 "(五樓)", "(A棟)", "(總統府)"
$address = preg_replace('/[\(][^\)]+[\)]/u', '', $address);
return trim($address);
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\System\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ImpersonateController extends Controller
{
/**
* Start impersonating a user.
*/
public function store(Request $request)
{
$request->validate([
'user_id' => 'required|exists:users,id',
]);
$currentUser = $request->user();
// 1. Only system admins can impersonate
if (!$currentUser->isSystemAdmin()) {
abort(403, '只有系統管理員可以使用此功能。');
}
// 2. Prevent nested impersonation (cannot impersonate someone while already impersonating)
if (session()->has('impersonated_by')) {
return back()->with('error', '您目前已經處於切換狀態,無法再次切換。請先退出目前的切換狀態。');
}
$targetUserId = $request->input('user_id');
// 3. Cannot impersonate oneself
if ($currentUser->id == $targetUserId) {
return back()->with('error', '無法切換至自己的帳號。');
}
$targetUser = User::findOrFail($targetUserId);
// 4. Set session and login
session()->put('impersonated_by', $currentUser->id);
Auth::loginUsingId($targetUser->id);
return redirect()->route('admin.dashboard')->with('success', "已成功切換至 {$targetUser->name} 的帳號。");
}
/**
* Stop impersonating and return to the original user.
*/
public function leave(Request $request)
{
if (!session()->has('impersonated_by')) {
return back()->with('error', '您目前並未處於切換狀態。');
}
$originalUserId = session()->pull('impersonated_by');
Auth::loginUsingId($originalUserId);
return redirect()->route('admin.dashboard')->with('success', '已退出切換狀態,返回原帳號。');
}
/**
* Fetch companies for the impersonate modal.
*/
public function companies(Request $request)
{
if (!$request->user()->isSystemAdmin()) abort(403);
$companies = \App\Models\System\Company::select('id', 'name')->orderBy('name')->get();
return response()->json($companies);
}
/**
* Fetch users for the impersonate modal, optionally filtered by company.
*/
public function users(Request $request)
{
if (!$request->user()->isSystemAdmin()) abort(403);
$query = User::select('id', 'name', 'username', 'email', 'company_id');
if ($request->filled('company_id')) {
$query->where('company_id', $request->company_id);
} else {
// Include system accounts (company_id is null) when no company is selected?
// Actually let's just return all users if no company selected, or just system accounts.
// But if company_id is explicitly sent as 'system', we filter for null.
if ($request->company_id === 'system') {
$query->whereNull('company_id');
}
}
$users = $query->orderBy('name')->get();
return response()->json($users);
}
}

View File

@ -32,10 +32,9 @@ class MachinePermissionController extends AdminController
}])
->whereNotNull('company_id');
// 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入)
if (!$currentUser->isSystemAdmin()) {
$userQuery->where('company_id', $currentUser->company_id);
} elseif ($company_id) {
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
$userQuery->visibleTo($currentUser);
if ($currentUser->isSystemAdmin() && $company_id) {
// 系統管理員的篩選邏輯
$userQuery->where('company_id', $company_id);
}
@ -54,6 +53,31 @@ class MachinePermissionController extends AdminController
return view('admin.machines.permissions', compact('users_list', 'companies'));
}
/**
* 操作者「可授權」的機台 ID 集合(授權子集約束):
* - 系統管理員:指定公司(或全部)的所有機台
* - 主帳號:同公司全部機台
* - 子帳號僅自己被授權的機台machine_user
*/
private function assignableMachineIds(User $operator, ?int $companyId): \Illuminate\Support\Collection
{
// 目標帳號無所屬公司(如系統管理員帳號)時,沒有可授權的公司機台。
if (is_null($companyId)) {
return collect();
}
if ($operator->isSystemAdmin() || $operator->is_admin) {
return Machine::withoutGlobalScope('machine_access')
->where('company_id', $companyId)
->pluck('id');
}
return $operator->machines()
->withoutGlobalScope('machine_access')
->where('machines.company_id', $companyId)
->pluck('machines.id');
}
/**
* AJAX: 取得特定帳號的機台分配狀態
*/
@ -61,17 +85,18 @@ class MachinePermissionController extends AdminController
{
$currentUser = auth()->user();
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
// 層級越權防護:只能操作可管轄範圍內的帳號。
if (!$currentUser->canManageAccount($user)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示)
// 可授權機台清單限縮為「操作者本身可授權」的機台子集,避免把自己沒有的機台授權出去。
$assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
$machines = Machine::withoutGlobalScope('machine_access')
->where('company_id', $user->company_id)
->whereIn('id', $assignableIds)
->get(['id', 'name', 'serial_no']);
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
$assignedIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id')->toArray();
return response()->json([
'user' => $user,
@ -87,8 +112,8 @@ class MachinePermissionController extends AdminController
{
$currentUser = auth()->user();
// 安全檢查
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
// 層級越權防護:只能操作可管轄範圍內的帳號。
if (!$currentUser->canManageAccount($user)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
@ -97,20 +122,27 @@ class MachinePermissionController extends AdminController
'machine_ids.*' => 'exists:machines,id'
]);
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司 (使用 withoutGlobalScope 避免管理員自身權限影響驗證邏輯)
if ($request->has('machine_ids')) {
$machineIds = array_unique($request->machine_ids);
$validCount = Machine::withoutGlobalScope('machine_access')
->where('company_id', $user->company_id)
->whereIn('id', $machineIds)
->count();
if ($validCount !== count($machineIds)) {
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
}
// 授權子集約束:被指派的機台必須是「操作者本身可授權機台」的子集,
// 既確保同公司,也避免子帳號把自己沒被授權的機台授權給他人。
$assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
$submittedIds = collect($request->machine_ids ?? [])->map(fn ($id) => (int) $id)->unique();
if ($submittedIds->diff($assignableIds)->isNotEmpty()) {
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
}
$user->machines()->sync($request->machine_ids ?? []);
// 只在「操作者可授權子集」範圍內做增刪;範圍外的既有授權一律保留,避免覆蓋式 sync 誤刪
// 操作者看不到(不在其可授權子集內)的機台。明確 detach/attach 以避開 machine_access
// 全域 scope 對 sync 取「現有附加」造成的干擾(該 scope 依登入者過濾 Machine 查詢)。
$existingIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id');
$currentInScope = $existingIds->intersect($assignableIds);
$toAttach = $submittedIds->diff($currentInScope);
$toDetach = $currentInScope->diff($submittedIds);
if ($toDetach->isNotEmpty()) {
$user->machines()->detach($toDetach->all());
}
if ($toAttach->isNotEmpty()) {
$user->machines()->attach($toAttach->all());
}
$message = __('Machine permissions updated successfully.');
session()->flash('success', $message);

View File

@ -2,9 +2,15 @@
namespace App\Http\Controllers\Admin;
use App\Jobs\Product\SendProductSyncCommandJob;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineProductPrice;
use App\Models\Product\Product;
use App\Models\System\Company;
use App\Services\Machine\MachineService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class MachineController extends AdminController
@ -16,8 +22,10 @@ class MachineController extends AdminController
public function index(Request $request): View
{
$per_page = $request->input('per_page', 10);
$companyId = trim((string) $request->input('company_id', ''));
$query = Machine::query();
$currentUser = auth()->user();
// 搜尋:名稱或序號
if ($search = $request->input('search')) {
@ -27,13 +35,36 @@ class MachineController extends AdminController
});
}
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
// 刷卡機狀態:以彙總/相關子查詢預載,避免列表逐列 N+1
$query->select('machines.*')
->withCount([
'logs as card_terminal_error_count' => fn($q) => $q->where('type', 'card_terminal')->where('level', 'error')->where('is_resolved', false),
'logs as card_terminal_warning_count' => fn($q) => $q->where('type', 'card_terminal')->where('level', 'warning')->where('is_resolved', false),
])
->addSelect(['latest_card_terminal_log_at' => \App\Models\Machine\MachineLog::select('created_at')
->whereColumn('machine_id', 'machines.id')
->where('type', 'card_terminal')
->whereIn('level', ['error', 'warning'])
->where('is_resolved', false)
->latest()
->limit(1)
]);
// 預加載統計資料
$machines = $query->orderBy("last_heartbeat_at", "desc")
->orderBy("id", "desc")
->paginate($per_page)
->withQueryString();
return view('admin.machines.index', compact('machines'));
$companies = $currentUser->isSystemAdmin()
? Company::select('id', 'name', 'code')->orderBy('name')->get()
: collect();
return view('admin.machines.index', compact('machines', 'companies'));
}
/**
@ -43,10 +74,48 @@ class MachineController extends AdminController
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'ambient_temp_setting' => 'nullable|integer',
]);
$machine->update($validated);
if ($machine->wasChanged('ambient_temp_setting')) {
$newSetting = $machine->ambient_temp_setting;
// 將先前未執行的同類型指令標記為 superseded (被取代)
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'ambient_temp_limit')
->where('status', 'pending')
->update([
'status' => 'superseded',
'remark' => __('Superseded by new command'),
'executed_at' => now(),
]);
// 建立新指令
$command = \App\Models\Machine\RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => auth()->id() ?: 1,
'command_type' => 'ambient_temp_limit',
'payload' => [
'temperature' => $newSetting !== null ? (int)$newSetting : null
],
'status' => 'pending',
'remark' => $newSetting !== null
? __('Temperature reaches :temp degrees, the fan will turn on', ['temp' => $newSetting])
: __('Disabled ambient temperature monitoring')
]);
// 發送 MQTT 指令
$mqttService = app(\App\Services\Machine\MqttService::class);
$mqttService->pushCommand(
$machine->serial_no,
$command->command_type,
$command->payload,
(string)$command->id
);
}
return redirect()->route('admin.machines.index')
->with('success', __('Machine updated successfully.'));
}
@ -139,9 +208,12 @@ class MachineController extends AdminController
->get();
// 取得該機台目前所有等待中的庫存更新指令 (Pending Commands)
// 僅將「1 分鐘內」的 pending 視為鎖定中;超過 1 分鐘未收到機台回傳 (ACK)
// 視為逾時,自動解除鎖定,讓使用者可重新編輯與重新下發 (與 dispense 逾時邏輯一致)
$pendingSlotNos = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->where('status', 'pending')
->where('created_at', '>=', now()->subMinute())
->get()
->pluck('payload.slot_no')
->flatten()
@ -149,7 +221,7 @@ class MachineController extends AdminController
// 將待處理狀態注入貨道物件
$slots->each(function ($slot) use ($pendingSlotNos) {
$slot->is_pending = in_array((int)$slot->slot_no, $pendingSlotNos);
$slot->is_pending = in_array((int) $slot->slot_no, $pendingSlotNos);
});
return response()->json([
@ -164,6 +236,11 @@ class MachineController extends AdminController
*/
public function updateSlotExpiry(Request $request, Machine $machine)
{
// 確保庫存數值為整數,處理如 "01" 轉為 1 的情況
if ($request->has('stock') && !is_null($request->stock) && $request->stock !== '') {
$request->merge(['stock' => (int) $request->stock]);
}
$validated = $request->validate([
'slot_no' => 'required|integer',
'stock' => 'nullable|integer|min:0',
@ -188,6 +265,117 @@ class MachineController extends AdminController
}
}
/**
* AJAX: 遠端鎖定/解鎖單一貨道(暫停販售)。
* updateSlotExpiry 共用 updateSlot 下發 update_inventory只帶 is_locked不動效期/庫存。
*/
public function toggleSlotLock(Request $request, Machine $machine)
{
$validated = $request->validate([
'slot_no' => 'required|integer',
'is_locked' => 'required|boolean',
]);
try {
$this->machineService->updateSlot($machine, $validated, auth()->id());
return response()->json([
'success' => true,
'message' => $validated['is_locked'] ? __('Slot locked.') : __('Slot unlocked.'),
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 422);
}
}
/**
* 機台專屬定價頁:列出該機台「所屬公司的完整商品目錄」(非僅貨道商品),
* 可在上貨前先把機台價/會員價訂好。未填=沿用全域 products 價。
*/
public function pricing(Machine $machine): View
{
$products = Product::where('company_id', $machine->company_id)
->active()
->orderBy('name')
->get(['id', 'name', 'image_url', 'barcode', 'price', 'member_price']);
$overrides = $machine->productPrices()->get()->keyBy('product_id');
$items = $products->map(function (Product $p) use ($overrides) {
$ov = $overrides->get($p->id);
return [
'product_id' => $p->id,
'name' => $p->name,
'image_url' => $p->image_url,
'barcode' => $p->barcode,
'global_price' => (float) $p->price,
'global_member_price' => $p->member_price !== null ? (float) $p->member_price : null,
'override_price' => $ov && $ov->price !== null ? (float) $ov->price : null,
'override_member_price' => $ov && $ov->member_price !== null ? (float) $ov->member_price : null,
];
})->values();
return view('admin.machines.pricing', compact('machine', 'items'));
}
/**
* 批次儲存機台專屬定價。
*
* - price/member_price 皆空 刪除覆蓋,回到全域價。
* - 任一有值 建立/更新覆蓋;會員價於 B012 下發時自動夾為不高於機台售價。
* - quiet 寫入避免逐筆觸發 observer 重複推送,最後統一推一次 B012。
* - 商品須屬於該機台公司(跨租戶防護)。
*/
public function updatePricing(Request $request, Machine $machine): \Illuminate\Http\JsonResponse
{
$validated = $request->validate([
'items' => 'required|array',
'items.*.product_id' => 'required|integer|exists:products,id',
'items.*.price' => 'nullable|numeric|min:1|max:99999999',
'items.*.member_price' => 'nullable|numeric|min:1|max:99999999',
]);
$allowedProductIds = Product::where('company_id', $machine->company_id)
->pluck('id')
->flip();
DB::transaction(function () use ($validated, $machine, $allowedProductIds) {
foreach ($validated['items'] as $item) {
$productId = (int) $item['product_id'];
if (!$allowedProductIds->has($productId)) {
continue;
}
$price = isset($item['price']) && $item['price'] !== '' ? (float) $item['price'] : null;
$memberPrice = isset($item['member_price']) && $item['member_price'] !== '' ? (float) $item['member_price'] : null;
if ($price === null && $memberPrice === null) {
$machine->productPrices()->where('product_id', $productId)->delete();
continue;
}
$row = MachineProductPrice::firstOrNew([
'machine_id' => $machine->id,
'product_id' => $productId,
]);
$row->price = $price;
$row->member_price = $memberPrice;
$row->saveQuietly();
}
});
// 統一推一次 B012 同步給該機台(覆蓋於請求當下即時套用,無需失效公司快取)
SendProductSyncCommandJob::dispatch($machine->id, __('Machine pricing updated'), Auth::id());
return response()->json([
'success' => true,
'message' => __('Machine pricing saved and sync command pushed.'),
]);
}
/**
* 取得機台統計數據 (AJAX)
*/
@ -216,4 +404,81 @@ class MachineController extends AdminController
{
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
}
/**
* AJAX: 清除機台所有未解決的警告與異常 (手動排除)
*/
public function resolveLogs(Machine $machine)
{
$machine->logs()
->where('is_resolved', false)
->whereIn('level', ['error', 'warning'])
->update(['is_resolved' => true]);
return response()->json([
'success' => true,
'message' => __('All issues marked as resolved.')
]);
}
/**
* AJAX: 取得機台溫度歷史紀錄 (供圖表使用)
*/
public function temperatureAjax(Request $request, Machine $machine)
{
$startDate = $request->get('start_date');
$endDate = $request->get('end_date');
$logs = $machine->logs()
->where('message', 'Temperature reported: :temp°C')
->when($startDate, function ($query, $start) {
return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->oldest()
->get();
$chartData = $logs->map(fn($log) => [
'x' => $log->created_at->getTimestamp() * 1000,
'y' => (int) ($log->context['temp'] ?? 0),
])->values()->toArray();
return response()->json([
'success' => true,
'data' => $chartData
]);
}
/**
* AJAX: 取得機台環境溫度歷史紀錄 (供圖表使用)
*/
public function ambientTemperatureAjax(Request $request, Machine $machine)
{
$startDate = $request->get('start_date');
$endDate = $request->get('end_date');
$logs = $machine->logs()
->where('type', 'ambient_temp')
->where('message', 'Ambient temperature reported: :temp°C')
->when($startDate, function ($query, $start) {
return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->oldest()
->get();
$chartData = $logs->map(fn($log) => [
'x' => $log->created_at->getTimestamp() * 1000,
'y' => (int) ($log->context['temp'] ?? 0),
])->values()->toArray();
return response()->json([
'success' => true,
'data' => $chartData
]);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Machine\Machine;
use App\Models\Machine\MaintenanceRecord;
use App\Models\System\Company;
use App\Traits\ImageHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -21,6 +22,9 @@ class MaintenanceController extends Controller
{
$this->authorize('viewAny', MaintenanceRecord::class);
$currentUser = auth()->user();
$companyId = trim((string) $request->input('company_id', ''));
$query = MaintenanceRecord::with(['machine', 'user', 'company'])
->whereHas('machine') // 確保僅顯示該帳號「看得見」的機台紀錄,避開因權限隔離導致的 null 報錯
->latest('maintenance_at');
@ -38,9 +42,16 @@ class MaintenanceController extends Controller
$query->where('category', $request->category);
}
$records = $query->paginate(15)->withQueryString();
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
return view('admin.maintenance.index', compact('records'));
$records = $query->paginate(15)->withQueryString();
$companies = $currentUser->isSystemAdmin()
? Company::select('id', 'name', 'code')->orderBy('name')->get()
: collect();
return view('admin.maintenance.index', compact('records', 'companies'));
}
/**

View File

@ -10,30 +10,30 @@ class PermissionController extends Controller
// 權限角色設定
public function roles()
{
$per_page = request()->input('per_page', 10);
$per_page = request()->input('roles_per_page', request()->input('per_page', 10));
$user = auth()->user();
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
// 租戶隔離:租戶只能看到自己公司的角色
if (!$user->isSystemAdmin()) {
$query->where('company_id', $user->company_id);
}
// 層級隔離:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己建立的角色。
$query->visibleTo($user);
// 搜尋:角色名稱
if ($search = request()->input('search')) {
// 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search
$search = request()->input('roles_search', request()->input('search'));
if ($search) {
$query->where('name', 'like', "%{$search}%");
}
// 篩選:公司名稱 (僅限系統管理員)
if ($user->isSystemAdmin() && request()->filled('company_id')) {
if (request()->company_id === 'system') {
// 篩選:公司名稱(支援 roles_company_id 命名空間 與舊版 company_id
$company_id = request()->input('roles_company_id', request()->input('company_id'));
if ($user->isSystemAdmin() && filled($company_id)) {
if ($company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', request()->company_id);
$query->where('company_id', $company_id);
}
}
$roles = $query->latest()->paginate($per_page)->withQueryString();
$roles = $query->latest()->paginate($per_page, ['*'], 'roles_page')->withQueryString();
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
// 權限分組邏輯中的標題與過濾
@ -66,13 +66,9 @@ class PermissionController extends Controller
$role = new \App\Models\System\Role();
$user = auth()->user();
// 權限遞迴約束
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
// 權限遞迴約束由 getFilteredPermissions 處理
$all_permissions = $permissionQuery->get()->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
$all_permissions = $this->getFilteredPermissions($user);
$title = request()->routeIs('*.sub-account-roles.create') ? __('Create Sub Account Role') : __('Create New Role');
$back_url = request()->routeIs('*.sub-account-roles.create')
@ -90,21 +86,14 @@ class PermissionController extends Controller
$role = \App\Models\System\Role::findOrFail($id);
$user = auth()->user();
// 權限遞迴約束:租戶管理員只能看到並指派自己擁有的權限
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
// 層級隔離:子帳號只能編輯自己建立的角色,不可開啟上層/旁線建立的角色。
if (!$role->canBeManagedBy($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
// 權限分組邏輯
$all_permissions = $permissionQuery->get()
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(function($perm) {
if (str_starts_with($perm->name, 'menu.')) {
return 'menu';
}
return 'other';
});
// 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理
$all_permissions = $this->getFilteredPermissions($user);
// 根據路由決定標題
$title = request()->routeIs('*.sub-account-roles.edit') ? __('Edit Sub Account Role') : __('Edit Role Permissions');
@ -140,6 +129,7 @@ class PermissionController extends Controller
'name' => $validated['name'],
'guard_name' => 'web',
'company_id' => $is_system ? null : auth()->user()->company_id,
'created_by' => auth()->id(),
'is_system' => $is_system,
]);
@ -156,7 +146,7 @@ class PermissionController extends Controller
// 如果不是系統角色,排除主選單的系統權限
if (!$is_system) {
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
$perms = array_diff($perms, ['menu.basic', 'menu.permissions']);
}
$role->syncPermissions($perms);
}
@ -196,6 +186,11 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('System roles cannot be modified by tenant administrators.'));
}
// 層級隔離:子帳號只能修改自己建立的角色。
if (!$role->canBeManagedBy(auth()->user())) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
$updateData = [
@ -218,7 +213,7 @@ class PermissionController extends Controller
// 如果不是系統角色,排除主選單的系統權限
if (!$is_system) {
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
$perms = array_diff($perms, ['menu.basic', 'menu.permissions']);
}
$role->syncPermissions($perms);
@ -237,10 +232,20 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('The Super Admin role cannot be deleted.'));
}
// 公司主帳號角色保護:比照 super-admin公司層級的「管理員」角色為該公司最高權限角色不可刪除。
if ($role->is_company_admin) {
return redirect()->back()->with('error', __('The company admin role cannot be deleted.'));
}
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
return redirect()->back()->with('error', __('System roles cannot be deleted by tenant administrators.'));
}
// 層級隔離:子帳號只能刪除自己建立的角色。
if (!$role->canBeManagedBy(auth()->user())) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
if ($role->users()->count() > 0) {
return redirect()->back()->with('error', __('Cannot delete role with active users.'));
}
@ -259,85 +264,69 @@ class PermissionController extends Controller
{
$user = auth()->user();
$isSubAccountRoute = $request->routeIs('admin.data-config.sub-accounts');
$tab = $request->input('tab', 'accounts');
$tab = $request->input('tab', 'accounts'); // 僅用於設定 Alpine activeTab 初始值
// 初始化變數
$users = collect();
$roles = collect();
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
// ── 帳號列表 ──────────────────────────────────────────────────
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines'])->visibleTo($user);
if ($search = $request->input('accounts_search', $request->input('search'))) {
$usersQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($user->isSystemAdmin() && $request->filled('accounts_company_id')) {
$cid = $request->input('accounts_company_id');
$cid === 'system' ? $usersQuery->whereNull('company_id') : $usersQuery->where('company_id', $cid);
}
$accounts_per_page = $request->input('accounts_per_page', 10);
$users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString();
// Modal 用的角色選單(不分頁)。層級隔離:子帳號僅見自己建立的角色(與角色清單一致)。
// 預先載入 permissions 以避免下方子集過濾造成 N+1。
$rolesForSelect = \App\Models\System\Role::query()->with('permissions')->visibleTo($user);
$roles = $rolesForSelect->get();
// 子集過濾:租戶只能指派「權限為自身子集」的角色(與 store/update 的後端 guard 一致,避免下拉出現越權選項)。
if (!$user->isSystemAdmin()) {
$operatorPerms = $user->getAllPermissions()->pluck('name');
$roles = $roles->filter(
fn($r) => collect($r->getPermissionNames())->diff($operatorPerms)->isEmpty()
)->values();
}
// ── 角色列表 (僅 data-config Tab 版需要) ─────────────────────
$paginated_roles = collect();
$all_permissions = collect();
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
if ($isSubAccountRoute && $tab === 'roles') {
// 處理角色分頁邏輯 (移植自 roles())
$per_page = $request->input('per_page', 10);
$roles_query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
if (!$user->isSystemAdmin()) {
$roles_query->where('company_id', $user->company_id);
if ($isSubAccountRoute) {
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user);
if ($search = $request->input('roles_search')) {
$rolesQuery->where('name', 'like', "%{$search}%");
}
if ($search = $request->input('search')) {
$roles_query->where('name', 'like', "%{$search}%");
if ($user->isSystemAdmin() && $request->filled('roles_company_id')) {
$cid = $request->input('roles_company_id');
$cid === 'system' ? $rolesQuery->where('is_system', true) : $rolesQuery->where('company_id', $cid);
}
$roles_per_page = $request->input('roles_per_page', 10);
$paginated_roles = $rolesQuery->latest()->paginate($roles_per_page, ['*'], 'roles_page')->withQueryString();
if ($user->isSystemAdmin() && $request->filled('company_id')) {
if ($request->company_id === 'system') {
$roles_query->where('is_system', true);
} else {
$roles_query->where('company_id', $request->company_id);
}
}
$paginated_roles = $roles_query->latest()->paginate($per_page)->withQueryString();
// 權限分組邏輯
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
$all_permissions = $permissionQuery->get()
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
} else {
// 處理帳號名單邏輯
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
if (!$user->isSystemAdmin()) {
$query->where('company_id', $user->company_id);
}
if ($search = $request->input('search')) {
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($user->isSystemAdmin() && $request->filled('company_id')) {
if ($request->company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', $request->company_id);
}
}
$per_page = $request->input('per_page', 10);
$users = $query->latest()->paginate($per_page)->withQueryString();
$roles_query = \App\Models\System\Role::query();
if (!$user->isSystemAdmin()) {
$roles_query->forCompany($user->company_id);
}
$roles = $roles_query->get();
}
$title = $isSubAccountRoute ? __('Sub Account Management') : __('Account Management');
$view = $isSubAccountRoute ? 'admin.data-config.accounts' : 'admin.permission.accounts';
return view('admin.data-config.accounts', compact(
return view($view, compact(
'users', 'companies', 'roles', 'paginated_roles', 'all_permissions', 'title', 'tab', 'currentUserRoleIds'
));
}
@ -358,6 +347,12 @@ class PermissionController extends Controller
'phone' => 'nullable|string|max:20',
]);
// 深度上限level >= MAX_LEVEL 的子帳號不可再建立下層(系統管理員不受限)。
// 此為後端硬擋,獨立於權限判斷(即使持有子帳號管理權限也不得突破層級)。
if (!auth()->user()->canCreateSubAccount()) {
return redirect()->back()->with('error', __('Sub-accounts at this level cannot create further sub-accounts.'));
}
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
@ -404,6 +399,7 @@ class PermissionController extends Controller
'guard_name' => 'web',
'company_id' => $company_id,
'is_system' => false,
'is_company_admin' => true,
]);
$newRole->syncPermissions($role->getPermissionNames());
$role = $newRole;
@ -413,6 +409,39 @@ class PermissionController extends Controller
}
}
// 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免透過指派高權限角色造成權限提升。
if (!auth()->user()->isSystemAdmin()) {
$operatorPerms = auth()->user()->getAllPermissions()->pluck('name');
if (collect($role->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) {
return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.'));
}
}
// 主帳號唯一性:一家公司只在「尚無主帳號」時,由系統管理員建立的第一個帳號自動成為主帳號 (is_admin)。
// 之後建立的帳號(含子帳號自建)一律 is_admin=false確保每家公司恰有一個主帳號。
$creator = auth()->user();
$companyHasAdmin = $company_id
? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->exists()
: false;
$isMainAccount = $creator->isSystemAdmin() && !empty($company_id) && !$companyHasAdmin;
// 樹狀層級歸屬:
// - 主帳號level 0、parent_id null。
// - 系統管理員建立的非主帳號掛在該公司主帳號下、level 1。
// - 租戶自建掛在建立者下、level = 建立者 level + 1。
if ($isMainAccount) {
$parentId = null;
$level = 0;
} elseif ($creator->isSystemAdmin()) {
$parentId = $company_id
? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->value('id')
: null;
$level = $parentId ? 1 : 0;
} else {
$parentId = $creator->id;
$level = $creator->level + 1;
}
$user = \App\Models\System\User::create([
'name' => $validated['name'],
'username' => $validated['username'],
@ -420,8 +449,10 @@ class PermissionController extends Controller
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
'status' => $validated['status'],
'company_id' => $company_id,
'parent_id' => $parentId,
'level' => $level,
'phone' => $validated['phone'] ?? null,
'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])),
'is_admin' => $isMainAccount,
]);
$user->assignRole($role);
@ -440,6 +471,11 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('System super admin accounts can only be modified by other super admins.'));
}
// 層級越權防護:租戶只能管理可管轄範圍內的帳號(主帳號=同公司全部;子帳號=自己直接建立的下層)。
if (!auth()->user()->canManageAccount($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this account.'));
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'username' => 'required|string|max:255|unique:users,username,' . $id,
@ -530,7 +566,7 @@ class PermissionController extends Controller
'guard_name' => 'web',
'company_id' => $target_company_id,
'is_system' => false,
'is_admin' => true,
'is_company_admin' => true,
]);
$newRole->syncPermissions($roleObj->getPermissionNames());
$roleObj = $newRole;
@ -539,6 +575,14 @@ class PermissionController extends Controller
}
}
// 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免權限提升。
if (!auth()->user()->isSystemAdmin()) {
$operatorPerms = auth()->user()->getAllPermissions()->pluck('name');
if (collect($roleObj->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) {
return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.'));
}
}
$user->update($updateData);
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
@ -557,15 +601,31 @@ class PermissionController extends Controller
public function destroyAccount($id)
{
$user = \App\Models\System\User::findOrFail($id);
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
return redirect()->back()->with('error', __('System super admin accounts can only be deleted by other super admins.'));
}
// 層級越權防護:租戶只能刪除可管轄範圍內的帳號。
if (!auth()->user()->canManageAccount($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this account.'));
}
if ($user->id === auth()->id()) {
return redirect()->back()->with('error', __('You cannot delete your own account.'));
}
// 主帳號保護:若該帳號是公司唯一主帳號,且公司仍有其他帳號,禁止刪除(須先轉移主帳號身分),
// 以維持「有帳號的公司恰有一個主帳號」的不變量。公司僅剩此主帳號時允許刪除(公司歸零)。
if ($user->is_admin && $user->company_id) {
$hasOtherAccounts = \App\Models\System\User::where('company_id', $user->company_id)
->where('id', '!=', $user->id)
->exists();
if ($hasOtherAccounts) {
return redirect()->back()->with('error', __('Cannot delete the company main account while other accounts exist. Please transfer the main account role first.'));
}
}
// 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重命名唯一欄位
$timestamp = now()->getTimestamp();
$user->username = $user->username . '.deleted.' . $timestamp;
@ -586,10 +646,57 @@ class PermissionController extends Controller
return back()->with('error', __('Only Super Admins can change other Super Admin status.'));
}
// 層級越權防護:租戶只能切換可管轄範圍內的帳號狀態。
if (!auth()->user()->canManageAccount($user)) {
return back()->with('error', __('You do not have permission to manage this account.'));
}
$user->status = $user->status ? 0 : 1;
$user->save();
$statusText = $user->status ? __('Enabled') : __('Disabled');
return back()->with('success', __('Account :name status has been changed to :status.', ['name' => $user->name, 'status' => $statusText]));
}
/**
* 獲取過濾後的活動模組權限清單
*/
private function getFilteredPermissions($user)
{
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
$activeModules = [
'menu.machines',
'menu.warehouses',
'menu.sales',
'menu.analysis',
'menu.data-config',
'menu.remote',
'menu.basic',
'menu.permissions',
];
return $permissionQuery->get()
->filter(function($p) use ($activeModules) {
if (str_starts_with($p->name, 'menu.')) {
foreach ($activeModules as $module) {
if ($p->name === $module || str_starts_with($p->name, $module . '.')) {
return true;
}
}
return false;
}
return true;
})
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(function($perm) {
if (str_starts_with($perm->name, 'menu.')) {
return 'menu';
}
return 'other';
});
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Models\Machine\Machine;
use App\Models\System\SystemOperationLog;
use App\Models\Transaction\Order;
use App\Services\Transaction\PharmacyPickupService;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
/**
* 領藥單管理(銷售管理 領藥單)。
*
* 授權雙閘:
* 1. 選單/頁面:權限 menu.sales.pharmacy-pickup角色/帳號權限可勾選)。
* 2. 機台行為:機台系統設定 shopping_mode=pickup_sheet pharmacy_pickup_enabled=true
*/
class PharmacyPickupController extends AdminController
{
public function __construct(private PharmacyPickupService $service)
{
}
public function index(Request $request)
{
// 僅列出「取物單模式 + 領藥單開關開啟」的機台(受 TenantScoped 自動租戶隔離)
$machines = Machine::query()
->where('settings->shopping_mode', 'pickup_sheet')
->where('settings->pharmacy_pickup_enabled', true)
->orderBy('name')
->get();
$selectedMachine = null;
$products = new Collection();
if ($request->filled('machine_id')) {
$selectedMachine = $machines->firstWhere('id', (int) $request->input('machine_id'));
if ($selectedMachine) {
$products = $this->service->availableProducts($selectedMachine);
}
}
$orders = Order::pharmacyPickup()
->with(['machine:id,name,serial_no', 'items', 'creator:id,name', 'pickupCode'])
->latest()
->paginate($request->input('per_page', 10))
->withQueryString();
return view('admin.sales.pharmacy-pickup.index', [
'title' => __('menu.sales.pharmacy-pickup'),
'machines' => $machines,
'selectedMachine' => $selectedMachine,
'products' => $products,
'orders' => $orders,
]);
}
/**
* AJAX回傳某機台目前可領藥品建立領藥單彈窗用對齊取貨碼 slots-ajax 模式)。
*/
public function productsAjax(Machine $machine)
{
$enabled = ($machine->settings['shopping_mode'] ?? null) === 'pickup_sheet'
&& (bool) ($machine->settings['pharmacy_pickup_enabled'] ?? false);
abort_unless($enabled, 403);
return response()->json([
'products' => $this->service->availableProducts($machine)->values(),
]);
}
public function store(Request $request)
{
$request->validate([
'machine_id' => 'required|exists:machines,id',
'pricing_slip_no' => 'nullable|string|max:64',
]);
// 由勾選的 products[] + qty[product_id] 組出 items
$productIds = (array) $request->input('products', []);
$qtyMap = (array) $request->input('qty', []);
$items = [];
foreach ($productIds as $pid) {
$q = (int) ($qtyMap[$pid] ?? 0);
if ($q > 0) {
$items[] = ['product_id' => (int) $pid, 'quantity' => $q];
}
}
try {
$order = $this->service->create([
'machine_id' => (int) $request->input('machine_id'),
'pricing_slip_no' => $request->input('pricing_slip_no'),
'items' => $items,
], Auth::id());
} catch (ValidationException $e) {
return back()->withErrors($e->errors())->withInput();
}
return redirect()
->route('admin.sales.pharmacy-pickup', ['machine_id' => $request->input('machine_id')])
->with('success', __('Pharmacy pickup order created: :no', ['no' => $order->order_no]))
->with('created_order_id', $order->id);
}
/**
* 可列印的領藥單QR 內嵌 SVG內容為 8 碼領藥碼,機台掃描後送 B660 驗證)。
*/
public function print(Order $order)
{
abort_unless($order->order_type === Order::TYPE_PHARMACY_PICKUP, 404);
$order->load(['items', 'machine', 'pickupCode', 'creator:id,name']);
$code = $order->pickupCode?->code;
$qrSvg = $code
? QrCode::format('svg')->size(220)->margin(1)->errorCorrection('M')->generate($code)
: null;
return view('admin.sales.pharmacy-pickup.print', [
'order' => $order,
'qrSvg' => $qrSvg,
]);
}
public function cancel(Order $order)
{
abort_unless($order->order_type === Order::TYPE_PHARMACY_PICKUP, 404);
if (in_array($order->status, [Order::STATUS_COMPLETED, Order::STATUS_FAILED], true)
|| $order->delivery_status === Order::DELIVERY_STATUS_SUCCESS) {
return back()->with('error', __('This pharmacy pickup order can no longer be cancelled.'));
}
$order->update(['status' => Order::STATUS_FAILED]);
if ($order->pickupCode) {
$order->pickupCode->update(['status' => 'cancelled']);
}
SystemOperationLog::create([
'company_id' => $order->company_id,
'user_id' => Auth::id(),
'module' => 'pharmacy_pickup',
'action' => 'cancel',
'target_id' => $order->id,
'target_type' => Order::class,
]);
return back()->with('success', __('Pharmacy pickup order cancelled.'));
}
}

View File

@ -8,6 +8,7 @@ use App\Models\System\Translation;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\System\SystemOperationLog;
class ProductCategoryController extends Controller
{
@ -73,6 +74,17 @@ class ProductCategoryController extends Controller
'name_dictionary_key' => $dictKey,
]);
SystemOperationLog::create([
'company_id' => $category->company_id,
'user_id' => auth()->id(),
'module' => 'category',
'action' => 'create',
'target_id' => $category->id,
'target_type' => ProductCategory::class,
'note' => 'category_created',
'new_values' => $category->toArray(),
]);
DB::commit();
return redirect()->back()->with('success', __('Category created successfully'));
@ -125,10 +137,24 @@ class ProductCategoryController extends Controller
);
}
$oldValues = $category->toArray();
$category->update([
'name' => $request->names['zh_TW'] ?? $category->name,
'name_dictionary_key' => $dictKey,
]);
$newValues = $category->fresh()->toArray();
SystemOperationLog::create([
'company_id' => $category->company_id,
'user_id' => auth()->id(),
'module' => 'category',
'action' => 'update',
'target_id' => $category->id,
'target_type' => ProductCategory::class,
'note' => 'category_updated',
'old_values' => $oldValues,
'new_values' => $newValues,
]);
DB::commit();
@ -157,12 +183,22 @@ class ProductCategoryController extends Controller
return redirect()->back()->with('error', $errorMsg);
}
if ($category->name_dictionary_key) {
Translation::withoutGlobalScopes()->where('group', 'category')->where('key', $category->name_dictionary_key)->delete();
}
$oldValues = $category->toArray();
$categoryId = $category->id;
$companyId = $category->company_id;
$category->delete();
SystemOperationLog::create([
'company_id' => $companyId,
'user_id' => auth()->id(),
'module' => 'category',
'action' => 'delete',
'target_id' => $categoryId,
'target_type' => ProductCategory::class,
'note' => 'category_deleted',
'old_values' => $oldValues,
]);
if (request()->ajax()) {
return response()->json([
'success' => true,

View File

@ -12,10 +12,63 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Services\Product\ProductCatalogService;
use App\Services\ProductImportService;
use App\Models\System\SystemOperationLog;
class ProductController extends Controller
{
use \App\Traits\ImageHandler;
public function __construct(
private readonly ProductCatalogService $catalogService
) {}
/**
* Upsert a locale=>value map into translations for a given group/key.
* 空值的語系會被刪除(清空翻譯)。繞過 TenantScoped。
*
* @param string $group product / product_spec
* @param string $key 對應的 dictionary key
* @param array $values locale => value
* @param int|null $companyId
*/
private function upsertTranslations(string $group, string $key, array $values, ?int $companyId): void
{
foreach ($values as $locale => $value) {
if (empty($value)) {
Translation::withoutGlobalScopes()->where([
'group' => $group,
'key' => $key,
'locale' => $locale,
])->delete();
continue;
}
Translation::withoutGlobalScopes()->updateOrCreate(
['group' => $group, 'key' => $key, 'locale' => $locale],
['value' => $value, 'company_id' => $companyId]
);
}
}
/**
* 解析規格多語系輸入:優先採 specs 物件;
* 若僅送舊版單一 spec 欄位則轉為 ['zh_TW' => spec] 以維持相容。
*
* @return array locale => value
*/
private function resolveSpecs(Request $request): array
{
$specs = $request->input('specs');
if (is_array($specs) && !empty($specs)) {
return $specs;
}
$spec = $request->input('spec');
return $spec !== null && $spec !== '' ? ['zh_TW' => $spec] : [];
}
public function index(Request $request)
{
$user = auth()->user();
@ -26,8 +79,8 @@ class ProductController extends Controller
$productQuery = Product::with(['category.translations', 'translations', 'company']);
// 搜尋
if ($request->filled('product_search')) {
$search = $request->product_search;
if ($request->filled('search')) {
$search = $request->search;
$productQuery->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
@ -46,7 +99,7 @@ class ProductController extends Controller
}
}
$products = $productQuery->latest()->paginate($per_page, ['*'], 'product_page')->withQueryString();
$products = $productQuery->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($per_page, ['*'], 'product_page')->withQueryString();
// Categories Query
$categoryQuery = ProductCategory::with(['translations', 'company']);
@ -55,14 +108,14 @@ class ProductController extends Controller
$categoryQuery->where('company_id', $request->category_company_id);
}
if ($request->filled('category_search')) {
$search = $request->category_search;
if ($request->filled('search')) {
$search = $request->search;
$categoryQuery->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
}
$categories = $categoryQuery->latest()->paginate($per_page, ['*'], 'category_page')->withQueryString();
$categories = $categoryQuery->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($per_page, ['*'], 'category_page')->withQueryString();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
// Settings for Modal (use current user company or fallback)
@ -80,12 +133,13 @@ class ProductController extends Controller
'success' => true,
'html' => view('admin.products.partials.tab-products', [
'products' => $products,
'categories' => $categories,
'companySettings' => $companySettings,
'companies' => $companies,
'routeName' => $routeName
])->render()
]);
} else {
} elseif ($tab === 'categories') {
return response()->json([
'success' => true,
'html' => view('admin.products.partials.tab-categories', [
@ -94,15 +148,68 @@ class ProductController extends Controller
'routeName' => $routeName
])->render()
]);
} elseif ($tab === 'logs') {
$logQuery = SystemOperationLog::with(['user', 'company', 'target'])
->whereIn('module', ['product', 'category']);
if ($user->isSystemAdmin()) {
if ($request->filled('log_company_id')) {
$logQuery->where('company_id', $request->log_company_id);
}
}
if ($request->filled('search_log')) {
$search = $request->search_log;
$logQuery->where(function($q) use ($search) {
$q->where('note', 'like', "%{$search}%")
->orWhere('old_values', 'like', "%{$search}%")
->orWhere('new_values', 'like', "%{$search}%");
});
}
$logDefaultStart = now()->startOfDay()->format('Y-m-d H:i');
$logDefaultEnd = now()->endOfDay()->format('Y-m-d H:i');
if ($request->filled('start_date') || $request->filled('end_date')) {
if ($request->filled('start_date')) {
$logQuery->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$logQuery->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
$logQuery->whereBetween('created_at', [now()->startOfDay(), now()->endOfDay()]);
$request->merge([
'start_date' => $logDefaultStart,
'end_date' => $logDefaultEnd
]);
}
$logs = $logQuery->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($per_page, ['*'], 'log_page')->withQueryString();
return response()->json([
'success' => true,
'html' => view('admin.products.partials.tab-logs', [
'logs' => $logs,
'companies' => $companies,
'logDefaultStart' => $logDefaultStart,
'logDefaultEnd' => $logDefaultEnd
])->render()
]);
}
}
$logDefaultStart = now()->startOfDay()->format('Y-m-d H:i');
$logDefaultEnd = now()->endOfDay()->format('Y-m-d H:i');
return view('admin.products.index', [
'products' => $products,
'categories' => $categories,
'companies' => $companies,
'companySettings' => $companySettings,
'routeName' => $routeName
'routeName' => $routeName,
'logDefaultStart' => $logDefaultStart,
'logDefaultEnd' => $logDefaultEnd
]);
}
@ -121,6 +228,9 @@ class ProductController extends Controller
'categories' => $categories,
'companies' => $companies,
'companySettings' => $companySettings,
// 商品多語系 Tab公司機台語系聯集見 product_multilingual_spec §4.2
'locales' => Company::activeLocalesFor($companyId),
'localeLabels' => config('locales.supported', ['zh_TW' => '繁體中文']),
]);
}
@ -129,12 +239,18 @@ class ProductController extends Controller
$user = auth()->user();
// 繞過 TenantScoped 載入翻譯,確保系統管理員能看到租戶公司的翻譯資料
$product = Product::with(['company'])->findOrFail($id);
$product->setRelation('translations',
$product->setRelation('translations',
Translation::withoutGlobalScopes()
->where('group', 'product')
->where('key', $product->name_dictionary_key)
->get()
);
$product->setRelation('specTranslations',
Translation::withoutGlobalScopes()
->where('group', 'product_spec')
->where('key', $product->spec_dictionary_key)
->get()
);
$categories = ProductCategory::with('translations')->get();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
@ -152,11 +268,13 @@ class ProductController extends Controller
public function store(Request $request)
{
$validated = $request->validate([
'names' => 'required|array',
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
'names.*' => 'nullable|string|max:255',
'specs' => 'nullable|array',
'specs.*' => 'nullable|string|max:2000',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'spec' => 'nullable|string|max:2000',
'category_id' => 'nullable|exists:product_categories,id',
'manufacturer' => 'nullable|string|max:255',
'track_limit' => 'required|integer|min:1',
@ -174,28 +292,22 @@ class ProductController extends Controller
DB::beginTransaction();
$dictKey = \Illuminate\Support\Str::uuid()->toString();
$specDictKey = \Illuminate\Support\Str::uuid()->toString();
// Determine company_id: prioritized from request (for sys admin) then from user
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
: auth()->user()->company_id;
// 儲存多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
foreach ($request->names as $locale => $name) {
if (empty($name)) continue;
Translation::withoutGlobalScopes()->create([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
'value' => $name,
'company_id' => $company_id,
]);
}
// 儲存名稱多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
$this->upsertTranslations('product', $dictKey, $request->input('names', []), $company_id);
// 儲存規格多語系翻譯與名稱同邏輯group=product_spec
$specs = $this->resolveSpecs($request);
$this->upsertTranslations('product_spec', $specDictKey, $specs, $company_id);
$imageUrl = null;
if ($request->hasFile('image')) {
$path = $this->storeAsWebp($request->file('image'), 'products');
$path = $this->storeAsWebp($request->file('image'), 'products', 80, 320, 320);
$imageUrl = Storage::url($path);
}
@ -206,7 +318,8 @@ class ProductController extends Controller
'name_dictionary_key' => $dictKey,
'image_url' => $imageUrl,
'barcode' => $request->barcode,
'spec' => $request->spec,
'spec' => $specs['zh_TW'] ?? '',
'spec_dictionary_key' => $specDictKey,
'manufacturer' => $request->manufacturer,
'track_limit' => $request->track_limit,
'spring_limit' => $request->spring_limit,
@ -217,8 +330,22 @@ class ProductController extends Controller
'is_active' => $request->boolean('is_active', true),
]);
SystemOperationLog::create([
'company_id' => $product->company_id,
'user_id' => auth()->id(),
'module' => 'product',
'action' => 'create',
'target_id' => $product->id,
'target_type' => Product::class,
'note' => 'product_created',
'new_values' => $product->toArray(),
]);
DB::commit();
// Rebuild catalog cache
$this->catalogService->rebuildCache($product->company_id);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
@ -243,11 +370,13 @@ class ProductController extends Controller
$product = Product::findOrFail($id);
$validated = $request->validate([
'names' => 'required|array',
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
'names.*' => 'nullable|string|max:255',
'specs' => 'nullable|array',
'specs.*' => 'nullable|string|max:2000',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'spec' => 'nullable|string|max:2000',
'category_id' => 'nullable|exists:product_categories,id',
'manufacturer' => 'nullable|string|max:255',
'track_limit' => 'required|integer|min:1',
@ -265,34 +394,19 @@ class ProductController extends Controller
DB::beginTransaction();
$dictKey = $product->name_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
// 既有商品若尚無 spec 字典鍵,首次編輯時補建
$specDictKey = $product->spec_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
// Determine company_id: prioritized from request (for sys admin) then from product's current company
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
: $product->company_id;
// 更新或建立多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
foreach ($request->names as $locale => $name) {
if (empty($name)) {
Translation::withoutGlobalScopes()->where([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale
])->delete();
continue;
}
// 更新或建立名稱多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
$this->upsertTranslations('product', $dictKey, $request->input('names', []), $company_id);
Translation::withoutGlobalScopes()->updateOrCreate(
[
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
],
[
'value' => $name,
'company_id' => $company_id,
]
);
}
// 更新或建立規格多語系翻譯與名稱同邏輯group=product_spec
$specs = $this->resolveSpecs($request);
$this->upsertTranslations('product_spec', $specDictKey, $specs, $company_id);
$data = [
'company_id' => $company_id,
@ -300,7 +414,8 @@ class ProductController extends Controller
'name' => $request->names['zh_TW'] ?? ($product->name ?? 'Untitled'),
'name_dictionary_key' => $dictKey,
'barcode' => $request->barcode,
'spec' => $request->spec,
'spec' => $specs['zh_TW'] ?? ($product->spec ?? ''),
'spec_dictionary_key' => $specDictKey,
'manufacturer' => $request->manufacturer,
'track_limit' => $request->track_limit,
'spring_limit' => $request->spring_limit,
@ -317,7 +432,7 @@ class ProductController extends Controller
$oldPath = str_replace('/storage/', '', $product->image_url);
Storage::disk('public')->delete($oldPath);
}
$path = $this->storeAsWebp($request->file('image'), 'products');
$path = $this->storeAsWebp($request->file('image'), 'products', 80, 320, 320);
$data['image_url'] = Storage::url($path);
} elseif ($request->boolean('remove_image')) {
if ($product->image_url) {
@ -327,10 +442,27 @@ class ProductController extends Controller
$data['image_url'] = null;
}
$oldValues = $product->toArray();
$product->update($data);
$newValues = $product->fresh()->toArray();
SystemOperationLog::create([
'company_id' => $product->company_id,
'user_id' => auth()->id(),
'module' => 'product',
'action' => 'update',
'target_id' => $product->id,
'target_type' => Product::class,
'note' => 'product_updated',
'old_values' => $oldValues,
'new_values' => $newValues,
]);
DB::commit();
// Rebuild catalog cache
$this->catalogService->rebuildCache($product->company_id);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
@ -354,8 +486,25 @@ class ProductController extends Controller
{
try {
$product = Product::findOrFail($id);
$oldValues = $product->toArray();
$product->is_active = !$product->is_active;
$product->save();
$newValues = $product->fresh()->toArray();
SystemOperationLog::create([
'company_id' => $product->company_id,
'user_id' => auth()->id(),
'module' => 'product',
'action' => 'update',
'target_id' => $product->id,
'target_type' => Product::class,
'note' => 'product_status_toggled',
'old_values' => $oldValues,
'new_values' => $newValues,
]);
// Rebuild catalog cache
$this->catalogService->rebuildCache($product->company_id);
$status = $product->is_active ? __('Enabled') : __('Disabled');
@ -384,10 +533,13 @@ class ProductController extends Controller
try {
$product = Product::findOrFail($id);
// 刪除與此商品關聯的翻譯資料(繞過 TenantScoped
// 刪除與此商品關聯的翻譯資料(名稱 + 規格,繞過 TenantScoped
if ($product->name_dictionary_key) {
Translation::withoutGlobalScopes()->where('key', $product->name_dictionary_key)->delete();
}
if ($product->spec_dictionary_key) {
Translation::withoutGlobalScopes()->where('key', $product->spec_dictionary_key)->delete();
}
// Delete image
if ($product->image_url) {
@ -395,8 +547,24 @@ class ProductController extends Controller
Storage::disk('public')->delete($oldPath);
}
$companyId = $product->company_id;
$oldValues = $product->toArray();
$product->delete();
SystemOperationLog::create([
'company_id' => $companyId,
'user_id' => auth()->id(),
'module' => 'product',
'action' => 'delete',
'target_id' => $id,
'target_type' => Product::class,
'note' => 'product_deleted',
'old_values' => $oldValues,
]);
// Rebuild catalog cache
$this->catalogService->rebuildCache($companyId);
if (request()->ajax()) {
return response()->json([
'success' => true,
@ -415,4 +583,243 @@ class ProductController extends Controller
return redirect()->back()->with('error', $e->getMessage());
}
}
/**
* 匯出商品資料CSV / Excel套用與 index 相同的篩選(搜尋/分類/公司)。
* 參考 SalesController::handleExport 的串流下載模式;欄位順序對齊匯入範本,方便編輯後回匯入。
*/
public function export(Request $request)
{
$user = auth()->user();
$type = $request->input('export', 'csv');
$isExcel = ($type === 'excel');
$ext = $isExcel ? 'xls' : 'csv';
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
// 與 index 相同的商品查詢與篩選(租戶隔離沿用 Product 全域 scope
$query = Product::with(['category.translations', 'translations', 'company']);
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('spec', 'like', "%{$search}%");
});
}
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
if ($user->isSystemAdmin() && $request->filled('product_company_id')) {
$query->where('company_id', $request->product_company_id);
}
// 欄位順序對齊匯入範本ProductImportService::downloadTemplate
$headers = [
'分類',
'商品名稱(zh_TW)',
'商品名稱(en)',
'商品名稱(ja)',
'條碼',
'規格',
'生產公司',
'售價',
'會員價',
'成本',
'履帶貨道上限',
'彈簧貨道上限',
'物料代碼',
'全額點數',
'半額點數',
'半額折抵金額',
'是否啟用',
'圖片檔名',
];
$filename = '商品資料_' . now()->format('YmdHis') . '.' . $ext;
$callback = function () use ($query, $headers, $isExcel) {
$file = fopen('php://output', 'w');
if ($isExcel) {
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
fwrite($file, '<table><thead><tr>');
foreach ($headers as $header) {
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
}
fwrite($file, '</tr></thead><tbody>');
} else {
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOMExcel 開啟不亂碼
fputcsv($file, $headers);
}
$locale = app()->getLocale();
$query->orderBy('id')->chunk(500, function ($products) use ($file, $isExcel, $locale) {
foreach ($products as $p) {
$meta = is_array($p->metadata) ? $p->metadata : [];
$nameEn = optional($p->translations->firstWhere('locale', 'en'))->value;
$nameJa = optional($p->translations->firstWhere('locale', 'ja'))->value;
$categoryName = '';
if ($p->category) {
$catTrans = $p->category->translations->firstWhere('locale', $locale)
?? $p->category->translations->firstWhere('locale', 'zh_TW');
$categoryName = $catTrans->value ?? ($p->category->name ?? '');
}
$imageFilename = $p->image_url ? basename($p->image_url) : '';
$row = [
$categoryName,
$p->name,
$nameEn,
$nameJa,
$p->barcode,
$p->spec,
$p->manufacturer,
$p->price,
$p->member_price,
$p->cost,
$p->track_limit,
$p->spring_limit,
$meta['material_code'] ?? '',
$meta['points_full'] ?? 0,
$meta['points_half'] ?? 0,
$meta['points_half_amount'] ?? 0,
$p->is_active ? 1 : 0,
$imageFilename,
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $cell) {
fwrite($file, '<td>' . htmlspecialchars((string) $cell) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
}
public function downloadTemplate(ProductImportService $importService)
{
return $importService->downloadTemplate();
}
public function import(Request $request)
{
$request->validate([
'file' => 'required|file|mimes:xlsx|max:5120',
'company_id' => 'nullable|exists:companies,id',
'images' => 'nullable|file|mimes:zip|max:61440', // 60MB落在 php 預設 100M 與 Cloudflare 100M 之內,免改機器設定
]);
$companyId = $request->get('company_id') ?: auth()->user()->company_id;
if (auth()->user()->isSystemAdmin() && !$companyId) {
return response()->json([
'success' => false,
'message' => __('Please select a company'),
], 422);
}
// 存檔到耐久暫存目錄並派發背景 Job請求立即回應避免大批匯入受 Cloudflare 100 秒等請求逾時限制
$baseDir = storage_path('app/product-imports/' . Str::uuid()->toString());
\Illuminate\Support\Facades\File::ensureDirectoryExists($baseDir);
$request->file('file')->move($baseDir, 'data.xlsx');
$xlsxPath = $baseDir . '/data.xlsx';
$zipPath = null;
if ($request->hasFile('images')) {
$request->file('images')->move($baseDir, 'images.zip');
$zipPath = $baseDir . '/images.zip';
}
\App\Jobs\Product\ProcessProductImportJob::dispatch(
$baseDir,
$xlsxPath,
$zipPath,
$companyId,
auth()->id()
);
return response()->json([
'success' => true,
'message' => __('Import has been queued. Refresh the list shortly to see the imported products.'),
], 202);
}
/**
* 同步商品目錄至全公司機台 (Phase 2)
*/
public function syncToAllMachines(Request $request)
{
$user = auth()->user();
// 1. 決定目標公司 ID
$companyId = $user->isSystemAdmin()
? $request->input('company_id')
: $user->company_id;
if (!$companyId) {
return response()->json([
'success' => false,
'message' => __('Please select a company.')
], 422);
}
// 3. 頻率限制1 分鐘內不可重複對同一家公司發送同步指令
$machineIds = \App\Models\Machine\Machine::where('company_id', $companyId)->pluck('id');
$recentCommand = \App\Models\Machine\RemoteCommand::whereIn('machine_id', $machineIds)
->where('command_type', 'update_products')
->where('created_at', '>=', now()->subMinute())
->whereIn('status', ['pending', 'sent'])
->exists();
if ($recentCommand) {
return response()->json([
'success' => false,
'message' => __('A sync command was recently sent. Please wait 1 minute.')
], 422);
}
$remark = $request->input('remark') ?: __('Manual Sync Products');
// 2. 派遣延遲廣播 Job
\App\Jobs\Product\SyncProductsToMachinesJob::dispatch(
(int) $companyId,
$remark,
$user->id
);
SystemOperationLog::create([
'company_id' => $companyId,
'user_id' => $user->id,
'module' => 'product',
'action' => 'sync',
'target_id' => $companyId,
'target_type' => Company::class,
'note' => 'product_catalog_synced_to_all_machines',
'new_values' => ['remark' => $remark],
]);
return response()->json([
'success' => true,
'message' => __('Batch sync command has been queued. Machines will be updated sequentially.')
]);
}
}

View File

@ -30,19 +30,22 @@ class RemoteController extends Controller
// --- 1. 機台列表處理 (New Command Tab) ---
$machineQuery = Machine::withCount(['slots'])->orderBy('last_heartbeat_at', 'desc')->orderBy('id', 'desc');
if ($request->filled('search') && $request->input('tab') === 'list') {
$search = $request->input('search');
$machineQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%");
->orWhere('serial_no', 'like', "%{$search}%");
});
}
$machines = $machineQuery->paginate($request->input('per_page', 10), ['*'], 'machine_page');
// --- 2. 歷史紀錄處理 (Operation Records Tab) ---
// whereHas('machine') 會套用 Machine 的 machine_access 全域作用域,
// 確保租戶僅能看到自己被授權機台的指令紀錄(系統管理員不受作用域限制,仍可看全部)。
$historyQuery = RemoteCommand::where('command_type', '!=', 'reload_stock')
->whereHas('machine')
->with(['machine', 'user']);
if ($request->filled('search') && ($request->input('tab') === 'history' || !$request->has('tab'))) {
@ -53,7 +56,8 @@ class RemoteController extends Controller
->orWhere('serial_no', 'like', "%{$search}%");
})->orWhereHas('user', function ($uq) use ($search) {
$uq->where('name', 'like', "%{$search}%");
});
})->orWhere('remark', 'like', "%{$search}%")
->orWhere('note', 'like', "%{$search}%");
});
}
@ -71,6 +75,9 @@ class RemoteController extends Controller
} catch (\Exception $e) {
// 忽略解析錯誤
}
} else {
// 預設過濾當天紀錄
$historyQuery->whereDate('created_at', now()->toDateString());
}
// 指令類型過濾
@ -102,7 +109,7 @@ class RemoteController extends Controller
if ($request->has('tab')) {
$tab = $request->input('tab');
$viewPath = $tab === 'list' ? 'admin.remote.partials.tab-machines-index' : 'admin.remote.partials.tab-history-index';
return response()->json([
'success' => true,
'html' => view($viewPath, [
@ -135,7 +142,7 @@ class RemoteController extends Controller
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense',
'command_type' => 'required|string|in:reboot,reboot_force,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto',
'amount' => 'nullable|integer|min:0',
'slot_no' => 'nullable|string',
'note' => 'nullable|string|max:255',
@ -145,7 +152,8 @@ class RemoteController extends Controller
$this->machineService->dispatchDispense(
Machine::findOrFail($validated['machine_id']),
$validated['slot_no'],
auth()->id()
auth()->id(),
$validated['note'] ?? null
);
} else {
$payload = [];
@ -169,7 +177,7 @@ class RemoteController extends Controller
'command_type' => $validated['command_type'],
'payload' => $payload,
'status' => 'pending',
'note' => $validated['note'] ?? null,
'remark' => $validated['note'] ?? null,
]);
// 推播 MQTT 指令
@ -205,7 +213,8 @@ class RemoteController extends Controller
$machineQuery = Machine::withCount([
'slots as slots_count',
'slots as low_stock_count' => function ($query) {
$query->where('stock', '<=', 5);
$query->where('max_stock', '>', 0)
->whereRaw('stock <= (max_stock * 0.2)');
},
'slots as expiring_soon_count' => function ($query) {
$query->whereNotNull('expiry_date')
@ -227,8 +236,9 @@ class RemoteController extends Controller
->paginate($request->input('per_page', 10), ['*'], 'machine_page');
// 2. 歷史紀錄查詢與分頁
// whereHas('machine') 套用 machine_access 作用域,租戶僅能看到自己授權機台的補貨紀錄。
$historyQuery = RemoteCommand::with(['machine', 'user']);
$historyQuery->where('command_type', 'reload_stock');
$historyQuery->whereHas('machine')->where('command_type', 'reload_stock');
if ($request->filled('search')) {
$search = $request->input('search');
@ -256,6 +266,9 @@ class RemoteController extends Controller
} catch (\Exception $e) {
// 忽略解析錯誤
}
} else {
// 預設過濾當天紀錄
$historyQuery->whereDate('created_at', now()->toDateString());
}
// 狀態過濾

File diff suppressed because it is too large Load Diff

View File

@ -15,22 +15,4 @@ class SpecialPermissionController extends Controller
'description' => '特殊權限庫存清空功能',
]);
}
// APK版本管理
public function apkVersions()
{
return view('admin.placeholder', [
'title' => 'APK版本管理',
'description' => 'APP版本控制與更新',
]);
}
// Discord通知設定
public function discordNotifications()
{
return view('admin.placeholder', [
'title' => 'Discord通知設定',
'description' => 'Discord通知整合設定',
]);
}
}

View File

@ -0,0 +1,188 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\StaffCard;
use App\Models\StaffCardLog;
use App\Services\StaffCardImportService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StaffCardController extends Controller
{
public function index(Request $request)
{
$tab = $request->get('tab', 'staff_list');
$isSystemAdmin = auth()->user()->isSystemAdmin();
$companies = [];
if ($isSystemAdmin) {
$companies = \App\Models\System\Company::active()->orderBy('name')->get();
}
$cardsQuery = StaffCard::with('lastMachine')
->when($request->search, function ($q) use ($request) {
$q->where(function ($sq) use ($request) {
$sq->where('name', 'like', "%{$request->search}%")
->orWhere('employee_id', 'like', "%{$request->search}%")
->orWhere('card_uid', 'like', "%{$request->search}%");
});
})
->when($isSystemAdmin && $request->company_id, function ($q) use ($request) {
$q->where('company_id', $request->company_id);
});
$logsQuery = StaffCardLog::with(['staffCard', 'machine', 'order'])
->when($request->log_search, function ($q) use ($request) {
$q->whereHas('staffCard', function ($sq) use ($request) {
$sq->where('name', 'like', "%{$request->log_search}%")
->orWhere('employee_id', 'like', "%{$request->log_search}%")
->orWhere('card_uid', 'like', "%{$request->log_search}%");
});
})
->when($isSystemAdmin && $request->log_company_id, function ($q) use ($request) {
$q->where('company_id', $request->log_company_id);
});
$staffPerPage = $request->input('staff_per_page', 10);
$usagePerPage = $request->input('usage_per_page', 10);
if ($request->ajax() && $request->has('_ajax')) {
if ($tab === 'staff_list') {
$cards = $cardsQuery->latest()->paginate($staffPerPage, ['*'], 'staff_page');
return response()->json([
'success' => true,
'html' => view('admin.data-config.staff-cards.partials.tab-staff-list', compact('cards', 'companies'))->render()
]);
} else {
$logs = $logsQuery->latest()->paginate($usagePerPage, ['*'], 'usage_page');
return response()->json([
'success' => true,
'html' => view('admin.data-config.staff-cards.partials.tab-usage-logs', compact('logs', 'companies'))->render()
]);
}
}
$cards = $cardsQuery->latest()->paginate($staffPerPage, ['*'], 'staff_page');
$logs = $logsQuery->latest()->paginate($usagePerPage, ['*'], 'usage_page');
return view('admin.data-config.staff-cards.index', compact('cards', 'logs', 'tab', 'companies'));
}
public function store(Request $request)
{
$validated = $request->validate([
'company_id' => 'nullable|exists:companies,id',
'employee_id' => 'required|string|max:50',
'name' => 'required|string|max:100',
'card_uid' => 'required|string|max:100',
'status' => 'nullable|string|in:active,inactive',
'notes' => 'nullable|string',
]);
$validated['status'] = $request->get('status', 'active');
$card = StaffCard::create($validated);
if ($request->ajax()) {
return response()->json([
'success' => true,
'message' => __('Staff card created successfully'),
'data' => $card
]);
}
return redirect()->route('admin.data-config.staff-cards.index')->with('success', __('Staff card created successfully'));
}
public function update(Request $request, StaffCard $staffCard)
{
$validated = $request->validate([
'company_id' => 'nullable|exists:companies,id',
'employee_id' => 'required|string|max:50',
'name' => 'required|string|max:100',
'card_uid' => 'required|string|max:100',
'status' => 'nullable|string|in:active,inactive',
'notes' => 'nullable|string',
]);
$validated['status'] = $request->get('status', $staffCard->status);
// 寫入安全company_id 不得由前端任意決定或被洗成 null
// - 非系統管理員:強制綁定自身公司 (租戶隔離)
// - 系統管理員:若未帶入公司,保留原本歸屬,避免變成無公司歸屬的孤兒卡 (機台將查無此卡)
if (!auth()->user()->isSystemAdmin()) {
$validated['company_id'] = auth()->user()->company_id;
} elseif (empty($validated['company_id'])) {
$validated['company_id'] = $staffCard->company_id;
}
$staffCard->update($validated);
if ($request->ajax()) {
return response()->json([
'success' => true,
'message' => __('Staff card updated successfully'),
]);
}
return redirect()->route('admin.data-config.staff-cards.index')->with('success', __('Staff card updated successfully'));
}
public function destroy(StaffCard $staffCard)
{
$staffCard->delete();
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Staff card deleted successfully'),
]);
}
return redirect()->route('admin.data-config.staff-cards.index')->with('success', __('Staff card deleted successfully'));
}
public function toggleStatus(StaffCard $staffCard)
{
$staffCard->status = $staffCard->status === 'active' ? 'inactive' : 'active';
$staffCard->save();
return response()->json([
'success' => true,
'message' => __('Status updated successfully'),
]);
}
public function downloadTemplate(StaffCardImportService $importService)
{
return $importService->downloadTemplate();
}
public function import(Request $request, StaffCardImportService $importService)
{
$request->validate([
'file' => 'required|file|mimes:xlsx,xls,csv|max:5120',
'company_id' => 'nullable|exists:companies,id',
]);
$companyId = $request->get('company_id') ?: auth()->user()->company_id;
if (auth()->user()->isSystemAdmin() && !$companyId) {
return response()->json([
'success' => false,
'message' => __('Please select a company'),
], 422);
}
$results = $importService->import($request->file('file')->getRealPath(), $companyId);
return response()->json([
'success' => true,
'message' => __(':count staff cards imported successfully', ['count' => $results['success_count']]),
'results' => $results,
]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -19,24 +19,15 @@ class MachineAuthController extends Controller
*/
public function loginB000(Request $request): JsonResponse
{
// 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式)
// 1. 驗證帳密與 IP
$validated = $request->validate([
'machine' => 'required|string',
'Su_Account' => 'required|string',
'Su_Password' => 'required|string',
'ip' => 'nullable|string',
'type' => 'nullable|string',
]);
// 2. 取得機台物件 (需優先於帳密驗證,以便記錄日誌到正確機台)
$machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first();
if (!$machine) {
Log::warning("B000 機台登入失敗: 伺服器找不到該機台", [
'machine_serial' => $validated['machine']
]);
return response()->json(['message' => 'Failed']);
}
// 2. 取得機台物件 (由 iot.auth 中介層注入)
$machine = $request->get('machine');
// 3. 透過帳號尋找使用者 (允許使用 username 或 email)
$user = User::where('username', $validated['Su_Account'])
@ -47,7 +38,7 @@ class MachineAuthController extends Controller
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
Log::warning("B000 機台登入失敗: 帳密錯誤", [
'account' => $validated['Su_Account'],
'machine' => $validated['machine']
'machine' => $machine->serial_no
]);
// 寫入機台日誌
@ -60,20 +51,30 @@ class MachineAuthController extends Controller
'login'
);
return response()->json(['message' => 'Failed']);
return response()->json([
'success' => false,
'code' => 401,
'message' => 'Failed'
], 401);
}
// 5. RBAC 權限驗證 (遵循多租戶與機台授權規範)
// $identity: 登入者身分 (system:系統登入者 / company:公司帳號 / staff:一般人員)
// 僅在授權通過時賦值,確保未授權者不洩漏身分
$isAuthorized = false;
$identity = null;
if ($user->isSystemAdmin()) {
$isAuthorized = true;
$identity = 'system';
} elseif ($user->is_admin) {
if ($machine->company_id === $user->company_id) {
$isAuthorized = true;
$identity = 'company';
}
} else {
if ($user->machines()->where('machine_id', $machine->id)->exists()) {
$isAuthorized = true;
$identity = 'staff';
}
}
@ -92,7 +93,11 @@ class MachineAuthController extends Controller
'login'
);
return response()->json(['message' => 'Forbidden']);
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Forbidden'
], 403);
}
// 6. 驗證完美通過!
@ -101,6 +106,12 @@ class MachineAuthController extends Controller
'machine' => $machine->serial_no
]);
// 自我修復:自動將先前所有未解決的登入失敗警告標記為已解決
$machine->logs()
->where('type', 'login')
->where('is_resolved', false)
->update(['is_resolved' => true]);
// 寫入成功登入日誌
ProcessStateLog::dispatch(
$machine->id,
@ -112,7 +123,10 @@ class MachineAuthController extends Controller
);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Success',
'identity' => $identity,
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
]);
}

View File

@ -7,13 +7,24 @@ use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
use App\Models\Machine\MachineStockMovement;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCode;
use App\Models\StaffCard;
use App\Models\StaffCardLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Models\System\SystemOperationLog;
use App\Services\Machine\MachineService;
use App\Services\Product\ProductCatalogService;
class MachineController extends Controller
{
@ -188,18 +199,6 @@ class MachineController extends Controller
}
/**
* B710: Sync Timer status (Asynchronous)
*/
public function syncTimer(Request $request)
{
$machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessTimerStatus::dispatch($machine->serial_no, $data);
return response()->json(['success' => true], 202);
}
/**
* B220: Sync Coin Inventory (Asynchronous)
@ -289,10 +288,10 @@ class MachineController extends Controller
return [
't070v01' => $ma->advertisement->name,
't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
't070v02' => (int) ($ma->advertisement->duration ?? 15),
't070v03' => (int) ($posIdMap[$ma->position] ?? 1),
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
't070v05' => (string) $ma->sort_order,
't070v05' => (int) $ma->sort_order,
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
'raw_sort' => (int) $ma->sort_order
];
@ -368,10 +367,16 @@ class MachineController extends Controller
$mappedSlots = array_map(function ($item) {
return [
'slot_no' => $item['tid'] ?? null,
'slot_no' => isset($item['tid']) ? (string) $item['tid'] : null,
'product_id' => $item['t060v00'] ?? null,
'stock' => $item['num'] ?? 0,
'type' => $item['type'] ?? null,
'stock' => isset($item['num']) ? (int) $item['num'] : 0,
'type' => isset($item['type']) ? (int) $item['type'] : null,
// 雙向 LWW鎖定與效期/批號 + 各自的編輯序號App 端 store 編輯才遞增)
'is_locked' => isset($item['is_locked']) ? (bool) $item['is_locked'] : false,
'lock_rev' => isset($item['lock_rev']) ? (int) $item['lock_rev'] : 0,
'expiry_date' => $item['expiry_date'] ?? null,
'batch_no' => $item['batch_no'] ?? null,
'expiry_rev' => isset($item['expiry_rev']) ? (int) $item['expiry_rev'] : 0,
];
}, $legacyData);
@ -385,49 +390,20 @@ class MachineController extends Controller
'success' => true,
'code' => 200,
'message' => __('Slot report synchronized success'),
'status' => '49'
]);
}
/**
* B012_new: Download Product Catalog (Synchronous)
*/
public function getProducts(Request $request)
public function getProducts(Request $request, ProductCatalogService $catalogService)
{
$machine = $request->get('machine');
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->with(['translations'])
->active()
->get()
->map(function ($product) {
// 提取多語系名稱 (Extract translations)
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
// 公司基底目錄(快取) + 該機台專屬定價覆蓋(即時套用,見 getMachinePayload
$payload = $catalogService->getMachinePayload($machine);
return [
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v03' => $product->spec ?? '',
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $products
]);
return response()->json($payload);
}
@ -453,43 +429,568 @@ class MachineController extends Controller
], 404);
}
// 2. 獲取關聯設定 (依據使用者要求,跳過權限校驗,只要有序號即可獲取)
// 2. 獲取金流配置 (依據使用者要求,跳過權限校驗,只要有序號即可獲取)
// 來源統一為 payment_configs.settings 的巢狀結構 (對齊「編輯金流配置」表單)
$paymentSettings = $machine->paymentConfig->settings ?? [];
$companySettings = $machine->company->settings ?? [];
// 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構)
// 4. 映射 App 預期欄位 (輸出鍵維持 HttpAPI.java 結構,僅改值來源為巢狀)
$data = [
't050v01' => $machine->serial_no,
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
// 玉山支付
't050v41' => $paymentSettings['esun_store_id'] ?? '',
't050v42' => $paymentSettings['esun_term_id'] ?? '',
't050v43' => $paymentSettings['esun_hash'] ?? '',
// 玉山掃碼 (esun_scan)
't050v41' => data_get($paymentSettings, 'esun_scan.store_id') ?? '',
't050v42' => data_get($paymentSettings, 'esun_scan.term_id') ?? '',
't050v43' => data_get($paymentSettings, 'esun_scan.key') ?? '',
// 電子發票 (綠界)
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
't050v35' => $companySettings['invoice_hash_key'] ?? '',
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
't050v38' => $companySettings['invoice_email'] ?? '',
// 綠界電子發票 (ecpay_invoice) — 改讀金流配置,不再讀 company.settings
't050v34' => data_get($paymentSettings, 'ecpay_invoice.store_id') ?? '',
't050v35' => data_get($paymentSettings, 'ecpay_invoice.hash_key') ?? '',
't050v36' => data_get($paymentSettings, 'ecpay_invoice.hash_iv') ?? '',
't050v38' => data_get($paymentSettings, 'ecpay_invoice.email') ?? '',
// 趨勢支付 (TrendPay/Greenpay)
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
// TapPay / 趨勢支付 (tappay)
'TP_APP_ID' => data_get($paymentSettings, 'tappay.app_id') ?? '',
'TP_APP_KEY' => data_get($paymentSettings, 'tappay.app_key') ?? '',
'TP_PARTNER_KEY' => data_get($paymentSettings, 'tappay.partner_key') ?? '',
// 各類行動支付特店 ID
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '',
'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '',
'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '',
// 各類行動支付特店 ID (tappay.*_merchant_id)
'TP_LINE_MERCHANT_ID' => data_get($paymentSettings, 'tappay.line_merchant_id') ?? '',
'TP_PS_MERCHANT_ID' => data_get($paymentSettings, 'tappay.ps_merchant_id') ?? '',
'TP_EASY_MERCHANT_ID' => data_get($paymentSettings, 'tappay.easy_merchant_id') ?? '',
'TP_PI_MERCHANT_ID' => data_get($paymentSettings, 'tappay.pi_merchant_id') ?? '',
'TP_JKO_MERCHANT_ID' => data_get($paymentSettings, 'tappay.jko_merchant_id') ?? '',
];
// 5. 機台系統設定 (machines.settings) → 全部以機台端 *Set + PascalCase 風格下發。
// 註:機台 App 端目前讀本地設定,需另案修改 App 才會實際消費此處下發值。
$s = $machine->settings ?? [];
// 5-1 DevSet支付旗標命名沿用機台 DevSetStructureDevCreditCard/DevMobilePay/
// DevCardPay/DevScanPay 為我方新定義,機台端需比照新增欄位)
$data['DevSet'] = [
'ShoppingCar' => (bool) ($s['shopping_cart_enabled'] ?? false), // 購物車
'Invoice' => (bool) ($s['tax_invoice_enabled'] ?? false), // 電子發票
'DevNFCPay' => (bool) ($s['card_terminal_enabled'] ?? false), // 刷卡機(總開關)
'DevCreditCard' => (bool) ($s['credit_card_enabled'] ?? false), // 信用卡支付(新定義)
'DevMobilePay' => (bool) ($s['mobile_pay_enabled'] ?? false), // 手機支付(新定義)
'DevCardPay' => (bool) ($s['card_pay_enabled'] ?? false), // 卡片支付(新定義)
'DevScanPay' => (bool) ($s['scan_pay_enabled'] ?? false), // 掃碼(總開關,新定義)
'DevEsunPay' => (bool) ($s['scan_pay_esun_enabled'] ?? false), // 玉山掃碼
'DevTapPay' => (bool) ($s['scan_pay_tappay_enabled'] ?? false), // TapPay 掃碼
'DevCash' => (bool) ($s['cash_module_enabled'] ?? false), // 現金
'DevLinePay' => (bool) ($s['scan_pay_linepay_enabled'] ?? false), // LINE Pay 官方直連Line官方支付與 TapPay30 不同)
'TapPay30' => (bool) ($s['tappay_linepay'] ?? false), // TapPay 底下的 LINE Pay
'TapPay31' => (bool) ($s['tappay_jkopay'] ?? false), // TapPay-街口支付
'TapPay32' => (bool) ($s['tappay_easywallet'] ?? false), // TapPay-悠遊付
'TapPay33' => (bool) ($s['tappay_pipay'] ?? false), // TapPay-Pi 支付
'TapPay34' => (bool) ($s['tappay_pluspay'] ?? false), // TapPay-全盈+支付
// 註VMC/Electic 為機台硬體類型,雲端無來源,由機台本地保留
];
// 5-2 CashSet現金面額對齊機台 CashSetStructure
$data['CashSet'] = [
'BillF1000' => (bool) ($s['cash_bill_1000'] ?? false),
'BillE500' => (bool) ($s['cash_bill_500'] ?? false),
'BillD100' => (bool) ($s['cash_bill_100'] ?? false),
'CoinF50' => (bool) ($s['cash_coin_50'] ?? false),
'CoinE10' => (bool) ($s['cash_coin_10'] ?? false),
'CoinD5' => (bool) ($s['cash_coin_5'] ?? false),
'CoinC1' => (bool) ($s['cash_coin_1'] ?? false),
];
// 5-3 FunctionSet非支付功能模組旗標我方新定義機台端待實作對應結構
$data['FunctionSet'] = [
'PickupModule' => (bool) ($s['pickup_module_enabled'] ?? false), // 取貨模組
'PickupCode' => (bool) ($s['pickup_code_enabled'] ?? false), // 取貨碼
'PassCode' => (bool) ($s['pass_code_enabled'] ?? false), // 通行碼
'WelcomeGift' => (bool) ($s['welcome_gift_enabled'] ?? false), // 來店禮
'MemberSystem' => (bool) ($s['member_system_enabled'] ?? false), // 會員系統
'AmbientTemp' => (bool) ($s['ambient_temp_monitoring_enabled'] ?? false), // 環境溫度監控
'PharmacyPickup' => (bool) ($s['pharmacy_pickup_enabled'] ?? false), // 領藥單(雲端建單,取物單模式下開關)
'Subcabinet' => (bool) ($s['subcabinet_enabled'] ?? false), // 副櫃系統(格子櫃功能,基礎版授權開關)
];
// 5-4 ShoppingMode購物方式頂層字串
$data['ShoppingMode'] = (string) ($s['shopping_mode'] ?? 'basic'); // basic / employee_card / pickup_sheet
// 5-4b LangSet機台顯示語系後台勾選最多 N 種)。機台據此渲染語系切換 UI
// Default 為開機/idle 預設語系(清單第一個)。未設定時退化為 fallback 單語。
$langs = array_values(array_filter((array) ($s['languages'] ?? [])));
if (empty($langs)) {
$langs = [config('locales.fallback', 'zh_TW')];
}
$data['LangSet'] = [
'Languages' => $langs, // 有序清單,順序即切換順序
'Default' => $langs[0], // 預設語系(清單第一個)
];
// 5-5 OperationSet運作參數machines 實體欄位)
$data['OperationSet'] = [
'CardReaderSeconds' => (int) ($machine->card_reader_seconds ?? 0), // 刷卡機秒數
'PaymentBufferSeconds' => (int) ($machine->payment_buffer_seconds ?? 0), // 金流緩衝秒數
'CheckoutTime1' => (string) ($machine->card_reader_checkout_time_1 ?? ''), // 結帳時間 1
'CheckoutTime2' => (string) ($machine->card_reader_checkout_time_2 ?? ''), // 結帳時間 2
'HeatingStartTime' => (string) ($machine->heating_start_time ?? ''), // 加熱開始時間
'HeatingEndTime' => (string) ($machine->heating_end_time ?? ''), // 加熱結束時間
];
// 5-6 HardwareSet硬體與貨道machines 實體欄位SpringSlot* true=彈簧 / false=履帶)
$data['HardwareSet'] = [
'CardReaderNo' => (string) ($machine->card_reader_no ?? ''), // 刷卡機編號
'SpringSlot1_10' => (bool) $machine->is_spring_slot_1_10,
'SpringSlot11_20' => (bool) $machine->is_spring_slot_11_20,
'SpringSlot21_30' => (bool) $machine->is_spring_slot_21_30,
'SpringSlot31_40' => (bool) $machine->is_spring_slot_31_40,
'SpringSlot41_50' => (bool) $machine->is_spring_slot_41_50,
'SpringSlot51_60' => (bool) $machine->is_spring_slot_51_60,
];
return response()->json([
'success' => true,
'code' => 200,
'data' => [$data] // App 預期的是包含單一物件的陣列
'data' => $data
]);
}
/**
* B016: Update Machine System Settings (Write-back from machine console)
* 機台主控台「系統設定」由系統方 (identity=system) 編輯後回寫雲端。
* 認證B000 核發的使用者 Token (auth:sanctum);僅系統管理員可操作。
* Body: { machine: 序號, settings: { is_spring_slot_*: bool, ... } }
* 機台端僅回寫硬體貨道類型 (is_spring_slot_*) 實體欄位;其餘支付旗標、
* 現金面額、功能模組等開關純由後台決定B016 不接收,未列入者一律忽略。
*/
public function updateSettings(Request $request)
{
// 1. 僅系統方可回寫雙重把關App 端隱藏控制 + 此處伺服器驗權)
$user = $request->user();
if (!$user || !$user->isSystemAdmin()) {
return response()->json([
'success' => false,
'code' => 403,
'message' => __('Forbidden')
], 403);
}
// 2. 查找機台
$serialNo = $request->input('machine');
$machine = Machine::withoutGlobalScopes()
->where('serial_no', $serialNo)
->first();
if (!$machine) {
return response()->json([
'success' => false,
'code' => 404,
'message' => __('Machine not found')
], 404);
}
$settings = $request->input('settings', []);
if (!is_array($settings)) {
return response()->json([
'success' => false,
'code' => 422,
'message' => __('Invalid settings payload')
], 422);
}
// 3. 直接欄位白名單machines 實體布林欄位(硬體貨道類型)
// 機台端 B016 僅回寫貨道類型,其餘系統設定由後台單向決定,不在此處接收。
$columnBoolKeys = [
'is_spring_slot_1_10', 'is_spring_slot_11_20', 'is_spring_slot_21_30',
'is_spring_slot_31_40', 'is_spring_slot_41_50', 'is_spring_slot_51_60',
];
// 4. 整理實體欄位更新(僅貨道類型,只覆寫有送來的鍵)
$columnData = [];
foreach ($columnBoolKeys as $k) {
if (array_key_exists($k, $settings)) {
$columnData[$k] = (bool) $settings[$k];
}
}
if (empty($columnData)) {
return response()->json([
'success' => false,
'code' => 422,
'message' => __('Invalid settings payload')
], 422);
}
$machine->update(array_merge($columnData, [
'updater_id' => $user->id,
]));
\Log::info('B016 Machine settings updated', [
'machine' => $machine->serial_no,
'user_id' => $user->id,
'keys' => array_keys($settings),
]);
return response()->json([
'success' => true,
'code' => 200,
'message' => __('Settings updated successfully.')
]);
}
/**
* B660: Verify/Consume Pickup Code
* POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌)
* PUT: 消耗取貨碼 (扣庫存)
*/
public function verifyPickupCode(Request $request)
{
return $this->handlePickupVerify($request, $request->get('machine'));
}
private function handlePickupVerify(Request $request, $machine)
{
$lockoutKey = "pickup_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Too many failed attempts.', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
}
$code = $request->input('code');
$pickupCode = PickupCode::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$pickupCode) {
$fails = Cache::increment("pickup_fails:{$machine->id}");
if ($fails === 1)
Cache::put("pickup_fails:{$machine->id}", 1, 600);
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60);
Cache::forget("pickup_fails:{$machine->id}");
}
ProcessStateLog::dispatch($machine->id, $machine->company_id, "[PickupCode] Verification failed: :code", 'info', ['code' => $code]);
return response()->json(['success' => false, 'message' => 'Invalid code', 'code' => 404, 'remaining_attempts' => 5 - $fails], 404);
}
Cache::forget("pickup_fails:{$machine->id}");
// 建立取貨碼專屬日誌 (驗證成功)
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'action' => 'verify_success',
'remark' => "log.pickup.verify_success",
'raw_data' => [
'machine_sn' => $machine->serial_no,
'code' => $pickupCode->code,
'slot' => $pickupCode->slot_no
]
]);
// 移除 StaffCardLog 重複紀錄,因為取貨碼本身有 status 和 order_id 可以追蹤
// 一般取貨碼的「核銷」仍由 MQTT finalizeTransaction 流程處理(行為不變)。
$data = [
'slot_no' => $pickupCode->slot_no, // 保留:既有綁貨道碼讀此欄(綁商品碼/領藥單為 null
'product_id' => $pickupCode->product_id, // 綁商品碼App 據此自挑可出貨道(綁貨道碼為 null
'pickup_code_id' => $pickupCode->id,
'code_id' => $pickupCode->id, // 統一回傳 code_id
'status' => $pickupCode->status,
];
// === 領藥單(pharmacy_pickup)擴充:回傳多貨道清單 + 標記碼已領(僅領藥單,不影響既有單貨道流程) ===
$order = $pickupCode->order;
$isPharmacy = $order && $order->order_type === \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
if ($isPharmacy) {
// Gate機台須仍為「取物單模式 + 領藥單開關開啟」才放行領藥碼
$settings = $machine->settings ?? [];
$enabled = (($settings['shopping_mode'] ?? null) === 'pickup_sheet')
&& (bool) ($settings['pharmacy_pickup_enabled'] ?? false);
if (!$enabled) {
return response()->json(['success' => false, 'message' => 'Pharmacy pickup not enabled on this machine', 'code' => 403], 403);
}
$pharmacyService = app(\App\Services\Transaction\PharmacyPickupService::class);
// 嚴格擋關:任一商品的貨道可出量 < 領藥單需求量 → 整單擋下、不出貨、不消耗領藥碼,回明確提示
$shortage = $pharmacyService->dispenseShortage($order);
if (!empty($shortage)) {
$detail = collect($shortage)
->map(fn ($s) => "{$s['product_name']}(需 {$s['required']}、可出 {$s['available']}")
->implode('、');
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.pharmacy_insufficient_stock',
'raw_data' => ['order_no' => $order->order_no, 'shortage' => $shortage],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '因貨道庫存不足,無法出貨:' . $detail,
], 409);
}
// 依目前貨道庫存解析多貨道出貨清單(一商品可跨多貨道湊量)
$items = $pharmacyService->resolveDispenseItems($order);
// 解析不到可出貨貨道(機台無對應貨道/庫存不足)→ 不消耗領藥碼,回明確錯誤讓藥師處理
if (empty($items)) {
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.pharmacy_no_slot',
'raw_data' => ['order_no' => $order->order_no, 'reason' => 'no available slot/stock'],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '此領藥單在本機台無可出貨貨道(請確認貨道對應與庫存)',
], 409);
}
// 不在「驗證/掃碼」當下核銷領藥碼。核銷統一改由機台實際出貨後的
// MQTT finalizeTransactionService::finalizePharmacyDispense標記為 used
// 這樣顧客掃碼後若未按「確定取貨」(逾時/取消、未出貨),領藥碼仍維持 active 可再次掃碼領取。
// (原本在此處 usage_count++/轉 used 會在尚未取貨時就把碼鎖死,導致再掃顯示「已領」。)
$data['order_type'] = \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
$data['order_no'] = $order->order_no;
$data['pricing_slip_no'] = $order->pricing_slip_no ?? ''; // 批價單號(藥師批價依據);機台「確認領藥」畫面備註欄顯示
$data['items'] = $items; // [{slot_no, product_id, product_name, qty}, ...]
$data['status'] = $pickupCode->status;
}
// === 綁商品取貨碼(非領藥單)擋關:機台須仍有此商品貨道,否則 App 必然挑不到貨道 ===
// 即時庫存/鎖定/效期由 App 刷碼當下自行判斷;此處只擋「機台根本沒上這商品」的明確錯誤。
if (!$isPharmacy && $pickupCode->product_id) {
$hasProduct = $machine->slots()->where('product_id', $pickupCode->product_id)->exists();
if (!$hasProduct) {
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.no_slot_for_product',
'raw_data' => ['code' => $pickupCode->code, 'product_id' => $pickupCode->product_id],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '此機台目前無此商品貨道,無法取貨',
], 409);
}
}
return response()->json([
'success' => true,
'code' => 200,
'data' => $data,
]);
}
/**
* B670: Verify Pass Code (For Technicians)
*/
public function verifyPassCode(Request $request)
{
$machine = $request->get('machine');
$lockoutKey = "passcode_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Locked', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails())
return response()->json(['success' => false, 'code' => 400], 400);
$code = $request->input('code');
$passCode = PassCode::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$passCode) {
$fails = Cache::increment("passcode_fails:{$machine->id}");
if ($fails >= 5)
Cache::put($lockoutKey, true, 60);
return response()->json(['success' => false, 'code' => 404], 404);
}
// 建立通行碼專屬日誌 (驗證成功)
PassCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pass_code_id' => $passCode->id,
'status' => 'verified',
'action' => 'verify_success',
'raw_data' => ['machine_sn' => $machine->serial_no, 'code' => $passCode->code]
]);
// 單次/限次通行碼:驗證成功即計為使用一次;達上限就標記 used
// 下次驗證會被上方 where('status','active') 過濾擋掉,達成「只能使用一次」。
// usage_limit 為 null未勾單次時不計數維持原本無限次行為。
if (!is_null($passCode->usage_limit)) {
$newCount = $passCode->usage_count + 1;
$passCode->update([
'usage_count' => $newCount,
'status' => $newCount >= $passCode->usage_limit ? 'used' : $passCode->status,
]);
}
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $passCode->name,
'code_id' => $passCode->id, // 統一回傳 code_id
'status' => 'active'
]
]);
}
/**
* B680: Verify Staff Card (New)
*/
public function verifyStaffCard(Request $request)
{
$machine = $request->get('machine');
$validator = Validator::make($request->all(), [
'code' => 'required|string', // 卡片 UID
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'code' => 400], 400);
}
$uid = $request->input('code');
// 搜尋該公司的有效員工卡 (遵循租戶隔離)
$staffCard = StaffCard::where('company_id', $machine->company_id)
->where('card_uid', $uid)
->where('status', 'active')
->first();
if (!$staffCard) {
ProcessStateLog::dispatch($machine->id, $machine->company_id, "[StaffCard] Verification failed: :uid", 'info', ['uid' => $uid]);
return response()->json(['success' => false, 'code' => 404, 'message' => 'Card not found or inactive'], 404);
}
// 建立刷卡日誌 (作為閉環起點)
StaffCardLog::create([
'company_id' => $machine->company_id,
'staff_card_id' => $staffCard->id,
'machine_id' => $machine->id,
'payment_type' => 41, // Staff Card
'code_id' => $staffCard->id,
'action' => 'verify_success',
'raw_data' => ['machine_sn' => $machine->serial_no, 'uid' => $uid]
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $staffCard->name,
'code_id' => $staffCard->id, // 統一回傳 code_id
'status' => 'active'
]
]);
}
/**
* B690: Verify Welcome Gift (New)
*/
public function verifyWelcomeGift(Request $request)
{
$machine = $request->get('machine');
$lockoutKey = "welcome_gift_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Too many attempts. Locked.', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
}
$code = $request->input('code');
$gift = WelcomeGift::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$gift || !$gift->isValid()) {
$fails = Cache::increment("welcome_gift_fails:{$machine->id}");
if ($fails === 1) {
Cache::put("welcome_gift_fails:{$machine->id}", 1, 600);
}
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60);
Cache::forget("welcome_gift_fails:{$machine->id}");
}
return response()->json(['success' => false, 'message' => 'Invalid or expired code', 'code' => 404, 'remaining_attempts' => max(0, 5 - $fails)], 404);
}
Cache::forget("welcome_gift_fails:{$machine->id}");
// 建立來店禮驗證日誌 (驗證成功)
WelcomeGiftLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'welcome_gift_id' => $gift->id,
'action' => 'verify_success',
'remark' => 'log.welcome_gift.verify_success',
'raw_data' => [
'code' => $gift->code,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value
]
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $gift->name,
'code_id' => $gift->id,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value,
'discount_label' => $gift->discount_label, // 回傳自動算好的中文折數 (如「八折」、「折50元」)
'usage_type' => $gift->usage_type,
'status' => 'active'
]
]);
}
}

View File

@ -27,6 +27,9 @@ class AuthenticatedSessionController extends Controller
{
$request->authenticate();
// 剔除該帳號在其他設備上的所有 Session 連線
Auth::logoutOtherDevices($request->password);
$request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME);

View File

@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\System\Company;
use App\Models\System\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
class TenantLoginController extends Controller
{
/**
* 顯示專屬客戶的客製化登入頁面
*/
public function showLoginForm($company_code)
{
// 1. 查詢啟用的公司
$company = Company::active()->where('code', $company_code)->first();
// 2. 安全防護:若公司不存在,或沒有啟用品牌客製化功能授權,一律自動重導向至通用登入頁
if (!$company) {
return redirect()->route('login');
}
$settings = $company->settings ?? [];
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$enableCustomBranding) {
Log::warning("Tenant login attempted for unauthorized company [{$company_code}]. Redirecting to main login.");
return redirect()->route('login');
}
// 3. 渲染共用的 login Blade傳入當前公司的上下文
return view('auth.login', compact('company'));
}
/**
* 處理專屬客戶的登入請求 (安全性加強版)
*/
public function login($company_code, Request $request)
{
// 1. 查詢並確認該公司
$company = Company::active()->where('code', $company_code)->first();
if (!$company) {
return redirect()->route('login');
}
$settings = $company->settings ?? [];
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$enableCustomBranding) {
return redirect()->route('login');
}
// 2. 驗證輸入欄位
$request->validate([
'username' => 'required|string',
'password' => 'required|string',
]);
// 3. 查詢使用者
$user = User::where('username', $request->username)->first();
// 4. 【關鍵安全防錯】驗證此使用者是否確實屬於該公司
if (!$user || (int)$user->company_id !== (int)$company->id) {
Log::warning("Tenant login security violation: User [{$request->username}] attempted login at company [{$company_code}] portal. User Company ID: " . var_export($user ? $user->company_id : null, true) . ", Target Company ID: {$company->id}");
return back()->withErrors([
'username' => __('This account does not belong to this company.'),
])->withInput($request->only('username', 'remember'));
}
// 5. 驗證帳號狀態
if ($user->status !== 1) {
return back()->withErrors([
'username' => __('Your account is disabled.'),
])->withInput($request->only('username', 'remember'));
}
// 6. 驗證密碼
if (!Hash::check($request->password, $user->password)) {
return back()->withErrors([
'password' => __('auth.failed'),
])->withInput($request->only('username', 'remember'));
}
// 7. 執行登入與安全 Session 重新生成
Auth::login($user, $request->boolean('remember'));
$request->session()->regenerate();
Log::info("User [{$user->username}] successfully logged in via company portal [{$company_code}].");
return redirect()->intended(route('admin.dashboard'));
}
/**
* 預覽專屬客戶的客製化登入頁面 (不受 guest 中間件阻擋,供後台預覽用)
*/
public function preview($company_code)
{
// 1. 查詢啟用的公司
$company = Company::active()->where('code', $company_code)->first();
// 2. 若公司不存在,重導向至主登入頁
if (!$company) {
return redirect()->route('login');
}
$settings = $company->settings ?? [];
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$enableCustomBranding) {
return redirect()->route('login');
}
$isPreview = true;
// 3. 渲染共用的 login Blade並傳入預覽標記
return view('auth.login', compact('company', 'isPreview'));
}
/**
* 處理打 /c/{company_code} 的跳轉邏輯
*/
public function indexRedirect($company_code)
{
// 1. 查詢啟用的公司
$company = Company::active()->where('code', $company_code)->first();
if (!$company) {
return redirect()->route('login');
}
// 2. 已登入則去 dashboard未登入則去專屬登入頁
if (auth()->check()) {
return redirect()->route('admin.dashboard');
}
return redirect()->route('tenant.login', $company_code);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Guest;
use App\Http\Controllers\Controller;
use App\Models\Transaction\PassCode;
use Illuminate\Http\Request;
class PassCodeController extends Controller
{
/**
* 顯示公開通行憑證頁面
*/
public function show($slug)
{
$passCode = PassCode::with(['machine'])
->where('slug', $slug)
->where('status', 'active')
->where(function($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->firstOrFail();
return view('guest.pass-code.show', compact('passCode'));
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Guest;
use App\Http\Controllers\Controller;
use App\Models\Transaction\PickupCode;
use Illuminate\Http\Request;
class PickupController extends Controller
{
/**
* 顯示公開取貨憑證頁面
*/
public function show($slug)
{
$pickupCode = PickupCode::with(['machine.slots.product', 'product'])
->where(function($query) use ($slug) {
$query->where('slug', $slug)
->orWhere('code', $slug); // 相容舊版或測試
})
->where('status', 'active')
->where('expires_at', '>', now())
->firstOrFail();
return view('guest.pickup.show', compact('pickupCode'));
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Guest;
use App\Http\Controllers\Controller;
use App\Models\Transaction\WelcomeGift;
class WelcomeGiftController extends Controller
{
/**
* 顯示公開來店禮憑證頁面
*/
public function show($slug)
{
$welcomeGift = WelcomeGift::with(['machine'])
->where('slug', $slug)
->where('status', 'active')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->firstOrFail();
return view('guest.welcome-gift.show', compact('welcomeGift'));
}
}

View File

@ -9,9 +9,11 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use App\Traits\ImageHandler;
class ProfileController extends Controller
{
use ImageHandler;
/**
* Display the user's profile form.
*/
@ -48,7 +50,7 @@ class ProfileController extends Controller
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
{
$request->validate([
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:1024'],
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:5120'],
]);
$user = $request->user();
@ -59,7 +61,8 @@ class ProfileController extends Controller
Storage::disk('public')->delete($user->avatar);
}
$path = $request->file('avatar')->store('avatars', 'public');
// 將上傳的大頭貼轉換為高壓縮率且清晰的 WebP 格式 (300x300 居中裁切)
$path = $this->storeAsWebp($request->file('avatar'), 'avatars', 85, 300, 300);
$user->avatar = $path;
$user->save();

View File

@ -19,7 +19,7 @@ class ProfileUpdateRequest extends FormRequest
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
'phone' => ['nullable', 'string', 'max:20'],
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:5120'],
];
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
class ProcessAmbientTemp implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $payload;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, $payload)
{
$this->serialNo = $serialNo;
$this->payload = (array) $payload;
}
/**
* Execute the job.
*/
public function handle(): void
{
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
if (!$machine) {
Log::warning("MQTT Ambient Temp: Machine not found", ['serial_no' => $this->serialNo]);
return;
}
// 環境溫度欄位更新
// 支持機台傳送 payload 為 {"temperature": 25} 或 {"ambient_temp": 25} 等結構
$temp = null;
if (isset($this->payload['temperature'])) {
$temp = (int) $this->payload['temperature'];
} elseif (isset($this->payload['ambient_temp'])) {
$temp = (int) $this->payload['ambient_temp'];
} elseif (isset($this->payload['raw_data'])) {
$temp = (int) $this->payload['raw_data'];
}
if ($temp === null) {
Log::warning("MQTT Ambient Temp: Missing temperature field in payload", [
'serial_no' => $this->serialNo,
'payload' => $this->payload
]);
return;
}
Log::debug("ProcessAmbientTemp: Ambient temperature reported for {$this->serialNo}: {$temp}");
$updateData = [
'ambient_temperature' => $temp,
];
// 讀取最後一次記錄日誌的溫度與時間,避免頻繁寫入相同的溫度日誌
$tempLogCacheKey = "machine:{$this->serialNo}:last_ambient_temp_log";
$lastLogEntry = Cache::get($tempLogCacheKey);
// 優先從快取取值,若無則從資料庫取
$oldTemp = isset($lastLogEntry['value']) ? (int)$lastLogEntry['value'] : $machine->ambient_temperature;
if ($temp !== (int)$oldTemp) {
Log::debug("ProcessAmbientTemp: Ambient temperature changed from {$oldTemp} to {$temp}. Triggering log.");
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Ambient temperature reported: :temp°C",
'info',
['temp' => $temp],
'ambient_temp' // type 欄位
);
}
// 更新快取 (存活時間為 7 天)
Cache::put($tempLogCacheKey, [
'value' => $temp,
'at' => now()->toDateTimeString()
], 604800);
$machine->update($updateData);
}
}

View File

@ -3,7 +3,9 @@
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineStockMovement;
use App\Models\Machine\RemoteCommand;
use App\Services\Machine\MachineService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -34,7 +36,7 @@ class ProcessCommandAck implements ShouldQueue
* 處理 B055 出貨回報 ACK
* 原始邏輯移植自 MachineController::reportDispenseResult()
*/
public function handle(): void
public function handle(MachineService $machineService): void
{
$commandId = $this->payload['command_id'] ?? null;
$resultCode = $this->payload['result'] ?? null;
@ -77,6 +79,19 @@ class ProcessCommandAck implements ShouldQueue
return;
}
// OTA 更新例外處理APK 下載/安裝為長時程作業機台會持續回報「進度」ACK
// (result="progress"如「Downloading 45%」「Installing...」)。這些屬於過程回報,
// 不可視為終態,否則指令會在「剛開始下載」就被標記完成,與機台實際版本不符。
// 僅將進度文字寫入 note 供後台檢視,並保持 pending等待最終 success/failed。
if ($command->command_type === 'update_app' && $resultCode === 'progress') {
$command->update(['note' => $this->payload['message'] ?? $command->note]);
Log::info('MQTT CommandAck: update_app progress (kept pending)', [
'command_id' => $commandId,
'message' => $this->payload['message'] ?? null,
]);
return;
}
// 判定執行結果 (result: "success" 或 "0" 代表成功)
$status = in_array($resultCode, ['success', '0'], true) ? 'success' : 'failed';
@ -88,41 +103,20 @@ class ProcessCommandAck implements ShouldQueue
$machine = $command->machine;
if ($command->command_type === 'dispense') {
$slotNo = $command->payload['slot_no'] ?? null;
$slot = $machine->slots()->where('slot_no', $slotNo)->first();
// 遠端出貨指令僅紀錄狀態,不再執行庫存預扣或回滾。
// 真正的庫存異動將由機台發送的 Transaction Finalize (結案) 流程處理。
$msgKey = ($status === 'success') ? "Remote dispense successful for slot :slot" : "Remote dispense failed for slot :slot";
$level = ($status === 'success') ? 'info' : 'error';
if ($slot) {
if ($status === 'success') {
// 若出貨成功,且 APP 回報了最新的庫存值,則以 APP 為準進行校準
// 否則維持預扣後的狀態 (不需要額外動作,因為下發時已扣除)
if ($stockReported !== null) {
$slot->update(['stock' => (int)$stockReported]);
$payloadUpdates['new_stock'] = (int)$stockReported;
}
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Remote dispense successful for slot :slot",
'info',
['slot' => $slotNo]
);
} else {
// 若出貨失敗,執行回滾 (Rollback):將預扣的庫存加回去
$oldStock = $command->payload['old_stock'] ?? $slot->stock;
$slot->update(['stock' => $oldStock]);
$payloadUpdates['rolled_back'] = true;
$payloadUpdates['new_stock'] = $oldStock;
Log::warning('MQTT Dispense failed, rolled back inventory', [
'serial_no' => $this->serialNo,
'slot_no' => $slotNo,
'rolled_back_to' => $oldStock
]);
}
}
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$msgKey,
$level,
['slot' => $command->payload['slot_no'] ?? 'N/A']
);
}
elseif ($command->command_type === 'reload_stock') {
// 若同步失敗,自動 Rollback 回滾庫存
if ($status === 'failed' && isset($command->payload['slot_no'], $command->payload['old'])) {
@ -152,6 +146,20 @@ class ProcessCommandAck implements ShouldQueue
'payload' => array_merge($command->payload, $payloadUpdates),
]);
// 記錄維護類指令到機台日誌 (MachineLog)
if (in_array($command->command_type, ['reboot', 'reboot_force', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads', 'update_settings'], true)) {
$msgKey = $status === 'success' ? 'log.command.success' : 'log.command.failed';
$level = $status === 'success' ? 'info' : 'warning';
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$msgKey,
$level,
['type' => __("command.{$command->command_type}")]
);
}
Log::info("MQTT CommandAck processed", [
'serial_no' => $this->serialNo,
'command_id' => $commandId,

View File

@ -48,25 +48,40 @@ class ProcessHeartbeat implements ShouldQueue
// === 狀態異動觸發 (Redis 快取,讓 MQTT 也支援日誌) ===
$cacheKey = "machine:{$this->serialNo}:state";
$oldState = \Illuminate\Support\Facades\Cache::get($cacheKey);
// 1. 偵測狀態由離線轉在線 (寫入日誌)
if ($machine->status === 'offline') {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Machine is online",
'info',
[]
);
}
$newState = $oldState ?? [];
// 1. 韌體版本比對 (有改才傳,有改才記)
// 2. 韌體版本比對 (有改才傳,有改才記)
if (isset($this->payload['firmware_version'])) {
$fv = (string) $this->payload['firmware_version'];
if (!isset($oldState['firmware_version']) || (string)$oldState['firmware_version'] !== $fv) {
// 優先從快取取舊版本,若無則從資料庫取
$oldVersion = $oldState['firmware_version'] ?? $machine->firmware_version;
if ((string)$oldVersion !== $fv) {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Firmware updated to :version",
'info',
['version' => $fv, 'old' => $oldState['firmware_version'] ?? 'Unknown']
['version' => $fv, 'old' => $oldVersion ?? 'Unknown']
);
}
$updateData['firmware_version'] = $fv;
$newState['firmware_version'] = $fv;
}
// 2. 溫度更新 (只要有變動就紀錄日誌)
// 2. 溫度更新 (只要有變動就紀錄日誌,並進行溫度監控告警判定)
if (isset($this->payload['temperature'])) {
$temp = (int) $this->payload['temperature'];
Log::debug("ProcessHeartbeat: Temperature reported for {$this->serialNo}: {$temp}");
@ -76,38 +91,147 @@ class ProcessHeartbeat implements ShouldQueue
$tempLogCacheKey = "machine:{$this->serialNo}:last_temp_log";
$lastLogEntry = \Illuminate\Support\Facades\Cache::get($tempLogCacheKey);
$shouldLog = false;
if (!$lastLogEntry) {
$shouldLog = true;
} else {
$lastValue = (int) $lastLogEntry['value'];
$lastAt = \Carbon\Carbon::parse($lastLogEntry['at']);
// 統一邏輯:優先從快取取值,若無則從資料庫取
$oldTemp = isset($lastLogEntry['value']) ? (int)$lastLogEntry['value'] : $machine->temperature;
// 條件 A: 只要溫度與上次記錄不同就記錄 (整數比對)
if ($temp !== $lastValue) {
Log::debug("ProcessHeartbeat: Temperature changed from {$lastValue} to {$temp}. Triggering log.");
$shouldLog = true;
}
// 條件 B: 距離上次日誌超過 4 小時 (保底機制)
if ($lastAt->diffInHours(now()) >= 4) {
$shouldLog = true;
}
}
if ($shouldLog) {
if ($temp !== (int)$oldTemp) {
Log::debug("ProcessHeartbeat: Temperature changed from {$oldTemp} to {$temp}. Triggering ordinary log.");
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Temperature reported: :temp°C",
'info',
['temp' => $temp]
['temp' => $temp],
'status' // 普通狀態日誌,不發送 Discord
);
}
// 更新快取,保留 7 天
\Illuminate\Support\Facades\Cache::put($tempLogCacheKey, [
'value' => $temp,
'at' => now()->toDateTimeString()
], 604800);
// 無論是否有變動,都更新/延長快取壽命,確保快取資料存在
\Illuminate\Support\Facades\Cache::put($tempLogCacheKey, [
'value' => $temp,
'at' => now()->toDateTimeString()
], 604800);
// === 溫度自適應告警判定 ===
$tempAlertEnabled = false;
$machineSettings = $machine->settings ?? [];
$company = $machine->company;
$companySettings = $company ? ($company->settings ?? []) : [];
// 1. 決定是否啟用溫度監控
$enabledSetting = $machineSettings['temp_alert_enabled'] ?? 'inherit';
if ($enabledSetting === 'enabled') {
$tempAlertEnabled = true;
} elseif ($enabledSetting === 'disabled') {
$tempAlertEnabled = false;
} else {
// inherit 或是沒設定,繼承公司設定
$companyEnabled = $companySettings['discord_notify_enabled'] ?? false;
$companyTypes = $companySettings['discord_notify_types'] ?? [];
if ($companyEnabled && in_array('temperature', $companyTypes)) {
$tempAlertEnabled = true;
}
}
if ($tempAlertEnabled) {
$upperLimit = null;
$lowerLimit = null;
// 優先度 1: 機台自訂
if (isset($machineSettings['temp_upper_limit']) && $machineSettings['temp_upper_limit'] !== '') {
$upperLimit = (int)$machineSettings['temp_upper_limit'];
}
if (isset($machineSettings['temp_lower_limit']) && $machineSettings['temp_lower_limit'] !== '') {
$lowerLimit = (int)$machineSettings['temp_lower_limit'];
}
// 優先度 2: 型號預設
$model = $machine->machineModel;
if ($model) {
$modelSettings = $model->settings ?? [];
if ($upperLimit === null && isset($modelSettings['temp_upper_limit']) && $modelSettings['temp_upper_limit'] !== '') {
$upperLimit = (int)$modelSettings['temp_upper_limit'];
}
if ($lowerLimit === null && isset($modelSettings['temp_lower_limit']) && $modelSettings['temp_lower_limit'] !== '') {
$lowerLimit = (int)$modelSettings['temp_lower_limit'];
}
}
// 優先度 3: 公司預設
if ($upperLimit === null && isset($companySettings['temp_upper_limit']) && $companySettings['temp_upper_limit'] !== '') {
$upperLimit = (int)$companySettings['temp_upper_limit'];
}
if ($lowerLimit === null && isset($companySettings['temp_lower_limit']) && $companySettings['temp_lower_limit'] !== '') {
$lowerLimit = (int)$companySettings['temp_lower_limit'];
}
// 全域預設值 fallback
if ($upperLimit === null) {
$upperLimit = 40;
}
if ($lowerLimit === null) {
$lowerLimit = 0;
}
$activeKey = "machine:{$this->serialNo}:temp_alert_active";
$cooldownKey = "machine:{$this->serialNo}:temp_alert_cooldown";
$activeStatus = \Illuminate\Support\Facades\Cache::get($activeKey);
if ($temp > $upperLimit) {
// 若從低溫直接跳到高溫,清除低溫告警冷卻快取,但不發送恢復通知
if ($activeStatus === 'low') {
\Illuminate\Support\Facades\Cache::forget($cooldownKey);
}
$hasCooldown = \Illuminate\Support\Facades\Cache::has($cooldownKey);
if ($activeStatus !== 'high' || !$hasCooldown) {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Temperature too high: :temp°C",
'error',
['temp' => $temp, 'limit' => $upperLimit],
'temperature'
);
\Illuminate\Support\Facades\Cache::put($cooldownKey, true, 3600);
\Illuminate\Support\Facades\Cache::put($activeKey, 'high', 86400);
}
} elseif ($temp < $lowerLimit) {
// 若從高溫直接跳到低溫,清除高溫告警冷卻快取,但不發送恢復通知
if ($activeStatus === 'high') {
\Illuminate\Support\Facades\Cache::forget($cooldownKey);
}
$hasCooldown = \Illuminate\Support\Facades\Cache::has($cooldownKey);
if ($activeStatus !== 'low' || !$hasCooldown) {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Temperature too low: :temp°C",
'error',
['temp' => $temp, 'limit' => $lowerLimit],
'temperature'
);
\Illuminate\Support\Facades\Cache::put($cooldownKey, true, 3600);
\Illuminate\Support\Facades\Cache::put($activeKey, 'low', 86400);
}
} else {
// 溫度正常
if ($activeStatus) {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Temperature restored: :temp°C",
'info',
['temp' => $temp],
'temperature'
);
\Illuminate\Support\Facades\Cache::forget($activeKey);
\Illuminate\Support\Facades\Cache::forget($cooldownKey);
}
}
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessMachineEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $payload;
/**
* Create a new job instance.
*/
public function __construct($serialNo, $payload)
{
$this->serialNo = $serialNo;
$this->payload = $payload;
}
/**
* Execute the job.
*/
public function handle(): void
{
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
if (!$machine) {
Log::warning("ProcessMachineEvent: Machine not found [{$this->serialNo}]");
return;
}
$event = $this->payload['event'] ?? null;
if (!$event) {
Log::warning("ProcessMachineEvent: Missing event in payload", ['payload' => $this->payload]);
return;
}
// 寫入環境溫度日誌type 為 'ambient_temp' 以便正確歸類顯示於「環境溫度回傳」日誌中
$machine->logs()->create([
'company_id' => $machine->company_id,
'type' => 'ambient_temp', // 歸類至環境溫度回傳
'level' => 'info',
'message' => $event, // 例如 "fanon" 或 "fanoff"
'context' => $this->payload,
]);
Log::info("ProcessMachineEvent: Machine [{$machine->serial_no}] recorded event [{$event}] to status logs");
}
}

View File

@ -23,9 +23,7 @@ class ProcessStatus implements ShouldQueue
*/
public function __construct($serialNo, $payload)
{
// 防止 Client ID 帶有 SC_ 前綴或隨機碼 (如 SC_M0408_test)
$cleanSerial = preg_replace('/^SC_/', '', $serialNo);
$this->serialNo = explode('_', $cleanSerial)[0];
$this->serialNo = $serialNo;
$this->payload = $payload;
}
@ -54,16 +52,30 @@ class ProcessStatus implements ShouldQueue
$updateData = ['status' => $status];
if ($status === 'online') {
$updateData['last_heartbeat_at'] = now();
// 自動消警:當機台恢復連線時,自動標記之前的「斷線 (LWT)」警告為已解決
$machine->logs()
->where('type', 'status')
->where('level', 'warning')
->where('message', 'Connection lost (LWT)')
->where('is_resolved', false)
->update(['is_resolved' => true]);
}
$machine->update($updateData);
// If status changed, log it
if ($oldStatus !== $status) {
$level = ($status === 'offline') ? 'warning' : 'info';
$message = ($status === 'offline')
? "Connection lost (LWT)"
: "Connection restored";
$level = 'info';
$message = "Connection restored";
if ($status === 'offline') {
$level = 'warning';
$message = "Connection lost (LWT)";
} elseif ($status === 'restarting') {
$level = 'info';
$message = "System restarting";
}
ProcessStateLog::dispatch($machine->id, $machine->company_id, $message, $level);

View File

@ -1,51 +0,0 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\TimerStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessTimerStatus implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
TimerStatus::updateOrCreate(
['machine_id' => $machine->id, 'slot_no' => $this->data['slot_no']],
[
'status' => $this->data['status'],
'remaining_seconds' => $this->data['remaining_seconds'],
'end_at' => isset($this->data['end_at']) ? \Carbon\Carbon::parse($this->data['end_at']) : null,
]
);
} catch (\Exception $e) {
Log::error("Failed to process timer status for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

@ -29,7 +29,7 @@ class ProcessTransaction implements ShouldQueue
/**
* Execute the job.
*/
public function handle(\App\Services\Transaction\TransactionService $transactionService): void
public function handle(\App\Services\Transaction\TransactionService $transactionService, \App\Services\Machine\MqttService $mqttService): void
{
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
@ -38,6 +38,8 @@ class ProcessTransaction implements ShouldQueue
return;
}
$flowId = $this->payload['flow_id'] ?? 'N/A';
try {
// 將 serial_no 放入 payload 供 Service 使用
$data = $this->payload;
@ -50,11 +52,30 @@ class ProcessTransaction implements ShouldQueue
$machine->company_id,
"Transaction processed: :id",
'info',
array_merge($this->payload, ['id' => ($this->payload['flow_id'] ?? 'N/A')]),
array_merge($this->payload, ['id' => $flowId]),
'transaction'
);
// 發送成功回饋 (ACK)
$mqttService->pushCommand($this->serialNo, 'transaction_ack', [
'flow_id' => $flowId,
'status' => 'success',
'message' => 'Processed successfully'
], (string) $flowId);
} catch (\Exception $e) {
Log::error("Failed to process MQTT transaction for machine {$this->serialNo}: " . $e->getMessage());
Log::error("Failed to process MQTT transaction for machine {$this->serialNo}: " . $e->getMessage(), [
'payload' => $this->payload,
'exception' => $e
]);
// 發送失敗回饋 (ACK)
$mqttService->pushCommand($this->serialNo, 'transaction_ack', [
'flow_id' => $flowId,
'status' => 'error',
'message' => $e->getMessage()
], (string) $flowId);
throw $e;
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Jobs\Notification;
use App\Models\Machine\MachineLog;
use App\Services\Notification\DiscordWebhookService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SendDiscordNotificationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* 任務最大重試次數
*/
public int $tries = 3;
protected int $logId;
/**
* 建立新任務實例
*/
public function __construct(int $logId)
{
$this->logId = $logId;
}
/**
* 執行任務
*/
public function handle(DiscordWebhookService $service): void
{
$log = MachineLog::find($this->logId);
if (!$log) {
Log::warning("SendDiscordNotificationJob: MachineLog [{$this->logId}] not found. Skipping.");
return;
}
$machine = $log->machine;
if (!$machine || !$machine->company) {
return;
}
$company = $machine->company;
$companySettings = $company->settings ?? [];
// 驗證是否啟用 Discord 通知
if (!($companySettings['discord_notify_enabled'] ?? false)) {
return;
}
// 優先使用機台專屬 Webhook URL其次為全域設定
$machineSettings = $machine->settings ?? [];
$webhookUrl = $machineSettings['discord_webhook_url'] ?? $companySettings['discord_webhook_url'] ?? null;
if (!$webhookUrl) {
return;
}
// 僅針對類型進行過濾,不再進行重要級別 (Levels) 的篩選
$allowedTypes = $companySettings['discord_notify_types'] ?? [];
if (!in_array($log->type, $allowedTypes)) {
Log::info("SendDiscordNotificationJob: Log [{$this->logId}] filtered by type settings.", [
'log_type' => $log->type,
'allowed_types' => $allowedTypes
]);
return;
}
// 發送告警通知
$success = $service->sendMachineLogAlert($webhookUrl, $log);
if (!$success) {
// 拋出 Exception 以便 Laravel Queue 機制自動重試
throw new \RuntimeException("Failed to deliver Discord notification for Log ID: {$log->id}");
}
}
}

View File

@ -0,0 +1,142 @@
<?php
namespace App\Jobs\Product;
use App\Models\Product\Product;
use App\Models\System\SystemOperationLog;
use App\Services\Product\ProductCatalogService;
use App\Services\ProductImportService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
/**
* 全背景處理商品匯入:解壓圖片壓縮檔、匯入商品(含圖片轉 WebP 入庫)、重建目錄快取、寫入結果摘要日誌、清除暫存。
* Controller 僅負責存檔並派發本 Job請求立即回應避免大批匯入受 Cloudflare 100 秒等請求逾時限制。
*/
class ProcessProductImportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800; // 30 分鐘,容納大批商品與圖片轉檔
public int $tries = 1; // 不重試整批,避免重複建立商品;結果以日誌呈現
public function __construct(
protected string $baseDir,
protected string $xlsxPath,
protected ?string $zipPath,
protected ?int $companyId = null,
protected ?int $userId = null
) {}
public function handle(ProductImportService $importService, ProductCatalogService $catalogService): void
{
try {
$imageDir = ($this->zipPath && is_file($this->zipPath))
? $this->extractImageZip($this->zipPath)
: null;
// 背景執行無請求逾時壓力圖片同步轉檔即可deferImages = false
$results = $importService->import($this->xlsxPath, $this->companyId, $this->userId, $imageDir);
foreach ($results['company_ids'] as $companyId) {
$catalogService->rebuildCache($companyId);
}
SystemOperationLog::create([
'company_id' => $this->companyId,
'user_id' => $this->userId,
'module' => 'product',
'action' => 'import',
'target_type' => Product::class,
'note' => 'product_import_completed',
'new_values' => [
'success_count' => $results['success_count'],
'created_count' => $results['created_count'],
'updated_count' => $results['updated_count'],
'error_count' => $results['error_count'],
'image_success' => $results['image_success'],
'image_missing' => count($results['image_missing']),
'errors' => array_slice($results['errors'], 0, 50),
'image_missing_detail' => array_slice($results['image_missing'], 0, 50),
],
]);
} finally {
if (is_dir($this->baseDir)) {
File::deleteDirectory($this->baseDir);
}
}
}
/**
* 將圖片壓縮檔安全解壓到 baseDir/images回傳目錄路徑。
* 防護副檔名白名單、basename 防穿越、項目數/單檔/總量上限 ( zip bomb)、內容驗證為圖片。
*/
private function extractImageZip(string $zipPath): string
{
$allowedExt = ['jpg', 'jpeg', 'png', 'webp'];
$maxEntries = 2000;
$maxFileBytes = 5 * 1024 * 1024;
$maxTotalBytes = 200 * 1024 * 1024;
$dir = rtrim($this->baseDir, '/') . '/images';
File::ensureDirectoryExists($dir);
$zip = new \ZipArchive();
if ($zip->open($zipPath) !== true) {
throw new \RuntimeException(__('Unable to read the image archive'));
}
if ($zip->numFiles > $maxEntries) {
$zip->close();
throw new \RuntimeException(__('The image archive contains too many files'));
}
$totalBytes = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$entryName = $stat['name'];
if (str_ends_with($entryName, '/') || str_contains($entryName, '..')) {
continue;
}
$base = basename($entryName);
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExt, true)) {
continue;
}
if ($stat['size'] > $maxFileBytes) {
continue;
}
$totalBytes += $stat['size'];
if ($totalBytes > $maxTotalBytes) {
$zip->close();
throw new \RuntimeException(__('The image archive is too large when extracted'));
}
$stream = $zip->getStream($entryName);
if ($stream === false) {
continue;
}
$contents = stream_get_contents($stream);
fclose($stream);
if ($contents === false || getimagesizefromstring($contents) === false) {
continue;
}
file_put_contents($dir . '/' . $base, $contents);
}
$zip->close();
return $dir;
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Jobs\Product;
use App\Models\Machine\Machine;
use App\Models\Machine\RemoteCommand;
use App\Services\Machine\MqttService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SendProductSyncCommandJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @param int $machineId
* @param string|null $remark
* @param int|null $userId
*/
public function __construct(
protected int $machineId,
protected ?string $remark = null,
protected ?int $userId = null
) {}
/**
* Execute the job.
*
* @param MqttService $mqttService
* @return void
*/
public function handle(MqttService $mqttService): void
{
$machine = Machine::find($this->machineId);
if (!$machine) {
Log::error("SendProductSyncCommandJob: Machine not found", ['id' => $this->machineId]);
return;
}
// 覆蓋任何該機台尚在「待處理」狀態的商品同步指令
RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'update_products')
->where('status', 'pending')
->update([
'status' => 'superseded',
'note' => 'Superseded by new command'
]);
$command = RemoteCommand::create([
'machine_id' => $machine->id,
'user_id' => $this->userId,
'command_type' => 'update_products',
'payload' => [], // No extra payload needed for B012 trigger
'status' => 'pending',
'remark' => $this->remark ?: __('Batch Product Sync'),
]);
$success = $mqttService->pushCommand(
$machine->serial_no,
'update_products',
[],
(string) $command->id
);
if (!$success) {
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
}
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Jobs\Product;
use App\Models\System\Company;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyncProductsToMachinesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @param int $companyId
* @param string|null $remark
* @param int|null $userId
*/
public function __construct(
protected int $companyId,
protected ?string $remark = null,
protected ?int $userId = null
) {}
/**
* Execute the job.
*
* @return void
*/
public function handle(): void
{
$company = Company::with('machines')->find($this->companyId);
if (!$company || $company->machines->isEmpty()) {
return;
}
$delayMs = 100; // 100ms per machine
foreach ($company->machines as $index => $machine) {
// Calculate delay to avoid thundering herd (100ms, 200ms, 300ms...)
$currentDelay = ($index * $delayMs) / 1000;
SendProductSyncCommandJob::dispatch($machine->id, $this->remark, $this->userId)
->delay(now()->addSeconds($currentDelay));
}
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Jobs\Transaction;
use App\Models\Transaction\Invoice;
use App\Services\Invoice\EcpayInvoiceService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* 後台開立電子發票(取代機台直連綠界)。
*
* finalize 建立 pending 發票後派發;以 invoice.relate_number 為冪等鍵向綠界開立Issue
* 成功→issued、失敗→failed待人工/排程補開)。連線失敗則 retry由佇列重試機制接手
* 仍有殘留 pending 者由 invoices:reconcileGetIssue做最終查證避免重複開立。
*/
class IssueInvoiceJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(public int $invoiceId)
{
}
public function handle(EcpayInvoiceService $ecpay): void
{
$invoice = Invoice::with(['machine.paymentConfig', 'order.items'])->find($this->invoiceId);
if (!$invoice) {
return;
}
// 僅處理待開立;已開/失敗/作廢不重複觸發(冪等)
if ($invoice->status !== Invoice::STATUS_PENDING) {
return;
}
// 機台無綠界設定 → 留 pending理論上不會發生無設定機台不會帶 invoice
if (!$invoice->machine || !$ecpay->configForMachine($invoice->machine)) {
Log::warning('IssueInvoiceJob: machine has no ECPay config, leave pending', [
'invoice_id' => $invoice->id,
]);
return;
}
$result = $ecpay->reissue($invoice);
// 連線/解密失敗 → 丟例外讓佇列重試(不改狀態)
if ($result === null) {
$invoice->increment('retry_count');
$invoice->update(['last_checked_at' => now()]);
throw new \RuntimeException('ECPay issue returned null for invoice ' . $invoice->id);
}
$rtnCode = (string) ($result['RtnCode'] ?? '');
$invoiceNo = $result['InvoiceNo'] ?? '';
if ($rtnCode === '1' && !empty($invoiceNo)) {
$invoice->update([
'status' => Invoice::STATUS_ISSUED,
'invoice_no' => $invoiceNo,
'invoice_date' => $this->safeDate($result['InvoiceDate'] ?? null),
'random_number' => $result['RandomNumber'] ?? $invoice->random_number,
'rtn_code' => $rtnCode,
'rtn_msg' => $result['RtnMsg'] ?? null,
'last_checked_at' => now(),
]);
Log::info('Invoice issued by backend', ['invoice_id' => $invoice->id, 'invoice_no' => $invoiceNo]);
return;
}
// 綠界明確回失敗 → 標 failed待補開不重試
$invoice->update([
'status' => Invoice::STATUS_FAILED,
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
'last_checked_at' => now(),
]);
Log::warning('Invoice issue failed', [
'invoice_id' => $invoice->id,
'rtn_code' => $rtnCode,
'rtn_msg' => $result['RtnMsg'] ?? '',
]);
}
private function safeDate($raw): ?string
{
if (empty($raw)) {
return null;
}
try {
return \Carbon\Carbon::parse($raw)->toDateString();
} catch (\Throwable $e) {
return null;
}
}
}

View File

@ -27,12 +27,34 @@ class ProcessDispenseRecord implements ShouldQueue
/**
* Execute the job.
*/
public function handle(TransactionService $transactionService): void
public function handle(\App\Services\Transaction\TransactionService $transactionService, \App\Services\Machine\MqttService $mqttService): void
{
$serialNo = $this->data['serial_no'] ?? 'unknown';
$orderNo = $this->data['order_no'] ?? 'N/A';
try {
$transactionService->recordDispense($this->data);
// 發送成功回饋 (ACK)
$mqttService->pushCommand($serialNo, 'dispense_ack', [
'order_no' => $orderNo,
'status' => 'success',
'message' => 'Processed successfully'
], $orderNo);
} catch (\Exception $e) {
Log::error("Failed to record dispense for machine {$this->data['serial_no']}: " . $e->getMessage());
Log::error("Failed to record dispense for machine {$serialNo}: " . $e->getMessage(), [
'data' => $this->data,
'exception' => $e
]);
// 發送失敗回饋 (ACK)
$mqttService->pushCommand($serialNo, 'dispense_ack', [
'order_no' => $orderNo,
'status' => 'error',
'message' => $e->getMessage()
], $orderNo);
throw $e;
}
}

View File

@ -27,15 +27,34 @@ class ProcessInvoice implements ShouldQueue
/**
* Execute the job.
*/
public function handle(TransactionService $transactionService): void
public function handle(\App\Services\Transaction\TransactionService $transactionService, \App\Services\Machine\MqttService $mqttService): void
{
$serialNo = $this->data['serial_no'] ?? 'unknown';
$invoiceNo = $this->data['invoice_no'] ?? 'N/A';
try {
$transactionService->recordInvoice($this->data);
// 發送成功回饋 (ACK)
$mqttService->pushCommand($serialNo, 'invoice_ack', [
'invoice_no' => $invoiceNo,
'status' => 'success',
'message' => 'Processed successfully'
], $invoiceNo);
} catch (\Exception $e) {
Log::error('Failed to process invoice: ' . $e->getMessage(), [
'data' => $this->data,
'exception' => $e
]);
// 發送失敗回饋 (ACK)
$mqttService->pushCommand($serialNo, 'invoice_ack', [
'invoice_no' => $invoiceNo,
'status' => 'error',
'message' => $e->getMessage()
], $invoiceNo);
throw $e;
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace App\Jobs\Transaction;
use App\Models\Machine\Machine;
use App\Services\Transaction\TransactionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use App\Jobs\Machine\ProcessStateLog;
use App\Models\Transaction\Order;
use App\Services\Machine\MachineService;
class ProcessTransactionFinalized implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** 走實體刷卡機(Nexsys)的支付類型1 信用卡、2 電子票證、10 手機感應支付,共用同一台機器。 */
private const CARD_TERMINAL_PAYMENT_TYPES = [1, 2, 10];
protected $serialNo;
protected $payload;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $payload)
{
$this->serialNo = $serialNo;
$this->payload = $payload;
}
/**
* Execute the job.
*/
public function handle(\App\Services\Transaction\TransactionService $transactionService, \App\Services\Machine\MqttService $mqttService): void
{
$machine = Machine::withoutGlobalScopes()->where('serial_no', $this->serialNo)->first();
if (!$machine) {
Log::warning("MQTT Transaction Finalized: Machine not found", ['serial_no' => $this->serialNo]);
return;
}
$flowId = $this->payload['order']['flow_id'] ?? ($this->payload['flow_id'] ?? 'N/A');
try {
$data = $this->payload;
$data['serial_no'] = $this->serialNo;
$order = $transactionService->finalizeTransaction($data);
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Transaction finalized: :id",
'info',
[
'id' => $order->order_no,
'flow_id' => $order->flow_id
],
'transaction'
);
// 刷卡機健康狀態同步:成功消警 / 失敗寫 card_terminal 日誌
$this->syncCardTerminalStatus($machine, $order);
Log::info("MQTT Transaction [finalize] processed successfully", [
'serial_no' => $this->serialNo,
'order_no' => $order->order_no
]);
// 發送成功回饋 (ACK)
$mqttService->pushCommand($this->serialNo, 'transaction_ack', [
'flow_id' => $flowId,
'order_no' => $order->order_no,
'status' => 'success',
'message' => 'Processed successfully'
], (string) $flowId);
} catch (\Exception $e) {
Log::error("Failed to process MQTT transaction finalized for machine {$this->serialNo}: " . $e->getMessage(), [
'payload' => $this->payload,
'exception' => $e
]);
// 發送失敗回饋 (ACK)
$mqttService->pushCommand($this->serialNo, 'transaction_ack', [
'flow_id' => $flowId,
'status' => 'error',
'message' => $e->getMessage()
], (string) $flowId);
throw $e;
}
}
/**
* 刷卡機 (信用卡/電子票證/手機支付payment_type 1/2/10) 狀態同步。
* 刷卡機沒有斷線心跳,所以靠交易結果維護燈號:
* - 成功交易 自動消除該機台先前未解決的刷卡機警告,恢復正常。
* - 失敗交易(payment_response NFC 回應碼) 寫一筆 type='card_terminal' warning 日誌
* (warning 級依 MachineLog 既有規則不會觸發 Discord)
*/
private function syncCardTerminalStatus(Machine $machine, Order $order): void
{
if (!in_array((int) $order->payment_type, self::CARD_TERMINAL_PAYMENT_TYPES, true)) {
return;
}
// 刷卡成功 → 消警,恢復刷卡機燈號
if ((int) $order->payment_status === Order::PAYMENT_STATUS_SUCCESS) {
$machine->logs()
->where('type', 'card_terminal')
->where('is_resolved', false)
->update(['is_resolved' => true]);
return;
}
// 刷卡失敗 → 解析 App 端格式 "[電文] | code=XXXX" 取回應碼
if (!preg_match('/\|\s*code=([A-Za-z0-9_]+)/', (string) $order->payment_response, $m)) {
return; // 無刷卡機回應碼(例如顧客未感應就放棄),不視為硬體訊號問題
}
$code = $m[1];
if ($code === '0000') {
return; // 0000 為成功碼,理論上不會走到失敗分支,保險過濾
}
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
'Card payment failed',
'warning',
['card_code' => $code, 'order_no' => $order->order_no],
'card_terminal'
);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class ApkVersion extends Model
{
protected $fillable = [
'version_name',
'version_code',
'flavor',
'file_path',
'download_url',
'release_notes',
'is_forced',
];
protected $casts = [
'is_forced' => 'boolean',
];
/**
* 取得最終下載 URL
*/
public function getUrlAttribute(): string
{
return $this->download_url ?: Storage::disk('public')->url($this->file_path);
}
}

View File

@ -10,15 +10,14 @@ use App\Traits\TenantScoped;
class Machine extends Model
{
use HasFactory, TenantScoped;
use \Illuminate\Database\Eloquent\SoftDeletes;
protected static function booted()
{
// 權限隔離:一般帳號登入時只能看到自己被分配的機台
static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) {
$user = auth()->user();
// 如果是在 Console、或是系統管理員,則不限制 (可看所有機台)
if (app()->runningInConsole() || !$user || $user->isSystemAdmin()) {
// 如果是在 Console(且非單元測試)、或是系統管理員,則不限制 (可看所有機台)
if ((app()->runningInConsole() && !app()->runningUnitTests()) || !$user || $user->isSystemAdmin()) {
return;
}
@ -44,6 +43,25 @@ class Machine extends Model
}
}
}
if ($machine->wasChanged([
'is_spring_slot_1_10',
'is_spring_slot_11_20',
'is_spring_slot_21_30',
'is_spring_slot_31_40',
'is_spring_slot_41_50',
'is_spring_slot_51_60',
])) {
app(\App\Services\Machine\MachineService::class)->syncMachineSlotMaxStock($machine);
}
// 機台轉移公司:不同公司商品 ID 體系不同,舊的機台專屬定價必須清空,
// 避免跨租戶價格錯位與資料污染。批次刪除不觸發 model 事件,故補推一次
// B012 同步,讓機台重抓新公司目錄與(已清空的)定價。
if ($machine->wasChanged('company_id')) {
$machine->productPrices()->delete();
\App\Jobs\Product\SendProductSyncCommandJob::dispatch($machine->id, __('Machine company changed'));
}
});
}
@ -60,6 +78,9 @@ class Machine extends Model
'current_page',
'door_status',
'temperature',
'ambient_temperature',
'ambient_temp_setting',
'ambient_temp_monitoring_enabled',
'firmware_version',
'api_token',
'last_heartbeat_at',
@ -85,12 +106,19 @@ class Machine extends Model
'images',
'creator_id',
'updater_id',
'tax_invoice_enabled',
'card_terminal_enabled',
'scan_pay_esun_enabled',
'scan_pay_linepay_enabled',
'shopping_cart_enabled',
'cash_module_enabled',
'settings',
];
protected $appends = ['image_urls', 'calculated_status'];
protected $appends = ['image_urls', 'calculated_status', 'hardware_status', 'latest_status_log_time', 'latest_hardware_log_time', 'is_unstable', 'recent_disconnection_count', 'has_login_warning'];
/**
* 動態計算機台當前狀態
* 動態計算機台當前狀態 (僅限連線與系統層級)
* 優先讀取資料庫 status 欄位
* 若在線,則進一步檢查過去 15 分鐘是否為異常
*/
@ -101,17 +129,150 @@ class Machine extends Model
return 'offline';
}
// 判定異常 (檢查過去 15 分鐘內是否有 error 日誌)
// 判定異常 (僅檢查 type = 'status' 的未解決日誌)
$hasRecentErrors = $this->logs()
->where('type', 'status')
->where('level', 'error')
->where('created_at', '>=', now()->subMinutes(15))
->where('is_resolved', false)
->exists();
if ($hasRecentErrors) {
return 'error';
}
return 'online';
// 判定警告 (僅檢查 type = 'status' 的未解決日誌)
// 優化:當機台在線時,自動忽略「斷線 (LWT)」警告,因為狀態已恢復
$hasRecentWarnings = $this->logs()
->where('type', 'status')
->where('level', 'warning')
->where('is_resolved', false)
->where('message', '!=', 'Connection lost (LWT)')
->exists();
if ($hasRecentWarnings) {
return 'warning';
}
return $this->status ?? 'online';
}
/**
* 判定機台目前是否有未解決的登入失敗警告 (需達 3 次未解決警告才亮起)
*/
public function getHasLoginWarningAttribute(): bool
{
return $this->logs()
->where('type', 'login')
->where('level', 'warning')
->where('is_resolved', false)
->count() >= 3;
}
/**
* 下位機/硬體健康狀態 (基於 type = 'submachine' 日誌)
*/
public function getHardwareStatusAttribute(): string
{
// 判定異常
$hasErrors = $this->logs()
->where('type', 'submachine')
->where('level', 'error')
->where('is_resolved', false)
->exists();
if ($hasErrors) {
return 'error';
}
// 判定警告
$hasWarnings = $this->logs()
->where('type', 'submachine')
->where('level', 'warning')
->where('is_resolved', false)
->exists();
if ($hasWarnings) {
return 'warning';
}
return 'normal';
}
/**
* 取得最新一筆未解決的系統狀態日誌時間
*/
public function getLatestStatusLogTimeAttribute()
{
$query = $this->logs()
->where('type', 'status')
->whereIn('level', ['error', 'warning'])
->where('is_resolved', false);
// 優化:若在線,不顯示已恢復的斷線時間
if ($this->status === 'online') {
$query->where('message', '!=', 'Connection lost (LWT)');
}
return $query->latest()->first()?->created_at;
}
/**
* 取得最新一筆未解決的下位機硬體日誌時間
*/
public function getLatestHardwareLogTimeAttribute()
{
return $this->logs()
->where('type', 'submachine')
->whereIn('level', ['error', 'warning'])
->where('is_resolved', false)
->latest()
->first()?->created_at;
}
/**
* 刷卡機健康狀態 (基於 type = 'card_terminal' 未解決日誌)
* 信用卡/電子票證/手機支付共用同一台實體刷卡機,刷卡失敗會寫此類日誌,
* 下一次刷卡成功時由 finalize 自動消警恢復正常。
* 列表頁優先使用 MachineController 預載的彙總欄位 (避免 N+1),其餘情境退回即時查詢。
*/
public function getCardTerminalStatusAttribute(): string
{
if (array_key_exists('card_terminal_error_count', $this->attributes)
|| array_key_exists('card_terminal_warning_count', $this->attributes)) {
if (($this->attributes['card_terminal_error_count'] ?? 0) > 0) {
return 'error';
}
if (($this->attributes['card_terminal_warning_count'] ?? 0) > 0) {
return 'warning';
}
return 'normal';
}
if ($this->logs()->where('type', 'card_terminal')->where('level', 'error')->where('is_resolved', false)->exists()) {
return 'error';
}
if ($this->logs()->where('type', 'card_terminal')->where('level', 'warning')->where('is_resolved', false)->exists()) {
return 'warning';
}
return 'normal';
}
/**
* 取得最新一筆未解決的刷卡機日誌時間 (列表頁優先用預載 subquery 欄位,避免 N+1)
*/
public function getLatestCardTerminalLogTimeAttribute()
{
if (array_key_exists('latest_card_terminal_log_at', $this->attributes)) {
$value = $this->attributes['latest_card_terminal_log_at'];
return $value ? \Illuminate\Support\Carbon::parse($value) : null;
}
return $this->logs()
->where('type', 'card_terminal')
->whereIn('level', ['error', 'warning'])
->where('is_resolved', false)
->latest()
->first()?->created_at;
}
/**
@ -130,31 +291,67 @@ class Machine extends Model
return $query->where('status', 'offline');
}
/**
* Scope: 判定異常 (過去 15 分鐘內有錯誤或警告日誌)
*/
public function scopeHasError($query)
{
return $query->whereExists(function ($q) {
$q->select(\Illuminate\Support\Facades\DB::raw(1))
->from('machine_logs')
->whereColumn('machine_logs.machine_id', 'machines.id')
->whereIn('level', ['error', 'warning'])
->where('created_at', '>=', now()->subMinutes(15));
return $query->whereHas('logs', function ($q) {
$q->where('is_resolved', false)
->where('level', 'error');
});
}
/**
* Scope: 判定運行中 (在線且無近期異常)
* Scope: 判定是否有任何待處理的異常 (排除掉單純的連線中斷)
* 只有當登入失敗次數達 3 次以上時,才列入待處理告警。
*/
public function scopeHasAnyAlert($query)
{
return $query->where(function($outer) {
// 1. 有任何非登入類型的待處理異常
$outer->whereHas('logs', function ($q) {
$q->where(function($sub) {
$sub->where('is_resolved', false)
->orWhereNull('is_resolved');
})
->whereIn('level', ['error', 'warning'])
->where('type', '!=', 'login')
->where('message', '!=', 'Connection lost (LWT)');
})
// 2. 或者有至少 3 筆未解決的登入異常
->orWhereHas('logs', function ($q) {
$q->where('is_resolved', false)
->where('type', 'login')
->where('level', 'warning');
}, '>=', 3);
});
}
public function scopeHasStatusError($query)
{
return $query->whereHas('logs', function ($q) {
$q->where('is_resolved', false)
->where('type', 'status')
->where('level', 'error');
});
}
public function scopeHasHardwareError($query)
{
return $query->whereHas('logs', function ($q) {
$q->where('is_resolved', false)
->where('type', 'submachine')
->where('level', 'error');
});
}
/**
* Scope: 判定運行中 (在線且無近期系統異常)
*/
public function scopeRunning($query)
{
return $query->online()->whereNotExists(function ($q) {
$q->select(\Illuminate\Support\Facades\DB::raw(1))
->from('machine_logs')
->whereColumn('machine_logs.machine_id', 'machines.id')
->whereIn('level', ['error', 'warning'])
->where('created_at', '>=', now()->subMinutes(15));
return $query->online()->whereDoesntHave('logs', function ($q) {
$q->where('is_resolved', false)
->where('type', 'status')
->whereIn('level', ['error', 'warning']);
});
}
@ -169,6 +366,9 @@ class Machine extends Model
protected $casts = [
'last_heartbeat_at' => 'datetime',
'temperature' => 'integer',
'ambient_temperature' => 'integer',
'ambient_temp_setting' => 'integer',
'ambient_temp_monitoring_enabled' => 'boolean',
'welcome_gift_enabled' => 'boolean',
'is_spring_slot_1_10' => 'boolean',
'is_spring_slot_11_20' => 'boolean',
@ -177,7 +377,14 @@ class Machine extends Model
'is_spring_slot_41_50' => 'boolean',
'is_spring_slot_51_60' => 'boolean',
'member_system_enabled' => 'boolean',
'tax_invoice_enabled' => 'boolean',
'card_terminal_enabled' => 'boolean',
'scan_pay_esun_enabled' => 'boolean',
'scan_pay_linepay_enabled' => 'boolean',
'shopping_cart_enabled' => 'boolean',
'cash_module_enabled' => 'boolean',
'images' => 'array',
'settings' => 'array',
];
/**
@ -202,6 +409,11 @@ class Machine extends Model
return $this->hasMany(MachineSlot::class);
}
public function productPrices()
{
return $this->hasMany(MachineProductPrice::class);
}
public function commands()
{
return $this->hasMany(RemoteCommand::class);
@ -228,7 +440,7 @@ class Machine extends Model
}
public const PAGE_STATUSES = [
'0' => 'Offline',
'0' => 'Standby',
'1' => 'Home Page',
'2' => 'Vending Page',
'3' => 'Admin Page',
@ -258,8 +470,174 @@ class Machine extends Model
return __($label);
}
public function getIsUnstableAttribute(): bool
{
// 抖動偵測1 小時內斷線次數 >= 3
return $this->recent_disconnection_count >= 3;
}
public function getRecentDisconnectionCountAttribute(): int
{
return $this->logs()
->where('type', 'status')
->where('message', 'Connection lost (LWT)')
->where('created_at', '>=', now()->subHour())
->count();
}
// === settings JSON 重構 Accessors & Mutators ===
public function getShoppingModeAttribute(): string
{
return $this->settings['shopping_mode'] ?? 'basic';
}
public function setShoppingModeAttribute($value)
{
$settings = $this->settings ?? [];
$settings['shopping_mode'] = (string) $value;
$this->settings = $settings;
}
public function getTaxInvoiceEnabledAttribute(): bool
{
return (bool) ($this->settings['tax_invoice_enabled'] ?? ($this->attributes['tax_invoice_enabled'] ?? false));
}
public function setTaxInvoiceEnabledAttribute($value)
{
$settings = $this->settings ?? [];
$settings['tax_invoice_enabled'] = (bool) $value;
$this->settings = $settings;
$this->attributes['tax_invoice_enabled'] = (bool) $value;
}
public function getCardTerminalEnabledAttribute(): bool
{
return (bool) ($this->settings['card_terminal_enabled'] ?? ($this->attributes['card_terminal_enabled'] ?? false));
}
public function setCardTerminalEnabledAttribute($value)
{
$settings = $this->settings ?? [];
$settings['card_terminal_enabled'] = (bool) $value;
$this->settings = $settings;
$this->attributes['card_terminal_enabled'] = (bool) $value;
}
/**
* 是否需要在後台呈現刷卡機狀態燈號與日誌分頁。
* 僅「基礎版(shopping_mode=basic)」且「刷卡機支付已啟用」的機台才有意義;
* 購物車模式或未啟用刷卡機的機台不顯示。
*/
public function getShowCardTerminalAttribute(): bool
{
return $this->shopping_mode === 'basic' && $this->card_terminal_enabled;
}
public function getScanPayEsunEnabledAttribute(): bool
{
return (bool) ($this->settings['scan_pay_esun_enabled'] ?? ($this->attributes['scan_pay_esun_enabled'] ?? false));
}
public function setScanPayEsunEnabledAttribute($value)
{
$settings = $this->settings ?? [];
$settings['scan_pay_esun_enabled'] = (bool) $value;
$this->settings = $settings;
$this->attributes['scan_pay_esun_enabled'] = (bool) $value;
}
public function getScanPayLinepayEnabledAttribute(): bool
{
return (bool) ($this->settings['scan_pay_linepay_enabled'] ?? ($this->attributes['scan_pay_linepay_enabled'] ?? false));
}
public function setScanPayLinepayEnabledAttribute($value)
{
$settings = $this->settings ?? [];
$settings['scan_pay_linepay_enabled'] = (bool) $value;
$this->settings = $settings;
$this->attributes['scan_pay_linepay_enabled'] = (bool) $value;
}
public function getShoppingCartEnabledAttribute(): bool
{
return (bool) ($this->settings['shopping_cart_enabled'] ?? ($this->attributes['shopping_cart_enabled'] ?? false));
}
public function setShoppingCartEnabledAttribute($value)
{
$settings = $this->settings ?? [];
$settings['shopping_cart_enabled'] = (bool) $value;
$this->settings = $settings;
$this->attributes['shopping_cart_enabled'] = (bool) $value;
}
public function getWelcomeGiftEnabledAttribute(): bool
{
return (bool) ($this->settings['welcome_gift_enabled'] ?? ($this->attributes['welcome_gift_enabled'] ?? false));
}
public function setWelcomeGiftEnabledAttribute($value)
{
$settings = $this->settings ?? [];
$settings['welcome_gift_enabled'] = (bool) $value;
$this->settings = $settings;
$this->attributes['welcome_gift_enabled'] = (bool) $value;
}
public function getCashModuleEnabledAttribute(): bool
{
return (bool) ($this->settings['cash_module_enabled'] ?? ($this->attributes['cash_module_enabled'] ?? false));
}
public function setCashModuleEnabledAttribute($value)
{
$settings = $this->settings ?? [];
$settings['cash_module_enabled'] = (bool) $value;
$this->settings = $settings;
$this->attributes['cash_module_enabled'] = (bool) $value;
}
public function getMemberSystemEnabledAttribute(): bool
{
return (bool) ($this->settings['member_system_enabled'] ?? ($this->attributes['member_system_enabled'] ?? false));
}
public function setMemberSystemEnabledAttribute($value)
{
$settings = $this->settings ?? [];
$settings['member_system_enabled'] = (bool) $value;
$this->settings = $settings;
$this->attributes['member_system_enabled'] = (bool) $value;
}
public function getAmbientTempMonitoringEnabledAttribute(): bool
{
return (bool) ($this->settings['ambient_temp_monitoring_enabled'] ?? ($this->attributes['ambient_temp_monitoring_enabled'] ?? false));
}
public function setAmbientTempMonitoringEnabledAttribute($value)
{
$settings = $this->settings ?? [];
$settings['ambient_temp_monitoring_enabled'] = (bool) $value;
$this->settings = $settings;
$this->attributes['ambient_temp_monitoring_enabled'] = (bool) $value;
}
public function users()
{
return $this->belongsToMany(\App\Models\System\User::class);
}
public function orders()
{
return $this->hasMany(\App\Models\Transaction\Order::class);
}
public function stockMovements()
{
return $this->hasMany(MachineStockMovement::class);
}
}

View File

@ -11,6 +11,68 @@ class MachineLog extends Model
const UPDATED_AT = null;
protected static function booted()
{
static::created(function (MachineLog $log) {
// 1. 取得機台與公司,若無公司則直接 return
$machine = $log->machine;
if (!$machine || !$machine->company) {
return;
}
$company = $machine->company;
$companySettings = $company->settings ?? [];
// 必須啟用 Discord 通知功能
if (!($companySettings['discord_notify_enabled'] ?? false)) {
return;
}
// 優先讀取機台專屬 Webhook無則繼承公司全域 Webhook
$machineSettings = $machine->settings ?? [];
$webhookUrl = $machineSettings['discord_webhook_url'] ?? $companySettings['discord_webhook_url'] ?? null;
if (empty($webhookUrl)) {
return;
}
// 2. 判定是否符合發送條件
$shouldNotify = false;
if ($log->type === 'temperature') {
$shouldNotify = true;
} elseif ($log->type === 'status') {
if ($log->message === 'Connection lost (LWT)') {
$shouldNotify = true;
} elseif ($log->message === 'Connection restored') {
// 尋找此機台前一筆狀態日誌 (ID 比當前小type 為 status倒序第一筆)
$previousStatus = self::withoutGlobalScopes()
->where('machine_id', $log->machine_id)
->where('type', 'status')
->where('id', '<', $log->id)
->orderBy('id', 'desc')
->first();
if ($previousStatus && $previousStatus->message === 'Connection lost (LWT)' && $previousStatus->is_resolved) {
// 只有當前一筆是真實 LWT 斷線,且已被消警 (is_resolved = true) 時,才發送恢復通知
$shouldNotify = true;
}
}
} elseif ($log->type === 'submachine') {
// 下位機日誌僅在 warning 或 error 級別時發送 Discord 告警info 狀態日誌不發送
if (in_array($log->level, ['warning', 'error'])) {
$shouldNotify = true;
}
} elseif ($log->level === 'error') {
$shouldNotify = true;
}
// 3. 若符合發送條件,調度非同步任務
if ($shouldNotify) {
\App\Jobs\Notification\SendDiscordNotificationJob::dispatch($log->id);
}
});
}
protected $fillable = [
'company_id',
'machine_id',
@ -18,10 +80,12 @@ class MachineLog extends Model
'type',
'message',
'context',
'is_resolved',
];
protected $casts = [
'context' => 'array',
'is_resolved' => 'boolean',
];
protected $appends = [
@ -35,11 +99,19 @@ class MachineLog extends Model
{
$context = $this->context;
// 若 context 中已有翻譯標籤 (B013 封裝),則進行動態重組
// 刷卡機回應碼:用目前代碼表重組失敗原因,支援多語系顯示。
if (isset($context['card_code'])) {
$code = $context['card_code'];
$reason = \App\Services\Machine\MachineService::CARD_TERMINAL_CODE_MAP[$code] ?? 'Transaction failed';
return __('Card payment failed') . '' . __($reason) . " (Code: {$code})";
}
// B013 封裝:優先用目前代碼表重組,讓舊日誌也能吃到最新文案。
if (isset($context['translated_label'])) {
$label = __($context['translated_label']);
$code = $context['raw_code'] ?? $context['error_code'] ?? '0000';
$mapping = \App\Services\Machine\MachineService::ERROR_CODE_MAP[$code] ?? null;
$label = __($mapping['label'] ?? $context['translated_label']);
$tid = $context['tid'] ?? null;
$code = $context['raw_code'] ?? '0000';
if ($tid) {
return __('Slot') . " {$tid}: {$label} (Code: {$code})";

View File

@ -13,6 +13,11 @@ class MachineModel extends Model
'company_id',
'creator_id',
'updater_id',
'settings',
];
protected $casts = [
'settings' => 'array',
];
public function machines()

View File

@ -0,0 +1,58 @@
<?php
namespace App\Models\Machine;
use App\Jobs\Product\SendProductSyncCommandJob;
use App\Models\Product\Product;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* 機台專屬定價:特定機台 × 特定商品的價格覆蓋。
*
* - price / member_price NULL 代表沿用全域 products 價。
* - 任何新增/修改/刪除都會推送 B012 同步指令給「該台」機台,使其即時重抓目錄。
* 目錄覆蓋於 B012 請求當下即時套用(見 ProductCatalogService::getMachinePayload
* 不進公司目錄快取,故改價不需失效公司快取。
*/
class MachineProductPrice extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'product_id',
'price',
'member_price',
];
protected $casts = [
'price' => 'decimal:2',
'member_price' => 'decimal:2',
];
protected static function booted(): void
{
static::saved(fn (self $row) => $row->dispatchSync());
static::deleted(fn (self $row) => $row->dispatchSync());
}
/**
* 推送 B012 同步指令給該機台,讓它即時重抓最新定價。
* 控制台/離線判定沿用 SendProductSyncCommandJob 既有邏輯。
*/
protected function dispatchSync(): void
{
SendProductSyncCommandJob::dispatch($this->machine_id, __('Machine pricing updated'));
}
public function machine()
{
return $this->belongsTo(Machine::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@ -20,12 +20,19 @@ class MachineSlot extends Model
'expiry_date',
'batch_no',
'is_active',
'is_locked',
'last_app_lock_rev',
'last_app_expiry_rev',
];
protected $casts = [
'price' => 'decimal:2',
'last_restocked_at' => 'datetime',
'expiry_date' => 'date:Y-m-d',
'is_active' => 'boolean',
'is_locked' => 'boolean',
'last_app_lock_rev' => 'integer',
'last_app_expiry_rev' => 'integer',
];
public function machine()

View File

@ -0,0 +1,119 @@
<?php
namespace App\Models\Machine;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use App\Models\Product\Product;
use App\Models\System\User;
class MachineStockMovement extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'product_id',
'slot_no',
'type',
'quantity',
'before_qty',
'after_qty',
'reference_type',
'reference_id',
'note',
'context',
'created_by',
];
protected $appends = [
'translated_note',
'translated_type',
];
public function getTranslatedNoteAttribute(): ?string
{
if (empty($this->note)) {
return null;
}
return __($this->note, $this->context ?? []);
}
/**
* 動態翻譯類型
*/
public function getTranslatedTypeAttribute(): string
{
return __("movement.type.{$this->type}");
}
protected $casts = [
'quantity' => 'integer',
'before_qty' => 'integer',
'after_qty' => 'integer',
'context' => 'array',
];
// ─── 異動類型常數 ───
const TYPE_REPLENISHMENT = 'replenishment'; // 補貨單完成
const TYPE_PICKUP = 'pickup'; // 取貨碼核銷
const TYPE_REMOTE_DISPENSE = 'remote_dispense'; // 遠端出貨 (B055 樂觀扣除)
const TYPE_SALE = 'sale'; // 一般訂單銷售
const TYPE_ADJUSTMENT = 'adjustment'; // B009 回報 / 後台手動修改
const TYPE_ROLLBACK = 'rollback'; // B055 出貨失敗回退
const TYPE_DECOMMISSION = 'decommission'; // B009 全量同步時移除不在清單的貨道
// ─── 關聯 ───
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class)->withTrashed();
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 多型關聯:補貨單、取貨碼操作記錄、遠端指令等
*/
public function reference(): MorphTo
{
return $this->morphTo();
}
// ─── 輔助方法 ───
/**
* 是否為增加庫存的類型
*/
public function isInbound(): bool
{
return in_array($this->type, [
self::TYPE_REPLENISHMENT,
self::TYPE_ROLLBACK,
]);
}
/**
* 是否為減少庫存的類型
*/
public function isOutbound(): bool
{
return in_array($this->type, [
self::TYPE_PICKUP,
self::TYPE_REMOTE_DISPENSE,
self::TYPE_SALE,
]);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OtaSchedule extends Model
{
use HasFactory;
protected $fillable = [
'apk_version_id',
'scheduled_at',
'target_type',
'target_value',
'status',
'user_id',
];
protected $casts = [
'scheduled_at' => 'datetime',
'target_value' => 'array',
];
public function apkVersion()
{
return $this->belongsTo(ApkVersion::class);
}
public function user()
{
return $this->belongsTo(\App\Models\System\User::class);
}
}

View File

@ -16,6 +16,8 @@ class RemoteCommand extends Model
'payload',
'status',
'ttl',
'note',
'remark',
'executed_at',
];

View File

@ -1,28 +0,0 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TimerStatus extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'slot_no',
'status',
'remaining_seconds',
'end_at',
];
protected $casts = [
'end_at' => 'datetime',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
}

View File

@ -5,11 +5,21 @@ namespace App\Models\Product;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Services\Machine\MachineService;
use App\Traits\TenantScoped;
class Product extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
protected static function booted()
{
static::updated(function (Product $product) {
if ($product->wasChanged(['track_limit', 'spring_limit'])) {
app(MachineService::class)->syncProductSlotMaxStock($product);
}
});
}
/**
* Scope a query to only include active products.
@ -26,6 +36,7 @@ class Product extends Model
'name_dictionary_key',
'barcode',
'spec',
'spec_dictionary_key',
'manufacturer',
'description',
'price',
@ -58,7 +69,7 @@ class Product extends Model
/**
* 自動附加到 JSON/陣列輸出的屬性(供 Alpine.js 等前端使用)
*/
protected $appends = ['localized_name'];
protected $appends = ['localized_name', 'localized_spec'];
/**
* 取得當前語系的商品名稱。
@ -82,6 +93,26 @@ class Product extends Model
return $this->name ?? '';
}
/**
* 取得當前語系的商品規格。
* 回退順序:當前語系 zh_TW spec 欄位(與名稱同邏輯)
*/
public function getLocalizedSpecAttribute(): string
{
if ($this->relationLoaded('specTranslations') && $this->specTranslations->isNotEmpty()) {
$locale = app()->getLocale();
$translation = $this->specTranslations->firstWhere('locale', $locale);
if ($translation) {
return $translation->value;
}
$fallback = $this->specTranslations->firstWhere('locale', 'zh_TW');
if ($fallback) {
return $fallback->value;
}
}
return $this->spec ?? '';
}
/**
* Get the translations for the product name.
*/
@ -91,6 +122,15 @@ class Product extends Model
->where('group', 'product');
}
/**
* Get the translations for the product spec.
*/
public function specTranslations()
{
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'spec_dictionary_key')
->where('group', 'product_spec');
}
/**
* 倉庫庫存紀錄
*/

45
app/Models/StaffCard.php Normal file
View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use App\Traits\TenantScoped;
use App\Models\Machine\Machine;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StaffCard extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'employee_id',
'name',
'card_uid',
'status',
'notes',
'last_used_at',
'last_machine_id',
];
protected $casts = [
'last_used_at' => 'datetime',
];
/**
* 取得此卡片的刷卡紀錄
*/
public function logs(): HasMany
{
return $this->hasMany(StaffCardLog::class);
}
/**
* 取得最後一次使用的機台
*/
public function lastMachine(): BelongsTo
{
return $this->belongsTo(Machine::class, 'last_machine_id');
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Models;
use App\Traits\TenantScoped;
use App\Models\Machine\Machine;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StaffCardLog extends Model
{
use TenantScoped;
public $timestamps = false;
protected $fillable = [
'company_id',
'staff_card_id',
'machine_id',
'payment_type',
'code_id',
'order_id',
'action',
'remark',
'raw_data',
];
protected $casts = [
'raw_data' => 'array',
'created_at' => 'datetime',
];
/**
* 註冊 Model 事件以自動更新 StaffCard 的最後使用狀態
*/
protected static function booted()
{
static::created(function ($log) {
if ($log->staff_card_id) {
// 使用 withoutGlobalScopes 確保不論在任何 Session Context 下皆能正確寫入
$staffCard = StaffCard::withoutGlobalScopes()->find($log->staff_card_id);
if ($staffCard) {
$staffCard->update([
'last_used_at' => $log->created_at ?? now(),
'last_machine_id' => $log->machine_id,
]);
}
}
});
}
/**
* 取得關聯的員工卡片
*/
public function staffCard(): BelongsTo
{
return $this->belongsTo(StaffCard::class);
}
/**
* 取得刷卡的機台
*/
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
/**
* 取得關聯的訂單
*/
public function order(): BelongsTo
{
return $this->belongsTo(\App\Models\Transaction\Order::class);
}
}

View File

@ -68,6 +68,14 @@ class Company extends Model
return $this->hasMany(Machine::class);
}
/**
* Get the products for the company.
*/
public function products(): HasMany
{
return $this->hasMany(\App\Models\Product\Product::class);
}
/**
* Scope僅篩選啟用的公司
*/
@ -75,4 +83,101 @@ class Company extends Model
{
return $query->where('status', 1);
}
/**
* 獲取公司自訂 Logo 的完整 URL (Accessor)
*/
public function getLogoUrlAttribute(): ?string
{
$settings = $this->settings ?? [];
$logoPath = $settings['logo_path'] ?? null;
// 只有在啟用品牌自訂功能且上傳了 Logo 時才使用自訂 Logo
if ($this->hasCustomBrandingEnabled() && $logoPath) {
return \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath);
}
return asset('starcloud_icon.png');
}
/**
* 檢查此公司是否啟用且配置了品牌自訂功能
*/
public function hasCustomBrandingEnabled(): bool
{
$settings = $this->settings ?? [];
$enableCustomBranding = filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN);
$logoPath = $settings['logo_path'] ?? null;
return $enableCustomBranding && !empty($logoPath);
}
/**
* 取得指定公司「所有機台已開語系的聯集」(商品翻譯實際要維護的語系)
*
* - 來源:各機台 settings['languages'] 去重後聯集。
* - 一律保證含 fallback 語系 (zh_TW),且依系統白名單定義順序排列(穩定、不隨機台增減跳動)。
* - 僅回傳仍在白名單內的語系(機台殘留的失效語系自動濾除)。
* - 公司沒有任何機台、或機台皆未設語系時:退化為僅 [fallback]
*
* 結果快取於 'company_active_locales:{companyId}',於機台語系異動時失效
* ( forgetActiveLocales())
*
* @param int|null $companyId null 代表系統層級(無租戶機台),直接回 [fallback]
* @return array<int,string>
*/
public static function activeLocalesFor(?int $companyId): array
{
$fallback = config('locales.fallback', 'zh_TW');
$whitelist = array_keys(config('locales.supported', [$fallback => $fallback]));
if ($companyId === null) {
return [$fallback];
}
return \Illuminate\Support\Facades\Cache::rememberForever(
"company_active_locales:{$companyId}",
function () use ($companyId, $fallback, $whitelist) {
// 繞過全域範圍:本計算需涵蓋公司全部機台,與登入者可見範圍無關。
$langSets = Machine::withoutGlobalScopes()
->where('company_id', $companyId)
->pluck('settings');
$union = [];
foreach ($langSets as $settings) {
foreach ((array) ($settings['languages'] ?? []) as $locale) {
$union[$locale] = true;
}
}
// 保證含 fallback並只保留白名單內語系、依白名單順序輸出。
$union[$fallback] = true;
return array_values(array_filter(
$whitelist,
fn ($locale) => isset($union[$locale])
));
}
);
}
/**
* 失效指定公司的語系聯集快取(機台 settings.languages 異動後呼叫)。
*/
public static function forgetActiveLocales(?int $companyId): void
{
if ($companyId !== null) {
\Illuminate\Support\Facades\Cache::forget("company_active_locales:{$companyId}");
}
}
/**
* 實例代理:當前公司的語系聯集。
*
* @return array<int,string>
*/
public function activeLocales(): array
{
return static::activeLocalesFor($this->id);
}
}

View File

@ -10,11 +10,14 @@ class Role extends SpatieRole
'name',
'guard_name',
'company_id',
'created_by',
'is_system',
'is_company_admin',
];
protected $casts = [
'is_system' => 'boolean',
'is_company_admin' => 'boolean',
];
/**
@ -25,6 +28,14 @@ class Role extends SpatieRole
return $this->belongsTo(Company::class);
}
/**
* 建立者帳號。
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Scope a query to only include roles for a specific company or system roles.
*/
@ -35,4 +46,43 @@ class Role extends SpatieRole
->orWhereNull('company_id');
});
}
/**
* 查詢範圍:限制為指定使用者「可見」的角色(鏡像 User::scopeVisibleTo
* - 系統管理員:全部
* - 主帳號:同公司全部
* - 子帳號:僅自己建立的角色
*/
public function scopeVisibleTo($query, User $viewer)
{
if ($viewer->isSystemAdmin()) {
return $query;
}
$query->where($this->getTable() . '.company_id', $viewer->company_id);
if (!$viewer->is_admin) {
$query->where($this->getTable() . '.created_by', $viewer->id);
}
return $query;
}
/**
* 操作者是否有權管理(編輯/刪除)此角色。
* - 系統管理員:全部
* - 跨公司:禁止
* - 主帳號:同公司全部
* - 子帳號:僅自己建立的角色
*/
public function canBeManagedBy(User $user): bool
{
if ($user->isSystemAdmin()) {
return true;
}
if ($this->company_id !== $user->company_id) {
return false;
}
if ($user->is_admin) {
return true;
}
return $this->created_by === $user->id;
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Models\System;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\TenantScoped;
use App\Models\System\User;
use App\Models\System\Company;
class SystemOperationLog extends Model
{
use HasFactory, TenantScoped;
protected $appends = [
'translated_note',
];
/**
* 動態翻譯備註
*/
public function getTranslatedNoteAttribute(): ?string
{
if (empty($this->note)) {
return null;
}
// 過濾掉非字串/數值的數值,避免 Laravel 翻譯引擎處理巢狀陣列時報錯
$replacements = collect(array_merge(
$this->old_values ?? [],
$this->new_values ?? []
))->filter(fn($val) => is_scalar($val))->toArray();
return __($this->note, $replacements);
}
protected $fillable = [
'company_id',
'user_id',
'module',
'action',
'target_id',
'target_type',
'old_values',
'new_values',
'note',
'ip_address',
'user_agent',
];
protected $casts = [
'old_values' => 'array',
'new_values' => 'array',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function company()
{
return $this->belongsTo(Company::class);
}
/**
* Polymorphic relationship to the target entity
*/
public function target()
{
return $this->morphTo()->withTrashed();
}
/**
* Get the name of the target entity from relation or values
*/
public function getTargetNameAttribute(): string
{
// 1. Try from loaded relationship
if ($this->target) {
if (method_exists($this->target, 'getLocalizedNameAttribute')) {
return $this->target->localized_name;
}
return $this->target->name ?? '';
}
// 2. Try from values
$name = $this->new_values['name'] ?? $this->old_values['name'] ??
$this->new_values['code'] ?? $this->old_values['code'] ?? null;
if ($name) {
return $name;
}
return '';
}
}

View File

@ -23,6 +23,8 @@ class User extends Authenticatable
*/
protected $fillable = [
'company_id',
'parent_id',
'level',
'username',
'name',
'email',
@ -53,8 +55,14 @@ class User extends Authenticatable
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
'level' => 'integer',
];
/**
* 帳號層級上限:子帳號最多兩層(主帳號 level 0 level 1 level 2level 2 不可再建)。
*/
public const MAX_LEVEL = 2;
/**
* Get the login logs for the user.
*/
@ -95,6 +103,85 @@ class User extends Authenticatable
return !is_null($this->company_id);
}
/**
* 上層(建立者)帳號。
*/
public function parent()
{
return $this->belongsTo(self::class, 'parent_id');
}
/**
* 直接建立的下層帳號。
*/
public function children()
{
return $this->hasMany(self::class, 'parent_id');
}
/**
* 是否為該公司主帳號(租戶層級且 is_admin
*/
public function isMainAccount(): bool
{
return $this->isTenant() && $this->is_admin;
}
/**
* 是否為子帳號(租戶層級且非主帳號)。
*/
public function isSubAccount(): bool
{
return $this->isTenant() && !$this->is_admin;
}
/**
* 是否還能再建立下層子帳號(深度上限約束,系統管理員不受限)。
*/
public function canCreateSubAccount(): bool
{
return $this->isSystemAdmin() || $this->level < self::MAX_LEVEL;
}
/**
* 查詢範圍:限制為指定使用者「可見」的帳號。
* - 系統管理員:全部
* - 主帳號:同公司全部(安全網)
* - 子帳號:僅自己直接建立的下層帳號
*/
public function scopeVisibleTo($query, self $viewer)
{
if ($viewer->isSystemAdmin()) {
return $query;
}
$query->where($this->getTable() . '.company_id', $viewer->company_id);
if (!$viewer->is_admin) {
$query->where($this->getTable() . '.parent_id', $viewer->id);
}
return $query;
}
/**
* 操作者是否有權管理(編輯/刪除/停用)指定帳號。
* - 系統管理員:全部
* - 跨公司:禁止
* - 主帳號:同公司全部
* - 子帳號:僅自己直接建立的下層帳號
*/
public function canManageAccount(self $target): bool
{
if ($this->isSystemAdmin()) {
return true;
}
if ($target->company_id !== $this->company_id) {
return false;
}
if ($this->is_admin) {
return true;
}
return $target->parent_id === $this->id;
}
/**
* Get the URL for the user's avatar.
*/

View File

@ -45,6 +45,6 @@ class DispenseRecord extends Model
public function product()
{
return $this->belongsTo(Product::class);
return $this->belongsTo(Product::class)->withTrashed();
}
}

View File

@ -5,10 +5,15 @@ namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
use App\Traits\HasDisplayFlowId;
class Invoice extends Model
{
use HasFactory, SoftDeletes;
// TenantScoped自動以登入者 company_id 過濾(含 route-model binding 與列表查詢),
// 防止跨公司讀取/操作他人發票。console佇列 Job / reconcile 命令)會自動跳過 scope
// 系統身分仍可跨公司開立/對帳。
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
protected $fillable = [
'company_id',
@ -16,9 +21,17 @@ class Invoice extends Model
'machine_id',
'flow_id',
'invoice_no',
'status',
'relate_number',
'amount',
'carrier_id',
'invoice_date',
'machine_time',
'last_checked_at',
'retry_count',
'voided_at',
'void_reason',
'remark',
'random_number',
'love_code',
'rtn_code',
@ -27,13 +40,28 @@ class Invoice extends Model
];
protected $casts = [
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'amount' => 'decimal:2',
'invoice_date' => 'date',
'machine_time' => 'datetime',
'last_checked_at' => 'datetime',
'voided_at' => 'datetime',
'retry_count' => 'integer',
'metadata' => 'array',
];
// 發票狀態常數
public const STATUS_PENDING = 'pending'; // 已送出綠界、尚未確認
public const STATUS_ISSUED = 'issued'; // 已開立成功
public const STATUS_FAILED = 'failed'; // 綠界回失敗,待補開
public const STATUS_VOID = 'void'; // 已作廢
public function order()
{
return $this->belongsTo(Order::class);
}
public function machine()
{
return $this->belongsTo(\App\Models\Machine\Machine::class);
}
}

View File

@ -6,42 +6,260 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
use App\Traits\HasDisplayFlowId;
use App\Models\Machine\Machine;
use App\Models\Member\Member;
class Order extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
// 訂單類型 (order_type)
public const TYPE_SALE = 'sale'; // 一般銷售
public const TYPE_PHARMACY_PICKUP = 'pharmacy_pickup'; // 領藥單
public const TYPE_MANUAL = 'manual'; // 手動補單(後台人工補登,機台斷線漏報時使用)
// 支付狀態
public const PAYMENT_STATUS_FAILED = 0;
public const PAYMENT_STATUS_SUCCESS = 1;
// 出貨狀態
public const DELIVERY_STATUS_FAILED = 0;
public const DELIVERY_STATUS_SUCCESS = 1;
public const DELIVERY_STATUS_PARTIAL = 2;
// 交易生命週期狀態status 欄位,字串)。
// pending進入付款、等待結果completed付款成功出貨另看 delivery_status
// failed付款失敗/逾時abandoned感應前取消由排程把逾時 pending 標記)。
// ⚠ 終態 = [completed, failed, abandoned],一律冪等不可覆蓋(保護線上 main 機台行為不變)。
public const STATUS_PENDING = 'pending';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_ABANDONED = 'abandoned';
// 領藥單專用:已開立、等待病人到機台領取(非 pending不被逾時清掃非終態可被出貨 finalize 更新)。
public const STATUS_AWAITING_PICKUP = 'awaiting_pickup';
/** 終態清單:到了這些狀態就不再變更(冪等)。 */
public const TERMINAL_STATUSES = [
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_ABANDONED,
];
protected $fillable = [
'company_id',
'flow_id',
'order_no',
'order_type',
'pricing_slip_no',
'created_by',
'machine_id',
'member_id',
'payment_type',
'total_amount',
'discount_amount',
'pay_amount',
'payment_type',
'change_amount',
'cash_detail',
'points_used',
'original_amount',
'payment_status',
'payment_request',
'payment_response',
'payment_at',
'member_barcode',
'code_id',
'welcome_gift_id',
'invoice_info',
'machine_time',
'status',
'metadata',
'delivery_status',
'remark',
];
protected $casts = [
'total_amount' => 'decimal:2',
'discount_amount' => 'decimal:2',
'pay_amount' => 'decimal:2',
'change_amount' => 'decimal:2',
'cash_detail' => 'array',
'original_amount' => 'decimal:2',
'payment_at' => 'datetime',
'machine_time' => 'datetime',
'metadata' => 'array',
'payment_type' => 'integer',
'delivery_status' => 'integer',
];
/**
* 現金收款摘要字串:「收:{總收金額}(只列有收到的面額明細) 找:{找零}」。
* 120:1、10:2 30。無 cash_detail非現金或舊資料或全為 0 時回 null
* 總收金額由各面額張數加總得出,與括號內明細一致。
*/
public function getCashReceivedSummaryAttribute(): ?string
{
$cd = $this->cash_detail;
if (empty($cd)) {
return null;
}
// 面額顯示標籤 + 幣值(依大到小)
$denoms = [
'b1000' => ['label' => '仟', 'unit' => 1000],
'b500' => ['label' => '伍佰', 'unit' => 500],
'b100' => ['label' => '佰', 'unit' => 100],
'c50' => ['label' => '50', 'unit' => 50],
'c10' => ['label' => '10', 'unit' => 10],
'c5' => ['label' => '5', 'unit' => 5],
'c1' => ['label' => '1', 'unit' => 1],
];
$received = 0;
$parts = [];
foreach ($denoms as $key => $d) {
$n = (int) ($cd[$key] ?? 0);
if ($n > 0) {
$parts[] = $d['label'] . ':' . $n; // 只列有收到的幣別
$received += $n * $d['unit'];
}
}
if (empty($parts)) {
return null;
}
return '收:' . $received . '' . implode('、', $parts) . ') 找:' . number_format($this->change_amount, 0);
}
/**
* 取得所有支付類型的對照標籤
*/
public static function getPaymentTypeLabels(): array
{
return [
1 => __('Credit Card'),
2 => __('E-Ticket (EasyCard/iPass)'),
3 => __('QR Code Payment - E.SUN'), // 掃碼支付-玉山
4 => __('Bill Acceptor'),
5 => __('Pass Code'), // 使用者要求由「通關密碼」改為「通行碼」
6 => __('Pickup Code'),
7 => __('Welcome Gift'),
8 => __('Questionnaire'),
9 => __('Coin Acceptor'),
10 => __('Mobile Pay'), // NFC 手機感應(行動支付,信用卡軌道,依使用者按鈕分類)
11 => __('QR Code Payment - TayPay'), // 掃碼支付-TayPay
21 => __('Offline + 1'),
22 => __('Offline + 2'),
23 => __('Offline + 3'),
24 => __('Offline + 4'),
25 => __('Offline + 9'),
30 => __('LINE Pay'),
31 => __('JKO Pay'),
32 => __('Easy Wallet'),
33 => __('Pi Pay'),
34 => __('PlusPay'),
40 => __('Member Verify Pickup'),
41 => __('Staff Card'),
42 => __('Pickup Voucher'),
50 => __('Offline + LINE Pay'),
51 => __('Offline + JKO Pay'),
52 => __('Offline + Easy Wallet'),
53 => __('Offline + Pi Pay'),
54 => __('Offline + PlusPay'),
60 => __('Points/Voucher'),
61 => __('Member + 1'),
62 => __('Member + 2'),
63 => __('Member + 3'),
64 => __('Member + 4'),
65 => __('Member + 5'),
66 => __('Member + 6'),
67 => __('Member + 7'),
68 => __('Member + 8'),
69 => __('Member + 9'),
70 => __('LINE Pay (Official)'), // LINE Pay 官方直連(非 TapPay 底下的 30
90 => __('Member + LINE Pay'),
91 => __('Member + JKO Pay'),
92 => __('Member + Easy Wallet'),
93 => __('Member + Pi Pay'),
94 => __('Member + PlusPay'),
];
}
/**
* 取得目前的支付類型標籤 (Accessor)
*/
public function getPaymentTypeLabelAttribute(): string
{
return self::getPaymentTypeLabels()[$this->payment_type] ?? __('Unknown');
}
public function getMaskedPickupRecipientAttribute(): ?string
{
if (empty($this->member_barcode)) {
return null;
}
return self::maskPickupRecipientName($this->member_barcode);
}
public static function maskPickupRecipientName(string $value): string
{
$value = trim($value);
if ($value === '') {
return $value;
}
if (preg_match('/^([^\s(]+)(.*)$/us', $value, $matches) !== 1) {
return self::maskName($value);
}
return self::maskName($matches[1]) . ($matches[2] ?? '');
}
private static function maskName(string $name): string
{
$length = mb_strlen($name);
if ($length <= 1) {
return $name;
}
if ($length === 2) {
return mb_substr($name, 0, 1) . 'O';
}
return mb_substr($name, 0, 1)
. str_repeat('O', $length - 2)
. mb_substr($name, -1);
}
/** 一般銷售訂單 */
public function scopeSales($query)
{
return $query->where('order_type', self::TYPE_SALE);
}
/** 領藥單 */
public function scopePharmacyPickup($query)
{
return $query->where('order_type', self::TYPE_PHARMACY_PICKUP);
}
/** 是否為後台手動補單 */
public function getIsManualAttribute(): bool
{
return $this->order_type === self::TYPE_MANUAL;
}
public function machine()
{
return $this->belongsTo(Machine::class);
}
/** 建單者 (領藥單藥師) */
public function creator()
{
return $this->belongsTo(\App\Models\System\User::class, 'created_by');
}
public function member()
{
return $this->belongsTo(Member::class);
@ -61,4 +279,54 @@ class Order extends Model
{
return $this->hasMany(DispenseRecord::class);
}
/** 領藥單對應的領藥碼(一單一碼) */
public function pickupCode()
{
return $this->hasOne(PickupCode::class);
}
public function staffCardLog()
{
return $this->hasOne(\App\Models\StaffCardLog::class);
}
public function welcomeGift()
{
return $this->belongsTo(WelcomeGift::class);
}
/**
* 取得所有出貨狀態的對照標籤
*/
public static function getDeliveryStatusLabels(): array
{
return [
self::DELIVERY_STATUS_FAILED => __('Dispense Failed'),
self::DELIVERY_STATUS_SUCCESS => __('Dispense Success'),
self::DELIVERY_STATUS_PARTIAL => __('Partial Dispense Success'),
];
}
/**
* 出貨狀態是否有意義:唯有付款成功(completed/paid)、機台真的動作過的單才有出貨結果。
* pending(未完成)、failed(支付失敗)、abandoned(未支付)從未出貨,出貨狀態應顯示 '--'
* delivery_status 欄位 DB 預設為 1(出貨成功),這些單若直接讀值會誤顯示成「出貨成功」。
*/
public function hasDeliveryOutcome(): bool
{
return in_array($this->status, [self::STATUS_COMPLETED, 'paid'], true);
}
/**
* 取得目前出貨狀態標籤 (Accessor)
*/
public function getDeliveryStatusLabelAttribute(): string
{
if (! $this->hasDeliveryOutcome()) {
return '--';
}
return self::getDeliveryStatusLabels()[$this->delivery_status] ?? __('Unknown');
}
}

View File

@ -14,6 +14,7 @@ class OrderItem extends Model
'order_id',
'product_id',
'product_name',
'slot_no',
'barcode',
'price',
'quantity',
@ -34,6 +35,6 @@ class OrderItem extends Model
public function product()
{
return $this->belongsTo(Product::class);
return $this->belongsTo(Product::class)->withTrashed();
}
}

View File

@ -0,0 +1,109 @@
<?php
namespace App\Models\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PassCode extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'name',
'code',
'slug',
'batch_no',
'expires_at',
'status',
'usage_limit', // 可用次數上限null=無限次。勾「只能使用一次」時=1
'usage_count', // 已使用次數
'created_by',
];
protected $casts = [
'expires_at' => 'datetime',
];
/**
* 獲取公開通行連結
*/
public function getTicketUrlAttribute()
{
return $this->slug ? route('pass-code.ticket', $this->slug) : null;
}
/**
* Boot the model.
*/
protected static function booted()
{
static::creating(function ($passCode) {
if (!$passCode->slug) {
$passCode->slug = \Illuminate\Support\Str::random(16);
}
});
}
/**
* 關聯機台
*/
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
/**
* 關聯建立者
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 判斷是否可用
*/
public function isValid(): bool
{
if ($this->status !== 'active') {
return false;
}
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
// 有設使用次數上限(如「只能使用一次」usage_limit=1)且已用完 → 失效。null=無限次。
if (!is_null($this->usage_limit) && $this->usage_count >= $this->usage_limit) {
return false;
}
return true;
}
/**
* 產生唯一的通行碼 (8)
*/
public static function generateUniqueCode(int $machineId): string
{
do {
$code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT);
$exists = self::where('machine_id', $machineId)
->where('code', $code)
->where('status', 'active')
->where(function($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
} while ($exists);
return $code;
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Models\Transaction;
use App\Models\Machine\Machine;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PassCodeLog extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'pass_code_id',
'action',
'user_id',
'order_id',
'raw_data',
];
protected $casts = [
'raw_data' => 'array',
];
public function passCode(): BelongsTo
{
return $this->belongsTo(PassCode::class);
}
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\System\User::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}

View File

@ -4,11 +4,10 @@ namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class PaymentType extends Model
{
use HasFactory, SoftDeletes;
use HasFactory;
protected $fillable = [
'name',

View File

@ -0,0 +1,113 @@
<?php
namespace App\Models\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PickupCode extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'product_id',
'slot_no',
'code',
'slug',
'batch_no',
'status',
'expires_at',
'used_at',
'usage_limit',
'usage_count',
'created_by',
'order_id',
];
/**
* 獲取公開取貨連結
*/
public function getTicketUrlAttribute()
{
return $this->slug ? route('pickup.ticket', $this->slug) : route('pickup.ticket', $this->code);
}
/**
* Boot the model.
*/
protected static function booted()
{
static::creating(function ($pickupCode) {
$pickupCode->slug = \Illuminate\Support\Str::random(16);
});
}
protected $casts = [
'expires_at' => 'datetime',
'used_at' => 'datetime',
];
/**
* 關聯機台
*/
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
/**
* 關聯商品(取貨碼改綁商品時使用)
*/
public function product(): BelongsTo
{
return $this->belongsTo(\App\Models\Product\Product::class);
}
/**
* 關聯建立者
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 關聯訂單
*/
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
/**
* 判斷是否可用
*/
public function isValid(): bool
{
return $this->status === 'active'
&& ($this->usage_count < $this->usage_limit)
&& ($this->expires_at?->isFuture() ?? true); // null expires_at = 無到期限制
}
/**
* 產生唯一的取貨碼 (8)
*/
public static function generateUniqueCode(int $machineId): string
{
do {
$code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT);
$exists = self::where('machine_id', $machineId)
->where('code', $code)
->where('status', 'active')
->where('expires_at', '>', now())
->exists();
} while ($exists);
return $code;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Model;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Traits\TenantScoped;
class PickupCodeLog extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'pickup_code_id',
'order_id',
'action',
'user_id', // Optional, for admin actions recorded here
'remark',
'raw_data'
];
protected $casts = [
'raw_data' => 'array'
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
public function pickupCode()
{
return $this->belongsTo(PickupCode::class);
}
public function order()
{
return $this->belongsTo(Order::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Models\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WelcomeGift extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'name',
'code',
'slug',
'discount_type',
'discount_value',
'usage_type',
'usage_limit',
'usage_count',
'expires_at',
'status',
'created_by',
];
protected $casts = [
'expires_at' => 'datetime',
'discount_value' => 'integer',
];
protected $appends = ['discount_label', 'input_fold'];
/**
* 獲取公開來店禮連結
*/
public function getTicketUrlAttribute()
{
return $this->slug ? route('welcome-gift.ticket', $this->slug) : null;
}
protected static function booted()
{
static::creating(function ($gift) {
if (!$gift->slug) {
$gift->slug = \Illuminate\Support\Str::random(16);
}
});
}
/**
* 後台編輯時回填使用的折數 (如趴數 20 換算回 8 15 換算回 8.5 )
*/
public function getInputFoldAttribute()
{
if ($this->discount_type !== 'percentage') {
return null;
}
return (100 - $this->discount_value) / 10;
}
/**
* 自動轉換為台灣消費者習慣的折數標籤 (例如:八折、八五折、折 50 )
*/
public function getDiscountLabelAttribute(): string
{
if ($this->discount_type === 'amount') {
return "{$this->discount_value}";
}
if ($this->discount_type === 'percentage') {
$fold = $this->input_fold;
return $this->convertToChineseDiscount($fold);
}
return '';
}
private function convertToChineseDiscount($fold): string
{
$map = [
'1' => '一', '2' => '二', '3' => '三', '4' => '四', '5' => '五',
'6' => '六', '7' => '七', '8' => '八', '9' => '九', '0' => '零'
];
$foldStr = (string)$fold;
if (strpos($foldStr, '.') !== false) {
list($integer, $decimal) = explode('.', $foldStr);
$intChar = $map[$integer] ?? $integer;
$decChar = $map[substr($decimal, 0, 1)] ?? substr($decimal, 0, 1);
return "{$intChar}{$decChar}";
}
$char = $map[$foldStr] ?? $foldStr;
return "{$char}";
}
/**
* 關聯機台
*/
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
/**
* 關聯建立者
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 判斷是否可用
*/
public function isValid(): bool
{
if ($this->status !== 'active') {
return false;
}
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
if ($this->usage_type === 'once' && $this->usage_count >= ($this->usage_limit ?? 1)) {
return false;
}
return true;
}
/**
* 產生唯一的來店禮代碼 (8)
*/
public static function generateUniqueCode(int $machineId): string
{
do {
$code = str_pad(rand(0, 99999999), 8, '0', STR_PAD_LEFT);
$exists = self::where('machine_id', $machineId)
->where('code', $code)
->where('status', 'active')
->exists();
} while ($exists);
return $code;
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Models\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WelcomeGiftLog extends Model
{
use TenantScoped;
protected $fillable = [
'company_id',
'machine_id',
'welcome_gift_id',
'order_id',
'action',
'remark',
'raw_data',
];
protected $casts = [
'raw_data' => 'array',
];
/**
* 關聯來店禮
*/
public function welcomeGift(): BelongsTo
{
return $this->belongsTo(WelcomeGift::class);
}
/**
* 關聯機台
*/
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
/**
* 關聯訂單
*/
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
/**
* 關聯操作人員(一般是系統透過訂單綁定或背景核銷,如有操作人可由關聯取得,通常從 Order 或由 SystemOperationLog 獲取)
*/
}

View File

@ -36,6 +36,42 @@ class ReplenishmentOrder extends Model
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
/**
* 合法狀態轉換定義
* key = 當前狀態, value = 可轉換至的狀態陣列
*/
public const TRANSITIONS = [
self::STATUS_PENDING => [self::STATUS_PREPARED, self::STATUS_CANCELLED],
self::STATUS_PREPARED => [self::STATUS_DELIVERING, self::STATUS_CANCELLED],
self::STATUS_DELIVERING => [self::STATUS_COMPLETED, self::STATUS_CANCELLED],
self::STATUS_COMPLETED => [],
self::STATUS_CANCELLED => [],
];
/**
* 檢查是否可轉換至指定狀態
*/
public function canTransitionTo(string $status): bool
{
return in_array($status, self::TRANSITIONS[$this->status] ?? []);
}
/**
* 排除已取消的訂單
*/
public function scopeActive($query)
{
return $query->where('status', '!=', self::STATUS_CANCELLED);
}
/**
* 補貨總數量
*/
public function getTotalQuantityAttribute(): int
{
return $this->items->sum('quantity');
}
/**
* 出貨倉庫
*/

View File

@ -14,12 +14,25 @@ class ReplenishmentOrderItem extends Model
'product_id',
'slot_no',
'quantity',
'current_stock',
'max_stock',
'snapshot_product_name',
'snapshot_product_name_key',
'snapshot_product_barcode',
'snapshot_product_image_url',
];
protected $casts = [
'quantity' => 'integer',
'current_stock' => 'integer',
'max_stock' => 'integer',
];
/**
* 自動附加到 JSON/陣列輸出的屬性
*/
protected $appends = ['localized_name', 'barcode', 'image_url'];
/**
* 所屬補貨單
*/
@ -29,10 +42,85 @@ class ReplenishmentOrderItem extends Model
}
/**
* 對應商品
* 對應商品 (包含被軟刪除的商品以相容舊資料)
*/
public function product()
{
return $this->belongsTo(\App\Models\Product\Product::class);
return $this->belongsTo(\App\Models\Product\Product::class)->withTrashed();
}
/**
* 取得快照商品名稱的字典翻譯
*/
public function translations()
{
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'snapshot_product_name_key')
->where('group', 'product');
}
/**
* 取得商品名稱。
* 回退順序:快照字典語系翻譯 快照字典 zh_TW 快照名稱 關聯商品名稱 空值
*/
public function getLocalizedNameAttribute(): string
{
if ($this->snapshot_product_name_key) {
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
$locale = app()->getLocale();
// 優先找當前語系
$translation = $this->translations->firstWhere('locale', $locale);
if ($translation) {
return $translation->value;
}
// 回退至 zh_TW
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
if ($fallback) {
return $fallback->value;
}
}
}
if ($this->snapshot_product_name) {
return $this->snapshot_product_name;
}
if ($this->product) {
return $this->product->localized_name;
}
return '';
}
/**
* 取得商品條碼。
* 回退順序:快照條碼 關聯商品條碼 空值
*/
public function getBarcodeAttribute(): string
{
if ($this->snapshot_product_barcode) {
return $this->snapshot_product_barcode;
}
if ($this->product) {
return $this->product->barcode ?? '';
}
return '';
}
/**
* 取得商品圖片。
* 回退順序:快照圖片 關聯商品圖片 空值
*/
public function getImageUrlAttribute(): ?string
{
$url = $this->snapshot_product_image_url ?: ($this->product ? $this->product->image_url : null);
// 防禦性過濾無效路徑或空值
if (empty($url) || trim($url) === '' || in_array(rtrim($url, '/'), ['/storage', '/storage/products'])) {
return null;
}
return $url;
}
}

View File

@ -21,6 +21,10 @@ class StockInOrder extends Model
'completed_at',
];
public const STATUS_DRAFT = 'draft';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $casts = [
'completed_at' => 'datetime',
];

View File

@ -13,12 +13,21 @@ class TransferOrderItem extends Model
'transfer_order_id',
'product_id',
'quantity',
'snapshot_product_name',
'snapshot_product_name_key',
'snapshot_product_barcode',
'snapshot_product_image_url',
];
protected $casts = [
'quantity' => 'integer',
];
/**
* 自動附加到 JSON/陣列輸出的屬性
*/
protected $appends = ['localized_name', 'barcode', 'image_url'];
/**
* 所屬調撥單
*/
@ -28,10 +37,85 @@ class TransferOrderItem extends Model
}
/**
* 對應商品
* 對應商品 (包含被軟刪除的商品以相容舊資料)
*/
public function product()
{
return $this->belongsTo(\App\Models\Product\Product::class);
return $this->belongsTo(\App\Models\Product\Product::class)->withTrashed();
}
/**
* 取得快照商品名稱的字典翻譯
*/
public function translations()
{
return $this->hasMany(\App\Models\System\Translation::class, 'key', 'snapshot_product_name_key')
->where('group', 'product');
}
/**
* 取得商品名稱。
* 回退順序:快照字典語系翻譯 快照字典 zh_TW 快照名稱 關聯商品名稱 空值
*/
public function getLocalizedNameAttribute(): string
{
if ($this->snapshot_product_name_key) {
if ($this->relationLoaded('translations') && $this->translations->isNotEmpty()) {
$locale = app()->getLocale();
// 優先找當前語系
$translation = $this->translations->firstWhere('locale', $locale);
if ($translation) {
return $translation->value;
}
// 回退至 zh_TW
$fallback = $this->translations->firstWhere('locale', 'zh_TW');
if ($fallback) {
return $fallback->value;
}
}
}
if ($this->snapshot_product_name) {
return $this->snapshot_product_name;
}
if ($this->product) {
return $this->product->localized_name;
}
return '';
}
/**
* 取得商品條碼。
* 回退順序:快照條碼 關聯商品條碼 空值
*/
public function getBarcodeAttribute(): string
{
if ($this->snapshot_product_barcode) {
return $this->snapshot_product_barcode;
}
if ($this->product) {
return $this->product->barcode ?? '';
}
return '';
}
/**
* 取得商品圖片。
* 回退順序:快照圖片 關聯商品圖片 空值
*/
public function getImageUrlAttribute(): ?string
{
$url = $this->snapshot_product_image_url ?: ($this->product ? $this->product->image_url : null);
// 防禦性過濾無效路徑或空值
if (empty($url) || trim($url) === '' || in_array(rtrim($url, '/'), ['/storage', '/storage/products'])) {
return null;
}
return $url;
}
}

View File

@ -0,0 +1,250 @@
<?php
namespace App\Services\Invoice;
use App\Models\Machine\Machine;
use App\Models\Transaction\Invoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 綠界電子發票 B2C API 服務(後台對帳/補開/作廢用)。
*
* 機台端在出貨時已「直連綠界」開立並把結果隨 finalize 上報;本服務負責後台側:
* - GetIssue pending 發票以 RelateNumber 去綠界查詢真實狀態(處理掉包/未知,避免重複開立)
* - Issue :對確認未開立者補開(沿用同一 RelateNumber冪等
* - Invalid :對「已開票未出貨」等情況作廢
*
* 金鑰來源:機台關聯的金流配置 payment_configs.settings.ecpay_invoicestore_id/hash_key/hash_iv/email
* 與機台 B014 下發同源。未設定者一律略過(回 null),確保未開發票的機台零接觸。
*/
class EcpayInvoiceService
{
private const REVISION = '3.2.9';
private function baseUrl(): string
{
// fail-safe一律預設「測試站」只有明確設定 ECPAY_INVOICE_BASE_URL 才會打正式站。
// 不可用 APP_ENV 判斷——demo 站的 APP_ENV 也是 production靠它會誤打正式綠界開出真發票。
// 正式環境必須在 .env 設 ECPAY_INVOICE_BASE_URL=https://einvoice.ecpay.com.tw
// 若漏設,最壞情況是正式環境開到測試站(無真實發票、可被察覺),不會誤開真發票。
$configured = config('services.ecpay_invoice.base_url');
if (!empty($configured)) {
return rtrim($configured, '/');
}
return 'https://einvoice-stage.ecpay.com.tw';
}
/** 取得機台綠界發票設定;未設定回 null。 */
public function configForMachine(Machine $machine): ?array
{
$settings = $machine->paymentConfig->settings ?? [];
$merchantId = data_get($settings, 'ecpay_invoice.store_id');
$hashKey = data_get($settings, 'ecpay_invoice.hash_key');
$hashIv = data_get($settings, 'ecpay_invoice.hash_iv');
if (empty($merchantId) || empty($hashKey) || empty($hashIv)) {
return null;
}
return [
'merchant_id' => $merchantId,
'hash_key' => $hashKey,
'hash_iv' => $hashIv,
'email' => data_get($settings, 'ecpay_invoice.email', ''),
];
}
private function encrypt(array $data, string $key, string $iv): string
{
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return base64_encode(openssl_encrypt(urlencode($json), 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv));
}
private function decrypt(?string $data, string $key, string $iv): array
{
if (empty($data)) {
return [];
}
$decrypted = openssl_decrypt(base64_decode($data), 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($decrypted === false) {
return [];
}
return json_decode(urldecode($decrypted), true) ?: [];
}
/** 共用 POST包 RqHeader + AES 加密 Data回傳 decode 後的 Data 陣列(失敗回 null。 */
private function post(string $endpoint, array $cfg, array $payloadData): ?array
{
$body = [
'MerchantID' => $cfg['merchant_id'],
'RqHeader' => ['Timestamp' => time(), 'Revision' => self::REVISION],
'Data' => $this->encrypt($payloadData, $cfg['hash_key'], $cfg['hash_iv']),
];
try {
$resp = Http::timeout(15)->asJson()->post($this->baseUrl() . $endpoint, $body);
$json = $resp->json();
if (!is_array($json) || !isset($json['Data'])) {
Log::warning('ECPay invoice response missing Data', ['endpoint' => $endpoint, 'resp' => $json]);
return null;
}
return $this->decrypt($json['Data'], $cfg['hash_key'], $cfg['hash_iv']);
} catch (\Throwable $e) {
Log::error('ECPay invoice request failed: ' . $e->getMessage(), ['endpoint' => $endpoint]);
return null;
}
}
/**
* 查詢發票GetIssue RelateNumber 查。
* 回傳 decode 後陣列(含 RtnCode/IIS_Number 等)或 null(金鑰缺/連線失敗)。
*/
public function getIssue(Machine $machine, string $relateNumber): ?array
{
$cfg = $this->configForMachine($machine);
if (!$cfg) {
return null;
}
return $this->post('/B2CInvoice/GetIssue', $cfg, [
'MerchantID' => $cfg['merchant_id'],
'RelateNumber' => $relateNumber,
]);
}
/** 補開:以完整開立內容呼叫 Issue。回傳 decode 後陣列或 null。 */
public function issue(Machine $machine, array $invoicePayload): ?array
{
$cfg = $this->configForMachine($machine);
if (!$cfg) {
return null;
}
$invoicePayload['MerchantID'] = $cfg['merchant_id'];
return $this->post('/B2CInvoice/Issue', $cfg, $invoicePayload);
}
/**
* 取得發票列印頁網址InvoicePrint
* 綠界回傳 Data.InvoiceHtml 為可列印頁 URL自呼叫起 1 小時內有效)。
* 回傳 decode 後陣列(含 RtnCode/InvoiceHtml null(金鑰缺/連線失敗)。
*/
public function invoicePrint(Machine $machine, string $invoiceNo, string $invoiceDate): ?array
{
$cfg = $this->configForMachine($machine);
if (!$cfg) {
return null;
}
return $this->post('/B2CInvoice/InvoicePrint', $cfg, [
'MerchantID' => $cfg['merchant_id'],
'InvoiceNo' => $invoiceNo,
'InvoiceDate' => $invoiceDate,
'PrintStyle' => 1,
'IsShowingDetail' => 1,
]);
}
/** 作廢Invalid。 */
public function invalid(Machine $machine, string $invoiceNo, string $invoiceDate, string $reason = '作廢'): ?array
{
$cfg = $this->configForMachine($machine);
if (!$cfg) {
return null;
}
return $this->post('/B2CInvoice/Invalid', $cfg, [
'MerchantID' => $cfg['merchant_id'],
'InvoiceNo' => $invoiceNo,
'InvoiceDate' => $invoiceDate,
'Reason' => mb_substr($reason ?: '作廢', 0, 20),
]);
}
/**
* 由既有 Invoice+ 關聯 Order/items重建開立內容並補開。
* 沿用 invoice.relate_number RelateNumber冪等不會重複開立
*/
public function reissue(Invoice $invoice): ?array
{
$machine = $invoice->machine;
$cfg = $this->configForMachine($machine);
if (!$cfg || empty($invoice->relate_number)) {
return null;
}
$order = $invoice->order;
$metadata = $invoice->metadata ?? [];
$businessTaxId = $metadata['business_tax_id'] ?? '';
$carrier = $invoice->carrier_id ?? '';
$loveCode = $invoice->love_code ?? '';
// 載具類型判定(對齊機台端邏輯)
$carrierType = '';
$donation = '0';
if (strlen($loveCode) > 2) {
$donation = '1';
$carrierType = '';
} elseif (strlen($carrier) === 8) {
$carrierType = '3'; // 手機條碼
} elseif (strlen($carrier) === 16) {
$carrierType = '2'; // 自然人憑證
} elseif (empty($businessTaxId)) {
$carrierType = '1'; // 綠界會員載具
}
// 品項
$items = [];
$seq = 1;
$orderItems = $order ? $order->items : collect();
foreach ($orderItems as $oi) {
$items[] = [
'ItemSeq' => $seq++,
'ItemName' => $oi->product_name ?: ('商品' . $oi->product_id),
'ItemCount' => (int) $oi->quantity,
'ItemWord' => '個',
'ItemPrice' => (int) round($oi->price),
'ItemTaxType' => '1',
'ItemAmount' => (int) round($oi->subtotal ?: ($oi->price * $oi->quantity)),
'ItemRemark' => '',
];
}
if (empty($items)) {
// 無明細時以單列總額補上,確保發票可開立
$items[] = [
'ItemSeq' => 1,
'ItemName' => '商品',
'ItemCount' => 1,
'ItemWord' => '式',
'ItemPrice' => (int) round($invoice->amount),
'ItemTaxType' => '1',
'ItemAmount' => (int) round($invoice->amount),
'ItemRemark' => '',
];
}
$payload = [
'RelateNumber' => $invoice->relate_number,
'CustomerID' => $machine->serial_no,
'Print' => empty($businessTaxId) ? '0' : '1',
'CustomerIdentifier' => $businessTaxId,
'CustomerName' => $businessTaxId,
'CustomerAddr' => $businessTaxId,
'CustomerPhone' => '',
'CustomerEmail' => $donation === '1'
? ($invoice->relate_number . substr($cfg['email'], strpos($cfg['email'], '@') ?: 0))
: ($cfg['email'] ?: ''),
'ClearanceMark' => '',
'Donation' => $donation,
'LoveCode' => $donation === '1' ? $loveCode : '',
'CarrierType' => $carrierType,
'CarrierNum' => ($carrierType === '2' || $carrierType === '3') ? str_replace('+', ' ', $carrier) : '',
'TaxType' => '1',
'SpecialTaxType' => 0,
'SalesAmount' => (int) round($invoice->amount),
'InvoiceRemark' => '發票備註:無',
'InvType' => '07',
'vat' => '1',
'Items' => $items,
];
return $this->issue($machine, $payload);
}
}

Some files were not shown because too many files have changed in this diff Show More