Compare commits

...

466 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
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
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
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
255 changed files with 31722 additions and 2585 deletions

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

@ -51,6 +51,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| success | Boolean | 請求是否成功 | true |
| code | Integer | 業務狀態碼 | 200 |
| message | String | 驗證結果 (Success 或 Failed) | Success |
| identity | String | **登入者身分**`system` 系統登入者 / `company` 公司帳號 / `staff` 一般人員。僅驗證成功時回傳 | system |
| token | String | **臨時身份認證 Token** (可選,供未來擴充使用) | 1\|abcdefg... |
---
@ -136,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 |
@ -150,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] 機台故障與異常狀態上報
@ -168,20 +177,80 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
- **資料來源**: 所有金流密鑰與電子發票設定,均取自機台關聯的「金流配置 (`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=履帶) |
> 註:
> - 金流密鑰/發票來源已由「公司設定 (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 B016: 機台系統設定回寫 (Settings Write-back)
機台主控台由**系統方**設定硬體貨道類型後,將貨道開關狀態回寫雲端。為 B014 下載的反向操作。
- **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` 跨租戶定位機台。
---
@ -234,11 +303,67 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
- `status`: "active"。
> [!WARNING]
> **鎖定機制**B660, B670 與 B680 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
> **鎖定機制**B660, B670, B680 與 B690 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
---
### 3.12 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
### 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 |
| 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 |
**Response (Failed 404):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | false |
| message | String | 失敗原因 | Invalid or expired code |
| code | Integer | 業務狀態碼 | 404 |
| remaining_attempts | Integer | 剩餘嘗試次數 | 3 |
**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
@ -249,7 +374,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
---
### 3.13 B650: 會員身分驗證 (Member Verification)
### 3.15 B650: 會員身分驗證 (Member Verification)
機台端掃碼後驗證會員身分。
- **URL**: POST /api/v1/app/machine/member/verify/B650
@ -289,7 +414,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| 代碼 | 支付方式 | 備註 / 說明 |
| :--- | :--- | :--- |
| `1` | 信用卡 | 一般實體信用卡刷卡 |
| `2` | 電子票證 | 包含悠遊卡、一卡通|
| `2` | 電子票證 | 包含悠遊卡、一卡通、iCash 等(與信用卡、手機支付共用同一台實體刷卡機)|
| `3` | 掃碼支付 | 一般掃碼支付 |
| `4` | 投幣機支付 | 實體紙鈔機 |
| `5` | 通行碼 | 現場維護人員測試使用 |
@ -297,15 +422,19 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| `7` | 來店禮 | |
| `8` | 問卷 | |
| `9` | 零錢 | 硬幣投幣機 |
| `10` | 手機支付 | NFC 手機感應(行動支付,走信用卡軌道,依使用者按鈕分類) |
| `21` ~ `25` | 線下付款 + X | `21`=線下+1, `22`=線下+2... `25`=線下+9 |
| `30` | TapPay - LINE Pay | |
| `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

@ -129,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`

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

@ -386,6 +386,12 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
:selected="request('company_id')"
:placeholder="__('All Companies')"
/>
{{-- 下拉選單遮擋處理範例 --}}
<div class="relative focus-within:z-20">
<label>...</label>
<x-searchable-select ... />
</div>
```
---
@ -398,7 +404,82 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
| 刪除確認 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 標準實作
@ -435,4 +516,35 @@ async fetchPage(url) {
| 基本資訊 | 翠綠 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` |
| 危險/刪除 | 玫瑰 Rose | `bg-rose-500/10 text-rose-500` |
---
## 10. Z-Index 堆疊與遮擋處理 (Z-Index Stacking)
> [!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-4">
<!-- 型號選單 -->
<div class="relative focus-within:z-[60]">
<label>型號</label>
<x-searchable-select ... />
</div>
<!-- 公司選單 -->
<div class="relative focus-within:z-[60]">
<label>公司</label>
<x-searchable-select ... />
</div>
</div>
```

View File

@ -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

@ -31,9 +31,57 @@ jobs:
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 - Build & Deploy Containers
- name: Step 1 - Ensure Existing Containers Are Running
run: |
WWWGROUP=1001 WWWUSER=1001 docker-compose up -d --build --wait
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: |
@ -75,4 +123,4 @@ jobs:
echo "✅ MQTT Worker 已重啟"
- name: Step 5 - Cleanup
run: docker image prune -f
run: docker image prune -f --filter "until=168h"

5
.gitignore vendored
View File

@ -21,3 +21,8 @@ yarn-error.log
/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`,需要時可直接呼叫。

Binary file not shown.

Binary file not shown.

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;
@ -94,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;

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

@ -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

@ -19,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');
@ -36,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();

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,23 +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']);
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;
@ -67,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) {
@ -76,15 +86,15 @@ 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',
@ -98,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')
@ -108,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'
));
}
@ -138,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',
@ -198,21 +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',
@ -319,6 +338,17 @@ 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());
@ -332,14 +362,132 @@ class MachineSettingController extends AdminController
'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,
@ -361,6 +509,7 @@ class MachineSettingController extends AdminController
'welcome_gift_enabled',
'cash_module_enabled',
'member_system_enabled',
'ambient_temp_monitoring_enabled',
];
if (!in_array($field, $allowedFields)) {
@ -375,6 +524,67 @@ class MachineSettingController extends AdminController
]);
}
/**
* 同步系統設定至機台:透過 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);
}
/**
* 刪除機台 (僅限系統管理員)
*/

View File

@ -51,7 +51,7 @@ class PaymentConfigController extends AdminController
'name' => 'required|string|max:255',
'company_id' => 'nullable|exists:companies,id',
'settings' => 'required|array',
]);
] + $this->ecpayInvoiceRules(), $this->ecpayInvoiceMessages());
PaymentConfig::create([
'name' => $request->name,
@ -83,7 +83,7 @@ class PaymentConfigController extends AdminController
'name' => 'required|string|max:255',
'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'));
}
/**
@ -213,11 +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),
];
'enable_custom_branding' => filter_var($settings['enable_custom_branding'] ?? false, FILTER_VALIDATE_BOOLEAN),
]);
// Ensure we force save the JSON cast properly
$company->settings = $formattedSettings;
@ -227,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

@ -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()
@ -193,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)
*/
@ -267,4 +450,35 @@ class MachineController extends AdminController
'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

@ -14,10 +14,8 @@ class PermissionController extends Controller
$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);
// 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search
$search = request()->input('roles_search', request()->input('search'));
@ -88,6 +86,11 @@ class PermissionController extends Controller
$role = \App\Models\System\Role::findOrFail($id);
$user = auth()->user();
// 層級隔離:子帳號只能編輯自己建立的角色,不可開啟上層/旁線建立的角色。
if (!$role->canBeManagedBy($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
// 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理
$all_permissions = $this->getFilteredPermissions($user);
@ -126,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,
]);
@ -182,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 = [
@ -223,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.'));
}
@ -251,10 +270,8 @@ class PermissionController extends Controller
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
// ── 帳號列表 ──────────────────────────────────────────────────
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
if (!$user->isSystemAdmin()) {
$usersQuery->where('company_id', $user->company_id);
}
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。
$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}%")
@ -269,22 +286,24 @@ class PermissionController extends Controller
$accounts_per_page = $request->input('accounts_per_page', 10);
$users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString();
// Modal 用的角色選單(不分頁)
$rolesForSelect = \App\Models\System\Role::query();
if (!$user->isSystemAdmin()) {
$rolesForSelect->forCompany($user->company_id);
}
// 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();
if ($isSubAccountRoute) {
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
if (!$user->isSystemAdmin()) {
$rolesQuery->where('company_id', $user->company_id);
}
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user);
if ($search = $request->input('roles_search')) {
$rolesQuery->where('name', 'like', "%{$search}%");
}
@ -328,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;
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
@ -374,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;
@ -383,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'],
@ -390,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);
@ -410,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,
@ -500,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;
@ -509,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 角色
@ -527,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;
@ -556,6 +646,11 @@ 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();
@ -577,9 +672,9 @@ class PermissionController extends Controller
'menu.machines',
'menu.warehouses',
'menu.sales',
'menu.analysis',
'menu.data-config',
'menu.remote',
'menu.special-permission',
'menu.basic',
'menu.permissions',
];

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

@ -13,6 +13,7 @@ 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
@ -23,6 +24,51 @@ class ProductController extends Controller
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();
@ -53,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']);
@ -69,7 +115,7 @@ class ProductController extends Controller
});
}
$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)
@ -87,6 +133,7 @@ class ProductController extends Controller
'success' => true,
'html' => view('admin.products.partials.tab-products', [
'products' => $products,
'categories' => $categories,
'companySettings' => $companySettings,
'companies' => $companies,
'routeName' => $routeName
@ -120,28 +167,49 @@ class ProductController extends Controller
});
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$logQuery->whereBetween('created_at', [$request->start_date, $request->end_date]);
$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->latest()->paginate($per_page, ['*'], 'log_page')->withQueryString();
$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
'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
]);
}
@ -160,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' => '繁體中文']),
]);
}
@ -168,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();
@ -191,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',
@ -213,24 +292,18 @@ 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')) {
@ -245,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,
@ -296,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',
@ -318,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,
@ -353,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,
@ -471,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) {
@ -519,6 +584,185 @@ class ProductController extends Controller
}
}
/**
* 匯出商品資料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)
*/

View File

@ -42,7 +42,10 @@ class RemoteController extends Controller
$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'))) {
@ -72,6 +75,9 @@ class RemoteController extends Controller
} catch (\Exception $e) {
// 忽略解析錯誤
}
} else {
// 預設過濾當天紀錄
$historyQuery->whereDate('created_at', now()->toDateString());
}
// 指令類型過濾
@ -136,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',
@ -230,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');
@ -259,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

