Compare commits

..

1 Commits

Author SHA1 Message Date
sky121113
9bc6450fac [FEAT] 遷移機台系統設定至機台管理模組並優化 UI
1. 修正客戶管理頁面 H1 標題與麵包屑導覽路徑。
2. 在機台編輯頁面實作分頁功能(基本、營運、機台系統設定)。
3. 實作極簡奢華風的機台系統設定 UI,按功能模組(電子發票、刷卡、支付、購物車等)進行卡片式分組。
4. 將系統設定資料從 Company JSON 遷移至 Machine 資料表獨立欄位,並更新關聯控制器與模型邏輯。
5. 清理 CompanyController 與視圖中的冗餘設定邏輯。
6. 完成現有資料遷移,確保機台正確繼承客戶層級之設定。
7. 優化倉儲管理「現有庫存」矩陣視圖與導覽功能。
2026-04-24 08:30:25 +08:00
346 changed files with 8923 additions and 59827 deletions

View File

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

View File

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

View File

@ -19,7 +19,6 @@ 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` |
---
@ -43,8 +42,4 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
必須讀取:
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用
### 🔴 新增或修改語系 JSON 檔案lang/*.json或執行多語系任務時
必須讀取:
1. **multilingual-specs** — 確保遵守三語系對齊、字母排序、斜線不逸出規範
2. **rbac-rules** — 確保 `company_id` 隔離邏輯正確套用

View File

@ -16,6 +16,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
### 2.1 維運人員認證 (User Authentication)
- **核發端點**B000 (登入)。
- **使用端點**B014 (參數下載)。
- **方式**:使用 Laravel Sanctum 核發之 **User Token**
- **Header**`Authorization: Bearer <user_token>`。
@ -29,30 +30,27 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
## 3. 機台核心 API (IoT Endpoints)
### 3.1 B000: 機台本地管理員同步登入
用於機台 Android 端維護人員登入與進入設定頁。此 API 需帶入機台 API Token 進行認證
用於機台 Android 端維護人員登入與進入設定頁。此 API 無狀態,且為例外不強制檢查 Bearer Token 的端點
- **URL**: POST /api/v1/app/admin/login/B000
- **Authentication**: Bearer Token (Header)
- **Headers**:
- `Content-Type: application/json`
- `Authorization: Bearer <api_token>`
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
| Su_Account | String | 是 | 系統管理員或公司管理員帳號 | admin |
| Su_Password | String | 是 | 密碼 | password123 |
| ip | String | 否 | 機台本地 IP | 192.168.1.100 |
| ip | String | 否 | 用戶端 IP (相容舊版) | 192.168.1.100 |
| type | String | 否 | 裝置類型代碼 (相容舊版) | 2 |
- **Response Body:**
> [!IMPORTANT]
> 為了相容 Java APP 現有邏輯,這裡嚴格規定成功必須回傳字串 Success。
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | true |
| code | Integer | 業務狀態碼 | 200 |
| message | String | 驗證結果 (Success 或 Failed) | Success |
| identity | String | **登入者身分**`system` 系統登入者 / `company` 公司帳號 / `staff` 一般人員。僅驗證成功時回傳 | system |
| token | String | **臨時身份認證 Token** (可選,供未來擴充使用) | 1\|abcdefg... |
| token | String | **臨時身份認證 Token** (用於 B014) | 1|abcdefg... |
---
@ -82,20 +80,21 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
### 3.3 B009: 貨道庫存即時回報 (Supplementary Report)
當維修或補貨人員在機台端完成操作後,將目前的貨道實體狀態同步回雲端。
- **URL**: PUT /api/v1/app/products/supplementary/B009
- **Authentication**: Bearer Token (Header)
- **Request Body:**
- **Request Body:** 無 (由 Token 自動識別機台)
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| account | String | 是 | 執行補貨的人員帳號 (Username 或 Email),用於權限驗證 | admin |
| data | Array/Object | 是 | 貨道數據陣列 (若傳單一物件會自動相容為陣列) | [{"tid": "1", "t060v00": "1", "num": 10, "type": 1}] |
| data | Array | 是 | 貨道數據陣列 | [{"tid":"1", "t060v00":"1", "num":"10"}] |
- **data 陣列內部欄位:**
| 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| tid | String | 貨道編號 (Slot No) | "1" |
| tid | Integer | 貨道編號 (Slot No) | 1 |
| t060v00 | String | 商品資料庫 ID (或是 Barcode) | "1" |
| num | Integer | 實體剩餘庫存數量 | 10 |
| type | Integer | 貨道物理類型 (1: 履帶, 2: 彈簧)。若未提供,預設為 1。 | 1 |
@ -111,14 +110,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| success | Boolean | 同步是否成功 | true |
| code | Integer | 內部業務狀態碼 | 200 |
| message | String | 回應訊息 | Slot report synchronized success |
- **Response Body (Failed 403 - 權限不足):**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| success | Boolean | 請求是否成功 | false |
| code | Integer | HTTP 狀態碼 | 403 |
| message | String | 失敗原因 | Unauthorized: Account not found |
| status | String | 固定回傳 49 代表同步完成 | "49" |
---
@ -137,12 +129,10 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| 欄位 | 型別 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| t060v00 | String | 商品資料庫 ID | "1" |
| 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"} |
| t060v01 | String | 商品名稱 | 可口可樂 330ml |
| t060v01_en | String | 英文名稱 | Coca Cola |
| t060v01_jp | String | 日文名稱 | コカコーラ |
| t060v03 | String | 商品規格/簡述 | Cold Drink |
| t060v06 | String | 圖片 URL | https://.../coke.png |
| t060v09 | Float | 標準零售價 | 25.0 |
| t060v11 | Integer | **貨道庫存上限** (預設履帶) | 10 |
@ -153,12 +143,6 @@ 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] 機台故障與異常狀態上報
@ -166,226 +150,118 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
> **此 REST API 已廢棄並移除**。機台異常上報已全面遷移至 **MQTT** 即時通訊架構。
> 詳細錯誤代碼請參閱:[MQTT 通訊規範 - B013 硬體代碼對照表](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#32-異常與錯誤上報-b013)
### 3.12 B010: [DEPRECATED / REMOVED] 心跳上報與狀態同步
> [!WARNING]
> **此 REST API 已廢棄並移除**。機台心跳上報與指令下發已全面遷移至 **MQTT** 架構。
> (註:溫度數據已標準化為 **Integer**,且雲端採「變動即記錄」規則)。
> 詳細格式請參閱:[MQTT 通訊規範 - B010 心跳上報](file:///home/mama/projects/star-cloud/.agents/skills/mqtt-communication-specs/SKILL.md#31-心跳上報-b010)
---
### 3.7 B014: 機台參數與金鑰下載 (Config Download)
用於機台引導階段 (Provisioning),向雲端請求支付金鑰、發票設定及機台正式 API Token。
- **URL**: GET /api/v1/app/machine/setting/B014
- **Authentication**: None (由 Query String 帶入 `machine` 參數識別)
- **Request Body:**
- **Authentication**: **User Token** (Sanctum Header)
- **Request Body:** (由 Query String 帶入 `machine` 參數)
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
- **資料來源**: 所有金流密鑰與電子發票設定,均取自機台關聯的「金流配置 (`payment_configs.settings`)」巢狀結構;機台須先在後台關聯一組金流配置 (`machines.payment_config_id`),未設值欄位回傳空字串 `""`
- **Response Body (Success 200 - Object):**
| 欄位 (Key) | 說明 | 來源 (payment_configs.settings) |
| :--- | :--- | :--- |
| **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 |
| 欄位 (Key) | 說明 | 備註 |
| :--- | :--- | :--- |
| 403 | 非系統管理員 / 未登入 | Forbidden |
| 404 | 查無該序號機台 | Machine not found |
| 422 | `settings` 非物件/陣列,或未含任何有效貨道鍵 | Invalid settings payload |
| **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 |
> 註:寫回時更新 `is_spring_slot_*` 實體欄位並記錄 `updater_id`。為 system-admin 專用,套用 `withoutGlobalScopes` 跨租戶定位機台。
> [!CAUTION]
> **安全性規範**B014 會回傳敏感金鑰與正式 Token背景必須強制進行 RBAC 校驗。只有當前登入的人員具備該機台管理權限時,後端才允許發放資料。
---
### 3.9 B660: 取貨碼驗證 (Pickup Code)
處理機台端的 8 位數代碼取貨流程。
### 3.8 B017: 貨道庫存同步 (Slot Synchronization)
用於機台端**主動**向遠端後台拉取目前所有貨道的最新庫存、效期與配置狀態。通常發生於機台開機初始化、補貨/維護模式結束,或是機台需要確保本地端庫存資料與雲端一致時主動請求。
- **URL**: POST /api/v1/app/machine/pickup/verify/B660
- **URL**: GET /api/v1/app/machine/reload_msg/B017
- **Authentication**: Bearer Token (Header)
- **運作模式**:
- **POST (驗證)**: 提交 `code` 驗證。成功後回傳 `pickup_code_id` 與建議 `slot_no`
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息,並在 `payment_type` 帶入 `6` (取貨碼) 完成扣減。
- **Request Body:** 無 (由 Token 自動識別機台)
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| code | String | 8 位數取貨碼 | "12345678" |
---
### 3.10 B670: 通行碼驗證 (Pass Code)
專供維修或工程人員現場測試使用的通行碼。僅做身分確認,不涉及庫存消耗。
- **URL**: POST /api/v1/app/machine/passcode/verify/B670
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 8 位數通行碼 | "88888888" |
**Response (Success 200):**
- `name`: 持有者姓名 (如:維護部 A 員)。
- `status`: "active"。
---
### 3.11 B680: 員工卡身分驗證 (Staff Card Verification)
機台端感應員工卡 (RFID/NFC) 後驗證卡片 UID。
- **URL**: POST /api/v1/app/machine/staff/verify/B680
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 卡片 UID 碼 | "A1B2C3D4" |
**Response (Success 200):**
- `name`: 員工姓名。
- `code_id`: 員工卡 ID (用於 MQTT 交易關聯)。
- `status`: "active"。
> [!WARNING]
> **鎖定機制**B660, B670, B680 與 B690 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
---
### 3.12 B690: 來店禮驗證 (Welcome Gift Verify)
處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊供機台端套用折扣。
- **URL**: POST /api/v1/app/machine/welcome-gift/verify/B690
- **Authentication**: Bearer Token (Header)
- **Request Body:**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 8 位數來店禮代碼 | "12345678" |
**Response (Success 200):**
- **Response Body:**
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| 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 |
| code | Integer | 200 | 200 |
| data | Array | 貨道數據陣列 (依 slot_no 排序) | 見下表 |
**Response (Failed 404):**
**data 陣列內部欄位:**
| 參數 | 類型 | 說明 | 範例 |
| 欄位 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| 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` (來店禮) 完成扣減。
| tid | String | 貨道編號 | "1" |
| num | Integer | 當前庫存數量 | 10 |
| expiry_date | String | 商品效期 | "2026-12-31" |
| batch_no | String | 批號 | "B202604" |
| product_id | Integer | 商品 ID | 1 |
| capacity | Integer | 貨道容量上限 | 15 |
| status | String | 貨道狀態 ("1": 啟用, "0": 停用) | "1" |
---
### 3.14 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程
### 3.9 B024: 取貨碼/通行碼驗證與消耗回報 (Access Code Verify & Report)
用於處理機台端的代碼取貨流程。包含驗證代碼有效性(驗證階段)與確認出貨完成後的狀態消耗(回報階段)。
- **URL**: POST /api/v1/app/sell/free-gift/B027
- **URL**: POST|PUT /api/v1/app/sell/access-code/B024
- **Authentication**: Bearer Token (Header)
- **運作模式**:
- **POST**: 提交 `passCode` 驗證。成功後雲端會回傳對應活動 ID 與商品配置。
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息進行消耗。
---
### 3.15 B650: 會員身分驗證 (Member Verification)
機台端掃碼後驗證會員身分。
- **URL**: POST /api/v1/app/machine/member/verify/B650
- **Authentication**: Bearer Token (Header)
- **Request Body:**
- **Request Body (POST - 驗證階段):**
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| code | String | 是 | 會員代碼 | "MEMBER12345" |
| passCode | String | 是 | 使用者輸入的取貨碼/通行碼 | "12345678" |
- **Response Body (POST - 驗證成功 200):**
雲端將回傳該碼對應的權限或待出貨商品資訊。
| 參數 | 類型 | 說明 | 範例 |
| :--- | :--- | :--- | :--- |
| res1 | String | 該代碼在雲端的關聯 ID | "99" |
| res2 | String | 操作模式 (1: 出貨, 2: 僅驗證) | "1" |
| res3 | String | 預計出貨商品 ID | "5" |
| res4 | String | 折扣金額或活動標籤 | "0.0" |
- **Request Body (PUT - 回報階段):**
當機台端確認實體出貨成功後,必須發送此請求以耗刷該代碼。
| 參數 | 類型 | 必填 | 說明 | 範例 |
| :--- | :--- | :--- | :--- | :--- |
| accessCodeId | String | 是 | 驗證階段取得的 res1 (ID) | "99" |
| status | String | 是 | 出貨結果 (1: 成功, 0: 失敗) | "1" |
---
### 3.10 B027: 贈品碼/優惠券驗證與消耗回報 (Free Gift Verify & Report)
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
- **URL**: POST|PUT /api/v1/app/sell/free-gift/B027
- **Authentication**: Bearer Token (Header)
- **運作模式**:
- **POST**: 提交 `passCode` 驗證該贈品券是否有效。成功後雲端會回傳對應活動 ID 與商品配置。
- **PUT**: 當該贈品0 元商品)出貨完成後,向雲端回報消耗,確保優惠券不會重複核銷。
> [!NOTE]
> B027 與 B024 的邏輯具備高度對稱性,區別在於 B027 通常綁定的是特定的行銷活動 (Campaign) 與 0 元出貨邏輯。
---
## 4. 已遷移至 MQTT 的端點索引
@ -394,47 +270,9 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| 原 API 代碼 | 名稱 | MQTT Topic | 備註 |
| :--- | :--- | :--- | :--- |
| B010 | 心跳上報 | `machine/{serial_no}/heartbeat` | [遷移](#312-b010-deprecated--removed-心跳上報與狀態同步) |
| B013 | 異常上報 | `machine/{serial_no}/error` | [遷移](#36-b013-deprecated--removed-機台故障與異常狀態上報) |
| B010 | 心跳上報 | `machine/{serial_no}/heartbeat` | [廢棄](#312-b010-deprecated--removed-心跳上報與狀態同步) |
| B013 | 異常上報 | `machine/{serial_no}/error` | [廢棄](#36-b013-deprecated--removed-機台故障與異常狀態上報) |
| B600 | 交易建立 | `machine/{serial_no}/transaction` | `action: create` |
| B601 | 發票紀錄 | `machine/{serial_no}/transaction` | `action: invoice` |
| B602 | 出貨結果 | `machine/{serial_no}/transaction` | `action: dispense` |
| B600+ | 交易完成(統整) | `machine/{serial_no}/transaction` | `action: finalize` |
| [New] | 商品同步指令 | `machine/{serial_no}/command` | `command: update_products` |
| B055 (PUT) | 遠端出貨回報 | `machine/{serial_no}/command/ack` | GET 查詢路由保留 |
---
## 附錄 (Appendix)
### Appendix A: B600 支付類型對照表 (`payment_type`)
此對照表依據機台 B600 規格定義,用於解析 `order.payment_type`
| 代碼 | 支付方式 | 備註 / 說明 |
| :--- | :--- | :--- |
| `1` | 信用卡 | 一般實體信用卡刷卡 |
| `2` | 電子票證 | 包含悠遊卡、一卡通、iCash 等(與信用卡、手機支付共用同一台實體刷卡機)|
| `3` | 掃碼支付 | 一般掃碼支付 |
| `4` | 投幣機支付 | 實體紙鈔機 |
| `5` | 通行碼 | 現場維護人員測試使用 |
| `6` | 取貨碼 | 包含 8 位數取貨碼與 0 元贈品碼 |
| `7` | 來店禮 | |
| `8` | 問卷 | |
| `9` | 零錢 | 硬幣投幣機 |
| `10` | 手機支付 | NFC 手機感應(行動支付,走信用卡軌道,依使用者按鈕分類) |
| `21` ~ `25` | 線下付款 + X | `21`=線下+1, `22`=線下+2... `25`=線下+9 |
| `30` | TapPay - LINE Pay | TapPay 底下的 LINE Pay |
| `31` | TapPay - 街口支付 | |
| `32` | TapPay - 悠遊付 | |
| `33` | TapPay - Pi 拍錢包 | |
| `34` | TapPay - 全盈+PAY | |
| `40` | 會員驗證取貨商品 | |
| `41` | 員工卡 | RFID / NFC 刷卡 |
| `42` | 取物單 | |
| `50` ~ `54` | 線下付款 + TapPay | `50`=線下+30, `51`=線下+31... `54`=線下+34 |
| `70` | LINE Pay官方直連 | LINE Pay 官方直連,非 TapPay 底下的 `30` |
| `60` | 點數 / 優惠卷全額折抵 | 點數全額折抵出貨 或 優惠卷全額折抵出貨 |
| `61` ~ `69` | 會員 + X | `61`=會員+1, `62`=會員+2... `69`=會員+9 |
| `90` ~ `94` | 會員 + TapPay | `90`=會員+30, `91`=會員+31... `94`=會員+34 |
| `100` | 遠端出貨 | 後台遠端觸發出貨 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ on:
jobs:
deploy-demo:
runs-on: [ubuntu-latest, demo]
runs-on: ubuntu-latest # 使用機器人預設宣告的標籤
steps:
- name: Checkout Code
uses: actions/checkout@v3
@ -34,6 +34,7 @@ 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
@ -80,4 +81,4 @@ jobs:
docker restart star-cloud-mqtt-worker
- name: Step 5 - Cleanup
run: docker image prune -f --filter "until=168h"
run: docker image prune -f

View File

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

View File

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

6
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -1,119 +0,0 @@
<?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

@ -1,113 +0,0 @@
<?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

@ -1,54 +0,0 @@
<?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

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

View File

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

View File

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

View File

@ -16,634 +16,21 @@ class AnalysisController extends Controller
]);
}
public function machineReports(Request $request)
// 機台報表分析
public function machineReports()
{
$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', [
return view('admin.placeholder', [
'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(Request $request)
public function productReports()
{
$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', [
return view('admin.placeholder', [
'title' => '商品報表分析',
'description' => '商品銷售數據 analysis',
'companies' => $companies,
'machines' => $machines,
'products' => $products,
'selectedCompanyId' => $selectedCompanyId,
'selectedMachineId' => $selectedMachineId,
'selectedProductId' => $selectedProductId,
'dateRange' => $dateRange,
'summary' => $summary,
'chartData' => $chartData,
'tableData' => $tableData,
'description' => '商品銷售數據分析',
]);
}

View File

@ -1,199 +0,0 @@
<?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

@ -1,203 +0,0 @@
<?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,22 +22,13 @@ 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',
]);
$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,
MachineModel::create(array_merge($validated, [
'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.'));
@ -56,20 +47,11 @@ 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',
]);
$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,
$machine_model->update(array_merge($validated, [
'updater_id' => auth()->id(),
]);
]));
return redirect()->route('admin.basic-settings.machines.index', ['tab' => 'models'])
->with('success', __('Machine model updated successfully.'));

