Compare commits
No commits in common. "main" and "feat/discord-notification" have entirely different histories.
main
...
feat/disco
@ -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`
|
||||
|
||||
@ -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` 隔離邏輯正確套用
|
||||
@ -51,7 +51,6 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| success | Boolean | 請求是否成功 | true |
|
||||
| code | Integer | 業務狀態碼 | 200 |
|
||||
| message | String | 驗證結果 (Success 或 Failed) | Success |
|
||||
| identity | String | **登入者身分**:`system` 系統登入者 / `company` 公司帳號 / `staff` 一般人員。僅驗證成功時回傳 | system |
|
||||
| token | String | **臨時身份認證 Token** (可選,供未來擴充使用) | 1\|abcdefg... |
|
||||
|
||||
---
|
||||
@ -137,12 +136,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 +150,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] 機台故障與異常狀態上報
|
||||
@ -177,80 +168,20 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| machine | String | 是 | 機台編號 (serial_no) | M-001 |
|
||||
|
||||
- **資料來源**: 所有金流密鑰與電子發票設定,均取自機台關聯的「金流配置 (`payment_configs.settings`)」巢狀結構;機台須先在後台關聯一組金流配置 (`machines.payment_config_id`),未設值欄位回傳空字串 `""`。
|
||||
- **Response Body (Success 200 - Object):**
|
||||
|
||||
| 欄位 (Key) | 說明 | 來源 (payment_configs.settings) |
|
||||
| 欄位 (Key) | 說明 | 備註 |
|
||||
| :--- | :--- | :--- |
|
||||
| **t050v01** | 機台序號 | machines.serial_no |
|
||||
| **api_token** | **機台正式 Token** | machines.api_token(初始化後存本地,後續認證用)|
|
||||
| **t050v41** | 玉山特店編號 | `esun_scan.store_id` |
|
||||
| **t050v42** | 玉山終端編號 | `esun_scan.term_id` |
|
||||
| **t050v43** | 玉山 Hash Key | `esun_scan.key` |
|
||||
| **t050v34** | 發票特店 ID | `ecpay_invoice.store_id` |
|
||||
| **t050v35** | 發票 Hash Key | `ecpay_invoice.hash_key` |
|
||||
| **t050v36** | 發票 Hash IV | `ecpay_invoice.hash_iv` |
|
||||
| **t050v38** | 發票通知 Email | `ecpay_invoice.email` |
|
||||
| **TP_APP_ID** | 趨勢/TapPay AppID | `tappay.app_id` |
|
||||
| **TP_APP_KEY** | 趨勢/TapPay Key | `tappay.app_key` |
|
||||
| **TP_PARTNER_KEY** | TapPay Partner Key | `tappay.partner_key` |
|
||||
| **TP_LINE_MERCHANT_ID** | LINE Pay 特店 ID | `tappay.line_merchant_id` |
|
||||
| **TP_JKO_MERCHANT_ID** | 街口支付 特店 ID | `tappay.jko_merchant_id` |
|
||||
| **TP_PI_MERCHANT_ID** | Pi 拍錢包 特店 ID | `tappay.pi_merchant_id` |
|
||||
| **TP_PS_MERCHANT_ID** | 全盈+Pay 特店 ID | `tappay.ps_merchant_id` |
|
||||
| **TP_EASY_MERCHANT_ID** | 悠遊付 特店 ID | `tappay.easy_merchant_id` |
|
||||
| **DevSet** | 支付旗標物件(命名沿用機台 `DevSetStructure`)| 機台既有:`ShoppingCar`(shopping_cart_enabled)/`Invoice`(tax_invoice_enabled)/`DevNFCPay`(card_terminal_enabled)/`DevEsunPay`(scan_pay_esun_enabled)/`DevTapPay`(scan_pay_tappay_enabled)/`DevCash`(cash_module_enabled)/`DevLinePay`(scan_pay_linepay_enabled LINE Pay 官方直連)/`TapPay30`(tappay_linepay TapPay 底下 LINE Pay)/`TapPay31`(tappay_jkopay 街口)/`TapPay32`(tappay_easywallet 悠遊付)/`TapPay33`(tappay_pipay)/`TapPay34`(tappay_pluspay 全盈+)。**新定義**(機台待新增欄位):`DevCreditCard`(credit_card_enabled)/`DevMobilePay`(mobile_pay_enabled)/`DevCardPay`(card_pay_enabled)/`DevScanPay`(scan_pay_enabled)。`VMC`/`Electic` 雲端不下發。註:LINE Pay 有兩條路線 —— `DevLinePay`=官方直連、`TapPay30`=經 TapPay,兩者來源不同|
|
||||
| **CashSet** | 現金面額旗標物件(對齊 `CashSetStructure`)| machines.settings → `BillF1000`/`BillE500`/`BillD100`/`CoinF50`/`CoinE10`/`CoinD5`/`CoinC1` |
|
||||
| **FunctionSet** | 非支付功能模組旗標物件(**新定義**,機台待實作)| machines.settings → `PickupModule`(pickup_module_enabled)/`PickupCode`(pickup_code_enabled)/`PassCode`(pass_code_enabled)/`WelcomeGift`(welcome_gift_enabled)/`MemberSystem`(member_system_enabled)/`AmbientTemp`(ambient_temp_monitoring_enabled) |
|
||||
| **ShoppingMode** | 購物方式(字串,**新定義**)| machines.settings.shopping_mode:`basic` / `employee_card` / `pickup_sheet` |
|
||||
| **LangSet** | 機台顯示語系(**新定義**,最多 5 種)| machines.settings.languages → `Languages`(有序語系陣列,如 `["zh_TW","en","ja"]`,順序即切換順序)/`Default`(預設語系=清單第一個)。未設定時退化為 `[fallback]`。機台據此渲染語系切換 UI。可選語系池子見 `config/locales.php` |
|
||||
| **OperationSet** | 運作參數(**新定義**,來源 machines 實體欄位)| `CardReaderSeconds`(card_reader_seconds 整數)/`PaymentBufferSeconds`(payment_buffer_seconds 整數)/`CheckoutTime1`(card_reader_checkout_time_1 時間字串)/`CheckoutTime2`(card_reader_checkout_time_2)/`HeatingStartTime`(heating_start_time)/`HeatingEndTime`(heating_end_time) |
|
||||
| **HardwareSet** | 硬體與貨道(**新定義**,來源 machines 實體欄位)| `CardReaderNo`(card_reader_no 字串)/`SpringSlot1_10`~`SpringSlot51_60`(is_spring_slot_* 布林,true=彈簧 / false=履帶) |
|
||||
|
||||
> 註:
|
||||
> - 金流密鑰/發票來源已由「公司設定 (companies.settings)」改為「金流配置 (payment_configs.settings)」,並新增 `t050v38` 發票通知 Email。
|
||||
> - 機台系統設定全部以機台端 `*Set` + PascalCase 風格下發(`DevSet`/`CashSet`/`FunctionSet`/`ShoppingMode`/`OperationSet`/`HardwareSet`),不再有原始 key 雜燴物件。`DevSet`/`CashSet` 對齊機台既有結構;其餘(含 `DevCreditCard/DevMobilePay/DevCardPay/DevScanPay`、`FunctionSet`、`ShoppingMode`、`OperationSet`、`HardwareSet`)為新定義,機台 App 端需另案新增對應結構與消費邏輯。旗標/設定來源為 `machines.settings`,運作參數與硬體貨道來源為 machines 實體欄位。
|
||||
|
||||
---
|
||||
|
||||
### 3.8 B016: 機台系統設定回寫 (Settings Write-back)
|
||||
機台主控台由**系統方**設定硬體貨道類型後,將貨道開關狀態回寫雲端。為 B014 下載的反向操作。
|
||||
|
||||
- **URL**: PATCH /api/v1/app/machine/setting/B016
|
||||
- **Authentication**: **User Token (Sanctum)** — 帶入 B000 核發之 `Authorization: Bearer <user_token>`,且**僅系統管理員 (`identity=system`)** 可操作;非系統方一律 403。
|
||||
- **Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `Authorization: Bearer <user_token>`
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| machine | String | 是 | 機台序號 (serial_no) | SN202604130001 |
|
||||
| settings | Object | 是 | 貨道類型鍵值物件,鍵名採**後端原始 snake_case**。只覆寫有送來的鍵,其餘保留 | {"is_spring_slot_1_10": true} |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **僅硬體貨道**:機台端 B016 現在**只回寫硬體貨道類型** (`is_spring_slot_*`)。支付旗標、現金面額、功能模組等系統設定純由**後台單向決定**,B016 **不再接收**,未列入下列白名單者一律忽略。
|
||||
|
||||
- **可回寫鍵(settings 之下,皆為布林):**
|
||||
- 貨道類型(machines 實體欄位):`is_spring_slot_1_10`、`is_spring_slot_11_20`、`is_spring_slot_21_30`、`is_spring_slot_31_40`、`is_spring_slot_41_50`、`is_spring_slot_51_60`(true=彈簧 / false=履帶)
|
||||
|
||||
- **Response Body (Success 200):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 是否成功 | true |
|
||||
| code | Integer | 業務狀態碼 | 200 |
|
||||
| message | String | 回應訊息 | Settings updated successfully. |
|
||||
|
||||
- **錯誤回應:**
|
||||
|
||||
| HTTP | 情境 | message |
|
||||
| :--- | :--- | :--- |
|
||||
| 403 | 非系統管理員 / 未登入 | Forbidden |
|
||||
| 404 | 查無該序號機台 | Machine not found |
|
||||
| 422 | `settings` 非物件/陣列,或未含任何有效貨道鍵 | Invalid settings payload |
|
||||
|
||||
> 註:寫回時更新 `is_spring_slot_*` 實體欄位並記錄 `updater_id`。為 system-admin 專用,套用 `withoutGlobalScopes` 跨租戶定位機台。
|
||||
| **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 |
|
||||
|
||||
---
|
||||
|
||||
@ -303,67 +234,11 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
- `status`: "active"。
|
||||
|
||||
> [!WARNING]
|
||||
> **鎖定機制**:B660, B670, B680 與 B690 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
|
||||
> **鎖定機制**:B660, B670 與 B680 若連續錯誤 5 次,該機台將被鎖定驗證功能 1 分鐘。
|
||||
|
||||
---
|
||||
|
||||
### 3.12 B690: 來店禮驗證 (Welcome Gift Verify)
|
||||
處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊供機台端套用折扣。
|
||||
|
||||
- **URL**: POST /api/v1/app/machine/welcome-gift/verify/B690
|
||||
- **Authentication**: Bearer Token (Header)
|
||||
- **Request Body:**
|
||||
|
||||
| 參數 | 類型 | 必填 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| code | String | 是 | 8 位數來店禮代碼 | "12345678" |
|
||||
|
||||
**Response (Success 200):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | true |
|
||||
| code | Integer | 業務狀態碼 | 200 |
|
||||
| data.name | String | 來店禮名稱 | 新客八五折 |
|
||||
| data.code_id | Integer | 來店禮 ID (用於 MQTT 交易關聯) | 12 |
|
||||
| data.discount_type | String | 折扣類型 (percentage / amount) | percentage |
|
||||
| data.discount_value | Integer | 折扣趴數 (如 15 代表 15% off) 或折扣金額 | 15 |
|
||||
| data.discount_label | String | 台灣中文折數標籤 (如「八五折」、「折 50 元」) | 八五折 |
|
||||
| data.usage_type | String | 使用類型 (once / unlimited) | once |
|
||||
| data.status | String | 狀態 | active |
|
||||
|
||||
**Response (Failed 404):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | false |
|
||||
| message | String | 失敗原因 | Invalid or expired code |
|
||||
| code | Integer | 業務狀態碼 | 404 |
|
||||
| remaining_attempts | Integer | 剩餘嘗試次數 | 3 |
|
||||
|
||||
**Response (Failed 400 — 格式錯誤):**
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | false |
|
||||
| message | String | 失敗原因 (code 非 8 位數) | Invalid format |
|
||||
| code | Integer | 業務狀態碼 | 400 |
|
||||
|
||||
**Response (Failed 429 — 已鎖定):**
|
||||
|
||||
連續錯誤達 5 次後,該機台於鎖定期間 (60 秒) 內的所有驗證請求皆回傳此回應。
|
||||
|
||||
| 參數 | 類型 | 說明 | 範例 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| success | Boolean | 請求是否成功 | false |
|
||||
| message | String | 失敗原因 | Too many attempts. Locked. |
|
||||
| code | Integer | 業務狀態碼 | 429 |
|
||||
|
||||
- **消耗機制**: 出貨完成後,由機台發送 MQTT `finalize` 訊息,並在 `payment_type` 帶入 `7` (來店禮) 完成扣減。
|
||||
|
||||
---
|
||||
|
||||
### 3.14 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
|
||||
### 3.12 B027: 贈品碼/優惠券驗證 (Free Gift Verify)
|
||||
用於處理行銷贈品券、0 元購活動或特定的優惠券核銷流程。
|
||||
|
||||
- **URL**: POST /api/v1/app/sell/free-gift/B027
|
||||
@ -374,7 +249,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
|
||||
---
|
||||
|
||||
### 3.15 B650: 會員身分驗證 (Member Verification)
|
||||
### 3.13 B650: 會員身分驗證 (Member Verification)
|
||||
機台端掃碼後驗證會員身分。
|
||||
|
||||
- **URL**: POST /api/v1/app/machine/member/verify/B650
|
||||
@ -414,7 +289,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| 代碼 | 支付方式 | 備註 / 說明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `1` | 信用卡 | 一般實體信用卡刷卡 |
|
||||
| `2` | 電子票證 | 包含悠遊卡、一卡通、iCash 等(與信用卡、手機支付共用同一台實體刷卡機)|
|
||||
| `2` | 電子票證 | 包含悠遊卡、一卡通等 |
|
||||
| `3` | 掃碼支付 | 一般掃碼支付 |
|
||||
| `4` | 投幣機支付 | 實體紙鈔機 |
|
||||
| `5` | 通行碼 | 現場維護人員測試使用 |
|
||||
@ -422,19 +297,15 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
|
||||
| `7` | 來店禮 | |
|
||||
| `8` | 問卷 | |
|
||||
| `9` | 零錢 | 硬幣投幣機 |
|
||||
| `10` | 手機支付 | NFC 手機感應(行動支付,走信用卡軌道,依使用者按鈕分類) |
|
||||
| `21` ~ `25` | 線下付款 + X | `21`=線下+1, `22`=線下+2... `25`=線下+9 |
|
||||
| `30` | TapPay - LINE Pay | TapPay 底下的 LINE Pay |
|
||||
| `30` | 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` | 遠端出貨 | 後台遠端觸發出貨 |
|
||||
|
||||
@ -129,90 +129,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`
|
||||
|
||||
|
||||
@ -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` 確認修改範疇僅限於新增的語系,無亂序與無關的語系被刪除。
|
||||
@ -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
|
||||
|
||||
@ -31,57 +31,9 @@ jobs:
|
||||
docker run --rm -v /home/twsystem1003/star-cloud:/target busybox mkdir -p /target/docker/emqx
|
||||
cat docker/emqx/acl.conf | docker run -i --rm -v /home/twsystem1003/star-cloud:/target busybox sh -c "cat > /target/docker/emqx/acl.conf"
|
||||
|
||||
- name: Step 1 - Ensure Existing Containers Are Running
|
||||
- name: Step 1 - Build & Deploy Containers
|
||||
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
|
||||
WWWGROUP=1001 WWWUSER=1001 docker-compose up -d --build --wait
|
||||
|
||||
- name: Step 2 - Deploy Code
|
||||
run: |
|
||||
@ -123,4 +75,4 @@ jobs:
|
||||
echo "✅ MQTT Worker 已重啟"
|
||||
|
||||
- name: Step 5 - Cleanup
|
||||
run: docker image prune -f --filter "until=168h"
|
||||
run: docker image prune -f
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -22,7 +22,3 @@ yarn-error.log
|
||||
/docs/pptx
|
||||
/scratch
|
||||
/.antigravitycli
|
||||
|
||||
# Claude Code 個人設定(權限授權等,不進版控)
|
||||
/.claude/settings.json
|
||||
/.claude/settings.local.json
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@ -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`,需要時可直接呼叫。
|
||||
BIN
Star_Cloud_April_Report.pdf
Normal file
BIN
Star_Cloud_April_Report.pdf
Normal file
Binary file not shown.
BIN
Star_Cloud_April_Report.pptx
Normal file
BIN
Star_Cloud_April_Report.pptx
Normal file
Binary file not shown.
@ -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;
|
||||
@ -95,16 +94,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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -19,9 +19,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 +36,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();
|
||||
|
||||
@ -16,387 +16,12 @@ 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,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -409,7 +34,6 @@ class AnalysisController extends Controller
|
||||
// 1. 取得篩選參數
|
||||
$selectedCompanyId = $request->input('company_id');
|
||||
$selectedProductId = $request->input('product_id', 'ALL');
|
||||
$selectedMachineId = $request->input('machine_id', 'ALL');
|
||||
$dateRange = $request->input('date_range');
|
||||
|
||||
// 2. 解析日期區間
|
||||
@ -436,12 +60,6 @@ class AnalysisController extends Controller
|
||||
$effectiveCompanyId = $selectedCompanyId;
|
||||
}
|
||||
|
||||
// 取得使用者授權的機台 ID 範圍 (一般用戶)
|
||||
$allowedMachineIds = null;
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$allowedMachineIds = \App\Models\Machine\Machine::pluck('id')->toArray();
|
||||
}
|
||||
|
||||
// 4. 加載下拉選單選項
|
||||
// 公司選單 (僅限 Super Admin)
|
||||
$companies = [];
|
||||
@ -449,13 +67,6 @@ class AnalysisController extends Controller
|
||||
$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) {
|
||||
@ -464,35 +75,19 @@ class AnalysisController extends Controller
|
||||
$products = $productsQuery->active()->get(['id', 'name', 'barcode']);
|
||||
|
||||
// 5. 執行主報表統計 SQL
|
||||
// 子查詢:取得每個 order_id + product_id 的銷售單價與品名
|
||||
$itemPriceQuery = \Illuminate\Support\Facades\DB::table('order_items')
|
||||
// 計算各商品的銷售數量、銷售額、總成本、利潤
|
||||
$mainQuery = \Illuminate\Support\Facades\DB::table('order_items as oi')
|
||||
->join('orders as o', 'o.id', '=', 'oi.order_id')
|
||||
->leftJoin('products as p', 'p.id', '=', 'oi.product_id')
|
||||
->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',
|
||||
'oi.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')
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity) as total_quantity'),
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) as total_sales'),
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity * COALESCE(p.cost, 0)) as total_cost'),
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) - SUM(oi.quantity * COALESCE(p.cost, 0)) 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]);
|
||||
@ -502,26 +97,12 @@ class AnalysisController extends Controller
|
||||
$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->where('oi.product_id', $selectedProductId);
|
||||
}
|
||||
|
||||
$mainQuery->groupBy('dr.product_id')
|
||||
$mainQuery->groupBy('oi.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();
|
||||
@ -552,20 +133,15 @@ class AnalysisController extends Controller
|
||||
];
|
||||
|
||||
// 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')
|
||||
$trendQuery = \Illuminate\Support\Facades\DB::table('order_items as oi')
|
||||
->join('orders as o', 'o.id', '=', 'oi.order_id')
|
||||
->leftJoin('products as p', 'p.id', '=', 'oi.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')
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity) as total_quantity'),
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(oi.subtotal) as total_sales'),
|
||||
\Illuminate\Support\Facades\DB::raw('SUM(oi.quantity * COALESCE(p.cost, 0)) 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]);
|
||||
@ -574,20 +150,8 @@ class AnalysisController extends Controller
|
||||
$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->where('oi.product_id', $selectedProductId);
|
||||
}
|
||||
|
||||
$trendQuery->groupBy('date')->orderBy('date', 'asc');
|
||||
@ -621,7 +185,7 @@ class AnalysisController extends Controller
|
||||
$chartData = array_values($chartData);
|
||||
|
||||
// 8. 判斷是否為 AJAX 請求,回傳 JSON 格式
|
||||
if ($request->ajax() || $request->wantsJson()) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'summary' => $summary,
|
||||
@ -633,12 +197,10 @@ class AnalysisController extends Controller
|
||||
// 9. 一般 GET 載入視圖
|
||||
return view('admin.analysis.product-reports', [
|
||||
'title' => '商品報表分析',
|
||||
'description' => '商品銷售數據 analysis',
|
||||
'description' => '商品銷售數據分析',
|
||||
'companies' => $companies,
|
||||
'machines' => $machines,
|
||||
'products' => $products,
|
||||
'selectedCompanyId' => $selectedCompanyId,
|
||||
'selectedMachineId' => $selectedMachineId,
|
||||
'selectedProductId' => $selectedProductId,
|
||||
'dateRange' => $dateRange,
|
||||
'summary' => $summary,
|
||||
|
||||
@ -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. 推播 MQTT,command_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));
|
||||
}
|
||||
}
|
||||
@ -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,23 @@ class MachineSettingController extends AdminController
|
||||
switch ($tab) {
|
||||
case 'machines':
|
||||
$machineQuery = Machine::query()->with(['machineModel', 'paymentConfig', '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;
|
||||
|
||||
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 +67,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,15 +76,15 @@ 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',
|
||||
@ -108,14 +98,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 +108,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 +138,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 +198,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',
|
||||
@ -338,17 +319,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());
|
||||
@ -362,132 +332,14 @@ class MachineSettingController extends AdminController
|
||||
'shopping_cart_enabled',
|
||||
'welcome_gift_enabled',
|
||||
'cash_module_enabled',
|
||||
'pickup_module_enabled',
|
||||
'member_system_enabled',
|
||||
'ambient_temp_monitoring_enabled',
|
||||
];
|
||||
|
||||
$allowedShoppingModes = ['basic', 'employee_card', 'pickup_sheet'];
|
||||
$shoppingMode = $settings['shopping_mode'] ?? 'basic';
|
||||
if (!in_array($shoppingMode, $allowedShoppingModes, true)) {
|
||||
$shoppingMode = 'basic';
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($allowedFields as $field) {
|
||||
$data[$field] = isset($settings[$field]) ? (bool)$settings[$field] : false;
|
||||
}
|
||||
|
||||
$jsonSettings = $machine->settings ?? [];
|
||||
$jsonSettings['shopping_mode'] = $shoppingMode;
|
||||
|
||||
if ($shoppingMode !== 'basic') {
|
||||
$data['card_terminal_enabled'] = false;
|
||||
$data['scan_pay_esun_enabled'] = false;
|
||||
$data['scan_pay_linepay_enabled'] = false;
|
||||
$data['shopping_cart_enabled'] = false;
|
||||
$data['welcome_gift_enabled'] = false;
|
||||
$data['cash_module_enabled'] = false;
|
||||
$data['pickup_module_enabled'] = false;
|
||||
|
||||
$jsonSettings['credit_card_enabled'] = false;
|
||||
$jsonSettings['mobile_pay_enabled'] = false;
|
||||
$jsonSettings['card_pay_enabled'] = false;
|
||||
|
||||
$jsonSettings['scan_pay_enabled'] = false;
|
||||
$jsonSettings['scan_pay_tappay_enabled'] = false;
|
||||
$jsonSettings['tappay_linepay'] = false;
|
||||
$jsonSettings['tappay_jkopay'] = false;
|
||||
$jsonSettings['tappay_pipay'] = false;
|
||||
$jsonSettings['tappay_pluspay'] = false;
|
||||
$jsonSettings['tappay_easywallet'] = false;
|
||||
|
||||
$jsonSettings['cash_bill_1000'] = false;
|
||||
$jsonSettings['cash_bill_500'] = false;
|
||||
$jsonSettings['cash_bill_100'] = false;
|
||||
$jsonSettings['cash_coin_50'] = false;
|
||||
$jsonSettings['cash_coin_10'] = false;
|
||||
$jsonSettings['cash_coin_5'] = false;
|
||||
$jsonSettings['cash_coin_1'] = false;
|
||||
|
||||
$jsonSettings['pickup_code_enabled'] = false;
|
||||
$jsonSettings['pass_code_enabled'] = false;
|
||||
|
||||
// 副櫃系統(格子櫃功能)僅基礎版可用,非基礎版一律關閉
|
||||
$jsonSettings['subcabinet_enabled'] = false;
|
||||
} else {
|
||||
$jsonSettings['credit_card_enabled'] = isset($settings['credit_card_enabled']) ? (bool)$settings['credit_card_enabled'] : false;
|
||||
$jsonSettings['mobile_pay_enabled'] = isset($settings['mobile_pay_enabled']) ? (bool)$settings['mobile_pay_enabled'] : false;
|
||||
$jsonSettings['card_pay_enabled'] = isset($settings['card_pay_enabled']) ? (bool)$settings['card_pay_enabled'] : false;
|
||||
|
||||
$jsonSettings['scan_pay_enabled'] = isset($settings['scan_pay_enabled']) ? (bool)$settings['scan_pay_enabled'] : false;
|
||||
$jsonSettings['scan_pay_tappay_enabled'] = isset($settings['scan_pay_tappay_enabled']) ? (bool)$settings['scan_pay_tappay_enabled'] : false;
|
||||
$jsonSettings['tappay_linepay'] = isset($settings['tappay_linepay']) ? (bool)$settings['tappay_linepay'] : false;
|
||||
$jsonSettings['tappay_jkopay'] = isset($settings['tappay_jkopay']) ? (bool)$settings['tappay_jkopay'] : false;
|
||||
$jsonSettings['tappay_pipay'] = isset($settings['tappay_pipay']) ? (bool)$settings['tappay_pipay'] : false;
|
||||
$jsonSettings['tappay_pluspay'] = isset($settings['tappay_pluspay']) ? (bool)$settings['tappay_pluspay'] : false;
|
||||
$jsonSettings['tappay_easywallet'] = isset($settings['tappay_easywallet']) ? (bool)$settings['tappay_easywallet'] : false;
|
||||
|
||||
$jsonSettings['cash_bill_1000'] = isset($settings['cash_bill_1000']) ? (bool)$settings['cash_bill_1000'] : false;
|
||||
$jsonSettings['cash_bill_500'] = isset($settings['cash_bill_500']) ? (bool)$settings['cash_bill_500'] : false;
|
||||
$jsonSettings['cash_bill_100'] = isset($settings['cash_bill_100']) ? (bool)$settings['cash_bill_100'] : false;
|
||||
$jsonSettings['cash_coin_50'] = isset($settings['cash_coin_50']) ? (bool)$settings['cash_coin_50'] : false;
|
||||
$jsonSettings['cash_coin_10'] = isset($settings['cash_coin_10']) ? (bool)$settings['cash_coin_10'] : false;
|
||||
$jsonSettings['cash_coin_5'] = isset($settings['cash_coin_5']) ? (bool)$settings['cash_coin_5'] : false;
|
||||
$jsonSettings['cash_coin_1'] = isset($settings['cash_coin_1']) ? (bool)$settings['cash_coin_1'] : false;
|
||||
|
||||
$jsonSettings['pickup_code_enabled'] = isset($settings['pickup_code_enabled']) ? (bool)$settings['pickup_code_enabled'] : false;
|
||||
$jsonSettings['pass_code_enabled'] = isset($settings['pass_code_enabled']) ? (bool)$settings['pass_code_enabled'] : false;
|
||||
|
||||
// 副櫃系統(格子櫃功能):基礎版可設定
|
||||
$jsonSettings['subcabinet_enabled'] = isset($settings['subcabinet_enabled']) ? (bool)$settings['subcabinet_enabled'] : false;
|
||||
}
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
$jsonSettings[$field] = $data[$field];
|
||||
}
|
||||
|
||||
// 領藥單模組:僅在「取物單(pickup_sheet)」模式下可啟用,其他模式一律關閉
|
||||
$jsonSettings['pharmacy_pickup_enabled'] = ($shoppingMode === 'pickup_sheet')
|
||||
? (bool) ($settings['pharmacy_pickup_enabled'] ?? false)
|
||||
: false;
|
||||
|
||||
// 顯示語系 (languages):機台螢幕可切換的語系,最多 N 種,僅系統管理員可設定。
|
||||
// 商品翻譯範圍由「公司機台語系聯集」反推,故此處異動需失效聯集快取並重建商品目錄。
|
||||
// 非系統管理員即使送出 languages 亦一律忽略(不報錯,讓其仍可儲存其他設定)。
|
||||
$languagesChanged = false;
|
||||
if (array_key_exists('languages', $settings) && auth()->user()->isSystemAdmin()) {
|
||||
$whitelist = array_keys(config('locales.supported', []));
|
||||
$max = (int) config('locales.max_per_machine', 5);
|
||||
|
||||
// 去重、保留順序、僅留白名單內語系
|
||||
$languages = array_values(array_unique(array_filter(
|
||||
(array) $settings['languages'],
|
||||
fn ($l) => in_array($l, $whitelist, true)
|
||||
)));
|
||||
|
||||
if (count((array) $settings['languages']) > $max || count($languages) > $max) {
|
||||
return $this->settingsError($request, __('You can select at most :max languages.', ['max' => $max]), 422);
|
||||
}
|
||||
|
||||
$jsonSettings['languages'] = $languages;
|
||||
$languagesChanged = true;
|
||||
}
|
||||
|
||||
$machine->update([
|
||||
'settings' => $jsonSettings,
|
||||
'updater_id' => auth()->id()
|
||||
]);
|
||||
|
||||
// 同時更新 model 的屬性欄位 (以防 mutator 同步,以及確保 dirty check 正確)
|
||||
$machine->update(array_merge($data, ['updater_id' => auth()->id()]));
|
||||
|
||||
// 語系異動:失效公司語系聯集快取並重建商品目錄 i18n(B012 下發內容隨之更新)
|
||||
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,
|
||||
@ -509,7 +361,6 @@ class MachineSettingController extends AdminController
|
||||
'welcome_gift_enabled',
|
||||
'cash_module_enabled',
|
||||
'member_system_enabled',
|
||||
'ambient_temp_monitoring_enabled',
|
||||
];
|
||||
|
||||
if (!in_array($field, $allowedFields)) {
|
||||
@ -524,67 +375,6 @@ class MachineSettingController extends AdminController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步系統設定至機台:透過 MQTT 下發 update_settings 指令,
|
||||
* 通知機台 APP 主動回抓最新系統設定 (B014)。
|
||||
* 參考廣告同步 (AdvertisementController::syncToMachine) 的併行控制模式。
|
||||
*/
|
||||
public function syncSettings(Request $request, Machine $machine, \App\Services\Machine\MqttService $mqttService): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
// 併行檢查:若有 pending 指令,超過 1 分鐘視為逾時可覆蓋
|
||||
$pendingCommand = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||||
->where('command_type', 'update_settings')
|
||||
->where('status', 'pending')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($pendingCommand) {
|
||||
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
|
||||
|
||||
if (!$isTimedOut) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('This machine has a pending command. Please wait.')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$pendingCommand->update([
|
||||
'status' => 'timeout',
|
||||
'note' => __('Superseded by new command (Timeout)'),
|
||||
]);
|
||||
}
|
||||
|
||||
$command = \App\Models\Machine\RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
'user_id' => auth()->id(),
|
||||
'command_type' => 'update_settings',
|
||||
'payload' => [],
|
||||
'status' => 'pending',
|
||||
'remark' => $request->filled('note') ? $request->input('note') : __('Manual Sync Settings'),
|
||||
]);
|
||||
|
||||
$success = $mqttService->pushCommand(
|
||||
$machine->serial_no,
|
||||
'update_settings',
|
||||
[],
|
||||
(string) $command->id
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Sync command sent successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
$command->update(['status' => 'failed', 'note' => 'MQTT push failed']);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to send sync command.')
|
||||
], 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除機台 (僅限系統管理員)
|
||||
*/
|
||||
|
||||
@ -51,7 +51,7 @@ class PaymentConfigController extends AdminController
|
||||
'name' => 'required|string|max:255',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'settings' => 'required|array',
|
||||
] + $this->ecpayInvoiceRules(), $this->ecpayInvoiceMessages());
|
||||
]);
|
||||
|
||||
PaymentConfig::create([
|
||||
'name' => $request->name,
|
||||
@ -83,7 +83,7 @@ class PaymentConfigController extends AdminController
|
||||
'name' => 'required|string|max:255',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'settings' => 'required|array',
|
||||
] + $this->ecpayInvoiceRules(), $this->ecpayInvoiceMessages());
|
||||
]);
|
||||
|
||||
$paymentConfig->update([
|
||||
'name' => $request->name,
|
||||
@ -105,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:空欄送出為 null,required_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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -218,14 +213,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 +227,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.'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 切換客戶狀態
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()
|
||||
@ -265,117 +193,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)
|
||||
*/
|
||||
@ -450,35 +267,4 @@ class MachineController extends AdminController
|
||||
'data' => $chartData
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: 取得機台環境溫度歷史紀錄 (供圖表使用)
|
||||
*/
|
||||
public function ambientTemperatureAjax(Request $request, Machine $machine)
|
||||
{
|
||||
$startDate = $request->get('start_date');
|
||||
$endDate = $request->get('end_date');
|
||||
|
||||
$logs = $machine->logs()
|
||||
->where('type', 'ambient_temp')
|
||||
->where('message', 'Ambient temperature reported: :temp°C')
|
||||
->when($startDate, function ($query, $start) {
|
||||
return $query->where('created_at', '>=', str_replace('T', ' ', $start));
|
||||
})
|
||||
->when($endDate, function ($query, $end) {
|
||||
return $query->where('created_at', '<=', str_replace('T', ' ', $end));
|
||||
})
|
||||
->oldest()
|
||||
->get();
|
||||
|
||||
$chartData = $logs->map(fn($log) => [
|
||||
'x' => $log->created_at->getTimestamp() * 1000,
|
||||
'y' => (int) ($log->context['temp'] ?? 0),
|
||||
])->values()->toArray();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -14,8 +14,10 @@ class PermissionController extends Controller
|
||||
$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'));
|
||||
@ -86,11 +88,6 @@ class PermissionController extends Controller
|
||||
$role = \App\Models\System\Role::findOrFail($id);
|
||||
$user = auth()->user();
|
||||
|
||||
// 層級隔離:子帳號只能編輯自己建立的角色,不可開啟上層/旁線建立的角色。
|
||||
if (!$role->canBeManagedBy($user)) {
|
||||
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
|
||||
}
|
||||
|
||||
// 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理
|
||||
|
||||
$all_permissions = $this->getFilteredPermissions($user);
|
||||
@ -129,7 +126,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,
|
||||
]);
|
||||
|
||||
@ -186,11 +182,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 = [
|
||||
@ -232,20 +223,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.'));
|
||||
}
|
||||
@ -270,8 +251,10 @@ class PermissionController extends Controller
|
||||
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
|
||||
|
||||
// ── 帳號列表 ──────────────────────────────────────────────────
|
||||
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。
|
||||
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines'])->visibleTo($user);
|
||||
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$usersQuery->where('company_id', $user->company_id);
|
||||
}
|
||||
if ($search = $request->input('accounts_search', $request->input('search'))) {
|
||||
$usersQuery->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
@ -286,24 +269,22 @@ class PermissionController extends Controller
|
||||
$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 一致,避免下拉出現越權選項)。
|
||||
// Modal 用的角色選單(不分頁)
|
||||
$rolesForSelect = \App\Models\System\Role::query();
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$operatorPerms = $user->getAllPermissions()->pluck('name');
|
||||
$roles = $roles->filter(
|
||||
fn($r) => collect($r->getPermissionNames())->diff($operatorPerms)->isEmpty()
|
||||
)->values();
|
||||
$rolesForSelect->forCompany($user->company_id);
|
||||
}
|
||||
$roles = $rolesForSelect->get();
|
||||
|
||||
// ── 角色列表 (僅 data-config Tab 版需要) ─────────────────────
|
||||
$paginated_roles = collect();
|
||||
$all_permissions = collect();
|
||||
|
||||
if ($isSubAccountRoute) {
|
||||
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user);
|
||||
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
|
||||
if (!$user->isSystemAdmin()) {
|
||||
$rolesQuery->where('company_id', $user->company_id);
|
||||
}
|
||||
if ($search = $request->input('roles_search')) {
|
||||
$rolesQuery->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
@ -347,12 +328,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 +374,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 +383,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 +390,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 +410,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 +500,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 +509,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 +527,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,11 +556,6 @@ 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();
|
||||
|
||||
@ -675,6 +580,7 @@ class PermissionController extends Controller
|
||||
'menu.analysis',
|
||||
'menu.data-config',
|
||||
'menu.remote',
|
||||
'menu.special-permission',
|
||||
'menu.basic',
|
||||
'menu.permissions',
|
||||
];
|
||||
|
||||
@ -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.'));
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,6 @@ 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
|
||||
@ -24,51 +23,6 @@ class ProductController extends Controller
|
||||
private readonly ProductCatalogService $catalogService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Upsert a locale=>value map into translations for a given group/key.
|
||||
* 空值的語系會被刪除(清空翻譯)。繞過 TenantScoped。
|
||||
*
|
||||
* @param string $group product / product_spec
|
||||
* @param string $key 對應的 dictionary key
|
||||
* @param array $values locale => value
|
||||
* @param int|null $companyId
|
||||
*/
|
||||
private function upsertTranslations(string $group, string $key, array $values, ?int $companyId): void
|
||||
{
|
||||
foreach ($values as $locale => $value) {
|
||||
if (empty($value)) {
|
||||
Translation::withoutGlobalScopes()->where([
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
'locale' => $locale,
|
||||
])->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
Translation::withoutGlobalScopes()->updateOrCreate(
|
||||
['group' => $group, 'key' => $key, 'locale' => $locale],
|
||||
['value' => $value, 'company_id' => $companyId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析規格多語系輸入:優先採 specs 物件;
|
||||
* 若僅送舊版單一 spec 欄位則轉為 ['zh_TW' => spec] 以維持相容。
|
||||
*
|
||||
* @return array locale => value
|
||||
*/
|
||||
private function resolveSpecs(Request $request): array
|
||||
{
|
||||
$specs = $request->input('specs');
|
||||
if (is_array($specs) && !empty($specs)) {
|
||||
return $specs;
|
||||
}
|
||||
|
||||
$spec = $request->input('spec');
|
||||
return $spec !== null && $spec !== '' ? ['zh_TW' => $spec] : [];
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -99,7 +53,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']);
|
||||
@ -115,7 +69,7 @@ class ProductController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$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,7 +87,6 @@ class ProductController extends Controller
|
||||
'success' => true,
|
||||
'html' => view('admin.products.partials.tab-products', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'companySettings' => $companySettings,
|
||||
'companies' => $companies,
|
||||
'routeName' => $routeName
|
||||
@ -185,7 +138,7 @@ class ProductController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$logs = $logQuery->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($per_page, ['*'], 'log_page')->withQueryString();
|
||||
$logs = $logQuery->latest()->paginate($per_page, ['*'], 'log_page')->withQueryString();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -228,9 +181,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 +189,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 +212,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,18 +234,24 @@ 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')) {
|
||||
@ -318,8 +266,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,
|
||||
@ -370,13 +317,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 +339,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 +374,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,
|
||||
@ -533,13 +492,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) {
|
||||
@ -584,185 +540,6 @@ class ProductController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匯出商品資料(CSV / Excel),套用與 index 相同的篩選(搜尋/分類/公司)。
|
||||
* 參考 SalesController::handleExport 的串流下載模式;欄位順序對齊匯入範本,方便編輯後回匯入。
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$type = $request->input('export', 'csv');
|
||||
$isExcel = ($type === 'excel');
|
||||
$ext = $isExcel ? 'xls' : 'csv';
|
||||
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
|
||||
|
||||
// 與 index 相同的商品查詢與篩選(租戶隔離沿用 Product 全域 scope)
|
||||
$query = Product::with(['category.translations', 'translations', 'company']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('barcode', 'like', "%{$search}%")
|
||||
->orWhere('spec', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('product_company_id')) {
|
||||
$query->where('company_id', $request->product_company_id);
|
||||
}
|
||||
|
||||
// 欄位順序對齊匯入範本(ProductImportService::downloadTemplate)
|
||||
$headers = [
|
||||
'分類',
|
||||
'商品名稱(zh_TW)',
|
||||
'商品名稱(en)',
|
||||
'商品名稱(ja)',
|
||||
'條碼',
|
||||
'規格',
|
||||
'生產公司',
|
||||
'售價',
|
||||
'會員價',
|
||||
'成本',
|
||||
'履帶貨道上限',
|
||||
'彈簧貨道上限',
|
||||
'物料代碼',
|
||||
'全額點數',
|
||||
'半額點數',
|
||||
'半額折抵金額',
|
||||
'是否啟用',
|
||||
'圖片檔名',
|
||||
];
|
||||
|
||||
$filename = '商品資料_' . now()->format('YmdHis') . '.' . $ext;
|
||||
|
||||
$callback = function () use ($query, $headers, $isExcel) {
|
||||
$file = fopen('php://output', 'w');
|
||||
if ($isExcel) {
|
||||
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
|
||||
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
|
||||
fwrite($file, '<table><thead><tr>');
|
||||
foreach ($headers as $header) {
|
||||
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
|
||||
}
|
||||
fwrite($file, '</tr></thead><tbody>');
|
||||
} else {
|
||||
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM,Excel 開啟不亂碼
|
||||
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)
|
||||
*/
|
||||
|
||||
@ -42,10 +42,7 @@ class RemoteController extends Controller
|
||||
$machines = $machineQuery->paginate($request->input('per_page', 10), ['*'], 'machine_page');
|
||||
|
||||
// --- 2. 歷史紀錄處理 (Operation Records Tab) ---
|
||||
// whereHas('machine') 會套用 Machine 的 machine_access 全域作用域,
|
||||
// 確保租戶僅能看到自己被授權機台的指令紀錄(系統管理員不受作用域限制,仍可看全部)。
|
||||
$historyQuery = RemoteCommand::where('command_type', '!=', 'reload_stock')
|
||||
->whereHas('machine')
|
||||
->with(['machine', 'user']);
|
||||
|
||||
if ($request->filled('search') && ($request->input('tab') === 'history' || !$request->has('tab'))) {
|
||||
@ -142,7 +139,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',
|
||||
@ -236,9 +233,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');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,4 +15,13 @@ class SpecialPermissionController extends Controller
|
||||
'description' => '特殊權限庫存清空功能',
|
||||
]);
|
||||
}
|
||||
|
||||
// APK版本管理
|
||||
public function apkVersions()
|
||||
{
|
||||
return view('admin.placeholder', [
|
||||
'title' => 'APK版本管理',
|
||||
'description' => 'APP版本控制與更新',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,15 +110,6 @@ class StaffCardController extends Controller
|
||||
|
||||
$validated['status'] = $request->get('status', $staffCard->status);
|
||||
|
||||
// 寫入安全:company_id 不得由前端任意決定或被洗成 null
|
||||
// - 非系統管理員:強制綁定自身公司 (租戶隔離)
|
||||
// - 系統管理員:若未帶入公司,保留原本歸屬,避免變成無公司歸屬的孤兒卡 (機台將查無此卡)
|
||||
if (!auth()->user()->isSystemAdmin()) {
|
||||
$validated['company_id'] = auth()->user()->company_id;
|
||||
} elseif (empty($validated['company_id'])) {
|
||||
$validated['company_id'] = $staffCard->company_id;
|
||||
}
|
||||
|
||||
$staffCard->update($validated);
|
||||
|
||||
if ($request->ajax()) {
|
||||
|
||||
@ -26,54 +26,36 @@ class WarehouseController extends Controller
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$currentUser = Auth::user();
|
||||
$companyId = trim((string) $request->input('company_id', ''));
|
||||
$search = $request->input('search');
|
||||
$type = $request->input('type');
|
||||
|
||||
$applyWarehouseFilters = function ($query) use ($currentUser, $companyId, $search, $type) {
|
||||
if ($currentUser->isSystemAdmin() && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('address', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($type) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
return $query;
|
||||
};
|
||||
|
||||
$query = Warehouse::query()
|
||||
->with(['company'])
|
||||
->withCount('stocks as products_count')
|
||||
->withSum('stocks as total_stock', 'quantity');
|
||||
|
||||
$applyWarehouseFilters($query);
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('address', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->where('type', $request->input('type'));
|
||||
}
|
||||
|
||||
$warehouses = $query->orderBy('type')->orderBy('name')
|
||||
->paginate($request->input('per_page', 10))
|
||||
->withQueryString();
|
||||
|
||||
$statsWarehouseQuery = fn() => $applyWarehouseFilters(Warehouse::query());
|
||||
|
||||
$stats = [
|
||||
'total' => $statsWarehouseQuery()->count(),
|
||||
'main' => $statsWarehouseQuery()->main()->count(),
|
||||
'branch' => $statsWarehouseQuery()->branch()->count(),
|
||||
'total' => Warehouse::count(),
|
||||
'main' => Warehouse::main()->count(),
|
||||
'branch' => Warehouse::branch()->count(),
|
||||
'low_stock' => WarehouseStock::lowStock()
|
||||
->whereHas('warehouse', fn($q) => $applyWarehouseFilters($q))
|
||||
->count(),
|
||||
->whereHas('warehouse', fn($q) => $q)->count(),
|
||||
];
|
||||
|
||||
$companies = collect();
|
||||
if ($currentUser->isSystemAdmin()) {
|
||||
if (Auth::user()->isSystemAdmin()) {
|
||||
$companies = \App\Models\System\Company::active()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
@ -167,39 +149,23 @@ class WarehouseController extends Controller
|
||||
public function inventory(Request $request)
|
||||
{
|
||||
$tab = $request->input('tab', 'stock');
|
||||
$currentUser = Auth::user();
|
||||
$companyId = trim((string) $request->input('company_id', ''));
|
||||
$applyCompanyFilter = fn($query) => $query->when(
|
||||
$currentUser->isSystemAdmin() && $companyId !== '',
|
||||
fn($q) => $q->where('company_id', $companyId)
|
||||
);
|
||||
|
||||
$warehouses = $applyCompanyFilter(Warehouse::active())
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$companies = $currentUser->isSystemAdmin()
|
||||
? \App\Models\System\Company::active()->orderBy('name')->get()
|
||||
: collect();
|
||||
$warehouses = Warehouse::active()->orderBy('name')->get();
|
||||
$isAjax = $request->ajax();
|
||||
|
||||
$data = compact('warehouses', 'companies', 'tab');
|
||||
$data = compact('warehouses', 'tab');
|
||||
|
||||
// 1. Current Stocks (stock)
|
||||
if (!$isAjax || $tab === 'stock') {
|
||||
$warehouse_ids = $request->input('warehouse_ids');
|
||||
$inventory_warehouses = $applyCompanyFilter(Warehouse::active())
|
||||
$inventory_warehouses = Warehouse::active()
|
||||
->when($warehouse_ids, fn($q) => $q->whereIn('id', (array)$warehouse_ids))
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'type', 'company_id']);
|
||||
->get(['id', 'name', 'type']);
|
||||
|
||||
$query = Product::with(['stocks' => function($q) {
|
||||
$q->select('id', 'product_id', 'warehouse_id', 'quantity', 'safety_stock');
|
||||
}, 'translations']);
|
||||
|
||||
if ($currentUser->isSystemAdmin() && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
if ($warehouse_ids) {
|
||||
$query->whereHas('stocks', fn($q) => $q->whereIn('warehouse_id', (array)$warehouse_ids));
|
||||
}
|
||||
@ -219,10 +185,6 @@ class WarehouseController extends Controller
|
||||
->withCount('items')
|
||||
->latest();
|
||||
|
||||
if ($currentUser->isSystemAdmin() && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
@ -244,10 +206,7 @@ class WarehouseController extends Controller
|
||||
$data['stockInDefaultEnd'] = '';
|
||||
}
|
||||
$data['orders'] = $query->paginate($request->input('per_page', 10), ['*'], 'order_page')->withQueryString();
|
||||
$data['stock_in_products'] = Product::with('translations')
|
||||
->when($currentUser->isSystemAdmin() && $companyId !== '', fn($q) => $q->where('company_id', $companyId))
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'image_url', 'name_dictionary_key']);
|
||||
$data['stock_in_products'] = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
|
||||
}
|
||||
|
||||
// 3. Movement History (movements)
|
||||
@ -256,10 +215,6 @@ class WarehouseController extends Controller
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc');
|
||||
|
||||
if ($currentUser->isSystemAdmin() && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
if ($request->filled('warehouse_id')) {
|
||||
$query->where('warehouse_id', $request->input('warehouse_id'));
|
||||
}
|
||||
@ -442,17 +397,10 @@ class WarehouseController extends Controller
|
||||
|
||||
public function transfers(Request $request)
|
||||
{
|
||||
$currentUser = Auth::user();
|
||||
$companyId = trim((string) $request->input('company_id', ''));
|
||||
$isSystemAdmin = $currentUser->isSystemAdmin();
|
||||
|
||||
$query = TransferOrder::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'fromMachine:id,name,serial_no', 'creator:id,name'])
|
||||
->withCount('items')
|
||||
->latest();
|
||||
|
||||
if ($isSystemAdmin && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
if ($request->filled('type')) {
|
||||
$query->where('type', $request->input('type'));
|
||||
}
|
||||
@ -483,22 +431,14 @@ class WarehouseController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$companies = $isSystemAdmin
|
||||
? \App\Models\System\Company::active()->orderBy('name')->get()
|
||||
: collect();
|
||||
$applyCompanyFilter = fn($query) => $query->when(
|
||||
$isSystemAdmin && $companyId !== '',
|
||||
fn($q) => $q->where('company_id', $companyId)
|
||||
);
|
||||
|
||||
$warehouses = $applyCompanyFilter(Warehouse::active())->orderBy('name')->get(['id', 'name']);
|
||||
$machines = $applyCompanyFilter(Machine::query())->orderBy('name')->get(['id', 'name', 'serial_no']);
|
||||
$products = $applyCompanyFilter(Product::with('translations'))->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
|
||||
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']);
|
||||
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
|
||||
$products = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
|
||||
|
||||
$defaultStart = '';
|
||||
$defaultEnd = '';
|
||||
|
||||
return view('admin.warehouses.transfers', compact('orders', 'warehouses', 'machines', 'products', 'companies', 'defaultStart', 'defaultEnd'));
|
||||
return view('admin.warehouses.transfers', compact('orders', 'warehouses', 'machines', 'products', 'defaultStart', 'defaultEnd'));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -547,18 +487,7 @@ class WarehouseController extends Controller
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
// 預先取得商品資訊以建立快照
|
||||
$productIds = collect($validated['items'])->pluck('product_id')->unique()->toArray();
|
||||
$products = \App\Models\Product\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
$product = $products->get($item['product_id']);
|
||||
if ($product) {
|
||||
$item['snapshot_product_name'] = $product->name;
|
||||
$item['snapshot_product_name_key'] = $product->name_dictionary_key;
|
||||
$item['snapshot_product_barcode'] = $product->barcode;
|
||||
$item['snapshot_product_image_url'] = $product->image_url;
|
||||
}
|
||||
$order->items()->create($item);
|
||||
}
|
||||
});
|
||||
@ -685,19 +614,11 @@ class WarehouseController extends Controller
|
||||
|
||||
public function machineInventory(Request $request)
|
||||
{
|
||||
$currentUser = Auth::user();
|
||||
$companyId = trim((string) $request->input('company_id', ''));
|
||||
$isSystemAdmin = $currentUser->isSystemAdmin();
|
||||
|
||||
$query = Machine::with(['slots' => fn($q) => $q->with('product:id,name,image_url')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')])
|
||||
->withCount('slots as total_slots')
|
||||
->withSum('slots as total_stock', 'stock')
|
||||
->withSum('slots as total_capacity', 'max_stock');
|
||||
|
||||
if ($isSystemAdmin && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
@ -718,210 +639,10 @@ class WarehouseController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$companies = $isSystemAdmin
|
||||
? \App\Models\System\Company::active()->orderBy('name')->get()
|
||||
: collect();
|
||||
|
||||
// 供批次匯出 modal 勾選用的完整機台清單(全域 scope 已限可存取機台)
|
||||
$exportMachines = Machine::select('id', 'name', 'serial_no')->orderBy('name')->get();
|
||||
|
||||
return view('admin.warehouses.machine-inventory', compact('machines', 'companies', 'exportMachines'));
|
||||
return view('admin.warehouses.machine-inventory', compact('machines'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 匯出單一機台的「每個貨道」明細(CSV / Excel)——供機台詳細視圖(矩陣/表格)旁的匯出鈕使用。
|
||||
* 參考 ProductController::export / SalesController::handleExport 的串流下載模式。
|
||||
*/
|
||||
public function machineInventoryExport(Request $request, Machine $machine)
|
||||
{
|
||||
$type = $request->input('export', 'csv');
|
||||
$isExcel = ($type === 'excel');
|
||||
$ext = $isExcel ? 'xls' : 'csv';
|
||||
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
|
||||
|
||||
$slots = $machine->slots()
|
||||
->with('product:id,name,barcode')
|
||||
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
|
||||
->get();
|
||||
|
||||
$headers = ['貨道號', '商品ID', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
|
||||
|
||||
$safeSerial = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $machine->serial_no);
|
||||
$filename = '貨道明細_' . ($safeSerial ?: $machine->id) . '_' . now()->format('YmdHis') . '.' . $ext;
|
||||
|
||||
$callback = function () use ($slots, $headers, $isExcel) {
|
||||
$file = fopen('php://output', 'w');
|
||||
if ($isExcel) {
|
||||
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
|
||||
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
|
||||
fwrite($file, '<table><thead><tr>');
|
||||
foreach ($headers as $header) {
|
||||
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
|
||||
}
|
||||
fwrite($file, '</tr></thead><tbody>');
|
||||
} else {
|
||||
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
|
||||
fputcsv($file, $headers);
|
||||
}
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
$stock = (int) $slot->stock;
|
||||
$capacity = (int) $slot->max_stock;
|
||||
$rate = $capacity > 0 ? round($stock / $capacity * 100) : 0;
|
||||
|
||||
$row = [
|
||||
$slot->slot_no,
|
||||
$slot->product_id ?? '',
|
||||
$slot->product->name ?? '',
|
||||
$slot->product->barcode ?? '',
|
||||
$slot->type,
|
||||
$stock,
|
||||
$capacity,
|
||||
$rate,
|
||||
$slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '',
|
||||
$slot->batch_no,
|
||||
$slot->is_active ? '是' : '否',
|
||||
$slot->is_locked ? '是' : '否',
|
||||
];
|
||||
|
||||
if ($isExcel) {
|
||||
fwrite($file, '<tr>');
|
||||
foreach ($row as $cell) {
|
||||
fwrite($file, '<td>' . htmlspecialchars((string) $cell) . '</td>');
|
||||
}
|
||||
fwrite($file, '</tr>');
|
||||
} else {
|
||||
fputcsv($file, $row);
|
||||
}
|
||||
}
|
||||
|
||||
if ($isExcel) {
|
||||
fwrite($file, '</tbody></table></body></html>');
|
||||
}
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批次匯出「機台庫存概覽」——每台機台一個分頁(Excel sheet)/一個檔(CSV),內容為該機台每貨道明細。
|
||||
* Excel = 多分頁 xlsx(OpenSpout);CSV 無分頁概念,故每台一個 .csv 打包成 ZIP。
|
||||
* ids:逗號字串或陣列;空則匯出全部可存取機台。全域 scope 確保只含可存取機台。
|
||||
*/
|
||||
public function machineInventoryExportBatch(Request $request)
|
||||
{
|
||||
$isExcel = ($request->input('export', 'csv') === 'excel');
|
||||
|
||||
$ids = $request->input('ids');
|
||||
if (is_string($ids)) {
|
||||
$ids = array_filter(array_map('intval', explode(',', $ids)));
|
||||
} elseif (is_array($ids)) {
|
||||
$ids = array_filter(array_map('intval', $ids));
|
||||
} else {
|
||||
$ids = [];
|
||||
}
|
||||
|
||||
$query = Machine::with(['slots' => fn ($q) => $q->with('product:id,name,barcode')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')])
|
||||
->orderBy('name');
|
||||
if (!empty($ids)) {
|
||||
$query->whereIn('id', $ids);
|
||||
}
|
||||
$machines = $query->get();
|
||||
|
||||
$headers = ['貨道號', '商品ID', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
|
||||
$ts = now()->format('YmdHis');
|
||||
|
||||
// 單台的資料列
|
||||
$rowsOf = function ($machine) {
|
||||
$rows = [];
|
||||
foreach ($machine->slots as $slot) {
|
||||
$stock = (int) $slot->stock;
|
||||
$capacity = (int) $slot->max_stock;
|
||||
$rate = $capacity > 0 ? round($stock / $capacity * 100) : 0;
|
||||
$rows[] = [
|
||||
(string) $slot->slot_no,
|
||||
$slot->product_id ?? '',
|
||||
$slot->product->name ?? '',
|
||||
$slot->product->barcode ?? '',
|
||||
(string) $slot->type,
|
||||
$stock,
|
||||
$capacity,
|
||||
$rate,
|
||||
$slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '',
|
||||
(string) $slot->batch_no,
|
||||
$slot->is_active ? '是' : '否',
|
||||
$slot->is_locked ? '是' : '否',
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
};
|
||||
|
||||
// 分頁/檔名(機台名稱_序號,去非法字元、限長、去重;Excel sheet 名上限 31 字)
|
||||
$labelOf = function ($machine, array &$used) {
|
||||
$b = trim(($machine->name ?? '機台') . '_' . ($machine->serial_no ?? $machine->id));
|
||||
$b = preg_replace('/[\\\\\\/\\?\\*\\[\\]:]/u', '_', $b);
|
||||
$b = mb_substr($b, 0, 28);
|
||||
$name = $b;
|
||||
$i = 1;
|
||||
while (in_array($name, $used, true)) {
|
||||
$name = mb_substr($b, 0, 24) . '_' . (++$i);
|
||||
}
|
||||
$used[] = $name;
|
||||
return $name;
|
||||
};
|
||||
|
||||
if ($isExcel) {
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'invxlsx');
|
||||
$writer = new \OpenSpout\Writer\XLSX\Writer();
|
||||
$writer->openToFile($tmp);
|
||||
$used = [];
|
||||
$first = true;
|
||||
foreach ($machines as $machine) {
|
||||
$sheet = $first ? $writer->getCurrentSheet() : $writer->addNewSheetAndMakeItCurrent();
|
||||
$first = false;
|
||||
$sheet->setName($labelOf($machine, $used));
|
||||
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
|
||||
foreach ($rowsOf($machine) as $row) {
|
||||
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($row));
|
||||
}
|
||||
}
|
||||
if ($first) {
|
||||
$writer->getCurrentSheet()->setName('機台庫存');
|
||||
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
|
||||
}
|
||||
$writer->close();
|
||||
|
||||
return response()->download($tmp, "機台庫存_{$ts}.xlsx", [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
])->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
// CSV:每台一個 .csv 打包成 ZIP
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'invzip');
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($tmp, \ZipArchive::OVERWRITE);
|
||||
$used = [];
|
||||
foreach ($machines as $machine) {
|
||||
$fh = fopen('php://temp', 'r+');
|
||||
fputcsv($fh, $headers);
|
||||
foreach ($rowsOf($machine) as $row) {
|
||||
fputcsv($fh, $row);
|
||||
}
|
||||
rewind($fh);
|
||||
$content = "\xEF\xBB\xBF" . stream_get_contents($fh);
|
||||
fclose($fh);
|
||||
$zip->addFromString($labelOf($machine, $used) . '.csv', $content);
|
||||
}
|
||||
if ($machines->isEmpty()) {
|
||||
$zip->addFromString('機台庫存.csv', "\xEF\xBB\xBF" . implode(',', $headers) . "\n");
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return response()->download($tmp, "機台庫存_{$ts}.zip")->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX:取得單台機台貨道詳情
|
||||
*/
|
||||
@ -986,17 +707,10 @@ class WarehouseController extends Controller
|
||||
|
||||
public function replenishments(Request $request)
|
||||
{
|
||||
$currentUser = Auth::user();
|
||||
$companyId = trim((string) $request->input('company_id', ''));
|
||||
$isSystemAdmin = $currentUser->isSystemAdmin();
|
||||
|
||||
$query = ReplenishmentOrder::with(['warehouse:id,name', 'machine:id,name,serial_no', 'assignee:id,name', 'creator:id,name'])
|
||||
->withCount('items')
|
||||
->latest();
|
||||
|
||||
if ($isSystemAdmin && $companyId !== '') {
|
||||
$query->where('company_id', $companyId);
|
||||
}
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
@ -1024,29 +738,20 @@ class WarehouseController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$companies = $isSystemAdmin
|
||||
? \App\Models\System\Company::active()->orderBy('name')->get()
|
||||
: collect();
|
||||
$applyCompanyFilter = fn($query) => $query->when(
|
||||
$isSystemAdmin && $companyId !== '',
|
||||
fn($q) => $q->where('company_id', $companyId)
|
||||
);
|
||||
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']);
|
||||
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
|
||||
$products = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
|
||||
|
||||
$warehouses = $applyCompanyFilter(Warehouse::active())->orderBy('name')->get(['id', 'name']);
|
||||
$machines = $applyCompanyFilter(Machine::query())->orderBy('name')->get(['id', 'name', 'serial_no']);
|
||||
$products = $applyCompanyFilter(Product::with('translations'))->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
|
||||
|
||||
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
|
||||
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name')->visibleTo($currentUser);
|
||||
if ($isSystemAdmin && $companyId !== '') {
|
||||
$userQuery->where('company_id', $companyId);
|
||||
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name');
|
||||
if (!Auth::user()->isSystemAdmin()) {
|
||||
$userQuery->where('company_id', Auth::user()->company_id);
|
||||
}
|
||||
$users = $userQuery->get(['id', 'name']);
|
||||
|
||||
$defaultStart = '';
|
||||
$defaultEnd = '';
|
||||
|
||||
return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines', 'products', 'users', 'companies', 'defaultStart', 'defaultEnd'));
|
||||
return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines', 'products', 'users', 'defaultStart', 'defaultEnd'));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1072,10 +777,7 @@ class WarehouseController extends Controller
|
||||
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
|
||||
]);
|
||||
|
||||
// 補貨針對機台,單據歸屬以「機台所綁公司」為準(系統管理員可為他公司機台建單)
|
||||
$machine = Machine::findOrFail($validated['machine_id']);
|
||||
|
||||
DB::transaction(function () use ($validated, $machine) {
|
||||
DB::transaction(function () use ($validated) {
|
||||
$prefix = 'RP-' . now()->format('Ymd') . '-';
|
||||
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
|
||||
->withTrashed()
|
||||
@ -1087,23 +789,18 @@ class WarehouseController extends Controller
|
||||
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$order = ReplenishmentOrder::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'company_id' => Auth::user()->company_id,
|
||||
'order_no' => $order_no,
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'machine_id' => $machine->id,
|
||||
'machine_id' => $validated['machine_id'],
|
||||
'status' => ReplenishmentOrder::STATUS_PENDING,
|
||||
'note' => $validated['note'] ?? null,
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
// 預先取得商品資訊以建立快照
|
||||
$productIds = collect($validated['items'])->pluck('product_id')->unique()->toArray();
|
||||
$products = \App\Models\Product\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||||
|
||||
// 批量寫入明細(遵循 DB Best Practice: 不在迴圈中逐筆 create)
|
||||
$itemsData = [];
|
||||
foreach ($validated['items'] as $item) {
|
||||
$product = $products->get($item['product_id']);
|
||||
$itemsData[] = [
|
||||
'replenishment_order_id' => $order->id,
|
||||
'product_id' => $item['product_id'],
|
||||
@ -1111,10 +808,6 @@ class WarehouseController extends Controller
|
||||
'quantity' => $item['quantity'],
|
||||
'current_stock' => $item['current_stock'] ?? 0,
|
||||
'max_stock' => $item['max_stock'] ?? 0,
|
||||
'snapshot_product_name' => $product ? $product->name : null,
|
||||
'snapshot_product_name_key' => $product ? $product->name_dictionary_key : null,
|
||||
'snapshot_product_barcode' => $product ? $product->barcode : null,
|
||||
'snapshot_product_image_url' => $product ? $product->image_url : null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
@ -1209,7 +902,7 @@ class WarehouseController extends Controller
|
||||
}
|
||||
|
||||
// 有 items = 確認建單
|
||||
DB::transaction(function () use ($validated, $machine) {
|
||||
DB::transaction(function () use ($validated) {
|
||||
$prefix = 'RP-' . now()->format('Ymd') . '-';
|
||||
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
|
||||
->withTrashed()
|
||||
@ -1221,22 +914,17 @@ class WarehouseController extends Controller
|
||||
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$order = ReplenishmentOrder::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'company_id' => Auth::user()->company_id,
|
||||
'order_no' => $order_no,
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'machine_id' => $machine->id,
|
||||
'machine_id' => $validated['machine_id'],
|
||||
'status' => ReplenishmentOrder::STATUS_PENDING,
|
||||
'note' => $validated['note'] ?? __('Auto Replenishment'),
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
// 預先取得商品資訊以建立快照
|
||||
$productIds = collect($validated['items'])->pluck('product_id')->unique()->toArray();
|
||||
$products = \App\Models\Product\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||||
|
||||
$itemsData = [];
|
||||
foreach ($validated['items'] as $item) {
|
||||
$product = $products->get($item['product_id']);
|
||||
$itemsData[] = [
|
||||
'replenishment_order_id' => $order->id,
|
||||
'product_id' => $item['product_id'],
|
||||
@ -1244,10 +932,6 @@ class WarehouseController extends Controller
|
||||
'quantity' => $item['quantity'],
|
||||
'current_stock' => $item['current_stock'] ?? 0,
|
||||
'max_stock' => $item['max_stock'] ?? 0,
|
||||
'snapshot_product_name' => $product ? $product->name : null,
|
||||
'snapshot_product_name_key' => $product ? $product->name_dictionary_key : null,
|
||||
'snapshot_product_barcode' => $product ? $product->barcode : null,
|
||||
'snapshot_product_image_url' => $product ? $product->image_url : null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
@ -1324,8 +1008,36 @@ class WarehouseController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// delivering → completed:更新完成時間(去耦機台庫存累加,由機台端上報決定)
|
||||
// delivering → completed:增加機台貨道庫存並記錄異動
|
||||
if ($newStatus === ReplenishmentOrder::STATUS_COMPLETED) {
|
||||
foreach ($replenishmentOrder->items as $item) {
|
||||
$slot = MachineSlot::where('machine_id', $replenishmentOrder->machine_id)
|
||||
->where('slot_no', $item->slot_no)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($slot) {
|
||||
$beforeQty = $slot->stock;
|
||||
$slot->increment('stock', $item->quantity);
|
||||
$slot->refresh();
|
||||
|
||||
MachineStockMovement::create([
|
||||
'company_id' => $replenishmentOrder->company_id,
|
||||
'machine_id' => $replenishmentOrder->machine_id,
|
||||
'product_id' => $item->product_id,
|
||||
'slot_no' => $item->slot_no,
|
||||
'type' => MachineStockMovement::TYPE_REPLENISHMENT,
|
||||
'quantity' => $item->quantity,
|
||||
'before_qty' => $beforeQty,
|
||||
'after_qty' => $slot->stock,
|
||||
'reference_type' => ReplenishmentOrder::class,
|
||||
'reference_id' => $replenishmentOrder->id,
|
||||
'note' => '補貨單完成入庫 #' . $replenishmentOrder->order_no,
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$replenishmentOrder->completed_at = now();
|
||||
}
|
||||
|
||||
@ -1493,14 +1205,14 @@ class WarehouseController extends Controller
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
$items = ReplenishmentOrderItem::with(['translations', 'product.translations'])
|
||||
$items = ReplenishmentOrderItem::with('product.translations')
|
||||
->where('replenishment_order_id', $id)
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->localized_name ?: __('Unknown'),
|
||||
'image_url' => $item->image_url,
|
||||
'product_name' => $item->product?->localized_name ?? 'Unknown',
|
||||
'image_url' => $item->product?->image_url,
|
||||
'quantity' => $item->quantity,
|
||||
'slot_no' => $item->slot_no,
|
||||
'current_stock' => $item->current_stock,
|
||||
@ -1664,14 +1376,14 @@ class WarehouseController extends Controller
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
$items = \App\Models\Warehouse\TransferOrderItem::with(['translations', 'product.translations'])
|
||||
$items = \App\Models\Warehouse\TransferOrderItem::with('product.translations')
|
||||
->where('transfer_order_id', $id)
|
||||
->get()
|
||||
->map(function($item) {
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->localized_name ?: __('Unknown'),
|
||||
'image_url' => $item->image_url,
|
||||
'product_name' => $item->product?->localized_name ?? __('Unknown'),
|
||||
'image_url' => $item->product?->image_url,
|
||||
'quantity' => (int)$item->quantity,
|
||||
];
|
||||
});
|
||||
@ -1695,46 +1407,4 @@ class WarehouseController extends Controller
|
||||
'items' => $items
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列印調撥單
|
||||
*/
|
||||
public function printTransfer($id)
|
||||
{
|
||||
$order = TransferOrder::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator'])
|
||||
->findOrFail($id);
|
||||
|
||||
// 雙重防護:驗證租戶資料隔離安全
|
||||
$user = auth()->user();
|
||||
if ($user && $user->company_id && $order->company_id !== $user->company_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$items = \App\Models\Warehouse\TransferOrderItem::with(['product' => fn($q) => $q->with('translations')])
|
||||
->where('transfer_order_id', $id)
|
||||
->get();
|
||||
|
||||
return view('admin.warehouses.print.transfer', compact('order', 'items'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 列印機台補貨單
|
||||
*/
|
||||
public function printReplenishment($id)
|
||||
{
|
||||
$order = ReplenishmentOrder::with(['machine', 'warehouse', 'creator', 'assignee'])
|
||||
->findOrFail($id);
|
||||
|
||||
// 雙重防護:驗證租戶資料隔離安全
|
||||
$user = auth()->user();
|
||||
if ($user && $user->company_id && $order->company_id !== $user->company_id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$items = ReplenishmentOrderItem::with(['product' => fn($q) => $q->with('translations')])
|
||||
->where('replenishment_order_id', $id)
|
||||
->get();
|
||||
|
||||
return view('admin.warehouses.print.replenishment', compact('order', 'items'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,22 +59,16 @@ class MachineAuthController extends Controller
|
||||
}
|
||||
|
||||
// 5. RBAC 權限驗證 (遵循多租戶與機台授權規範)
|
||||
// $identity: 登入者身分 (system:系統登入者 / company:公司帳號 / staff:一般人員)
|
||||
// 僅在授權通過時賦值,確保未授權者不洩漏身分
|
||||
$isAuthorized = false;
|
||||
$identity = null;
|
||||
if ($user->isSystemAdmin()) {
|
||||
$isAuthorized = true;
|
||||
$identity = 'system';
|
||||
} elseif ($user->is_admin) {
|
||||
if ($machine->company_id === $user->company_id) {
|
||||
$isAuthorized = true;
|
||||
$identity = 'company';
|
||||
}
|
||||
} else {
|
||||
if ($user->machines()->where('machine_id', $machine->id)->exists()) {
|
||||
$isAuthorized = true;
|
||||
$identity = 'staff';
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,7 +120,6 @@ class MachineAuthController extends Controller
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Success',
|
||||
'identity' => $identity,
|
||||
'token' => $user->createToken('technician-setup', ['*'], now()->addHours(8))->plainTextToken
|
||||
]);
|
||||
}
|
||||
|
||||
@ -20,8 +20,6 @@ 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;
|
||||
@ -371,12 +369,6 @@ class MachineController extends Controller
|
||||
'product_id' => $item['t060v00'] ?? null,
|
||||
'stock' => isset($item['num']) ? (int) $item['num'] : 0,
|
||||
'type' => isset($item['type']) ? (int) $item['type'] : null,
|
||||
// 雙向 LWW:鎖定與效期/批號 + 各自的編輯序號(App 端 store 編輯才遞增)
|
||||
'is_locked' => isset($item['is_locked']) ? (bool) $item['is_locked'] : false,
|
||||
'lock_rev' => isset($item['lock_rev']) ? (int) $item['lock_rev'] : 0,
|
||||
'expiry_date' => $item['expiry_date'] ?? null,
|
||||
'batch_no' => $item['batch_no'] ?? null,
|
||||
'expiry_rev' => isset($item['expiry_rev']) ? (int) $item['expiry_rev'] : 0,
|
||||
];
|
||||
}, $legacyData);
|
||||
|
||||
@ -400,8 +392,8 @@ class MachineController extends Controller
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
|
||||
// 公司基底目錄(快取) + 該機台專屬定價覆蓋(即時套用,見 getMachinePayload)
|
||||
$payload = $catalogService->getMachinePayload($machine);
|
||||
// Cache First: Get from cache or rebuild if missing
|
||||
$payload = $catalogService->getPayload($machine->company_id);
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
@ -429,121 +421,37 @@ 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:支付旗標(命名沿用機台 DevSetStructure;DevCreditCard/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([
|
||||
@ -553,89 +461,6 @@ class MachineController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B016: Update Machine System Settings (Write-back from machine console)
|
||||
* 機台主控台「系統設定」由系統方 (identity=system) 編輯後回寫雲端。
|
||||
* 認證:B000 核發的使用者 Token (auth:sanctum);僅系統管理員可操作。
|
||||
* Body: { machine: 序號, settings: { is_spring_slot_*: bool, ... } }
|
||||
* 機台端僅回寫硬體貨道類型 (is_spring_slot_*) 實體欄位;其餘支付旗標、
|
||||
* 現金面額、功能模組等開關純由後台決定,B016 不接收,未列入者一律忽略。
|
||||
*/
|
||||
public function updateSettings(Request $request)
|
||||
{
|
||||
// 1. 僅系統方可回寫(雙重把關:App 端隱藏控制 + 此處伺服器驗權)
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->isSystemAdmin()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 403,
|
||||
'message' => __('Forbidden')
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. 查找機台
|
||||
$serialNo = $request->input('machine');
|
||||
$machine = Machine::withoutGlobalScopes()
|
||||
->where('serial_no', $serialNo)
|
||||
->first();
|
||||
|
||||
if (!$machine) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 404,
|
||||
'message' => __('Machine not found')
|
||||
], 404);
|
||||
}
|
||||
|
||||
$settings = $request->input('settings', []);
|
||||
if (!is_array($settings)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 422,
|
||||
'message' => __('Invalid settings payload')
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 3. 直接欄位白名單:machines 實體布林欄位(硬體貨道類型)
|
||||
// 機台端 B016 僅回寫貨道類型,其餘系統設定由後台單向決定,不在此處接收。
|
||||
$columnBoolKeys = [
|
||||
'is_spring_slot_1_10', 'is_spring_slot_11_20', 'is_spring_slot_21_30',
|
||||
'is_spring_slot_31_40', 'is_spring_slot_41_50', 'is_spring_slot_51_60',
|
||||
];
|
||||
|
||||
// 4. 整理實體欄位更新(僅貨道類型,只覆寫有送來的鍵)
|
||||
$columnData = [];
|
||||
foreach ($columnBoolKeys as $k) {
|
||||
if (array_key_exists($k, $settings)) {
|
||||
$columnData[$k] = (bool) $settings[$k];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($columnData)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'code' => 422,
|
||||
'message' => __('Invalid settings payload')
|
||||
], 422);
|
||||
}
|
||||
|
||||
$machine->update(array_merge($columnData, [
|
||||
'updater_id' => $user->id,
|
||||
]));
|
||||
|
||||
\Log::info('B016 Machine settings updated', [
|
||||
'machine' => $machine->serial_no,
|
||||
'user_id' => $user->id,
|
||||
'keys' => array_keys($settings),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => __('Settings updated successfully.')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B660: Verify/Consume Pickup Code
|
||||
* POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌)
|
||||
@ -698,114 +523,17 @@ class MachineController extends Controller
|
||||
]);
|
||||
|
||||
// 移除 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 finalize(TransactionService::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);
|
||||
}
|
||||
}
|
||||
// 憑證的「核銷」將由 MQTT finalizeTransaction 流程處理,不再需要 B660 PUT
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => $data,
|
||||
'data' => [
|
||||
'slot_no' => $pickupCode->slot_no,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'code_id' => $pickupCode->id, // 統一回傳 code_id
|
||||
'status' => 'active'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@ -852,17 +580,6 @@ class MachineController extends Controller
|
||||
'raw_data' => ['machine_sn' => $machine->serial_no, 'code' => $passCode->code]
|
||||
]);
|
||||
|
||||
// 單次/限次通行碼:驗證成功即計為使用一次;達上限就標記 used,
|
||||
// 下次驗證會被上方 where('status','active') 過濾擋掉,達成「只能使用一次」。
|
||||
// usage_limit 為 null(未勾單次)時不計數,維持原本無限次行為。
|
||||
if (!is_null($passCode->usage_limit)) {
|
||||
$newCount = $passCode->usage_count + 1;
|
||||
$passCode->update([
|
||||
'usage_count' => $newCount,
|
||||
'status' => $newCount >= $passCode->usage_limit ? 'used' : $passCode->status,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -924,73 +641,4 @@ class MachineController extends Controller
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* B690: Verify Welcome Gift (New)
|
||||
*/
|
||||
public function verifyWelcomeGift(Request $request)
|
||||
{
|
||||
$machine = $request->get('machine');
|
||||
$lockoutKey = "welcome_gift_lockout:{$machine->id}";
|
||||
|
||||
if (Cache::has($lockoutKey)) {
|
||||
return response()->json(['success' => false, 'message' => 'Too many attempts. Locked.', 'code' => 429], 429);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
|
||||
}
|
||||
|
||||
$code = $request->input('code');
|
||||
$gift = WelcomeGift::where('machine_id', $machine->id)
|
||||
->where('code', $code)
|
||||
->where('status', 'active')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$gift || !$gift->isValid()) {
|
||||
$fails = Cache::increment("welcome_gift_fails:{$machine->id}");
|
||||
if ($fails === 1) {
|
||||
Cache::put("welcome_gift_fails:{$machine->id}", 1, 600);
|
||||
}
|
||||
if ($fails >= 5) {
|
||||
Cache::put($lockoutKey, true, 60);
|
||||
Cache::forget("welcome_gift_fails:{$machine->id}");
|
||||
}
|
||||
return response()->json(['success' => false, 'message' => 'Invalid or expired code', 'code' => 404, 'remaining_attempts' => max(0, 5 - $fails)], 404);
|
||||
}
|
||||
|
||||
Cache::forget("welcome_gift_fails:{$machine->id}");
|
||||
|
||||
// 建立來店禮驗證日誌 (驗證成功)
|
||||
WelcomeGiftLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'machine_id' => $machine->id,
|
||||
'welcome_gift_id' => $gift->id,
|
||||
'action' => 'verify_success',
|
||||
'remark' => 'log.welcome_gift.verify_success',
|
||||
'raw_data' => [
|
||||
'code' => $gift->code,
|
||||
'discount_type' => $gift->discount_type,
|
||||
'discount_value' => $gift->discount_value
|
||||
]
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'name' => $gift->name,
|
||||
'code_id' => $gift->id,
|
||||
'discount_type' => $gift->discount_type,
|
||||
'discount_value' => $gift->discount_value,
|
||||
'discount_label' => $gift->discount_label, // 回傳自動算好的中文折數 (如「八折」、「折50元」)
|
||||
'usage_type' => $gift->usage_type,
|
||||
'status' => 'active'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ class PickupController extends Controller
|
||||
*/
|
||||
public function show($slug)
|
||||
{
|
||||
$pickupCode = PickupCode::with(['machine.slots.product', 'product'])
|
||||
$pickupCode = PickupCode::with(['machine.slots.product'])
|
||||
->where(function($query) use ($slug) {
|
||||
$query->where('slug', $slug)
|
||||
->orWhere('code', $slug); // 相容舊版或測試
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -79,19 +79,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';
|
||||
|
||||
@ -147,7 +134,7 @@ class ProcessCommandAck implements ShouldQueue
|
||||
]);
|
||||
|
||||
// 記錄維護類指令到機台日誌 (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)) {
|
||||
if (in_array($command->command_type, ['reboot', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads'], true)) {
|
||||
$msgKey = $status === 'success' ? 'log.command.success' : 'log.command.failed';
|
||||
$level = $status === 'success' ? 'info' : 'warning';
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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:reconcile(GetIssue)做最終查證,避免重複開立。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,16 +11,11 @@ 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;
|
||||
|
||||
@ -64,10 +59,7 @@ class ProcessTransactionFinalized implements ShouldQueue
|
||||
],
|
||||
'transaction'
|
||||
);
|
||||
|
||||
// 刷卡機健康狀態同步:成功消警 / 失敗寫 card_terminal 日誌
|
||||
$this->syncCardTerminalStatus($machine, $order);
|
||||
|
||||
|
||||
Log::info("MQTT Transaction [finalize] processed successfully", [
|
||||
'serial_no' => $this->serialNo,
|
||||
'order_no' => $order->order_no
|
||||
@ -97,45 +89,4 @@ class ProcessTransactionFinalized implements ShouldQueue
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷卡機 (信用卡/電子票證/手機支付,payment_type 1/2/10) 狀態同步。
|
||||
* 刷卡機沒有斷線心跳,所以靠交易結果維護燈號:
|
||||
* - 成功交易 → 自動消除該機台先前未解決的刷卡機警告,恢復正常。
|
||||
* - 失敗交易(payment_response 帶 NFC 回應碼) → 寫一筆 type='card_terminal' 的 warning 日誌
|
||||
* (warning 級依 MachineLog 既有規則不會觸發 Discord)。
|
||||
*/
|
||||
private function syncCardTerminalStatus(Machine $machine, Order $order): void
|
||||
{
|
||||
if (!in_array((int) $order->payment_type, self::CARD_TERMINAL_PAYMENT_TYPES, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷卡成功 → 消警,恢復刷卡機燈號
|
||||
if ((int) $order->payment_status === Order::PAYMENT_STATUS_SUCCESS) {
|
||||
$machine->logs()
|
||||
->where('type', 'card_terminal')
|
||||
->where('is_resolved', false)
|
||||
->update(['is_resolved' => true]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷卡失敗 → 解析 App 端格式 "[電文] | code=XXXX" 取回應碼
|
||||
if (!preg_match('/\|\s*code=([A-Za-z0-9_]+)/', (string) $order->payment_response, $m)) {
|
||||
return; // 無刷卡機回應碼(例如顧客未感應就放棄),不視為硬體訊號問題
|
||||
}
|
||||
$code = $m[1];
|
||||
if ($code === '0000') {
|
||||
return; // 0000 為成功碼,理論上不會走到失敗分支,保險過濾
|
||||
}
|
||||
|
||||
ProcessStateLog::dispatch(
|
||||
$machine->id,
|
||||
$machine->company_id,
|
||||
'Card payment failed',
|
||||
'warning',
|
||||
['card_code' => $code, 'order_no' => $order->order_no],
|
||||
'card_terminal'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -16,8 +16,8 @@ class Machine extends Model
|
||||
// 權限隔離:一般帳號登入時只能看到自己被分配的機台
|
||||
static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) {
|
||||
$user = auth()->user();
|
||||
// 如果是在 Console(且非單元測試)、或是系統管理員,則不限制 (可看所有機台)
|
||||
if ((app()->runningInConsole() && !app()->runningUnitTests()) || !$user || $user->isSystemAdmin()) {
|
||||
// 如果是在 Console、或是系統管理員,則不限制 (可看所有機台)
|
||||
if (app()->runningInConsole() || !$user || $user->isSystemAdmin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -43,25 +43,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 +59,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',
|
||||
@ -229,52 +207,6 @@ class Machine extends Model
|
||||
->first()?->created_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷卡機健康狀態 (基於 type = 'card_terminal' 未解決日誌)。
|
||||
* 信用卡/電子票證/手機支付共用同一台實體刷卡機,刷卡失敗會寫此類日誌,
|
||||
* 下一次刷卡成功時由 finalize 自動消警恢復正常。
|
||||
* 列表頁優先使用 MachineController 預載的彙總欄位 (避免 N+1),其餘情境退回即時查詢。
|
||||
*/
|
||||
public function getCardTerminalStatusAttribute(): string
|
||||
{
|
||||
if (array_key_exists('card_terminal_error_count', $this->attributes)
|
||||
|| array_key_exists('card_terminal_warning_count', $this->attributes)) {
|
||||
if (($this->attributes['card_terminal_error_count'] ?? 0) > 0) {
|
||||
return 'error';
|
||||
}
|
||||
if (($this->attributes['card_terminal_warning_count'] ?? 0) > 0) {
|
||||
return 'warning';
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
if ($this->logs()->where('type', 'card_terminal')->where('level', 'error')->where('is_resolved', false)->exists()) {
|
||||
return 'error';
|
||||
}
|
||||
if ($this->logs()->where('type', 'card_terminal')->where('level', 'warning')->where('is_resolved', false)->exists()) {
|
||||
return 'warning';
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得最新一筆未解決的刷卡機日誌時間 (列表頁優先用預載 subquery 欄位,避免 N+1)。
|
||||
*/
|
||||
public function getLatestCardTerminalLogTimeAttribute()
|
||||
{
|
||||
if (array_key_exists('latest_card_terminal_log_at', $this->attributes)) {
|
||||
$value = $this->attributes['latest_card_terminal_log_at'];
|
||||
return $value ? \Illuminate\Support\Carbon::parse($value) : null;
|
||||
}
|
||||
|
||||
return $this->logs()
|
||||
->where('type', 'card_terminal')
|
||||
->whereIn('level', ['error', 'warning'])
|
||||
->where('is_resolved', false)
|
||||
->latest()
|
||||
->first()?->created_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 判定在線 (讀取 status 欄位)
|
||||
*/
|
||||
@ -366,9 +298,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',
|
||||
@ -409,11 +338,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);
|
||||
@ -485,147 +409,6 @@ class Machine extends Model
|
||||
->count();
|
||||
}
|
||||
|
||||
// === settings JSON 重構 Accessors & Mutators ===
|
||||
|
||||
public function getShoppingModeAttribute(): string
|
||||
{
|
||||
return $this->settings['shopping_mode'] ?? 'basic';
|
||||
}
|
||||
|
||||
public function setShoppingModeAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['shopping_mode'] = (string) $value;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
public function getTaxInvoiceEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['tax_invoice_enabled'] ?? ($this->attributes['tax_invoice_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setTaxInvoiceEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['tax_invoice_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['tax_invoice_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getCardTerminalEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['card_terminal_enabled'] ?? ($this->attributes['card_terminal_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setCardTerminalEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['card_terminal_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['card_terminal_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要在後台呈現刷卡機狀態燈號與日誌分頁。
|
||||
* 僅「基礎版(shopping_mode=basic)」且「刷卡機支付已啟用」的機台才有意義;
|
||||
* 購物車模式或未啟用刷卡機的機台不顯示。
|
||||
*/
|
||||
public function getShowCardTerminalAttribute(): bool
|
||||
{
|
||||
return $this->shopping_mode === 'basic' && $this->card_terminal_enabled;
|
||||
}
|
||||
|
||||
public function getScanPayEsunEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['scan_pay_esun_enabled'] ?? ($this->attributes['scan_pay_esun_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setScanPayEsunEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['scan_pay_esun_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['scan_pay_esun_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getScanPayLinepayEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['scan_pay_linepay_enabled'] ?? ($this->attributes['scan_pay_linepay_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setScanPayLinepayEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['scan_pay_linepay_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['scan_pay_linepay_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getShoppingCartEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['shopping_cart_enabled'] ?? ($this->attributes['shopping_cart_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setShoppingCartEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['shopping_cart_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['shopping_cart_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getWelcomeGiftEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['welcome_gift_enabled'] ?? ($this->attributes['welcome_gift_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setWelcomeGiftEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['welcome_gift_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['welcome_gift_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getCashModuleEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['cash_module_enabled'] ?? ($this->attributes['cash_module_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setCashModuleEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['cash_module_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['cash_module_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getMemberSystemEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['member_system_enabled'] ?? ($this->attributes['member_system_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setMemberSystemEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['member_system_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['member_system_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function getAmbientTempMonitoringEnabledAttribute(): bool
|
||||
{
|
||||
return (bool) ($this->settings['ambient_temp_monitoring_enabled'] ?? ($this->attributes['ambient_temp_monitoring_enabled'] ?? false));
|
||||
}
|
||||
|
||||
public function setAmbientTempMonitoringEnabledAttribute($value)
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
$settings['ambient_temp_monitoring_enabled'] = (bool) $value;
|
||||
$this->settings = $settings;
|
||||
$this->attributes['ambient_temp_monitoring_enabled'] = (bool) $value;
|
||||
}
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\System\User::class);
|
||||
|
||||
@ -58,10 +58,7 @@ class MachineLog extends Model
|
||||
}
|
||||
}
|
||||
} elseif ($log->type === 'submachine') {
|
||||
// 下位機日誌僅在 warning 或 error 級別時發送 Discord 告警,info 狀態日誌不發送
|
||||
if (in_array($log->level, ['warning', 'error'])) {
|
||||
$shouldNotify = true;
|
||||
}
|
||||
$shouldNotify = true;
|
||||
} elseif ($log->level === 'error') {
|
||||
$shouldNotify = true;
|
||||
}
|
||||
@ -99,19 +96,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})";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 倉庫庫存紀錄
|
||||
*/
|
||||
|
||||
@ -83,101 +83,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 2,level 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.
|
||||
*/
|
||||
|
||||
@ -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,10 @@ 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,21 +28,12 @@ class Invoice extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'invoice_date' => 'date',
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'machine_time' => 'datetime',
|
||||
'last_checked_at' => 'datetime',
|
||||
'voided_at' => 'datetime',
|
||||
'retry_count' => 'integer',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
// 發票狀態常數
|
||||
public const STATUS_PENDING = 'pending'; // 已送出綠界、尚未確認
|
||||
public const STATUS_ISSUED = 'issued'; // 已開立成功
|
||||
public const STATUS_FAILED = 'failed'; // 綠界回失敗,待補開
|
||||
public const STATUS_VOID = 'void'; // 已作廢
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
|
||||
@ -6,53 +6,21 @@ 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'; // 手動補單(後台人工補登,機台斷線漏報時使用)
|
||||
use HasFactory, SoftDeletes, TenantScoped;
|
||||
|
||||
// 支付狀態
|
||||
public const PAYMENT_STATUS_FAILED = 0;
|
||||
public const PAYMENT_STATUS_SUCCESS = 1;
|
||||
|
||||
// 出貨狀態
|
||||
public const DELIVERY_STATUS_FAILED = 0;
|
||||
public const DELIVERY_STATUS_SUCCESS = 1;
|
||||
public const DELIVERY_STATUS_PARTIAL = 2;
|
||||
|
||||
// 交易生命週期狀態(status 欄位,字串)。
|
||||
// pending:進入付款、等待結果;completed:付款成功(出貨另看 delivery_status);
|
||||
// failed:付款失敗/逾時;abandoned:感應前取消(由排程把逾時 pending 標記)。
|
||||
// ⚠ 終態 = [completed, failed, abandoned],一律冪等不可覆蓋(保護線上 main 機台行為不變)。
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
// 領藥單專用:已開立、等待病人到機台領取(非 pending,不被逾時清掃;非終態,可被出貨 finalize 更新)。
|
||||
public const STATUS_AWAITING_PICKUP = 'awaiting_pickup';
|
||||
|
||||
/** 終態清單:到了這些狀態就不再變更(冪等)。 */
|
||||
public const TERMINAL_STATUSES = [
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_ABANDONED,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'flow_id',
|
||||
'order_no',
|
||||
'order_type',
|
||||
'pricing_slip_no',
|
||||
'created_by',
|
||||
'machine_id',
|
||||
'member_id',
|
||||
'payment_type',
|
||||
@ -60,7 +28,6 @@ class Order extends Model
|
||||
'discount_amount',
|
||||
'pay_amount',
|
||||
'change_amount',
|
||||
'cash_detail',
|
||||
'points_used',
|
||||
'original_amount',
|
||||
'payment_status',
|
||||
@ -69,13 +36,10 @@ class Order extends Model
|
||||
'payment_at',
|
||||
'member_barcode',
|
||||
'code_id',
|
||||
'welcome_gift_id',
|
||||
'invoice_info',
|
||||
'machine_time',
|
||||
'status',
|
||||
'metadata',
|
||||
'delivery_status',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -83,51 +47,13 @@ class Order extends Model
|
||||
'discount_amount' => 'decimal:2',
|
||||
'pay_amount' => 'decimal:2',
|
||||
'change_amount' => 'decimal:2',
|
||||
'cash_detail' => 'array',
|
||||
'original_amount' => 'decimal:2',
|
||||
'payment_at' => 'datetime',
|
||||
'machine_time' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
'payment_type' => 'integer',
|
||||
'delivery_status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 現金收款摘要字串:「收:{總收金額}(只列有收到的面額明細) 找:{找零}」。
|
||||
* 例:收:120(佰:1、10:2) 找:30。無 cash_detail(非現金或舊資料)或全為 0 時回 null。
|
||||
* 總收金額由各面額張數加總得出,與括號內明細一致。
|
||||
*/
|
||||
public function getCashReceivedSummaryAttribute(): ?string
|
||||
{
|
||||
$cd = $this->cash_detail;
|
||||
if (empty($cd)) {
|
||||
return null;
|
||||
}
|
||||
// 面額顯示標籤 + 幣值(依大到小)
|
||||
$denoms = [
|
||||
'b1000' => ['label' => '仟', 'unit' => 1000],
|
||||
'b500' => ['label' => '伍佰', 'unit' => 500],
|
||||
'b100' => ['label' => '佰', 'unit' => 100],
|
||||
'c50' => ['label' => '50', 'unit' => 50],
|
||||
'c10' => ['label' => '10', 'unit' => 10],
|
||||
'c5' => ['label' => '5', 'unit' => 5],
|
||||
'c1' => ['label' => '1', 'unit' => 1],
|
||||
];
|
||||
$received = 0;
|
||||
$parts = [];
|
||||
foreach ($denoms as $key => $d) {
|
||||
$n = (int) ($cd[$key] ?? 0);
|
||||
if ($n > 0) {
|
||||
$parts[] = $d['label'] . ':' . $n; // 只列有收到的幣別
|
||||
$received += $n * $d['unit'];
|
||||
}
|
||||
}
|
||||
if (empty($parts)) {
|
||||
return null;
|
||||
}
|
||||
return '收:' . $received . '(' . implode('、', $parts) . ') 找:' . number_format($this->change_amount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有支付類型的對照標籤
|
||||
*/
|
||||
@ -136,15 +62,13 @@ class Order extends Model
|
||||
return [
|
||||
1 => __('Credit Card'),
|
||||
2 => __('E-Ticket (EasyCard/iPass)'),
|
||||
3 => __('QR Code Payment - E.SUN'), // 掃碼支付-玉山
|
||||
3 => __('QR Code Payment'),
|
||||
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'),
|
||||
@ -157,7 +81,6 @@ class Order extends Model
|
||||
34 => __('PlusPay'),
|
||||
40 => __('Member Verify Pickup'),
|
||||
41 => __('Staff Card'),
|
||||
42 => __('Pickup Voucher'),
|
||||
50 => __('Offline + LINE Pay'),
|
||||
51 => __('Offline + JKO Pay'),
|
||||
52 => __('Offline + Easy Wallet'),
|
||||
@ -173,7 +96,6 @@ class Order extends Model
|
||||
67 => __('Member + 7'),
|
||||
68 => __('Member + 8'),
|
||||
69 => __('Member + 9'),
|
||||
70 => __('LINE Pay (Official)'), // LINE Pay 官方直連(非 TapPay 底下的 30)
|
||||
90 => __('Member + LINE Pay'),
|
||||
91 => __('Member + JKO Pay'),
|
||||
92 => __('Member + Easy Wallet'),
|
||||
@ -190,76 +112,11 @@ class Order extends Model
|
||||
return self::getPaymentTypeLabels()[$this->payment_type] ?? __('Unknown');
|
||||
}
|
||||
|
||||
public function getMaskedPickupRecipientAttribute(): ?string
|
||||
{
|
||||
if (empty($this->member_barcode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::maskPickupRecipientName($this->member_barcode);
|
||||
}
|
||||
|
||||
public static function maskPickupRecipientName(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('/^([^\s((]+)(.*)$/us', $value, $matches) !== 1) {
|
||||
return self::maskName($value);
|
||||
}
|
||||
|
||||
return self::maskName($matches[1]) . ($matches[2] ?? '');
|
||||
}
|
||||
|
||||
private static function maskName(string $name): string
|
||||
{
|
||||
$length = mb_strlen($name);
|
||||
|
||||
if ($length <= 1) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
if ($length === 2) {
|
||||
return mb_substr($name, 0, 1) . 'O';
|
||||
}
|
||||
|
||||
return mb_substr($name, 0, 1)
|
||||
. str_repeat('O', $length - 2)
|
||||
. mb_substr($name, -1);
|
||||
}
|
||||
|
||||
/** 一般銷售訂單 */
|
||||
public function scopeSales($query)
|
||||
{
|
||||
return $query->where('order_type', self::TYPE_SALE);
|
||||
}
|
||||
|
||||
/** 領藥單 */
|
||||
public function scopePharmacyPickup($query)
|
||||
{
|
||||
return $query->where('order_type', self::TYPE_PHARMACY_PICKUP);
|
||||
}
|
||||
|
||||
/** 是否為後台手動補單 */
|
||||
public function getIsManualAttribute(): bool
|
||||
{
|
||||
return $this->order_type === self::TYPE_MANUAL;
|
||||
}
|
||||
|
||||
public function machine()
|
||||
{
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/** 建單者 (領藥單藥師) */
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\System\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
@ -280,53 +137,8 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@ class OrderItem extends Model
|
||||
'order_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
'slot_no',
|
||||
'barcode',
|
||||
'price',
|
||||
'quantity',
|
||||
|
||||
@ -18,11 +18,8 @@ class PassCode extends Model
|
||||
'name',
|
||||
'code',
|
||||
'slug',
|
||||
'batch_no',
|
||||
'expires_at',
|
||||
'status',
|
||||
'usage_limit', // 可用次數上限;null=無限次。勾「只能使用一次」時=1
|
||||
'usage_count', // 已使用次數
|
||||
'created_by',
|
||||
];
|
||||
|
||||
@ -79,11 +76,6 @@ class PassCode extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
// 有設使用次數上限(如「只能使用一次」usage_limit=1)且已用完 → 失效。null=無限次。
|
||||
if (!is_null($this->usage_limit) && $this->usage_count >= $this->usage_limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -15,12 +15,9 @@ class PickupCode extends Model
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'machine_id',
|
||||
'product_id',
|
||||
'slot_no',
|
||||
'code',
|
||||
'slug',
|
||||
'batch_no',
|
||||
'status',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'usage_limit',
|
||||
@ -60,14 +57,6 @@ class PickupCode extends Model
|
||||
return $this->belongsTo(Machine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯商品(取貨碼改綁商品時使用)
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Product\Product::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯建立者
|
||||
*/
|
||||
@ -89,9 +78,9 @@ class PickupCode extends Model
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->status === 'active'
|
||||
return $this->status === 'active'
|
||||
&& ($this->usage_count < $this->usage_limit)
|
||||
&& ($this->expires_at?->isFuture() ?? true); // null expires_at = 無到期限制
|
||||
&& ($this->expires_at->isFuture());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 獲取)
|
||||
*/
|
||||
}
|
||||
@ -16,10 +16,6 @@ class ReplenishmentOrderItem extends Model
|
||||
'quantity',
|
||||
'current_stock',
|
||||
'max_stock',
|
||||
'snapshot_product_name',
|
||||
'snapshot_product_name_key',
|
||||
'snapshot_product_barcode',
|
||||
'snapshot_product_image_url',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -28,11 +24,6 @@ class ReplenishmentOrderItem extends Model
|
||||
'max_stock' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 自動附加到 JSON/陣列輸出的屬性
|
||||
*/
|
||||
protected $appends = ['localized_name', 'barcode', 'image_url'];
|
||||
|
||||
/**
|
||||
* 所屬補貨單
|
||||
*/
|
||||
@ -42,85 +33,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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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_invoice(store_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);
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,6 @@ use App\Models\Machine\MachineLog;
|
||||
use App\Models\Machine\MachineSlot;
|
||||
use App\Models\Machine\MachineStockMovement;
|
||||
use App\Models\Machine\RemoteCommand;
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\Transaction\Order;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -15,171 +14,57 @@ use Carbon\Carbon;
|
||||
|
||||
class MachineService
|
||||
{
|
||||
private const SPRING_SLOT_FLAGS = [
|
||||
[1, 10, 'is_spring_slot_1_10'],
|
||||
[11, 20, 'is_spring_slot_11_20'],
|
||||
[21, 30, 'is_spring_slot_21_30'],
|
||||
[31, 40, 'is_spring_slot_31_40'],
|
||||
[41, 50, 'is_spring_slot_41_50'],
|
||||
[51, 60, 'is_spring_slot_51_60'],
|
||||
];
|
||||
|
||||
/**
|
||||
* B013: 硬體狀態代碼對照表 (Hardware Status Code Mapping)
|
||||
*
|
||||
* 代碼格式:Command 0x04 + Status Byte,例如 0x04+0x03 = '0403'
|
||||
* 出貨結果判定:僅 0402 (出貨成功) 與 0424 (商品取出成功) 視為成功,
|
||||
* 其餘出貨結果碼均應觸發退款。
|
||||
*
|
||||
* 參考文件:廠商硬體通訊協議 Command(0x04) 狀態定義
|
||||
*/
|
||||
public const ERROR_CODE_MAP = [
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 出貨與物理動作類 (Command 0x04 - BUY_STATUS)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 出貨狀態類 (Prefix: 04 - BUY_STATUS)
|
||||
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'],
|
||||
'0402' => ['label' => 'Dispense successful', 'level' => 'info'],
|
||||
'0403' => ['label' => 'Slot jammed', 'level' => 'error'],
|
||||
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'],
|
||||
'0406' => ['label' => 'Slot not found', 'level' => 'error'],
|
||||
'0407' => ['label' => 'Dispense error (0407)', 'level' => 'error'],
|
||||
'0408' => ['label' => 'Dispense error (0408)', 'level' => 'error'],
|
||||
'0409' => ['label' => 'Dispense error (0409)', 'level' => 'error'],
|
||||
'040A' => ['label' => 'Dispense error (040A)', 'level' => 'error'],
|
||||
'0410' => ['label' => 'Elevator rising', 'level' => 'info'],
|
||||
'0411' => ['label' => 'Elevator descending', 'level' => 'info'],
|
||||
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'],
|
||||
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'],
|
||||
'0414' => ['label' => 'Pickup door closed', 'level' => 'info'],
|
||||
'0415' => ['label' => 'Pickup door error', 'level' => 'error'],
|
||||
'0416' => ['label' => 'Delivery door opened', 'level' => 'info'],
|
||||
'0417' => ['label' => 'Delivery door open error', 'level' => 'error'],
|
||||
'0418' => ['label' => 'Delivering product', 'level' => 'info'],
|
||||
'0419' => ['label' => 'Delivery door closed', 'level' => 'info'],
|
||||
'0420' => ['label' => 'Delivery door close error', 'level' => 'error'],
|
||||
'0421' => ['label' => 'Hopper empty', 'level' => 'warning'],
|
||||
'0422' => ['label' => 'Hopper overheated', 'level' => 'warning'],
|
||||
'0423' => ['label' => 'Hopper heating timeout', 'level' => 'error'],
|
||||
'0424' => ['label' => 'Hopper error (0424)', 'level' => 'error'],
|
||||
'0426' => ['label' => 'Microwave door opened', 'level' => 'info'],
|
||||
'0427' => ['label' => 'Microwave door error', 'level' => 'error'],
|
||||
'04FF' => ['label' => 'Dispense stopped', 'level' => 'info'],
|
||||
|
||||
// --- 基本出貨流程 ---
|
||||
'0401' => ['label' => 'Dispensing in progress', 'level' => 'info'], // 正在出貨
|
||||
'0402' => ['label' => 'Dispense successful', 'level' => 'info'], // ✅ 出貨成功
|
||||
'0403' => ['label' => 'Slot jammed', 'level' => 'error'], // 貨道卡貨 (重大異常)
|
||||
'0404' => ['label' => 'Motor not stopped', 'level' => 'warning'], // 電機未正常停止
|
||||
'0405' => ['label' => 'Motor not found', 'level' => 'error'], // 電機不存在
|
||||
'0406' => ['label' => 'Motor not found (0406)', 'level' => 'error'], // 電機不存在 (同 0405)
|
||||
'0407' => ['label' => 'Elevator failure', 'level' => 'error'], // 昇降機故障
|
||||
// 貨道狀態類 (Prefix: 02 - SLOT_STATUS)
|
||||
'0201' => ['label' => 'Slot normal', 'level' => 'info'],
|
||||
'0202' => ['label' => 'Product empty', 'level' => 'warning'],
|
||||
'0203' => ['label' => 'Slot empty', 'level' => 'warning'],
|
||||
'0205' => ['label' => 'Slot sensor blocked', 'level' => 'warning'],
|
||||
'0206' => ['label' => 'Slot not closed', 'level' => 'warning'],
|
||||
'0207' => ['label' => 'Slot motor error (0207)', 'level' => 'error'],
|
||||
'0208' => ['label' => 'Slot motor error (0208)', 'level' => 'error'],
|
||||
'0209' => ['label' => 'Slot motor error (0209)', 'level' => 'error'],
|
||||
'0212' => ['label' => 'Hopper empty (0212)', 'level' => 'warning'],
|
||||
|
||||
// --- 昇降機系統 ---
|
||||
'040A' => ['label' => 'Elevator belt self-check error', 'level' => 'error'], // 昇降機皮帶自檢錯誤
|
||||
'0410' => ['label' => 'Elevator rising', 'level' => 'info'], // 昇降機正在上升
|
||||
'0411' => ['label' => 'Elevator descending', 'level' => 'info'], // 昇降機正在下降
|
||||
'0412' => ['label' => 'Elevator rise error', 'level' => 'error'], // 昇降機上升錯誤
|
||||
'0413' => ['label' => 'Elevator descent error', 'level' => 'error'], // 昇降機下降錯誤
|
||||
'0425' => ['label' => 'Elevator support rod return error', 'level' => 'error'], // 昇降台撐桿回位錯誤
|
||||
'0428' => ['label' => 'Elevator support rod push error', 'level' => 'error'], // 昇降台撐桿推出錯誤
|
||||
'0429' => ['label' => 'Elevator enter microwave error', 'level' => 'error'], // 昇降台進微波爐錯誤
|
||||
'0430' => ['label' => 'Elevator exit microwave error', 'level' => 'error'], // 昇降台出微波爐錯誤
|
||||
|
||||
// --- 微波爐取貨口門 ---
|
||||
'0414' => ['label' => 'Microwave pickup door closing', 'level' => 'info'], // 正在關閉微波爐取貨口門
|
||||
'0415' => ['label' => 'Microwave pickup door close error', 'level' => 'error'], // 微波爐取貨口門關閉錯誤
|
||||
'0426' => ['label' => 'Microwave pickup door opening', 'level' => 'info'], // 正在打開微波爐取貨口門
|
||||
'0427' => ['label' => 'Microwave pickup door open error', 'level' => 'error'], // 微波爐取貨口門打開錯誤
|
||||
|
||||
// --- 微波爐進貨口門 ---
|
||||
'0416' => ['label' => 'Microwave delivery door opening', 'level' => 'info'], // 正在打開微波爐進貨口門
|
||||
'0417' => ['label' => 'Microwave delivery door open error', 'level' => 'error'], // 微波爐進貨口門打開錯誤
|
||||
'0418' => ['label' => 'Pushing product into microwave', 'level' => 'info'], // 正在將商品推入微波爐
|
||||
'0419' => ['label' => 'Microwave delivery door closing', 'level' => 'info'], // 正在關閉微波爐進貨口門
|
||||
'0420' => ['label' => 'Microwave delivery door close error', 'level' => 'error'], // 微波爐進貨口門關閉錯誤
|
||||
|
||||
// --- 微波爐內部 ---
|
||||
'0421' => ['label' => 'Product not detected in microwave', 'level' => 'error'], // 微波爐內未檢測到商品
|
||||
'0422' => ['label' => 'Product heating', 'level' => 'info'], // 商品正在加熱
|
||||
'0423' => ['label' => 'Product heating remaining time', 'level' => 'info'], // 商品加熱剩餘時間 (貨道號=秒數)
|
||||
'0424' => ['label' => 'Purchase successful', 'level' => 'info'], // ✅ 購買成功
|
||||
'0431' => ['label' => 'Microwave push rod push error', 'level' => 'error'], // 微波爐內推桿推出錯誤
|
||||
'0432' => ['label' => 'Microwave push rod retract error', 'level' => 'error'], // 微波爐內推桿收回錯誤
|
||||
|
||||
// --- 鮮奶機專用 ---
|
||||
'040B' => ['label' => 'Milk dispense timeout', 'level' => 'error'], // 按鍵出奶超時 (鮮奶機)
|
||||
'040C' => ['label' => 'Cup dropper failure', 'level' => 'error'], // 漏杯器漏杯失敗 (鮮奶機)
|
||||
'040D' => ['label' => 'Cup drop not detected', 'level' => 'error'], // 未檢測到杯子掉落 (鮮奶機)
|
||||
|
||||
// --- 手環機專用 ---
|
||||
'040E' => ['label' => 'Bracelet scan failed', 'level' => 'error'], // 沒掃碼到手環
|
||||
'040F' => ['label' => 'Bracelet drop not detected', 'level' => 'error'], // 沒檢測到手環掉入取貨斗
|
||||
|
||||
// --- 取貨口門 (購物車模式) ---
|
||||
'0433' => ['label' => 'Pickup door opened (cart mode)', 'level' => 'info'], // 取貨口門打開成功 (購物車購買)
|
||||
'0434' => ['label' => 'Pickup door closed (cart mode)', 'level' => 'info'], // 取貨口門關閉成功 (購物車購買)
|
||||
'0441' => ['label' => 'Pickup door unlocked', 'level' => 'info'], // 取貨口門開鎖上報
|
||||
|
||||
// --- 液體機專用 ---
|
||||
'0435' => ['label' => 'Liquid dispense paused', 'level' => 'warning'], // 暫停出液
|
||||
|
||||
// --- 終止 ---
|
||||
'04FF' => ['label' => 'Purchase terminated', 'level' => 'error'], // 購買終止
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 貨道與馬達狀態類 (Prefix: 02 - SLOT_STATUS)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'0201' => ['label' => 'Slot normal', 'level' => 'info'], // 貨道正常
|
||||
'0202' => ['label' => 'Slot out of stock', 'level' => 'warning'], // 貨道缺貨
|
||||
'0203' => ['label' => 'Slot does not exist', 'level' => 'warning'], // 貨道不存在
|
||||
'0204' => ['label' => 'Slot suspended', 'level' => 'warning'], // 貨道暫停使用
|
||||
'0205' => ['label' => 'Elevator contains items', 'level' => 'warning'], // 升降台裡面有物品
|
||||
'0206' => ['label' => 'Pickup door not closed', 'level' => 'warning'], // 取貨口門沒關上
|
||||
'0207' => ['label' => 'Elevator failure (0207)', 'level' => 'error'], // 升降機故障
|
||||
'0208' => ['label' => 'Elevator self-check error', 'level' => 'error'], // 升降機自檢錯誤
|
||||
'0209' => ['label' => 'Microwave pickup door close error (0209)', 'level' => 'error'], // 微波爐取貨口門關閉錯誤
|
||||
'020B' => ['label' => 'Machine temperature exceeds limit', 'level' => 'warning'], // 機器超過設定溫度,暫停購買
|
||||
'020C' => ['label' => 'Bento machine cooling water tank empty', 'level' => 'error'], // 盒飯機冷卻水箱缺水
|
||||
'020D' => ['label' => 'Age verification required', 'level' => 'warning'], // 貨道需要年齡驗證
|
||||
'020E' => ['label' => 'Water level too low', 'level' => 'warning'], // 水量過低
|
||||
'020F' => ['label' => 'Weight sensor communication error', 'level' => 'error'], // 重量傳感器通訊錯誤
|
||||
'0210' => ['label' => 'Microwave delivery door open error (0210)', 'level' => 'error'],// 微波爐進貨口門打開錯誤
|
||||
'0211' => ['label' => 'Microwave delivery door close error (0211)', 'level' => 'error'],// 微波爐進貨口門關閉錯誤
|
||||
'0212' => ['label' => 'Bento not detected', 'level' => 'warning'], // 沒檢測到盒飯
|
||||
'0213' => ['label' => 'Bento heating', 'level' => 'info'], // 盒飯正在加熱
|
||||
'0214' => ['label' => 'Microwave pickup door open error (0214)', 'level' => 'error'], // 微波爐取貨口門打開錯誤
|
||||
'0215' => ['label' => 'Please remove bento from microwave', 'level' => 'warning'], // 請取出微波爐內盒飯
|
||||
'0216' => ['label' => 'Support rod return error', 'level' => 'error'], // 撐桿回位錯誤
|
||||
'0217' => ['label' => 'Main motor failure', 'level' => 'error'], // 主電機故障
|
||||
'0218' => ['label' => 'Translation motor failure', 'level' => 'error'], // 平移電機故障
|
||||
'0219' => ['label' => 'Support rod push error', 'level' => 'error'], // 撐桿推出錯誤
|
||||
'0220' => ['label' => 'Elevator enter microwave error (0220)', 'level' => 'error'], // 升降台進去微波爐錯誤
|
||||
'0221' => ['label' => 'Elevator exit microwave error (0221)', 'level' => 'error'], // 升降台出去微波爐錯誤
|
||||
'0222' => ['label' => 'Microwave push rod push error (0222)', 'level' => 'error'], // 微波爐內推桿推出錯誤
|
||||
'0223' => ['label' => 'Microwave push rod retract error (0223)', 'level' => 'error'], // 微波爐內推桿收回錯誤
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 機台整體狀態類 (Prefix: 54 - MACHINE_STATUS)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'5400' => ['label' => 'Machine normal', 'level' => 'info'],
|
||||
'5401' => ['label' => 'Elevator sensor error', 'level' => 'error'],
|
||||
'5402' => ['label' => 'Pickup door not closed', 'level' => 'warning'],
|
||||
'5403' => ['label' => 'Elevator failure', 'level' => 'error'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 出貨成功結果碼 (Dispense Success Codes)
|
||||
*
|
||||
* 僅以下代碼視為出貨成功,其餘出貨結果碼均應觸發退款。
|
||||
* - 0402: 一般出貨成功
|
||||
* - 0424: 商品取出成功 (微波爐機型)
|
||||
*/
|
||||
public const DISPENSE_SUCCESS_CODES = ['0402', '0424'];
|
||||
|
||||
/**
|
||||
* 刷卡機 (Nexsys 終端機 / NCCC 標準) 交易回應碼對照表。
|
||||
* 來源:聯合信用卡處理中心終端機回應碼規格。
|
||||
* 用於把 order.payment_response 內的 "| code=XXXX" 解析成可讀(可多語)的失敗原因,
|
||||
* 寫入 type='card_terminal' 機台日誌。信用卡(1)/電子票證(2)/手機支付(10) 共用同一台實體刷卡機。
|
||||
* 值為英文翻譯鍵,實際顯示由 lang/*.json 多語系翻譯。
|
||||
*/
|
||||
public const CARD_TERMINAL_CODE_MAP = [
|
||||
'0000' => 'Transaction approved',
|
||||
'0001' => 'Transaction failed (issuer/card declined)',
|
||||
'0002' => 'Please contact bank / e-wallet provider',
|
||||
'0003' => 'Transaction timeout',
|
||||
'0004' => 'Operation error',
|
||||
'0005' => 'Communication failure',
|
||||
'0006' => 'User terminated transaction',
|
||||
'0009' => 'Non-participating institution card',
|
||||
'0010' => 'Message format error',
|
||||
'0011' => 'Not a co-branded card',
|
||||
'0012' => 'Stored-value amount over limit',
|
||||
'0013' => 'Card locked',
|
||||
'0014' => 'Cannot match original stored-value transaction',
|
||||
'0015' => 'Card auto-load not enabled',
|
||||
'0016' => 'Insufficient stored-value / e-wallet balance',
|
||||
'0017' => 'Multiple cards detected',
|
||||
'0018' => 'Please re-tap card / re-scan / retry',
|
||||
'0019' => 'Auto-load succeeded but purchase failed',
|
||||
'1001' => 'Credit card and stored-value settlement both failed',
|
||||
'1002' => 'Credit card settled, stored-value settlement failed',
|
||||
'1003' => 'Credit card settlement failed, stored-value settled',
|
||||
];
|
||||
|
||||
/**
|
||||
* Update machine heartbeat and status.
|
||||
*
|
||||
@ -319,12 +204,15 @@ class MachineService
|
||||
})?->id;
|
||||
}
|
||||
|
||||
// 根據機台貨道機制設定決定上限與貨道類型 (1: 履帶, 2: 彈簧)
|
||||
$slotType = $this->resolveSlotType($machine, $slotNo);
|
||||
// 根據貨道類型自動決定上限 (Auto-calculate max_stock based on slot type)
|
||||
// 若未提供 type,預設為 '1' (履帶/Track)
|
||||
$slotType = $slotData['type'] ?? $existingSlot->type ?? '1';
|
||||
if ($actualProductId) {
|
||||
$product = $products->find($actualProductId);
|
||||
if ($product) {
|
||||
$slotData['capacity'] = $this->calculateSlotMaxStock($product, $machine, $slotNo);
|
||||
// 1: 履帶, 2: 彈簧
|
||||
$calculatedMaxStock = ($slotType == '1') ? $product->track_limit : $product->spring_limit;
|
||||
$slotData['capacity'] = $calculatedMaxStock ?? $slotData['capacity'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -338,47 +226,29 @@ class MachineService
|
||||
'is_active' => true,
|
||||
];
|
||||
|
||||
// ── 雙向 LWW:鎖定(lock) 與 效期/批號(expiry/batch),各自獨立 rev ──
|
||||
$updateData = array_merge(
|
||||
$updateData,
|
||||
$this->resolveSlotLockLww($existingSlot, $slotData),
|
||||
$this->resolveSlotExpiryLww($existingSlot, $slotData, $actualProductId)
|
||||
);
|
||||
|
||||
if ($existingSlot) {
|
||||
$oldStock = (int) $existingSlot->stock;
|
||||
$oldProductId = $existingSlot->product_id;
|
||||
|
||||
|
||||
// 檢查是否有實質變動 (Check for actual changes)
|
||||
$isStockChanged = ($oldStock !== $newStock);
|
||||
$isProductChanged = ($oldProductId != $actualProductId); // 這裡用鬆散比對以處理 null/0 的情況
|
||||
$isSlotConfigChanged = (string) $existingSlot->type !== (string) $updateData['type']
|
||||
|| (int) $existingSlot->max_stock !== (int) $updateData['max_stock'];
|
||||
// lock/expiry/rev 也算實質變動,確保 LWW 結果會被寫回(即使 stock/product 沒變)
|
||||
$curExpiryStr = $existingSlot->expiry_date ? $existingSlot->expiry_date->format('Y-m-d') : null;
|
||||
$isLwwChanged = ((bool) $existingSlot->is_locked !== (bool) $updateData['is_locked'])
|
||||
|| ((int) $existingSlot->last_app_lock_rev !== (int) $updateData['last_app_lock_rev'])
|
||||
|| ((int) $existingSlot->last_app_expiry_rev !== (int) $updateData['last_app_expiry_rev'])
|
||||
|| ($curExpiryStr !== $updateData['expiry_date'])
|
||||
|| ($existingSlot->batch_no !== $updateData['batch_no']);
|
||||
|
||||
if ($isStockChanged || $isProductChanged || $isSlotConfigChanged || $isLwwChanged) {
|
||||
if ($isStockChanged || $isProductChanged) {
|
||||
$existingSlot->update($updateData);
|
||||
$existingSlot->refresh();
|
||||
|
||||
if ($isStockChanged || $isProductChanged) {
|
||||
$delta = $newStock - $oldStock;
|
||||
$note = $isProductChanged ? "movement.note.product_changed_and_adjusted" : "movement.note.replenishment_correction";
|
||||
|
||||
$this->recordStockMovement(
|
||||
$existingSlot,
|
||||
$delta,
|
||||
MachineStockMovement::TYPE_ADJUSTMENT,
|
||||
null,
|
||||
$note,
|
||||
['slot_no' => $existingSlot->slot_no, 'old' => $oldStock, 'new' => $newStock, 'old_product_id' => $oldProductId, 'new_product_id' => $actualProductId]
|
||||
);
|
||||
}
|
||||
$delta = $newStock - $oldStock;
|
||||
$note = $isProductChanged ? "movement.note.product_changed_and_adjusted" : "movement.note.replenishment_correction";
|
||||
|
||||
$this->recordStockMovement(
|
||||
$existingSlot,
|
||||
$delta,
|
||||
MachineStockMovement::TYPE_ADJUSTMENT,
|
||||
null,
|
||||
$note,
|
||||
['slot_no' => $existingSlot->slot_no, 'old' => $oldStock, 'new' => $newStock, 'old_product_id' => $oldProductId, 'new_product_id' => $actualProductId]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$newSlot = $machine->slots()->create(array_merge($updateData, ['slot_no' => $slotNo]));
|
||||
@ -406,12 +276,10 @@ class MachineService
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
// 防呆:上報清單為空時不執行全量刪除(避免機台/APP 誤報空清單而把所有貨道清光)。
|
||||
$orphanedSlots = empty($reportedSlotNos)
|
||||
? collect()
|
||||
: $machine->slots()
|
||||
->whereNotIn('slot_no', $reportedSlotNos)
|
||||
->get();
|
||||
// 找出資料庫中屬於此機台但不在清單內的舊貨道
|
||||
$orphanedSlots = $machine->slots()
|
||||
->whereNotIn('slot_no', $reportedSlotNos)
|
||||
->get();
|
||||
|
||||
foreach ($orphanedSlots as $orphan) {
|
||||
$oldStock = (int) $orphan->stock;
|
||||
@ -436,150 +304,6 @@ class MachineService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 空字串/"null" 一律歸一化為 null(避免 MySQL date 欄位寫入空字串解析爆)。
|
||||
*/
|
||||
private function blankToNull($value)
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
}
|
||||
if ($value === '' || $value === null || (is_string($value) && strtolower($value) === 'null')) {
|
||||
return null;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取日期字串的 yyyy-MM-dd 部分(容錯後台/機台帶了時間),空值回 null。
|
||||
*/
|
||||
private function normalizeDate($value)
|
||||
{
|
||||
$value = $this->blankToNull($value);
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
return substr((string) $value, 0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 貨道鎖定(lock)的雙向 LWW 解析,回傳要寫入的 is_locked / last_app_lock_rev。
|
||||
* - 新貨道 → 採 App 現場值。
|
||||
* - 既有貨道 → 常態 LWW:App rev 較大才採用,否則保留後台值。
|
||||
* (既有機台升級時,App 端 onUpgrade 已把現場既有鎖定的 lock_rev 設為 1,故能 1>0 自動遷移上雲。)
|
||||
*/
|
||||
private function resolveSlotLockLww(?MachineSlot $existingSlot, array $slotData): array
|
||||
{
|
||||
$appLocked = (bool) ($slotData['is_locked'] ?? false);
|
||||
$appRev = (int) ($slotData['lock_rev'] ?? 0);
|
||||
|
||||
if (!$existingSlot) {
|
||||
$newRev = $appRev > 0 ? $appRev : ($appLocked ? 1 : 0);
|
||||
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $newRev];
|
||||
}
|
||||
$curLocked = (bool) $existingSlot->is_locked;
|
||||
$curRev = (int) $existingSlot->last_app_lock_rev;
|
||||
if ($appRev > $curRev) {
|
||||
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $appRev];
|
||||
}
|
||||
return ['is_locked' => $curLocked, 'last_app_lock_rev' => $curRev];
|
||||
}
|
||||
|
||||
/**
|
||||
* 效期/批號(expiry/batch)的雙向 LWW 解析,回傳要寫入的 expiry_date / batch_no / last_app_expiry_rev。
|
||||
* 換商品(product_id 變更)時預設清空舊效期(安全網),除非 App 同筆帶了較新的新效期。
|
||||
*/
|
||||
private function resolveSlotExpiryLww(?MachineSlot $existingSlot, array $slotData, $actualProductId): array
|
||||
{
|
||||
$appExpiry = $this->normalizeDate($slotData['expiry_date'] ?? null);
|
||||
$appBatch = $this->blankToNull($slotData['batch_no'] ?? null);
|
||||
$appRev = (int) ($slotData['expiry_rev'] ?? 0);
|
||||
|
||||
if (!$existingSlot) {
|
||||
$newRev = $appRev > 0 ? $appRev : ($appExpiry !== null ? 1 : 0);
|
||||
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $newRev];
|
||||
}
|
||||
$curExpiry = $existingSlot->expiry_date ? $existingSlot->expiry_date->format('Y-m-d') : null;
|
||||
$curBatch = $this->blankToNull($existingSlot->batch_no);
|
||||
$curRev = (int) $existingSlot->last_app_expiry_rev;
|
||||
|
||||
// 換商品安全網:預設清空,除非 App 同筆帶了較新的新效期
|
||||
$isProductChanged = ((string) ($existingSlot->product_id) !== (string) $actualProductId);
|
||||
if ($isProductChanged) {
|
||||
if ($appExpiry !== null && $appRev > $curRev) {
|
||||
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
|
||||
}
|
||||
return ['expiry_date' => null, 'batch_no' => null, 'last_app_expiry_rev' => max($curRev, $appRev)];
|
||||
}
|
||||
|
||||
if ($appRev > $curRev) {
|
||||
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
|
||||
}
|
||||
return ['expiry_date' => $curExpiry, 'batch_no' => $curBatch, 'last_app_expiry_rev' => $curRev];
|
||||
}
|
||||
|
||||
public function syncProductSlotMaxStock(Product $product): void
|
||||
{
|
||||
MachineSlot::query()
|
||||
->where('product_id', $product->id)
|
||||
->with(['machine' => fn($query) => $query->withoutGlobalScopes()])
|
||||
->chunkById(500, function ($slots) use ($product) {
|
||||
foreach ($slots as $slot) {
|
||||
if (!$slot->machine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slot->update([
|
||||
'type' => $this->resolveSlotType($slot->machine, $slot->slot_no),
|
||||
'max_stock' => $this->calculateSlotMaxStock($product, $slot->machine, $slot->slot_no),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function syncMachineSlotMaxStock(Machine $machine): void
|
||||
{
|
||||
$machine->slots()
|
||||
->with(['product' => fn($query) => $query->withoutGlobalScopes()])
|
||||
->chunkById(500, function ($slots) use ($machine) {
|
||||
foreach ($slots as $slot) {
|
||||
if (!$slot->product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slot->update([
|
||||
'type' => $this->resolveSlotType($machine, $slot->slot_no),
|
||||
'max_stock' => $this->calculateSlotMaxStock($slot->product, $machine, $slot->slot_no),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function calculateSlotMaxStock(Product $product, Machine $machine, string|int|null $slotNo): int
|
||||
{
|
||||
return $this->isSpringSlot($machine, $slotNo)
|
||||
? (int) $product->spring_limit
|
||||
: (int) $product->track_limit;
|
||||
}
|
||||
|
||||
public function resolveSlotType(Machine $machine, string|int|null $slotNo): string
|
||||
{
|
||||
return $this->isSpringSlot($machine, $slotNo) ? '2' : '1';
|
||||
}
|
||||
|
||||
public function isSpringSlot(Machine $machine, string|int|null $slotNo): bool
|
||||
{
|
||||
$slotNumber = (int) $slotNo;
|
||||
|
||||
foreach (self::SPRING_SLOT_FLAGS as [$start, $end, $field]) {
|
||||
if ($slotNumber >= $start && $slotNumber <= $end) {
|
||||
return (bool) $machine->{$field};
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine slot stock, expiry, and batch.
|
||||
*
|
||||
@ -606,55 +330,27 @@ class MachineService
|
||||
->first();
|
||||
|
||||
if ($pendingCommand) {
|
||||
// 重新下發時覆蓋前一筆尚未執行的指令。
|
||||
// 若舊指令已超過 1 分鐘未收到機台回傳 (ACK),視為逾時 (timeout);
|
||||
// 否則為使用者主動覆蓋 (superseded)。與 dispense 逾時邏輯一致。
|
||||
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
|
||||
// 直接覆蓋:將前一個尚未執行的指令標記為「已取代」
|
||||
$pendingCommand->update([
|
||||
'status' => $isTimedOut ? 'timeout' : 'superseded',
|
||||
'note' => $isTimedOut
|
||||
? __('Superseded by new command (Timeout)')
|
||||
: __('Superseded by new command'),
|
||||
'status' => 'superseded',
|
||||
'note' => __('Superseded by new command'),
|
||||
]);
|
||||
}
|
||||
|
||||
// 紀錄舊數據以供回滾使用
|
||||
$oldExpiry = $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null;
|
||||
$oldData = [
|
||||
'stock' => $slot->stock,
|
||||
'expiry_date' => $oldExpiry,
|
||||
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
|
||||
'batch_no' => $slot->batch_no,
|
||||
'is_locked' => (bool) $slot->is_locked,
|
||||
];
|
||||
|
||||
// 2. 執行樂觀更新 (Optimistic Update):只更新「明確帶上的欄位」,避免鎖定切換誤清效期
|
||||
$updateData = [];
|
||||
$normExpiry = $oldExpiry;
|
||||
if (array_key_exists('expiry_date', $data)) {
|
||||
$normExpiry = $this->normalizeDate($expiryDate);
|
||||
$updateData['expiry_date'] = $normExpiry;
|
||||
}
|
||||
$normBatch = $slot->batch_no;
|
||||
if (array_key_exists('batch_no', $data)) {
|
||||
$normBatch = $this->blankToNull($batchNo);
|
||||
$updateData['batch_no'] = $normBatch;
|
||||
}
|
||||
if ($stock !== null) $updateData['stock'] = (int) $stock;
|
||||
if (array_key_exists('is_locked', $data)) {
|
||||
$updateData['is_locked'] = (bool) $data['is_locked'];
|
||||
}
|
||||
|
||||
// 後台編輯效期/批號或鎖定 → 遞增對應 rev(成為權威值,下行帶給 App 對齊 synced_rev)
|
||||
$expiryEdited = ($normExpiry !== $oldExpiry) || ($normBatch !== $slot->batch_no);
|
||||
if ($expiryEdited) {
|
||||
$updateData['last_app_expiry_rev'] = (int) $slot->last_app_expiry_rev + 1;
|
||||
}
|
||||
$lockEdited = array_key_exists('is_locked', $data) && ((bool) $data['is_locked'] !== (bool) $slot->is_locked);
|
||||
if ($lockEdited) {
|
||||
$updateData['last_app_lock_rev'] = (int) $slot->last_app_lock_rev + 1;
|
||||
}
|
||||
|
||||
if (!empty($updateData)) $slot->update($updateData);
|
||||
// 2. 執行樂觀更新 (Optimistic Update)
|
||||
$updateData = [
|
||||
'expiry_date' => $expiryDate,
|
||||
'batch_no' => $batchNo,
|
||||
];
|
||||
if ($stock !== null) $updateData['stock'] = (int)$stock;
|
||||
$slot->update($updateData);
|
||||
|
||||
// 3. 若庫存數值有異動,記錄 adjustment 流水帳
|
||||
if ($stock !== null && (int)$stock !== $oldData['stock']) {
|
||||
@ -670,8 +366,6 @@ class MachineService
|
||||
);
|
||||
}
|
||||
|
||||
$slot->refresh();
|
||||
|
||||
// 3. 建立遠端指令紀錄
|
||||
$command = RemoteCommand::create([
|
||||
'machine_id' => $machine->id,
|
||||
@ -682,23 +376,19 @@ class MachineService
|
||||
'slot_no' => (string)$slotNo,
|
||||
'old' => $oldData,
|
||||
'new' => [
|
||||
'stock' => (int) $slot->stock,
|
||||
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
|
||||
'batch_no' => $slot->batch_no,
|
||||
'is_locked' => (bool) $slot->is_locked,
|
||||
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
|
||||
'expiry_date' => $expiryDate ?: null,
|
||||
'batch_no' => $batchNo ?: null,
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
// 4. 推播 MQTT(一律帶上目前權威的 is_locked 與雙 rev,讓 App 端對齊 synced_rev)
|
||||
// 4. 推播 MQTT
|
||||
$mqttPayload = [
|
||||
'slot_no' => (string) $slotNo,
|
||||
'stock' => (int) $slot->stock,
|
||||
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
|
||||
'batch_no' => $slot->batch_no,
|
||||
'is_locked' => (bool) $slot->is_locked,
|
||||
'lock_rev' => (int) $slot->last_app_lock_rev,
|
||||
'expiry_rev' => (int) $slot->last_app_expiry_rev,
|
||||
'slot_no' => $command->payload['slot_no'],
|
||||
'stock' => $command->payload['new']['stock'],
|
||||
'expiry_date' => $command->payload['new']['expiry_date'],
|
||||
'batch_no' => $command->payload['new']['batch_no'] ?? null,
|
||||
];
|
||||
|
||||
app(\App\Services\Machine\MqttService::class)->pushCommand(
|
||||
|
||||
@ -8,7 +8,16 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DiscordWebhookService
|
||||
{
|
||||
// B013 硬體代碼翻譯統一由 MachineService::ERROR_CODE_MAP 管理 (Single Source of Truth)
|
||||
// B013 硬體代碼中文翻譯對照
|
||||
protected array $errorCodes = [
|
||||
'0402' => '出貨成功',
|
||||
'0403' => '貨道卡貨 (重大異常)',
|
||||
'0202' => '貨道缺貨',
|
||||
'0412' => '昇降機上升異常',
|
||||
'0415' => '取貨門異常',
|
||||
'5402' => '取貨門未關 (警告)',
|
||||
'5403' => '昇降系統故障',
|
||||
];
|
||||
|
||||
/**
|
||||
* 發送測試通知
|
||||
@ -90,7 +99,7 @@ class DiscordWebhookService
|
||||
} elseif (str_contains($log->message, 'too low')) {
|
||||
$title = '🔴 ' . $this->cleanTranslation(__('Temperature Alert (Low)'), $companyLocale);
|
||||
$description = $this->cleanTranslation(__('Current temperature (:temp°C) is below the lower limit (:limit°C).', ['temp' => $temp, 'limit' => $limit]), $companyLocale);
|
||||
$color = 14753096; // rose-600 (與過高告警一致使用紅色)
|
||||
$color = 561586; // cyan-600
|
||||
|
||||
$fields[] = [
|
||||
'name' => $this->cleanTranslation(__('Current Temperature'), $companyLocale),
|
||||
@ -142,8 +151,7 @@ class DiscordWebhookService
|
||||
$translatedError = __($translatedError);
|
||||
}
|
||||
|
||||
$icon = ($log->level === 'error') ? '🚨 ' : '⚠️ ';
|
||||
$title = $icon . $this->cleanTranslation(__('Submachine Exception'), $companyLocale);
|
||||
$title = '⚠️ ' . $this->cleanTranslation(__('Submachine Exception'), $companyLocale);
|
||||
$description = $this->cleanTranslation(__('Vending machine reported a submachine hardware error.'), $companyLocale);
|
||||
$color = ($log->level === 'error') ? 14753096 : 14251782;
|
||||
|
||||
|
||||
@ -2,10 +2,7 @@
|
||||
|
||||
namespace App\Services\Product;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineProductPrice;
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\System\Company;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ProductCatalogService
|
||||
@ -47,14 +44,11 @@ class ProductCatalogService
|
||||
*/
|
||||
public function rebuildCache(?int $companyId): array
|
||||
{
|
||||
// 公司機台語系聯集:商品 i18n map 下發的語系集合(見 product_multilingual_spec §5.1)
|
||||
$locales = Company::activeLocalesFor($companyId);
|
||||
|
||||
$products = Product::where('company_id', $companyId)
|
||||
->with(['translations', 'specTranslations'])
|
||||
->with(['translations'])
|
||||
->active()
|
||||
->get()
|
||||
->map(fn($p) => $this->mapProduct($p, $locales));
|
||||
->map(fn($p) => $this->mapProduct($p));
|
||||
|
||||
$payload = [
|
||||
'success' => true,
|
||||
@ -78,66 +72,13 @@ class ProductCatalogService
|
||||
Cache::forget($this->getCacheKey($companyId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得「該機台」的商品目錄:公司基底目錄(快取) + 機台專屬定價覆蓋。
|
||||
*
|
||||
* 設計取捨:重的 i18n 目錄留在 per-company 快取;機台覆蓋於請求當下即時套用,
|
||||
* 不另設機台快取。覆蓋只是一次 (machine_id) 的索引查詢,極輕量;且改價僅推「該台」
|
||||
* 重抓,不會造成多台群湧。如此完全避開「機台快取失效 / 公司基底變動連帶失效」的複雜度。
|
||||
*
|
||||
* @param Machine $machine
|
||||
* @return array
|
||||
*/
|
||||
public function getMachinePayload(Machine $machine): array
|
||||
{
|
||||
$payload = $this->getPayload($machine->company_id);
|
||||
|
||||
$overrides = MachineProductPrice::where('machine_id', $machine->id)
|
||||
->get(['product_id', 'price', 'member_price'])
|
||||
->keyBy('product_id');
|
||||
|
||||
if ($overrides->isEmpty()) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
// PHP 陣列為值複製,array_map 產生新陣列,不會污染 Cache 內的公司基底目錄。
|
||||
$payload['data'] = array_map(function ($item) use ($overrides) {
|
||||
$override = $overrides->get((int) ($item['t060v00'] ?? 0));
|
||||
if (!$override) {
|
||||
return $item;
|
||||
}
|
||||
|
||||
// 機台售價(t063v03=App machinePrice):有覆蓋價就用,否則維持全域售價基底。
|
||||
$sellingPrice = $override->price !== null
|
||||
? (float) $override->price
|
||||
: (float) $item['t063v03'];
|
||||
$item['t063v03'] = $sellingPrice;
|
||||
|
||||
// 機台會員價(t060v30):有覆蓋就用,否則沿用全域會員價;
|
||||
// 一律夾為「不高於機台售價」,避免會員買得比一般人貴。
|
||||
$memberPrice = $override->member_price !== null
|
||||
? (float) $override->member_price
|
||||
: (float) $item['t060v30'];
|
||||
$item['t060v30'] = min($memberPrice, $sellingPrice);
|
||||
|
||||
return $item;
|
||||
}, $payload['data']);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Product model to the catalog format expected by machines.
|
||||
*
|
||||
* 相容鐵則:既有欄位(t060v01 / t060v01_en / t060v01_jp / t060v03 …)一律保留,
|
||||
* 僅「新增」locale-keyed 的 t060v01_i18n(名稱)與 t060v03_i18n(規格)。
|
||||
* 線上舊 App 忽略新欄位、照舊運作;新版 App 讀 *_i18n 以支援多語系切換。
|
||||
*
|
||||
* @param Product $product
|
||||
* @param array<int,string> $locales 公司機台語系聯集(i18n map 下發的語系集合)
|
||||
* @return array
|
||||
*/
|
||||
private function mapProduct($product, array $locales = ['zh_TW']): array
|
||||
private function mapProduct($product): array
|
||||
{
|
||||
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
|
||||
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
|
||||
@ -147,9 +88,7 @@ class ProductCatalogService
|
||||
't060v01' => $product->name,
|
||||
't060v01_en' => $nameEn,
|
||||
't060v01_jp' => $nameJp,
|
||||
't060v01_i18n' => $this->buildI18nMap($product->translations, $product->name, $locales),
|
||||
't060v03' => $product->spec ?? '',
|
||||
't060v03_i18n' => $this->buildI18nMap($product->specTranslations, $product->spec ?? '', $locales),
|
||||
't060v06' => $product->image_url
|
||||
? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url))
|
||||
: '',
|
||||
@ -163,29 +102,4 @@ class ProductCatalogService
|
||||
't063v03' => (float) $product->price,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 依公司機台語系聯集,建立 locale → 文字 的 i18n map。
|
||||
*
|
||||
* - 僅輸出 $locales 內的語系。
|
||||
* - 缺翻譯時回退至 zh_TW,再回退至 $fallbackText(products.name / spec 主值),
|
||||
* 確保不下發空字串,避免機台顯示空白。
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $translations 該欄位的翻譯集合
|
||||
* @param string|null $fallbackText 最終回退文字(主欄位值)
|
||||
* @param array<int,string> $locales 公司機台語系聯集
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function buildI18nMap($translations, ?string $fallbackText, array $locales): array
|
||||
{
|
||||
$zhFallback = $translations->firstWhere('locale', 'zh_TW')?->value
|
||||
?? ($fallbackText ?? '');
|
||||
|
||||
$map = [];
|
||||
foreach ($locales as $locale) {
|
||||
$map[$locale] = $translations->firstWhere('locale', $locale)?->value ?: $zhFallback;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,413 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\Product\ProductCategory;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Models\System\Translation;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenSpout\Reader\XLSX\Reader;
|
||||
use OpenSpout\Writer\XLSX\Writer;
|
||||
use OpenSpout\Common\Entity\Row;
|
||||
|
||||
class ProductImportService
|
||||
{
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* @param string|null $imageDir 已解壓的圖片資料夾路徑 (Excel「圖片檔名」欄對應此資料夾內檔案)
|
||||
* @param bool $deferImages true 時不在此同步轉檔,改將 (product_id, filename) 收集到 results['pending_images']
|
||||
* 供呼叫端丟背景 Job 處理,避免大量圖片同步轉檔逾時。
|
||||
*/
|
||||
public function import(string $filePath, ?int $companyId = null, ?int $userId = null, ?string $imageDir = null, bool $deferImages = false): array
|
||||
{
|
||||
$reader = new Reader();
|
||||
$reader->open($filePath);
|
||||
|
||||
$results = [
|
||||
'success_count' => 0,
|
||||
'created_count' => 0,
|
||||
'updated_count' => 0,
|
||||
'error_count' => 0,
|
||||
'errors' => [],
|
||||
'company_ids' => [],
|
||||
'image_success' => 0,
|
||||
'image_missing' => [],
|
||||
'pending_images' => [],
|
||||
];
|
||||
|
||||
$rowCount = 0;
|
||||
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
$rowCount++;
|
||||
|
||||
if ($rowCount === 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cells = $row->toArray();
|
||||
$data = $this->normalizeRow($cells, $companyId);
|
||||
|
||||
if ($this->isEmptyRow($data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validator = Validator::make($data, [
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'category' => 'nullable|string|max:255',
|
||||
'name_zh_TW' => 'required|string|max:255',
|
||||
'name_en' => 'nullable|string|max:255',
|
||||
'name_ja' => 'nullable|string|max:255',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'spec' => 'nullable|string|max:255',
|
||||
'manufacturer' => 'nullable|string|max:255',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'member_price' => 'nullable|numeric|min:0',
|
||||
'cost' => 'required|numeric|min:0',
|
||||
'track_limit' => 'nullable|integer|min:0',
|
||||
'spring_limit' => 'nullable|integer|min:0',
|
||||
'material_code' => 'nullable|string|max:255',
|
||||
'points_full' => 'nullable|integer|min:0',
|
||||
'points_half' => 'nullable|integer|min:0',
|
||||
'points_half_amount' => 'nullable|integer|min:0',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'image_filename' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$this->appendError($results, $rowCount, $validator->errors()->all());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($data, $rowCount, $userId, $imageDir, $deferImages, &$results) {
|
||||
$categoryId = $this->resolveCategoryId($data['category'], $data['company_id']);
|
||||
$lookup = $this->buildProductLookup($data);
|
||||
$product = Product::withoutGlobalScopes()->where($lookup)->first();
|
||||
$oldValues = $product?->toArray();
|
||||
$dictKey = $product?->name_dictionary_key ?: Str::uuid()->toString();
|
||||
|
||||
$this->syncTranslations($dictKey, $data, $data['company_id']);
|
||||
|
||||
$payload = [
|
||||
'company_id' => $data['company_id'],
|
||||
'category_id' => $categoryId,
|
||||
'name' => $data['name_zh_TW'],
|
||||
'name_dictionary_key' => $dictKey,
|
||||
'barcode' => $data['barcode'],
|
||||
'spec' => $data['spec'],
|
||||
'manufacturer' => $data['manufacturer'],
|
||||
'track_limit' => $data['track_limit'],
|
||||
'spring_limit' => $data['spring_limit'],
|
||||
'price' => $data['price'],
|
||||
'cost' => $data['cost'],
|
||||
'member_price' => $data['member_price'],
|
||||
'metadata' => [
|
||||
'material_code' => $data['material_code'],
|
||||
'points_full' => $data['points_full'],
|
||||
'points_half' => $data['points_half'],
|
||||
'points_half_amount' => $data['points_half_amount'],
|
||||
],
|
||||
'is_active' => $data['is_active'],
|
||||
];
|
||||
|
||||
if ($product) {
|
||||
$product->update($payload);
|
||||
$results['updated_count']++;
|
||||
$action = 'update';
|
||||
$note = 'product_import_updated';
|
||||
} else {
|
||||
$product = Product::withoutGlobalScopes()->create($payload);
|
||||
$results['created_count']++;
|
||||
$action = 'create';
|
||||
$note = 'product_import_created';
|
||||
}
|
||||
|
||||
if ($deferImages) {
|
||||
if ($imageDir && $data['image_filename']) {
|
||||
$results['pending_images'][] = [
|
||||
'product_id' => $product->id,
|
||||
'filename' => $data['image_filename'],
|
||||
'row' => $rowCount,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$this->attachImage($product, $data['image_filename'], $imageDir, $rowCount, $results);
|
||||
}
|
||||
|
||||
$results['success_count']++;
|
||||
if ($product->company_id) {
|
||||
$results['company_ids'][$product->company_id] = $product->company_id;
|
||||
}
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $product->company_id,
|
||||
'user_id' => $userId,
|
||||
'module' => 'product',
|
||||
'action' => $action,
|
||||
'target_id' => $product->id,
|
||||
'target_type' => Product::class,
|
||||
'note' => $note,
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => array_merge($product->fresh()->toArray(), ['import_row' => $rowCount]),
|
||||
]);
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
$this->appendError($results, $rowCount, [$e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
$results['company_ids'] = array_values($results['company_ids']);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function downloadTemplate()
|
||||
{
|
||||
$writer = new Writer();
|
||||
$writer->openToBrowser('products_template.xlsx');
|
||||
|
||||
$writer->addRow(Row::fromValues([
|
||||
'分類 ID 或名稱(選填)',
|
||||
'商品名稱 zh_TW(必填)',
|
||||
'商品名稱 en(選填)',
|
||||
'商品名稱 ja(選填)',
|
||||
'商品條碼(選填,用於更新)',
|
||||
'規格(選填)',
|
||||
'生產公司(選填)',
|
||||
'售價(必填)',
|
||||
'會員價(選填)',
|
||||
'成本(必填)',
|
||||
'履帶貨道上限(選填,預設 15)',
|
||||
'彈簧貨道上限(選填,預設 15)',
|
||||
'物料代碼(選填)',
|
||||
'全額點數(選填)',
|
||||
'半額點數(選填)',
|
||||
'半額點數折抵金額(選填)',
|
||||
'是否啟用(1/0,選填)',
|
||||
'圖片檔名(選填,需一併上傳圖片壓縮檔)',
|
||||
]));
|
||||
|
||||
$writer->addRow(Row::fromValues([
|
||||
'飲料',
|
||||
'經典拿鐵',
|
||||
'Classic Latte',
|
||||
'クラシックラテ',
|
||||
'471000000001',
|
||||
'350ml',
|
||||
'Star Cloud',
|
||||
80,
|
||||
75,
|
||||
45,
|
||||
15,
|
||||
15,
|
||||
'MAT-LATTE-001',
|
||||
800,
|
||||
400,
|
||||
40,
|
||||
1,
|
||||
'latte.png',
|
||||
]));
|
||||
|
||||
$writer->close();
|
||||
}
|
||||
|
||||
private function normalizeRow(array $cells, ?int $companyId): array
|
||||
{
|
||||
return [
|
||||
'company_id' => $companyId,
|
||||
'category' => $this->stringValue($cells[0] ?? null),
|
||||
'name_zh_TW' => $this->stringValue($cells[1] ?? null),
|
||||
'name_en' => $this->stringValue($cells[2] ?? null),
|
||||
'name_ja' => $this->stringValue($cells[3] ?? null),
|
||||
'barcode' => $this->stringValue($cells[4] ?? null),
|
||||
'spec' => $this->stringValue($cells[5] ?? null),
|
||||
'manufacturer' => $this->stringValue($cells[6] ?? null),
|
||||
'price' => $this->numericValue($cells[7] ?? null, null),
|
||||
'member_price' => $this->numericValue($cells[8] ?? null, 0),
|
||||
'cost' => $this->numericValue($cells[9] ?? null, null),
|
||||
'track_limit' => (int) $this->numericValue($cells[10] ?? null, 15),
|
||||
'spring_limit' => (int) $this->numericValue($cells[11] ?? null, 15),
|
||||
'material_code' => $this->stringValue($cells[12] ?? null),
|
||||
'points_full' => (int) $this->numericValue($cells[13] ?? null, 0),
|
||||
'points_half' => (int) $this->numericValue($cells[14] ?? null, 0),
|
||||
'points_half_amount' => (int) $this->numericValue($cells[15] ?? null, 0),
|
||||
'is_active' => $this->booleanValue($cells[16] ?? null),
|
||||
'image_filename' => $this->stringValue($cells[17] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 依「圖片檔名」欄將已解壓資料夾中的圖片轉 WebP 入庫並寫回 image_url。
|
||||
* 失敗 (找不到檔/非圖) 只記錄至 image_missing,不影響商品本身寫入。
|
||||
*/
|
||||
private function attachImage(Product $product, ?string $filename, ?string $imageDir, int $rowCount, array &$results): void
|
||||
{
|
||||
if (!$filename || !$imageDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$path = rtrim($imageDir, '/') . '/' . basename($filename);
|
||||
|
||||
if (!is_file($path) || getimagesize($path) === false) {
|
||||
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
|
||||
return;
|
||||
}
|
||||
|
||||
$oldImage = $product->image_url;
|
||||
|
||||
$file = new UploadedFile($path, basename($path), null, null, true);
|
||||
$storedPath = $this->storeAsWebp($file, 'products', 80, 320, 320);
|
||||
|
||||
$product->update(['image_url' => Storage::url($storedPath)]);
|
||||
|
||||
if ($oldImage) {
|
||||
$oldPath = str_replace('/storage/', '', $oldImage);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
|
||||
$results['image_success']++;
|
||||
} catch (\Throwable $e) {
|
||||
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
|
||||
}
|
||||
}
|
||||
|
||||
private function isEmptyRow(array $data): bool
|
||||
{
|
||||
return empty($data['category'])
|
||||
&& empty($data['name_zh_TW'])
|
||||
&& empty($data['barcode'])
|
||||
&& $data['price'] === null
|
||||
&& $data['cost'] === null;
|
||||
}
|
||||
|
||||
private function resolveCategoryId(?string $category, ?int $companyId): ?int
|
||||
{
|
||||
if (!$category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = ProductCategory::withoutGlobalScopes()
|
||||
->where(function ($q) use ($companyId) {
|
||||
$q->where('company_id', $companyId)->orWhereNull('company_id');
|
||||
});
|
||||
|
||||
if (ctype_digit($category)) {
|
||||
$found = (clone $query)->where('id', (int) $category)->first();
|
||||
if ($found) {
|
||||
return $found->id;
|
||||
}
|
||||
}
|
||||
|
||||
$found = (clone $query)
|
||||
->where(function ($q) use ($category) {
|
||||
$q->where('name', $category)
|
||||
->orWhereHas('translations', function ($translationQuery) use ($category) {
|
||||
$translationQuery->withoutGlobalScopes()->where('value', $category);
|
||||
});
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$found) {
|
||||
throw new \InvalidArgumentException(__('Category not found: :category', ['category' => $category]));
|
||||
}
|
||||
|
||||
return $found->id;
|
||||
}
|
||||
|
||||
private function buildProductLookup(array $data): array
|
||||
{
|
||||
if (!empty($data['barcode'])) {
|
||||
return [
|
||||
'company_id' => $data['company_id'],
|
||||
'barcode' => $data['barcode'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'company_id' => $data['company_id'],
|
||||
'name' => $data['name_zh_TW'],
|
||||
];
|
||||
}
|
||||
|
||||
private function syncTranslations(string $dictKey, array $data, ?int $companyId): void
|
||||
{
|
||||
$names = [
|
||||
'zh_TW' => $data['name_zh_TW'],
|
||||
'en' => $data['name_en'],
|
||||
'ja' => $data['name_ja'],
|
||||
];
|
||||
|
||||
foreach ($names as $locale => $name) {
|
||||
if (empty($name)) {
|
||||
Translation::withoutGlobalScopes()->where([
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
])->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
Translation::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'group' => 'product',
|
||||
'key' => $dictKey,
|
||||
'locale' => $locale,
|
||||
],
|
||||
[
|
||||
'value' => $name,
|
||||
'company_id' => $companyId,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function appendError(array &$results, int $rowCount, array $messages): void
|
||||
{
|
||||
$results['error_count']++;
|
||||
$results['errors'][] = [
|
||||
'row' => $rowCount,
|
||||
'messages' => $messages,
|
||||
];
|
||||
}
|
||||
|
||||
private function stringValue(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function numericValue(mixed $value, mixed $default): mixed
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? $value : $value;
|
||||
}
|
||||
|
||||
private function booleanValue(mixed $value): bool
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string) $value));
|
||||
|
||||
return in_array($normalized, ['1', 'true', 'yes', 'y', 'active', 'enabled', '啟用', '是'], true);
|
||||
}
|
||||
}
|
||||
@ -1,304 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Transaction;
|
||||
|
||||
use App\Models\Machine\Machine;
|
||||
use App\Models\Machine\MachineSlot;
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Models\Transaction\Order;
|
||||
use App\Models\Transaction\PickupCode;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* 領藥單建立服務。
|
||||
*
|
||||
* 設計重點(對齊交叉檢查修正):
|
||||
* - 領藥單序號 = orders.flow_id(唯一),讓後續出貨 finalize 能套用既有終態冪等保護。
|
||||
* - 建單時「不」預扣庫存;庫存僅在機台出貨 finalize 時扣一次(避免雙重扣減)。
|
||||
* - 以「商品」為單位建單,貨道於 B660 掃碼時再由 product_id × machine_slots 解析(一張單可多貨道)。
|
||||
*/
|
||||
class PharmacyPickupService
|
||||
{
|
||||
/** 領藥單支付類型碼:沿用 42 = 領藥憑證 (Pickup Voucher)。 */
|
||||
public const PAYMENT_TYPE_PHARMACY = 42;
|
||||
|
||||
/**
|
||||
* 取得某機台「已生成但尚未領取(awaiting_pickup)」的領藥單預留量(依商品彙總)。
|
||||
* 用於建單時「預扣」顯示與防超領:可用 = 實體庫存 − 預留量。
|
||||
*/
|
||||
public function reservedQtyMap(Machine $machine): array
|
||||
{
|
||||
$map = [];
|
||||
$orders = Order::where('machine_id', $machine->id)
|
||||
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
|
||||
->where('status', Order::STATUS_AWAITING_PICKUP)
|
||||
->with('items')
|
||||
->get();
|
||||
|
||||
foreach ($orders as $order) {
|
||||
foreach ($order->items as $item) {
|
||||
$map[$item->product_id] = ($map[$item->product_id] ?? 0) + (int) $item->quantity;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得某機台目前「有庫存」的可領藥品(依商品彙總,跨貨道加總庫存)。
|
||||
* available 已扣除「尚未領取的領藥單預留量」(生成領藥單即預扣)。
|
||||
*/
|
||||
public function availableProducts(Machine $machine): Collection
|
||||
{
|
||||
$reserved = $this->reservedQtyMap($machine);
|
||||
|
||||
return MachineSlot::where('machine_id', $machine->id)
|
||||
->where('is_active', true)
|
||||
->where('stock', '>', 0)
|
||||
->with('product')
|
||||
->get()
|
||||
->groupBy('product_id')
|
||||
->map(function ($slots) use ($reserved) {
|
||||
$product = $slots->first()->product;
|
||||
if (!$product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$physical = (int) $slots->sum('stock');
|
||||
$reservedQty = (int) ($reserved[$product->id] ?? 0);
|
||||
$available = max(0, $physical - $reservedQty);
|
||||
|
||||
return (object) [
|
||||
'product_id' => $product->id,
|
||||
'name' => $product->localized_name ?? $product->name,
|
||||
'spec' => $product->localized_spec ?? $product->spec,
|
||||
'barcode' => $product->barcode,
|
||||
'price' => (float) ($product->price ?? 0),
|
||||
'available' => $available, // 預扣後可用(= 實體 − 預留)
|
||||
'physical' => $physical, // 實體庫存
|
||||
'reserved' => $reservedQty, // 已生成領藥單預留量
|
||||
'slot_count' => $slots->count(),
|
||||
'slots' => $slots->pluck('slot_no')->implode(', '),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->sortBy('name')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立一張領藥單:Order(pharmacy_pickup) + N 個 OrderItem + 1 個 PickupCode。
|
||||
*
|
||||
* @param array{machine_id:int, pricing_slip_no?:string, items:array<array{product_id:int, quantity:int}>, expires_at?:\DateTimeInterface} $data
|
||||
*/
|
||||
public function create(array $data, int $userId): Order
|
||||
{
|
||||
$machine = Machine::findOrFail($data['machine_id']);
|
||||
$items = collect($data['items'] ?? [])
|
||||
->map(fn ($i) => ['product_id' => (int) $i['product_id'], 'quantity' => (int) $i['quantity']])
|
||||
->filter(fn ($i) => $i['quantity'] > 0)
|
||||
->values();
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => __('Please select at least one medicine with quantity.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($machine, $items, $data, $userId) {
|
||||
$orderItemsData = [];
|
||||
$total = 0.0;
|
||||
|
||||
// 既有「尚未領取領藥單」的預留量(本單尚未建立,不含本單)→ 可用 = 實體 − 預留,防超領。
|
||||
$reserved = $this->reservedQtyMap($machine);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$product = Product::findOrFail($item['product_id']);
|
||||
$qty = $item['quantity'];
|
||||
|
||||
// 該機台此商品跨貨道可用庫存 = 實體庫存 − 已生成領藥單預留量
|
||||
$physical = (int) MachineSlot::where('machine_id', $machine->id)
|
||||
->where('product_id', $product->id)
|
||||
->where('is_active', true)
|
||||
->sum('stock');
|
||||
$available = $physical - (int) ($reserved[$product->id] ?? 0);
|
||||
|
||||
if ($qty > $available) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => __('Insufficient stock for :name (requested :qty, available :available).', [
|
||||
'name' => $product->localized_name ?? $product->name,
|
||||
'qty' => $qty,
|
||||
'available' => $available,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
$price = (float) ($product->price ?? 0);
|
||||
$subtotal = $price * $qty;
|
||||
$total += $subtotal;
|
||||
|
||||
$orderItemsData[] = [
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->localized_name ?? $product->name,
|
||||
'barcode' => $product->barcode,
|
||||
'quantity' => $qty,
|
||||
'price' => $price,
|
||||
'subtotal' => $subtotal,
|
||||
];
|
||||
}
|
||||
|
||||
$serial = $this->generateSerial();
|
||||
|
||||
$order = Order::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'flow_id' => $serial,
|
||||
'order_no' => $serial,
|
||||
'order_type' => Order::TYPE_PHARMACY_PICKUP,
|
||||
'pricing_slip_no' => $data['pricing_slip_no'] ?? null,
|
||||
'created_by' => $userId,
|
||||
'machine_id' => $machine->id,
|
||||
'payment_type' => self::PAYMENT_TYPE_PHARMACY,
|
||||
'total_amount' => $total,
|
||||
'discount_amount' => 0,
|
||||
'pay_amount' => 0,
|
||||
'change_amount' => 0,
|
||||
'points_used' => 0,
|
||||
'payment_status' => Order::PAYMENT_STATUS_SUCCESS,
|
||||
'status' => Order::STATUS_AWAITING_PICKUP,
|
||||
'delivery_status' => Order::DELIVERY_STATUS_FAILED, // 0:尚未出貨
|
||||
'machine_time' => now(),
|
||||
]);
|
||||
|
||||
foreach ($orderItemsData as $oi) {
|
||||
$order->items()->create($oi);
|
||||
}
|
||||
|
||||
$pickupCode = PickupCode::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'machine_id' => $machine->id,
|
||||
'slot_no' => null, // 多貨道:由 B660 掃碼時解析
|
||||
'code' => PickupCode::generateUniqueCode($machine->id),
|
||||
'status' => 'active',
|
||||
'expires_at' => $data['expires_at'] ?? now()->endOfDay(), // 預設當日有效
|
||||
'usage_limit' => 1,
|
||||
'usage_count' => 0,
|
||||
'created_by' => $userId,
|
||||
'order_id' => $order->id,
|
||||
]);
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'user_id' => $userId,
|
||||
'module' => 'pharmacy_pickup',
|
||||
'action' => 'create',
|
||||
'target_id' => $order->id,
|
||||
'target_type' => Order::class,
|
||||
'new_values' => [
|
||||
'order_no' => $order->order_no,
|
||||
'pricing_slip_no' => $order->pricing_slip_no,
|
||||
'pickup_code' => $pickupCode->code,
|
||||
'items' => $orderItemsData,
|
||||
],
|
||||
]);
|
||||
|
||||
return $order->load(['items', 'pickupCode', 'machine']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查一張領藥單在「目前機台貨道庫存」下是否有商品庫存不足(無法整單出貨)。
|
||||
* 回傳不足清單:[['product_name'=>, 'required'=>, 'available'=>], ...];空陣列表示可整單出貨。
|
||||
* 用於 B660 掃碼出貨前的嚴格擋關:任何商品不足即整單擋下、不出貨、不消耗領藥碼。
|
||||
*/
|
||||
public function dispenseShortage(Order $order): array
|
||||
{
|
||||
$short = [];
|
||||
|
||||
foreach ($order->items as $orderItem) {
|
||||
$available = (int) MachineSlot::where('machine_id', $order->machine_id)
|
||||
->where('product_id', $orderItem->product_id)
|
||||
->where('is_active', true)
|
||||
->where('stock', '>', 0)
|
||||
->sum('stock');
|
||||
|
||||
if ($available < (int) $orderItem->quantity) {
|
||||
$short[] = [
|
||||
'product_name' => $orderItem->product_name,
|
||||
'required' => (int) $orderItem->quantity,
|
||||
'available' => $available,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $short;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一張領藥單在「目前機台貨道庫存」下的出貨清單(一商品可跨多貨道湊量)。
|
||||
* 回傳:[['slot_no'=>, 'product_id'=>, 'product_name'=>, 'qty'=>], ...]
|
||||
* 庫存不足時盡力分配(出可出的數量;部分/失敗由出貨回報 Phase 標記)。
|
||||
*/
|
||||
public function resolveDispenseItems(Order $order): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($order->items as $orderItem) {
|
||||
$remaining = (int) $orderItem->quantity;
|
||||
|
||||
$slots = MachineSlot::where('machine_id', $order->machine_id)
|
||||
->where('product_id', $orderItem->product_id)
|
||||
->where('is_active', true)
|
||||
->where('stock', '>', 0)
|
||||
->orderByDesc('stock')
|
||||
->orderBy('slot_no')
|
||||
->get();
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
if ($remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
$take = min($remaining, (int) $slot->stock);
|
||||
if ($take <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'slot_no' => $slot->slot_no,
|
||||
'product_id' => $orderItem->product_id,
|
||||
'product_name' => $orderItem->product_name,
|
||||
'qty' => $take,
|
||||
];
|
||||
$remaining -= $take;
|
||||
}
|
||||
// $remaining > 0 表示目前庫存不足以完全滿足此商品,僅回傳可出的部分。
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生領藥單序號:RX + 西元年月日 + 當日 4 碼流水(與 flow_id 唯一性衝突時自動遞增)。
|
||||
*/
|
||||
protected function generateSerial(): string
|
||||
{
|
||||
$datePart = now()->format('Ymd');
|
||||
$base = Order::withoutGlobalScopes()
|
||||
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
|
||||
->whereDate('created_at', now()->toDateString())
|
||||
->count();
|
||||
|
||||
$attempt = 0;
|
||||
do {
|
||||
$seq = $base + 1 + $attempt;
|
||||
$serial = 'RX' . $datePart . str_pad((string) $seq, 4, '0', STR_PAD_LEFT);
|
||||
$attempt++;
|
||||
} while (
|
||||
Order::withoutGlobalScopes()->where('flow_id', $serial)->exists()
|
||||
&& $attempt < 100
|
||||
);
|
||||
|
||||
return $serial;
|
||||
}
|
||||
}
|
||||
@ -12,8 +12,6 @@ use App\Models\StaffCardLog;
|
||||
use App\Models\Transaction\PickupCode;
|
||||
use App\Models\Transaction\PickupCodeLog;
|
||||
use App\Models\Transaction\PassCodeLog;
|
||||
use App\Models\Transaction\WelcomeGift;
|
||||
use App\Models\Transaction\WelcomeGiftLog;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Services\Machine\MachineService;
|
||||
|
||||
@ -34,32 +32,18 @@ class TransactionService
|
||||
{
|
||||
$flowId = $data['flow_id'] ?? null;
|
||||
|
||||
// 交易生命週期狀態:payload 沒帶就預設 completed。
|
||||
// → main(晟崴/中國醫)的成功 finalize 不帶 status,仍得到 'completed',行為逐字不變。
|
||||
$incomingStatus = $data['status'] ?? Order::STATUS_COMPLETED;
|
||||
|
||||
if ($flowId) {
|
||||
$existingOrder = Order::withoutGlobalScopes()
|
||||
->where('flow_id', $flowId)
|
||||
->first();
|
||||
if ($existingOrder) {
|
||||
// 鐵律二:終態(completed/failed/abandoned)一律冪等回傳,不可覆蓋。
|
||||
// main 的單永遠是 completed → 等同原本「flow_id 已存在直接 return」的行為,
|
||||
// 重送/redelivery 也不會重複建立、不會重複扣庫存。
|
||||
if (in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
|
||||
return $existingOrder;
|
||||
}
|
||||
// 鐵律三:只有「既有單為 pending」才允許狀態轉移(pending → completed/failed/abandoned)。
|
||||
// 唯有「先送過 pending」的機台(本分支、flag on)會走到這;main 從不產生 pending → 不可達。
|
||||
return $this->transitionOrder($existingOrder, $data, $incomingStatus);
|
||||
return $existingOrder;
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $incomingStatus) {
|
||||
return DB::transaction(function () use ($data) {
|
||||
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
|
||||
|
||||
$paymentStatus = (int) ($data['payment_status'] ?? Order::PAYMENT_STATUS_SUCCESS);
|
||||
|
||||
// Create Order
|
||||
$order = Order::create([
|
||||
'company_id' => $machine->company_id,
|
||||
@ -71,107 +55,47 @@ class TransactionService
|
||||
'discount_amount' => $data['discount_amount'] ?? 0,
|
||||
'pay_amount' => $data['pay_amount'],
|
||||
'change_amount' => $data['change_amount'] ?? 0,
|
||||
// 現金面額明細(僅現金 payment_type=9 會帶;其餘為 null)
|
||||
'cash_detail' => $data['cash_detail'] ?? null,
|
||||
'points_used' => $data['points_used'] ?? 0,
|
||||
'original_amount' => $data['original_amount'] ?? $data['total_amount'],
|
||||
'payment_type' => (int) ($data['payment_type'] ?? 0),
|
||||
'payment_status' => $paymentStatus,
|
||||
'payment_status' => $data['payment_status'] ?? 1,
|
||||
'payment_request' => $data['payment_request'] ?? null,
|
||||
'payment_response' => $data['payment_response'] ?? null,
|
||||
'member_barcode' => $data['member_barcode'] ?? null,
|
||||
'code_id' => $data['code_id'] ?? null,
|
||||
'welcome_gift_id' => $data['welcome_gift_id'] ?? null,
|
||||
'invoice_info' => $data['invoice_info'] ?? null,
|
||||
'machine_time' => $data['machine_time'] ?? now(),
|
||||
// 付款成功才有 payment_at;pending/failed 無實際付款時間。
|
||||
// main = completed + payment_status=1 → now(),與原本一致。
|
||||
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS ? now() : null,
|
||||
'status' => $incomingStatus,
|
||||
'payment_at' => now(),
|
||||
'status' => 'completed',
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
'delivery_status' => (int) ($data['delivery_status'] ?? Order::DELIVERY_STATUS_SUCCESS),
|
||||
]);
|
||||
|
||||
$this->createOrderItems($order, $data['items'] ?? []);
|
||||
// Create Order Items
|
||||
if (!empty($data['items'])) {
|
||||
foreach ($data['items'] as $item) {
|
||||
$productName = $item['product_name'] ?? null;
|
||||
|
||||
// 如果沒傳名稱,嘗試從資料庫抓取
|
||||
if (empty($productName)) {
|
||||
$product = \App\Models\Product\Product::find($item['product_id']);
|
||||
$productName = $product?->name ?? 'Unknown';
|
||||
}
|
||||
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'product_name' => $productName,
|
||||
'barcode' => $item['barcode'] ?? null,
|
||||
'price' => $item['price'],
|
||||
'quantity' => $item['quantity'],
|
||||
'subtotal' => $item['price'] * $item['quantity'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* pending 單的狀態轉移(pending → completed/failed/abandoned)。
|
||||
* 只更新付款結果相關欄位;商品明細在 pending 階段已建立,不重複建立(避免品項翻倍)。
|
||||
*/
|
||||
protected function transitionOrder(Order $order, array $data, string $newStatus): Order
|
||||
{
|
||||
return DB::transaction(function () use ($order, $data, $newStatus) {
|
||||
$paymentStatus = isset($data['payment_status'])
|
||||
? (int) $data['payment_status']
|
||||
: (int) $order->payment_status;
|
||||
|
||||
$order->update([
|
||||
'status' => $newStatus,
|
||||
'payment_status' => $paymentStatus,
|
||||
// payment_type 以本次上報為準:例如手機支付 pending 階段為 4(路由鍵),
|
||||
// 成功 finalize 覆寫為 10(手機支付),轉移時需一併更新。
|
||||
'payment_type' => isset($data['payment_type'])
|
||||
? (int) $data['payment_type']
|
||||
: $order->payment_type,
|
||||
'payment_request' => $data['payment_request'] ?? $order->payment_request,
|
||||
'payment_response' => $data['payment_response'] ?? $order->payment_response,
|
||||
'pay_amount' => $data['pay_amount'] ?? $order->pay_amount,
|
||||
// 商品總額/原始金額:completed finalize 才帶正確金額(pending 階段機台送 0),
|
||||
// 須比照 pay_amount 在轉移時一併更新,否則後台銷售紀錄金額會停在建立時的 0。
|
||||
'total_amount' => $data['total_amount'] ?? $order->total_amount,
|
||||
'original_amount' => $data['original_amount'] ?? $order->original_amount,
|
||||
'discount_amount' => $data['discount_amount'] ?? $order->discount_amount,
|
||||
'change_amount' => $data['change_amount'] ?? $order->change_amount,
|
||||
// 現金面額明細:completed finalize 才帶(pending 階段無投幣結果)
|
||||
'cash_detail' => $data['cash_detail'] ?? $order->cash_detail,
|
||||
'points_used' => $data['points_used'] ?? $order->points_used,
|
||||
'invoice_info' => $data['invoice_info'] ?? $order->invoice_info,
|
||||
'delivery_status' => isset($data['delivery_status'])
|
||||
? (int) $data['delivery_status']
|
||||
: $order->delivery_status,
|
||||
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS
|
||||
? ($order->payment_at ?? now())
|
||||
: $order->payment_at,
|
||||
]);
|
||||
|
||||
// pending 階段若未帶明細,補建一次(防禦)。
|
||||
if ($order->items()->count() === 0 && !empty($data['items'])) {
|
||||
$this->createOrderItems($order, $data['items']);
|
||||
}
|
||||
|
||||
return $order->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
/** 建立訂單品項(沒帶名稱時回資料庫補抓)。 */
|
||||
protected function createOrderItems(Order $order, array $items): void
|
||||
{
|
||||
foreach ($items as $item) {
|
||||
$productName = $item['product_name'] ?? null;
|
||||
|
||||
// 如果沒傳名稱,嘗試從資料庫抓取
|
||||
if (empty($productName)) {
|
||||
$product = \App\Models\Product\Product::find($item['product_id']);
|
||||
$productName = $product?->name ?? 'Unknown';
|
||||
}
|
||||
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'product_name' => $productName,
|
||||
// 消費者選擇的貨道/格子號(App items[].slot_no 帶入);不管出貨成功或失敗都記錄。
|
||||
'slot_no' => $item['slot_no'] ?? null,
|
||||
'barcode' => $item['barcode'] ?? null,
|
||||
'price' => $item['price'],
|
||||
'quantity' => $item['quantity'],
|
||||
'subtotal' => $item['price'] * $item['quantity'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique order number.
|
||||
*/
|
||||
@ -186,11 +110,11 @@ class TransactionService
|
||||
public function recordInvoice(array $data): Invoice
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$machine = Machine::withoutGlobalScopes()->where('serial_no', $data['serial_no'])->firstOrFail();
|
||||
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
|
||||
|
||||
$order = null;
|
||||
if (!empty($data['flow_id'])) {
|
||||
$order = Order::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
|
||||
$order = Order::where('flow_id', $data['flow_id'])->first();
|
||||
}
|
||||
|
||||
// 處理額外的發票資訊 (business_tax_id, carrier_type)
|
||||
@ -200,67 +124,22 @@ class TransactionService
|
||||
if (isset($data['carrier_type']))
|
||||
$metadata['carrier_type'] = $data['carrier_type'];
|
||||
|
||||
$invoiceNo = $data['invoice_no'] ?? null;
|
||||
$rtnCode = $data['rtn_code'] ?? null;
|
||||
|
||||
// 狀態:優先採用 App 已判定的 status(新 finalize 流程一定帶 status=pending);否則才推導。
|
||||
// 此推導分支目前僅 MQTT 'invoice' 指令(ProcessInvoice)會走,App 暫未使用,屬防禦性。
|
||||
// 重點:有發票號就代表已開立,除非「明確」收到失敗碼才算 failed
|
||||
//(部分上報格式只帶 invoice_no、不帶 rtn_code,不可因缺 rtn_code 就誤判失敗)。
|
||||
// 綠界 RtnCode == "1" 為成功;其餘非空值才視為明確失敗。
|
||||
$status = $data['status'] ?? null;
|
||||
if (empty($status)) {
|
||||
$explicitFailure = !empty($rtnCode) && (string) $rtnCode !== '1';
|
||||
if (!empty($invoiceNo)) {
|
||||
$status = $explicitFailure ? 'failed' : 'issued';
|
||||
} else {
|
||||
// 無發票號:明確失敗→failed;否則待開立/待查證→pending
|
||||
$status = $explicitFailure ? 'failed' : 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
// RelateNumber 冪等鍵:App 未帶時由後台以 flow_id 推導(綠界上限 30 字、限英數)。
|
||||
// flow_id 在 finalizeTransaction 入口已前綴機台序號(serial+rawFlowId),
|
||||
// 故此處直接取用即可,輸出與線上既有 serial+rawFlowId 逐字等價,不可再重複加 serial。
|
||||
$relateNumber = $data['relate_number'] ?? null;
|
||||
if (empty($relateNumber) && !empty($data['flow_id'])) {
|
||||
$relateNumber = substr($data['flow_id'], 0, 30);
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
return Invoice::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
|
||||
'machine_id' => $machine->id,
|
||||
'invoice_no' => $invoiceNo,
|
||||
'status' => $status,
|
||||
'relate_number' => $relateNumber,
|
||||
'flow_id' => $data['flow_id'] ?? null,
|
||||
'invoice_no' => $data['invoice_no'] ?? null,
|
||||
'amount' => $data['amount'] ?? 0,
|
||||
'carrier_id' => $data['carrier_id'] ?? null,
|
||||
'invoice_date' => $data['invoice_date'] ?? null,
|
||||
'machine_time' => $data['machine_time'] ?? null,
|
||||
'random_number' => $data['random_number'] ?? ($data['random_no'] ?? null),
|
||||
'love_code' => $data['love_code'] ?? null,
|
||||
'rtn_code' => $rtnCode,
|
||||
'rtn_code' => $data['rtn_code'] ?? null,
|
||||
'rtn_msg' => $data['rtn_msg'] ?? null,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
|
||||
// 去重:同一 flow_id(一張訂單一張發票)以 updateOrCreate 收斂,
|
||||
// 避免 finalize 重送 / 補開回寫產生重複發票列。
|
||||
if (!empty($data['flow_id'])) {
|
||||
// 終態守衛(防禦縱深):已開立/已作廢的發票為終態,不得被後續寫入覆蓋,
|
||||
// 避免任何重送(含 ProcessInvoice/MQTT invoice 指令)把 issued 打回 pending、清掉發票號。
|
||||
$existing = Invoice::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
|
||||
if ($existing && in_array($existing->status, [Invoice::STATUS_ISSUED, Invoice::STATUS_VOID], true)) {
|
||||
return $existing;
|
||||
}
|
||||
return Invoice::withoutGlobalScopes()->updateOrCreate(
|
||||
['flow_id' => $data['flow_id']],
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
return Invoice::create(array_merge($attributes, ['flow_id' => null]));
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@ -314,40 +193,18 @@ class TransactionService
|
||||
// 執行實體庫存扣除 (真實交易化)
|
||||
if ((int) ($data['dispense_status'] ?? 0) === 1) {
|
||||
if ($slot) {
|
||||
// 防呆:庫存已為 0(或更低)時不再扣減,避免出現負庫存(-1)。
|
||||
// 正常情況下 B660 已嚴格擋下庫存不足的領藥單;此處為下位機與後台庫存不同步時的最後防線。
|
||||
if ((int) $slot->stock > 0) {
|
||||
$slot->decrement('stock');
|
||||
$slot->decrement('stock');
|
||||
|
||||
// 售完(庫存歸 0)即清空效期與批號,避免舊批貨效期殘留。
|
||||
// 場景:日後管理員只補 stock 未改 expiry_date 時,update_inventory 才不會
|
||||
// 把過期日一起下發、把新批貨誤鎖成「暫不販售」。與機台端「賣完清效期」對稱。
|
||||
// 純加法:僅在仍有殘留值時才寫,不影響既有扣庫存行為。
|
||||
if ($slot->stock <= 0 && ($slot->expiry_date !== null || $slot->batch_no !== null)) {
|
||||
$slot->update([
|
||||
'expiry_date' => null,
|
||||
'batch_no' => null,
|
||||
]);
|
||||
}
|
||||
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
|
||||
|
||||
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
|
||||
|
||||
$this->machineService->recordStockMovement(
|
||||
$slot,
|
||||
-1,
|
||||
$type,
|
||||
$order ?? $record,
|
||||
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
|
||||
['order_id' => $order?->id, 'dispense_id' => $record->id]
|
||||
);
|
||||
} else {
|
||||
\Log::warning("Dispense decrement skipped: slot stock already <= 0", [
|
||||
'machine_id' => $machine->id,
|
||||
'slot_no' => $slotNo,
|
||||
'current_stock' => (int) $slot->stock,
|
||||
'flow_id' => $data['flow_id'] ?? null,
|
||||
]);
|
||||
}
|
||||
$this->machineService->recordStockMovement(
|
||||
$slot,
|
||||
-1,
|
||||
$type,
|
||||
$order ?? $record,
|
||||
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
|
||||
['order_id' => $order?->id, 'dispense_id' => $record->id]
|
||||
);
|
||||
} else {
|
||||
// Log warning if slot matching fails to help diagnostic
|
||||
\Log::warning("Dispense record created but slot not found for decrement", [
|
||||
@ -368,105 +225,43 @@ class TransactionService
|
||||
public function finalizeTransaction(array $data): Order
|
||||
{
|
||||
// 優先從 Root 讀取,再從 order 內讀取
|
||||
$rawFlowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
|
||||
if (empty($rawFlowId)) {
|
||||
$flowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
|
||||
|
||||
// 冪等性檢查:如果 flow_id 已經存在,直接回傳既有訂單
|
||||
if ($flowId) {
|
||||
$existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first();
|
||||
if ($existingOrder) {
|
||||
Log::info("Transaction already finalized, returning existing order", ['flow_id' => $flowId]);
|
||||
return $existingOrder;
|
||||
}
|
||||
} else {
|
||||
throw new \Exception("Flow ID is required for finalization");
|
||||
}
|
||||
|
||||
// 跨機台防撞號:App 端 flow_id 僅單機唯一(YYYYMMDDHHMMSSXXXX),加上機台序號前綴
|
||||
// 才全域唯一(orders.flow_id 是全域 unique)。無分隔符——綠界 RelateNumber 禁特殊符號,
|
||||
// 且 serial+flow_id 與線上既有 relateNumber 推導逐字等價(見 recordInvoice)。
|
||||
// ACK 仍由 ProcessTransactionFinalized 回傳『原始』flow_id,App / outbox 對帳無感。
|
||||
$flowId = $data['serial_no'] . $rawFlowId;
|
||||
$data['flow_id'] = $flowId;
|
||||
$data['order']['flow_id'] = $flowId;
|
||||
|
||||
return DB::transaction(function () use ($data, $flowId) {
|
||||
$serialNo = $data['serial_no'];
|
||||
|
||||
// 冪等性 + 併發防護:終態檢查移進交易內並對既有 row 加排他鎖,
|
||||
// 防止兩個 worker 同時穿透 pending 狀態而重複出貨/扣庫存/核銷。
|
||||
// main(晟崴/中國醫)的單永遠是 completed → 等同原本的 early-return,行為不變。
|
||||
// 既有單為 pending 時放行,往下交給 processTransaction 做 pending → paid/failed 轉移。
|
||||
$existingOrder = Order::withoutGlobalScopes()
|
||||
->where('flow_id', $flowId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($existingOrder && in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
|
||||
Log::info("Transaction already finalized (terminal), returning existing order", [
|
||||
'flow_id' => $flowId,
|
||||
'status' => $existingOrder->status,
|
||||
]);
|
||||
return $existingOrder;
|
||||
}
|
||||
|
||||
// 領藥單(pharmacy_pickup):機台沿用中國醫(CMUH)出貨流程,finalize 的 flow_id 是「機台本地流水號」,
|
||||
// 但 order.order_no = 後端預建的領藥單號(RX…)。故優先以 order_no 對應預建領藥單,避免被當成新銷售單建立。
|
||||
$pharmacyOrderNo = $data['order']['order_no'] ?? ($data['order_no'] ?? null);
|
||||
if ($pharmacyOrderNo) {
|
||||
$pharmacyOrder = Order::withoutGlobalScopes()
|
||||
->where('order_type', Order::TYPE_PHARMACY_PICKUP)
|
||||
->where('order_no', $pharmacyOrderNo)
|
||||
->first();
|
||||
if ($pharmacyOrder) {
|
||||
if (in_array($pharmacyOrder->status, Order::TERMINAL_STATUSES, true)) {
|
||||
Log::info("Pharmacy order already finalized (terminal)", [
|
||||
'order_no' => $pharmacyOrderNo,
|
||||
'status' => $pharmacyOrder->status,
|
||||
]);
|
||||
return $pharmacyOrder; // 冪等
|
||||
}
|
||||
return $this->finalizePharmacyDispense($pharmacyOrder, $data);
|
||||
}
|
||||
}
|
||||
|
||||
// 後備:若 flow_id 本身就對到領藥單(例如機台有正確帶 RX flow_id),也走領藥流程。
|
||||
if ($existingOrder && $existingOrder->order_type === Order::TYPE_PHARMACY_PICKUP) {
|
||||
return $this->finalizePharmacyDispense($existingOrder, $data);
|
||||
}
|
||||
|
||||
// 1. Process Order (B600)
|
||||
$orderData = $data['order'];
|
||||
$orderData['serial_no'] = $serialNo;
|
||||
|
||||
// 確保提取 payment_type 與 flow_id (可能在 root 或 order 內)
|
||||
$orderData['flow_id'] = $flowId;
|
||||
if (!isset($orderData['delivery_status']) && !empty($data['dispense'])) {
|
||||
$orderData['delivery_status'] = $this->resolveDeliveryStatusFromDispense($data['dispense']);
|
||||
}
|
||||
if (!isset($orderData['payment_type']) && isset($data['payment_type'])) {
|
||||
$orderData['payment_type'] = $data['payment_type'];
|
||||
}
|
||||
$order = $this->processTransaction($orderData);
|
||||
|
||||
// 2. Record Invoice — 後台開立版:finalize 帶 invoice 輸入(status=pending)時建 pending 發票,
|
||||
// commit 後派 IssueInvoiceJob 去綠界開立(不在交易內等 ECPay,避免長交易)。
|
||||
// 開立條件:機台「電子發票開關」開啟 AND 上報帶 invoice,兩者皆成立才開立。
|
||||
// 機台關閉電子發票時略過開立(不建發票、不送綠界),僅留 warning log 供日後追查。
|
||||
if (!empty($data['invoice']) && $order->machine?->tax_invoice_enabled) {
|
||||
// 2. Record Invoice (B601) - Optional
|
||||
if (!empty($data['invoice'])) {
|
||||
$invoiceData = $data['invoice'];
|
||||
$invoiceData['serial_no'] = $serialNo;
|
||||
$invoiceData['flow_id'] = $order->flow_id;
|
||||
$invoiceData['order_id'] = $order->id;
|
||||
$invoice = $this->recordInvoice($invoiceData);
|
||||
|
||||
// 待開立 → commit 後非同步開立(已開/失敗則不重複觸發)
|
||||
if ($invoice->status === Invoice::STATUS_PENDING) {
|
||||
DB::afterCommit(function () use ($invoice) {
|
||||
\App\Jobs\Transaction\IssueInvoiceJob::dispatch($invoice->id);
|
||||
});
|
||||
}
|
||||
} elseif (!empty($data['invoice'])) {
|
||||
// 上報帶了 invoice,但機台「電子發票開關」為關閉 → 略過開立,留軌跡供追查。
|
||||
Log::warning('機台已關閉電子發票,略過開立', [
|
||||
'machine_id' => $order->machine_id,
|
||||
'flow_id' => $order->flow_id,
|
||||
'order_id' => $order->id,
|
||||
]);
|
||||
$this->recordInvoice($invoiceData);
|
||||
}
|
||||
|
||||
// 3. Record Dispense Results (B602) - Optional/Multiple
|
||||
$dispensedSlots = []; // 收集實際出貨貨道/櫃號,供取貨碼核銷日誌記錄「真正取貨的貨道」
|
||||
if (!empty($data['dispense'])) {
|
||||
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
|
||||
foreach ($dispenseList as $dispenseItem) {
|
||||
@ -479,10 +274,6 @@ class TransactionService
|
||||
$dispenseItem['member_barcode'] = $order->member_barcode;
|
||||
}
|
||||
|
||||
if (!empty($dispenseItem['slot_no'])) {
|
||||
$dispensedSlots[] = (string) $dispenseItem['slot_no'];
|
||||
}
|
||||
|
||||
$this->recordDispense($dispenseItem);
|
||||
}
|
||||
}
|
||||
@ -508,11 +299,9 @@ class TransactionService
|
||||
break;
|
||||
|
||||
case 6: // 取貨碼 (Pickup Code)
|
||||
// 排他鎖:防並發核銷 lost-update 使 usage_count 突破 usage_limit。
|
||||
$pickupCode = PickupCode::where('machine_id', $order->machine_id)
|
||||
->where('id', $codeId)
|
||||
->where('status', 'active')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($pickupCode) {
|
||||
$newCount = $pickupCode->usage_count + 1;
|
||||
@ -534,11 +323,7 @@ class TransactionService
|
||||
'action' => $isUsedUp ? 'used' : 'consume',
|
||||
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
|
||||
'raw_data' => [
|
||||
// 取貨碼綁「商品」時 pickupCode->slot_no 為 null;
|
||||
// 改記「實際出貨的貨道/櫃號」(dispense),取不到才退回取貨碼綁定貨道。
|
||||
'slot' => !empty($dispensedSlots)
|
||||
? implode(', ', array_unique($dispensedSlots))
|
||||
: $pickupCode->slot_no,
|
||||
'slot' => $pickupCode->slot_no,
|
||||
'code' => $pickupCode->code,
|
||||
'count' => $newCount,
|
||||
'limit' => $pickupCode->usage_limit
|
||||
@ -548,8 +333,6 @@ class TransactionService
|
||||
break;
|
||||
|
||||
case 5: // 通行碼 (Pass Code)
|
||||
// 注意:通行碼「用掉一次」的計數改在 B670 驗證(verifyPassCode)當下處理,
|
||||
// 因為 App 對通行碼會略過交易結案上報,此 case 5 實務上不會被觸發。
|
||||
// 建立通行碼核銷日誌
|
||||
PassCodeLog::create([
|
||||
'company_id' => $order->company_id,
|
||||
@ -563,251 +346,8 @@ class TransactionService
|
||||
}
|
||||
}
|
||||
|
||||
// 處理來店禮 (Welcome Gift) 自動核銷
|
||||
$welcomeGiftId = $data['order']['welcome_gift_id'] ?? ($data['welcome_gift_id'] ?? null);
|
||||
if ($welcomeGiftId) {
|
||||
// 排他鎖:防並發核銷 lost-update 使 usage_count 突破 usage_limit。
|
||||
$welcomeGift = WelcomeGift::where('machine_id', $order->machine_id)
|
||||
->where('id', $welcomeGiftId)
|
||||
->where('status', 'active')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($welcomeGift) {
|
||||
$newCount = $welcomeGift->usage_count + 1;
|
||||
$isUsedUp = ($welcomeGift->usage_type === 'once' && $newCount >= ($welcomeGift->usage_limit ?? 1));
|
||||
|
||||
$welcomeGift->update([
|
||||
'usage_count' => $newCount,
|
||||
'status' => $isUsedUp ? 'used' : 'active',
|
||||
]);
|
||||
|
||||
WelcomeGiftLog::create([
|
||||
'company_id' => $order->company_id,
|
||||
'machine_id' => $order->machine_id,
|
||||
'welcome_gift_id' => $welcomeGift->id,
|
||||
'order_id' => $order->id,
|
||||
'action' => 'consume',
|
||||
'remark' => "MQTT 交易完成自動核銷,訂單: #{$order->id}",
|
||||
'raw_data' => [
|
||||
'code' => $welcomeGift->code,
|
||||
'count' => $newCount,
|
||||
'type' => $welcomeGift->usage_type
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 後台手動補單(機台斷線/漏報時,由系統管理員人工補登銷售紀錄)。
|
||||
*
|
||||
* 與機台上報共用底層 recordDispense()(扣庫存)與 recordInvoice()+IssueInvoiceJob(後台開發票),
|
||||
* 但訂單標記 order_type=manual、created_by=操作者;flow_id 以 'MAN' 前綴與機台上報區隔,
|
||||
* 一眼可辨識並天然避開 orders.flow_id 唯一鍵衝突(長度 ≤30、純英數,亦相容綠界 RelateNumber)。
|
||||
* 扣庫存與開發票皆為選配(由呼叫端決定);權限把關(限系統管理員)在 Controller。
|
||||
*
|
||||
* @param array $data {
|
||||
* serial_no, created_by, payment_type, occurred_at?,
|
||||
* items: [{product_id, slot_no?, product_name?, price, quantity}],
|
||||
* deduct_stock?: bool, remark?,
|
||||
* invoice?: null|{business_tax_id?, carrier_id?, love_code?} // 給定才開立
|
||||
* }
|
||||
*/
|
||||
public function createManualOrder(array $data): Order
|
||||
{
|
||||
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
|
||||
|
||||
$items = $data['items'] ?? [];
|
||||
if (empty($items)) {
|
||||
throw new \InvalidArgumentException('Manual order requires at least one item');
|
||||
}
|
||||
|
||||
$totalAmount = collect($items)
|
||||
->sum(fn ($i) => (float) $i['price'] * (int) $i['quantity']);
|
||||
|
||||
// 補單常為事後補登:machine_time / created_at 採實際發生時間,使銷售報表落在正確日期。
|
||||
$occurredAt = !empty($data['occurred_at'])
|
||||
? \Carbon\Carbon::parse($data['occurred_at'])
|
||||
: now();
|
||||
|
||||
$flowId = $this->generateManualFlowId();
|
||||
|
||||
return DB::transaction(function () use ($machine, $flowId, $items, $totalAmount, $occurredAt, $data) {
|
||||
// 1. 建立訂單(終態 completed + 付款成功)
|
||||
$order = Order::create([
|
||||
'company_id' => $machine->company_id,
|
||||
'flow_id' => $flowId,
|
||||
'order_no' => $this->generateOrderNo(),
|
||||
'order_type' => Order::TYPE_MANUAL,
|
||||
'created_by' => $data['created_by'] ?? null,
|
||||
'machine_id' => $machine->id,
|
||||
'payment_type' => (int) ($data['payment_type'] ?? 0),
|
||||
'total_amount' => $totalAmount,
|
||||
'original_amount' => $totalAmount,
|
||||
'discount_amount' => 0,
|
||||
'pay_amount' => $totalAmount,
|
||||
'payment_status' => Order::PAYMENT_STATUS_SUCCESS,
|
||||
'payment_at' => $occurredAt,
|
||||
'machine_time' => $occurredAt,
|
||||
'status' => Order::STATUS_COMPLETED,
|
||||
'delivery_status' => Order::DELIVERY_STATUS_SUCCESS,
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'metadata' => ['source' => 'manual'],
|
||||
]);
|
||||
// created_at 對齊實際發生時間(補單事後補登,報表需落在真實日期而非補登當下)
|
||||
$order->forceFill(['created_at' => $occurredAt])->save();
|
||||
|
||||
$this->createOrderItems($order, $items);
|
||||
|
||||
// 2. 扣庫存(選配):逐單位呼叫 recordDispense,與機台上報一致(成功才扣 1,庫存為 0 時不為負)
|
||||
if (!empty($data['deduct_stock'])) {
|
||||
foreach ($items as $item) {
|
||||
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
||||
for ($n = 0; $n < $qty; $n++) {
|
||||
$this->recordDispense([
|
||||
'serial_no' => $machine->serial_no,
|
||||
'flow_id' => $flowId,
|
||||
'order_id' => $order->id,
|
||||
'slot_no' => $item['slot_no'] ?? null,
|
||||
'product_id' => $item['product_id'] ?? null,
|
||||
'amount' => $item['price'],
|
||||
'dispense_status' => 1,
|
||||
'machine_time' => $occurredAt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 開立電子發票(選配):建 pending 發票 → commit 後派 IssueInvoiceJob 去綠界開立。
|
||||
// 閘門:唯有機台「電子發票開關」開啟才開立(防禦縱深,與 finalizeTransaction 一致;前端已隱藏選項)。
|
||||
if (!empty($data['invoice']) && $machine->tax_invoice_enabled) {
|
||||
$invoiceData = $data['invoice'];
|
||||
$invoiceData['serial_no'] = $machine->serial_no;
|
||||
$invoiceData['flow_id'] = $flowId;
|
||||
$invoiceData['order_id'] = $order->id;
|
||||
$invoiceData['amount'] = $totalAmount;
|
||||
$invoiceData['status'] = Invoice::STATUS_PENDING;
|
||||
$invoiceData['machine_time'] = $occurredAt;
|
||||
$invoice = $this->recordInvoice($invoiceData);
|
||||
|
||||
if ($invoice->status === Invoice::STATUS_PENDING) {
|
||||
DB::afterCommit(function () use ($invoice) {
|
||||
\App\Jobs\Transaction\IssueInvoiceJob::dispatch($invoice->id);
|
||||
});
|
||||
}
|
||||
} elseif (!empty($data['invoice'])) {
|
||||
Log::warning('手動補單要求開立發票,但機台已關閉電子發票,略過開立', [
|
||||
'machine_id' => $machine->id,
|
||||
'flow_id' => $flowId,
|
||||
'order_id' => $order->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
/** 產生手動補單專用 flow_id('MAN' 前綴,全域唯一、純英數、≤30 字)。 */
|
||||
protected function generateManualFlowId(): string
|
||||
{
|
||||
do {
|
||||
$flowId = 'MAN' . now()->format('YmdHis') . strtoupper(bin2hex(random_bytes(3)));
|
||||
} while (Order::withoutGlobalScopes()->where('flow_id', $flowId)->exists());
|
||||
|
||||
return $flowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 領藥單出貨回報(獨立於銷售流程)。
|
||||
*
|
||||
* 機台逐道出貨後,以 transaction/finalize 帶 dispense[](每出貨一單位一筆,含 slot_no/dispense_status)回報。
|
||||
* 本方法:建立出貨紀錄(沿用 recordDispense:成功則扣 1 庫存)→ 彙整 delivery_status →
|
||||
* 將訂單轉為終態 completed(冪等:重送會在 finalizeTransaction 開頭 early-return)→ 標記領藥碼已領 + 核銷日誌。
|
||||
*/
|
||||
protected function finalizePharmacyDispense(Order $order, array $data): Order
|
||||
{
|
||||
return DB::transaction(function () use ($order, $data) {
|
||||
$serialNo = $data['serial_no'] ?? $order->machine?->serial_no;
|
||||
|
||||
if (!empty($data['dispense'])) {
|
||||
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
|
||||
foreach ($dispenseList as $dispenseItem) {
|
||||
$dispenseItem['serial_no'] = $serialNo;
|
||||
$dispenseItem['flow_id'] = $order->flow_id;
|
||||
$dispenseItem['order_id'] = $order->id;
|
||||
$this->recordDispense($dispenseItem); // 建 DispenseRecord;dispense_status=1 時扣 1 庫存
|
||||
}
|
||||
$deliveryStatus = $this->resolveDeliveryStatusFromDispense($data['dispense']);
|
||||
} else {
|
||||
// 無出貨明細視為失敗
|
||||
$deliveryStatus = Order::DELIVERY_STATUS_FAILED;
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'delivery_status' => $deliveryStatus,
|
||||
'status' => Order::STATUS_COMPLETED, // 終態 → 後續重送冪等
|
||||
'machine_time' => $data['machine_time'] ?? $order->machine_time,
|
||||
]);
|
||||
|
||||
// 標記領藥碼已領(B660 通常已標 used;此處保險並留核銷日誌)
|
||||
$pickupCode = $order->pickupCode;
|
||||
if ($pickupCode) {
|
||||
if ($pickupCode->status !== 'used') {
|
||||
$pickupCode->update(['status' => 'used', 'used_at' => $pickupCode->used_at ?? now()]);
|
||||
}
|
||||
PickupCodeLog::create([
|
||||
'company_id' => $order->company_id,
|
||||
'machine_id' => $order->machine_id,
|
||||
'pickup_code_id' => $pickupCode->id,
|
||||
'order_id' => $order->id,
|
||||
'action' => 'used',
|
||||
'remark' => "log.pickup.pharmacy_dispensed",
|
||||
'raw_data' => [
|
||||
'order_no' => $order->order_no,
|
||||
'delivery_status' => $deliveryStatus,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
Log::info('Pharmacy pickup dispense finalized', [
|
||||
'flow_id' => $order->flow_id,
|
||||
'delivery_status' => $deliveryStatus,
|
||||
]);
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve order-level delivery status from item-level dispense results.
|
||||
*/
|
||||
protected function resolveDeliveryStatusFromDispense(array $dispense): int
|
||||
{
|
||||
$dispenseList = isset($dispense[0]) ? $dispense : [$dispense];
|
||||
$statuses = collect($dispenseList)
|
||||
->filter(fn ($item) => is_array($item) && array_key_exists('dispense_status', $item))
|
||||
->map(fn ($item) => (int) $item['dispense_status']);
|
||||
|
||||
if ($statuses->isEmpty()) {
|
||||
return Order::DELIVERY_STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
$successCount = $statuses
|
||||
->filter(fn ($status) => $status === Order::DELIVERY_STATUS_SUCCESS)
|
||||
->count();
|
||||
|
||||
if ($successCount === $statuses->count()) {
|
||||
return Order::DELIVERY_STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
if ($successCount === 0) {
|
||||
return Order::DELIVERY_STATUS_FAILED;
|
||||
}
|
||||
|
||||
return Order::DELIVERY_STATUS_PARTIAL;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
/**
|
||||
* 提供「給人看的 flow_id」accessor:去掉機台序號前綴,顯示乾淨易讀的機台流水號。
|
||||
*
|
||||
* 資料庫仍儲存完整的 `serial_no . flow_id`(全域唯一值),供後台去重 / 冪等 / 對帳判斷使用;
|
||||
* 此 accessor 只用於畫面與匯出顯示,不改動任何 DB 值。
|
||||
*
|
||||
* flow_id 格式:serial_no . YYYYMMDDHHMMSSXXXX(無分隔符)。
|
||||
* 套用此 trait 的 Model 需具備 `flow_id` 欄位與 `machine`(含 serial_no)關聯。
|
||||
*/
|
||||
trait HasDisplayFlowId
|
||||
{
|
||||
public function getDisplayFlowIdAttribute(): string
|
||||
{
|
||||
$flowId = (string) ($this->flow_id ?? '');
|
||||
$serial = $this->machine?->serial_no;
|
||||
|
||||
// 僅在確實以該機台序號開頭時才剝除前綴;舊的未前綴資料原樣顯示(防禦)。
|
||||
if ($serial !== null && $serial !== '' && str_starts_with($flowId, $serial)) {
|
||||
return substr($flowId, strlen($serial));
|
||||
}
|
||||
|
||||
return $flowId;
|
||||
}
|
||||
}
|
||||
@ -55,11 +55,6 @@ return [
|
||||
'description' => '回應訊息',
|
||||
'example' => 'Success'
|
||||
],
|
||||
'identity' => [
|
||||
'type' => 'string',
|
||||
'description' => '登入者身分:system 系統登入者 / company 公司帳號 / staff 一般人員 (僅驗證成功時回傳)',
|
||||
'example' => 'system'
|
||||
],
|
||||
'token' => [
|
||||
'type' => 'string',
|
||||
'description' => '臨時身份認證 Token',
|
||||
@ -75,7 +70,6 @@ return [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Success',
|
||||
'identity' => 'system',
|
||||
'token' => '1|abcdefg...'
|
||||
],
|
||||
],
|
||||
@ -84,7 +78,7 @@ return [
|
||||
'slug' => 'b014-config-download',
|
||||
'method' => 'GET',
|
||||
'path' => '/api/v1/app/machine/setting/B014',
|
||||
'description' => '透過此介面下載金流金鑰、電子發票設定與機台專屬通訊 Token。金流密鑰與發票設定均取自機台關聯的「金流配置 (payment_configs.settings)」,未設值欄位回傳空字串。',
|
||||
'description' => '透過此介面下載金流金鑰、電子發票設定與機台專屬通訊 Token。',
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
@ -100,106 +94,18 @@ return [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '是否成功',
|
||||
'example' => true
|
||||
],
|
||||
'data' => [
|
||||
'type' => 'object',
|
||||
'description' => '配置物件,下列各欄位皆位於 data 之下。金鑰/發票來源為機台關聯的「金流配置 (payment_configs.settings)」;DevSet / CashSet / FunctionSet / ShoppingMode 來源為「機台系統設定 (machines.settings)」,OperationSet / HardwareSet 來源為 machines 實體欄位,皆採機台端 *Set + PascalCase 風格。',
|
||||
],
|
||||
'data.t050v01' => [
|
||||
'type' => 'string',
|
||||
'description' => '機台序號 ← machines.serial_no',
|
||||
],
|
||||
'data.api_token' => [
|
||||
'type' => 'string',
|
||||
'description' => '機台正式通訊 Token ← machines.api_token(初始化後存本地,後續 API 認證用)',
|
||||
],
|
||||
'data.t050v41' => [
|
||||
'type' => 'string',
|
||||
'description' => '玉山掃碼 StoreID ← 金流配置 esun_scan.store_id',
|
||||
],
|
||||
'data.t050v42' => [
|
||||
'type' => 'string',
|
||||
'description' => '玉山掃碼 TermID ← 金流配置 esun_scan.term_id',
|
||||
],
|
||||
'data.t050v43' => [
|
||||
'type' => 'string',
|
||||
'description' => '玉山掃碼 Key ← 金流配置 esun_scan.key',
|
||||
],
|
||||
'data.t050v34' => [
|
||||
'type' => 'string',
|
||||
'description' => '綠界發票 商店代號 ← 金流配置 ecpay_invoice.store_id',
|
||||
],
|
||||
'data.t050v35' => [
|
||||
'type' => 'string',
|
||||
'description' => '綠界發票 HashKey ← 金流配置 ecpay_invoice.hash_key',
|
||||
],
|
||||
'data.t050v36' => [
|
||||
'type' => 'string',
|
||||
'description' => '綠界發票 HashIV ← 金流配置 ecpay_invoice.hash_iv',
|
||||
],
|
||||
'data.t050v38' => [
|
||||
'type' => 'string',
|
||||
'description' => '綠界發票 通知 Email ← 金流配置 ecpay_invoice.email',
|
||||
],
|
||||
'data.TP_APP_ID' => [
|
||||
'type' => 'string',
|
||||
'description' => 'TapPay App ID ← 金流配置 tappay.app_id',
|
||||
],
|
||||
'data.TP_APP_KEY' => [
|
||||
'type' => 'string',
|
||||
'description' => 'TapPay App Key ← 金流配置 tappay.app_key',
|
||||
],
|
||||
'data.TP_PARTNER_KEY' => [
|
||||
'type' => 'string',
|
||||
'description' => 'TapPay Partner Key ← 金流配置 tappay.partner_key',
|
||||
],
|
||||
'data.TP_LINE_MERCHANT_ID' => [
|
||||
'type' => 'string',
|
||||
'description' => 'LINE Pay 特店 ID ← 金流配置 tappay.line_merchant_id',
|
||||
],
|
||||
'data.TP_JKO_MERCHANT_ID' => [
|
||||
'type' => 'string',
|
||||
'description' => '街口支付 特店 ID ← 金流配置 tappay.jko_merchant_id',
|
||||
],
|
||||
'data.TP_PI_MERCHANT_ID' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Pi 拍錢包 特店 ID ← 金流配置 tappay.pi_merchant_id',
|
||||
],
|
||||
'data.TP_PS_MERCHANT_ID' => [
|
||||
'type' => 'string',
|
||||
'description' => '全盈+Pay 特店 ID ← 金流配置 tappay.ps_merchant_id',
|
||||
],
|
||||
'data.TP_EASY_MERCHANT_ID' => [
|
||||
'type' => 'string',
|
||||
'description' => '悠遊付 特店 ID ← 金流配置 tappay.easy_merchant_id',
|
||||
],
|
||||
'data.DevSet' => [
|
||||
'type' => 'object',
|
||||
'description' => "支付旗標(命名沿用機台 DevSetStructure;布林)。機台既有欄位:\n• ShoppingCar ← shopping_cart_enabled(購物車)\n• Invoice ← tax_invoice_enabled(電子發票)\n• DevNFCPay ← card_terminal_enabled(刷卡機總開關)\n• DevEsunPay ← scan_pay_esun_enabled(玉山掃碼)\n• DevTapPay ← scan_pay_tappay_enabled(TapPay 掃碼)\n• DevCash ← cash_module_enabled(現金)\n• DevLinePay ← scan_pay_linepay_enabled(LINE Pay 官方直連)\n• TapPay30 ← tappay_linepay(TapPay 底下的 LINE Pay)\n• TapPay31 ← tappay_jkopay(街口支付)\n• TapPay32 ← tappay_easywallet(悠遊付)\n• TapPay33 ← tappay_pipay(Pi 支付)\n• TapPay34 ← tappay_pluspay(全盈+支付)\n新定義(機台端待新增欄位):\n• DevCreditCard ← credit_card_enabled(信用卡)\n• DevMobilePay ← mobile_pay_enabled(手機支付)\n• DevCardPay ← card_pay_enabled(卡片支付)\n• DevScanPay ← scan_pay_enabled(掃碼總開關)\n(VMC/Electic 為機台硬體類型,雲端不下發)",
|
||||
],
|
||||
'data.CashSet' => [
|
||||
'type' => 'object',
|
||||
'description' => "現金面額旗標(命名對齊機台端 CashSetStructure;布林):\n• BillF1000 / BillE500 / BillD100 ← cash_bill_1000 / 500 / 100\n• CoinF50 / CoinE10 / CoinD5 / CoinC1 ← cash_coin_50 / 10 / 5 / 1",
|
||||
],
|
||||
'data.FunctionSet' => [
|
||||
'type' => 'object',
|
||||
'description' => "非支付功能模組旗標(新定義,機台端待實作對應結構;布林):\n• PickupModule ← pickup_module_enabled(取貨模組)\n• PickupCode ← pickup_code_enabled(取貨碼)\n• PassCode ← pass_code_enabled(通行碼)\n• WelcomeGift ← welcome_gift_enabled(來店禮)\n• MemberSystem ← member_system_enabled(會員系統)\n• AmbientTemp ← ambient_temp_monitoring_enabled(環境溫度監控)\n• PharmacyPickup ← pharmacy_pickup_enabled(領藥單;雲端建單、掃 QR 出貨。僅在 ShoppingMode=pickup_sheet 取物單模式下有效)",
|
||||
],
|
||||
'data.ShoppingMode' => [
|
||||
'type' => 'string',
|
||||
'description' => '購物方式(新定義)← machines.settings.shopping_mode:basic / employee_card / pickup_sheet',
|
||||
],
|
||||
'data.LangSet' => [
|
||||
'type' => 'object',
|
||||
'description' => "機台顯示語系(新定義,最多 5 種)← machines.settings.languages:\n• Languages ← 有序語系陣列(如 [\"zh_TW\",\"en\",\"ja\"],順序即切換順序)\n• Default ← 預設語系(清單第一個,開機/idle 顯示)\n未設定時退化為 [fallback]。機台據此渲染語系切換 UI;可選語系池子見 config/locales.php。商品名稱/規格的對應翻譯由 B012 的 t060v01_i18n / t060v03_i18n 提供。",
|
||||
],
|
||||
'data.OperationSet' => [
|
||||
'type' => 'object',
|
||||
'description' => "運作參數(新定義;來源 machines 實體欄位):\n• CardReaderSeconds ← card_reader_seconds(整數,刷卡機秒數)\n• PaymentBufferSeconds ← payment_buffer_seconds(整數,金流緩衝秒數)\n• CheckoutTime1 ← card_reader_checkout_time_1(時間字串)\n• CheckoutTime2 ← card_reader_checkout_time_2\n• HeatingStartTime ← heating_start_time(加熱開始)\n• HeatingEndTime ← heating_end_time(加熱結束)",
|
||||
],
|
||||
'data.HardwareSet' => [
|
||||
'type' => 'object',
|
||||
'description' => "硬體與貨道(新定義;來源 machines 實體欄位):\n• CardReaderNo ← card_reader_no(字串,刷卡機編號)\n• SpringSlot1_10 / 11_20 / 21_30 / 31_40 / 41_50 / 51_60 ← is_spring_slot_*(布林,true=彈簧 / false=履帶)",
|
||||
'description' => '配置物件。包含:t050v01 (序號), api_token (通訊 Token), t050v41~43 (玉山設定), t050v34~38 (發票設定), TP_... (趨勢/手機支付設定)',
|
||||
'example' => [
|
||||
't050v01' => 'SN202604130001',
|
||||
'api_token' => 'mac_token_...',
|
||||
't050v41' => '80812345',
|
||||
't050v34' => '2000132',
|
||||
'TP_APP_ID' => 'GP_001'
|
||||
]
|
||||
],
|
||||
],
|
||||
'request' => [],
|
||||
@ -213,130 +119,10 @@ return [
|
||||
't050v42' => '9001',
|
||||
't050v43' => 'hash_key',
|
||||
't050v34' => '2000132',
|
||||
't050v35' => 'invoice_hash_key',
|
||||
't050v36' => 'invoice_hash_iv',
|
||||
't050v38' => 'invoice@example.com',
|
||||
'TP_APP_ID' => 'GP_001',
|
||||
'TP_APP_KEY' => 'app_key_...',
|
||||
'TP_PARTNER_KEY' => 'partner_key_...',
|
||||
'TP_LINE_MERCHANT_ID' => 'LINE_M_001',
|
||||
'TP_JKO_MERCHANT_ID' => 'JKO_M_001',
|
||||
'TP_PI_MERCHANT_ID' => 'PI_M_001',
|
||||
'TP_PS_MERCHANT_ID' => 'PS_M_001',
|
||||
'TP_EASY_MERCHANT_ID' => 'EASY_M_001',
|
||||
'DevSet' => [
|
||||
'ShoppingCar' => true,
|
||||
'Invoice' => true,
|
||||
'DevNFCPay' => true,
|
||||
'DevCreditCard' => true,
|
||||
'DevMobilePay' => true,
|
||||
'DevCardPay' => false,
|
||||
'DevScanPay' => true,
|
||||
'DevEsunPay' => true,
|
||||
'DevTapPay' => false,
|
||||
'DevCash' => true,
|
||||
'DevLinePay' => true,
|
||||
'TapPay30' => false,
|
||||
'TapPay31' => true,
|
||||
'TapPay32' => false,
|
||||
'TapPay33' => false,
|
||||
'TapPay34' => false,
|
||||
],
|
||||
'CashSet' => [
|
||||
'BillF1000' => true,
|
||||
'BillE500' => true,
|
||||
'BillD100' => true,
|
||||
'CoinF50' => true,
|
||||
'CoinE10' => true,
|
||||
'CoinD5' => true,
|
||||
'CoinC1' => true,
|
||||
],
|
||||
'FunctionSet' => [
|
||||
'PickupModule' => false,
|
||||
'PickupCode' => false,
|
||||
'PassCode' => false,
|
||||
'WelcomeGift' => false,
|
||||
'MemberSystem' => false,
|
||||
'AmbientTemp' => false,
|
||||
'PharmacyPickup' => false,
|
||||
],
|
||||
'ShoppingMode' => 'basic',
|
||||
'LangSet' => [
|
||||
'Languages' => ['zh_TW', 'en', 'ja'],
|
||||
'Default' => 'zh_TW',
|
||||
],
|
||||
'OperationSet' => [
|
||||
'CardReaderSeconds' => 30,
|
||||
'PaymentBufferSeconds' => 5,
|
||||
'CheckoutTime1' => '22:30:00',
|
||||
'CheckoutTime2' => '23:45:00',
|
||||
'HeatingStartTime' => '00:00:00',
|
||||
'HeatingEndTime' => '00:00:00',
|
||||
],
|
||||
'HardwareSet' => [
|
||||
'CardReaderNo' => 'CR-001',
|
||||
'SpringSlot1_10' => true,
|
||||
'SpringSlot11_20' => false,
|
||||
'SpringSlot21_30' => false,
|
||||
'SpringSlot31_40' => false,
|
||||
'SpringSlot41_50' => false,
|
||||
'SpringSlot51_60' => false,
|
||||
],
|
||||
'TP_APP_ID' => 'GP_001'
|
||||
]
|
||||
],
|
||||
'notes' => '此 API 為機台初始化引導用,目前不強制驗證 User Token。金流密鑰與發票設定來源為機台關聯的金流配置 (payment_configs);DevSet / CashSet / FunctionSet / ShoppingMode 來源為 machines.settings、OperationSet / HardwareSet 來源為 machines 實體欄位,全部採機台端 *Set + PascalCase 風格(DevSet/CashSet 對齊機台既有結構,其餘為新定義,機台 App 端需另案實作)。'
|
||||
],
|
||||
[
|
||||
'name' => 'B016: 機台系統設定回寫 (Settings Write-back)',
|
||||
'slug' => 'b016-settings-writeback',
|
||||
'method' => 'PATCH',
|
||||
'path' => '/api/v1/app/machine/setting/B016',
|
||||
'description' => '機台主控台由系統方設定硬體貨道類型後回寫雲端,為 B014 下載的反向操作。需帶 B000 核發之 User Token,且僅系統管理員可操作。機台端現在只回寫硬體貨道類型 (is_spring_slot_*),其餘系統設定純由後台單向決定,B016 不再接收。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <user_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'machine' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '機台序號 (serial_no)',
|
||||
'example' => 'SN202604130001'
|
||||
],
|
||||
'settings' => [
|
||||
'type' => 'object',
|
||||
'required' => true,
|
||||
'description' => "貨道類型鍵值物件(皆為布林),鍵名採後端原始 snake_case,只覆寫有送來的鍵、其餘保留。白名單外鍵一律忽略。可回寫鍵:\n• 貨道類型(machines 實體欄位):is_spring_slot_1_10 / 11_20 / 21_30 / 31_40 / 41_50 / 51_60(true=彈簧 / false=履帶)",
|
||||
'example' => ['is_spring_slot_1_10' => false]
|
||||
],
|
||||
],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '是否成功',
|
||||
],
|
||||
'code' => [
|
||||
'type' => 'integer',
|
||||
'description' => '業務狀態碼',
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'string',
|
||||
'description' => '回應訊息',
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'machine' => 'SN202604130001',
|
||||
'settings' => [
|
||||
'is_spring_slot_1_10' => true,
|
||||
'is_spring_slot_11_20' => false,
|
||||
],
|
||||
],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'message' => 'Settings updated successfully.',
|
||||
],
|
||||
'notes' => '僅系統管理員 (identity=system) 可操作,非系統方回 403 Forbidden;查無序號回 404 Machine not found;settings 非物件或未含任何有效貨道鍵回 422 Invalid settings payload。寫回時更新 is_spring_slot_* 實體欄位並記錄 updater_id,套用 withoutGlobalScopes 跨租戶定位機台。'
|
||||
'notes' => '此 API 為機台初始化引導用,目前不強制驗證 User Token。'
|
||||
],
|
||||
[
|
||||
'name' => 'B005: 廣告清單同步 (Ad Sync)',
|
||||
@ -468,16 +254,14 @@ return [
|
||||
],
|
||||
'data' => [
|
||||
'type' => 'array',
|
||||
'description' => '商品明細物件陣列。欄位包含:t060v00(ID), t060v01(名稱), t060v01_en/jp(既有英日名稱,保留相容), t060v01_i18n(名稱多語系 map), t060v03(規格), t060v03_i18n(規格多語系 map), t060v06(圖片), t060v09(售價), t060v11(預設上限), spring_limit(彈簧上限), track_limit(履帶上限)。多語系 map 的 key 集合=公司機台語系聯集,缺翻譯回退 zh_TW;既有欄位一律保留以相容線上舊 App。',
|
||||
'description' => '商品明細物件陣列。欄位包含:t060v00(ID), t060v01(名稱), t060v03(規格), t060v06(圖片), t060v09(售價), t060v11(預設上限), spring_limit(彈簧上限), track_limit(履帶上限)',
|
||||
'example' => [
|
||||
[
|
||||
't060v00' => '1',
|
||||
't060v01' => '可口可樂 330ml',
|
||||
't060v01_en' => 'Coca Cola',
|
||||
't060v01_jp' => 'コカコーラ',
|
||||
't060v01_i18n' => ['zh_TW' => '可口可樂 330ml', 'en' => 'Coca Cola', 'ja' => 'コカコーラ'],
|
||||
't060v03' => 'Cold Drink',
|
||||
't060v03_i18n' => ['zh_TW' => 'Cold Drink', 'en' => 'Cold Drink', 'ja' => 'コールドドリンク'],
|
||||
't060v06' => 'https://.../coke.png',
|
||||
't060v09' => 25.0,
|
||||
't060v11' => 10,
|
||||
@ -501,9 +285,7 @@ return [
|
||||
't060v01' => '可口可樂 330ml',
|
||||
't060v01_en' => 'Coca Cola 330ml',
|
||||
't060v01_jp' => 'コカコーラ 330ml',
|
||||
't060v01_i18n' => ['zh_TW' => '可口可樂 330ml', 'en' => 'Coca Cola 330ml', 'ja' => 'コカコーラ 330ml'],
|
||||
't060v03' => '經典原味,冰涼好滋味',
|
||||
't060v03_i18n' => ['zh_TW' => '經典原味,冰涼好滋味', 'en' => 'Classic taste, ice cold', 'ja' => 'クラシックな味わい'],
|
||||
't060v06' => 'https://cloud.star.com/storage/products/coke.png',
|
||||
't060v09' => 25.0,
|
||||
't060v11' => 10,
|
||||
@ -627,104 +409,30 @@ return [
|
||||
'notes' => '驗證成功後會建立刷卡日誌。連續錯誤同樣會觸發鎖定。'
|
||||
],
|
||||
[
|
||||
'name' => 'B690: 來店禮驗證 (Welcome Gift Verify)',
|
||||
'slug' => 'b690-welcome-gift-verify',
|
||||
'name' => 'B027: 贈品碼/優惠券驗證 (Free Gift Verify)',
|
||||
'slug' => 'b027-freebie-code',
|
||||
'method' => 'POST',
|
||||
'path' => '/api/v1/app/machine/welcome-gift/verify/B690',
|
||||
'description' => '處理機台端的 8 位數來店禮代碼驗證。成功後回傳折扣資訊(含台灣中文折數標籤),供機台端套用折扣。正式消耗改由 MQTT finalize 處理 (payment_type=7)。',
|
||||
'path' => '/api/v1/app/sell/free-gift/B027',
|
||||
'description' => '驗證贈品券有效性。成功後回傳對應資訊,正式消耗改由 MQTT finalize 處理。',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer <api_token>',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'parameters' => [
|
||||
'code' => [
|
||||
'passCode' => [
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'description' => '8 位數來店禮代碼',
|
||||
'example' => '12345678'
|
||||
]
|
||||
],
|
||||
'response_parameters' => [
|
||||
'success' => [
|
||||
'type' => 'boolean',
|
||||
'description' => '請求是否成功',
|
||||
'example' => true
|
||||
'description' => '贈品碼/優惠券代碼',
|
||||
'example' => 'FREE888'
|
||||
],
|
||||
'code' => [
|
||||
'type' => 'integer',
|
||||
'description' => '業務狀態碼',
|
||||
'example' => 200
|
||||
],
|
||||
'data.name' => [
|
||||
'type' => 'string',
|
||||
'description' => '來店禮名稱',
|
||||
'example' => '新客八五折'
|
||||
],
|
||||
'data.code_id' => [
|
||||
'type' => 'integer',
|
||||
'description' => '來店禮 ID (用於 MQTT 交易關聯,payment_type=7 時帶入)',
|
||||
'example' => 12
|
||||
],
|
||||
'data.discount_type' => [
|
||||
'type' => 'string',
|
||||
'description' => '折扣類型:percentage (趴數折扣) 或 amount (金額折扣)',
|
||||
'example' => 'percentage'
|
||||
],
|
||||
'data.discount_value' => [
|
||||
'type' => 'integer',
|
||||
'description' => '折扣數值。percentage 時為趴數 (如 15 代表 15% off);amount 時為折扣金額 (如 50)',
|
||||
'example' => 15
|
||||
],
|
||||
'data.discount_label' => [
|
||||
'type' => 'string',
|
||||
'description' => '台灣中文折數標籤 (如「八五折」、「折 50 元」),供 UI 直接顯示',
|
||||
'example' => '八五折'
|
||||
],
|
||||
'data.usage_type' => [
|
||||
'type' => 'string',
|
||||
'description' => '使用類型:once (單次) 或 unlimited (無限制)',
|
||||
'example' => 'once'
|
||||
],
|
||||
'data.status' => [
|
||||
'type' => 'string',
|
||||
'description' => '來店禮狀態',
|
||||
'example' => 'active'
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'code' => '12345678'
|
||||
],
|
||||
'response' => [
|
||||
'success' => true,
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'name' => '新客八五折',
|
||||
'code_id' => 12,
|
||||
'discount_type' => 'percentage',
|
||||
'discount_value' => 15,
|
||||
'discount_label' => '八五折',
|
||||
'usage_type' => 'once',
|
||||
'status' => 'active'
|
||||
'res1' => 'cloud_id_123',
|
||||
'message' => 'Free gift verified'
|
||||
]
|
||||
],
|
||||
'error_responses' => [
|
||||
[
|
||||
'code' => 400,
|
||||
'message' => 'Invalid format',
|
||||
'description' => 'code 非 8 位數或缺少必填欄位。'
|
||||
],
|
||||
[
|
||||
'code' => 404,
|
||||
'message' => 'Invalid or expired code',
|
||||
'description' => '代碼不存在、已失效或已過期。回應另含 remaining_attempts (剩餘嘗試次數)。'
|
||||
],
|
||||
[
|
||||
'code' => 429,
|
||||
'message' => 'Too many attempts. Locked.',
|
||||
'description' => '連續錯誤達 5 次,該機台驗證功能於鎖定期間 (60 秒) 內皆回傳此回應。'
|
||||
],
|
||||
],
|
||||
'notes' => '安全性:連續錯誤 5 次將鎖定該機台驗證功能 1 分鐘。消耗流程已遷移至 MQTT (payment_type=7)。'
|
||||
'notes' => '消耗流程已遷移至 MQTT。'
|
||||
],
|
||||
],
|
||||
],
|
||||
@ -769,42 +477,12 @@ return [
|
||||
'qos' => 0,
|
||||
'description' => '用於機台連線與斷線的即時狀態同步。通常用於 MQTT 的遺囑訊息 (LWT) 設定,或由 Broker 連線事件觸發。',
|
||||
'payload_parameters' => [
|
||||
'status' => ['type' => 'string', 'description' => '連線狀態。接受值:online, offline, restarting'],
|
||||
'status' => ['type' => 'string', 'description' => '連線狀態。接受值:online, offline'],
|
||||
],
|
||||
'payload_example' => [
|
||||
'status' => 'online'
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '機台環境溫度上報 (Ambient Temperature Report)',
|
||||
'slug' => 'mqtt-ambient-temp-report',
|
||||
'action' => 'PUB',
|
||||
'topic' => 'machine/{serial_no}/ambient_temp',
|
||||
'qos' => 1,
|
||||
'description' => '機台主動上報當前環境溫度。支援 temperature 或 ambient_temp 欄位上報。',
|
||||
'payload_parameters' => [
|
||||
'temperature' => ['type' => 'integer', 'description' => '當前環境溫度值 (例如 28)'],
|
||||
'ambient_temp' => ['type' => 'integer', 'description' => '當前環境溫度值 (相容相應硬體參數)'],
|
||||
],
|
||||
'payload_example' => [
|
||||
'temperature' => 28
|
||||
],
|
||||
],
|
||||
|
||||
[
|
||||
'name' => '機台硬體事件上報 (Event)',
|
||||
'slug' => 'mqtt-event',
|
||||
'action' => 'PUB',
|
||||
'topic' => 'machine/{serial_no}/event',
|
||||
'qos' => 1,
|
||||
'description' => '用於機台上報各種獨立的硬體運行與物理事件。後台收到後會直接寫入機台狀態日誌中並進行多語系翻譯。',
|
||||
'payload_parameters' => [
|
||||
'event' => ['type' => 'string', 'description' => '事件識別碼。例如:fanon (風扇開), fanoff (風扇關) 等。'],
|
||||
],
|
||||
'payload_example' => [
|
||||
'event' => 'fanon'
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '機台異常上報 (Error)',
|
||||
'slug' => 'mqtt-error',
|
||||
@ -858,7 +536,7 @@ return [
|
||||
'order.original_amount' => ['type' => 'numeric', 'description' => '(選填) 原始標價金額', 'required' => false],
|
||||
'order.discount_amount' => ['type' => 'numeric', 'description' => '(選填) 折扣金額,預設 0', 'required' => false],
|
||||
'order.points_used' => ['type' => 'integer', 'description' => '(選填) 使用點數,預設 0', 'required' => false],
|
||||
'order.payment_type' => ['type' => 'integer', 'description' => "(必填) 支付類型代碼:\n1:信用卡, 2:電子票證(悠遊卡/一卡通/iCash 等), 3:掃碼支付, 4:紙鈔機, 5:通行碼, 6:取貨碼, 7:來店禮, 8:問卷, 9:零錢(硬幣), 10:手機支付(行動支付/NFC 手機感應), 41:員工卡, 42:取物單。\n※ 1(信用卡)、2(電子票證)、10(手機支付) 走同一台實體刷卡機(Nexsys 終端機)。\n21~25:線下付款+基本類型 (21:線下+1 信用卡, 22:線下+2 電子票證, 23:線下+3 掃碼, 24:線下+4 紙鈔, 25:線下+9 零錢)。\n30:LINE Pay(TapPay), 31:街口, 32:悠遊付, 33:Pi 拍錢包, 34:全盈+PAY。\n70:LINE Pay(官方直連,非 TapPay 底下的 30)。\n40:會員驗證取貨商品。\n50~54:線下付款+TapPay (50:線下+30, 51:線下+31, 52:線下+32, 53:線下+33, 54:線下+34)。\n60:點數/優惠券全額折抵。61~69:會員+基本類型 (61:會員+1...69:會員+9)。\n90~94:會員+TapPay (90:會員+30...94:會員+34)。\n100:遠端出貨。"],
|
||||
'order.payment_type' => ['type' => 'integer', 'description' => "(必填) 支付類型代碼:\n1:信用卡, 2:悠遊卡/一卡通, 3:掃碼支付, 4:紙鈔機, 5:通行碼, 6:取貨碼, 7:來店禮, 8:問卷, 9:零錢, 41:員工卡。\n21~25:線下付款+X (21:線下+1, 22:線下+2, 23:線下+3, 24:線下+4, 25:線下+9)。\n30:LINE Pay, 31:街口, 32:悠遊付, 33:Pi, 34:全盈+。\n40:會員驗證取貨商品。\n50~54:線下付款+TapPay (50:線下+30, 51:線下+31, 52:線下+32, 53:線下+33, 54:線下+34)。\n60:點數/優惠卷全額折抵。61~69:會員+X (61:會員+1...69:會員+9)。\n90~94:會員+TapPay (90:會員+30...94:會員+34)。\n100:遠端出貨。"],
|
||||
'order.payment_status' => ['type' => 'integer', 'description' => '(必填) 支付狀態 (1:成功)'],
|
||||
'order.code_id' => ['type' => 'string', 'description' => '(選填) 閉環勾稽用的代碼 ID (payment_type 為 5, 6, 41 時必填)', 'required' => false],
|
||||
'order.payment_request' => ['type' => 'string', 'description' => '(選填) 金流請求原始字串', 'required' => false],
|
||||
@ -1024,7 +702,7 @@ return [
|
||||
'qos' => 1,
|
||||
'description' => '雲端主動下發的系統級別控制指令。包含重啟、設備鎖定解鎖、購物車結帳等無額外參數的指令。',
|
||||
'payload_parameters' => [
|
||||
'command' => ['type' => 'string', 'description' => '系統指令類型。支援:reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖), update_ads (廣告同步), update_products (商品資料同步), fanon (開啟風扇), fanoff (關閉風扇), fanauto (風扇自動控制)'],
|
||||
'command' => ['type' => 'string', 'description' => '系統指令類型。支援:reboot (重啟機台), reboot_card (重啟刷卡機), checkout (購物車結帳), lock (設備鎖定), unlock (設備解鎖), update_ads (廣告同步), update_products (商品資料同步)'],
|
||||
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
|
||||
'payload' => ['type' => 'object', 'description' => '空物件 (無額外參數)'],
|
||||
],
|
||||
@ -1035,28 +713,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
[
|
||||
'name' => '指令下發:環境溫度上限 (Ambient Temperature Upper Limit)',
|
||||
'slug' => 'mqtt-command-ambient-temp-limit',
|
||||
'action' => 'SUB',
|
||||
'topic' => 'machine/{serial_no}/command',
|
||||
'qos' => 1,
|
||||
'description' => '雲端主動下發「環境溫度上限」指令。指示機台更新環境溫度開啟風扇的閾值設定。',
|
||||
'payload_parameters' => [
|
||||
'command' => ['type' => 'string', 'description' => '固定為 "ambient_temp_limit"'],
|
||||
'command_id' => ['type' => 'string', 'description' => '指令唯一 ID'],
|
||||
'payload' => ['type' => 'object', 'description' => '溫度設定參數'],
|
||||
'payload.temperature' => ['type' => 'integer', 'description' => '環境溫度上限閾值(可為 null 表示停用監測)'],
|
||||
],
|
||||
'payload_example' => [
|
||||
'command' => 'ambient_temp_limit',
|
||||
'command_id' => '1122334455',
|
||||
'payload' => [
|
||||
'temperature' => 28
|
||||
]
|
||||
],
|
||||
],
|
||||
|
||||
[
|
||||
'name' => '指令執行回報 (Machine to Cloud)',
|
||||
'slug' => 'mqtt-command-ack',
|
||||
|
||||
@ -15,7 +15,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', env('CACHE_DRIVER', 'database')),
|
||||
'default' => env('CACHE_DRIVER', 'file'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 系統支援語系白名單 (Master Locale Whitelist)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 系統目前支援的所有語系,作為以下用途的唯一來源:
|
||||
| 1. 機台設定頁「顯示語系」可勾選清單(管理員從此白名單替機台挑語系,最多 5 種)。
|
||||
| 2. 後台儲存語系時(updateSystemSettings)languages 欄位的驗證白名單。
|
||||
|
|
||||
| 注意:商品編輯頁的語系 Tab 不是直接讀白名單,而是讀「公司所有機台已開語系
|
||||
| 的聯集」(Company::activeLocales())。白名單是「機台能挑的池子」,聯集是
|
||||
| 「商品實際要維護的語系」。
|
||||
|
|
||||
| 詳見 docs/API/product_multilingual_spec.md。
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
// 每台機台最多可勾選的語系數量
|
||||
'max_per_machine' => 5,
|
||||
|
||||
// 缺翻譯時的回退語系(亦為商品名稱/規格的最終預設值)
|
||||
'fallback' => 'zh_TW',
|
||||
|
||||
/*
|
||||
| 支援語系:locale => 顯示名稱。
|
||||
| 標籤採「原文(中文註記)」格式:因後台為我方使用,原文(如 ภาษาไทย、Tiếng Việt)
|
||||
| 不易辨識,故一律附上中文註記,方便管理員一眼判斷是哪種語言。
|
||||
| 陣列順序即為各處 UI 的呈現順序,請維持 zh_TW 在最前。
|
||||
*/
|
||||
'supported' => [
|
||||
'zh_TW' => '繁體中文',
|
||||
'en' => 'English(英文)',
|
||||
'ja' => '日本語(日文)',
|
||||
'ko' => '한국어(韓文)',
|
||||
'th' => 'ภาษาไทย(泰文)',
|
||||
'zh_CN' => '简体中文(簡中)',
|
||||
'vi' => 'Tiếng Việt(越南文)',
|
||||
'fr' => 'Français(法文)',
|
||||
'de' => 'Deutsch(德文)',
|
||||
'es' => 'Español(西班牙文)',
|
||||
'id' => 'Bahasa Indonesia(印尼文)',
|
||||
],
|
||||
|
||||
];
|
||||
@ -66,8 +66,7 @@ return [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
// 須大於最長 Job 的 timeout(AttachProductImagesJob 1800s),否則長任務未跑完即被重派造成重複處理
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 1830),
|
||||
'retry_after' => 90,
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
@ -31,11 +31,4 @@ return [
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
// 綠界電子發票 B2C API(後台對帳/補開/作廢)。金鑰仍取自各機台 payment_configs.ecpay_invoice;
|
||||
// 此處僅控制 API 網域。留空(未設 env)時由 EcpayInvoiceService 依環境 fail-safe:
|
||||
// 正式環境才用 https://einvoice.ecpay.com.tw,其餘走 https://einvoice-stage.ecpay.com.tw。
|
||||
'ecpay_invoice' => [
|
||||
'base_url' => env('ECPAY_INVOICE_BASE_URL'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('transfer_order_items', function (Blueprint $table) {
|
||||
$table->string('snapshot_product_name')->nullable()->after('quantity');
|
||||
$table->string('snapshot_product_name_key')->nullable()->after('snapshot_product_name');
|
||||
$table->string('snapshot_product_barcode')->nullable()->after('snapshot_product_name_key');
|
||||
$table->string('snapshot_product_image_url')->nullable()->after('snapshot_product_barcode');
|
||||
});
|
||||
|
||||
Schema::table('replenishment_order_items', function (Blueprint $table) {
|
||||
$table->string('snapshot_product_name')->nullable()->after('max_stock');
|
||||
$table->string('snapshot_product_name_key')->nullable()->after('snapshot_product_name');
|
||||
$table->string('snapshot_product_barcode')->nullable()->after('snapshot_product_name_key');
|
||||
$table->string('snapshot_product_image_url')->nullable()->after('snapshot_product_barcode');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('transfer_order_items', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'snapshot_product_name',
|
||||
'snapshot_product_name_key',
|
||||
'snapshot_product_barcode',
|
||||
'snapshot_product_image_url'
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::table('replenishment_order_items', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'snapshot_product_name',
|
||||
'snapshot_product_name_key',
|
||||
'snapshot_product_barcode',
|
||||
'snapshot_product_image_url'
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('welcome_gifts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained('companies')->onDelete('cascade');
|
||||
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
|
||||
$table->string('name', 100)->comment('來店禮名稱');
|
||||
$table->string('code', 10)->comment('來店禮代碼 (8位)');
|
||||
$table->string('slug', 16)->unique()->comment('票券 Slug');
|
||||
$table->string('discount_type')->comment('折扣類型: percentage (趴數), amount (金額)');
|
||||
$table->integer('discount_value')->comment('折扣值 (趴數或折抵金額)');
|
||||
$table->string('usage_type')->default('once')->comment('使用次數: once (單次), unlimited (無限制)');
|
||||
$table->integer('usage_limit')->nullable()->default(1)->comment('使用次數上限');
|
||||
$table->integer('usage_count')->default(0)->comment('已使用次數');
|
||||
$table->timestamp('expires_at')->nullable()->comment('到期時間');
|
||||
$table->string('status')->default('active')->comment('狀態: active, disabled, used, expired');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['company_id', 'machine_id', 'code', 'status']);
|
||||
});
|
||||
|
||||
Schema::create('welcome_gift_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained('companies')->onDelete('cascade');
|
||||
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
|
||||
$table->foreignId('welcome_gift_id')->constrained('welcome_gifts')->onDelete('cascade');
|
||||
$table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null');
|
||||
$table->string('action')->comment('動作: verify_success, consume, cancel');
|
||||
$table->string('remark')->nullable();
|
||||
$table->json('raw_data')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('welcome_gift_logs');
|
||||
Schema::dropIfExists('welcome_gifts');
|
||||
}
|
||||
};
|
||||
@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->foreignId('welcome_gift_id')
|
||||
->nullable()
|
||||
->after('code_id')
|
||||
->constrained('welcome_gifts')
|
||||
->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->dropForeign(['welcome_gift_id']);
|
||||
$table->dropColumn('welcome_gift_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('apk_versions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('version_name'); // 例如 '10_08_6_R'
|
||||
$table->integer('version_code')->unique(); // 例如 100806,用於比對版本新舊,必須遞增
|
||||
$table->string('flavor'); // 機台風味,例如 'S_12_XY_U_Standard_'
|
||||
$table->string('file_path')->nullable(); // 本地上傳的 APK 儲存路徑
|
||||
$table->string('download_url')->nullable(); // 外連下載 URL (如果未使用本地上傳)
|
||||
$table->text('release_notes')->nullable(); // 版本更新說明
|
||||
$table->boolean('is_forced')->default(false); // 是否強制更新
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('apk_versions');
|
||||
}
|
||||
};
|
||||
@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ota_schedules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('apk_version_id')->constrained('apk_versions')->onDelete('cascade');
|
||||
$table->dateTime('scheduled_at');
|
||||
$table->string('target_type')->default('custom'); // 'all', 'custom'
|
||||
$table->json('target_value')->nullable(); // 當 target_type = 'custom' 時,儲存 [1, 2, 3] 機台 ID 的 JSON 陣列
|
||||
$table->string('status')->default('pending'); // 'pending', 'processing', 'completed', 'failed'
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null'); // 建立排程的使用者
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ota_schedules');
|
||||
}
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->tinyInteger('delivery_status')->default(1)->comment('出貨狀態: 0:出貨失敗, 1:出貨成功, 2:部分出貨成功')->after('payment_status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->dropColumn('delivery_status');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('machines', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('machines', 'ambient_temperature')) {
|
||||
$table->integer('ambient_temperature')->nullable()->after('temperature')->comment('環境溫度');
|
||||
}
|
||||
if (!Schema::hasColumn('machines', 'ambient_temp_setting')) {
|
||||
$table->integer('ambient_temp_setting')->nullable()->after('ambient_temperature')->comment('環境溫度設定值');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machines', function (Blueprint $table) {
|
||||
$table->dropColumn(['ambient_temperature', 'ambient_temp_setting']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('machines', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('machines', 'ambient_temp_monitoring_enabled')) {
|
||||
$table->boolean('ambient_temp_monitoring_enabled')->default(false)->after('ambient_temp_setting')->comment('是否啟用環境溫度監測');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machines', function (Blueprint $table) {
|
||||
$table->dropColumn('ambient_temp_monitoring_enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 商品「規格 (spec)」多語系:比照名稱的 name_dictionary_key,
|
||||
* 為規格新增獨立字典鍵,對應 translations.group = 'product_spec'。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->uuid('spec_dictionary_key')->nullable()->after('spec')->comment('規格多語系 Key');
|
||||
$table->index('spec_dictionary_key');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->dropIndex(['spec_dictionary_key']);
|
||||
$table->dropColumn('spec_dictionary_key');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 回填既有機台的顯示語系:將尚未設定 settings.languages 的機台補為
|
||||
* ["zh_TW","en","ja"],使既有 demo/prod 行為與現況一致 —— 公司機台語系
|
||||
* 聯集維持中英日,商品編輯器照常顯示三 Tab,機台 B014 LangSet 亦得中英日。
|
||||
*
|
||||
* 僅補「缺鍵或空值」者,已設定者不動。直接以 DB 更新避免觸發 Machine 事件
|
||||
* (MQTT 同步等)與全域範圍。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$default = ['zh_TW', 'en', 'ja'];
|
||||
|
||||
DB::table('machines')->select('id', 'settings')->orderBy('id')
|
||||
->chunkById(200, function ($machines) use ($default) {
|
||||
foreach ($machines as $m) {
|
||||
$settings = $m->settings ? json_decode($m->settings, true) : [];
|
||||
if (!is_array($settings)) {
|
||||
$settings = [];
|
||||
}
|
||||
// 已有非空 languages 者跳過
|
||||
if (!empty($settings['languages']) && is_array($settings['languages'])) {
|
||||
continue;
|
||||
}
|
||||
$settings['languages'] = $default;
|
||||
DB::table('machines')->where('id', $m->id)->update([
|
||||
'settings' => json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 不反向移除 languages:無法區分回填值與使用者後續手動設定值,
|
||||
* 貿然移除恐誤刪設定,故 down 不動資料。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 將 products.spec(商品詳情主鏡像值)由 VARCHAR(255) 改為 TEXT,
|
||||
* 以支援多行詳情排版,避免 zh_TW 較長內容被截斷。
|
||||
* (translations.value 本即 TEXT,不需變更。)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// sqlite 為動態型別,VARCHAR/TEXT 等價且不支援 MODIFY 語法,僅在 MySQL 執行。
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
DB::statement('ALTER TABLE products MODIFY spec TEXT NULL COMMENT \'詳情\'');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() !== 'sqlite') {
|
||||
DB::statement('ALTER TABLE products MODIFY spec VARCHAR(255) NULL COMMENT \'規格\'');
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 電子發票對帳/補開所需欄位。
|
||||
*
|
||||
* 背景:機台改走 MQTT finalize 後,發票結果(含 pending)隨交易一起上報;
|
||||
* 後台需要狀態機與冪等鍵,才能對 pending 去綠界查詢(GetIssue)、對 failed 補開(Issue)、
|
||||
* 對「已開票未出貨」作廢(Invalid)。全部欄位 nullable / 有預設,對既有資料與未開發票的機台零影響。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('invoices', function (Blueprint $table) {
|
||||
// 開立狀態:pending(已送出未回應)/ issued(已開)/ failed(綠界回失敗)/ void(已作廢)
|
||||
$table->string('status')->default('issued')->index()->after('invoice_no')
|
||||
->comment('發票狀態: pending/issued/failed/void');
|
||||
// 綠界 RelateNumber(冪等鍵, = machineID + flow_id),查詢/補開用
|
||||
$table->string('relate_number')->nullable()->index()->after('status')
|
||||
->comment('綠界 RelateNumber 冪等鍵');
|
||||
// 對帳排程:上次去綠界查詢的時間與已重試次數
|
||||
$table->timestamp('last_checked_at')->nullable()->after('machine_time')
|
||||
->comment('上次向綠界查詢/補開的時間');
|
||||
$table->unsignedInteger('retry_count')->default(0)->after('last_checked_at')
|
||||
->comment('查詢/補開重試次數');
|
||||
// 作廢稽核
|
||||
$table->timestamp('voided_at')->nullable()->after('retry_count')
|
||||
->comment('作廢時間');
|
||||
$table->string('void_reason')->nullable()->after('voided_at')
|
||||
->comment('作廢原因');
|
||||
});
|
||||
|
||||
// 既有資料回填:有發票號→issued,否則→failed(未開發票的機台沒有任何列,等同 no-op)
|
||||
DB::table('invoices')
|
||||
->whereNull('invoice_no')->orWhere('invoice_no', '')
|
||||
->update(['status' => 'failed']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('invoices', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'status',
|
||||
'relate_number',
|
||||
'last_checked_at',
|
||||
'retry_count',
|
||||
'voided_at',
|
||||
'void_reason',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 機台狀態燈號查詢加速。
|
||||
* 連線(calculated_status)、下位機(hardware_status)、登入、刷卡機(card_terminal) 等狀態
|
||||
* 皆以 WHERE machine_id=? AND type=? AND is_resolved=? AND level=? 過濾未解決日誌,
|
||||
* 且在機台列表頁逐台執行。加複合索引避免隨日誌量成長而變慢。
|
||||
*
|
||||
* 註:正式環境 machine_logs 可能很大,加索引會短暫鎖表,建議離峰執行。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('machine_logs', function (Blueprint $table) {
|
||||
$table->index(['machine_id', 'type', 'is_resolved', 'level'], 'machine_logs_status_lookup_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machine_logs', function (Blueprint $table) {
|
||||
$table->dropIndex('machine_logs_status_lookup_index');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 為交易訂單與電子發票各自新增「管理者備註」欄位。
|
||||
* - orders.remark:訂單內部備註(出貨/客訴等)。
|
||||
* - invoices.remark:發票內部備註(作廢視窗亦寫入此欄,不影響送綠界的 void_reason)。
|
||||
* 兩欄皆 nullable,對既有資料零影響。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->text('remark')->nullable()->after('metadata');
|
||||
});
|
||||
|
||||
Schema::table('invoices', function (Blueprint $table) {
|
||||
$table->text('remark')->nullable()->after('void_reason');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->dropColumn('remark');
|
||||
});
|
||||
|
||||
Schema::table('invoices', function (Blueprint $table) {
|
||||
$table->dropColumn('remark');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 領藥單模組:orders 增加訂單類型/批價單號/建單者欄位。
|
||||
* order_type 預設 sale,既有資料一律視為一般銷售,不影響線上行為。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('orders', 'order_type')) {
|
||||
$table->string('order_type', 20)->default('sale')->index()
|
||||
->comment('訂單類型: sale=一般銷售, pharmacy_pickup=領藥單')->after('order_no');
|
||||
}
|
||||
if (!Schema::hasColumn('orders', 'pricing_slip_no')) {
|
||||
$table->string('pricing_slip_no', 64)->nullable()
|
||||
->comment('批價單號 (領藥單藥師填寫之依據)')->after('order_type');
|
||||
}
|
||||
if (!Schema::hasColumn('orders', 'created_by')) {
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()
|
||||
->comment('建單者 (領藥單由後台藥師建立)')->after('pricing_slip_no');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('orders', 'created_by')) {
|
||||
$table->dropForeign(['created_by']);
|
||||
$table->dropColumn('created_by');
|
||||
}
|
||||
if (Schema::hasColumn('orders', 'pricing_slip_no')) {
|
||||
$table->dropColumn('pricing_slip_no');
|
||||
}
|
||||
if (Schema::hasColumn('orders', 'order_type')) {
|
||||
$table->dropColumn('order_type');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 領藥單一張單多貨道:slot_no 改為可空。
|
||||
* 一般取貨碼仍寫入 slot_no(單貨道);領藥單的多貨道由 order_items × machine_slots 解析,
|
||||
* 故 slot_no 允許為 null,讀取端依 order_type 區分。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('pickup_codes', function (Blueprint $table) {
|
||||
$table->string('slot_no')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('pickup_codes', function (Blueprint $table) {
|
||||
$table->string('slot_no')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 機台專屬定價:針對「特定機台 × 特定商品」覆蓋全域 products.price / member_price。
|
||||
*
|
||||
* - 無紀錄(或欄位為 NULL)= 沿用全域商品價(products.price / member_price)。
|
||||
* - B012 下發時,命中的商品會把 t063v03(機台價) / t060v30(會員價) 換成此處的值。
|
||||
* - 粒度為 (machine_id, product_id):App 端價格模型是 product-level,同商品占多貨道共用同一價。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('machine_product_prices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('machine_id')->constrained('machines')->onDelete('cascade');
|
||||
$table->foreignId('product_id')->constrained('products')->onDelete('cascade');
|
||||
$table->decimal('price', 10, 2)->nullable()->comment('機台專屬售價;NULL=用全域 products.price');
|
||||
$table->decimal('member_price', 10, 2)->nullable()->comment('機台專屬會員價;NULL=用全域 member_price(並夾為不高於機台售價)');
|
||||
$table->timestamps();
|
||||
|
||||
// 一台機台對同一商品只有一筆覆蓋價
|
||||
$table->unique(['machine_id', 'product_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('machine_product_prices');
|
||||
}
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 補貨單歸屬以「機台所綁公司」為準。
|
||||
* 既有單據(特別是系統管理員建立、company_id 寫成建立者 null 的)依機台回填,
|
||||
* 否則篩選列選公司或租戶帳號登入皆看不到這些補貨單。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 以 machines.company_id 校正 replenishment_orders.company_id(含 NULL 安全比較)
|
||||
DB::statement(<<<'SQL'
|
||||
UPDATE replenishment_orders AS ro
|
||||
INNER JOIN machines AS m ON m.id = ro.machine_id
|
||||
SET ro.company_id = m.company_id
|
||||
WHERE NOT (ro.company_id <=> m.company_id)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 資料校正,無法可靠還原
|
||||
}
|
||||
};
|
||||
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 貨道「效期 + 鎖定」雙向 LWW 同步所需欄位。
|
||||
*
|
||||
* - machine_slots.is_locked : 貨道是否被「鎖定停售」(與 is_active「是否啟用/配置」語意不同,兩者並存)。
|
||||
* - machine_slots.last_app_lock_rev : 後台已採用的 App 鎖定編輯序號(per-field LWW,避免比較機台/後台兩個時鐘)。
|
||||
* - machine_slots.last_app_expiry_rev: 後台已採用的 App 效期/批號編輯序號。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('machine_slots', function (Blueprint $table) {
|
||||
$table->boolean('is_locked')->default(false)->after('is_active');
|
||||
$table->unsignedBigInteger('last_app_lock_rev')->default(0)->after('is_locked');
|
||||
$table->unsignedBigInteger('last_app_expiry_rev')->default(0)->after('last_app_lock_rev');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('machine_slots', function (Blueprint $table) {
|
||||
$table->dropColumn(['is_locked', 'last_app_lock_rev', 'last_app_expiry_rev']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 為公司層級的「管理員」角色加入明確的權威旗標 is_company_admin,
|
||||
* 取代過去靠中文 name='管理員' 來辨識主帳號角色的脆弱作法。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasColumn('roles', 'is_company_admin')) {
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->boolean('is_company_admin')->default(false)->after('is_system');
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill:既有的公司層級「管理員」角色標記為 is_company_admin = true
|
||||
DB::table('roles')
|
||||
->whereNotNull('company_id')
|
||||
->where('name', '管理員')
|
||||
->update(['is_company_admin' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('roles', 'is_company_admin')) {
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->dropColumn('is_company_admin');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 帳號樹狀層級化:加入 parent_id(建立者)與 level(主帳號=0)。
|
||||
* 並對既有資料做兩步 backfill:
|
||||
* 1. is_admin 正規化:每家「有帳號但無主帳號」的公司,將最早建立的帳號設為主帳號;多主帳號則只保留最早一個。
|
||||
* 2. 回填 parent_id / level:主帳號 level=0、parent_id=null;其餘帳號掛在該公司主帳號下、level=1。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('parent_id')->nullable()->after('company_id');
|
||||
$table->unsignedTinyInteger('level')->default(0)->after('parent_id');
|
||||
$table->foreign('parent_id')->references('id')->on('users')->nullOnDelete();
|
||||
$table->index('parent_id');
|
||||
});
|
||||
|
||||
$companyIds = DB::table('companies')->pluck('id');
|
||||
|
||||
// 1. is_admin 正規化
|
||||
foreach ($companyIds as $cid) {
|
||||
// a. 軟刪除帳號一律不得為主帳號
|
||||
DB::table('users')
|
||||
->where('company_id', $cid)
|
||||
->whereNotNull('deleted_at')
|
||||
->where('is_admin', true)
|
||||
->update(['is_admin' => false]);
|
||||
|
||||
// b. 在「未軟刪除」帳號中確保恰有一個主帳號
|
||||
$admins = DB::table('users')
|
||||
->where('company_id', $cid)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_admin', true)
|
||||
->orderBy('id')
|
||||
->pluck('id');
|
||||
|
||||
if ($admins->isEmpty()) {
|
||||
// 無主帳號 → 將最早建立的帳號補為主帳號
|
||||
$firstId = DB::table('users')
|
||||
->where('company_id', $cid)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->value('id');
|
||||
if ($firstId) {
|
||||
DB::table('users')->where('id', $firstId)->update(['is_admin' => true]);
|
||||
}
|
||||
} elseif ($admins->count() > 1) {
|
||||
// 多主帳號 → 只保留最早一個,其餘降為一般帳號
|
||||
DB::table('users')
|
||||
->whereIn('id', $admins->slice(1)->values())
|
||||
->update(['is_admin' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 回填 parent_id / level
|
||||
foreach ($companyIds as $cid) {
|
||||
// 主帳號必須取自「未軟刪除」帳號
|
||||
$mainId = DB::table('users')
|
||||
->where('company_id', $cid)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_admin', true)
|
||||
->value('id');
|
||||
|
||||
if (!$mainId) {
|
||||
continue; // 空公司,略過
|
||||
}
|
||||
|
||||
// 主帳號
|
||||
DB::table('users')->where('id', $mainId)->update(['parent_id' => null, 'level' => 0]);
|
||||
// 其餘帳號(含軟刪除)掛在主帳號下,level=1
|
||||
DB::table('users')
|
||||
->where('company_id', $cid)
|
||||
->where('id', '!=', $mainId)
|
||||
->update(['parent_id' => $mainId, 'level' => 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 防禦式:外鍵 / 索引 / 欄位可能因 DDL 半途失敗而部分不存在,逐項忽略不存在的情況。
|
||||
try {
|
||||
Schema::table('users', fn (Blueprint $t) => $t->dropForeign(['parent_id']));
|
||||
} catch (\Throwable $e) {
|
||||
// 外鍵已不存在,略過
|
||||
}
|
||||
try {
|
||||
Schema::table('users', fn (Blueprint $t) => $t->dropIndex('users_parent_id_index'));
|
||||
} catch (\Throwable $e) {
|
||||
// 索引已不存在,略過
|
||||
}
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$cols = array_values(array_filter(
|
||||
['parent_id', 'level'],
|
||||
fn ($c) => Schema::hasColumn('users', $c)
|
||||
));
|
||||
if ($cols) {
|
||||
$table->dropColumn($cols);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 角色樹狀層級化:加入 created_by(建立者帳號)。
|
||||
* Backfill:既有公司層級角色 created_by 指向該公司主帳號;全域系統角色維持 null。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('created_by')->nullable()->after('company_id');
|
||||
$table->foreign('created_by')->references('id')->on('users')->nullOnDelete();
|
||||
$table->index('created_by');
|
||||
});
|
||||
|
||||
// 既有公司角色歸屬該公司主帳號(未軟刪除的 is_admin)
|
||||
$companyIds = DB::table('roles')->whereNotNull('company_id')->distinct()->pluck('company_id');
|
||||
foreach ($companyIds as $cid) {
|
||||
$mainId = DB::table('users')
|
||||
->where('company_id', $cid)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_admin', true)
|
||||
->value('id');
|
||||
if ($mainId) {
|
||||
DB::table('roles')
|
||||
->where('company_id', $cid)
|
||||
->whereNull('created_by')
|
||||
->update(['created_by' => $mainId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
try {
|
||||
Schema::table('roles', fn (Blueprint $t) => $t->dropForeign(['created_by']));
|
||||
} catch (\Throwable $e) {
|
||||
// 外鍵已不存在,略過
|
||||
}
|
||||
try {
|
||||
Schema::table('roles', fn (Blueprint $t) => $t->dropIndex('roles_created_by_index'));
|
||||
} catch (\Throwable $e) {
|
||||
// 索引已不存在,略過
|
||||
}
|
||||
if (Schema::hasColumn('roles', 'created_by')) {
|
||||
Schema::table('roles', fn (Blueprint $t) => $t->dropColumn('created_by'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('pickup_codes', function (Blueprint $table) {
|
||||
// 取貨碼改綁「商品」而非「貨道」:發碼時只指定機台+商品,
|
||||
// 刷碼當下由 App 依本機即時庫存/鎖定/效期挑可出貨道(slot_no 對綁商品碼為 null)。
|
||||
// 既有綁貨道碼(slot_no 有值、product_id 為 null)行為不變,向後相容。
|
||||
$table->unsignedBigInteger('product_id')->nullable()->after('machine_id')
|
||||
->comment('綁定商品(取貨碼改綁商品時使用;貨道由 App 刷碼當下挑)');
|
||||
$table->index('product_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('pickup_codes', function (Blueprint $table) {
|
||||
$table->dropIndex(['product_id']);
|
||||
$table->dropColumn('product_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user