@ -110,6 +110,15 @@ class StaffCardController extends Controller
$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()) {
@ -126,10 +135,14 @@ class StaffCardController extends Controller
{
$staffCard->delete();
return response()->json([
'success' => true,
'message' => __('Staff card deleted successfully'),
]);
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)

View File

@ -26,36 +26,54 @@ class WarehouseController extends Controller
public function index(Request $request)
{
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$search = $request->input('search');
$type = $request->input('type');
$applyWarehouseFilters = function ($query) use ($currentUser, $companyId, $search, $type) {
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('address', 'like', "%{$search}%");
});
}
if ($type) {
$query->where('type', $type);
}
return $query;
};
$query = Warehouse::query()
->with(['company'])
->withCount('stocks as products_count')
->withSum('stocks as total_stock', 'quantity');
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('address', 'like', "%{$search}%");
});
}
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
$applyWarehouseFilters($query);
$warehouses = $query->orderBy('type')->orderBy('name')
->paginate($request->input('per_page', 10))
->withQueryString();
$statsWarehouseQuery = fn() => $applyWarehouseFilters(Warehouse::query());
$stats = [
'total' => Warehouse::count(),
'main' => Warehouse::main()->count(),
'branch' => Warehouse::branch()->count(),
'total' => $statsWarehouseQuery()->count(),
'main' => $statsWarehouseQuery()->main()->count(),
'branch' => $statsWarehouseQuery()->branch()->count(),
'low_stock' => WarehouseStock::lowStock()
->whereHas('warehouse', fn($q) => $q)->count(),
->whereHas('warehouse', fn($q) => $applyWarehouseFilters($q))
->count(),
];
$companies = collect();
if (Auth::user()->isSystemAdmin()) {
if ($currentUser->isSystemAdmin()) {
$companies = \App\Models\System\Company::active()->orderBy('name')->get();
}
@ -149,23 +167,39 @@ class WarehouseController extends Controller
public function inventory(Request $request)
{
$tab = $request->input('tab', 'stock');
$warehouses = Warehouse::active()->orderBy('name')->get();
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$applyCompanyFilter = fn($query) => $query->when(
$currentUser->isSystemAdmin() && $companyId !== '',
fn($q) => $q->where('company_id', $companyId)
);
$warehouses = $applyCompanyFilter(Warehouse::active())
->orderBy('name')
->get();
$companies = $currentUser->isSystemAdmin()
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
$isAjax = $request->ajax();
$data = compact('warehouses', 'tab');
$data = compact('warehouses', 'companies', 'tab');
// 1. Current Stocks (stock)
if (!$isAjax || $tab === 'stock') {
$warehouse_ids = $request->input('warehouse_ids');
$inventory_warehouses = Warehouse::active()
$inventory_warehouses = $applyCompanyFilter(Warehouse::active())
->when($warehouse_ids, fn($q) => $q->whereIn('id', (array)$warehouse_ids))
->orderBy('name')
->get(['id', 'name', 'type']);
->get(['id', 'name', 'type', 'company_id']);
$query = Product::with(['stocks' => function($q) {
$q->select('id', 'product_id', 'warehouse_id', 'quantity', 'safety_stock');
}, 'translations']);
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
if ($warehouse_ids) {
$query->whereHas('stocks', fn($q) => $q->whereIn('warehouse_id', (array)$warehouse_ids));
}
@ -185,17 +219,35 @@ class WarehouseController extends Controller
->withCount('items')
->latest();
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
if ($search = $request->input('search_order')) {
$query->where('order_no', 'like', "%{$search}%");
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [$request->start_date, $request->end_date]);
if ($request->filled('start_date') || $request->filled('end_date')) {
if ($request->filled('start_date')) {
$query->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
// 隱性限制:預設只抓 6 個月內的單據以保護效能,但 UI 保持空白
$query->where('created_at', '>=', now()->subMonths(6)->startOfDay());
$data['stockInDefaultStart'] = '';
$data['stockInDefaultEnd'] = '';
}
$data['orders'] = $query->paginate($request->input('per_page', 10), ['*'], 'order_page')->withQueryString();
$data['stock_in_products'] = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
$data['stock_in_products'] = Product::with('translations')
->when($currentUser->isSystemAdmin() && $companyId !== '', fn($q) => $q->where('company_id', $companyId))
->orderBy('name')
->get(['id', 'name', 'image_url', 'name_dictionary_key']);
}
// 3. Movement History (movements)
@ -204,14 +256,39 @@ class WarehouseController extends Controller
->orderBy('created_at', 'desc')
->orderBy('id', 'desc');
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->input('warehouse_id'));
}
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [$request->start_date, $request->end_date]);
if ($request->filled('start_date') || $request->filled('end_date')) {
if ($request->filled('start_date')) {
$query->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
// 預設當天
$start = now()->startOfDay();
$end = now()->endOfDay();
$query->whereBetween('created_at', [$start, $end]);
$data['movementDefaultStart'] = $start->format('Y-m-d H:i');
$data['movementDefaultEnd'] = $end->format('Y-m-d H:i');
// 只有在當前分頁是 movements 時才合併到 request
if ($tab === 'movements') {
$request->merge([
'start_date' => $data['movementDefaultStart'],
'end_date' => $data['movementDefaultEnd']
]);
}
}
$data['movements'] = $query->paginate($request->input('per_page', 15), ['*'], 'movement_page')->withQueryString();
}
@ -365,10 +442,17 @@ class WarehouseController extends Controller
public function transfers(Request $request)
{
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$isSystemAdmin = $currentUser->isSystemAdmin();
$query = TransferOrder::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'fromMachine:id,name,serial_no', 'creator:id,name'])
->withCount('items')
->latest();
if ($isSystemAdmin && $companyId !== '') {
$query->where('company_id', $companyId);
}
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
@ -378,8 +462,16 @@ class WarehouseController extends Controller
if ($search = $request->input('search_order')) {
$query->where('order_no', 'like', "%{$search}%");
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [$request->start_date, $request->end_date]);
if ($request->filled('start_date') || $request->filled('end_date')) {
if ($request->filled('start_date')) {
$query->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
// 隱性限制:預設只抓 6 個月內的單據以保護效能,但 UI 保持空白
$query->where('created_at', '>=', now()->subMonths(6)->startOfDay());
}
$orders = $query->paginate($request->input('per_page', 15))->withQueryString();
@ -391,11 +483,22 @@ class WarehouseController extends Controller
]);
}
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']);
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
$products = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
$companies = $isSystemAdmin
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
$applyCompanyFilter = fn($query) => $query->when(
$isSystemAdmin && $companyId !== '',
fn($q) => $q->where('company_id', $companyId)
);
return view('admin.warehouses.transfers', compact('orders', 'warehouses', 'machines', 'products'));
$warehouses = $applyCompanyFilter(Warehouse::active())->orderBy('name')->get(['id', 'name']);
$machines = $applyCompanyFilter(Machine::query())->orderBy('name')->get(['id', 'name', 'serial_no']);
$products = $applyCompanyFilter(Product::with('translations'))->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
$defaultStart = '';
$defaultEnd = '';
return view('admin.warehouses.transfers', compact('orders', 'warehouses', 'machines', 'products', 'companies', 'defaultStart', 'defaultEnd'));
}
/**
@ -444,7 +547,18 @@ class WarehouseController extends Controller
'created_by' => Auth::id(),
]);
// 預先取得商品資訊以建立快照
$productIds = collect($validated['items'])->pluck('product_id')->unique()->toArray();
$products = \App\Models\Product\Product::whereIn('id', $productIds)->get()->keyBy('id');
foreach ($validated['items'] as $item) {
$product = $products->get($item['product_id']);
if ($product) {
$item['snapshot_product_name'] = $product->name;
$item['snapshot_product_name_key'] = $product->name_dictionary_key;
$item['snapshot_product_barcode'] = $product->barcode;
$item['snapshot_product_image_url'] = $product->image_url;
}
$order->items()->create($item);
}
});
@ -571,11 +685,19 @@ class WarehouseController extends Controller
public function machineInventory(Request $request)
{
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$isSystemAdmin = $currentUser->isSystemAdmin();
$query = Machine::with(['slots' => fn($q) => $q->with('product:id,name,image_url')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')])
->withCount('slots as total_slots')
->withSum('slots as total_stock', 'stock')
->withSum('slots as total_capacity', 'max_stock');
if ($isSystemAdmin && $companyId !== '') {
$query->where('company_id', $companyId);
}
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
@ -596,10 +718,210 @@ class WarehouseController extends Controller
]);
}
return view('admin.warehouses.machine-inventory', compact('machines'));
$companies = $isSystemAdmin
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
// 供批次匯出 modal 勾選用的完整機台清單(全域 scope 已限可存取機台)
$exportMachines = Machine::select('id', 'name', 'serial_no')->orderBy('name')->get();
return view('admin.warehouses.machine-inventory', compact('machines', 'companies', 'exportMachines'));
}
/**
* 匯出單一機台的「每個貨道」明細CSV / Excel——供機台詳細視圖(矩陣/表格)旁的匯出鈕使用。
* 參考 ProductController::export / SalesController::handleExport 的串流下載模式。
*/
public function machineInventoryExport(Request $request, Machine $machine)
{
$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';
$slots = $machine->slots()
->with('product:id,name,barcode')
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
$headers = ['貨道號', '商品ID', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
$safeSerial = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $machine->serial_no);
$filename = '貨道明細_' . ($safeSerial ?: $machine->id) . '_' . now()->format('YmdHis') . '.' . $ext;
$callback = function () use ($slots, $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 BOM
fputcsv($file, $headers);
}
foreach ($slots as $slot) {
$stock = (int) $slot->stock;
$capacity = (int) $slot->max_stock;
$rate = $capacity > 0 ? round($stock / $capacity * 100) : 0;
$row = [
$slot->slot_no,
$slot->product_id ?? '',
$slot->product->name ?? '',
$slot->product->barcode ?? '',
$slot->type,
$stock,
$capacity,
$rate,
$slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '',
$slot->batch_no,
$slot->is_active ? '是' : '否',
$slot->is_locked ? '是' : '否',
];
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]);
}
/**
* 批次匯出「機台庫存概覽」——每台機台一個分頁(Excel sheet)/一個檔(CSV),內容為該機台每貨道明細。
* Excel = 多分頁 xlsx(OpenSpout)CSV 無分頁概念,故每台一個 .csv 打包成 ZIP。
* ids逗號字串或陣列空則匯出全部可存取機台。全域 scope 確保只含可存取機台。
*/
public function machineInventoryExportBatch(Request $request)
{
$isExcel = ($request->input('export', 'csv') === 'excel');
$ids = $request->input('ids');
if (is_string($ids)) {
$ids = array_filter(array_map('intval', explode(',', $ids)));
} elseif (is_array($ids)) {
$ids = array_filter(array_map('intval', $ids));
} else {
$ids = [];
}
$query = Machine::with(['slots' => fn ($q) => $q->with('product:id,name,barcode')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')])
->orderBy('name');
if (!empty($ids)) {
$query->whereIn('id', $ids);
}
$machines = $query->get();
$headers = ['貨道號', '商品ID', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
$ts = now()->format('YmdHis');
// 單台的資料列
$rowsOf = function ($machine) {
$rows = [];
foreach ($machine->slots as $slot) {
$stock = (int) $slot->stock;
$capacity = (int) $slot->max_stock;
$rate = $capacity > 0 ? round($stock / $capacity * 100) : 0;
$rows[] = [
(string) $slot->slot_no,
$slot->product_id ?? '',
$slot->product->name ?? '',
$slot->product->barcode ?? '',
(string) $slot->type,
$stock,
$capacity,
$rate,
$slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '',
(string) $slot->batch_no,
$slot->is_active ? '是' : '否',
$slot->is_locked ? '是' : '否',
];
}
return $rows;
};
// 分頁/檔名機台名稱_序號去非法字元、限長、去重Excel sheet 名上限 31 字)
$labelOf = function ($machine, array &$used) {
$b = trim(($machine->name ?? '機台') . '_' . ($machine->serial_no ?? $machine->id));
$b = preg_replace('/[\\\\\\/\\?\\*\\[\\]:]/u', '_', $b);
$b = mb_substr($b, 0, 28);
$name = $b;
$i = 1;
while (in_array($name, $used, true)) {
$name = mb_substr($b, 0, 24) . '_' . (++$i);
}
$used[] = $name;
return $name;
};
if ($isExcel) {
$tmp = tempnam(sys_get_temp_dir(), 'invxlsx');
$writer = new \OpenSpout\Writer\XLSX\Writer();
$writer->openToFile($tmp);
$used = [];
$first = true;
foreach ($machines as $machine) {
$sheet = $first ? $writer->getCurrentSheet() : $writer->addNewSheetAndMakeItCurrent();
$first = false;
$sheet->setName($labelOf($machine, $used));
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
foreach ($rowsOf($machine) as $row) {
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($row));
}
}
if ($first) {
$writer->getCurrentSheet()->setName('機台庫存');
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
}
$writer->close();
return response()->download($tmp, "機台庫存_{$ts}.xlsx", [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
])->deleteFileAfterSend(true);
}
// CSV每台一個 .csv 打包成 ZIP
$tmp = tempnam(sys_get_temp_dir(), 'invzip');
$zip = new \ZipArchive();
$zip->open($tmp, \ZipArchive::OVERWRITE);
$used = [];
foreach ($machines as $machine) {
$fh = fopen('php://temp', 'r+');
fputcsv($fh, $headers);
foreach ($rowsOf($machine) as $row) {
fputcsv($fh, $row);
}
rewind($fh);
$content = "\xEF\xBB\xBF" . stream_get_contents($fh);
fclose($fh);
$zip->addFromString($labelOf($machine, $used) . '.csv', $content);
}
if ($machines->isEmpty()) {
$zip->addFromString('機台庫存.csv', "\xEF\xBB\xBF" . implode(',', $headers) . "\n");
}
$zip->close();
return response()->download($tmp, "機台庫存_{$ts}.zip")->deleteFileAfterSend(true);
}
/**
* AJAX取得單台機台貨道詳情
*/
@ -664,18 +986,33 @@ class WarehouseController extends Controller
public function replenishments(Request $request)
{
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$isSystemAdmin = $currentUser->isSystemAdmin();
$query = ReplenishmentOrder::with(['warehouse:id,name', 'machine:id,name,serial_no', 'assignee:id,name', 'creator:id,name'])
->withCount('items')
->latest();
if ($isSystemAdmin && $companyId !== '') {
$query->where('company_id', $companyId);
}
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
if ($search = $request->input('search_order')) {
$query->where('order_no', 'like', "%{$search}%");
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [$request->start_date, $request->end_date]);
if ($request->filled('start_date') || $request->filled('end_date')) {
if ($request->filled('start_date')) {
$query->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
// 隱性限制:預設只抓 6 個月內的單據以保護效能,但 UI 保持空白
$query->where('created_at', '>=', now()->subMonths(6)->startOfDay());
}
$orders = $query->paginate($request->input('per_page', 10))->withQueryString();
@ -687,18 +1024,29 @@ class WarehouseController extends Controller
]);
}
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']);
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
$products = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
$companies = $isSystemAdmin
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
$applyCompanyFilter = fn($query) => $query->when(
$isSystemAdmin && $companyId !== '',
fn($q) => $q->where('company_id', $companyId)
);
// 同租戶帳號(用於指派人員)
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name');
if (!Auth::user()->isSystemAdmin()) {
$userQuery->where('company_id', Auth::user()->company_id);
$warehouses = $applyCompanyFilter(Warehouse::active())->orderBy('name')->get(['id', 'name']);
$machines = $applyCompanyFilter(Machine::query())->orderBy('name')->get(['id', 'name', 'serial_no']);
$products = $applyCompanyFilter(Product::with('translations'))->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name')->visibleTo($currentUser);
if ($isSystemAdmin && $companyId !== '') {
$userQuery->where('company_id', $companyId);
}
$users = $userQuery->get(['id', 'name']);
return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines', 'products', 'users'));
$defaultStart = '';
$defaultEnd = '';
return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines', 'products', 'users', 'companies', 'defaultStart', 'defaultEnd'));
}
/**
@ -724,7 +1072,10 @@ class WarehouseController extends Controller
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
]);
DB::transaction(function () use ($validated) {
// 補貨針對機台,單據歸屬以「機台所綁公司」為準(系統管理員可為他公司機台建單)
$machine = Machine::findOrFail($validated['machine_id']);
DB::transaction(function () use ($validated, $machine) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
@ -736,18 +1087,23 @@ class WarehouseController extends Controller
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([
'company_id' => Auth::user()->company_id,
'company_id' => $machine->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $validated['machine_id'],
'machine_id' => $machine->id,
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? null,
'created_by' => Auth::id(),
]);
// 預先取得商品資訊以建立快照
$productIds = collect($validated['items'])->pluck('product_id')->unique()->toArray();
$products = \App\Models\Product\Product::whereIn('id', $productIds)->get()->keyBy('id');
// 批量寫入明細(遵循 DB Best Practice: 不在迴圈中逐筆 create
$itemsData = [];
foreach ($validated['items'] as $item) {
$product = $products->get($item['product_id']);
$itemsData[] = [
'replenishment_order_id' => $order->id,
'product_id' => $item['product_id'],
@ -755,6 +1111,10 @@ class WarehouseController extends Controller
'quantity' => $item['quantity'],
'current_stock' => $item['current_stock'] ?? 0,
'max_stock' => $item['max_stock'] ?? 0,
'snapshot_product_name' => $product ? $product->name : null,
'snapshot_product_name_key' => $product ? $product->name_dictionary_key : null,
'snapshot_product_barcode' => $product ? $product->barcode : null,
'snapshot_product_image_url' => $product ? $product->image_url : null,
'created_at' => now(),
'updated_at' => now(),
];
@ -768,7 +1128,7 @@ class WarehouseController extends Controller
}
/**
* 一鍵補貨 自動讀取機台所有貨道,計算 max_stock - stock
* 自動補貨單 自動讀取機台所有貨道,計算 max_stock - stock
*/
public function autoReplenishment(Request $request)
{
@ -849,7 +1209,7 @@ class WarehouseController extends Controller
}
// 有 items = 確認建單
DB::transaction(function () use ($validated) {
DB::transaction(function () use ($validated, $machine) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
@ -861,17 +1221,22 @@ class WarehouseController extends Controller
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([
'company_id' => Auth::user()->company_id,
'company_id' => $machine->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $validated['machine_id'],
'machine_id' => $machine->id,
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? __('Auto Replenishment'),
'created_by' => Auth::id(),
]);
// 預先取得商品資訊以建立快照
$productIds = collect($validated['items'])->pluck('product_id')->unique()->toArray();
$products = \App\Models\Product\Product::whereIn('id', $productIds)->get()->keyBy('id');
$itemsData = [];
foreach ($validated['items'] as $item) {
$product = $products->get($item['product_id']);
$itemsData[] = [
'replenishment_order_id' => $order->id,
'product_id' => $item['product_id'],
@ -879,6 +1244,10 @@ class WarehouseController extends Controller
'quantity' => $item['quantity'],
'current_stock' => $item['current_stock'] ?? 0,
'max_stock' => $item['max_stock'] ?? 0,
'snapshot_product_name' => $product ? $product->name : null,
'snapshot_product_name_key' => $product ? $product->name_dictionary_key : null,
'snapshot_product_barcode' => $product ? $product->barcode : null,
'snapshot_product_image_url' => $product ? $product->image_url : null,
'created_at' => now(),
'updated_at' => now(),
];
@ -955,36 +1324,8 @@ class WarehouseController extends Controller
}
}
// delivering → completed增加機台貨道庫存並記錄異動
// delivering → completed更新完成時間(去耦機台庫存累加,由機台端上報決定)
if ($newStatus === ReplenishmentOrder::STATUS_COMPLETED) {
foreach ($replenishmentOrder->items as $item) {
$slot = MachineSlot::where('machine_id', $replenishmentOrder->machine_id)
->where('slot_no', $item->slot_no)
->lockForUpdate()
->first();
if ($slot) {
$beforeQty = $slot->stock;
$slot->increment('stock', $item->quantity);
$slot->refresh();
MachineStockMovement::create([
'company_id' => $replenishmentOrder->company_id,
'machine_id' => $replenishmentOrder->machine_id,
'product_id' => $item->product_id,
'slot_no' => $item->slot_no,
'type' => MachineStockMovement::TYPE_REPLENISHMENT,
'quantity' => $item->quantity,
'before_qty' => $beforeQty,
'after_qty' => $slot->stock,
'reference_type' => ReplenishmentOrder::class,
'reference_id' => $replenishmentOrder->id,
'note' => '補貨單完成入庫 #' . $replenishmentOrder->order_no,
'created_by' => Auth::id(),
]);
}
}
$replenishmentOrder->completed_at = now();
}
@ -1152,14 +1493,14 @@ class WarehouseController extends Controller
->where('id', $id)
->firstOrFail();
$items = ReplenishmentOrderItem::with('product.translations')
$items = ReplenishmentOrderItem::with(['translations', 'product.translations'])
->where('replenishment_order_id', $id)
->get()
->map(function ($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->localized_name ?? 'Unknown',
'image_url' => $item->product?->image_url,
'product_name' => $item->localized_name ?: __('Unknown'),
'image_url' => $item->image_url,
'quantity' => $item->quantity,
'slot_no' => $item->slot_no,
'current_stock' => $item->current_stock,
@ -1323,14 +1664,14 @@ class WarehouseController extends Controller
->where('id', $id)
->firstOrFail();
$items = \App\Models\Warehouse\TransferOrderItem::with('product.translations')
$items = \App\Models\Warehouse\TransferOrderItem::with(['translations', 'product.translations'])
->where('transfer_order_id', $id)
->get()
->map(function($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->localized_name ?? __('Unknown'),
'image_url' => $item->product?->image_url,
'product_name' => $item->localized_name ?: __('Unknown'),
'image_url' => $item->image_url,
'quantity' => (int)$item->quantity,
];
});
@ -1354,4 +1695,46 @@ class WarehouseController extends Controller
'items' => $items
]);
}
/**
* 列印調撥單
*/
public function printTransfer($id)
{
$order = TransferOrder::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator'])
->findOrFail($id);
// 雙重防護:驗證租戶資料隔離安全
$user = auth()->user();
if ($user && $user->company_id && $order->company_id !== $user->company_id) {
abort(404);
}
$items = \App\Models\Warehouse\TransferOrderItem::with(['product' => fn($q) => $q->with('translations')])
->where('transfer_order_id', $id)
->get();
return view('admin.warehouses.print.transfer', compact('order', 'items'));
}
/**
* 列印機台補貨單
*/
public function printReplenishment($id)
{
$order = ReplenishmentOrder::with(['machine', 'warehouse', 'creator', 'assignee'])
->findOrFail($id);
// 雙重防護:驗證租戶資料隔離安全
$user = auth()->user();
if ($user && $user->company_id && $order->company_id !== $user->company_id) {
abort(404);
}
$items = ReplenishmentOrderItem::with(['product' => fn($q) => $q->with('translations')])
->where('replenishment_order_id', $id)
->get();
return view('admin.warehouses.print.replenishment', compact('order', 'items'));
}
}

View File

@ -59,16 +59,22 @@ class MachineAuthController extends Controller
}
// 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';
}
}
@ -100,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,
@ -114,6 +126,7 @@ class MachineAuthController extends Controller
'success' => true,
'code' => 200,
'message' => 'Success',
'identity' => $identity,
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
]);
}

View File

@ -20,6 +20,8 @@ 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;
@ -369,6 +371,12 @@ class MachineController extends Controller
'product_id' => $item['t060v00'] ?? 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);
@ -392,8 +400,8 @@ class MachineController extends Controller
{
$machine = $request->get('machine');
// Cache First: Get from cache or rebuild if missing
$payload = $catalogService->getPayload($machine->company_id);
// 公司基底目錄(快取) + 該機台專屬定價覆蓋(即時套用,見 getMachinePayload
$payload = $catalogService->getMachinePayload($machine);
return response()->json($payload);
}
@ -421,37 +429,121 @@ 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([
@ -461,6 +553,89 @@ class MachineController extends Controller
]);
}
/**
* 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 位數取貨碼 (與原邏輯一致,但加入日誌)
@ -523,17 +698,114 @@ class MachineController extends Controller
]);
// 移除 StaffCardLog 重複紀錄,因為取貨碼本身有 status 和 order_id 可以追蹤
// 憑證的「核銷」將由 MQTT finalizeTransaction 流程處理,不再需要 B660 PUT
// 一般取貨碼的「核銷」仍由 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' => [
'slot_no' => $pickupCode->slot_no,
'pickup_code_id' => $pickupCode->id,
'code_id' => $pickupCode->id, // 統一回傳 code_id
'status' => 'active'
]
'data' => $data,
]);
}
@ -580,6 +852,17 @@ class MachineController extends Controller
'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,
@ -641,4 +924,73 @@ class MachineController extends Controller
]
]);
}
/**
* 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

@ -13,7 +13,7 @@ class PickupController extends Controller
*/
public function show($slug)
{
$pickupCode = PickupCode::with(['machine.slots.product'])
$pickupCode = PickupCode::with(['machine.slots.product', 'product'])
->where(function($query) use ($slug) {
$query->where('slug', $slug)
->orWhere('code', $slug); // 相容舊版或測試

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

@ -79,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';
@ -134,7 +147,7 @@ class ProcessCommandAck implements ShouldQueue
]);
// 記錄維護類指令到機台日誌 (MachineLog)
if (in_array($command->command_type, ['reboot', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads'], true)) {
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';

View File

@ -64,20 +64,24 @@ class ProcessHeartbeat implements ShouldQueue
// 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}");
@ -87,34 +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;
}
}
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

@ -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,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

@ -11,11 +11,16 @@ 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;
@ -59,7 +64,10 @@ class ProcessTransactionFinalized implements ShouldQueue
],
'transaction'
);
// 刷卡機健康狀態同步:成功消警 / 失敗寫 card_terminal 日誌
$this->syncCardTerminalStatus($machine, $order);
Log::info("MQTT Transaction [finalize] processed successfully", [
'serial_no' => $this->serialNo,
'order_no' => $order->order_no
@ -89,4 +97,45 @@ class ProcessTransactionFinalized implements ShouldQueue
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

@ -16,8 +16,8 @@ class Machine extends Model
// 權限隔離:一般帳號登入時只能看到自己被分配的機台
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;
}
@ -43,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'));
}
});
}
@ -59,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',
@ -90,9 +112,10 @@ class Machine extends Model
'scan_pay_linepay_enabled',
'shopping_cart_enabled',
'cash_module_enabled',
'settings',
];
protected $appends = ['image_urls', 'calculated_status', 'hardware_status', 'latest_status_log_time', 'latest_hardware_log_time', 'is_unstable', 'recent_disconnection_count'];
protected $appends = ['image_urls', 'calculated_status', 'hardware_status', 'latest_status_log_time', 'latest_hardware_log_time', 'is_unstable', 'recent_disconnection_count', 'has_login_warning'];
/**
* 動態計算機台當前狀態 (僅限連線與系統層級)
@ -133,6 +156,18 @@ class Machine extends Model
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' 日誌)
*/
@ -194,6 +229,52 @@ class Machine extends Model
->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;
}
/**
* Scope: 判定在線 (讀取 status 欄位)
*/
@ -220,17 +301,27 @@ class Machine extends Model
/**
* Scope: 判定是否有任何待處理的異常 (排除掉單純的連線中斷)
* 待處理告警應專注於「功能性異常」如硬體錯誤、APP 報錯),而非連線狀態
* 只有當登入失敗次數達 3 次以上時,才列入待處理告警。
*/
public function scopeHasAnyAlert($query)
{
return $query->whereHas('logs', function ($q) {
$q->where(function($sub) {
$sub->where('is_resolved', false)
->orWhereNull('is_resolved');
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)');
})
->whereIn('level', ['error', 'warning'])
->where('message', '!=', 'Connection lost (LWT)');
// 2. 或者有至少 3 筆未解決的登入異常
->orWhereHas('logs', function ($q) {
$q->where('is_resolved', false)
->where('type', 'login')
->where('level', 'warning');
}, '>=', 3);
});
}
@ -275,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',
@ -290,6 +384,7 @@ class Machine extends Model
'shopping_cart_enabled' => 'boolean',
'cash_module_enabled' => 'boolean',
'images' => 'array',
'settings' => 'array',
];
/**
@ -314,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);
@ -385,6 +485,147 @@ class Machine extends Model
->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);

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',
@ -37,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

@ -76,7 +76,7 @@ class MachineStockMovement extends Model
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
return $this->belongsTo(Product::class)->withTrashed();
}
public function creator(): BelongsTo

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

@ -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');
}
/**
* 倉庫庫存紀錄
*/

View File

@ -30,6 +30,25 @@ class StaffCardLog extends Model
'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,
]);
}
}
});
}
/**
* 取得關聯的員工卡片
*/

View File

@ -83,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

@ -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,10 +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',
@ -28,12 +40,21 @@ 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);

View File

@ -6,21 +6,53 @@ 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',
@ -28,6 +60,7 @@ class Order extends Model
'discount_amount',
'pay_amount',
'change_amount',
'cash_detail',
'points_used',
'original_amount',
'payment_status',
@ -36,10 +69,13 @@ class Order extends Model
'payment_at',
'member_barcode',
'code_id',
'welcome_gift_id',
'invoice_info',
'machine_time',
'status',
'metadata',
'delivery_status',
'remark',
];
protected $casts = [
@ -47,13 +83,51 @@ class Order extends Model
'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);
}
/**
* 取得所有支付類型的對照標籤
*/
@ -62,13 +136,15 @@ class Order extends Model
return [
1 => __('Credit Card'),
2 => __('E-Ticket (EasyCard/iPass)'),
3 => __('QR Code Payment'),
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'),
@ -81,6 +157,7 @@ class Order extends Model
34 => __('PlusPay'),
40 => __('Member Verify Pickup'),
41 => __('Staff Card'),
42 => __('Pickup Voucher'),
50 => __('Offline + LINE Pay'),
51 => __('Offline + JKO Pay'),
52 => __('Offline + Easy Wallet'),
@ -96,6 +173,7 @@ class Order extends Model
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'),
@ -112,11 +190,76 @@ class Order extends Model
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);
@ -136,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

@ -18,8 +18,11 @@ class PassCode extends Model
'name',
'code',
'slug',
'batch_no',
'expires_at',
'status',
'usage_limit', // 可用次數上限null=無限次。勾「只能使用一次」時=1
'usage_count', // 已使用次數
'created_by',
];
@ -76,6 +79,11 @@ class PassCode extends Model
return false;
}
// 有設使用次數上限(如「只能使用一次」usage_limit=1)且已用完 → 失效。null=無限次。
if (!is_null($this->usage_limit) && $this->usage_count >= $this->usage_limit) {
return false;
}
return true;
}

View File

@ -15,9 +15,12 @@ class PickupCode extends Model
protected $fillable = [
'company_id',
'machine_id',
'product_id',
'slot_no',
'code',
'slug',
'batch_no',
'status',
'expires_at',
'used_at',
'usage_limit',
@ -57,6 +60,14 @@ class PickupCode extends Model
return $this->belongsTo(Machine::class);
}
/**
* 關聯商品(取貨碼改綁商品時使用)
*/
public function product(): BelongsTo
{
return $this->belongsTo(\App\Models\Product\Product::class);
}
/**
* 關聯建立者
*/
@ -78,9 +89,9 @@ class PickupCode extends Model
*/
public function isValid(): bool
{
return $this->status === 'active'
return $this->status === 'active'
&& ($this->usage_count < $this->usage_limit)
&& ($this->expires_at->isFuture());
&& ($this->expires_at?->isFuture() ?? true); // null expires_at = 無到期限制
}
/**

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

@ -16,6 +16,10 @@ class ReplenishmentOrderItem extends Model
'quantity',
'current_stock',
'max_stock',
'snapshot_product_name',
'snapshot_product_name_key',
'snapshot_product_barcode',
'snapshot_product_image_url',
];
protected $casts = [
@ -24,6 +28,11 @@ class ReplenishmentOrderItem extends Model
'max_stock' => 'integer',
];
/**
* 自動附加到 JSON/陣列輸出的屬性
*/
protected $appends = ['localized_name', 'barcode', 'image_url'];
/**
* 所屬補貨單
*/
@ -33,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