View File

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

View File

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

View File

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

View File

@ -25,9 +25,6 @@ class GeocodingController extends Controller
$address = $request->input('address');
// 1. 清洗台灣地址中的樓層、室、地下室與括號備註,避免干擾地理編碼解析
$address = $this->cleanFloorInfo($address);
// 策略 1結構化搜尋台灣地址精確度最高
$parsed = $this->parseTaiwanAddress($address);
@ -117,8 +114,8 @@ class GeocodingController extends Controller
$streetPart = $address;
}
// 拆分路名與門牌號碼(層級感知台灣地址結構,完整保留巷弄)
preg_match('/^(.+?(?:路|街|大道)(?:[一二三四五六七八九十]+段)?(?:[0-9一二三四五六七八九十百]+巷)?(?:[0-9一二三四五六七八九十百]+弄)?)\s*(.*)/u', $streetPart, $roadMatch);
// 拆分路名與門牌號碼
preg_match('/^(.+?(?:路|街|大道|巷|弄)(?:[一二三四五六七八九十]+段)?)\s*(.*)/u', $streetPart, $roadMatch);
$street = '';
$houseNumber = '';
@ -137,21 +134,4 @@ 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

@ -1,98 +0,0 @@
<?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,9 +32,10 @@ class MachinePermissionController extends AdminController
}])
->whereNotNull('company_id');
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
$userQuery->visibleTo($currentUser);
if ($currentUser->isSystemAdmin() && $company_id) {
// 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入)
if (!$currentUser->isSystemAdmin()) {
$userQuery->where('company_id', $currentUser->company_id);
} elseif ($company_id) {
// 系統管理員的篩選邏輯
$userQuery->where('company_id', $company_id);
}
@ -53,31 +54,6 @@ 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: 取得特定帳號的機台分配狀態
*/
@ -85,18 +61,17 @@ class MachinePermissionController extends AdminController
{
$currentUser = auth()->user();
// 層級越權防護:只能操作可管轄範圍內的帳號。
if (!$currentUser->canManageAccount($user)) {
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// 可授權機台清單限縮為「操作者本身可授權」的機台子集,避免把自己沒有的機台授權出去。
$assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
// 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示)
$machines = Machine::withoutGlobalScope('machine_access')
->whereIn('id', $assignableIds)
->where('company_id', $user->company_id)
->get(['id', 'name', 'serial_no']);
$assignedIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id')->toArray();
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
return response()->json([
'user' => $user,
@ -112,8 +87,8 @@ class MachinePermissionController extends AdminController
{
$currentUser = auth()->user();
// 層級越權防護:只能操作可管轄範圍內的帳號。
if (!$currentUser->canManageAccount($user)) {
// 安全檢查
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
@ -122,27 +97,20 @@ class MachinePermissionController extends AdminController
'machine_ids.*' => 'exists:machines,id'
]);
// 授權子集約束:被指派的機台必須是「操作者本身可授權機台」的子集,
// 既確保同公司,也避免子帳號把自己沒被授權的機台授權給他人。
$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);
// 加固驗證:確保所有機台 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);
}
}
// 只在「操作者可授權子集」範圍內做增刪;範圍外的既有授權一律保留,避免覆蓋式 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());
}
$user->machines()->sync($request->machine_ids ?? []);
$message = __('Machine permissions updated successfully.');
session()->flash('success', $message);

View File

@ -2,15 +2,9 @@
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
@ -22,10 +16,8 @@ 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')) {
@ -35,36 +27,13 @@ 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();
$companies = $currentUser->isSystemAdmin()
? Company::select('id', 'name', 'code')->orderBy('name')->get()
: collect();
return view('admin.machines.index', compact('machines', 'companies'));
return view('admin.machines.index', compact('machines'));
}
/**
@ -74,48 +43,10 @@ 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.'));
}
@ -208,12 +139,9 @@ 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()
@ -221,7 +149,7 @@ class MachineController extends AdminController
// 將待處理狀態注入貨道物件
$slots->each(function ($slot) use ($pendingSlotNos) {
$slot->is_pending = in_array((int) $slot->slot_no, $pendingSlotNos);
$slot->is_pending = in_array((int)$slot->slot_no, $pendingSlotNos);
});
return response()->json([
@ -236,11 +164,6 @@ class MachineController extends AdminController
*/
public function updateSlotExpiry(Request $request, Machine $machine)
{
// 確保庫存數值為整數,處理如 "01" 轉為 1 的情況
if ($request->has('stock') && !is_null($request->stock) && $request->stock !== '') {
$request->merge(['stock' => (int) $request->stock]);
}
$validated = $request->validate([
'slot_no' => 'required|integer',
'stock' => 'nullable|integer|min:0',
@ -265,117 +188,6 @@ 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)
*/
@ -404,81 +216,4 @@ class MachineController extends AdminController
{
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
}
/**
* AJAX: 清除機台所有未解決的警告與異常 (手動排除)
*/
public function resolveLogs(Machine $machine)
{
$machine->logs()
->where('is_resolved', false)
->whereIn('level', ['error', 'warning'])
->update(['is_resolved' => true]);
return response()->json([
'success' => true,
'message' => __('All issues marked as resolved.')
]);
}
/**
* AJAX: 取得機台溫度歷史紀錄 (供圖表使用)
*/
public function temperatureAjax(Request $request, Machine $machine)
{
$startDate = $request->get('start_date');
$endDate = $request->get('end_date');
$logs = $machine->logs()
->where('message', 'Temperature reported: :temp°C')
->when($startDate, function ($query, $start) {
return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->oldest()
->get();
$chartData = $logs->map(fn($log) => [
'x' => $log->created_at->getTimestamp() * 1000,
'y' => (int) ($log->context['temp'] ?? 0),
])->values()->toArray();
return response()->json([
'success' => true,
'data' => $chartData
]);
}
/**
* AJAX: 取得機台環境溫度歷史紀錄 (供圖表使用)
*/
public function ambientTemperatureAjax(Request $request, Machine $machine)
{
$startDate = $request->get('start_date');
$endDate = $request->get('end_date');
$logs = $machine->logs()
->where('type', 'ambient_temp')
->where('message', 'Ambient temperature reported: :temp°C')
->when($startDate, function ($query, $start) {
return $query->where('created_at', '>=', str_replace('T', ' ', $start));
})
->when($endDate, function ($query, $end) {
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
})
->oldest()
->get();
$chartData = $logs->map(fn($log) => [
'x' => $log->created_at->getTimestamp() * 1000,
'y' => (int) ($log->context['temp'] ?? 0),
])->values()->toArray();
return response()->json([
'success' => true,
'data' => $chartData
]);
}
}

View File

@ -5,7 +5,6 @@ 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;
@ -22,9 +21,6 @@ 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');
@ -42,16 +38,9 @@ class MaintenanceController extends Controller
$query->where('category', $request->category);
}
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
$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'));
return view('admin.maintenance.index', compact('records'));
}
/**

View File

@ -10,30 +10,30 @@ class PermissionController extends Controller
// 權限角色設定
public function roles()
{
$per_page = request()->input('roles_per_page', request()->input('per_page', 10));
$per_page = request()->input('per_page', 10);
$user = auth()->user();
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
// 層級隔離:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己建立的角色。
$query->visibleTo($user);
// 租戶隔離:租戶只能看到自己公司的角色
if (!$user->isSystemAdmin()) {
$query->where('company_id', $user->company_id);
}
// 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search
$search = request()->input('roles_search', request()->input('search'));
if ($search) {
// 搜尋:角色名稱
if ($search = request()->input('search')) {
$query->where('name', 'like', "%{$search}%");
}
// 篩選:公司名稱(支援 roles_company_id 命名空間 與舊版 company_id
$company_id = request()->input('roles_company_id', request()->input('company_id'));
if ($user->isSystemAdmin() && filled($company_id)) {
if ($company_id === 'system') {
// 篩選:公司名稱 (僅限系統管理員)
if ($user->isSystemAdmin() && request()->filled('company_id')) {
if (request()->company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', $company_id);
$query->where('company_id', request()->company_id);
}
}
$roles = $query->latest()->paginate($per_page, ['*'], 'roles_page')->withQueryString();
$roles = $query->latest()->paginate($per_page)->withQueryString();
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
// 權限分組邏輯中的標題與過濾
@ -66,9 +66,13 @@ class PermissionController extends Controller
$role = new \App\Models\System\Role();
$user = auth()->user();
// 權限遞迴約束由 getFilteredPermissions 處理
// 權限遞迴約束
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
$all_permissions = $this->getFilteredPermissions($user);
$all_permissions = $permissionQuery->get()->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
$title = request()->routeIs('*.sub-account-roles.create') ? __('Create Sub Account Role') : __('Create New Role');
$back_url = request()->routeIs('*.sub-account-roles.create')
@ -86,14 +90,21 @@ 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.'));
// 權限遞迴約束:租戶管理員只能看到並指派自己擁有的權限
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
// 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理
$all_permissions = $this->getFilteredPermissions($user);
// 權限分組邏輯
$all_permissions = $permissionQuery->get()
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(function($perm) {
if (str_starts_with($perm->name, 'menu.')) {
return 'menu';
}
return 'other';
});
// 根據路由決定標題
$title = request()->routeIs('*.sub-account-roles.edit') ? __('Edit Sub Account Role') : __('Edit Role Permissions');
@ -129,7 +140,6 @@ 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,
]);
@ -146,7 +156,7 @@ class PermissionController extends Controller
// 如果不是系統角色,排除主選單的系統權限
if (!$is_system) {
$perms = array_diff($perms, ['menu.basic', 'menu.permissions']);
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
}
$role->syncPermissions($perms);
}
@ -186,11 +196,6 @@ 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 = [
@ -213,7 +218,7 @@ class PermissionController extends Controller
// 如果不是系統角色,排除主選單的系統權限
if (!$is_system) {
$perms = array_diff($perms, ['menu.basic', 'menu.permissions']);
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']);
}
$role->syncPermissions($perms);
@ -232,20 +237,10 @@ 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.'));
}
@ -264,69 +259,85 @@ class PermissionController extends Controller
{
$user = auth()->user();
$isSubAccountRoute = $request->routeIs('admin.data-config.sub-accounts');
$tab = $request->input('tab', 'accounts'); // 僅用於設定 Alpine activeTab 初始值
$tab = $request->input('tab', 'accounts');
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
// ── 帳號列表 ──────────────────────────────────────────────────
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines'])->visibleTo($user);
if ($search = $request->input('accounts_search', $request->input('search'))) {
$usersQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($user->isSystemAdmin() && $request->filled('accounts_company_id')) {
$cid = $request->input('accounts_company_id');
$cid === 'system' ? $usersQuery->whereNull('company_id') : $usersQuery->where('company_id', $cid);
}
$accounts_per_page = $request->input('accounts_per_page', 10);
$users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString();
// Modal 用的角色選單(不分頁)。層級隔離:子帳號僅見自己建立的角色(與角色清單一致)。
// 預先載入 permissions 以避免下方子集過濾造成 N+1。
$rolesForSelect = \App\Models\System\Role::query()->with('permissions')->visibleTo($user);
$roles = $rolesForSelect->get();
// 子集過濾:租戶只能指派「權限為自身子集」的角色(與 store/update 的後端 guard 一致,避免下拉出現越權選項)。
if (!$user->isSystemAdmin()) {
$operatorPerms = $user->getAllPermissions()->pluck('name');
$roles = $roles->filter(
fn($r) => collect($r->getPermissionNames())->diff($operatorPerms)->isEmpty()
)->values();
}
// ── 角色列表 (僅 data-config Tab 版需要) ─────────────────────
// 初始化變數
$users = collect();
$roles = collect();
$paginated_roles = collect();
$all_permissions = collect();
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
if ($isSubAccountRoute) {
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user);
if ($search = $request->input('roles_search')) {
$rolesQuery->where('name', 'like', "%{$search}%");
}
if ($user->isSystemAdmin() && $request->filled('roles_company_id')) {
$cid = $request->input('roles_company_id');
$cid === 'system' ? $rolesQuery->where('is_system', true) : $rolesQuery->where('company_id', $cid);
}
$roles_per_page = $request->input('roles_per_page', 10);
$paginated_roles = $rolesQuery->latest()->paginate($roles_per_page, ['*'], 'roles_page')->withQueryString();
if ($isSubAccountRoute && $tab === 'roles') {
// 處理角色分頁邏輯 (移植自 roles())
$per_page = $request->input('per_page', 10);
$roles_query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
if (!$user->isSystemAdmin()) {
$roles_query->where('company_id', $user->company_id);
}
if ($search = $request->input('search')) {
$roles_query->where('name', 'like', "%{$search}%");
}
if ($user->isSystemAdmin() && $request->filled('company_id')) {
if ($request->company_id === 'system') {
$roles_query->where('is_system', true);
} else {
$roles_query->where('company_id', $request->company_id);
}
}
$paginated_roles = $roles_query->latest()->paginate($per_page)->withQueryString();
// 權限分組邏輯
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
$all_permissions = $permissionQuery->get()
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
} else {
// 處理帳號名單邏輯
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
if (!$user->isSystemAdmin()) {
$query->where('company_id', $user->company_id);
}
if ($search = $request->input('search')) {
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($user->isSystemAdmin() && $request->filled('company_id')) {
if ($request->company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', $request->company_id);
}
}
$per_page = $request->input('per_page', 10);
$users = $query->latest()->paginate($per_page)->withQueryString();
$roles_query = \App\Models\System\Role::query();
if (!$user->isSystemAdmin()) {
$roles_query->forCompany($user->company_id);
}
$roles = $roles_query->get();
}
$title = $isSubAccountRoute ? __('Sub Account Management') : __('Account Management');
$view = $isSubAccountRoute ? 'admin.data-config.accounts' : 'admin.permission.accounts';
return view($view, compact(
return view('admin.data-config.accounts', compact(
'users', 'companies', 'roles', 'paginated_roles', 'all_permissions', 'title', 'tab', 'currentUserRoleIds'
));
}
@ -347,12 +358,6 @@ 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;
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
@ -399,7 +404,6 @@ class PermissionController extends Controller
'guard_name' => 'web',
'company_id' => $company_id,
'is_system' => false,
'is_company_admin' => true,
]);
$newRole->syncPermissions($role->getPermissionNames());
$role = $newRole;
@ -409,39 +413,6 @@ 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'],
@ -449,10 +420,8 @@ 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' => $isMainAccount,
'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])),
]);
$user->assignRole($role);
@ -471,11 +440,6 @@ 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,
@ -566,7 +530,7 @@ class PermissionController extends Controller
'guard_name' => 'web',
'company_id' => $target_company_id,
'is_system' => false,
'is_company_admin' => true,
'is_admin' => true,
]);
$newRole->syncPermissions($roleObj->getPermissionNames());
$roleObj = $newRole;
@ -575,14 +539,6 @@ 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 角色
@ -601,31 +557,15 @@ 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;
@ -646,57 +586,10 @@ class PermissionController extends Controller
return back()->with('error', __('Only Super Admins can change other Super Admin status.'));
}
// 層級越權防護:租戶只能切換可管轄範圍內的帳號狀態。
if (!auth()->user()->canManageAccount($user)) {
return back()->with('error', __('You do not have permission to manage this account.'));
}
$user->status = $user->status ? 0 : 1;
$user->save();
$statusText = $user->status ? __('Enabled') : __('Disabled');
return back()->with('success', __('Account :name status has been changed to :status.', ['name' => $user->name, 'status' => $statusText]));
}
/**
* 獲取過濾後的活動模組權限清單
*/
private function getFilteredPermissions($user)
{
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
$activeModules = [
'menu.machines',
'menu.warehouses',
'menu.sales',
'menu.analysis',
'menu.data-config',
'menu.remote',
'menu.basic',
'menu.permissions',
];
return $permissionQuery->get()
->filter(function($p) use ($activeModules) {
if (str_starts_with($p->name, 'menu.')) {
foreach ($activeModules as $module) {
if ($p->name === $module || str_starts_with($p->name, $module . '.')) {
return true;
}
}
return false;
}
return true;
})
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(function($perm) {
if (str_starts_with($perm->name, 'menu.')) {
return 'menu';
}
return 'other';
});
}
}

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@ -19,15 +19,24 @@ class MachineAuthController extends Controller
*/
public function loginB000(Request $request): JsonResponse
{
// 1. 驗證帳密與 IP
// 1. 驗證欄位 (相容舊版 Java App 發送的 JSON 格式)
$validated = $request->validate([
'machine' => 'required|string',
'Su_Account' => 'required|string',
'Su_Password' => 'required|string',
'ip' => 'nullable|string',
'type' => 'nullable|string',
]);
// 2. 取得機台物件 (由 iot.auth 中介層注入)
$machine = $request->get('machine');
// 2. 取得機台物件 (需優先於帳密驗證,以便記錄日誌到正確機台)
$machine = Machine::withoutGlobalScopes()->where('serial_no', $validated['machine'])->first();
if (!$machine) {
Log::warning("B000 機台登入失敗: 伺服器找不到該機台", [
'machine_serial' => $validated['machine']
]);
return response()->json(['message' => 'Failed']);
}
// 3. 透過帳號尋找使用者 (允許使用 username 或 email)
$user = User::where('username', $validated['Su_Account'])
@ -38,7 +47,7 @@ class MachineAuthController extends Controller
if (!$user || !Hash::check($validated['Su_Password'], $user->password)) {
Log::warning("B000 機台登入失敗: 帳密錯誤", [
'account' => $validated['Su_Account'],
'machine' => $machine->serial_no
'machine' => $validated['machine']
]);
// 寫入機台日誌
@ -51,30 +60,20 @@ class MachineAuthController extends Controller
'login'
);
return response()->json([
'success' => false,
'code' => 401,
'message' => 'Failed'
], 401);
return response()->json(['message' => 'Failed']);
}
// 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';
}
}
@ -93,11 +92,7 @@ class MachineAuthController extends Controller
'login'
);
return response()->json([
'success' => false,
'code' => 403,
'message' => 'Forbidden'
], 403);
return response()->json(['message' => 'Forbidden']);
}
// 6. 驗證完美通過!
@ -106,12 +101,6 @@ 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,
@ -123,10 +112,7 @@ class MachineAuthController extends Controller
);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Success',
'identity' => $identity,
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
]);
}