@ -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);
}
}

View File

@ -7,6 +7,7 @@ use App\Models\Machine\MachineLog;
use App\Models\Machine\MachineSlot;
use App\Models\Machine\MachineStockMovement;
use App\Models\Machine\RemoteCommand;
use App\Models\Product\Product;
use App\Models\Transaction\Order;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@ -14,57 +15,171 @@ use Carbon\Carbon;
class MachineService
{
private const SPRING_SLOT_FLAGS = [
[1, 10, 'is_spring_slot_1_10'],
[11, 20, 'is_spring_slot_11_20'],
[21, 30, 'is_spring_slot_21_30'],
[31, 40, 'is_spring_slot_31_40'],
[41, 50, 'is_spring_slot_41_50'],
[51, 60, 'is_spring_slot_51_60'],
];
/**
* B013: 硬體狀態代碼對照表 (Hardware Status Code Mapping)
*
* 代碼格式Command 0x04 + Status Byte例如 0x04+0x03 = '0403'
* 出貨結果判定:僅 0402 (出貨成功) 0424 (商品取出成功) 視為成功,
* 其餘出貨結果碼均應觸發退款。
*
* 參考文件:廠商硬體通訊協議 Command(0x04) 狀態定義
*/
public const ERROR_CODE_MAP = [
// 出貨狀態類 (Prefix: 04 - BUY_STATUS)
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'],
'0402' => ['label' => 'Dispense successful', 'level' => 'info'],
'0403' => ['label' => 'Slot jammed', 'level' => 'error'],
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'],
'0406' => ['label' => 'Slot not found', 'level' => 'error'],
'0407' => ['label' => 'Dispense error (0407)', 'level' => 'error'],
'0408' => ['label' => 'Dispense error (0408)', 'level' => 'error'],
'0409' => ['label' => 'Dispense error (0409)', 'level' => 'error'],
'040A' => ['label' => 'Dispense error (040A)', 'level' => 'error'],
'0410' => ['label' => 'Elevator rising', 'level' => 'info'],
'0411' => ['label' => 'Elevator descending', 'level' => 'info'],
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'],
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'],
'0414' => ['label' => 'Pickup door closed', 'level' => 'info'],
'0415' => ['label' => 'Pickup door error', 'level' => 'error'],
'0416' => ['label' => 'Delivery door opened', 'level' => 'info'],
'0417' => ['label' => 'Delivery door open error', 'level' => 'error'],
'0418' => ['label' => 'Delivering product', 'level' => 'info'],
'0419' => ['label' => 'Delivery door closed', 'level' => 'info'],
'0420' => ['label' => 'Delivery door close error', 'level' => 'error'],
'0421' => ['label' => 'Hopper empty', 'level' => 'warning'],
'0422' => ['label' => 'Hopper overheated', 'level' => 'warning'],
'0423' => ['label' => 'Hopper heating timeout', 'level' => 'error'],
'0424' => ['label' => 'Hopper error (0424)', 'level' => 'error'],
'0426' => ['label' => 'Microwave door opened', 'level' => 'info'],
'0427' => ['label' => 'Microwave door error', 'level' => 'error'],
'04FF' => ['label' => 'Dispense stopped', 'level' => 'info'],
// ═══════════════════════════════════════════════════════════════
// 出貨與物理動作類 (Command 0x04 - BUY_STATUS)
// ═══════════════════════════════════════════════════════════════
// 貨道狀態類 (Prefix: 02 - SLOT_STATUS)
'0201' => ['label' => 'Slot normal', 'level' => 'info'],
'0202' => ['label' => 'Product empty', 'level' => 'warning'],
'0203' => ['label' => 'Slot empty', 'level' => 'warning'],
'0205' => ['label' => 'Slot sensor blocked', 'level' => 'warning'],
'0206' => ['label' => 'Slot not closed', 'level' => 'warning'],
'0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'],
'0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'],
'0209' => ['label' => 'Slot motor error (0209)', 'level' => 'error'],
'0212' => ['label' => 'Hopper empty (0212)', 'level' => 'warning'],
// --- 基本出貨流程 ---
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'], // 正在出貨
'0402' => ['label' => 'Dispense successful', 'level' => 'info'], // ✅ 出貨成功
'0403' => ['label' => 'Slot jammed', 'level' => 'error'], // 貨道卡貨 (重大異常)
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'], // 電機未正常停止
'0405' => ['label' => 'Motor not found', 'level' => 'error'], // 電機不存在
'0406' => ['label' => 'Motor not found (0406)', 'level' => 'error'], // 電機不存在 (同 0405)
'0407' => ['label' => 'Elevator failure', 'level' => 'error'], // 昇降機故障
// --- 昇降機系統 ---
'040A' => ['label' => 'Elevator belt self-check error', 'level' => 'error'], // 昇降機皮帶自檢錯誤
'0410' => ['label' => 'Elevator rising', 'level' => 'info'], // 昇降機正在上升
'0411' => ['label' => 'Elevator descending', 'level' => 'info'], // 昇降機正在下降
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'], // 昇降機上升錯誤
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'], // 昇降機下降錯誤
'0425' => ['label' => 'Elevator support rod return error', 'level' => 'error'], // 昇降台撐桿回位錯誤
'0428' => ['label' => 'Elevator support rod push error', 'level' => 'error'], // 昇降台撐桿推出錯誤
'0429' => ['label' => 'Elevator enter microwave error', 'level' => 'error'], // 昇降台進微波爐錯誤
'0430' => ['label' => 'Elevator exit microwave error', 'level' => 'error'], // 昇降台出微波爐錯誤
// --- 微波爐取貨口門 ---
'0414' => ['label' => 'Microwave pickup door closing', 'level' => 'info'], // 正在關閉微波爐取貨口門
'0415' => ['label' => 'Microwave pickup door close error', 'level' => 'error'], // 微波爐取貨口門關閉錯誤
'0426' => ['label' => 'Microwave pickup door opening', 'level' => 'info'], // 正在打開微波爐取貨口門
'0427' => ['label' => 'Microwave pickup door open error', 'level' => 'error'], // 微波爐取貨口門打開錯誤
// --- 微波爐進貨口門 ---
'0416' => ['label' => 'Microwave delivery door opening', 'level' => 'info'], // 正在打開微波爐進貨口門
'0417' => ['label' => 'Microwave delivery door open error', 'level' => 'error'], // 微波爐進貨口門打開錯誤
'0418' => ['label' => 'Pushing product into microwave', 'level' => 'info'], // 正在將商品推入微波爐
'0419' => ['label' => 'Microwave delivery door closing', 'level' => 'info'], // 正在關閉微波爐進貨口門
'0420' => ['label' => 'Microwave delivery door close error', 'level' => 'error'], // 微波爐進貨口門關閉錯誤
// --- 微波爐內部 ---
'0421' => ['label' => 'Product not detected in microwave', 'level' => 'error'], // 微波爐內未檢測到商品
'0422' => ['label' => 'Product heating', 'level' => 'info'], // 商品正在加熱
'0423' => ['label' => 'Product heating remaining time', 'level' => 'info'], // 商品加熱剩餘時間 (貨道號=秒數)
'0424' => ['label' => 'Purchase successful', 'level' => 'info'], // ✅ 購買成功
'0431' => ['label' => 'Microwave push rod push error', 'level' => 'error'], // 微波爐內推桿推出錯誤
'0432' => ['label' => 'Microwave push rod retract error', 'level' => 'error'], // 微波爐內推桿收回錯誤
// --- 鮮奶機專用 ---
'040B' => ['label' => 'Milk dispense timeout', 'level' => 'error'], // 按鍵出奶超時 (鮮奶機)
'040C' => ['label' => 'Cup dropper failure', 'level' => 'error'], // 漏杯器漏杯失敗 (鮮奶機)
'040D' => ['label' => 'Cup drop not detected', 'level' => 'error'], // 未檢測到杯子掉落 (鮮奶機)
// --- 手環機專用 ---
'040E' => ['label' => 'Bracelet scan failed', 'level' => 'error'], // 沒掃碼到手環
'040F' => ['label' => 'Bracelet drop not detected', 'level' => 'error'], // 沒檢測到手環掉入取貨斗
// --- 取貨口門 (購物車模式) ---
'0433' => ['label' => 'Pickup door opened (cart mode)', 'level' => 'info'], // 取貨口門打開成功 (購物車購買)
'0434' => ['label' => 'Pickup door closed (cart mode)', 'level' => 'info'], // 取貨口門關閉成功 (購物車購買)
'0441' => ['label' => 'Pickup door unlocked', 'level' => 'info'], // 取貨口門開鎖上報
// --- 液體機專用 ---
'0435' => ['label' => 'Liquid dispense paused', 'level' => 'warning'], // 暫停出液
// --- 終止 ---
'04FF' => ['label' => 'Purchase terminated', 'level' => 'error'], // 購買終止
// ═══════════════════════════════════════════════════════════════
// 貨道與馬達狀態類 (Prefix: 02 - SLOT_STATUS)
// ═══════════════════════════════════════════════════════════════
'0201' => ['label' => 'Slot normal', 'level' => 'info'], // 貨道正常
'0202' => ['label' => 'Slot out of stock', 'level' => 'warning'], // 貨道缺貨
'0203' => ['label' => 'Slot does not exist', 'level' => 'warning'], // 貨道不存在
'0204' => ['label' => 'Slot suspended', 'level' => 'warning'], // 貨道暫停使用
'0205' => ['label' => 'Elevator contains items', 'level' => 'warning'], // 升降台裡面有物品
'0206' => ['label' => 'Pickup door not closed', 'level' => 'warning'], // 取貨口門沒關上
'0207' => ['label' => 'Elevator failure (0207)', 'level' => 'error'], // 升降機故障
'0208' => ['label' => 'Elevator self-check error', 'level' => 'error'], // 升降機自檢錯誤
'0209' => ['label' => 'Microwave pickup door close error (0209)', 'level' => 'error'], // 微波爐取貨口門關閉錯誤
'020B' => ['label' => 'Machine temperature exceeds limit', 'level' => 'warning'], // 機器超過設定溫度,暫停購買
'020C' => ['label' => 'Bento machine cooling water tank empty', 'level' => 'error'], // 盒飯機冷卻水箱缺水
'020D' => ['label' => 'Age verification required', 'level' => 'warning'], // 貨道需要年齡驗證
'020E' => ['label' => 'Water level too low', 'level' => 'warning'], // 水量過低
'020F' => ['label' => 'Weight sensor communication error', 'level' => 'error'], // 重量傳感器通訊錯誤
'0210' => ['label' => 'Microwave delivery door open error (0210)', 'level' => 'error'],// 微波爐進貨口門打開錯誤
'0211' => ['label' => 'Microwave delivery door close error (0211)', 'level' => 'error'],// 微波爐進貨口門關閉錯誤
'0212' => ['label' => 'Bento not detected', 'level' => 'warning'], // 沒檢測到盒飯
'0213' => ['label' => 'Bento heating', 'level' => 'info'], // 盒飯正在加熱
'0214' => ['label' => 'Microwave pickup door open error (0214)', 'level' => 'error'], // 微波爐取貨口門打開錯誤
'0215' => ['label' => 'Please remove bento from microwave', 'level' => 'warning'], // 請取出微波爐內盒飯
'0216' => ['label' => 'Support rod return error', 'level' => 'error'], // 撐桿回位錯誤
'0217' => ['label' => 'Main motor failure', 'level' => 'error'], // 主電機故障
'0218' => ['label' => 'Translation motor failure', 'level' => 'error'], // 平移電機故障
'0219' => ['label' => 'Support rod push error', 'level' => 'error'], // 撐桿推出錯誤
'0220' => ['label' => 'Elevator enter microwave error (0220)', 'level' => 'error'], // 升降台進去微波爐錯誤
'0221' => ['label' => 'Elevator exit microwave error (0221)', 'level' => 'error'], // 升降台出去微波爐錯誤
'0222' => ['label' => 'Microwave push rod push error (0222)', 'level' => 'error'], // 微波爐內推桿推出錯誤
'0223' => ['label' => 'Microwave push rod retract error (0223)', 'level' => 'error'], // 微波爐內推桿收回錯誤
// ═══════════════════════════════════════════════════════════════
// 機台整體狀態類 (Prefix: 54 - MACHINE_STATUS)
// ═══════════════════════════════════════════════════════════════
'5400' => ['label' => 'Machine normal', 'level' => 'info'],
'5401' => ['label' => 'Elevator sensor error', 'level' => 'error'],
'5402' => ['label' => 'Pickup door not closed', 'level' => 'warning'],
'5403' => ['label' => 'Elevator failure', 'level' => 'error'],
];
/**
* 出貨成功結果碼 (Dispense Success Codes)
*
* 僅以下代碼視為出貨成功,其餘出貨結果碼均應觸發退款。
* - 0402: 一般出貨成功
* - 0424: 商品取出成功 (微波爐機型)
*/
public const DISPENSE_SUCCESS_CODES = ['0402', '0424'];
/**
* 刷卡機 (Nexsys 終端機 / NCCC 標準) 交易回應碼對照表。
* 來源:聯合信用卡處理中心終端機回應碼規格。
* 用於把 order.payment_response 內的 "| code=XXXX" 解析成可讀(可多語)的失敗原因,
* 寫入 type='card_terminal' 機台日誌。信用卡(1)/電子票證(2)/手機支付(10) 共用同一台實體刷卡機。
* 值為英文翻譯鍵,實際顯示由 lang/*.json 多語系翻譯。
*/
public const CARD_TERMINAL_CODE_MAP = [
'0000' => 'Transaction approved',
'0001' => 'Transaction failed (issuer/card declined)',
'0002' => 'Please contact bank / e-wallet provider',
'0003' => 'Transaction timeout',
'0004' => 'Operation error',
'0005' => 'Communication failure',
'0006' => 'User terminated transaction',
'0009' => 'Non-participating institution card',
'0010' => 'Message format error',
'0011' => 'Not a co-branded card',
'0012' => 'Stored-value amount over limit',
'0013' => 'Card locked',
'0014' => 'Cannot match original stored-value transaction',
'0015' => 'Card auto-load not enabled',
'0016' => 'Insufficient stored-value / e-wallet balance',
'0017' => 'Multiple cards detected',
'0018' => 'Please re-tap card / re-scan / retry',
'0019' => 'Auto-load succeeded but purchase failed',
'1001' => 'Credit card and stored-value settlement both failed',
'1002' => 'Credit card settled, stored-value settlement failed',
'1003' => 'Credit card settlement failed, stored-value settled',
];
/**
* Update machine heartbeat and status.
*
@ -204,15 +319,12 @@ class MachineService
})?->id;
}
// 根據貨道類型自動決定上限 (Auto-calculate max_stock based on slot type)
// 若未提供 type預設為 '1' (履帶/Track)
$slotType = $slotData['type'] ?? $existingSlot->type ?? '1';
// 根據機台貨道機制設定決定上限與貨道類型 (1: 履帶, 2: 彈簧)
$slotType = $this->resolveSlotType($machine, $slotNo);
if ($actualProductId) {
$product = $products->find($actualProductId);
if ($product) {
// 1: 履帶, 2: 彈簧
$calculatedMaxStock = ($slotType == '1') ? $product->track_limit : $product->spring_limit;
$slotData['capacity'] = $calculatedMaxStock ?? $slotData['capacity'] ?? null;
$slotData['capacity'] = $this->calculateSlotMaxStock($product, $machine, $slotNo);
}
}
@ -226,29 +338,47 @@ class MachineService
'is_active' => true,
];
// ── 雙向 LWW鎖定(lock) 與 效期/批號(expiry/batch),各自獨立 rev ──
$updateData = array_merge(
$updateData,
$this->resolveSlotLockLww($existingSlot, $slotData),
$this->resolveSlotExpiryLww($existingSlot, $slotData, $actualProductId)
);
if ($existingSlot) {
$oldStock = (int) $existingSlot->stock;
$oldProductId = $existingSlot->product_id;
// 檢查是否有實質變動 (Check for actual changes)
$isStockChanged = ($oldStock !== $newStock);
$isProductChanged = ($oldProductId != $actualProductId); // 這裡用鬆散比對以處理 null/0 的情況
$isSlotConfigChanged = (string) $existingSlot->type !== (string) $updateData['type']
|| (int) $existingSlot->max_stock !== (int) $updateData['max_stock'];
// lock/expiry/rev 也算實質變動,確保 LWW 結果會被寫回(即使 stock/product 沒變)
$curExpiryStr = $existingSlot->expiry_date ? $existingSlot->expiry_date->format('Y-m-d') : null;
$isLwwChanged = ((bool) $existingSlot->is_locked !== (bool) $updateData['is_locked'])
|| ((int) $existingSlot->last_app_lock_rev !== (int) $updateData['last_app_lock_rev'])
|| ((int) $existingSlot->last_app_expiry_rev !== (int) $updateData['last_app_expiry_rev'])
|| ($curExpiryStr !== $updateData['expiry_date'])
|| ($existingSlot->batch_no !== $updateData['batch_no']);
if ($isStockChanged || $isProductChanged) {
if ($isStockChanged || $isProductChanged || $isSlotConfigChanged || $isLwwChanged) {
$existingSlot->update($updateData);
$existingSlot->refresh();
$delta = $newStock - $oldStock;
$note = $isProductChanged ? "movement.note.product_changed_and_adjusted" : "movement.note.replenishment_correction";
$this->recordStockMovement(
$existingSlot,
$delta,
MachineStockMovement::TYPE_ADJUSTMENT,
null,
$note,
['slot_no' => $existingSlot->slot_no, 'old' => $oldStock, 'new' => $newStock, 'old_product_id' => $oldProductId, 'new_product_id' => $actualProductId]
);
if ($isStockChanged || $isProductChanged) {
$delta = $newStock - $oldStock;
$note = $isProductChanged ? "movement.note.product_changed_and_adjusted" : "movement.note.replenishment_correction";
$this->recordStockMovement(
$existingSlot,
$delta,
MachineStockMovement::TYPE_ADJUSTMENT,
null,
$note,
['slot_no' => $existingSlot->slot_no, 'old' => $oldStock, 'new' => $newStock, 'old_product_id' => $oldProductId, 'new_product_id' => $actualProductId]
);
}
}
} else {
$newSlot = $machine->slots()->create(array_merge($updateData, ['slot_no' => $slotNo]));
@ -276,10 +406,12 @@ class MachineService
->values()
->toArray();
// 找出資料庫中屬於此機台但不在清單內的舊貨道
$orphanedSlots = $machine->slots()
->whereNotIn('slot_no', $reportedSlotNos)
->get();
// 防呆:上報清單為空時不執行全量刪除(避免機台/APP 誤報空清單而把所有貨道清光)。
$orphanedSlots = empty($reportedSlotNos)
? collect()
: $machine->slots()
->whereNotIn('slot_no', $reportedSlotNos)
->get();
foreach ($orphanedSlots as $orphan) {
$oldStock = (int) $orphan->stock;
@ -304,6 +436,150 @@ class MachineService
});
}
/**
* 空字串/"null" 一律歸一化為 null(避免 MySQL date 欄位寫入空字串解析爆)。
*/
private function blankToNull($value)
{
if (is_string($value)) {
$value = trim($value);
}
if ($value === '' || $value === null || (is_string($value) && strtolower($value) === 'null')) {
return null;
}
return $value;
}
/**
* 取日期字串的 yyyy-MM-dd 部分(容錯後台/機台帶了時間),空值回 null
*/
private function normalizeDate($value)
{
$value = $this->blankToNull($value);
if ($value === null) {
return null;
}
return substr((string) $value, 0, 10);
}
/**
* 貨道鎖定(lock)的雙向 LWW 解析,回傳要寫入的 is_locked / last_app_lock_rev。
* - 新貨道 App 現場值。
* - 既有貨道 常態 LWWApp rev 較大才採用,否則保留後台值。
* 既有機台升級時App onUpgrade 已把現場既有鎖定的 lock_rev 設為 1,故能 1>0 自動遷移上雲。)
*/
private function resolveSlotLockLww(?MachineSlot $existingSlot, array $slotData): array
{
$appLocked = (bool) ($slotData['is_locked'] ?? false);
$appRev = (int) ($slotData['lock_rev'] ?? 0);
if (!$existingSlot) {
$newRev = $appRev > 0 ? $appRev : ($appLocked ? 1 : 0);
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $newRev];
}
$curLocked = (bool) $existingSlot->is_locked;
$curRev = (int) $existingSlot->last_app_lock_rev;
if ($appRev > $curRev) {
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $appRev];
}
return ['is_locked' => $curLocked, 'last_app_lock_rev' => $curRev];
}
/**
* 效期/批號(expiry/batch)的雙向 LWW 解析,回傳要寫入的 expiry_date / batch_no / last_app_expiry_rev。
* 換商品(product_id 變更)時預設清空舊效期(安全網),除非 App 同筆帶了較新的新效期。
*/
private function resolveSlotExpiryLww(?MachineSlot $existingSlot, array $slotData, $actualProductId): array
{
$appExpiry = $this->normalizeDate($slotData['expiry_date'] ?? null);
$appBatch = $this->blankToNull($slotData['batch_no'] ?? null);
$appRev = (int) ($slotData['expiry_rev'] ?? 0);
if (!$existingSlot) {
$newRev = $appRev > 0 ? $appRev : ($appExpiry !== null ? 1 : 0);
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $newRev];
}
$curExpiry = $existingSlot->expiry_date ? $existingSlot->expiry_date->format('Y-m-d') : null;
$curBatch = $this->blankToNull($existingSlot->batch_no);
$curRev = (int) $existingSlot->last_app_expiry_rev;
// 換商品安全網:預設清空,除非 App 同筆帶了較新的新效期
$isProductChanged = ((string) ($existingSlot->product_id) !== (string) $actualProductId);
if ($isProductChanged) {
if ($appExpiry !== null && $appRev > $curRev) {
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
}
return ['expiry_date' => null, 'batch_no' => null, 'last_app_expiry_rev' => max($curRev, $appRev)];
}
if ($appRev > $curRev) {
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
}
return ['expiry_date' => $curExpiry, 'batch_no' => $curBatch, 'last_app_expiry_rev' => $curRev];
}
public function syncProductSlotMaxStock(Product $product): void
{
MachineSlot::query()
->where('product_id', $product->id)
->with(['machine' => fn($query) => $query->withoutGlobalScopes()])
->chunkById(500, function ($slots) use ($product) {
foreach ($slots as $slot) {
if (!$slot->machine) {
continue;
}
$slot->update([
'type' => $this->resolveSlotType($slot->machine, $slot->slot_no),
'max_stock' => $this->calculateSlotMaxStock($product, $slot->machine, $slot->slot_no),
]);
}
});
}
public function syncMachineSlotMaxStock(Machine $machine): void
{
$machine->slots()
->with(['product' => fn($query) => $query->withoutGlobalScopes()])
->chunkById(500, function ($slots) use ($machine) {
foreach ($slots as $slot) {
if (!$slot->product) {
continue;
}
$slot->update([
'type' => $this->resolveSlotType($machine, $slot->slot_no),
'max_stock' => $this->calculateSlotMaxStock($slot->product, $machine, $slot->slot_no),
]);
}
});
}
public function calculateSlotMaxStock(Product $product, Machine $machine, string|int|null $slotNo): int
{
return $this->isSpringSlot($machine, $slotNo)
? (int) $product->spring_limit
: (int) $product->track_limit;
}
public function resolveSlotType(Machine $machine, string|int|null $slotNo): string
{
return $this->isSpringSlot($machine, $slotNo) ? '2' : '1';
}
public function isSpringSlot(Machine $machine, string|int|null $slotNo): bool
{
$slotNumber = (int) $slotNo;
foreach (self::SPRING_SLOT_FLAGS as [$start, $end, $field]) {
if ($slotNumber >= $start && $slotNumber <= $end) {
return (bool) $machine->{$field};
}
}
return false;
}
/**
* Update machine slot stock, expiry, and batch.
*
@ -330,27 +606,55 @@ class MachineService
->first();
if ($pendingCommand) {
// 直接覆蓋:將前一個尚未執行的指令標記為「已取代」
// 重新下發時覆蓋前一筆尚未執行的指令。
// 若舊指令已超過 1 分鐘未收到機台回傳 (ACK),視為逾時 (timeout)
// 否則為使用者主動覆蓋 (superseded)。與 dispense 逾時邏輯一致。
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
$pendingCommand->update([
'status' => 'superseded',
'note' => __('Superseded by new command'),
'status' => $isTimedOut ? 'timeout' : 'superseded',
'note' => $isTimedOut
? __('Superseded by new command (Timeout)')
: __('Superseded by new command'),
]);
}
// 紀錄舊數據以供回滾使用
$oldExpiry = $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null;
$oldData = [
'stock' => $slot->stock,
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
'expiry_date' => $oldExpiry,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
];
// 2. 執行樂觀更新 (Optimistic Update)
$updateData = [
'expiry_date' => $expiryDate,
'batch_no' => $batchNo,
];
if ($stock !== null) $updateData['stock'] = (int)$stock;
$slot->update($updateData);
// 2. 執行樂觀更新 (Optimistic Update):只更新「明確帶上的欄位」,避免鎖定切換誤清效期
$updateData = [];
$normExpiry = $oldExpiry;
if (array_key_exists('expiry_date', $data)) {
$normExpiry = $this->normalizeDate($expiryDate);
$updateData['expiry_date'] = $normExpiry;
}
$normBatch = $slot->batch_no;
if (array_key_exists('batch_no', $data)) {
$normBatch = $this->blankToNull($batchNo);
$updateData['batch_no'] = $normBatch;
}
if ($stock !== null) $updateData['stock'] = (int) $stock;
if (array_key_exists('is_locked', $data)) {
$updateData['is_locked'] = (bool) $data['is_locked'];
}
// 後台編輯效期/批號或鎖定 → 遞增對應 rev成為權威值下行帶給 App 對齊 synced_rev
$expiryEdited = ($normExpiry !== $oldExpiry) || ($normBatch !== $slot->batch_no);
if ($expiryEdited) {
$updateData['last_app_expiry_rev'] = (int) $slot->last_app_expiry_rev + 1;
}
$lockEdited = array_key_exists('is_locked', $data) && ((bool) $data['is_locked'] !== (bool) $slot->is_locked);
if ($lockEdited) {
$updateData['last_app_lock_rev'] = (int) $slot->last_app_lock_rev + 1;
}
if (!empty($updateData)) $slot->update($updateData);
// 3. 若庫存數值有異動,記錄 adjustment 流水帳
if ($stock !== null && (int)$stock !== $oldData['stock']) {
@ -366,6 +670,8 @@ class MachineService
);
}
$slot->refresh();
// 3. 建立遠端指令紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
@ -376,19 +682,23 @@ class MachineService
'slot_no' => (string)$slotNo,
'old' => $oldData,
'new' => [
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
'expiry_date' => $expiryDate ?: null,
'batch_no' => $batchNo ?: null,
'stock' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
]
]
]);
// 4. 推播 MQTT
// 4. 推播 MQTT(一律帶上目前權威的 is_locked 與雙 rev讓 App 端對齊 synced_rev
$mqttPayload = [
'slot_no' => $command->payload['slot_no'],
'stock' => $command->payload['new']['stock'],
'expiry_date' => $command->payload['new']['expiry_date'],
'batch_no' => $command->payload['new']['batch_no'] ?? null,
'slot_no' => (string) $slotNo,
'stock' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
'lock_rev' => (int) $slot->last_app_lock_rev,
'expiry_rev' => (int) $slot->last_app_expiry_rev,
];
app(\App\Services\Machine\MqttService::class)->pushCommand(

View File

@ -0,0 +1,248 @@
<?php
namespace App\Services\Notification;
use App\Models\Machine\MachineLog;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DiscordWebhookService
{
// B013 硬體代碼翻譯統一由 MachineService::ERROR_CODE_MAP 管理 (Single Source of Truth)
/**
* 發送測試通知
*/
public function sendTestAlert(string $url): bool
{
$payload = [
'username' => 'Star Cloud Alert Bot',
'embeds' => [
[
'title' => '🎉 Star Cloud 測試通知',
'description' => "這是一則來自 Star Cloud 後台的 Discord Webhook 連線測試通知。\n如果您收到這則訊息,代表您的 Webhook 設定已順利生效並正常運作!",
'color' => 561586, // cyan-600 (0x0891B2)
'fields' => [
[
'name' => '測試時間 (台北時間)',
'value' => now()->timezone('Asia/Taipei')->format('Y-m-d H:i:s'),
'inline' => true,
],
[
'name' => '發送人員',
'value' => auth()->user() ? auth()->user()->name : 'System',
'inline' => true,
],
],
'footer' => [
'text' => 'Star Cloud Notification Subsystem',
],
'timestamp' => now()->toISOString(),
]
]
];
return $this->postToWebhook($url, $payload);
}
/**
* 發送機台日誌告警
*/
public function sendMachineLogAlert(string $url, MachineLog $log): bool
{
$machine = $log->machine;
if (!$machine) {
return false;
}
// 1. 智慧語系切換與還原
$company = $machine->company;
$companyLocale = $company ? ($company->settings['locale'] ?? 'zh_TW') : 'zh_TW';
$originalLocale = app()->getLocale();
app()->setLocale($companyLocale);
try {
$title = '';
$description = '';
$color = 561586; // 預設藍色 (cyan-600)
$fields = [];
// 根據日誌類別與內容動態建構卡片
if ($log->type === 'temperature') {
$temp = $log->context['temp'] ?? 'N/A';
$limit = $log->context['limit'] ?? 'N/A';
if (str_contains($log->message, 'too high')) {
$title = '🔴 ' . $this->cleanTranslation(__('Temperature Alert (High)'), $companyLocale);
$description = $this->cleanTranslation(__('Current temperature (:temp°C) is above the upper limit (:limit°C).', ['temp' => $temp, 'limit' => $limit]), $companyLocale);
$color = 14753096; // rose-600
$fields[] = [
'name' => $this->cleanTranslation(__('Current Temperature'), $companyLocale),
'value' => "`{$temp}°C`",
'inline' => true,
];
$fields[] = [
'name' => $this->cleanTranslation(__('Temperature Limit'), $companyLocale),
'value' => "`{$limit}°C`",
'inline' => true,
];
} elseif (str_contains($log->message, 'too low')) {
$title = '🔴 ' . $this->cleanTranslation(__('Temperature Alert (Low)'), $companyLocale);
$description = $this->cleanTranslation(__('Current temperature (:temp°C) is below the lower limit (:limit°C).', ['temp' => $temp, 'limit' => $limit]), $companyLocale);
$color = 14753096; // rose-600 (與過高告警一致使用紅色)
$fields[] = [
'name' => $this->cleanTranslation(__('Current Temperature'), $companyLocale),
'value' => "`{$temp}°C`",
'inline' => true,
];
$fields[] = [
'name' => $this->cleanTranslation(__('Temperature Limit'), $companyLocale),
'value' => "`{$limit}°C`",
'inline' => true,
];
} else {
$title = '🟢 ' . $this->cleanTranslation(__('Temperature Restored'), $companyLocale);
$description = $this->cleanTranslation(__('Current temperature has returned to normal range (:temp°C).', ['temp' => $temp]), $companyLocale);
$color = 366185; // emerald-600
$fields[] = [
'name' => $this->cleanTranslation(__('Current Temperature'), $companyLocale),
'value' => "`{$temp}°C`",
'inline' => true,
];
}
} elseif ($log->type === 'status') {
if ($log->message === 'Connection lost (LWT)') {
$title = '🔴 ' . $this->cleanTranslation(__('Machine Connectivity Lost'), $companyLocale);
$description = $this->cleanTranslation(__('System detected that the machine disconnected unexpectedly.'), $companyLocale);
$color = 14753096; // rose-600 (0xE11D48)
} elseif ($log->message === 'Connection restored') {
$title = '🟢 ' . $this->cleanTranslation(__('Machine Connectivity Restored'), $companyLocale);
$description = $this->cleanTranslation(__('Machine has reconnected to the server and resumed normal heartbeat.'), $companyLocale);
$color = 366185; // emerald-600 (0x059669)
} else {
$title = ' ' . $this->cleanTranslation(__('Machine Status Updated'), $companyLocale);
$description = $this->cleanTranslation(__($log->message), $companyLocale);
$color = 14251782; // warning/amber
}
} elseif ($log->type === 'submachine') {
// 解析 B013 Payload可能包含 tid (貨道) 與 error_code
$data = is_array($log->context) ? $log->context : json_decode($log->context ?? '', true);
$errorCode = $data['error_code'] ?? $data['raw_code'] ?? 'Unknown';
$slotNo = $data['tid'] ?? 'N/A';
// 智慧異常對譯:查詢 MachineService::ERROR_CODE_MAP
$mapping = \App\Services\Machine\MachineService::ERROR_CODE_MAP[$errorCode] ?? null;
if ($mapping) {
$translatedError = __($mapping['label']);
} else {
$translatedError = $data['translated_label'] ?? $errorCode;
$translatedError = __($translatedError);
}
$icon = ($log->level === 'error') ? '🚨 ' : '⚠️ ';
$title = $icon . $this->cleanTranslation(__('Submachine Exception'), $companyLocale);
$description = $this->cleanTranslation(__('Vending machine reported a submachine hardware error.'), $companyLocale);
$color = ($log->level === 'error') ? 14753096 : 14251782;
$fields[] = [
'name' => __('Error Code'),
'value' => "`{$errorCode}`",
'inline' => true,
];
$fields[] = [
'name' => __('Error Details'),
'value' => $this->cleanTranslation($translatedError, $companyLocale),
'inline' => true,
];
$fields[] = [
'name' => __('Affected Slot'),
'value' => "`{$slotNo}`",
'inline' => true,
];
} else {
// 其他類型的日誌
$title = '📢 ' . $this->cleanTranslation(__('Machine Status Updated'), $companyLocale);
$description = $this->cleanTranslation(__($log->message), $companyLocale);
$color = ($log->level === 'error') ? 14753096 : (($log->level === 'warning') ? 14251782 : 561586);
}
// 加入機台基本欄位資訊
$fields[] = [
'name' => __('Machine Name'),
'value' => $machine->name ?? __('Unnamed Machine'),
'inline' => true,
];
$fields[] = [
'name' => __('Serial No'),
'value' => "`{$machine->serial_no}`",
'inline' => true,
];
// 所屬公司欄位依使用者要求完全移除
if ($machine->location) {
$fields[] = [
'name' => __('Location'),
'value' => $machine->location,
'inline' => false,
];
}
$payload = [
'username' => 'Star Cloud Alert Bot',
'embeds' => [
[
'title' => $title,
'description' => $description,
'color' => $color,
'fields' => $fields,
'footer' => [
'text' => 'Star Cloud Telemetry Subsystem',
],
'timestamp' => $log->created_at ? $log->created_at->toISOString() : now()->toISOString(),
]
]
];
return $this->postToWebhook($url, $payload);
} finally {
// 強制還原語系,防止系統語系污染
app()->setLocale($originalLocale);
}
}
/**
* 清理翻譯字串,移除末尾或中間的英文技術括號說明(如 "(K-PDT)""(LWT)" 等)
*/
protected function cleanTranslation(string $text, string $locale): string
{
if ($locale === 'en') {
return $text;
}
// 匹配半角及全角括號,內含英數字、連字號、空格等,例如 " (K-PDT)" 或 " LWT"
return preg_replace('/\s*[\(\][A-Za-z0-9\-_\s]+[\)\]/u', '', $text);
}
/**
* HTTP POST 請求 Discord Webhook
*/
protected function postToWebhook(string $url, array $payload): bool
{
try {
$response = Http::timeout(5)->post($url, $payload);
if ($response->successful()) {
return true;
}
Log::error('Discord Webhook Failed: ' . $response->body());
return false;
} catch (\Exception $e) {
Log::error('Discord Webhook Exception: ' . $e->getMessage());
return false;
}
}
}

View File

@ -2,7 +2,10 @@
namespace App\Services\Product;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineProductPrice;
use App\Models\Product\Product;
use App\Models\System\Company;
use Illuminate\Support\Facades\Cache;
class ProductCatalogService
@ -44,11 +47,14 @@ class ProductCatalogService
*/
public function rebuildCache(?int $companyId): array
{
// 公司機台語系聯集:商品 i18n map 下發的語系集合(見 product_multilingual_spec §5.1
$locales = Company::activeLocalesFor($companyId);
$products = Product::where('company_id', $companyId)
->with(['translations'])
->with(['translations', 'specTranslations'])
->active()
->get()
->map(fn($p) => $this->mapProduct($p));
->map(fn($p) => $this->mapProduct($p, $locales));
$payload = [
'success' => true,
@ -73,12 +79,65 @@ class ProductCatalogService
}
/**
* Map a Product model to the catalog format expected by machines.
* 取得「該機台」的商品目錄:公司基底目錄(快取) + 機台專屬定價覆蓋。
*
* @param Product $product
* 設計取捨:重的 i18n 目錄留在 per-company 快取;機台覆蓋於請求當下即時套用,
* 不另設機台快取。覆蓋只是一次 (machine_id) 的索引查詢,極輕量;且改價僅推「該台」
* 重抓,不會造成多台群湧。如此完全避開「機台快取失效 / 公司基底變動連帶失效」的複雜度。
*
* @param Machine $machine
* @return array
*/
private function mapProduct($product): array
public function getMachinePayload(Machine $machine): array
{
$payload = $this->getPayload($machine->company_id);
$overrides = MachineProductPrice::where('machine_id', $machine->id)
->get(['product_id', 'price', 'member_price'])
->keyBy('product_id');
if ($overrides->isEmpty()) {
return $payload;
}
// PHP 陣列為值複製array_map 產生新陣列,不會污染 Cache 內的公司基底目錄。
$payload['data'] = array_map(function ($item) use ($overrides) {
$override = $overrides->get((int) ($item['t060v00'] ?? 0));
if (!$override) {
return $item;
}
// 機台售價t063v03=App machinePrice有覆蓋價就用否則維持全域售價基底。
$sellingPrice = $override->price !== null
? (float) $override->price
: (float) $item['t063v03'];
$item['t063v03'] = $sellingPrice;
// 機台會員價t060v30有覆蓋就用否則沿用全域會員價
// 一律夾為「不高於機台售價」,避免會員買得比一般人貴。
$memberPrice = $override->member_price !== null
? (float) $override->member_price
: (float) $item['t060v30'];
$item['t060v30'] = min($memberPrice, $sellingPrice);
return $item;
}, $payload['data']);
return $payload;
}
/**
* Map a Product model to the catalog format expected by machines.
*
* 相容鐵則既有欄位t060v01 / t060v01_en / t060v01_jp / t060v03 …)一律保留,
* 僅「新增」locale-keyed t060v01_i18n名稱 t060v03_i18n規格
* 線上舊 App 忽略新欄位、照舊運作;新版 App *_i18n 以支援多語系切換。
*
* @param Product $product
* @param array<int,string> $locales 公司機台語系聯集i18n map 下發的語系集合)
* @return array
*/
private function mapProduct($product, array $locales = ['zh_TW']): array
{
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
@ -88,7 +147,9 @@ class ProductCatalogService
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v01_i18n' => $this->buildI18nMap($product->translations, $product->name, $locales),
't060v03' => $product->spec ?? '',
't060v03_i18n' => $this->buildI18nMap($product->specTranslations, $product->spec ?? '', $locales),
't060v06' => $product->image_url
? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url))
: '',
@ -102,4 +163,29 @@ class ProductCatalogService
't063v03' => (float) $product->price,
];
}
/**
* 依公司機台語系聯集,建立 locale 文字 i18n map。
*
* - 僅輸出 $locales 內的語系。
* - 缺翻譯時回退至 zh_TW再回退至 $fallbackTextproducts.name / spec 主值),
* 確保不下發空字串,避免機台顯示空白。
*
* @param \Illuminate\Support\Collection $translations 該欄位的翻譯集合
* @param string|null $fallbackText 最終回退文字(主欄位值)
* @param array<int,string> $locales 公司機台語系聯集
* @return array<string,string>
*/
private function buildI18nMap($translations, ?string $fallbackText, array $locales): array
{
$zhFallback = $translations->firstWhere('locale', 'zh_TW')?->value
?? ($fallbackText ?? '');
$map = [];
foreach ($locales as $locale) {
$map[$locale] = $translations->firstWhere('locale', $locale)?->value ?: $zhFallback;
}
return $map;
}
}

View File