View File

@ -7,24 +7,13 @@ use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
use App\Models\Machine\MachineStockMovement;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCode;
use App\Models\StaffCard;
use App\Models\StaffCardLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Models\System\SystemOperationLog;
use App\Services\Machine\MachineService;
use App\Services\Product\ProductCatalogService;
class MachineController extends Controller
{
@ -199,6 +188,18 @@ class MachineController extends Controller
}
/**
* B710: Sync Timer status (Asynchronous)
*/
public function syncTimer(Request $request)
{
$machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessTimerStatus::dispatch($machine->serial_no, $data);
return response()->json(['success' => true], 202);
}
/**
* B220: Sync Coin Inventory (Asynchronous)
@ -288,10 +289,10 @@ class MachineController extends Controller
return [
't070v01' => $ma->advertisement->name,
't070v02' => (int) ($ma->advertisement->duration ?? 15),
't070v03' => (int) ($posIdMap[$ma->position] ?? 1),
't070v02' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
't070v05' => (int) $ma->sort_order,
't070v05' => (string) $ma->sort_order,
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
'raw_sort' => (int) $ma->sort_order
];
@ -367,16 +368,10 @@ class MachineController extends Controller
$mappedSlots = array_map(function ($item) {
return [
'slot_no' => isset($item['tid']) ? (string) $item['tid'] : null,
'slot_no' => $item['tid'] ?? null,
'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,
'stock' => $item['num'] ?? 0,
'type' => $item['type'] ?? null,
];
}, $legacyData);
@ -390,20 +385,49 @@ class MachineController extends Controller
'success' => true,
'code' => 200,
'message' => __('Slot report synchronized success'),
'status' => '49'
]);
}
/**
* B012_new: Download Product Catalog (Synchronous)
*/
public function getProducts(Request $request, ProductCatalogService $catalogService)
public function getProducts(Request $request)
{
$machine = $request->get('machine');
// 公司基底目錄(快取) + 該機台專屬定價覆蓋(即時套用,見 getMachinePayload
$payload = $catalogService->getMachinePayload($machine);
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->with(['translations'])
->active()
->get()
->map(function ($product) {
// 提取多語系名稱 (Extract translations)
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
return response()->json($payload);
return [
't060v00' => (string) $product->id, // ID 仍建議維持字串,增加未來編號彈性
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v03' => $product->spec ?? '',
't060v06' => $product->image_url ? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url)) : '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '', // 行銷計畫
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '', // 物料編碼 (優先從 metadata 找,回退至條碼)
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $products
]);
}
@ -429,568 +453,43 @@ class MachineController extends Controller
], 404);
}
// 2. 獲取金流配置 (依據使用者要求,跳過權限校驗,只要有序號即可獲取)
// 來源統一為 payment_configs.settings 的巢狀結構 (對齊「編輯金流配置」表單)
// 2. 獲取關聯設定 (依據使用者要求,跳過權限校驗,只要有序號即可獲取)
$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
// 玉山掃碼 (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') ?? '',
// 玉山支付
't050v41' => $paymentSettings['esun_store_id'] ?? '',
't050v42' => $paymentSettings['esun_term_id'] ?? '',
't050v43' => $paymentSettings['esun_hash'] ?? '',
// 綠界電子發票 (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') ?? '',
// 電子發票 (綠界)
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
't050v35' => $companySettings['invoice_hash_key'] ?? '',
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
't050v38' => $companySettings['invoice_email'] ?? '',
// 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') ?? '',
// 趨勢支付 (TrendPay/Greenpay)
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
// 各類行動支付特店 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,
// 各類行動支付特店 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'] ?? '',
];
return response()->json([
'success' => true,
'code' => 200,
'data' => $data
]);
}
/**
* B016: Update Machine System Settings (Write-back from machine console)
* 機台主控台「系統設定」由系統方 (identity=system) 編輯後回寫雲端。
* 認證B000 核發的使用者 Token (auth:sanctum);僅系統管理員可操作。
* Body: { machine: 序號, settings: { is_spring_slot_*: bool, ... } }
* 機台端僅回寫硬體貨道類型 (is_spring_slot_*) 實體欄位;其餘支付旗標、
* 現金面額、功能模組等開關純由後台決定B016 不接收,未列入者一律忽略。
*/
public function updateSettings(Request $request)
{
// 1. 僅系統方可回寫雙重把關App 端隱藏控制 + 此處伺服器驗權)
$user = $request->user();
if (!$user || !$user->isSystemAdmin()) {
return response()->json([
'success' => false,
'code' => 403,
'message' => __('Forbidden')
], 403);
}
// 2. 查找機台
$serialNo = $request->input('machine');
$machine = Machine::withoutGlobalScopes()
->where('serial_no', $serialNo)
->first();
if (!$machine) {
return response()->json([
'success' => false,
'code' => 404,
'message' => __('Machine not found')
], 404);
}
$settings = $request->input('settings', []);
if (!is_array($settings)) {
return response()->json([
'success' => false,
'code' => 422,
'message' => __('Invalid settings payload')
], 422);
}
// 3. 直接欄位白名單machines 實體布林欄位(硬體貨道類型)
// 機台端 B016 僅回寫貨道類型,其餘系統設定由後台單向決定,不在此處接收。
$columnBoolKeys = [
'is_spring_slot_1_10', 'is_spring_slot_11_20', 'is_spring_slot_21_30',
'is_spring_slot_31_40', 'is_spring_slot_41_50', 'is_spring_slot_51_60',
];
// 4. 整理實體欄位更新(僅貨道類型,只覆寫有送來的鍵)
$columnData = [];
foreach ($columnBoolKeys as $k) {
if (array_key_exists($k, $settings)) {
$columnData[$k] = (bool) $settings[$k];
}
}
if (empty($columnData)) {
return response()->json([
'success' => false,
'code' => 422,
'message' => __('Invalid settings payload')
], 422);
}
$machine->update(array_merge($columnData, [
'updater_id' => $user->id,
]));
\Log::info('B016 Machine settings updated', [
'machine' => $machine->serial_no,
'user_id' => $user->id,
'keys' => array_keys($settings),
]);
return response()->json([
'success' => true,
'code' => 200,
'message' => __('Settings updated successfully.')
]);
}
/**
* B660: Verify/Consume Pickup Code
* POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌)
* PUT: 消耗取貨碼 (扣庫存)
*/
public function verifyPickupCode(Request $request)
{
return $this->handlePickupVerify($request, $request->get('machine'));
}
private function handlePickupVerify(Request $request, $machine)
{
$lockoutKey = "pickup_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Too many failed attempts.', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
}
$code = $request->input('code');
$pickupCode = PickupCode::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$pickupCode) {
$fails = Cache::increment("pickup_fails:{$machine->id}");
if ($fails === 1)
Cache::put("pickup_fails:{$machine->id}", 1, 600);
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60);
Cache::forget("pickup_fails:{$machine->id}");
}
ProcessStateLog::dispatch($machine->id, $machine->company_id, "[PickupCode] Verification failed: :code", 'info', ['code' => $code]);
return response()->json(['success' => false, 'message' => 'Invalid code', 'code' => 404, 'remaining_attempts' => 5 - $fails], 404);
}
Cache::forget("pickup_fails:{$machine->id}");
// 建立取貨碼專屬日誌 (驗證成功)
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'action' => 'verify_success',
'remark' => "log.pickup.verify_success",
'raw_data' => [
'machine_sn' => $machine->serial_no,
'code' => $pickupCode->code,
'slot' => $pickupCode->slot_no
]
]);
// 移除 StaffCardLog 重複紀錄,因為取貨碼本身有 status 和 order_id 可以追蹤
// 一般取貨碼的「核銷」仍由 MQTT finalizeTransaction 流程處理(行為不變)。
$data = [
'slot_no' => $pickupCode->slot_no, // 保留:既有綁貨道碼讀此欄(綁商品碼/領藥單為 null
'product_id' => $pickupCode->product_id, // 綁商品碼App 據此自挑可出貨道(綁貨道碼為 null
'pickup_code_id' => $pickupCode->id,
'code_id' => $pickupCode->id, // 統一回傳 code_id
'status' => $pickupCode->status,
];
// === 領藥單(pharmacy_pickup)擴充:回傳多貨道清單 + 標記碼已領(僅領藥單,不影響既有單貨道流程) ===
$order = $pickupCode->order;
$isPharmacy = $order && $order->order_type === \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
if ($isPharmacy) {
// Gate機台須仍為「取物單模式 + 領藥單開關開啟」才放行領藥碼
$settings = $machine->settings ?? [];
$enabled = (($settings['shopping_mode'] ?? null) === 'pickup_sheet')
&& (bool) ($settings['pharmacy_pickup_enabled'] ?? false);
if (!$enabled) {
return response()->json(['success' => false, 'message' => 'Pharmacy pickup not enabled on this machine', 'code' => 403], 403);
}
$pharmacyService = app(\App\Services\Transaction\PharmacyPickupService::class);
// 嚴格擋關:任一商品的貨道可出量 < 領藥單需求量 → 整單擋下、不出貨、不消耗領藥碼,回明確提示
$shortage = $pharmacyService->dispenseShortage($order);
if (!empty($shortage)) {
$detail = collect($shortage)
->map(fn ($s) => "{$s['product_name']}(需 {$s['required']}、可出 {$s['available']}")
->implode('、');
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.pharmacy_insufficient_stock',
'raw_data' => ['order_no' => $order->order_no, 'shortage' => $shortage],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '因貨道庫存不足,無法出貨:' . $detail,
], 409);
}
// 依目前貨道庫存解析多貨道出貨清單(一商品可跨多貨道湊量)
$items = $pharmacyService->resolveDispenseItems($order);
// 解析不到可出貨貨道(機台無對應貨道/庫存不足)→ 不消耗領藥碼,回明確錯誤讓藥師處理
if (empty($items)) {
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.pharmacy_no_slot',
'raw_data' => ['order_no' => $order->order_no, 'reason' => 'no available slot/stock'],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '此領藥單在本機台無可出貨貨道(請確認貨道對應與庫存)',
], 409);
}
// 不在「驗證/掃碼」當下核銷領藥碼。核銷統一改由機台實際出貨後的
// MQTT finalizeTransactionService::finalizePharmacyDispense標記為 used
// 這樣顧客掃碼後若未按「確定取貨」(逾時/取消、未出貨),領藥碼仍維持 active 可再次掃碼領取。
// (原本在此處 usage_count++/轉 used 會在尚未取貨時就把碼鎖死,導致再掃顯示「已領」。)
$data['order_type'] = \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
$data['order_no'] = $order->order_no;
$data['pricing_slip_no'] = $order->pricing_slip_no ?? ''; // 批價單號(藥師批價依據);機台「確認領藥」畫面備註欄顯示
$data['items'] = $items; // [{slot_no, product_id, product_name, qty}, ...]
$data['status'] = $pickupCode->status;
}
// === 綁商品取貨碼(非領藥單)擋關:機台須仍有此商品貨道,否則 App 必然挑不到貨道 ===
// 即時庫存/鎖定/效期由 App 刷碼當下自行判斷;此處只擋「機台根本沒上這商品」的明確錯誤。
if (!$isPharmacy && $pickupCode->product_id) {
$hasProduct = $machine->slots()->where('product_id', $pickupCode->product_id)->exists();
if (!$hasProduct) {
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.no_slot_for_product',
'raw_data' => ['code' => $pickupCode->code, 'product_id' => $pickupCode->product_id],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '此機台目前無此商品貨道,無法取貨',
], 409);
}
}
return response()->json([
'success' => true,
'code' => 200,
'data' => $data,
]);
}
/**
* B670: Verify Pass Code (For Technicians)
*/
public function verifyPassCode(Request $request)
{
$machine = $request->get('machine');
$lockoutKey = "passcode_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Locked', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails())
return response()->json(['success' => false, 'code' => 400], 400);
$code = $request->input('code');
$passCode = PassCode::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$passCode) {
$fails = Cache::increment("passcode_fails:{$machine->id}");
if ($fails >= 5)
Cache::put($lockoutKey, true, 60);
return response()->json(['success' => false, 'code' => 404], 404);
}
// 建立通行碼專屬日誌 (驗證成功)
PassCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pass_code_id' => $passCode->id,
'status' => 'verified',
'action' => 'verify_success',
'raw_data' => ['machine_sn' => $machine->serial_no, 'code' => $passCode->code]
]);
// 單次/限次通行碼:驗證成功即計為使用一次;達上限就標記 used
// 下次驗證會被上方 where('status','active') 過濾擋掉,達成「只能使用一次」。
// usage_limit 為 null未勾單次時不計數維持原本無限次行為。
if (!is_null($passCode->usage_limit)) {
$newCount = $passCode->usage_count + 1;
$passCode->update([
'usage_count' => $newCount,
'status' => $newCount >= $passCode->usage_limit ? 'used' : $passCode->status,
]);
}
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $passCode->name,
'code_id' => $passCode->id, // 統一回傳 code_id
'status' => 'active'
]
]);
}
/**
* B680: Verify Staff Card (New)
*/
public function verifyStaffCard(Request $request)
{
$machine = $request->get('machine');
$validator = Validator::make($request->all(), [
'code' => 'required|string', // 卡片 UID
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'code' => 400], 400);
}
$uid = $request->input('code');
// 搜尋該公司的有效員工卡 (遵循租戶隔離)
$staffCard = StaffCard::where('company_id', $machine->company_id)
->where('card_uid', $uid)
->where('status', 'active')
->first();
if (!$staffCard) {
ProcessStateLog::dispatch($machine->id, $machine->company_id, "[StaffCard] Verification failed: :uid", 'info', ['uid' => $uid]);
return response()->json(['success' => false, 'code' => 404, 'message' => 'Card not found or inactive'], 404);
}
// 建立刷卡日誌 (作為閉環起點)
StaffCardLog::create([
'company_id' => $machine->company_id,
'staff_card_id' => $staffCard->id,
'machine_id' => $machine->id,
'payment_type' => 41, // Staff Card
'code_id' => $staffCard->id,
'action' => 'verify_success',
'raw_data' => ['machine_sn' => $machine->serial_no, 'uid' => $uid]
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $staffCard->name,
'code_id' => $staffCard->id, // 統一回傳 code_id
'status' => 'active'
]
]);
}
/**
* B690: Verify Welcome Gift (New)
*/
public function verifyWelcomeGift(Request $request)
{
$machine = $request->get('machine');
$lockoutKey = "welcome_gift_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Too many attempts. Locked.', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
}
$code = $request->input('code');
$gift = WelcomeGift::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$gift || !$gift->isValid()) {
$fails = Cache::increment("welcome_gift_fails:{$machine->id}");
if ($fails === 1) {
Cache::put("welcome_gift_fails:{$machine->id}", 1, 600);
}
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60);
Cache::forget("welcome_gift_fails:{$machine->id}");
}
return response()->json(['success' => false, 'message' => 'Invalid or expired code', 'code' => 404, 'remaining_attempts' => max(0, 5 - $fails)], 404);
}
Cache::forget("welcome_gift_fails:{$machine->id}");
// 建立來店禮驗證日誌 (驗證成功)
WelcomeGiftLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'welcome_gift_id' => $gift->id,
'action' => 'verify_success',
'remark' => 'log.welcome_gift.verify_success',
'raw_data' => [
'code' => $gift->code,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value
]
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $gift->name,
'code_id' => $gift->id,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value,
'discount_label' => $gift->discount_label, // 回傳自動算好的中文折數 (如「八折」、「折50元」)
'usage_type' => $gift->usage_type,
'status' => 'active'
]
'data' => [$data] // App 預期的是包含單一物件的陣列
]);
}
}