@ -0,0 +1,413 @@
<?php
namespace App\Services;
use App\Models\Product\Product;
use App\Models\Product\ProductCategory;
use App\Models\System\SystemOperationLog;
use App\Models\System\Translation;
use App\Traits\ImageHandler;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use OpenSpout\Reader\XLSX\Reader;
use OpenSpout\Writer\XLSX\Writer;
use OpenSpout\Common\Entity\Row;
class ProductImportService
{
use ImageHandler;
/**
* @param string|null $imageDir 已解壓的圖片資料夾路徑 (Excel「圖片檔名」欄對應此資料夾內檔案)
* @param bool $deferImages true 時不在此同步轉檔,改將 (product_id, filename) 收集到 results['pending_images']
* 供呼叫端丟背景 Job 處理,避免大量圖片同步轉檔逾時。
*/
public function import(string $filePath, ?int $companyId = null, ?int $userId = null, ?string $imageDir = null, bool $deferImages = false): array
{
$reader = new Reader();
$reader->open($filePath);
$results = [
'success_count' => 0,
'created_count' => 0,
'updated_count' => 0,
'error_count' => 0,
'errors' => [],
'company_ids' => [],
'image_success' => 0,
'image_missing' => [],
'pending_images' => [],
];
$rowCount = 0;
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$rowCount++;
if ($rowCount === 1) {
continue;
}
$cells = $row->toArray();
$data = $this->normalizeRow($cells, $companyId);
if ($this->isEmptyRow($data)) {
continue;
}
$validator = Validator::make($data, [
'company_id' => 'nullable|exists:companies,id',
'category' => 'nullable|string|max:255',
'name_zh_TW' => 'required|string|max:255',
'name_en' => 'nullable|string|max:255',
'name_ja' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'manufacturer' => 'nullable|string|max:255',
'price' => 'required|numeric|min:0',
'member_price' => 'nullable|numeric|min:0',
'cost' => 'required|numeric|min:0',
'track_limit' => 'nullable|integer|min:0',
'spring_limit' => 'nullable|integer|min:0',
'material_code' => 'nullable|string|max:255',
'points_full' => 'nullable|integer|min:0',
'points_half' => 'nullable|integer|min:0',
'points_half_amount' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
'image_filename' => 'nullable|string|max:255',
]);
if ($validator->fails()) {
$this->appendError($results, $rowCount, $validator->errors()->all());
continue;
}
try {
DB::transaction(function () use ($data, $rowCount, $userId, $imageDir, $deferImages, &$results) {
$categoryId = $this->resolveCategoryId($data['category'], $data['company_id']);
$lookup = $this->buildProductLookup($data);
$product = Product::withoutGlobalScopes()->where($lookup)->first();
$oldValues = $product?->toArray();
$dictKey = $product?->name_dictionary_key ?: Str::uuid()->toString();
$this->syncTranslations($dictKey, $data, $data['company_id']);
$payload = [
'company_id' => $data['company_id'],
'category_id' => $categoryId,
'name' => $data['name_zh_TW'],
'name_dictionary_key' => $dictKey,
'barcode' => $data['barcode'],
'spec' => $data['spec'],
'manufacturer' => $data['manufacturer'],
'track_limit' => $data['track_limit'],
'spring_limit' => $data['spring_limit'],
'price' => $data['price'],
'cost' => $data['cost'],
'member_price' => $data['member_price'],
'metadata' => [
'material_code' => $data['material_code'],
'points_full' => $data['points_full'],
'points_half' => $data['points_half'],
'points_half_amount' => $data['points_half_amount'],
],
'is_active' => $data['is_active'],
];
if ($product) {
$product->update($payload);
$results['updated_count']++;
$action = 'update';
$note = 'product_import_updated';
} else {
$product = Product::withoutGlobalScopes()->create($payload);
$results['created_count']++;
$action = 'create';
$note = 'product_import_created';
}
if ($deferImages) {
if ($imageDir && $data['image_filename']) {
$results['pending_images'][] = [
'product_id' => $product->id,
'filename' => $data['image_filename'],
'row' => $rowCount,
];
}
} else {
$this->attachImage($product, $data['image_filename'], $imageDir, $rowCount, $results);
}
$results['success_count']++;
if ($product->company_id) {
$results['company_ids'][$product->company_id] = $product->company_id;
}
SystemOperationLog::create([
'company_id' => $product->company_id,
'user_id' => $userId,
'module' => 'product',
'action' => $action,
'target_id' => $product->id,
'target_type' => Product::class,
'note' => $note,
'old_values' => $oldValues,
'new_values' => array_merge($product->fresh()->toArray(), ['import_row' => $rowCount]),
]);
});
} catch (\Exception $e) {
$this->appendError($results, $rowCount, [$e->getMessage()]);
}
}
}
$reader->close();
$results['company_ids'] = array_values($results['company_ids']);
return $results;
}
public function downloadTemplate()
{
$writer = new Writer();
$writer->openToBrowser('products_template.xlsx');
$writer->addRow(Row::fromValues([
'分類 ID 或名稱(選填)',
'商品名稱 zh_TW必填',
'商品名稱 en選填',
'商品名稱 ja選填',
'商品條碼(選填,用於更新)',
'規格(選填)',
'生產公司(選填)',
'售價(必填)',
'會員價(選填)',
'成本(必填)',
'履帶貨道上限(選填,預設 15',
'彈簧貨道上限(選填,預設 15',
'物料代碼(選填)',
'全額點數(選填)',
'半額點數(選填)',
'半額點數折抵金額(選填)',
'是否啟用1/0選填',
'圖片檔名(選填,需一併上傳圖片壓縮檔)',
]));
$writer->addRow(Row::fromValues([
'飲料',
'經典拿鐵',
'Classic Latte',
'クラシックラテ',
'471000000001',
'350ml',
'Star Cloud',
80,
75,
45,
15,
15,
'MAT-LATTE-001',
800,
400,
40,
1,
'latte.png',
]));
$writer->close();
}
private function normalizeRow(array $cells, ?int $companyId): array
{
return [
'company_id' => $companyId,
'category' => $this->stringValue($cells[0] ?? null),
'name_zh_TW' => $this->stringValue($cells[1] ?? null),
'name_en' => $this->stringValue($cells[2] ?? null),
'name_ja' => $this->stringValue($cells[3] ?? null),
'barcode' => $this->stringValue($cells[4] ?? null),
'spec' => $this->stringValue($cells[5] ?? null),
'manufacturer' => $this->stringValue($cells[6] ?? null),
'price' => $this->numericValue($cells[7] ?? null, null),
'member_price' => $this->numericValue($cells[8] ?? null, 0),
'cost' => $this->numericValue($cells[9] ?? null, null),
'track_limit' => (int) $this->numericValue($cells[10] ?? null, 15),
'spring_limit' => (int) $this->numericValue($cells[11] ?? null, 15),
'material_code' => $this->stringValue($cells[12] ?? null),
'points_full' => (int) $this->numericValue($cells[13] ?? null, 0),
'points_half' => (int) $this->numericValue($cells[14] ?? null, 0),
'points_half_amount' => (int) $this->numericValue($cells[15] ?? null, 0),
'is_active' => $this->booleanValue($cells[16] ?? null),
'image_filename' => $this->stringValue($cells[17] ?? null),
];
}
/**
* 依「圖片檔名」欄將已解壓資料夾中的圖片轉 WebP 入庫並寫回 image_url。
* 失敗 (找不到檔/非圖) 只記錄至 image_missing不影響商品本身寫入。
*/
private function attachImage(Product $product, ?string $filename, ?string $imageDir, int $rowCount, array &$results): void
{
if (!$filename || !$imageDir) {
return;
}
try {
$path = rtrim($imageDir, '/') . '/' . basename($filename);
if (!is_file($path) || getimagesize($path) === false) {
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
return;
}
$oldImage = $product->image_url;
$file = new UploadedFile($path, basename($path), null, null, true);
$storedPath = $this->storeAsWebp($file, 'products', 80, 320, 320);
$product->update(['image_url' => Storage::url($storedPath)]);
if ($oldImage) {
$oldPath = str_replace('/storage/', '', $oldImage);
Storage::disk('public')->delete($oldPath);
}
$results['image_success']++;
} catch (\Throwable $e) {
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
}
}
private function isEmptyRow(array $data): bool
{
return empty($data['category'])
&& empty($data['name_zh_TW'])
&& empty($data['barcode'])
&& $data['price'] === null
&& $data['cost'] === null;
}
private function resolveCategoryId(?string $category, ?int $companyId): ?int
{
if (!$category) {
return null;
}
$query = ProductCategory::withoutGlobalScopes()
->where(function ($q) use ($companyId) {
$q->where('company_id', $companyId)->orWhereNull('company_id');
});
if (ctype_digit($category)) {
$found = (clone $query)->where('id', (int) $category)->first();
if ($found) {
return $found->id;
}
}
$found = (clone $query)
->where(function ($q) use ($category) {
$q->where('name', $category)
->orWhereHas('translations', function ($translationQuery) use ($category) {
$translationQuery->withoutGlobalScopes()->where('value', $category);
});
})
->first();
if (!$found) {
throw new \InvalidArgumentException(__('Category not found: :category', ['category' => $category]));
}
return $found->id;
}
private function buildProductLookup(array $data): array
{
if (!empty($data['barcode'])) {
return [
'company_id' => $data['company_id'],
'barcode' => $data['barcode'],
];
}
return [
'company_id' => $data['company_id'],
'name' => $data['name_zh_TW'],
];
}
private function syncTranslations(string $dictKey, array $data, ?int $companyId): void
{
$names = [
'zh_TW' => $data['name_zh_TW'],
'en' => $data['name_en'],
'ja' => $data['name_ja'],
];
foreach ($names as $locale => $name) {
if (empty($name)) {
Translation::withoutGlobalScopes()->where([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
])->delete();
continue;
}
Translation::withoutGlobalScopes()->updateOrCreate(
[
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
],
[
'value' => $name,
'company_id' => $companyId,
]
);
}
}
private function appendError(array &$results, int $rowCount, array $messages): void
{
$results['error_count']++;
$results['errors'][] = [
'row' => $rowCount,
'messages' => $messages,
];
}
private function stringValue(mixed $value): ?string
{
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function numericValue(mixed $value, mixed $default): mixed
{
if ($value === null || $value === '') {
return $default;
}
return is_numeric($value) ? $value : $value;
}
private function booleanValue(mixed $value): bool
{
if ($value === null || $value === '') {
return true;
}
if (is_bool($value)) {
return $value;
}
$normalized = strtolower(trim((string) $value));
return in_array($normalized, ['1', 'true', 'yes', 'y', 'active', 'enabled', '啟用', '是'], true);
}
}

View File

@ -44,10 +44,10 @@ class StaffCardImportService
// Expecting: 0:employee_id, 1:name, 2:card_uid, 3:notes
$data = [
'employee_id' => isset($cells[0]) ? (string)$cells[0] : null,
'name' => isset($cells[1]) ? (string)$cells[1] : null,
'card_uid' => isset($cells[2]) ? (string)$cells[2] : null,
'notes' => isset($cells[3]) ? (string)$cells[3] : null,
'employee_id' => isset($cells[0]) ? trim((string)$cells[0]) : null,
'name' => isset($cells[1]) ? trim((string)$cells[1]) : null,
'card_uid' => isset($cells[2]) ? trim((string)$cells[2]) : null,
'notes' => isset($cells[3]) ? trim((string)$cells[3]) : null,
'company_id' => $companyId,
];

View File

@ -0,0 +1,304 @@
<?php
namespace App\Services\Transaction;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineSlot;
use App\Models\Product\Product;
use App\Models\System\SystemOperationLog;
use App\Models\Transaction\Order;
use App\Models\Transaction\PickupCode;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* 領藥單建立服務。
*
* 設計重點(對齊交叉檢查修正):
* - 領藥單序號 = orders.flow_id唯一讓後續出貨 finalize 能套用既有終態冪等保護。
* - 建單時「不」預扣庫存;庫存僅在機台出貨 finalize 時扣一次(避免雙重扣減)。
* - 以「商品」為單位建單,貨道於 B660 掃碼時再由 product_id × machine_slots 解析(一張單可多貨道)。
*/
class PharmacyPickupService
{
/** 領藥單支付類型碼:沿用 42 = 領藥憑證 (Pickup Voucher)。 */
public const PAYMENT_TYPE_PHARMACY = 42;
/**
* 取得某機台「已生成但尚未領取(awaiting_pickup)」的領藥單預留量(依商品彙總)。
* 用於建單時「預扣」顯示與防超領:可用 = 實體庫存 預留量。
*/
public function reservedQtyMap(Machine $machine): array
{
$map = [];
$orders = Order::where('machine_id', $machine->id)
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
->where('status', Order::STATUS_AWAITING_PICKUP)
->with('items')
->get();
foreach ($orders as $order) {
foreach ($order->items as $item) {
$map[$item->product_id] = ($map[$item->product_id] ?? 0) + (int) $item->quantity;
}
}
return $map;
}
/**
* 取得某機台目前「有庫存」的可領藥品(依商品彙總,跨貨道加總庫存)。
* available 已扣除「尚未領取的領藥單預留量」(生成領藥單即預扣)。
*/
public function availableProducts(Machine $machine): Collection
{
$reserved = $this->reservedQtyMap($machine);
return MachineSlot::where('machine_id', $machine->id)
->where('is_active', true)
->where('stock', '>', 0)
->with('product')
->get()
->groupBy('product_id')
->map(function ($slots) use ($reserved) {
$product = $slots->first()->product;
if (!$product) {
return null;
}
$physical = (int) $slots->sum('stock');
$reservedQty = (int) ($reserved[$product->id] ?? 0);
$available = max(0, $physical - $reservedQty);
return (object) [
'product_id' => $product->id,
'name' => $product->localized_name ?? $product->name,
'spec' => $product->localized_spec ?? $product->spec,
'barcode' => $product->barcode,
'price' => (float) ($product->price ?? 0),
'available' => $available, // 預扣後可用(= 實體 預留)
'physical' => $physical, // 實體庫存
'reserved' => $reservedQty, // 已生成領藥單預留量
'slot_count' => $slots->count(),
'slots' => $slots->pluck('slot_no')->implode(', '),
];
})
->filter()
->sortBy('name')
->values();
}
/**
* 建立一張領藥單Order(pharmacy_pickup) + N OrderItem + 1 PickupCode。
*
* @param array{machine_id:int, pricing_slip_no?:string, items:array<array{product_id:int, quantity:int}>, expires_at?:\DateTimeInterface} $data
*/
public function create(array $data, int $userId): Order
{
$machine = Machine::findOrFail($data['machine_id']);
$items = collect($data['items'] ?? [])
->map(fn ($i) => ['product_id' => (int) $i['product_id'], 'quantity' => (int) $i['quantity']])
->filter(fn ($i) => $i['quantity'] > 0)
->values();
if ($items->isEmpty()) {
throw ValidationException::withMessages([
'items' => __('Please select at least one medicine with quantity.'),
]);
}
return DB::transaction(function () use ($machine, $items, $data, $userId) {
$orderItemsData = [];
$total = 0.0;
// 既有「尚未領取領藥單」的預留量(本單尚未建立,不含本單)→ 可用 = 實體 預留,防超領。
$reserved = $this->reservedQtyMap($machine);
foreach ($items as $item) {
$product = Product::findOrFail($item['product_id']);
$qty = $item['quantity'];
// 該機台此商品跨貨道可用庫存 = 實體庫存 已生成領藥單預留量
$physical = (int) MachineSlot::where('machine_id', $machine->id)
->where('product_id', $product->id)
->where('is_active', true)
->sum('stock');
$available = $physical - (int) ($reserved[$product->id] ?? 0);
if ($qty > $available) {
throw ValidationException::withMessages([
'items' => __('Insufficient stock for :name (requested :qty, available :available).', [
'name' => $product->localized_name ?? $product->name,
'qty' => $qty,
'available' => $available,
]),
]);
}
$price = (float) ($product->price ?? 0);
$subtotal = $price * $qty;
$total += $subtotal;
$orderItemsData[] = [
'product_id' => $product->id,
'product_name' => $product->localized_name ?? $product->name,
'barcode' => $product->barcode,
'quantity' => $qty,
'price' => $price,
'subtotal' => $subtotal,
];
}
$serial = $this->generateSerial();
$order = Order::create([
'company_id' => $machine->company_id,
'flow_id' => $serial,
'order_no' => $serial,
'order_type' => Order::TYPE_PHARMACY_PICKUP,
'pricing_slip_no' => $data['pricing_slip_no'] ?? null,
'created_by' => $userId,
'machine_id' => $machine->id,
'payment_type' => self::PAYMENT_TYPE_PHARMACY,
'total_amount' => $total,
'discount_amount' => 0,
'pay_amount' => 0,
'change_amount' => 0,
'points_used' => 0,
'payment_status' => Order::PAYMENT_STATUS_SUCCESS,
'status' => Order::STATUS_AWAITING_PICKUP,
'delivery_status' => Order::DELIVERY_STATUS_FAILED, // 0尚未出貨
'machine_time' => now(),
]);
foreach ($orderItemsData as $oi) {
$order->items()->create($oi);
}
$pickupCode = PickupCode::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'slot_no' => null, // 多貨道:由 B660 掃碼時解析
'code' => PickupCode::generateUniqueCode($machine->id),
'status' => 'active',
'expires_at' => $data['expires_at'] ?? now()->endOfDay(), // 預設當日有效
'usage_limit' => 1,
'usage_count' => 0,
'created_by' => $userId,
'order_id' => $order->id,
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => $userId,
'module' => 'pharmacy_pickup',
'action' => 'create',
'target_id' => $order->id,
'target_type' => Order::class,
'new_values' => [
'order_no' => $order->order_no,
'pricing_slip_no' => $order->pricing_slip_no,
'pickup_code' => $pickupCode->code,
'items' => $orderItemsData,
],
]);
return $order->load(['items', 'pickupCode', 'machine']);
});
}
/**
* 檢查一張領藥單在「目前機台貨道庫存」下是否有商品庫存不足(無法整單出貨)。
* 回傳不足清單:[['product_name'=>, 'required'=>, 'available'=>], ...];空陣列表示可整單出貨。
* 用於 B660 掃碼出貨前的嚴格擋關:任何商品不足即整單擋下、不出貨、不消耗領藥碼。
*/
public function dispenseShortage(Order $order): array
{
$short = [];
foreach ($order->items as $orderItem) {
$available = (int) MachineSlot::where('machine_id', $order->machine_id)
->where('product_id', $orderItem->product_id)
->where('is_active', true)
->where('stock', '>', 0)
->sum('stock');
if ($available < (int) $orderItem->quantity) {
$short[] = [
'product_name' => $orderItem->product_name,
'required' => (int) $orderItem->quantity,
'available' => $available,
];
}
}
return $short;
}
/**
* 解析一張領藥單在「目前機台貨道庫存」下的出貨清單(一商品可跨多貨道湊量)。
* 回傳:[['slot_no'=>, 'product_id'=>, 'product_name'=>, 'qty'=>], ...]
* 庫存不足時盡力分配(出可出的數量;部分/失敗由出貨回報 Phase 標記)。
*/
public function resolveDispenseItems(Order $order): array
{
$result = [];
foreach ($order->items as $orderItem) {
$remaining = (int) $orderItem->quantity;
$slots = MachineSlot::where('machine_id', $order->machine_id)
->where('product_id', $orderItem->product_id)
->where('is_active', true)
->where('stock', '>', 0)
->orderByDesc('stock')
->orderBy('slot_no')
->get();
foreach ($slots as $slot) {
if ($remaining <= 0) {
break;
}
$take = min($remaining, (int) $slot->stock);
if ($take <= 0) {
continue;
}
$result[] = [
'slot_no' => $slot->slot_no,
'product_id' => $orderItem->product_id,
'product_name' => $orderItem->product_name,
'qty' => $take,
];
$remaining -= $take;
}
// $remaining > 0 表示目前庫存不足以完全滿足此商品,僅回傳可出的部分。
}
return $result;
}
/**
* 產生領藥單序號RX + 西元年月日 + 當日 4 碼流水(與 flow_id 唯一性衝突時自動遞增)。
*/
protected function generateSerial(): string
{
$datePart = now()->format('Ymd');
$base = Order::withoutGlobalScopes()
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
->whereDate('created_at', now()->toDateString())
->count();
$attempt = 0;
do {
$seq = $base + 1 + $attempt;
$serial = 'RX' . $datePart . str_pad((string) $seq, 4, '0', STR_PAD_LEFT);
$attempt++;
} while (
Order::withoutGlobalScopes()->where('flow_id', $serial)->exists()
&& $attempt < 100
);
return $serial;
}
}

View File