View File

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

View File

@ -1,144 +0,0 @@
<?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

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

View File

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

View File

@ -1,26 +0,0 @@
<?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,11 +9,9 @@ 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.
*/
@ -50,7 +48,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:5120'],
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:1024'],
]);
$user = $request->user();
@ -61,8 +59,7 @@ class ProfileController extends Controller
Storage::disk('public')->delete($user->avatar);
}
// 將上傳的大頭貼轉換為高壓縮率且清晰的 WebP 格式 (300x300 居中裁切)
$path = $this->storeAsWebp($request->file('avatar'), 'avatars', 85, 300, 300);
$path = $request->file('avatar')->store('avatars', 'public');
$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,webp', 'max:5120'],
'avatar' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
];
}
}

View File

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

View File

@ -3,9 +3,7 @@
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineStockMovement;
use App\Models\Machine\RemoteCommand;
use App\Services\Machine\MachineService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -36,7 +34,7 @@ class ProcessCommandAck implements ShouldQueue
* 處理 B055 出貨回報 ACK
* 原始邏輯移植自 MachineController::reportDispenseResult()
*/
public function handle(MachineService $machineService): void
public function handle(): void
{
$commandId = $this->payload['command_id'] ?? null;
$resultCode = $this->payload['result'] ?? null;
@ -79,19 +77,6 @@ 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';
@ -103,20 +88,41 @@ class ProcessCommandAck implements ShouldQueue
$machine = $command->machine;
if ($command->command_type === 'dispense') {
// 遠端出貨指令僅紀錄狀態,不再執行庫存預扣或回滾。
// 真正的庫存異動將由機台發送的 Transaction Finalize (結案) 流程處理。
$msgKey = ($status === 'success') ? "Remote dispense successful for slot :slot" : "Remote dispense failed for slot :slot";
$level = ($status === 'success') ? 'info' : 'error';
$slotNo = $command->payload['slot_no'] ?? null;
$slot = $machine->slots()->where('slot_no', $slotNo)->first();
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$msgKey,
$level,
['slot' => $command->payload['slot_no'] ?? 'N/A']
);
if ($slot) {
if ($status === 'success') {
// 若出貨成功,且 APP 回報了最新的庫存值,則以 APP 為準進行校準
// 否則維持預扣後的狀態 (不需要額外動作,因為下發時已扣除)
if ($stockReported !== null) {
$slot->update(['stock' => (int)$stockReported]);
$payloadUpdates['new_stock'] = (int)$stockReported;
}
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Remote dispense successful for slot :slot",
'info',
['slot' => $slotNo]
);
} else {
// 若出貨失敗,執行回滾 (Rollback):將預扣的庫存加回去
$oldStock = $command->payload['old_stock'] ?? $slot->stock;
$slot->update(['stock' => $oldStock]);
$payloadUpdates['rolled_back'] = true;
$payloadUpdates['new_stock'] = $oldStock;
Log::warning('MQTT Dispense failed, rolled back inventory', [
'serial_no' => $this->serialNo,
'slot_no' => $slotNo,
'rolled_back_to' => $oldStock
]);
}
}
}
elseif ($command->command_type === 'reload_stock') {
// 若同步失敗,自動 Rollback 回滾庫存
if ($status === 'failed' && isset($command->payload['slot_no'], $command->payload['old'])) {
@ -146,20 +152,6 @@ class ProcessCommandAck implements ShouldQueue
'payload' => array_merge($command->payload, $payloadUpdates),
]);
// 記錄維護類指令到機台日誌 (MachineLog)
if (in_array($command->command_type, ['reboot', 'reboot_force', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads', 'update_settings'], true)) {
$msgKey = $status === 'success' ? 'log.command.success' : 'log.command.failed';
$level = $status === 'success' ? 'info' : 'warning';
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
$msgKey,
$level,
['type' => __("command.{$command->command_type}")]
);
}
Log::info("MQTT CommandAck processed", [
'serial_no' => $this->serialNo,
'command_id' => $commandId,

View File

@ -48,40 +48,25 @@ class ProcessHeartbeat implements ShouldQueue
// === 狀態異動觸發 (Redis 快取,讓 MQTT 也支援日誌) ===
$cacheKey = "machine:{$this->serialNo}:state";
$oldState = \Illuminate\Support\Facades\Cache::get($cacheKey);
// 1. 偵測狀態由離線轉在線 (寫入日誌)
if ($machine->status === 'offline') {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Machine is online",
'info',
[]
);
}
$newState = $oldState ?? [];
// 2. 韌體版本比對 (有改才傳,有改才記)
// 1. 韌體版本比對 (有改才傳,有改才記)
if (isset($this->payload['firmware_version'])) {
$fv = (string) $this->payload['firmware_version'];
// 優先從快取取舊版本,若無則從資料庫取
$oldVersion = $oldState['firmware_version'] ?? $machine->firmware_version;
if ((string)$oldVersion !== $fv) {
if (!isset($oldState['firmware_version']) || (string)$oldState['firmware_version'] !== $fv) {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Firmware updated to :version",
'info',
['version' => $fv, 'old' => $oldVersion ?? 'Unknown']
['version' => $fv, 'old' => $oldState['firmware_version'] ?? '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}");
@ -91,147 +76,38 @@ class ProcessHeartbeat implements ShouldQueue
$tempLogCacheKey = "machine:{$this->serialNo}:last_temp_log";
$lastLogEntry = \Illuminate\Support\Facades\Cache::get($tempLogCacheKey);
// 統一邏輯:優先從快取取值,若無則從資料庫取
$oldTemp = isset($lastLogEntry['value']) ? (int)$lastLogEntry['value'] : $machine->temperature;
$shouldLog = false;
if (!$lastLogEntry) {
$shouldLog = true;
} else {
$lastValue = (int) $lastLogEntry['value'];
$lastAt = \Carbon\Carbon::parse($lastLogEntry['at']);
if ($temp !== (int)$oldTemp) {
Log::debug("ProcessHeartbeat: Temperature changed from {$oldTemp} to {$temp}. Triggering ordinary log.");
// 條件 A: 只要溫度與上次記錄不同就記錄 (整數比對)
if ($temp !== $lastValue) {
Log::debug("ProcessHeartbeat: Temperature changed from {$lastValue} to {$temp}. Triggering log.");
$shouldLog = true;
}
// 條件 B: 距離上次日誌超過 4 小時 (保底機制)
if ($lastAt->diffInHours(now()) >= 4) {
$shouldLog = true;
}
}
if ($shouldLog) {
\App\Jobs\Machine\ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Temperature reported: :temp°C",
'info',
['temp' => $temp],
'status' // 普通狀態日誌,不發送 Discord
['temp' => $temp]
);
}
// 無論是否有變動,都更新/延長快取壽命,確保快取資料存在
\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);
}
}
// 更新快取,保留 7 天
\Illuminate\Support\Facades\Cache::put($tempLogCacheKey, [
'value' => $temp,
'at' => now()->toDateTimeString()
], 604800);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,84 +0,0 @@
<?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

@ -1,142 +0,0 @@
<?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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,68 +11,6 @@ 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',
@ -80,12 +18,10 @@ class MachineLog extends Model
'type',
'message',
'context',
'is_resolved',
];
protected $casts = [
'context' => 'array',
'is_resolved' => 'boolean',
];
protected $appends = [
@ -99,19 +35,11 @@ class MachineLog extends Model
{
$context = $this->context;
// 刷卡機回應碼:用目前代碼表重組失敗原因,支援多語系顯示。
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 封裝:優先用目前代碼表重組,讓舊日誌也能吃到最新文案。
// 若 context 中已有翻譯標籤 (B013 封裝),則進行動態重組
if (isset($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']);
$label = __($context['translated_label']);
$tid = $context['tid'] ?? null;
$code = $context['raw_code'] ?? '0000';
if ($tid) {
return __('Slot') . " {$tid}: {$label} (Code: {$code})";

View File

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

View File

@ -1,58 +0,0 @@
<?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,19 +20,12 @@ 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

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

View File

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

View File

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

View File

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

View File

@ -5,21 +5,11 @@ 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.
@ -36,7 +26,6 @@ class Product extends Model
'name_dictionary_key',
'barcode',
'spec',
'spec_dictionary_key',
'manufacturer',
'description',
'price',
@ -69,7 +58,7 @@ class Product extends Model
/**
* 自動附加到 JSON/陣列輸出的屬性(供 Alpine.js 等前端使用)
*/
protected $appends = ['localized_name', 'localized_spec'];
protected $appends = ['localized_name'];
/**
* 取得當前語系的商品名稱。
@ -93,26 +82,6 @@ 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.
*/
@ -122,15 +91,6 @@ 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

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

View File

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

View File

@ -68,14 +68,6 @@ class Company extends Model
return $this->hasMany(Machine::class);
}
/**
* Get the products for the company.
*/
public function products(): HasMany
{
return $this->hasMany(\App\Models\Product\Product::class);
}
/**
* Scope僅篩選啟用的公司
*/
@ -83,101 +75,4 @@ 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,14 +10,11 @@ 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',
];
/**
@ -28,14 +25,6 @@ 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.
*/
@ -46,43 +35,4 @@ 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

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

View File

@ -23,8 +23,6 @@ class User extends Authenticatable
*/
protected $fillable = [
'company_id',
'parent_id',
'level',
'username',
'name',
'email',
@ -55,14 +53,8 @@ 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.
*/
@ -103,85 +95,6 @@ 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)->withTrashed();
return $this->belongsTo(Product::class);
}
}

View File

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

View File

@ -6,260 +6,42 @@ 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, 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,
];
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'company_id',
'flow_id',
'order_no',
'order_type',
'pricing_slip_no',
'created_by',
'machine_id',
'member_id',
'payment_type',
'total_amount',
'discount_amount',
'pay_amount',
'change_amount',
'cash_detail',
'points_used',
'original_amount',
'payment_type',
'payment_status',
'payment_request',
'payment_response',
'payment_at',
'member_barcode',
'code_id',
'welcome_gift_id',
'invoice_info',
'machine_time',
'status',
'metadata',
'delivery_status',
'remark',
];
protected $casts = [
'total_amount' => 'decimal:2',
'discount_amount' => 'decimal:2',
'pay_amount' => 'decimal:2',
'change_amount' => 'decimal:2',
'cash_detail' => 'array',
'original_amount' => 'decimal:2',
'payment_at' => 'datetime',
'machine_time' => 'datetime',
'metadata' => 'array',
'payment_type' => 'integer',
'delivery_status' => 'integer',
];
/**
* 現金收款摘要字串:「收:{總收金額}(只列有收到的面額明細) 找:{找零}」。
* 120:1、10:2 30。無 cash_detail非現金或舊資料或全為 0 時回 null
* 總收金額由各面額張數加總得出,與括號內明細一致。
*/
public function getCashReceivedSummaryAttribute(): ?string
{
$cd = $this->cash_detail;
if (empty($cd)) {
return null;
}
// 面額顯示標籤 + 幣值(依大到小)
$denoms = [
'b1000' => ['label' => '仟', 'unit' => 1000],
'b500' => ['label' => '伍佰', 'unit' => 500],
'b100' => ['label' => '佰', 'unit' => 100],
'c50' => ['label' => '50', 'unit' => 50],
'c10' => ['label' => '10', 'unit' => 10],
'c5' => ['label' => '5', 'unit' => 5],
'c1' => ['label' => '1', 'unit' => 1],
];
$received = 0;
$parts = [];
foreach ($denoms as $key => $d) {
$n = (int) ($cd[$key] ?? 0);
if ($n > 0) {
$parts[] = $d['label'] . ':' . $n; // 只列有收到的幣別
$received += $n * $d['unit'];
}
}
if (empty($parts)) {
return null;
}
return '收:' . $received . '' . implode('、', $parts) . ') 找:' . number_format($this->change_amount, 0);
}
/**
* 取得所有支付類型的對照標籤
*/
public static function getPaymentTypeLabels(): array
{
return [
1 => __('Credit Card'),
2 => __('E-Ticket (EasyCard/iPass)'),
3 => __('QR Code Payment - E.SUN'), // 掃碼支付-玉山
4 => __('Bill Acceptor'),
5 => __('Pass Code'), // 使用者要求由「通關密碼」改為「通行碼」
6 => __('Pickup Code'),
7 => __('Welcome Gift'),
8 => __('Questionnaire'),
9 => __('Coin Acceptor'),
10 => __('Mobile Pay'), // NFC 手機感應(行動支付,信用卡軌道,依使用者按鈕分類)
11 => __('QR Code Payment - TayPay'), // 掃碼支付-TayPay
21 => __('Offline + 1'),
22 => __('Offline + 2'),
23 => __('Offline + 3'),
24 => __('Offline + 4'),
25 => __('Offline + 9'),
30 => __('LINE Pay'),
31 => __('JKO Pay'),
32 => __('Easy Wallet'),
33 => __('Pi Pay'),
34 => __('PlusPay'),
40 => __('Member Verify Pickup'),
41 => __('Staff Card'),
42 => __('Pickup Voucher'),
50 => __('Offline + LINE Pay'),
51 => __('Offline + JKO Pay'),
52 => __('Offline + Easy Wallet'),
53 => __('Offline + Pi Pay'),
54 => __('Offline + PlusPay'),
60 => __('Points/Voucher'),
61 => __('Member + 1'),
62 => __('Member + 2'),
63 => __('Member + 3'),
64 => __('Member + 4'),
65 => __('Member + 5'),
66 => __('Member + 6'),
67 => __('Member + 7'),
68 => __('Member + 8'),
69 => __('Member + 9'),
70 => __('LINE Pay (Official)'), // LINE Pay 官方直連(非 TapPay 底下的 30
90 => __('Member + LINE Pay'),
91 => __('Member + JKO Pay'),
92 => __('Member + Easy Wallet'),
93 => __('Member + Pi Pay'),
94 => __('Member + PlusPay'),
];
}
/**
* 取得目前的支付類型標籤 (Accessor)
*/
public function getPaymentTypeLabelAttribute(): string
{
return self::getPaymentTypeLabels()[$this->payment_type] ?? __('Unknown');
}
public function getMaskedPickupRecipientAttribute(): ?string
{
if (empty($this->member_barcode)) {
return null;
}
return self::maskPickupRecipientName($this->member_barcode);
}
public static function maskPickupRecipientName(string $value): string
{
$value = trim($value);
if ($value === '') {
return $value;
}
if (preg_match('/^([^\s(]+)(.*)$/us', $value, $matches) !== 1) {
return self::maskName($value);
}
return self::maskName($matches[1]) . ($matches[2] ?? '');
}
private static function maskName(string $name): string
{
$length = mb_strlen($name);
if ($length <= 1) {
return $name;
}
if ($length === 2) {
return mb_substr($name, 0, 1) . 'O';
}
return mb_substr($name, 0, 1)
. str_repeat('O', $length - 2)
. mb_substr($name, -1);
}
/** 一般銷售訂單 */
public function scopeSales($query)
{
return $query->where('order_type', self::TYPE_SALE);
}
/** 領藥單 */
public function scopePharmacyPickup($query)
{
return $query->where('order_type', self::TYPE_PHARMACY_PICKUP);
}
/** 是否為後台手動補單 */
public function getIsManualAttribute(): bool
{
return $this->order_type === self::TYPE_MANUAL;
}
public function machine()
{
return $this->belongsTo(Machine::class);
}
/** 建單者 (領藥單藥師) */
public function creator()
{
return $this->belongsTo(\App\Models\System\User::class, 'created_by');
}
public function member()
{
return $this->belongsTo(Member::class);
@ -279,54 +61,4 @@ 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,7 +14,6 @@ class OrderItem extends Model
'order_id',
'product_id',
'product_name',
'slot_no',
'barcode',
'price',
'quantity',
@ -35,6 +34,6 @@ class OrderItem extends Model
public function product()
{
return $this->belongsTo(Product::class)->withTrashed();
return $this->belongsTo(Product::class);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,153 +0,0 @@
<?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

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

View File

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

View File

@ -14,25 +14,12 @@ class ReplenishmentOrderItem extends Model
'product_id',
'slot_no',
'quantity',
'current_stock',
'max_stock',
'snapshot_product_name',
'snapshot_product_name_key',
'snapshot_product_barcode',
'snapshot_product_image_url',
];
protected $casts = [
'quantity' => 'integer',
'current_stock' => 'integer',
'max_stock' => 'integer',
];
/**
* 自動附加到 JSON/陣列輸出的屬性
*/
protected $appends = ['localized_name', 'barcode', 'image_url'];
/**
* 所屬補貨單
*/
@ -42,85 +29,10 @@ class ReplenishmentOrderItem extends Model
}
/**
* 對應商品 (包含被軟刪除的商品以相容舊資料)
* 對應商品
*/
public function product()
{
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;
return $this->belongsTo(\App\Models\Product\Product::class);
}
}

View File

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

View File

@ -13,21 +13,12 @@ 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'];
/**
* 所屬調撥單
*/
@ -37,85 +28,10 @@ class TransferOrderItem extends Model
}
/**
* 對應商品 (包含被軟刪除的商品以相容舊資料)
* 對應商品
*/
public function product()
{
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;
return $this->belongsTo(\App\Models\Product\Product::class);
}
}

View File

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

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