@ -12,6 +12,8 @@ use App\Models\StaffCardLog;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use Illuminate\Support\Facades\Log;
use App\Services\Machine\MachineService;
@ -32,18 +34,32 @@ class TransactionService
{
$flowId = $data['flow_id'] ?? null;
// 交易生命週期狀態payload 沒帶就預設 completed。
// → main(晟崴/中國醫)的成功 finalize 不帶 status仍得到 'completed',行為逐字不變。
$incomingStatus = $data['status'] ?? Order::STATUS_COMPLETED;
if ($flowId) {
$existingOrder = Order::withoutGlobalScopes()
->where('flow_id', $flowId)
->first();
if ($existingOrder) {
return $existingOrder;
// 鐵律二:終態(completed/failed/abandoned)一律冪等回傳,不可覆蓋。
// main 的單永遠是 completed → 等同原本「flow_id 已存在直接 return」的行為
// 重送/redelivery 也不會重複建立、不會重複扣庫存。
if (in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
return $existingOrder;
}
// 鐵律三:只有「既有單為 pending」才允許狀態轉移pending → completed/failed/abandoned
// 唯有「先送過 pending」的機台(本分支、flag on)會走到這main 從不產生 pending → 不可達。
return $this->transitionOrder($existingOrder, $data, $incomingStatus);
}
}
return DB::transaction(function () use ($data) {
return DB::transaction(function () use ($data, $incomingStatus) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$paymentStatus = (int) ($data['payment_status'] ?? Order::PAYMENT_STATUS_SUCCESS);
// Create Order
$order = Order::create([
'company_id' => $machine->company_id,
@ -55,47 +71,107 @@ class TransactionService
'discount_amount' => $data['discount_amount'] ?? 0,
'pay_amount' => $data['pay_amount'],
'change_amount' => $data['change_amount'] ?? 0,
// 現金面額明細(僅現金 payment_type=9 會帶;其餘為 null
'cash_detail' => $data['cash_detail'] ?? null,
'points_used' => $data['points_used'] ?? 0,
'original_amount' => $data['original_amount'] ?? $data['total_amount'],
'payment_type' => (int) ($data['payment_type'] ?? 0),
'payment_status' => $data['payment_status'] ?? 1,
'payment_status' => $paymentStatus,
'payment_request' => $data['payment_request'] ?? null,
'payment_response' => $data['payment_response'] ?? null,
'member_barcode' => $data['member_barcode'] ?? null,
'code_id' => $data['code_id'] ?? null,
'welcome_gift_id' => $data['welcome_gift_id'] ?? null,
'invoice_info' => $data['invoice_info'] ?? null,
'machine_time' => $data['machine_time'] ?? now(),
'payment_at' => now(),
'status' => 'completed',
// 付款成功才有 payment_atpending/failed 無實際付款時間。
// main = completed + payment_status=1 → now(),與原本一致。
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS ? now() : null,
'status' => $incomingStatus,
'metadata' => $data['metadata'] ?? null,
'delivery_status' => (int) ($data['delivery_status'] ?? Order::DELIVERY_STATUS_SUCCESS),
]);
// Create Order Items
if (!empty($data['items'])) {
foreach ($data['items'] as $item) {
$productName = $item['product_name'] ?? null;
// 如果沒傳名稱,嘗試從資料庫抓取
if (empty($productName)) {
$product = \App\Models\Product\Product::find($item['product_id']);
$productName = $product?->name ?? 'Unknown';
}
$order->items()->create([
'product_id' => $item['product_id'],
'product_name' => $productName,
'barcode' => $item['barcode'] ?? null,
'price' => $item['price'],
'quantity' => $item['quantity'],
'subtotal' => $item['price'] * $item['quantity'],
]);
}
}
$this->createOrderItems($order, $data['items'] ?? []);
return $order;
});
}
/**
* pending 單的狀態轉移pending completed/failed/abandoned
* 只更新付款結果相關欄位;商品明細在 pending 階段已建立,不重複建立(避免品項翻倍)。
*/
protected function transitionOrder(Order $order, array $data, string $newStatus): Order
{
return DB::transaction(function () use ($order, $data, $newStatus) {
$paymentStatus = isset($data['payment_status'])
? (int) $data['payment_status']
: (int) $order->payment_status;
$order->update([
'status' => $newStatus,
'payment_status' => $paymentStatus,
// payment_type 以本次上報為準:例如手機支付 pending 階段為 4(路由鍵)
// 成功 finalize 覆寫為 10(手機支付),轉移時需一併更新。
'payment_type' => isset($data['payment_type'])
? (int) $data['payment_type']
: $order->payment_type,
'payment_request' => $data['payment_request'] ?? $order->payment_request,
'payment_response' => $data['payment_response'] ?? $order->payment_response,
'pay_amount' => $data['pay_amount'] ?? $order->pay_amount,
// 商品總額/原始金額completed finalize 才帶正確金額(pending 階段機台送 0)
// 須比照 pay_amount 在轉移時一併更新,否則後台銷售紀錄金額會停在建立時的 0。
'total_amount' => $data['total_amount'] ?? $order->total_amount,
'original_amount' => $data['original_amount'] ?? $order->original_amount,
'discount_amount' => $data['discount_amount'] ?? $order->discount_amount,
'change_amount' => $data['change_amount'] ?? $order->change_amount,
// 現金面額明細completed finalize 才帶pending 階段無投幣結果)
'cash_detail' => $data['cash_detail'] ?? $order->cash_detail,
'points_used' => $data['points_used'] ?? $order->points_used,
'invoice_info' => $data['invoice_info'] ?? $order->invoice_info,
'delivery_status' => isset($data['delivery_status'])
? (int) $data['delivery_status']
: $order->delivery_status,
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS
? ($order->payment_at ?? now())
: $order->payment_at,
]);
// pending 階段若未帶明細,補建一次(防禦)。
if ($order->items()->count() === 0 && !empty($data['items'])) {
$this->createOrderItems($order, $data['items']);
}
return $order->fresh();
});
}
/** 建立訂單品項(沒帶名稱時回資料庫補抓)。 */
protected function createOrderItems(Order $order, array $items): void
{
foreach ($items as $item) {
$productName = $item['product_name'] ?? null;
// 如果沒傳名稱,嘗試從資料庫抓取
if (empty($productName)) {
$product = \App\Models\Product\Product::find($item['product_id']);
$productName = $product?->name ?? 'Unknown';
}
$order->items()->create([
'product_id' => $item['product_id'],
'product_name' => $productName,
// 消費者選擇的貨道/格子號App items[].slot_no 帶入);不管出貨成功或失敗都記錄。
'slot_no' => $item['slot_no'] ?? null,
'barcode' => $item['barcode'] ?? null,
'price' => $item['price'],
'quantity' => $item['quantity'],
'subtotal' => $item['price'] * $item['quantity'],
]);
}
}
/**
* Generate a unique order number.
*/
@ -110,11 +186,11 @@ class TransactionService
public function recordInvoice(array $data): Invoice
{
return DB::transaction(function () use ($data) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$machine = Machine::withoutGlobalScopes()->where('serial_no', $data['serial_no'])->firstOrFail();
$order = null;
if (!empty($data['flow_id'])) {
$order = Order::where('flow_id', $data['flow_id'])->first();
$order = Order::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
}
// 處理額外的發票資訊 (business_tax_id, carrier_type)
@ -124,22 +200,67 @@ class TransactionService
if (isset($data['carrier_type']))
$metadata['carrier_type'] = $data['carrier_type'];
return Invoice::create([
$invoiceNo = $data['invoice_no'] ?? null;
$rtnCode = $data['rtn_code'] ?? null;
// 狀態:優先採用 App 已判定的 status新 finalize 流程一定帶 status=pending否則才推導。
// 此推導分支目前僅 MQTT 'invoice' 指令(ProcessInvoice)會走App 暫未使用,屬防禦性。
// 重點:有發票號就代表已開立,除非「明確」收到失敗碼才算 failed
//(部分上報格式只帶 invoice_no、不帶 rtn_code不可因缺 rtn_code 就誤判失敗)。
// 綠界 RtnCode == "1" 為成功;其餘非空值才視為明確失敗。
$status = $data['status'] ?? null;
if (empty($status)) {
$explicitFailure = !empty($rtnCode) && (string) $rtnCode !== '1';
if (!empty($invoiceNo)) {
$status = $explicitFailure ? 'failed' : 'issued';
} else {
// 無發票號明確失敗→failed否則待開立/待查證→pending
$status = $explicitFailure ? 'failed' : 'pending';
}
}
// RelateNumber 冪等鍵App 未帶時由後台以 flow_id 推導(綠界上限 30 字、限英數)。
// flow_id 在 finalizeTransaction 入口已前綴機台序號(serial+rawFlowId)
// 故此處直接取用即可,輸出與線上既有 serial+rawFlowId 逐字等價,不可再重複加 serial。
$relateNumber = $data['relate_number'] ?? null;
if (empty($relateNumber) && !empty($data['flow_id'])) {
$relateNumber = substr($data['flow_id'], 0, 30);
}
$attributes = [
'company_id' => $machine->company_id,
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
'machine_id' => $machine->id,
'flow_id' => $data['flow_id'] ?? null,
'invoice_no' => $data['invoice_no'] ?? null,
'invoice_no' => $invoiceNo,
'status' => $status,
'relate_number' => $relateNumber,
'amount' => $data['amount'] ?? 0,
'carrier_id' => $data['carrier_id'] ?? null,
'invoice_date' => $data['invoice_date'] ?? null,
'machine_time' => $data['machine_time'] ?? null,
'random_number' => $data['random_number'] ?? ($data['random_no'] ?? null),
'love_code' => $data['love_code'] ?? null,
'rtn_code' => $data['rtn_code'] ?? null,
'rtn_code' => $rtnCode,
'rtn_msg' => $data['rtn_msg'] ?? null,
'metadata' => $metadata,
]);
];
// 去重:同一 flow_id一張訂單一張發票以 updateOrCreate 收斂,
// 避免 finalize 重送 / 補開回寫產生重複發票列。
if (!empty($data['flow_id'])) {
// 終態守衛(防禦縱深):已開立/已作廢的發票為終態,不得被後續寫入覆蓋,
// 避免任何重送(含 ProcessInvoice/MQTT invoice 指令)把 issued 打回 pending、清掉發票號。
$existing = Invoice::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
if ($existing && in_array($existing->status, [Invoice::STATUS_ISSUED, Invoice::STATUS_VOID], true)) {
return $existing;
}
return Invoice::withoutGlobalScopes()->updateOrCreate(
['flow_id' => $data['flow_id']],
$attributes
);
}
return Invoice::create(array_merge($attributes, ['flow_id' => null]));
});
}
@ -193,18 +314,40 @@ class TransactionService
// 執行實體庫存扣除 (真實交易化)
if ((int) ($data['dispense_status'] ?? 0) === 1) {
if ($slot) {
$slot->decrement('stock');
// 防呆:庫存已為 0或更低時不再扣減避免出現負庫存(-1)。
// 正常情況下 B660 已嚴格擋下庫存不足的領藥單;此處為下位機與後台庫存不同步時的最後防線。
if ((int) $slot->stock > 0) {
$slot->decrement('stock');
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
// 售完(庫存歸 0即清空效期與批號避免舊批貨效期殘留。
// 場景:日後管理員只補 stock 未改 expiry_date 時update_inventory 才不會
// 把過期日一起下發、把新批貨誤鎖成「暫不販售」。與機台端「賣完清效期」對稱。
// 純加法:僅在仍有殘留值時才寫,不影響既有扣庫存行為。
if ($slot->stock <= 0 && ($slot->expiry_date !== null || $slot->batch_no !== null)) {
$slot->update([
'expiry_date' => null,
'batch_no' => null,
]);
}
$this->machineService->recordStockMovement(
$slot,
-1,
$type,
$order ?? $record,
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
['order_id' => $order?->id, 'dispense_id' => $record->id]
);
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
$this->machineService->recordStockMovement(
$slot,
-1,
$type,
$order ?? $record,
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
['order_id' => $order?->id, 'dispense_id' => $record->id]
);
} else {
\Log::warning("Dispense decrement skipped: slot stock already <= 0", [
'machine_id' => $machine->id,
'slot_no' => $slotNo,
'current_stock' => (int) $slot->stock,
'flow_id' => $data['flow_id'] ?? null,
]);
}
} else {
// Log warning if slot matching fails to help diagnostic
\Log::warning("Dispense record created but slot not found for decrement", [
@ -225,43 +368,105 @@ class TransactionService
public function finalizeTransaction(array $data): Order
{
// 優先從 Root 讀取,再從 order 內讀取
$flowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
// 冪等性檢查:如果 flow_id 已經存在,直接回傳既有訂單
if ($flowId) {
$existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first();
if ($existingOrder) {
Log::info("Transaction already finalized, returning existing order", ['flow_id' => $flowId]);
return $existingOrder;
}
} else {
$rawFlowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
if (empty($rawFlowId)) {
throw new \Exception("Flow ID is required for finalization");
}
// 跨機台防撞號App 端 flow_id 僅單機唯一(YYYYMMDDHHMMSSXXXX),加上機台序號前綴
// 才全域唯一(orders.flow_id 是全域 unique)。無分隔符——綠界 RelateNumber 禁特殊符號,
// 且 serial+flow_id 與線上既有 relateNumber 推導逐字等價(見 recordInvoice)。
// ACK 仍由 ProcessTransactionFinalized 回傳『原始』flow_idApp / outbox 對帳無感。
$flowId = $data['serial_no'] . $rawFlowId;
$data['flow_id'] = $flowId;
$data['order']['flow_id'] = $flowId;
return DB::transaction(function () use ($data, $flowId) {
$serialNo = $data['serial_no'];
// 冪等性 + 併發防護:終態檢查移進交易內並對既有 row 加排他鎖,
// 防止兩個 worker 同時穿透 pending 狀態而重複出貨/扣庫存/核銷。
// main(晟崴/中國醫)的單永遠是 completed → 等同原本的 early-return行為不變。
// 既有單為 pending 時放行,往下交給 processTransaction 做 pending → paid/failed 轉移。
$existingOrder = Order::withoutGlobalScopes()
->where('flow_id', $flowId)
->lockForUpdate()
->first();
if ($existingOrder && in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
Log::info("Transaction already finalized (terminal), returning existing order", [
'flow_id' => $flowId,
'status' => $existingOrder->status,
]);
return $existingOrder;
}
// 領藥單(pharmacy_pickup):機台沿用中國醫(CMUH)出貨流程finalize 的 flow_id 是「機台本地流水號」,
// 但 order.order_no = 後端預建的領藥單號(RX…)。故優先以 order_no 對應預建領藥單,避免被當成新銷售單建立。
$pharmacyOrderNo = $data['order']['order_no'] ?? ($data['order_no'] ?? null);
if ($pharmacyOrderNo) {
$pharmacyOrder = Order::withoutGlobalScopes()
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
->where('order_no', $pharmacyOrderNo)
->first();
if ($pharmacyOrder) {
if (in_array($pharmacyOrder->status, Order::TERMINAL_STATUSES, true)) {
Log::info("Pharmacy order already finalized (terminal)", [
'order_no' => $pharmacyOrderNo,
'status' => $pharmacyOrder->status,
]);
return $pharmacyOrder; // 冪等
}
return $this->finalizePharmacyDispense($pharmacyOrder, $data);
}
}
// 後備:若 flow_id 本身就對到領藥單(例如機台有正確帶 RX flow_id也走領藥流程。
if ($existingOrder && $existingOrder->order_type === Order::TYPE_PHARMACY_PICKUP) {
return $this->finalizePharmacyDispense($existingOrder, $data);
}
// 1. Process Order (B600)
$orderData = $data['order'];
$orderData['serial_no'] = $serialNo;
// 確保提取 payment_type 與 flow_id (可能在 root 或 order 內)
$orderData['flow_id'] = $flowId;
if (!isset($orderData['delivery_status']) && !empty($data['dispense'])) {
$orderData['delivery_status'] = $this->resolveDeliveryStatusFromDispense($data['dispense']);
}
if (!isset($orderData['payment_type']) && isset($data['payment_type'])) {
$orderData['payment_type'] = $data['payment_type'];
}
$order = $this->processTransaction($orderData);
// 2. Record Invoice (B601) - Optional
if (!empty($data['invoice'])) {
// 2. Record Invoice — 後台開立版finalize 帶 invoice 輸入(status=pending)時建 pending 發票,
// commit 後派 IssueInvoiceJob 去綠界開立(不在交易內等 ECPay避免長交易
// 開立條件:機台「電子發票開關」開啟 AND 上報帶 invoice兩者皆成立才開立。
// 機台關閉電子發票時略過開立(不建發票、不送綠界),僅留 warning log 供日後追查。
if (!empty($data['invoice']) && $order->machine?->tax_invoice_enabled) {
$invoiceData = $data['invoice'];
$invoiceData['serial_no'] = $serialNo;
$invoiceData['flow_id'] = $order->flow_id;
$invoiceData['order_id'] = $order->id;
$this->recordInvoice($invoiceData);
$invoice = $this->recordInvoice($invoiceData);
// 待開立 → commit 後非同步開立(已開/失敗則不重複觸發)
if ($invoice->status === Invoice::STATUS_PENDING) {
DB::afterCommit(function () use ($invoice) {
\App\Jobs\Transaction\IssueInvoiceJob::dispatch($invoice->id);
});
}
} elseif (!empty($data['invoice'])) {
// 上報帶了 invoice但機台「電子發票開關」為關閉 → 略過開立,留軌跡供追查。
Log::warning('機台已關閉電子發票,略過開立', [
'machine_id' => $order->machine_id,
'flow_id' => $order->flow_id,
'order_id' => $order->id,
]);
}
// 3. Record Dispense Results (B602) - Optional/Multiple
$dispensedSlots = []; // 收集實際出貨貨道/櫃號,供取貨碼核銷日誌記錄「真正取貨的貨道」
if (!empty($data['dispense'])) {
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
foreach ($dispenseList as $dispenseItem) {
@ -274,6 +479,10 @@ class TransactionService
$dispenseItem['member_barcode'] = $order->member_barcode;
}
if (!empty($dispenseItem['slot_no'])) {
$dispensedSlots[] = (string) $dispenseItem['slot_no'];
}
$this->recordDispense($dispenseItem);
}
}
@ -299,9 +508,11 @@ class TransactionService
break;
case 6: // 取貨碼 (Pickup Code)
// 排他鎖:防並發核銷 lost-update 使 usage_count 突破 usage_limit。
$pickupCode = PickupCode::where('machine_id', $order->machine_id)
->where('id', $codeId)
->where('status', 'active')
->lockForUpdate()
->first();
if ($pickupCode) {
$newCount = $pickupCode->usage_count + 1;
@ -323,7 +534,11 @@ class TransactionService
'action' => $isUsedUp ? 'used' : 'consume',
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
'raw_data' => [
'slot' => $pickupCode->slot_no,
// 取貨碼綁「商品」時 pickupCode->slot_no 為 null
// 改記「實際出貨的貨道/櫃號」(dispense),取不到才退回取貨碼綁定貨道。
'slot' => !empty($dispensedSlots)
? implode(', ', array_unique($dispensedSlots))
: $pickupCode->slot_no,
'code' => $pickupCode->code,
'count' => $newCount,
'limit' => $pickupCode->usage_limit
@ -333,6 +548,8 @@ class TransactionService
break;
case 5: // 通行碼 (Pass Code)
// 注意:通行碼「用掉一次」的計數改在 B670 驗證(verifyPassCode)當下處理,
// 因為 App 對通行碼會略過交易結案上報,此 case 5 實務上不會被觸發。
// 建立通行碼核銷日誌
PassCodeLog::create([
'company_id' => $order->company_id,
@ -346,8 +563,251 @@ class TransactionService
}
}
// 處理來店禮 (Welcome Gift) 自動核銷
$welcomeGiftId = $data['order']['welcome_gift_id'] ?? ($data['welcome_gift_id'] ?? null);
if ($welcomeGiftId) {
// 排他鎖:防並發核銷 lost-update 使 usage_count 突破 usage_limit。
$welcomeGift = WelcomeGift::where('machine_id', $order->machine_id)
->where('id', $welcomeGiftId)
->where('status', 'active')
->lockForUpdate()
->first();
if ($welcomeGift) {
$newCount = $welcomeGift->usage_count + 1;
$isUsedUp = ($welcomeGift->usage_type === 'once' && $newCount >= ($welcomeGift->usage_limit ?? 1));
$welcomeGift->update([
'usage_count' => $newCount,
'status' => $isUsedUp ? 'used' : 'active',
]);
WelcomeGiftLog::create([
'company_id' => $order->company_id,
'machine_id' => $order->machine_id,
'welcome_gift_id' => $welcomeGift->id,
'order_id' => $order->id,
'action' => 'consume',
'remark' => "MQTT 交易完成自動核銷,訂單: #{$order->id}",
'raw_data' => [
'code' => $welcomeGift->code,
'count' => $newCount,
'type' => $welcomeGift->usage_type
]
]);
}
}
return $order;
});
}
/**
* 後台手動補單(機台斷線/漏報時,由系統管理員人工補登銷售紀錄)。
*
* 與機台上報共用底層 recordDispense()(扣庫存)與 recordInvoice()+IssueInvoiceJob後台開發票
* 但訂單標記 order_type=manual、created_by=操作者flow_id 'MAN' 前綴與機台上報區隔,
* 一眼可辨識並天然避開 orders.flow_id 唯一鍵衝突(長度 ≤30、純英數亦相容綠界 RelateNumber
* 扣庫存與開發票皆為選配(由呼叫端決定);權限把關(限系統管理員)在 Controller。
*
* @param array $data {
* serial_no, created_by, payment_type, occurred_at?,
* items: [{product_id, slot_no?, product_name?, price, quantity}],
* deduct_stock?: bool, remark?,
* invoice?: null|{business_tax_id?, carrier_id?, love_code?} // 給定才開立
* }
*/
public function createManualOrder(array $data): Order
{
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$items = $data['items'] ?? [];
if (empty($items)) {
throw new \InvalidArgumentException('Manual order requires at least one item');
}
$totalAmount = collect($items)
->sum(fn ($i) => (float) $i['price'] * (int) $i['quantity']);
// 補單常為事後補登machine_time / created_at 採實際發生時間,使銷售報表落在正確日期。
$occurredAt = !empty($data['occurred_at'])
? \Carbon\Carbon::parse($data['occurred_at'])
: now();
$flowId = $this->generateManualFlowId();
return DB::transaction(function () use ($machine, $flowId, $items, $totalAmount, $occurredAt, $data) {
// 1. 建立訂單(終態 completed + 付款成功)
$order = Order::create([
'company_id' => $machine->company_id,
'flow_id' => $flowId,
'order_no' => $this->generateOrderNo(),
'order_type' => Order::TYPE_MANUAL,
'created_by' => $data['created_by'] ?? null,
'machine_id' => $machine->id,
'payment_type' => (int) ($data['payment_type'] ?? 0),
'total_amount' => $totalAmount,
'original_amount' => $totalAmount,
'discount_amount' => 0,
'pay_amount' => $totalAmount,
'payment_status' => Order::PAYMENT_STATUS_SUCCESS,
'payment_at' => $occurredAt,
'machine_time' => $occurredAt,
'status' => Order::STATUS_COMPLETED,
'delivery_status' => Order::DELIVERY_STATUS_SUCCESS,
'remark' => $data['remark'] ?? null,
'metadata' => ['source' => 'manual'],
]);
// created_at 對齊實際發生時間(補單事後補登,報表需落在真實日期而非補登當下)
$order->forceFill(['created_at' => $occurredAt])->save();
$this->createOrderItems($order, $items);
// 2. 扣庫存(選配):逐單位呼叫 recordDispense與機台上報一致成功才扣 1庫存為 0 時不為負)
if (!empty($data['deduct_stock'])) {
foreach ($items as $item) {
$qty = max(1, (int) ($item['quantity'] ?? 1));
for ($n = 0; $n < $qty; $n++) {
$this->recordDispense([
'serial_no' => $machine->serial_no,
'flow_id' => $flowId,
'order_id' => $order->id,
'slot_no' => $item['slot_no'] ?? null,
'product_id' => $item['product_id'] ?? null,
'amount' => $item['price'],
'dispense_status' => 1,
'machine_time' => $occurredAt,
]);
}
}
}
// 3. 開立電子發票(選配):建 pending 發票 → commit 後派 IssueInvoiceJob 去綠界開立。
// 閘門:唯有機台「電子發票開關」開啟才開立(防禦縱深,與 finalizeTransaction 一致;前端已隱藏選項)。
if (!empty($data['invoice']) && $machine->tax_invoice_enabled) {
$invoiceData = $data['invoice'];
$invoiceData['serial_no'] = $machine->serial_no;
$invoiceData['flow_id'] = $flowId;
$invoiceData['order_id'] = $order->id;
$invoiceData['amount'] = $totalAmount;
$invoiceData['status'] = Invoice::STATUS_PENDING;
$invoiceData['machine_time'] = $occurredAt;
$invoice = $this->recordInvoice($invoiceData);
if ($invoice->status === Invoice::STATUS_PENDING) {
DB::afterCommit(function () use ($invoice) {
\App\Jobs\Transaction\IssueInvoiceJob::dispatch($invoice->id);
});
}
} elseif (!empty($data['invoice'])) {
Log::warning('手動補單要求開立發票,但機台已關閉電子發票,略過開立', [
'machine_id' => $machine->id,
'flow_id' => $flowId,
'order_id' => $order->id,
]);
}
return $order;
});
}
/** 產生手動補單專用 flow_id'MAN' 前綴全域唯一、純英數、≤30 字)。 */
protected function generateManualFlowId(): string
{
do {
$flowId = 'MAN' . now()->format('YmdHis') . strtoupper(bin2hex(random_bytes(3)));
} while (Order::withoutGlobalScopes()->where('flow_id', $flowId)->exists());
return $flowId;
}
/**
* 領藥單出貨回報(獨立於銷售流程)。
*
* 機台逐道出貨後,以 transaction/finalize dispense[](每出貨一單位一筆,含 slot_no/dispense_status回報。
* 本方法:建立出貨紀錄(沿用 recordDispense成功則扣 1 庫存)→ 彙整 delivery_status
* 將訂單轉為終態 completed冪等重送會在 finalizeTransaction 開頭 early-return)→ 標記領藥碼已領 + 核銷日誌。
*/
protected function finalizePharmacyDispense(Order $order, array $data): Order
{
return DB::transaction(function () use ($order, $data) {
$serialNo = $data['serial_no'] ?? $order->machine?->serial_no;
if (!empty($data['dispense'])) {
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
foreach ($dispenseList as $dispenseItem) {
$dispenseItem['serial_no'] = $serialNo;
$dispenseItem['flow_id'] = $order->flow_id;
$dispenseItem['order_id'] = $order->id;
$this->recordDispense($dispenseItem); // 建 DispenseRecorddispense_status=1 時扣 1 庫存
}
$deliveryStatus = $this->resolveDeliveryStatusFromDispense($data['dispense']);
} else {
// 無出貨明細視為失敗
$deliveryStatus = Order::DELIVERY_STATUS_FAILED;
}
$order->update([
'delivery_status' => $deliveryStatus,
'status' => Order::STATUS_COMPLETED, // 終態 → 後續重送冪等
'machine_time' => $data['machine_time'] ?? $order->machine_time,
]);
// 標記領藥碼已領B660 通常已標 used此處保險並留核銷日誌
$pickupCode = $order->pickupCode;
if ($pickupCode) {
if ($pickupCode->status !== 'used') {
$pickupCode->update(['status' => 'used', 'used_at' => $pickupCode->used_at ?? now()]);
}
PickupCodeLog::create([
'company_id' => $order->company_id,
'machine_id' => $order->machine_id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'used',
'remark' => "log.pickup.pharmacy_dispensed",
'raw_data' => [
'order_no' => $order->order_no,
'delivery_status' => $deliveryStatus,
],
]);
}
Log::info('Pharmacy pickup dispense finalized', [
'flow_id' => $order->flow_id,
'delivery_status' => $deliveryStatus,
]);
return $order;
});
}
/**
* Resolve order-level delivery status from item-level dispense results.
*/
protected function resolveDeliveryStatusFromDispense(array $dispense): int
{
$dispenseList = isset($dispense[0]) ? $dispense : [$dispense];
$statuses = collect($dispenseList)
->filter(fn ($item) => is_array($item) && array_key_exists('dispense_status', $item))
->map(fn ($item) => (int) $item['dispense_status']);
if ($statuses->isEmpty()) {
return Order::DELIVERY_STATUS_SUCCESS;
}
$successCount = $statuses
->filter(fn ($status) => $status === Order::DELIVERY_STATUS_SUCCESS)
->count();
if ($successCount === $statuses->count()) {
return Order::DELIVERY_STATUS_SUCCESS;
}
if ($successCount === 0) {
return Order::DELIVERY_STATUS_FAILED;
}
return Order::DELIVERY_STATUS_PARTIAL;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Traits;
/**
* 提供「給人看的 flow_id」accessor去掉機台序號前綴顯示乾淨易讀的機台流水號。
*
* 資料庫仍儲存完整的 `serial_no . flow_id`(全域唯一值),供後台去重 / 冪等 / 對帳判斷使用;
* accessor 只用於畫面與匯出顯示,不改動任何 DB 值。
*
* flow_id 格式serial_no . YYYYMMDDHHMMSSXXXX無分隔符
* 套用此 trait Model 需具備 `flow_id` 欄位與 `machine`(含 serial_no關聯。
*/
trait HasDisplayFlowId
{
public function getDisplayFlowIdAttribute(): string
{
$flowId = (string) ($this->flow_id ?? '');
$serial = $this->machine?->serial_no;
// 僅在確實以該機台序號開頭時才剝除前綴;舊的未前綴資料原樣顯示(防禦)。
if ($serial !== null && $serial !== '' && str_starts_with($flowId, $serial)) {
return substr($flowId, strlen($serial));
}
return $flowId;
}
}

View File

@ -17,8 +17,8 @@ trait TenantScoped
return;
}
// check if running in console/migration
if (app()->runningInConsole()) {
// check if running in console/migration (but not during unit tests)
if (app()->runningInConsole() && !app()->runningUnitTests()) {
return;
}

View File

@ -55,6 +55,11 @@ return [
'description' => '回應訊息',
'example' => 'Success'
],
'identity' => [
'type' => 'string',
'description' => '登入者身分system 系統登入者 / company 公司帳號 / staff 一般人員 (僅驗證成功時回傳)',
'example' => 'system'
],
'token' => [
'type' => 'string',
'description' => '臨時身份認證 Token',
@ -70,6 +75,7 @@ return [
'success' => true,
'code' => 200,
'message' => 'Success',
'identity' => 'system',
'token' => '1|abcdefg...'
],
],
@ -78,7 +84,7 @@ return [
'slug' => 'b014-config-download',
'method' => 'GET',
'path' => '/api/v1/app/machine/setting/B014',
'description' => '透過此介面下載金流金鑰、電子發票設定與機台專屬通訊 Token。',
'description' => '透過此介面下載金流金鑰、電子發票設定與機台專屬通訊 Token。金流密鑰與發票設定均取自機台關聯的「金流配置 (payment_configs.settings)」,未設值欄位回傳空字串。',
'headers' => [
'Content-Type' => 'application/json',
],
@ -94,18 +100,106 @@ return [
'success' => [
'type' => 'boolean',
'description' => '是否成功',
'example' => true
],
'data' => [
'type' => 'object',
'description' => '配置物件。包含t050v01 (序號), api_token (通訊 Token), t050v41~43 (玉山設定), t050v34~38 (發票設定), TP_... (趨勢/手機支付設定)',
'example' => [
't050v01' => 'SN202604130001',
'api_token' => 'mac_token_...',
't050v41' => '80812345',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
]
'description' => '配置物件,下列各欄位皆位於 data 之下。金鑰/發票來源為機台關聯的「金流配置 (payment_configs.settings)」DevSet / CashSet / FunctionSet / ShoppingMode 來源為「機台系統設定 (machines.settings)」OperationSet / HardwareSet 來源為 machines 實體欄位,皆採機台端 *Set + PascalCase 風格。',
],
'data.t050v01' => [
'type' => 'string',
'description' => '機台序號 ← machines.serial_no',
],
'data.api_token' => [
'type' => 'string',
'description' => '機台正式通訊 Token ← machines.api_token初始化後存本地後續 API 認證用)',
],
'data.t050v41' => [
'type' => 'string',
'description' => '玉山掃碼 StoreID ← 金流配置 esun_scan.store_id',
],
'data.t050v42' => [
'type' => 'string',
'description' => '玉山掃碼 TermID ← 金流配置 esun_scan.term_id',
],
'data.t050v43' => [
'type' => 'string',
'description' => '玉山掃碼 Key ← 金流配置 esun_scan.key',
],
'data.t050v34' => [
'type' => 'string',
'description' => '綠界發票 商店代號 ← 金流配置 ecpay_invoice.store_id',
],
'data.t050v35' => [
'type' => 'string',
'description' => '綠界發票 HashKey ← 金流配置 ecpay_invoice.hash_key',
],
'data.t050v36' => [
'type' => 'string',
'description' => '綠界發票 HashIV ← 金流配置 ecpay_invoice.hash_iv',
],
'data.t050v38' => [
'type' => 'string',
'description' => '綠界發票 通知 Email ← 金流配置 ecpay_invoice.email',
],
'data.TP_APP_ID' => [
'type' => 'string',
'description' => 'TapPay App ID ← 金流配置 tappay.app_id',
],
'data.TP_APP_KEY' => [
'type' => 'string',
'description' => 'TapPay App Key ← 金流配置 tappay.app_key',
],
'data.TP_PARTNER_KEY' => [
'type' => 'string',
'description' => 'TapPay Partner Key ← 金流配置 tappay.partner_key',
],
'data.TP_LINE_MERCHANT_ID' => [
'type' => 'string',
'description' => 'LINE Pay 特店 ID ← 金流配置 tappay.line_merchant_id',
],
'data.TP_JKO_MERCHANT_ID' => [
'type' => 'string',
'description' => '街口支付 特店 ID ← 金流配置 tappay.jko_merchant_id',
],
'data.TP_PI_MERCHANT_ID' => [
'type' => 'string',
'description' => 'Pi 拍錢包 特店 ID ← 金流配置 tappay.pi_merchant_id',
],
'data.TP_PS_MERCHANT_ID' => [
'type' => 'string',
'description' => '全盈+Pay 特店 ID ← 金流配置 tappay.ps_merchant_id',
],
'data.TP_EASY_MERCHANT_ID' => [
'type' => 'string',
'description' => '悠遊付 特店 ID ← 金流配置 tappay.easy_merchant_id',
],
'data.DevSet' => [
'type' => 'object',
'description' => "支付旗標(命名沿用機台 DevSetStructure布林。機台既有欄位\n• ShoppingCar ← shopping_cart_enabled購物車\n• Invoice ← tax_invoice_enabled電子發票\n• DevNFCPay ← card_terminal_enabled刷卡機總開關\n• DevEsunPay ← scan_pay_esun_enabled玉山掃碼\n• DevTapPay ← scan_pay_tappay_enabledTapPay 掃碼)\n• DevCash ← cash_module_enabled現金\n• DevLinePay ← scan_pay_linepay_enabledLINE Pay 官方直連)\n• TapPay30 ← tappay_linepayTapPay 底下的 LINE Pay\n• TapPay31 ← tappay_jkopay街口支付\n• TapPay32 ← tappay_easywallet悠遊付\n• TapPay33 ← tappay_pipayPi 支付)\n• TapPay34 ← tappay_pluspay全盈+支付)\n新定義(機台端待新增欄位):\n• DevCreditCard ← credit_card_enabled信用卡\n• DevMobilePay ← mobile_pay_enabled手機支付\n• DevCardPay ← card_pay_enabled卡片支付\n• DevScanPay ← scan_pay_enabled掃碼總開關\nVMC/Electic 為機台硬體類型,雲端不下發)",
],
'data.CashSet' => [
'type' => 'object',
'description' => "現金面額旗標(命名對齊機台端 CashSetStructure布林\n• BillF1000 / BillE500 / BillD100 ← cash_bill_1000 / 500 / 100\n• CoinF50 / CoinE10 / CoinD5 / CoinC1 ← cash_coin_50 / 10 / 5 / 1",
],
'data.FunctionSet' => [
'type' => 'object',
'description' => "非支付功能模組旗標(新定義,機台端待實作對應結構;布林):\n• PickupModule ← pickup_module_enabled取貨模組\n• PickupCode ← pickup_code_enabled取貨碼\n• PassCode ← pass_code_enabled通行碼\n• WelcomeGift ← welcome_gift_enabled來店禮\n• MemberSystem ← member_system_enabled會員系統\n• AmbientTemp ← ambient_temp_monitoring_enabled環境溫度監控\n• PharmacyPickup ← pharmacy_pickup_enabled領藥單雲端建單、掃 QR 出貨。僅在 ShoppingMode=pickup_sheet 取物單模式下有效)",
],
'data.ShoppingMode' => [
'type' => 'string',
'description' => '購物方式(新定義)← machines.settings.shopping_modebasic / employee_card / pickup_sheet',
],
'data.LangSet' => [
'type' => 'object',
'description' => "機台顯示語系(新定義,最多 5 種)← machines.settings.languages\n• Languages ← 有序語系陣列(如 [\"zh_TW\",\"en\",\"ja\"],順序即切換順序)\n• Default ← 預設語系(清單第一個,開機/idle 顯示)\n未設定時退化為 [fallback]。機台據此渲染語系切換 UI可選語系池子見 config/locales.php。商品名稱/規格的對應翻譯由 B012 的 t060v01_i18n / t060v03_i18n 提供。",
],
'data.OperationSet' => [
'type' => 'object',
'description' => "運作參數(新定義;來源 machines 實體欄位):\n• CardReaderSeconds ← card_reader_seconds整數刷卡機秒數\n• PaymentBufferSeconds ← payment_buffer_seconds整數金流緩衝秒數\n• CheckoutTime1 ← card_reader_checkout_time_1時間字串\n• CheckoutTime2 ← card_reader_checkout_time_2\n• HeatingStartTime ← heating_start_time加熱開始\n• HeatingEndTime ← heating_end_time加熱結束",
],
'data.HardwareSet' => [
'type' => 'object',
'description' => "硬體與貨道(新定義;來源 machines 實體欄位):\n• CardReaderNo ← card_reader_no字串刷卡機編號\n• SpringSlot1_10 / 11_20 / 21_30 / 31_40 / 41_50 / 51_60 ← is_spring_slot_*布林true=彈簧 / false=履帶)",
],
],
'request' => [],
@ -119,10 +213,130 @@ return [
't050v42' => '9001',
't050v43' => 'hash_key',
't050v34' => '2000132',
'TP_APP_ID' => 'GP_001'
't050v35' => 'invoice_hash_key',
't050v36' => 'invoice_hash_iv',
't050v38' => 'invoice@example.com',
'TP_APP_ID' => 'GP_001',
'TP_APP_KEY' => 'app_key_...',
'TP_PARTNER_KEY' => 'partner_key_...',
'TP_LINE_MERCHANT_ID' => 'LINE_M_001',
'TP_JKO_MERCHANT_ID' => 'JKO_M_001',
'TP_PI_MERCHANT_ID' => 'PI_M_001',
'TP_PS_MERCHANT_ID' => 'PS_M_001',
'TP_EASY_MERCHANT_ID' => 'EASY_M_001',
'DevSet' => [
'ShoppingCar' => true,
'Invoice' => true,
'DevNFCPay' => true,
'DevCreditCard' => true,
'DevMobilePay' => true,
'DevCardPay' => false,
'DevScanPay' => true,
'DevEsunPay' => true,
'DevTapPay' => false,
'DevCash' => true,
'DevLinePay' => true,
'TapPay30' => false,
'TapPay31' => true,
'TapPay32' => false,
'TapPay33' => false,
'TapPay34' => false,
],
'CashSet' => [
'BillF1000' => true,
'BillE500' => true,
'BillD100' => true,
'CoinF50' => true,
'CoinE10' => true,
'CoinD5' => true,
'CoinC1' => true,
],
'FunctionSet' => [
'PickupModule' => false,
'PickupCode' => false,
'PassCode' => false,
'WelcomeGift' => false,
'MemberSystem' => false,
'AmbientTemp' => false,
'PharmacyPickup' => false,
],
'ShoppingMode' => 'basic',
'LangSet' => [
'Languages' => ['zh_TW', 'en', 'ja'],
'Default' => 'zh_TW',
],
'OperationSet' => [
'CardReaderSeconds' => 30,
'PaymentBufferSeconds' => 5,
'CheckoutTime1' => '22:30:00',
'CheckoutTime2' => '23:45:00',
'HeatingStartTime' => '00:00:00',
'HeatingEndTime' => '00:00:00',
],
'HardwareSet' => [
'CardReaderNo' => 'CR-001',
'SpringSlot1_10' => true,
'SpringSlot11_20' => false,
'SpringSlot21_30' => false,
'SpringSlot31_40' => false,
'SpringSlot41_50' => false,
'SpringSlot51_60' => false,
],
]
],
'notes' => '此 API 為機台初始化引導用,目前不強制驗證 User Token。'
'notes' => '此 API 為機台初始化引導用,目前不強制驗證 User Token。金流密鑰與發票設定來源為機台關聯的金流配置 (payment_configs)DevSet / CashSet / FunctionSet / ShoppingMode 來源為 machines.settings、OperationSet / HardwareSet 來源為 machines 實體欄位,全部採機台端 *Set + PascalCase 風格DevSet/CashSet 對齊機台既有結構,其餘為新定義,機台 App 端需另案實作)。'
],
[
'name' => 'B016: 機台系統設定回寫 (Settings Write-back)',
'slug' => 'b016-settings-writeback',
'method' => 'PATCH',
'path' => '/api/v1/app/machine/setting/B016',
'description' => '機台主控台由系統方設定硬體貨道類型後回寫雲端,為 B014 下載的反向操作。需帶 B000 核發之 User Token且僅系統管理員可操作。機台端現在只回寫硬體貨道類型 (is_spring_slot_*)其餘系統設定純由後台單向決定B016 不再接收。',
'headers' => [
'Authorization' => 'Bearer <user_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'machine' => [
'type' => 'string',
'required' => true,
'description' => '機台序號 (serial_no)',
'example' => 'SN202604130001'
],
'settings' => [
'type' => 'object',
'required' => true,
'description' => "貨道類型鍵值物件(皆為布林),鍵名採後端原始 snake_case只覆寫有送來的鍵、其餘保留。白名單外鍵一律忽略。可回寫鍵\n• 貨道類型machines 實體欄位is_spring_slot_1_10 / 11_20 / 21_30 / 31_40 / 41_50 / 51_60true=彈簧 / false=履帶)",
'example' => ['is_spring_slot_1_10' => false]
],
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '是否成功',
],
'code' => [
'type' => 'integer',
'description' => '業務狀態碼',
],
'message' => [
'type' => 'string',
'description' => '回應訊息',
],
],
'request' => [
'machine' => 'SN202604130001',
'settings' => [
'is_spring_slot_1_10' => true,
'is_spring_slot_11_20' => false,
],
],
'response' => [
'success' => true,
'code' => 200,
'message' => 'Settings updated successfully.',
],
'notes' => '僅系統管理員 (identity=system) 可操作,非系統方回 403 Forbidden查無序號回 404 Machine not foundsettings 非物件或未含任何有效貨道鍵回 422 Invalid settings payload。寫回時更新 is_spring_slot_* 實體欄位並記錄 updater_id套用 withoutGlobalScopes 跨租戶定位機台。'
],
[
'name' => 'B005: 廣告清單同步 (Ad Sync)',
@ -254,14 +468,16 @@ return [
],
'data' => [
'type' => 'array',
'description' => '商品明細物件陣列。欄位包含t060v00(ID), t060v01(名稱), t060v03(規格), t060v06(圖片), t060v09(售價), t060v11(預設上限), spring_limit(彈簧上限), track_limit(履帶上限)',
'description' => '商品明細物件陣列。欄位包含t060v00(ID), t060v01(名稱), t060v01_en/jp(既有英日名稱,保留相容), t060v01_i18n(名稱多語系 map), t060v03(規格), t060v03_i18n(規格多語系 map), t060v06(圖片), t060v09(售價), t060v11(預設上限), spring_limit(彈簧上限), track_limit(履帶上限)。多語系 map 的 key 集合=公司機台語系聯集,缺翻譯回退 zh_TW既有欄位一律保留以相容線上舊 App。',
'example' => [
[
't060v00' => '1',
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola',
't060v01_jp' => 'コカコーラ',
't060v01_i18n' => ['zh_TW' => '可口可樂 330ml', 'en' => 'Coca Cola', 'ja' => 'コカコーラ'],
't060v03' => 'Cold Drink',
't060v03_i18n' => ['zh_TW' => 'Cold Drink', 'en' => 'Cold Drink', 'ja' => 'コールドドリンク'],
't060v06' => 'https://.../coke.png',
't060v09' => 25.0,
't060v11' => 10,
@ -285,7 +501,9 @@ return [
't060v01' => '可口可樂 330ml',
't060v01_en' => 'Coca Cola 330ml',
't060v01_jp' => 'コカコーラ 330ml',
't060v01_i18n' => ['zh_TW' => '可口可樂 330ml', 'en' => 'Coca Cola 330ml', 'ja' => 'コカコーラ 330ml'],
't060v03' => '經典原味,冰涼好滋味',
't060v03_i18n' => ['zh_TW' => '經典原味,冰涼好滋味', 'en' => 'Classic taste, ice cold', 'ja' => 'クラシックな味わい'],
't060v06' => 'https://cloud.star.com/storage/products/coke.png',
't060v09' => 25.0,
't060v11' => 10,
@ -409,30 +627,104 @@ return [
'notes' => '驗證成功後會建立刷卡日誌。連續錯誤同樣會觸發鎖定。'
],
[
'name' => 'B027: 贈品碼/優惠券驗證 (Free Gift Verify)',
'slug' => 'b027-freebie-code',
'name' => 'B690: 來店禮驗證 (Welcome Gift Verify)',
'slug' => 'b690-welcome-gift-verify',
'method' => 'POST',
'path' => '/api/v1/app/sell/free-gift/B027',
'description' => '驗證贈品券有效性。成功後回傳對應資訊,正式消耗改由 MQTT finalize 處理。',
'path' => '/api/v1/app/machine/welcome-gift/verify/B690',
'description' => '處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊(含台灣中文折數標籤),供機台端套用折扣。正式消耗改由 MQTT finalize 處理 (payment_type=7)。',
'headers' => [
'Authorization' => 'Bearer <api_token>',
'Content-Type' => 'application/json',
],
'parameters' => [
'passCode' => [
'code' => [
'type' => 'string',
'description' => '贈品碼/優惠券代碼',
'example' => 'FREE888'
'required' => true,
'description' => '8 位數來店禮代碼',
'example' => '12345678'
]
],
'response_parameters' => [
'success' => [
'type' => 'boolean',
'description' => '請求是否成功',
'example' => true
],
'code' => [
'type' => 'integer',
'description' => '業務狀態碼',
'example' => 200
],
'data.name' => [
'type' => 'string',
'description' => '來店禮名稱',
'example' => '新客八五折'
],
'data.code_id' => [
'type' => 'integer',
'description' => '來店禮 ID (用於 MQTT 交易關聯payment_type=7 時帶入)',
'example' => 12
],
'data.discount_type' => [
'type' => 'string',
'description' => '折扣類型percentage (趴數折扣) 或 amount (金額折扣)',
'example' => 'percentage'
],
'data.discount_value' => [
'type' => 'integer',
'description' => '折扣數值。percentage 時為趴數 (如 15 代表 15% off)amount 時為折扣金額 (如 50)',
'example' => 15
],
'data.discount_label' => [
'type' => 'string',
'description' => '台灣中文折數標籤 (如「八五折」、「折 50 元」),供 UI 直接顯示',
'example' => '八五折'
],
'data.usage_type' => [
'type' => 'string',
'description' => '使用類型once (單次) 或 unlimited (無限制)',
'example' => 'once'
],
'data.status' => [
'type' => 'string',
'description' => '來店禮狀態',
'example' => 'active'
],
],
'request' => [
'code' => '12345678'
],
'response' => [
'success' => true,
'code' => 200,
'data' => [
'res1' => 'cloud_id_123',
'message' => 'Free gift verified'
'name' => '新客八五折',
'code_id' => 12,
'discount_type' => 'percentage',
'discount_value' => 15,
'discount_label' => '八五折',
'usage_type' => 'once',
'status' => 'active'
]
],
'notes' => '消耗流程已遷移至 MQTT。'
'error_responses' => [
[
'code' => 400,
'message' => 'Invalid format',
'description' => 'code 非 8 位數或缺少必填欄位。'
],
[
'code' => 404,
'message' => 'Invalid or expired code',
'description' => '代碼不存在、已失效或已過期。回應另含 remaining_attempts (剩餘嘗試次數)。'
],
[
'code' => 429,
'message' => 'Too many attempts. Locked.',
'description' => '連續錯誤達 5 次,該機台驗證功能於鎖定期間 (60 秒) 內皆回傳此回應。'
],
],
'notes' => '安全性:連續錯誤 5 次將鎖定該機台驗證功能 1 分鐘。消耗流程已遷移至 MQTT (payment_type=7)。'
],
],
],
@ -477,12 +769,42 @@ return [
'qos' => 0,
'description' => '用於機台連線與斷線的即時狀態同步。通常用於 MQTT 的遺囑訊息 (LWT) 設定,或由 Broker 連線事件觸發。',
'payload_parameters' => [
'status' => ['type' => 'string', 'description' => '連線狀態。接受值online, offline'],
'status' => ['type' => 'string', 'description' => '連線狀態。接受值online, offline, restarting'],
],
'payload_example' => [
'status' => 'online'
],
],
[
'name' => '機台環境溫度上報 (Ambient Temperature Report)',
'slug' => 'mqtt-ambient-temp-report',
'action' => 'PUB',
'topic' => 'machine/{serial_no}/ambient_temp',
'qos' => 1,
'description' => '機台主動上報當前環境溫度。支援 temperature 或 ambient_temp 欄位上報。',
'payload_parameters' => [
'temperature' => ['type' => 'integer', 'description' => '當前環境溫度值 (例如 28)'],
'ambient_temp' => ['type' => 'integer', 'description' => '當前環境溫度值 (相容相應硬體參數)'],
],
'payload_example' => [
'temperature' => 28
],
],
[
'name' => '機台硬體事件上報 (Event)',
'slug' => 'mqtt-event',
'action' => 'PUB',
'topic' => 'machine/{serial_no}/event',
'qos' => 1,
'description' => '用於機台上報各種獨立的硬體運行與物理事件。後台收到後會直接寫入機台狀態日誌中並進行多語系翻譯。',
'payload_parameters' => [
'event' => ['type' => 'string', 'description' => '事件識別碼。例如fanon (風扇開), fanoff (風扇關) 等。'],
],
'payload_example' => [
'event' => 'fanon'
],
],
[
'name' => '機台異常上報 (Error)',
'slug' => 'mqtt-error',
@ -536,7 +858,7 @@ return [
'order.original_amount' => ['type' => 'numeric', 'description' => '(選填) 原始標價金額', 'required' => false],
'order.discount_amount' => ['type' => 'numeric', 'description' => '(選填) 折扣金額,預設 0', 'required' => false],
'order.points_used' => ['type' => 'integer', 'description' => '(選填) 使用點數,預設 0', 'required' => false],
'order.payment_type' => ['type' => 'integer', 'description' => "(必填) 支付類型代碼:\n1:信用卡, 2:悠遊卡/一卡通, 3:掃碼支付, 4:紙鈔機, 5:通行碼, 6:取貨碼, 7:來店禮, 8:問卷, 9:零錢, 41:員工卡。\n21~25:線下付款+X (21:線下+1, 22:線下+2, 23:線下+3, 24:線下+4, 25:線下+9)。\n30:LINE Pay, 31:街口, 32:悠遊付, 33:Pi, 34:全盈+\n40:會員驗證取貨商品。\n50~54:線下付款+TapPay (50:線下+30, 51:線下+31, 52:線下+32, 53:線下+33, 54:線下+34)。\n60:點數/優惠卷全額折抵。61~69:會員+X (61:會員+1...69:會員+9)。\n90~94:會員+TapPay (90:會員+30...94:會員+34)。\n100:遠端出貨。"],
'order.payment_type' => ['type' => 'integer', 'description' => "(必填) 支付類型代碼:\n1:信用卡, 2:電子票證(悠遊卡/一卡通/iCash 等), 3:掃碼支付, 4:紙鈔機, 5:通行碼, 6:取貨碼, 7:來店禮, 8:問卷, 9:零錢(硬幣), 10:手機支付(行動支付/NFC 手機感應), 41:員工卡, 42:取物單。\n※ 1(信用卡)、2(電子票證)、10(手機支付) 走同一台實體刷卡機(Nexsys 終端機)。\n21~25:線下付款+基本類型 (21:線下+1 信用卡, 22:線下+2 電子票證, 23:線下+3 掃碼, 24:線下+4 紙鈔, 25:線下+9 零錢)。\n30:LINE Pay(TapPay), 31:街口, 32:悠遊付, 33:Pi 拍錢包, 34:全盈+PAY。\n70:LINE Pay(官方直連,非 TapPay 底下的 30)\n40:會員驗證取貨商品。\n50~54:線下付款+TapPay (50:線下+30, 51:線下+31, 52:線下+32, 53:線下+33, 54:線下+34)。\n60:點數/優惠券全額折抵。61~69:會員+基本類型 (61:會員+1...69:會員+9)。\n90~94:會員+TapPay (90:會員+30...94:會員+34)。\n100:遠端出貨。"],
'order.payment_status' => ['type' => 'integer', 'description' => '(必填) 支付狀態 (1:成功)'],
'order.code_id' => ['type' => 'string', 'description' => '(選填) 閉環勾稽用的代碼 ID (payment_type 為 5, 6, 41 時必填)', 'required' => false],
'order.payment_request' => ['type' => 'string', 'description' => '(選填) 金流請求原始字串', 'required' => false],
@ -702,7 +1024,7 @@ return [
'qos' => 1,
'description' => '雲端主動下發的系統級別控制指令。包含重啟、設備鎖定解鎖、購物車結帳等無額外參數的指令。',
'payload_parameters' => [
'command' => ['type' => 'string', 'description' => '系統指令類型。支援reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖), update_ads (廣告同步), update_products (商品資料同步)'],
'command' => ['type' => 'string', 'description' => '系統指令類型。支援reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖), update_ads (廣告同步), update_products (商品資料同步), fanon (開啟風扇), fanoff (關閉風扇), fanauto (風扇自動控制)'],
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'],
],
@ -713,6 +1035,28 @@ return [
],
],
[
'name' => '指令下發:環境溫度上限 (Ambient Temperature Upper Limit)',
'slug' => 'mqtt-command-ambient-temp-limit',
'action' => 'SUB',
'topic' => 'machine/{serial_no}/command',
'qos' => 1,
'description' => '雲端主動下發「環境溫度上限」指令。指示機台更新環境溫度開啟風扇的閾值設定。',
'payload_parameters' => [
'command' => ['type' => 'string', 'description' => '固定為 "ambient_temp_limit"'],
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
'payload' => ['type' => 'object', 'description' => '溫度設定參數'],
'payload.temperature' => ['type' => 'integer', 'description' => '環境溫度上限閾值(可為 null 表示停用監測)'],
],
'payload_example' => [
'command' => 'ambient_temp_limit',
'command_id' => '1122334455',
'payload' => [
'temperature' => 28
]
],
],
[
'name' => '指令執行回報 (Machine to Cloud)',
'slug' => 'mqtt-command-ack',

View File

@ -15,7 +15,7 @@ return [
|
*/
'default' => env('CACHE_DRIVER', 'file'),
'default' => env('CACHE_STORE', env('CACHE_DRIVER', 'database')),
/*
|--------------------------------------------------------------------------

48
config/locales.php Normal file
View File

@ -0,0 +1,48 @@
<?php
/*
|--------------------------------------------------------------------------
| 系統支援語系白名單 (Master Locale Whitelist)
|--------------------------------------------------------------------------
|
| 系統目前支援的所有語系,作為以下用途的唯一來源:
| 1. 機台設定頁「顯示語系」可勾選清單(管理員從此白名單替機台挑語系,最多 5 種)。
| 2. 後台儲存語系時updateSystemSettingslanguages 欄位的驗證白名單。
|
| 注意:商品編輯頁的語系 Tab 不是直接讀白名單,而是讀「公司所有機台已開語系
| 的聯集」(Company::activeLocales())。白名單是「機台能挑的池子」,聯集是
| 「商品實際要維護的語系」。
|
| 詳見 docs/API/product_multilingual_spec.md。
|
*/
return [
// 每台機台最多可勾選的語系數量
'max_per_machine' => 5,
// 缺翻譯時的回退語系(亦為商品名稱/規格的最終預設值)
'fallback' => 'zh_TW',
/*
| 支援語系locale => 顯示名稱。
| 標籤採「原文(中文註記)」格式:因後台為我方使用,原文(如 ภาษาไทย、Tiếng Việt
| 不易辨識,故一律附上中文註記,方便管理員一眼判斷是哪種語言。
| 陣列順序即為各處 UI 的呈現順序,請維持 zh_TW 在最前。
*/
'supported' => [
'zh_TW' => '繁體中文',
'en' => 'English英文',
'ja' => '日本語(日文)',
'ko' => '한국어(韓文)',
'th' => 'ภาษาไทย(泰文)',
'zh_CN' => '简体中文(簡中)',
'vi' => 'Tiếng Việt越南文',
'fr' => 'Français法文',
'de' => 'Deutsch德文',
'es' => 'Español西班牙文',
'id' => 'Bahasa Indonesia印尼文',
],
];

View File

@ -66,7 +66,8 @@ return [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
// 須大於最長 Job 的 timeoutAttachProductImagesJob 1800s否則長任務未跑完即被重派造成重複處理
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 1830),
'block_for' => null,
'after_commit' => false,
],

View File

@ -31,4 +31,11 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
// 綠界電子發票 B2C API後台對帳/補開/作廢)。金鑰仍取自各機台 payment_configs.ecpay_invoice
// 此處僅控制 API 網域。留空(未設 env時由 EcpayInvoiceService 依環境 fail-safe
// 正式環境才用 https://einvoice.ecpay.com.tw其餘走 https://einvoice-stage.ecpay.com.tw。
'ecpay_invoice' => [
'base_url' => env('ECPAY_INVOICE_BASE_URL'),
],
];

View File

@ -12,10 +12,9 @@ return new class extends Migration
*/
public function up(): void
{
Schema::table('machine_stock_movements', function (Blueprint $table) {
// 在 MySQL 中,更新 ENUM 建議使用 DB::statement 以確保相容性
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback', 'sale', 'decommission') NOT NULL");
});
}
}
/**
@ -23,8 +22,8 @@ return new class extends Migration
*/
public function down(): void
{
Schema::table('machine_stock_movements', function (Blueprint $table) {
if (Schema::getConnection()->getDriverName() !== 'sqlite') {
DB::statement("ALTER TABLE machine_stock_movements MODIFY COLUMN type ENUM('replenishment', 'pickup', 'remote_dispense', 'adjustment', 'rollback') NOT NULL");
});
}
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
$table->index('command_type');
$table->index('status');
$table->index('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('remote_commands', function (Blueprint $table) {
$table->dropIndex(['command_type']);
$table->dropIndex(['status']);
$table->dropIndex(['created_at']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('system_operation_logs', function (Blueprint $table) {
$table->index(['company_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('system_operation_logs', function (Blueprint $table) {
$table->dropIndex(['company_id', 'created_at']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machine_models', function (Blueprint $table) {
$table->json('settings')->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machine_models', function (Blueprint $table) {
$table->dropColumn('settings');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->json('settings')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('machines', function (Blueprint $table) {
$table->dropColumn('settings');
});
}
};

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('transfer_order_items', function (Blueprint $table) {
$table->string('snapshot_product_name')->nullable()->after('quantity');
$table->string('snapshot_product_name_key')->nullable()->after('snapshot_product_name');
$table->string('snapshot_product_barcode')->nullable()->after('snapshot_product_name_key');
$table->string('snapshot_product_image_url')->nullable()->after('snapshot_product_barcode');
});
Schema::table('replenishment_order_items', function (Blueprint $table) {
$table->string('snapshot_product_name')->nullable()->after('max_stock');
$table->string('snapshot_product_name_key')->nullable()->after('snapshot_product_name');
$table->string('snapshot_product_barcode')->nullable()->after('snapshot_product_name_key');
$table->string('snapshot_product_image_url')->nullable()->after('snapshot_product_barcode');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('transfer_order_items', function (Blueprint $table) {
$table->dropColumn([
'snapshot_product_name',
'snapshot_product_name_key',
'snapshot_product_barcode',
'snapshot_product_image_url'
]);
});
Schema::table('replenishment_order_items', function (Blueprint $table) {
$table->dropColumn([
'snapshot_product_name',
'snapshot_product_name_key',
'snapshot_product_barcode',
'snapshot_product_image_url'
]);
});
}
};

View File

@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('welcome_gifts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->onDelete('cascade');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->string('name', 100)->comment('來店禮名稱');
$table->string('code', 10)->comment('來店禮代碼 (8位)');
$table->string('slug', 16)->unique()->comment('票券 Slug');
$table->string('discount_type')->comment('折扣類型: percentage (趴數), amount (金額)');
$table->integer('discount_value')->comment('折扣值 (趴數或折抵金額)');
$table->string('usage_type')->default('once')->comment('使用次數: once (單次), unlimited (無限制)');
$table->integer('usage_limit')->nullable()->default(1)->comment('使用次數上限');
$table->integer('usage_count')->default(0)->comment('已使用次數');
$table->timestamp('expires_at')->nullable()->comment('到期時間');
$table->string('status')->default('active')->comment('狀態: active, disabled, used, expired');
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->index(['company_id', 'machine_id', 'code', 'status']);
});
Schema::create('welcome_gift_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->onDelete('cascade');
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
$table->foreignId('welcome_gift_id')->constrained('welcome_gifts')->onDelete('cascade');
$table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null');
$table->string('action')->comment('動作: verify_success, consume, cancel');
$table->string('remark')->nullable();
$table->json('raw_data')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('welcome_gift_logs');
Schema::dropIfExists('welcome_gifts');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->foreignId('welcome_gift_id')
->nullable()
->after('code_id')
->constrained('welcome_gifts')
->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropForeign(['welcome_gift_id']);
$table->dropColumn('welcome_gift_id');
});
}
};

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