Compare commits

...

153 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

備註:
- migration change_products_spec_to_text 僅加 sqlite 守衛,demo 已執行過不會重跑、無實際影響。
- 新增 machine_logs 索引 migration 於 demo 部署執行;正式站資料量大建議離峰。
2026-06-18 13:49:05 +08:00
sky121113
1ec149a624 [PROMOTE] dev → demo:發票列印限縮統編 + 銷售中心 RWD 調整
1. [FIX] 電子發票列印限縮為僅統編發票,避免載具/捐贈列印失敗(69a1d02)
   - printInvoice() 守衛與前端列印鈕改用 metadata['business_tax_id'] 判定。
   - 非統編發票顯示「此發票無可列印紙本」。
   - 新增兩組三語系 key。
2. [STYLE] 銷售中心與 admin header 手機版響應式調整(840052d)
   - 三個 Tab 容器圓角/內距行動優先、搜尋欄手機滿版。
   - admin layout 防水平溢出與行動版 header 間距調整。

備註:本次無 migration 變更。
2026-06-18 11:42:10 +08:00
sky121113
f8a56f2b4e [PROMOTE] dev → demo:交易訂單與發票列表備註欄位晉升驗收
1. [FEAT] 交易訂單與電子發票列表新增可編輯備註欄位(ae4c9fb)
   - 新增 x-remark-cell 共用元件,列表內嵌備註編輯(AJAX PATCH + toast,不重整)。
   - 交易訂單列表(桌面+手機)新增備註欄,接 order.remark。
   - 電子發票列表(桌面+手機)新增備註欄,接 invoice.remark。
   - 列表與訂單詳情面板共用同一 remark 欄位。
   - 新增三語系 key「Add Remark」並對齊排序。

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

備註:本次含新增 migration(orders.remark、invoices.remark,皆 nullable,非破壞性),demo 部署將自動套用。
2026-06-18 10:53:53 +08:00
sky121113
f01bffecd9 [RELEASE] demo → main:OTA 更新進度 ACK 處理修復上線
1. [FIX] OTA 更新進度 ACK 不應視為指令終態:update_app 指令收到 result="progress" 時維持 pending,僅將進度訊息寫入 note,避免 APK 下載/安裝過程被誤標為完成、與機台實際版本不符。
2026-06-17 20:03:17 +08:00
sky121113
3dc5bd6ee1 [PROMOTE] dev → demo:OTA 更新進度 ACK 處理修復
1. [FIX] OTA 更新進度 ACK 不應視為指令終態:update_app 指令收到 result="progress" 時維持 pending,僅將進度訊息寫入 note,避免下載過程被誤標為完成。
2026-06-17 20:00:45 +08:00
sky121113
159fa38fc0 [RELEASE] demo → main 正式上線:遠端管理租戶 500 修復與電子發票調整
1. [FIX] 遠端管理歷史紀錄套用機台授權過濾(whereHas('machine')),修復租戶帳號進入遠端管理與庫存歷史時 machine 為 null 導致的 500,並對 Blade 加上 null-safe 防禦;系統管理員不受影響。
2. [FIX] 開立電子發票前檢查機台電子發票開關(tax_invoice_enabled),未開啟者不開立。
3. [STYLE] 發票操作按鈕配置調整,作廢確認框改用自製 UI。
2026-06-17 11:17:38 +08:00
sky121113
338bd2914f [PROMOTE] dev → demo:遠端管理租戶 500 修復
1. [FIX] 遠端管理歷史紀錄套用機台授權過濾(whereHas('machine')),修復租戶帳號(晟崴/中國醫)進入遠端管理與庫存歷史 tab 時因 machine 關聯為 null 導致的 500 錯誤;系統管理員不受影響仍可看全部。
2026-06-17 11:15:55 +08:00
sky121113
04195caf9b [PROMOTE] dev 晉升至 demo:電子發票開關閘門與發票操作 UI 調整
1. [FIX] 開立電子發票前檢查機台電子發票開關(tax_invoice_enabled),關閉時略過開立並記 log。
2. [STYLE] 發票操作按鈕配置調整,作廢確認框改用自製 UI。
2026-06-16 16:30:59 +08:00
sky121113
83015b841b [RELEASE] 晉升 demo 至正式 main:商品匯入圖片、電子發票、多語系與支付類型
1. 商品匯入支援圖片:Excel 圖片檔名欄 + 圖片壓縮檔上傳,並改為全背景處理避免請求逾時。
2. 電子發票後台開立/對帳/補開/作廢流程,訂單 pending 狀態轉移與逾時交易自動清除。
3. 商品與機台多語系架構,商品詳情多行排版與欄位標題調整。
4. 新增 B016 機台系統設定回寫端點、B000 登入回應 identity 身分欄位。
5. 新增手機支付與 LINE Pay 官方直連支付類型,銷售掃碼支付方式細分(玉山/TayPay)。
6. 多項修正:取貨碼有效期限、訪客取貨 QR Code 公開路由、商品分類頁 Alpine 範圍、捐贈發票排除列印等。
7. 含 4 個資料庫遷移(spec 字典鍵、機台顯示語系回填、spec 改 TEXT、發票對帳狀態欄位)。
2026-06-16 14:54:14 +08:00
sky121113
33d3eba998 [PROMOTE] dev → demo:商品匯入改為全背景處理
1. [REFACTOR] 商品匯入改為全背景處理:Controller 存檔後派發 ProcessProductImportJob 並立即回應,背景一條龍完成商品與圖片匯入,避免大批匯入受 Cloudflare 100 秒等請求逾時限制;結果寫入操作日誌查詢。
2026-06-16 13:58:43 +08:00
sky121113
db0099f8c9 [PROMOTE] dev → demo:商品匯入圖片功能與銷售發票調整
1. [FEAT] 商品匯入支援圖片:Excel 新增「圖片檔名」欄,搭配圖片壓縮檔(上限 60MB)一併上傳,背景 Job 轉 WebP 入庫;含安全解壓防護與 retry_after 調整。
2. [FIX] 銷售掃碼支付方式細分為玉山/TayPay,並調整訂單詳情發票操作按鈕與相關分頁顯示。
2026-06-16 13:44:50 +08:00
sky121113
372f2fc55d [PROMOTE] 晉升捐贈發票列印修正至 demo
1. 捐贈發票排除列印按鈕並補上不可列印提示,後端加守衛防偽造 POST。
2026-06-16 11:41:56 +08:00
sky121113
8256fcb623 [PROMOTE] dev -> demo:綠界電子發票設定條件式必填驗證
1. [FEAT] 綠界電子發票設定整組選填一旦啟用即全欄必填:store/update 加入 required_with 群組驗證、email 格式驗證、三語系訊息與表單動態必填提示。
2026-06-16 10:42:40 +08:00
sky121113
c0f6cb5ab6 [PROMOTE] 晉升銷售與發票模組修正至 demo
1. 修正訂單詳情發票狀態勾稽:徽章改為動態讀取狀態,新增列印發票功能並接上作廢按鈕。
2. 補齊銷售紀錄訂單狀態多語系並調整電子票證顯示。
2026-06-16 10:00:39 +08:00
sky121113
a32572facb [PROMOTE] 晉升 dev 變更至 demo 環境
1. 實作訂單 pending 狀態轉移與逾時交易自動清除機制。
2. 實作電子發票後台開立、對帳、補開與作廢流程。
2026-06-16 08:52:49 +08:00
sky121113
b8135acca9 [PROMOTE] 晉升手機支付與 LINE Pay 官方直連支付類型至 Demo
1. [FEAT] 新增支付碼 10(手機支付/NFC 行動支付)與 70(LINE Pay 官方直連),同步更新 Order.php、api-docs.php、api-technical-specs 規格與三語系。
2026-06-15 14:02:03 +08:00
sky121113
a44b981059 [PROMOTE] dev → demo:商品詳情多行排版
1. [FEAT] 商品詳情支援多行排版並原樣下發 APP(textarea 輸入、放寬字數上限至 2000、products.spec 改 TEXT)。
2026-06-15 11:44:22 +08:00
sky121113
d84b2de1b7 [PROMOTE] dev → demo:商品多語系欄位標題改為「詳情」
1. [STYLE] 商品建立/編輯頁多語系區塊標題由「規格(多語系)」改為「詳情(多語系)」,三語系 JSON 同步換 key (Details (Multilingual));底層 spec 欄位與 B012 t060v03 不變。
2026-06-15 11:06:15 +08:00
sky121113
7a97792b67 [PROMOTE] dev → demo:顯示語系選中態樣式調整
1. [STYLE] 機台系統設定彈窗「顯示語系」勾選按鈕與商品多語系 Tab:選中態由整顆深底反白改為淡 cyan 強調 (bg-cyan-500/10 + cyan 邊框/文字),降低對比、與 toggle 開關一致。
2026-06-15 09:31:17 +08:00
sky121113
6fe2ef4349 [PROMOTE] dev → demo:商品與機台多語系架構
1. [FEAT] 商品與機台多語系架構:config/locales.php 11 種語系白名單、公司機台語系聯集 (Company::activeLocalesFor)、商品名稱與規格多語系 (translations + spec_dictionary_key)、B012 新增 t060v01_i18n/t060v03_i18n (保留舊欄相容)、B014 新增 LangSet、機台設定顯示語系勾選 UI、商品編輯多語 Tab。
2. [FEAT] 系統設定同步:sync-settings 路由與 update_settings 指令類型、遠端歷史標籤、機台設定頁同步按鈕、B016 回寫測試。
3. [FIX] 修正多語系升級導致既有環境商品編輯器只剩繁中 Tab:回填 migration 補機台 languages=[zh_TW,en,ja]、商品編輯器 Tab 併入已有翻譯語系。
4. [DOCS] 同步更新 product_multilingual_spec 規格書、api-technical-specs SKILL、config/api-docs。
2026-06-15 09:10:07 +08:00
sky121113
e0c4da0933 [PROMOTE] dev → demo:補齊 B690 來店禮錯誤回應文件
1. [DOCS] B690 來店禮驗證新增 400 / 404 / 429 錯誤回應 (SKILL.md、api-docs.php、api-docs.blade.php)。
2026-06-11 15:03:17 +08:00
sky121113
a969d2c4cc [PROMOTE] dev → demo 晉升取貨碼有效期限修正
1. [FIX] 修正取貨碼有效期限固定為 24 小時的問題:時數模式與自訂日期模式互斥停用未使用欄位,避免隱藏的 expires_at 預填值覆蓋使用者設定的時數。
2026-06-11 14:28:38 +08:00
sky121113
4e5f952f28 [PROMOTE] dev → demo:B016 設定回寫與 B000 identity
1. [FEAT] B016 機台系統設定回寫端點:系統方回寫 machines.settings 與貨道欄位,含路由、SKILL.md §3.8 與 api-docs.php 文件
2. [FEAT] B000 登入回應新增 identity 身分欄位 (system/company/staff),並補 MachineAuthB000Test 共 5 案例

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:23:35 +08:00
sky121113
a1695789dd [PROMOTE] dev → demo:修正商品分類頁 Alpine 範圍崩壞
1. [FIX] 移除 tab-categories.blade.php 多餘的 </div>,修復因 x-data 範圍提前關閉導致操作紀錄 tab、編輯分類 modal、匯入 modal 失效的問題。
2026-06-10 16:14:04 +08:00
sky121113
cfa824f905 [PROMOTE] dev → demo:APK 機台風味欄位自由輸入
1. [FEAT] APK 版本建立頁「機台風味」欄位改為自由輸入 (text input + datalist 建議值),新增必填驗證、檔名解析填入調整,並同步 zh_TW / en / ja 三語系。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:12:06 +08:00
sky121113
1a004a4d08 [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FEAT] 金流配置與機台系統設定整合進 B014,補完機台端設定下發(DevSet/CashSet/FunctionSet/ShoppingMode/OperationSet/HardwareSet、Line官方支付、卡片支付細項簡化、編輯機台區塊調整、API 文件同步)。
2. [FIX] 訪客取貨/通行頁 QR Code 改用公開路由,修正手機開啟失效。
3. [DOCS] 新增 CLAUDE.md 共用專案開發規範。
4. [CHORE] 將 Claude Code 個人設定排除於版控外。
5. [REFACTOR] 機台系統設定 settings JSON 化與編輯介面多語系/UI 優化。
2026-06-10 13:04:30 +08:00
sky121113
fa50d39020 [RELEASE] 晉升 demo 變更至正式 main 環境
1. 晉升補貨單完成與機台庫存去耦邏輯,避免因先上報後點確認造成的重複累加 Bug,並防範時間差空轉出貨之客訴風險。
2026-06-09 14:41:03 +08:00
sky121113
4e0f4dd747 [PROMOTE] 晉升 dev 變更至 demo 環境
1. [FIX] 補貨單完成邏輯與機台庫存去耦,防範重複累加 Bug
2. [FIX] 熱修復 Flatpickr 初始化中 Blade 大括號內不必要的反斜線轉義
2026-06-09 13:54:42 +08:00
86 changed files with 6682 additions and 248 deletions

View File

@ -95,6 +95,7 @@ class AnalysisController extends Controller
7 => __('Welcome Gift'),
8 => __('Questionnaire'),
9 => __('Coin Acceptor'),
10 => __('Mobile Pay'), // NFC 手機感應(手機支付)
11 => __('QR Code Payment - TayPay'), // 掃碼支付-TayPay
41 => __('Staff Card'),
42 => __('Pickup Voucher'),

View File

@ -75,8 +75,10 @@ class MachineSettingController extends AdminController
break;
case 'permissions':
// 套用層級可見範圍:系統管理員看全部;租戶僅看可見範圍內帳號(避免跨租戶洩漏)。
$userQuery = \App\Models\System\User::query()
->where('is_admin', true)
->visibleTo($currentUser)
->with(['company', 'machines']);
if ($search) {
$userQuery->where(function($q) use ($search) {
@ -121,6 +123,7 @@ 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();
@ -364,7 +367,11 @@ class MachineSettingController extends AdminController
'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) {
@ -405,6 +412,9 @@ class MachineSettingController extends AdminController
$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;
@ -428,12 +438,20 @@ class MachineSettingController extends AdminController
$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 亦一律忽略(不報錯,讓其仍可儲存其他設定)。

View File

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

View File

@ -2,10 +2,15 @@
namespace App\Http\Controllers\Admin;
use App\Jobs\Product\SendProductSyncCommandJob;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineProductPrice;
use App\Models\Product\Product;
use App\Models\System\Company;
use App\Services\Machine\MachineService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class MachineController extends AdminController
@ -203,9 +208,12 @@ class MachineController extends AdminController
->get();
// 取得該機台目前所有等待中的庫存更新指令 (Pending Commands)
// 僅將「1 分鐘內」的 pending 視為鎖定中;超過 1 分鐘未收到機台回傳 (ACK)
// 視為逾時,自動解除鎖定,讓使用者可重新編輯與重新下發 (與 dispense 逾時邏輯一致)
$pendingSlotNos = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->where('status', 'pending')
->where('created_at', '>=', now()->subMinute())
->get()
->pluck('payload.slot_no')
->flatten()
@ -257,6 +265,117 @@ class MachineController extends AdminController
}
}
/**
* AJAX: 遠端鎖定/解鎖單一貨道(暫停販售)。
* updateSlotExpiry 共用 updateSlot 下發 update_inventory只帶 is_locked不動效期/庫存。
*/
public function toggleSlotLock(Request $request, Machine $machine)
{
$validated = $request->validate([
'slot_no' => 'required|integer',
'is_locked' => 'required|boolean',
]);
try {
$this->machineService->updateSlot($machine, $validated, auth()->id());
return response()->json([
'success' => true,
'message' => $validated['is_locked'] ? __('Slot locked.') : __('Slot unlocked.'),
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 422);
}
}
/**
* 機台專屬定價頁:列出該機台「所屬公司的完整商品目錄」(非僅貨道商品),
* 可在上貨前先把機台價/會員價訂好。未填=沿用全域 products 價。
*/
public function pricing(Machine $machine): View
{
$products = Product::where('company_id', $machine->company_id)
->active()
->orderBy('name')
->get(['id', 'name', 'image_url', 'barcode', 'price', 'member_price']);
$overrides = $machine->productPrices()->get()->keyBy('product_id');
$items = $products->map(function (Product $p) use ($overrides) {
$ov = $overrides->get($p->id);
return [
'product_id' => $p->id,
'name' => $p->name,
'image_url' => $p->image_url,
'barcode' => $p->barcode,
'global_price' => (float) $p->price,
'global_member_price' => $p->member_price !== null ? (float) $p->member_price : null,
'override_price' => $ov && $ov->price !== null ? (float) $ov->price : null,
'override_member_price' => $ov && $ov->member_price !== null ? (float) $ov->member_price : null,
];
})->values();
return view('admin.machines.pricing', compact('machine', 'items'));
}
/**
* 批次儲存機台專屬定價。
*
* - price/member_price 皆空 刪除覆蓋,回到全域價。
* - 任一有值 建立/更新覆蓋;會員價於 B012 下發時自動夾為不高於機台售價。
* - quiet 寫入避免逐筆觸發 observer 重複推送,最後統一推一次 B012。
* - 商品須屬於該機台公司(跨租戶防護)。
*/
public function updatePricing(Request $request, Machine $machine): \Illuminate\Http\JsonResponse
{
$validated = $request->validate([
'items' => 'required|array',
'items.*.product_id' => 'required|integer|exists:products,id',
'items.*.price' => 'nullable|numeric|min:1|max:99999999',
'items.*.member_price' => 'nullable|numeric|min:1|max:99999999',
]);
$allowedProductIds = Product::where('company_id', $machine->company_id)
->pluck('id')
->flip();
DB::transaction(function () use ($validated, $machine, $allowedProductIds) {
foreach ($validated['items'] as $item) {
$productId = (int) $item['product_id'];
if (!$allowedProductIds->has($productId)) {
continue;
}
$price = isset($item['price']) && $item['price'] !== '' ? (float) $item['price'] : null;
$memberPrice = isset($item['member_price']) && $item['member_price'] !== '' ? (float) $item['member_price'] : null;
if ($price === null && $memberPrice === null) {
$machine->productPrices()->where('product_id', $productId)->delete();
continue;
}
$row = MachineProductPrice::firstOrNew([
'machine_id' => $machine->id,
'product_id' => $productId,
]);
$row->price = $price;
$row->member_price = $memberPrice;
$row->saveQuietly();
}
});
// 統一推一次 B012 同步給該機台(覆蓋於請求當下即時套用,無需失效公司快取)
SendProductSyncCommandJob::dispatch($machine->id, __('Machine pricing updated'), Auth::id());
return response()->json([
'success' => true,
'message' => __('Machine pricing saved and sync command pushed.'),
]);
}
/**
* 取得機台統計數據 (AJAX)
*/

View File

@ -14,10 +14,8 @@ class PermissionController extends Controller
$user = auth()->user();
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
// 租戶隔離:租戶只能看到自己公司的角色
if (!$user->isSystemAdmin()) {
$query->where('company_id', $user->company_id);
}
// 層級隔離:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己建立的角色。
$query->visibleTo($user);
// 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search
$search = request()->input('roles_search', request()->input('search'));
@ -88,6 +86,11 @@ class PermissionController extends Controller
$role = \App\Models\System\Role::findOrFail($id);
$user = auth()->user();
// 層級隔離:子帳號只能編輯自己建立的角色,不可開啟上層/旁線建立的角色。
if (!$role->canBeManagedBy($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
// 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理
$all_permissions = $this->getFilteredPermissions($user);
@ -126,6 +129,7 @@ class PermissionController extends Controller
'name' => $validated['name'],
'guard_name' => 'web',
'company_id' => $is_system ? null : auth()->user()->company_id,
'created_by' => auth()->id(),
'is_system' => $is_system,
]);
@ -182,6 +186,11 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('System roles cannot be modified by tenant administrators.'));
}
// 層級隔離:子帳號只能修改自己建立的角色。
if (!$role->canBeManagedBy(auth()->user())) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
$is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
$updateData = [
@ -223,10 +232,20 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('The Super Admin role cannot be deleted.'));
}
// 公司主帳號角色保護:比照 super-admin公司層級的「管理員」角色為該公司最高權限角色不可刪除。
if ($role->is_company_admin) {
return redirect()->back()->with('error', __('The company admin role cannot be deleted.'));
}
if (!auth()->user()->isSystemAdmin() && $role->is_system) {
return redirect()->back()->with('error', __('System roles cannot be deleted by tenant administrators.'));
}
// 層級隔離:子帳號只能刪除自己建立的角色。
if (!$role->canBeManagedBy(auth()->user())) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
if ($role->users()->count() > 0) {
return redirect()->back()->with('error', __('Cannot delete role with active users.'));
}
@ -251,10 +270,8 @@ class PermissionController extends Controller
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
// ── 帳號列表 ──────────────────────────────────────────────────
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
if (!$user->isSystemAdmin()) {
$usersQuery->where('company_id', $user->company_id);
}
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines'])->visibleTo($user);
if ($search = $request->input('accounts_search', $request->input('search'))) {
$usersQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
@ -269,22 +286,24 @@ class PermissionController extends Controller
$accounts_per_page = $request->input('accounts_per_page', 10);
$users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString();
// Modal 用的角色選單(不分頁)
$rolesForSelect = \App\Models\System\Role::query();
if (!$user->isSystemAdmin()) {
$rolesForSelect->forCompany($user->company_id);
}
// Modal 用的角色選單(不分頁)。層級隔離:子帳號僅見自己建立的角色(與角色清單一致)。
// 預先載入 permissions 以避免下方子集過濾造成 N+1。
$rolesForSelect = \App\Models\System\Role::query()->with('permissions')->visibleTo($user);
$roles = $rolesForSelect->get();
// 子集過濾:租戶只能指派「權限為自身子集」的角色(與 store/update 的後端 guard 一致,避免下拉出現越權選項)。
if (!$user->isSystemAdmin()) {
$operatorPerms = $user->getAllPermissions()->pluck('name');
$roles = $roles->filter(
fn($r) => collect($r->getPermissionNames())->diff($operatorPerms)->isEmpty()
)->values();
}
// ── 角色列表 (僅 data-config Tab 版需要) ─────────────────────
$paginated_roles = collect();
$all_permissions = collect();
if ($isSubAccountRoute) {
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
if (!$user->isSystemAdmin()) {
$rolesQuery->where('company_id', $user->company_id);
}
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user);
if ($search = $request->input('roles_search')) {
$rolesQuery->where('name', 'like', "%{$search}%");
}
@ -328,6 +347,12 @@ class PermissionController extends Controller
'phone' => 'nullable|string|max:20',
]);
// 深度上限level >= MAX_LEVEL 的子帳號不可再建立下層(系統管理員不受限)。
// 此為後端硬擋,獨立於權限判斷(即使持有子帳號管理權限也不得突破層級)。
if (!auth()->user()->canCreateSubAccount()) {
return redirect()->back()->with('error', __('Sub-accounts at this level cannot create further sub-accounts.'));
}
$company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本
@ -374,6 +399,7 @@ class PermissionController extends Controller
'guard_name' => 'web',
'company_id' => $company_id,
'is_system' => false,
'is_company_admin' => true,
]);
$newRole->syncPermissions($role->getPermissionNames());
$role = $newRole;
@ -383,6 +409,39 @@ class PermissionController extends Controller
}
}
// 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免透過指派高權限角色造成權限提升。
if (!auth()->user()->isSystemAdmin()) {
$operatorPerms = auth()->user()->getAllPermissions()->pluck('name');
if (collect($role->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) {
return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.'));
}
}
// 主帳號唯一性:一家公司只在「尚無主帳號」時,由系統管理員建立的第一個帳號自動成為主帳號 (is_admin)。
// 之後建立的帳號(含子帳號自建)一律 is_admin=false確保每家公司恰有一個主帳號。
$creator = auth()->user();
$companyHasAdmin = $company_id
? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->exists()
: false;
$isMainAccount = $creator->isSystemAdmin() && !empty($company_id) && !$companyHasAdmin;
// 樹狀層級歸屬:
// - 主帳號level 0、parent_id null。
// - 系統管理員建立的非主帳號掛在該公司主帳號下、level 1。
// - 租戶自建掛在建立者下、level = 建立者 level + 1。
if ($isMainAccount) {
$parentId = null;
$level = 0;
} elseif ($creator->isSystemAdmin()) {
$parentId = $company_id
? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->value('id')
: null;
$level = $parentId ? 1 : 0;
} else {
$parentId = $creator->id;
$level = $creator->level + 1;
}
$user = \App\Models\System\User::create([
'name' => $validated['name'],
'username' => $validated['username'],
@ -390,8 +449,10 @@ class PermissionController extends Controller
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
'status' => $validated['status'],
'company_id' => $company_id,
'parent_id' => $parentId,
'level' => $level,
'phone' => $validated['phone'] ?? null,
'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])),
'is_admin' => $isMainAccount,
]);
$user->assignRole($role);
@ -410,6 +471,11 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('System super admin accounts can only be modified by other super admins.'));
}
// 層級越權防護:租戶只能管理可管轄範圍內的帳號(主帳號=同公司全部;子帳號=自己直接建立的下層)。
if (!auth()->user()->canManageAccount($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this account.'));
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'username' => 'required|string|max:255|unique:users,username,' . $id,
@ -500,7 +566,7 @@ class PermissionController extends Controller
'guard_name' => 'web',
'company_id' => $target_company_id,
'is_system' => false,
'is_admin' => true,
'is_company_admin' => true,
]);
$newRole->syncPermissions($roleObj->getPermissionNames());
$roleObj = $newRole;
@ -509,6 +575,14 @@ class PermissionController extends Controller
}
}
// 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免權限提升。
if (!auth()->user()->isSystemAdmin()) {
$operatorPerms = auth()->user()->getAllPermissions()->pluck('name');
if (collect($roleObj->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) {
return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.'));
}
}
$user->update($updateData);
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
@ -527,15 +601,31 @@ class PermissionController extends Controller
public function destroyAccount($id)
{
$user = \App\Models\System\User::findOrFail($id);
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
return redirect()->back()->with('error', __('System super admin accounts can only be deleted by other super admins.'));
}
// 層級越權防護:租戶只能刪除可管轄範圍內的帳號。
if (!auth()->user()->canManageAccount($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this account.'));
}
if ($user->id === auth()->id()) {
return redirect()->back()->with('error', __('You cannot delete your own account.'));
}
// 主帳號保護:若該帳號是公司唯一主帳號,且公司仍有其他帳號,禁止刪除(須先轉移主帳號身分),
// 以維持「有帳號的公司恰有一個主帳號」的不變量。公司僅剩此主帳號時允許刪除(公司歸零)。
if ($user->is_admin && $user->company_id) {
$hasOtherAccounts = \App\Models\System\User::where('company_id', $user->company_id)
->where('id', '!=', $user->id)
->exists();
if ($hasOtherAccounts) {
return redirect()->back()->with('error', __('Cannot delete the company main account while other accounts exist. Please transfer the main account role first.'));
}
}
// 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重命名唯一欄位
$timestamp = now()->getTimestamp();
$user->username = $user->username . '.deleted.' . $timestamp;
@ -556,6 +646,11 @@ class PermissionController extends Controller
return back()->with('error', __('Only Super Admins can change other Super Admin status.'));
}
// 層級越權防護:租戶只能切換可管轄範圍內的帳號狀態。
if (!auth()->user()->canManageAccount($user)) {
return back()->with('error', __('You do not have permission to manage this account.'));
}
$user->status = $user->status ? 0 : 1;
$user->save();

View File

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

View File

@ -584,6 +584,136 @@ class ProductController extends Controller
}
}
/**
* 匯出商品資料CSV / Excel套用與 index 相同的篩選(搜尋/分類/公司)。
* 參考 SalesController::handleExport 的串流下載模式;欄位順序對齊匯入範本,方便編輯後回匯入。
*/
public function export(Request $request)
{
$user = auth()->user();
$type = $request->input('export', 'csv');
$isExcel = ($type === 'excel');
$ext = $isExcel ? 'xls' : 'csv';
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
// 與 index 相同的商品查詢與篩選(租戶隔離沿用 Product 全域 scope
$query = Product::with(['category.translations', 'translations', 'company']);
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('spec', 'like', "%{$search}%");
});
}
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
if ($user->isSystemAdmin() && $request->filled('product_company_id')) {
$query->where('company_id', $request->product_company_id);
}
// 欄位順序對齊匯入範本ProductImportService::downloadTemplate
$headers = [
'分類',
'商品名稱(zh_TW)',
'商品名稱(en)',
'商品名稱(ja)',
'條碼',
'規格',
'生產公司',
'售價',
'會員價',
'成本',
'履帶貨道上限',
'彈簧貨道上限',
'物料代碼',
'全額點數',
'半額點數',
'半額折抵金額',
'是否啟用',
'圖片檔名',
];
$filename = '商品資料_' . now()->format('YmdHis') . '.' . $ext;
$callback = function () use ($query, $headers, $isExcel) {
$file = fopen('php://output', 'w');
if ($isExcel) {
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
fwrite($file, '<table><thead><tr>');
foreach ($headers as $header) {
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
}
fwrite($file, '</tr></thead><tbody>');
} else {
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOMExcel 開啟不亂碼
fputcsv($file, $headers);
}
$locale = app()->getLocale();
$query->orderBy('id')->chunk(500, function ($products) use ($file, $isExcel, $locale) {
foreach ($products as $p) {
$meta = is_array($p->metadata) ? $p->metadata : [];
$nameEn = optional($p->translations->firstWhere('locale', 'en'))->value;
$nameJa = optional($p->translations->firstWhere('locale', 'ja'))->value;
$categoryName = '';
if ($p->category) {
$catTrans = $p->category->translations->firstWhere('locale', $locale)
?? $p->category->translations->firstWhere('locale', 'zh_TW');
$categoryName = $catTrans->value ?? ($p->category->name ?? '');
}
$imageFilename = $p->image_url ? basename($p->image_url) : '';
$row = [
$categoryName,
$p->name,
$nameEn,
$nameJa,
$p->barcode,
$p->spec,
$p->manufacturer,
$p->price,
$p->member_price,
$p->cost,
$p->track_limit,
$p->spring_limit,
$meta['material_code'] ?? '',
$meta['points_full'] ?? 0,
$meta['points_half'] ?? 0,
$meta['points_half_amount'] ?? 0,
$p->is_active ? 1 : 0,
$imageFilename,
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $cell) {
fwrite($file, '<td>' . htmlspecialchars((string) $cell) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
}
public function downloadTemplate(ProductImportService $importService)
{
return $importService->downloadTemplate();

View File

@ -142,7 +142,7 @@ class RemoteController extends Controller
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'command_type' => 'required|string|in:reboot,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto',
'command_type' => 'required|string|in:reboot,reboot_force,reboot_card,checkout,lock,unlock,change,dispense,fanon,fanoff,fanauto',
'amount' => 'nullable|integer|min:0',
'slot_no' => 'nullable|string',
'note' => 'nullable|string|max:255',

View File

@ -19,6 +19,7 @@ use App\Models\Machine\Machine;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\System\SystemOperationLog;
@ -34,7 +35,10 @@ class SalesController extends Controller
$search = $request->input('search');
$machineId = $request->input('machine_id');
$paymentType = $request->input('payment_type');
$status = $request->input('status');
// 付款狀態預設只看「已完成」:避免客戶把 failed(付款失敗/逾時) 誤會成刷卡失敗。
// 「所有付款狀態」為明確選項 status=all非空值AJAX 才不會當空值丟掉)。
$status = $request->input('status', 'completed');
$deliveryStatus = $request->input('delivery_status');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
@ -55,6 +59,7 @@ class SalesController extends Controller
'machine_id' => $machineId,
'payment_type' => $paymentType,
'status' => $status,
'delivery_status' => $deliveryStatus,
'start_date' => $request->input('start_date'), // 保留原始輸入以顯示於 UI (如果是隱含的則 UI 顯示空)
'end_date' => $request->input('end_date'),
],
@ -83,6 +88,16 @@ class SalesController extends Controller
$invoicesQuery->whereBetween('created_at', [$start, $end]);
$dispenseQuery->whereBetween('machine_time', [$start, $end]);
// 機台權限隔離:非系統管理員只能看到「自己可存取機台」的交易。沿用 Machine 的 machine_access 全域 scope
// 取得可存取機台集合(與上方機台下拉清單一致),修正訂單/發票/出貨清單先前未依機台授權過濾、
// 導致無該機台權限的帳號仍可見其交易紀錄的資料隔離漏洞。
if (!auth()->user()->isSystemAdmin()) {
$accessibleMachineIds = Machine::pluck('id');
$ordersQuery->whereIn('machine_id', $accessibleMachineIds);
$invoicesQuery->whereIn('machine_id', $accessibleMachineIds);
$dispenseQuery->whereIn('machine_id', $accessibleMachineIds);
}
// 共用過濾器:機台
if ($machineId) {
$ordersQuery->where('machine_id', $machineId);
@ -125,7 +140,12 @@ class SalesController extends Controller
// 訂單專用過濾器
if ($tab === 'orders') {
if ($paymentType) $ordersQuery->where('payment_type', $paymentType);
if ($status) $ordersQuery->where('status', $status);
if ($status && $status !== 'all') $ordersQuery->where('status', $status);
// 出貨狀態:唯有付款成功(completed/paid)的單才有出貨結果,需與列表顯示邏輯 (hasDeliveryOutcome) 一致
if ($deliveryStatus !== null && $deliveryStatus !== '') {
$ordersQuery->whereIn('status', [Order::STATUS_COMPLETED, 'paid'])
->where('delivery_status', $deliveryStatus);
}
}
// 發票專用過濾器狀態pending/issued/failed/void
@ -146,6 +166,11 @@ class SalesController extends Controller
$data['invoices'] = $invoicesQuery->latest()->paginate($perPage, ['*'], 'invoices_page')->withQueryString();
$data['dispenseLogs'] = $dispenseQuery->latest()->paginate($perPage, ['*'], 'dispense_page')->withQueryString();
// 出貨失敗補償取貨碼:僅掛載既有碼供顯示;產碼由 + 按鈕手動觸發 generateOrderPickupCode()
if ($tab === 'orders') {
$this->attachOrderPickupCodes($data['orders']->getCollection());
}
if ($isAjax) {
return response()->json([
'success' => true,
@ -157,6 +182,98 @@ class SalesController extends Controller
return view('admin.sales.index', $data);
}
/**
* 掛載訂單的補償取貨碼(batch_no='COMP{id}')供銷售頁顯示。只讀不產碼。
*/
private function attachOrderPickupCodes($orders): void
{
if ($orders->isEmpty()) {
return;
}
$batchNos = $orders->map(fn ($o) => 'COMP' . $o->id)->all();
$codesByBatch = PickupCode::whereIn('batch_no', $batchNos)->get()->groupBy('batch_no');
foreach ($orders as $order) {
$order->setRelation('compensationCodes', $codesByBatch->get('COMP' . $order->id, collect()));
}
}
/**
* 手動產生「出貨失敗補償取貨碼」:由銷售頁 + 按鈕、前端確認後 POST 觸發。
* 僅限「付款成功(completed/paid)但出貨失敗/部分(delivery 0/2)」的訂單;依每商品缺量各產一碼
* (綁機台+商品、7 天有效、usage_limit=缺量);冪等(batch_no='COMP{id}')
* 不設 order_id——取貨碼核銷時 order_id 會被回填為「新出貨單」。
*/
public function generateOrderPickupCode(Request $request, Order $order)
{
if (!Auth::user()->isSystemAdmin() && $order->machine_id && !Machine::whereKey($order->machine_id)->exists()) {
abort(403);
}
$isPaid = in_array($order->status, [Order::STATUS_COMPLETED, 'paid'], true);
$failedDelivery = in_array((int) $order->delivery_status, [Order::DELIVERY_STATUS_FAILED, Order::DELIVERY_STATUS_PARTIAL], true);
if (!$isPaid || !$failedDelivery || !$order->machine_id) {
return response()->json(['success' => false, 'message' => __('This order is not eligible for a pickup code.')], 422);
}
$batchNo = 'COMP' . $order->id;
$existing = PickupCode::where('batch_no', $batchNo)->get();
if ($existing->isNotEmpty()) {
return response()->json(['success' => true, 'message' => __('Pickup code already generated.'), 'codes' => $existing->pluck('code')]);
}
$machine = $order->machine ?: Machine::find($order->machine_id);
if (!$machine) {
return response()->json(['success' => false, 'message' => __('Machine not found.')], 422);
}
$ordered = $order->items->groupBy('product_id')->map(fn ($g) => (int) $g->sum('quantity'));
$dispensedOk = DispenseRecord::where('order_id', $order->id)
->where('dispense_status', 1)
->get()->groupBy('product_id')->map(fn ($g) => (int) $g->sum('amount'));
$created = [];
foreach ($ordered as $productId => $orderedQty) {
if (!$productId) {
continue;
}
$shortfall = $orderedQty - (int) ($dispensedOk[$productId] ?? 0);
if ($shortfall <= 0) {
continue;
}
$code = PickupCode::create([
'company_id' => $order->company_id ?? $machine->company_id,
'machine_id' => $order->machine_id,
'product_id' => $productId,
'code' => PickupCode::generateUniqueCode($order->machine_id),
'slug' => Str::random(16),
'batch_no' => $batchNo,
'status' => 'active',
'usage_limit' => $shortfall,
'usage_count' => 0,
'expires_at' => now()->addDays(7),
'created_by' => Auth::id(),
]);
$created[] = $code->code;
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'create',
'target_id' => $code->id,
'target_type' => PickupCode::class,
'new_values' => $code->toArray() + ['source' => 'failed_delivery_compensation', 'source_order_id' => $order->id],
]);
}
if (empty($created)) {
return response()->json(['success' => false, 'message' => __('No undelivered items to compensate.')], 422);
}
return response()->json(['success' => true, 'message' => __('Pickup code generated.'), 'codes' => $created]);
}
/**
* 取得單筆交易詳情 (用於 Slide-over)
*/
@ -180,6 +297,170 @@ class SalesController extends Controller
]);
}
/**
* 手動補單:取得指定機台的貨道(含商品)與電子發票開關,供補單彈窗動態載入。
* 僅限系統管理員(整個補單功能限系統管理員)。
*/
public function manualMachineSlots(Request $request)
{
if (!Auth::user()->isSystemAdmin()) {
abort(403);
}
$machine = Machine::with(['slots.product:id,name,price'])
->findOrFail($request->input('machine_id'));
$slots = $machine->slots
->sortBy(fn ($s) => str_pad(ltrim($s->slot_no, '0') ?: '0', 6, '0', STR_PAD_LEFT))
->values()
->map(fn ($slot) => [
'slot_no' => $slot->slot_no,
'product_id' => $slot->product_id,
'product_name' => $slot->product?->localized_name ?? __('Unknown Product'),
'price' => $slot->product?->price !== null ? (float) $slot->product->price : 0,
'stock' => (int) $slot->stock,
]);
return response()->json([
'success' => true,
'tax_invoice_enabled' => (bool) $machine->tax_invoice_enabled,
'slots' => $slots,
]);
}
/**
* 手動補單:機台斷線/漏報時人工補登銷售紀錄。
*
* 僅限系統管理員。複用 TransactionService::createManualOrder與機台上報共用扣庫存開發票邏輯
* 扣庫存與開立電子發票皆為選配;開發票受機台「電子發票開關」閘門限制(後端再驗一次)。
*/
public function storeManualOrder(Request $request, \App\Services\Transaction\TransactionService $service)
{
// 權限閘門:補單僅限平台系統管理員(前端僅隱藏入口,真正把關在後端,防偽造 POST
if (!Auth::user()->isSystemAdmin()) {
abort(403, __('Only system administrators can create manual orders'));
}
$validated = $request->validate([
'machine_id' => ['required', 'exists:machines,id'],
'payment_type' => ['required', 'integer', 'in:' . implode(',', array_keys(Order::getPaymentTypeLabels()))],
'occurred_at' => ['nullable', 'date'],
'deduct_stock' => ['nullable', 'boolean'],
'remark' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.slot_no' => ['nullable', 'string', 'max:20'],
'items.*.price' => ['required', 'numeric', 'min:0'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:999'],
'issue_invoice' => ['nullable', 'boolean'],
'invoice_type' => ['nullable', 'required_if:issue_invoice,1', 'in:b2c,taxid,mobile,citizen,donation'],
'invoice_value' => ['nullable', 'string', 'max:64'],
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 組裝發票輸入(僅在勾選開立、且機台開關開啟時)。後端閘門:機台關閉電子發票一律不開。
$invoice = null;
if (!empty($validated['issue_invoice'])) {
if (!$machine->tax_invoice_enabled) {
return $this->manualOrderResponse($request, false, __('This machine has e-invoice disabled; cannot issue'));
}
$invoice = $this->buildManualInvoiceInput($validated['invoice_type'] ?? 'b2c', trim((string) ($validated['invoice_value'] ?? '')));
if (isset($invoice['error'])) {
return $this->manualOrderResponse($request, false, $invoice['error']);
}
}
try {
$order = $service->createManualOrder([
'serial_no' => $machine->serial_no,
'created_by' => Auth::id(),
'payment_type' => (int) $validated['payment_type'],
'occurred_at' => $validated['occurred_at'] ?? null,
'deduct_stock' => !empty($validated['deduct_stock']),
'remark' => $validated['remark'] ?? null,
'items' => array_map(fn ($i) => [
'product_id' => (int) $i['product_id'],
'slot_no' => $i['slot_no'] ?? null,
'price' => (float) $i['price'],
'quantity' => (int) $i['quantity'],
], $validated['items']),
'invoice' => $invoice,
]);
} catch (\Throwable $e) {
\Log::error('Manual order creation failed: ' . $e->getMessage(), [
'machine_id' => $machine->id,
'user_id' => Auth::id(),
]);
return $this->manualOrderResponse($request, false, __('Failed to create manual order'));
}
// 審計日誌:留痕「誰/何時/對哪台機台補了什麼、是否開發票/扣庫存」。
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'manual_order',
'action' => 'create',
'target_id' => $order->id,
'target_type' => Order::class,
'new_values' => [
'order_no' => $order->order_no,
'flow_id' => $order->flow_id,
'machine_id' => $machine->id,
'total_amount' => $order->total_amount,
'payment_type' => $order->payment_type,
'deduct_stock' => !empty($validated['deduct_stock']),
'issue_invoice' => $invoice !== null,
'occurred_at' => optional($order->machine_time)->toDateTimeString(),
],
]);
return $this->manualOrderResponse($request, true, __('Manual order created: :no', ['no' => $order->order_no]));
}
/**
* 將補單表單的發票類型/輸入值轉成 recordInvoice 可用的欄位。
* reissue() business_tax_id / carrier_id / love_code 自動判別綠界載具類型與 Print 旗標。
*/
private function buildManualInvoiceInput(string $type, string $value): array
{
switch ($type) {
case 'b2c': // 一般 B2C綠界會員載具Print=0不可列印
return [];
case 'taxid': // 統一編號Print=1可列印
if (!preg_match('/^\d{8}$/', $value)) {
return ['error' => __('Tax ID must be 8 digits')];
}
return ['business_tax_id' => $value];
case 'mobile': // 手機條碼載具8 碼,/ 開頭)
if (!preg_match('#^/[0-9A-Z.\-+]{7}$#', $value)) {
return ['error' => __('Invalid mobile barcode carrier')];
}
return ['carrier_id' => $value];
case 'citizen': // 自然人憑證載具16 碼2 英文 + 14 數字)
if (!preg_match('/^[A-Z]{2}\d{14}$/', $value)) {
return ['error' => __('Invalid citizen digital certificate carrier')];
}
return ['carrier_id' => $value];
case 'donation': // 捐贈(愛心碼 3~7 碼數字)
if (!preg_match('/^\d{3,7}$/', $value)) {
return ['error' => __('Donation love code must be 3 to 7 digits')];
}
return ['love_code' => $value];
default:
return ['error' => __('Invalid invoice type')];
}
}
/** 補單回應AJAX 回 JSON、一般表單回 back()。 */
private function manualOrderResponse(Request $request, bool $success, string $message)
{
if ($request->expectsJson()) {
return response()->json(['success' => $success, 'message' => $message], $success ? 200 : 422);
}
return back()->with($success ? 'success' : 'error', $message);
}
// 取貨碼設定
public function pickupCodes(Request $request)
{
@ -192,9 +473,17 @@ class SalesController extends Controller
'tab' => $tab,
];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 取貨碼列表 (list)
if (!$isAjax || $tab === 'list') {
$query = PickupCode::with(['machine.slots.product', 'creator', 'order'])->latest();
// 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PickupCode::with(['machine.slots.product', 'product', 'creator', 'order'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
@ -219,7 +508,12 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order']);
// 同上操作紀錄只顯示可存取機台的資料machine_id 為 null 的非機台紀錄仍保留)
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order'])
->where(function ($q) {
$q->whereNull('machine_id')
->orWhereHas('machine');
});
if ($request->filled('search_log')) {
$search = $request->input('search_log');
@ -285,25 +579,105 @@ class SalesController extends Controller
*/
public function storePickupCode(Request $request)
{
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆。
// 後端強制把關,避免前端被繞過送出大量產碼請求。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'slot_no' => 'required|string',
// 取貨碼改綁「商品」:新流程傳 product_id舊的綁「貨道」流程仍可傳 slot_no向後相容
'product_id' => 'required_without:slot_no|nullable|exists:products,id',
'slot_no' => 'required_without:product_id|nullable|string',
'usage_limit' => 'nullable|integer|min:1|max:20',
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
]);
$machine = Machine::findOrFail($validated['machine_id']);
$quantity = (int) ($validated['quantity'] ?? 1);
// 綁商品時驗證該商品屬於此機台所屬公司(不要求機台當下有貨道;機台未上架仍可產碼,
// 由前端提示、刷碼時若無貨道再由 B660 擋下)。
if (!empty($validated['product_id'])) {
$belongsToCompany = \App\Models\Product\Product::where('id', $validated['product_id'])
->where('company_id', $machine->company_id)
->exists();
if (!$belongsToCompany) {
return back()->withErrors(['product_id' => __('The selected product does not belong to this machine\'s company.')]);
}
}
// 處理過期時間:優先使用直接傳入的日期,否則使用時數計算
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
: now()->addHours((int) ($request->expires_hours ?? 24));
$usageLimit = $validated['usage_limit'] ?? 1;
// 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。
// 沿用單筆的綁定方式(優先 product_id相容舊的 slot_no
if ($quantity > 1) {
$batchNo = 'PUB' . now()->format('YmdHis') . strtoupper(Str::random(4));
$codes = $this->generateUniqueCodeBatch(PickupCode::class, $machine->id, $quantity);
$nowTs = now();
$rows = [];
foreach ($codes as $code) {
$rows[] = [
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
'code' => $code,
'slug' => Str::random(16),
'batch_no' => $batchNo,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'expires_at' => $expiresAt,
'created_by' => Auth::id(),
'created_at' => $nowTs,
'updated_at' => $nowTs,
];
}
DB::transaction(function () use ($rows) {
foreach (array_chunk($rows, 500) as $chunk) {
PickupCode::insert($chunk);
}
});
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'batch_create',
'target_id' => null,
'target_type' => PickupCode::class,
'new_values' => [
'batch_no' => $batchNo,
'count' => count($rows),
'machine_id' => $machine->id,
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
],
]);
return back()
->with('success', __(':count pickup codes generated', ['count' => count($rows)]))
->with('code_batch', [
'type' => 'pickup',
'batch_no' => $batchNo,
'count' => count($rows),
'download_url' => route('admin.sales.pickup-codes.batch-download', $batchNo),
]);
}
$pickupCode = PickupCode::create([
'machine_id' => $validated['machine_id'],
'slot_no' => $validated['slot_no'],
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
'code' => $request->custom_code ?? PickupCode::generateUniqueCode($validated['machine_id']),
'usage_limit' => $validated['usage_limit'] ?? 1,
'usage_count' => 0,
@ -326,6 +700,30 @@ class SalesController extends Controller
return back()->with('success', __('Pickup code generated: :code', ['code' => $pickupCode->code]));
}
/**
* 產碼用 AJAX回傳機台所屬「公司」的商品清單以及機台目前已上架的 product_id。
* 商品下拉以公司商品為準(非僅機台貨道);前端用 machine_product_ids 標示「此機台未上架」提示。
*/
public function pickupCompanyProducts(Machine $machine)
{
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->select('id', 'name')
->orderBy('name')
->get();
$machineProductIds = $machine->slots()
->whereNotNull('product_id')
->pluck('product_id')
->unique()
->values();
return response()->json([
'success' => true,
'products' => $products,
'machine_product_ids' => $machineProductIds,
]);
}
/**
* 更新取貨碼 (僅限修改時間)
*/
@ -362,6 +760,11 @@ class SalesController extends Controller
*/
public function destroyPickupCode(PickupCode $pickupCode)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該取貨碼所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($pickupCode->machine_id)->exists()) {
abort(403);
}
$oldValues = $pickupCode->toArray();
$pickupCode->update(['status' => 'cancelled']);
@ -409,9 +812,17 @@ class SalesController extends Controller
'tab' => $tab,
];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 通行碼列表 (list)
if (!$isAjax || $tab === 'list') {
$query = PassCode::with(['machine', 'creator'])->latest();
// 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PassCode::with(['machine', 'creator'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
@ -447,7 +858,9 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode']);
// 同上操作紀錄只顯示可存取機台的資料pass_code_logs.machine_id 為 NOT NULL
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode'])
->whereHas('machine');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
@ -513,22 +926,87 @@ class SalesController extends Controller
*/
public function storePassCode(Request $request)
{
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆(後端強制把關)。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'name' => 'required|string|max:50',
'expires_days' => 'nullable|integer|min:0',
'custom_code' => 'required|string|min:4|max:12',
'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
]);
$machine = Machine::findOrFail($validated['machine_id']);
$quantity = (int) ($validated['quantity'] ?? 1);
$expiresAt = $request->expires_days ? now()->addDays((int) $request->expires_days) : null;
// 勾「只能使用一次」→ 使用上限 1未勾 → null(無限次,維持原行為)。
$usageLimit = $request->boolean('single_use') ? 1 : null;
// 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。
if ($quantity > 1) {
$batchNo = 'PSB' . now()->format('YmdHis') . strtoupper(Str::random(4));
$codes = $this->generateUniqueCodeBatch(PassCode::class, $machine->id, $quantity);
$nowTs = now();
$rows = [];
foreach ($codes as $i => $code) {
$rows[] = [
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'name' => $validated['name'] . ' #' . ($i + 1),
'code' => $code,
'slug' => Str::random(16),
'batch_no' => $batchNo,
'expires_at' => $expiresAt,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'created_by' => Auth::id(),
'created_at' => $nowTs,
'updated_at' => $nowTs,
];
}
DB::transaction(function () use ($rows) {
foreach (array_chunk($rows, 500) as $chunk) {
PassCode::insert($chunk);
}
});
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'batch_create',
'target_id' => null,
'target_type' => PassCode::class,
'new_values' => [
'batch_no' => $batchNo,
'count' => count($rows),
'machine_id' => $machine->id,
'name' => $validated['name'],
],
]);
return back()
->with('success', __(':count pass codes created', ['count' => count($rows)]))
->with('code_batch', [
'type' => 'pass',
'batch_no' => $batchNo,
'count' => count($rows),
'download_url' => route('admin.sales.pass-codes.batch-download', $batchNo),
]);
}
$passCode = PassCode::create([
'machine_id' => $validated['machine_id'],
'name' => $validated['name'] ?? 'Manual Generate',
'code' => $validated['custom_code'] ?? PassCode::generateUniqueCode($validated['machine_id']),
'code' => $request->custom_code ?: PassCode::generateUniqueCode($validated['machine_id']),
'expires_at' => $expiresAt,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
@ -546,6 +1024,125 @@ class SalesController extends Controller
return back()->with('success', __('Pass code created: :code', ['code' => $passCode->code]));
}
/**
* 批次產生指定數量的唯一 8 位數字碼(避開該機台目前有效的碼,並去除同批重複)
*
* @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass PickupCode::class PassCode::class
* @return string[]
*/
private function generateUniqueCodeBatch(string $modelClass, int $machineId, int $quantity): array
{
// 載入該機台目前仍有效的碼,避免與既有碼衝突
$existing = array_flip(
$modelClass::where('machine_id', $machineId)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->pluck('code')
->all()
);
$codes = [];
$seen = [];
while (count($codes) < $quantity) {
$code = str_pad((string) random_int(0, 99999999), 8, '0', STR_PAD_LEFT);
if (isset($existing[$code]) || isset($seen[$code])) {
continue;
}
$seen[$code] = true;
$codes[] = $code;
}
return $codes;
}
/**
* 下載某一批取貨碼清單 (CSV)
*/
public function downloadPickupCodeBatch(string $batchNo)
{
$codes = PickupCode::with(['machine:id,name,serial_no', 'product:id,name'])
->where('batch_no', $batchNo)
->orderBy('id')
->get();
abort_if($codes->isEmpty(), 404);
$header = [
__('Pickup Code'),
__('Target Machine'),
__('Select Product'),
__('Select Slot'),
__('Usage Limit'),
__('Expires At'),
__('Link'),
__('Created At'),
];
return response()->streamDownload(function () use ($codes, $header) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOMExcel 開啟才不會亂碼
fputcsv($out, $header);
foreach ($codes as $c) {
fputcsv($out, [
$c->code,
optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')',
optional($c->product)->name ?? '',
$c->slot_no ?? '',
$c->usage_limit,
optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'),
$c->ticket_url,
optional($c->created_at)->format('Y-m-d H:i'),
]);
}
fclose($out);
}, 'pickup-codes-' . $batchNo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 下載某一批通行碼清單 (CSV)
*/
public function downloadPassCodeBatch(string $batchNo)
{
$codes = PassCode::with('machine:id,name,serial_no')
->where('batch_no', $batchNo)
->orderBy('id')
->get();
abort_if($codes->isEmpty(), 404);
$header = [
__('Pass Code'),
__('Description / Name'),
__('Target Machine'),
__('Expires At'),
__('Link'),
__('Created At'),
];
return response()->streamDownload(function () use ($codes, $header) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, $header);
foreach ($codes as $c) {
fputcsv($out, [
$c->code,
$c->name,
optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')',
optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'),
$c->ticket_url,
optional($c->created_at)->format('Y-m-d H:i'),
]);
}
fclose($out);
}, 'pass-codes-' . $batchNo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 更新通行碼
*/
@ -587,6 +1184,11 @@ class SalesController extends Controller
*/
public function destroyPassCode(PassCode $passCode)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該通行碼所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($passCode->machine_id)->exists()) {
abort(403);
}
$oldValues = $passCode->toArray();
$passCode->update(['status' => 'disabled']);
@ -618,7 +1220,10 @@ class SalesController extends Controller
// 1. 來店禮列表 (list)
if (!$isAjax || $tab === 'list') {
$query = WelcomeGift::with(['machine', 'creator'])->latest();
// 僅顯示目前帳號可存取機台的來店禮(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = WelcomeGift::with(['machine', 'creator'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
@ -654,7 +1259,9 @@ class SalesController extends Controller
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no']);
// 同上操作紀錄只顯示可存取機台的資料welcome_gift_logs.machine_id 為 NOT NULL
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no'])
->whereHas('machine');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
@ -846,6 +1453,11 @@ class SalesController extends Controller
*/
public function destroyWelcomeGift(WelcomeGift $welcomeGift)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該來店禮所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($welcomeGift->machine_id)->exists()) {
abort(403);
}
$oldValues = $welcomeGift->toArray();
$welcomeGift->update(['status' => 'disabled']);
@ -935,6 +1547,9 @@ class SalesController extends Controller
'pending' => __('Pending'),
'paid' => __('Paid'),
'completed' => __('Completed'),
'awaiting_pickup' => __('Awaiting Pickup'),
'failed' => __('Failed'),
'abandoned' => __('Unpaid'),
'cancelled' => __('Cancelled'),
'refunded' => __('Refunded'),
];
@ -1028,7 +1643,7 @@ class SalesController extends Controller
$row = [
$inv->invoice_no ?: '---',
$inv->invoice_date ?: '---',
$inv->flow_id ?: '---',
$inv->display_flow_id ?: '---',
$inv->machine->name ?? ($inv->order->machine->name ?? 'Unknown'),
$inv->machine->serial_no ?? ($inv->order->machine->serial_no ?? '---'),
$inv->order->order_no ?? '---',

View File

@ -110,6 +110,15 @@ class StaffCardController extends Controller
$validated['status'] = $request->get('status', $staffCard->status);
// 寫入安全company_id 不得由前端任意決定或被洗成 null
// - 非系統管理員:強制綁定自身公司 (租戶隔離)
// - 系統管理員:若未帶入公司,保留原本歸屬,避免變成無公司歸屬的孤兒卡 (機台將查無此卡)
if (!auth()->user()->isSystemAdmin()) {
$validated['company_id'] = auth()->user()->company_id;
} elseif (empty($validated['company_id'])) {
$validated['company_id'] = $staffCard->company_id;
}
$staffCard->update($validated);
if ($request->ajax()) {

View File

@ -722,10 +722,206 @@ class WarehouseController extends Controller
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
return view('admin.warehouses.machine-inventory', compact('machines', 'companies'));
// 供批次匯出 modal 勾選用的完整機台清單(全域 scope 已限可存取機台)
$exportMachines = Machine::select('id', 'name', 'serial_no')->orderBy('name')->get();
return view('admin.warehouses.machine-inventory', compact('machines', 'companies', 'exportMachines'));
}
/**
* 匯出單一機台的「每個貨道」明細CSV / Excel——供機台詳細視圖(矩陣/表格)旁的匯出鈕使用。
* 參考 ProductController::export / SalesController::handleExport 的串流下載模式。
*/
public function machineInventoryExport(Request $request, Machine $machine)
{
$type = $request->input('export', 'csv');
$isExcel = ($type === 'excel');
$ext = $isExcel ? 'xls' : 'csv';
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
$slots = $machine->slots()
->with('product:id,name,barcode')
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
$headers = ['貨道號', '商品ID', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
$safeSerial = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $machine->serial_no);
$filename = '貨道明細_' . ($safeSerial ?: $machine->id) . '_' . now()->format('YmdHis') . '.' . $ext;
$callback = function () use ($slots, $headers, $isExcel) {
$file = fopen('php://output', 'w');
if ($isExcel) {
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
fwrite($file, '<table><thead><tr>');
foreach ($headers as $header) {
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
}
fwrite($file, '</tr></thead><tbody>');
} else {
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
fputcsv($file, $headers);
}
foreach ($slots as $slot) {
$stock = (int) $slot->stock;
$capacity = (int) $slot->max_stock;
$rate = $capacity > 0 ? round($stock / $capacity * 100) : 0;
$row = [
$slot->slot_no,
$slot->product_id ?? '',
$slot->product->name ?? '',
$slot->product->barcode ?? '',
$slot->type,
$stock,
$capacity,
$rate,
$slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '',
$slot->batch_no,
$slot->is_active ? '是' : '否',
$slot->is_locked ? '是' : '否',
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $cell) {
fwrite($file, '<td>' . htmlspecialchars((string) $cell) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
}
/**
* 批次匯出「機台庫存概覽」——每台機台一個分頁(Excel sheet)/一個檔(CSV),內容為該機台每貨道明細。
* Excel = 多分頁 xlsx(OpenSpout)CSV 無分頁概念,故每台一個 .csv 打包成 ZIP。
* ids逗號字串或陣列空則匯出全部可存取機台。全域 scope 確保只含可存取機台。
*/
public function machineInventoryExportBatch(Request $request)
{
$isExcel = ($request->input('export', 'csv') === 'excel');
$ids = $request->input('ids');
if (is_string($ids)) {
$ids = array_filter(array_map('intval', explode(',', $ids)));
} elseif (is_array($ids)) {
$ids = array_filter(array_map('intval', $ids));
} else {
$ids = [];
}
$query = Machine::with(['slots' => fn ($q) => $q->with('product:id,name,barcode')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')])
->orderBy('name');
if (!empty($ids)) {
$query->whereIn('id', $ids);
}
$machines = $query->get();
$headers = ['貨道號', '商品ID', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
$ts = now()->format('YmdHis');
// 單台的資料列
$rowsOf = function ($machine) {
$rows = [];
foreach ($machine->slots as $slot) {
$stock = (int) $slot->stock;
$capacity = (int) $slot->max_stock;
$rate = $capacity > 0 ? round($stock / $capacity * 100) : 0;
$rows[] = [
(string) $slot->slot_no,
$slot->product_id ?? '',
$slot->product->name ?? '',
$slot->product->barcode ?? '',
(string) $slot->type,
$stock,
$capacity,
$rate,
$slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '',
(string) $slot->batch_no,
$slot->is_active ? '是' : '否',
$slot->is_locked ? '是' : '否',
];
}
return $rows;
};
// 分頁/檔名機台名稱_序號去非法字元、限長、去重Excel sheet 名上限 31 字)
$labelOf = function ($machine, array &$used) {
$b = trim(($machine->name ?? '機台') . '_' . ($machine->serial_no ?? $machine->id));
$b = preg_replace('/[\\\\\\/\\?\\*\\[\\]:]/u', '_', $b);
$b = mb_substr($b, 0, 28);
$name = $b;
$i = 1;
while (in_array($name, $used, true)) {
$name = mb_substr($b, 0, 24) . '_' . (++$i);
}
$used[] = $name;
return $name;
};
if ($isExcel) {
$tmp = tempnam(sys_get_temp_dir(), 'invxlsx');
$writer = new \OpenSpout\Writer\XLSX\Writer();
$writer->openToFile($tmp);
$used = [];
$first = true;
foreach ($machines as $machine) {
$sheet = $first ? $writer->getCurrentSheet() : $writer->addNewSheetAndMakeItCurrent();
$first = false;
$sheet->setName($labelOf($machine, $used));
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
foreach ($rowsOf($machine) as $row) {
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($row));
}
}
if ($first) {
$writer->getCurrentSheet()->setName('機台庫存');
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
}
$writer->close();
return response()->download($tmp, "機台庫存_{$ts}.xlsx", [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
])->deleteFileAfterSend(true);
}
// CSV每台一個 .csv 打包成 ZIP
$tmp = tempnam(sys_get_temp_dir(), 'invzip');
$zip = new \ZipArchive();
$zip->open($tmp, \ZipArchive::OVERWRITE);
$used = [];
foreach ($machines as $machine) {
$fh = fopen('php://temp', 'r+');
fputcsv($fh, $headers);
foreach ($rowsOf($machine) as $row) {
fputcsv($fh, $row);
}
rewind($fh);
$content = "\xEF\xBB\xBF" . stream_get_contents($fh);
fclose($fh);
$zip->addFromString($labelOf($machine, $used) . '.csv', $content);
}
if ($machines->isEmpty()) {
$zip->addFromString('機台庫存.csv', "\xEF\xBB\xBF" . implode(',', $headers) . "\n");
}
$zip->close();
return response()->download($tmp, "機台庫存_{$ts}.zip")->deleteFileAfterSend(true);
}
/**
* AJAX取得單台機台貨道詳情
*/
@ -840,10 +1036,9 @@ class WarehouseController extends Controller
$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');
if (!$isSystemAdmin) {
$userQuery->where('company_id', $currentUser->company_id);
} elseif ($companyId !== '') {
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name')->visibleTo($currentUser);
if ($isSystemAdmin && $companyId !== '') {
$userQuery->where('company_id', $companyId);
}
$users = $userQuery->get(['id', 'name']);
@ -877,7 +1072,10 @@ class WarehouseController extends Controller
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
]);
DB::transaction(function () use ($validated) {
// 補貨針對機台,單據歸屬以「機台所綁公司」為準(系統管理員可為他公司機台建單)
$machine = Machine::findOrFail($validated['machine_id']);
DB::transaction(function () use ($validated, $machine) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
@ -889,10 +1087,10 @@ class WarehouseController extends Controller
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([
'company_id' => Auth::user()->company_id,
'company_id' => $machine->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $validated['machine_id'],
'machine_id' => $machine->id,
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? null,
'created_by' => Auth::id(),
@ -1011,7 +1209,7 @@ class WarehouseController extends Controller
}
// 有 items = 確認建單
DB::transaction(function () use ($validated) {
DB::transaction(function () use ($validated, $machine) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
@ -1023,10 +1221,10 @@ class WarehouseController extends Controller
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([
'company_id' => Auth::user()->company_id,
'company_id' => $machine->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $validated['machine_id'],
'machine_id' => $machine->id,
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? __('Auto Replenishment'),
'created_by' => Auth::id(),

View File

@ -371,6 +371,12 @@ class MachineController extends Controller
'product_id' => $item['t060v00'] ?? null,
'stock' => isset($item['num']) ? (int) $item['num'] : 0,
'type' => isset($item['type']) ? (int) $item['type'] : null,
// 雙向 LWW鎖定與效期/批號 + 各自的編輯序號App 端 store 編輯才遞增)
'is_locked' => isset($item['is_locked']) ? (bool) $item['is_locked'] : false,
'lock_rev' => isset($item['lock_rev']) ? (int) $item['lock_rev'] : 0,
'expiry_date' => $item['expiry_date'] ?? null,
'batch_no' => $item['batch_no'] ?? null,
'expiry_rev' => isset($item['expiry_rev']) ? (int) $item['expiry_rev'] : 0,
];
}, $legacyData);
@ -394,8 +400,8 @@ class MachineController extends Controller
{
$machine = $request->get('machine');
// Cache First: Get from cache or rebuild if missing
$payload = $catalogService->getPayload($machine->company_id);
// 公司基底目錄(快取) + 該機台專屬定價覆蓋(即時套用,見 getMachinePayload
$payload = $catalogService->getMachinePayload($machine);
return response()->json($payload);
}
@ -501,6 +507,8 @@ class MachineController extends Controller
'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購物方式頂層字串
@ -690,17 +698,114 @@ class MachineController extends Controller
]);
// 移除 StaffCardLog 重複紀錄,因為取貨碼本身有 status 和 order_id 可以追蹤
// 憑證的「核銷」將由 MQTT finalizeTransaction 流程處理,不再需要 B660 PUT
// 一般取貨碼的「核銷」仍由 MQTT finalizeTransaction 流程處理(行為不變)。
$data = [
'slot_no' => $pickupCode->slot_no, // 保留:既有綁貨道碼讀此欄(綁商品碼/領藥單為 null
'product_id' => $pickupCode->product_id, // 綁商品碼App 據此自挑可出貨道(綁貨道碼為 null
'pickup_code_id' => $pickupCode->id,
'code_id' => $pickupCode->id, // 統一回傳 code_id
'status' => $pickupCode->status,
];
// === 領藥單(pharmacy_pickup)擴充:回傳多貨道清單 + 標記碼已領(僅領藥單,不影響既有單貨道流程) ===
$order = $pickupCode->order;
$isPharmacy = $order && $order->order_type === \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
if ($isPharmacy) {
// Gate機台須仍為「取物單模式 + 領藥單開關開啟」才放行領藥碼
$settings = $machine->settings ?? [];
$enabled = (($settings['shopping_mode'] ?? null) === 'pickup_sheet')
&& (bool) ($settings['pharmacy_pickup_enabled'] ?? false);
if (!$enabled) {
return response()->json(['success' => false, 'message' => 'Pharmacy pickup not enabled on this machine', 'code' => 403], 403);
}
$pharmacyService = app(\App\Services\Transaction\PharmacyPickupService::class);
// 嚴格擋關:任一商品的貨道可出量 < 領藥單需求量 → 整單擋下、不出貨、不消耗領藥碼,回明確提示
$shortage = $pharmacyService->dispenseShortage($order);
if (!empty($shortage)) {
$detail = collect($shortage)
->map(fn ($s) => "{$s['product_name']}(需 {$s['required']}、可出 {$s['available']}")
->implode('、');
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.pharmacy_insufficient_stock',
'raw_data' => ['order_no' => $order->order_no, 'shortage' => $shortage],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '因貨道庫存不足,無法出貨:' . $detail,
], 409);
}
// 依目前貨道庫存解析多貨道出貨清單(一商品可跨多貨道湊量)
$items = $pharmacyService->resolveDispenseItems($order);
// 解析不到可出貨貨道(機台無對應貨道/庫存不足)→ 不消耗領藥碼,回明確錯誤讓藥師處理
if (empty($items)) {
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.pharmacy_no_slot',
'raw_data' => ['order_no' => $order->order_no, 'reason' => 'no available slot/stock'],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '此領藥單在本機台無可出貨貨道(請確認貨道對應與庫存)',
], 409);
}
// 不在「驗證/掃碼」當下核銷領藥碼。核銷統一改由機台實際出貨後的
// MQTT finalizeTransactionService::finalizePharmacyDispense標記為 used
// 這樣顧客掃碼後若未按「確定取貨」(逾時/取消、未出貨),領藥碼仍維持 active 可再次掃碼領取。
// (原本在此處 usage_count++/轉 used 會在尚未取貨時就把碼鎖死,導致再掃顯示「已領」。)
$data['order_type'] = \App\Models\Transaction\Order::TYPE_PHARMACY_PICKUP;
$data['order_no'] = $order->order_no;
$data['pricing_slip_no'] = $order->pricing_slip_no ?? ''; // 批價單號(藥師批價依據);機台「確認領藥」畫面備註欄顯示
$data['items'] = $items; // [{slot_no, product_id, product_name, qty}, ...]
$data['status'] = $pickupCode->status;
}
// === 綁商品取貨碼(非領藥單)擋關:機台須仍有此商品貨道,否則 App 必然挑不到貨道 ===
// 即時庫存/鎖定/效期由 App 刷碼當下自行判斷;此處只擋「機台根本沒上這商品」的明確錯誤。
if (!$isPharmacy && $pickupCode->product_id) {
$hasProduct = $machine->slots()->where('product_id', $pickupCode->product_id)->exists();
if (!$hasProduct) {
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'action' => 'verify_failed',
'remark' => 'log.pickup.no_slot_for_product',
'raw_data' => ['code' => $pickupCode->code, 'product_id' => $pickupCode->product_id],
]);
return response()->json([
'success' => false,
'code' => 409,
'message' => '此機台目前無此商品貨道,無法取貨',
], 409);
}
}
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'slot_no' => $pickupCode->slot_no,
'pickup_code_id' => $pickupCode->id,
'code_id' => $pickupCode->id, // 統一回傳 code_id
'status' => 'active'
]
'data' => $data,
]);
}
@ -747,6 +852,17 @@ class MachineController extends Controller
'raw_data' => ['machine_sn' => $machine->serial_no, 'code' => $passCode->code]
]);
// 單次/限次通行碼:驗證成功即計為使用一次;達上限就標記 used
// 下次驗證會被上方 where('status','active') 過濾擋掉,達成「只能使用一次」。
// usage_limit 為 null未勾單次時不計數維持原本無限次行為。
if (!is_null($passCode->usage_limit)) {
$newCount = $passCode->usage_count + 1;
$passCode->update([
'usage_count' => $newCount,
'status' => $newCount >= $passCode->usage_limit ? 'used' : $passCode->status,
]);
}
return response()->json([
'success' => true,

View File

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

View File

@ -147,7 +147,7 @@ class ProcessCommandAck implements ShouldQueue
]);
// 記錄維護類指令到機台日誌 (MachineLog)
if (in_array($command->command_type, ['reboot', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads', 'update_settings'], true)) {
if (in_array($command->command_type, ['reboot', 'reboot_force', 'reboot_card', 'lock', 'unlock', 'checkout', 'change', 'reload_stock', 'update_products', 'update_ads', 'update_settings'], true)) {
$msgKey = $status === 'success' ? 'log.command.success' : 'log.command.failed';
$level = $status === 'success' ? 'info' : 'warning';

View File

@ -54,6 +54,14 @@ class Machine extends Model
])) {
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'));
}
});
}
@ -401,6 +409,11 @@ class Machine extends Model
return $this->hasMany(MachineSlot::class);
}
public function productPrices()
{
return $this->hasMany(MachineProductPrice::class);
}
public function commands()
{
return $this->hasMany(RemoteCommand::class);

View File

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

View File

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

View File

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

View File

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

View File

@ -6,13 +6,14 @@ 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;
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
protected $fillable = [
'company_id',

View File

@ -6,12 +6,18 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
use App\Traits\HasDisplayFlowId;
use App\Models\Machine\Machine;
use App\Models\Member\Member;
class Order extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
use HasFactory, SoftDeletes, TenantScoped, HasDisplayFlowId;
// 訂單類型 (order_type)
public const TYPE_SALE = 'sale'; // 一般銷售
public const TYPE_PHARMACY_PICKUP = 'pharmacy_pickup'; // 領藥單
public const TYPE_MANUAL = 'manual'; // 手動補單(後台人工補登,機台斷線漏報時使用)
// 支付狀態
public const PAYMENT_STATUS_FAILED = 0;
@ -30,6 +36,8 @@ class Order extends Model
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 = [
@ -42,6 +50,9 @@ class Order extends Model
'company_id',
'flow_id',
'order_no',
'order_type',
'pricing_slip_no',
'created_by',
'machine_id',
'member_id',
'payment_type',
@ -49,6 +60,7 @@ class Order extends Model
'discount_amount',
'pay_amount',
'change_amount',
'cash_detail',
'points_used',
'original_amount',
'payment_status',
@ -71,6 +83,7 @@ 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',
@ -79,6 +92,42 @@ class Order extends Model
'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);
}
/**
* 取得所有支付類型的對照標籤
*/
@ -182,11 +231,35 @@ class Order extends Model
. 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);
@ -207,6 +280,12 @@ 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);
@ -229,11 +308,25 @@ class Order extends Model
];
}
/**
* 出貨狀態是否有意義:唯有付款成功(completed/paid)、機台真的動作過的單才有出貨結果。
* pending(未完成)、failed(支付失敗)、abandoned(未支付)從未出貨,出貨狀態應顯示 '--'
* delivery_status 欄位 DB 預設為 1(出貨成功),這些單若直接讀值會誤顯示成「出貨成功」。
*/
public function hasDeliveryOutcome(): bool
{
return in_array($this->status, [self::STATUS_COMPLETED, 'paid'], true);
}
/**
* 取得目前出貨狀態標籤 (Accessor)
*/
public function getDeliveryStatusLabelAttribute(): string
{
if (! $this->hasDeliveryOutcome()) {
return '--';
}
return self::getDeliveryStatusLabels()[$this->delivery_status] ?? __('Unknown');
}
}

View File

@ -14,6 +14,7 @@ class OrderItem extends Model
'order_id',
'product_id',
'product_name',
'slot_no',
'barcode',
'price',
'quantity',

View File

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

View File

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

View File

@ -338,17 +338,31 @@ 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) {
if ($isStockChanged || $isProductChanged || $isSlotConfigChanged || $isLwwChanged) {
$existingSlot->update($updateData);
$existingSlot->refresh();
@ -392,10 +406,12 @@ class MachineService
->values()
->toArray();
// 找出資料庫中屬於此機台但不在清單內的舊貨道
$orphanedSlots = $machine->slots()
->whereNotIn('slot_no', $reportedSlotNos)
->get();
// 防呆:上報清單為空時不執行全量刪除(避免機台/APP 誤報空清單而把所有貨道清光)。
$orphanedSlots = empty($reportedSlotNos)
? collect()
: $machine->slots()
->whereNotIn('slot_no', $reportedSlotNos)
->get();
foreach ($orphanedSlots as $orphan) {
$oldStock = (int) $orphan->stock;
@ -420,6 +436,88 @@ class MachineService
});
}
/**
* 空字串/"null" 一律歸一化為 null(避免 MySQL date 欄位寫入空字串解析爆)。
*/
private function blankToNull($value)
{
if (is_string($value)) {
$value = trim($value);
}
if ($value === '' || $value === null || (is_string($value) && strtolower($value) === 'null')) {
return null;
}
return $value;
}
/**
* 取日期字串的 yyyy-MM-dd 部分(容錯後台/機台帶了時間),空值回 null
*/
private function normalizeDate($value)
{
$value = $this->blankToNull($value);
if ($value === null) {
return null;
}
return substr((string) $value, 0, 10);
}
/**
* 貨道鎖定(lock)的雙向 LWW 解析,回傳要寫入的 is_locked / last_app_lock_rev。
* - 新貨道 App 現場值。
* - 既有貨道 常態 LWWApp rev 較大才採用,否則保留後台值。
* 既有機台升級時App onUpgrade 已把現場既有鎖定的 lock_rev 設為 1,故能 1>0 自動遷移上雲。)
*/
private function resolveSlotLockLww(?MachineSlot $existingSlot, array $slotData): array
{
$appLocked = (bool) ($slotData['is_locked'] ?? false);
$appRev = (int) ($slotData['lock_rev'] ?? 0);
if (!$existingSlot) {
$newRev = $appRev > 0 ? $appRev : ($appLocked ? 1 : 0);
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $newRev];
}
$curLocked = (bool) $existingSlot->is_locked;
$curRev = (int) $existingSlot->last_app_lock_rev;
if ($appRev > $curRev) {
return ['is_locked' => $appLocked, 'last_app_lock_rev' => $appRev];
}
return ['is_locked' => $curLocked, 'last_app_lock_rev' => $curRev];
}
/**
* 效期/批號(expiry/batch)的雙向 LWW 解析,回傳要寫入的 expiry_date / batch_no / last_app_expiry_rev。
* 換商品(product_id 變更)時預設清空舊效期(安全網),除非 App 同筆帶了較新的新效期。
*/
private function resolveSlotExpiryLww(?MachineSlot $existingSlot, array $slotData, $actualProductId): array
{
$appExpiry = $this->normalizeDate($slotData['expiry_date'] ?? null);
$appBatch = $this->blankToNull($slotData['batch_no'] ?? null);
$appRev = (int) ($slotData['expiry_rev'] ?? 0);
if (!$existingSlot) {
$newRev = $appRev > 0 ? $appRev : ($appExpiry !== null ? 1 : 0);
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $newRev];
}
$curExpiry = $existingSlot->expiry_date ? $existingSlot->expiry_date->format('Y-m-d') : null;
$curBatch = $this->blankToNull($existingSlot->batch_no);
$curRev = (int) $existingSlot->last_app_expiry_rev;
// 換商品安全網:預設清空,除非 App 同筆帶了較新的新效期
$isProductChanged = ((string) ($existingSlot->product_id) !== (string) $actualProductId);
if ($isProductChanged) {
if ($appExpiry !== null && $appRev > $curRev) {
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
}
return ['expiry_date' => null, 'batch_no' => null, 'last_app_expiry_rev' => max($curRev, $appRev)];
}
if ($appRev > $curRev) {
return ['expiry_date' => $appExpiry, 'batch_no' => $appBatch, 'last_app_expiry_rev' => $appRev];
}
return ['expiry_date' => $curExpiry, 'batch_no' => $curBatch, 'last_app_expiry_rev' => $curRev];
}
public function syncProductSlotMaxStock(Product $product): void
{
MachineSlot::query()
@ -508,27 +606,55 @@ class MachineService
->first();
if ($pendingCommand) {
// 直接覆蓋:將前一個尚未執行的指令標記為「已取代」
// 重新下發時覆蓋前一筆尚未執行的指令。
// 若舊指令已超過 1 分鐘未收到機台回傳 (ACK),視為逾時 (timeout)
// 否則為使用者主動覆蓋 (superseded)。與 dispense 逾時邏輯一致。
$isTimedOut = $pendingCommand->created_at->lt(now()->subMinute());
$pendingCommand->update([
'status' => 'superseded',
'note' => __('Superseded by new command'),
'status' => $isTimedOut ? 'timeout' : 'superseded',
'note' => $isTimedOut
? __('Superseded by new command (Timeout)')
: __('Superseded by new command'),
]);
}
// 紀錄舊數據以供回滾使用
$oldExpiry = $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null;
$oldData = [
'stock' => $slot->stock,
'expiry_date' => $slot->expiry_date ? Carbon::parse($slot->expiry_date)->toDateString() : null,
'expiry_date' => $oldExpiry,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
];
// 2. 執行樂觀更新 (Optimistic Update)
$updateData = [
'expiry_date' => $expiryDate,
'batch_no' => $batchNo,
];
if ($stock !== null) $updateData['stock'] = (int)$stock;
$slot->update($updateData);
// 2. 執行樂觀更新 (Optimistic Update):只更新「明確帶上的欄位」,避免鎖定切換誤清效期
$updateData = [];
$normExpiry = $oldExpiry;
if (array_key_exists('expiry_date', $data)) {
$normExpiry = $this->normalizeDate($expiryDate);
$updateData['expiry_date'] = $normExpiry;
}
$normBatch = $slot->batch_no;
if (array_key_exists('batch_no', $data)) {
$normBatch = $this->blankToNull($batchNo);
$updateData['batch_no'] = $normBatch;
}
if ($stock !== null) $updateData['stock'] = (int) $stock;
if (array_key_exists('is_locked', $data)) {
$updateData['is_locked'] = (bool) $data['is_locked'];
}
// 後台編輯效期/批號或鎖定 → 遞增對應 rev成為權威值下行帶給 App 對齊 synced_rev
$expiryEdited = ($normExpiry !== $oldExpiry) || ($normBatch !== $slot->batch_no);
if ($expiryEdited) {
$updateData['last_app_expiry_rev'] = (int) $slot->last_app_expiry_rev + 1;
}
$lockEdited = array_key_exists('is_locked', $data) && ((bool) $data['is_locked'] !== (bool) $slot->is_locked);
if ($lockEdited) {
$updateData['last_app_lock_rev'] = (int) $slot->last_app_lock_rev + 1;
}
if (!empty($updateData)) $slot->update($updateData);
// 3. 若庫存數值有異動,記錄 adjustment 流水帳
if ($stock !== null && (int)$stock !== $oldData['stock']) {
@ -544,6 +670,8 @@ class MachineService
);
}
$slot->refresh();
// 3. 建立遠端指令紀錄
$command = RemoteCommand::create([
'machine_id' => $machine->id,
@ -554,19 +682,23 @@ class MachineService
'slot_no' => (string)$slotNo,
'old' => $oldData,
'new' => [
'stock' => $stock !== null ? (int)$stock : $oldData['stock'],
'expiry_date' => $expiryDate ?: null,
'batch_no' => $batchNo ?: null,
'stock' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
]
]
]);
// 4. 推播 MQTT
// 4. 推播 MQTT(一律帶上目前權威的 is_locked 與雙 rev讓 App 端對齊 synced_rev
$mqttPayload = [
'slot_no' => $command->payload['slot_no'],
'stock' => $command->payload['new']['stock'],
'expiry_date' => $command->payload['new']['expiry_date'],
'batch_no' => $command->payload['new']['batch_no'] ?? null,
'slot_no' => (string) $slotNo,
'stock' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
'is_locked' => (bool) $slot->is_locked,
'lock_rev' => (int) $slot->last_app_lock_rev,
'expiry_rev' => (int) $slot->last_app_expiry_rev,
];
app(\App\Services\Machine\MqttService::class)->pushCommand(

View File

@ -2,6 +2,8 @@
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;
@ -76,6 +78,54 @@ 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.
*

View File

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

View File

@ -71,6 +71,8 @@ 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),
@ -118,7 +120,14 @@ class TransactionService
'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'])
@ -153,6 +162,8 @@ class TransactionService
$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'],
@ -208,10 +219,12 @@ class TransactionService
}
}
// RelateNumber 冪等鍵App 未帶時由後台以 serial + flow_id 推導(綠界上限 30 字)
// 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($machine->serial_no . $data['flow_id'], 0, 30);
$relateNumber = substr($data['flow_id'], 0, 30);
}
$attributes = [
@ -301,18 +314,40 @@ class TransactionService
// 執行實體庫存扣除 (真實交易化)
if ((int) ($data['dispense_status'] ?? 0) === 1) {
if ($slot) {
$slot->decrement('stock');
// 防呆:庫存已為 0或更低時不再扣減避免出現負庫存(-1)。
// 正常情況下 B660 已嚴格擋下庫存不足的領藥單;此處為下位機與後台庫存不同步時的最後防線。
if ((int) $slot->stock > 0) {
$slot->decrement('stock');
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
// 售完(庫存歸 0即清空效期與批號避免舊批貨效期殘留。
// 場景:日後管理員只補 stock 未改 expiry_date 時update_inventory 才不會
// 把過期日一起下發、把新批貨誤鎖成「暫不販售」。與機台端「賣完清效期」對稱。
// 純加法:僅在仍有殘留值時才寫,不影響既有扣庫存行為。
if ($slot->stock <= 0 && ($slot->expiry_date !== null || $slot->batch_no !== null)) {
$slot->update([
'expiry_date' => null,
'batch_no' => null,
]);
}
$this->machineService->recordStockMovement(
$slot,
-1,
$type,
$order ?? $record,
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
['order_id' => $order?->id, 'dispense_id' => $record->id]
);
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
$this->machineService->recordStockMovement(
$slot,
-1,
$type,
$order ?? $record,
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
['order_id' => $order?->id, 'dispense_id' => $record->id]
);
} else {
\Log::warning("Dispense decrement skipped: slot stock already <= 0", [
'machine_id' => $machine->id,
'slot_no' => $slotNo,
'current_stock' => (int) $slot->stock,
'flow_id' => $data['flow_id'] ?? null,
]);
}
} else {
// Log warning if slot matching fails to help diagnostic
\Log::warning("Dispense record created but slot not found for decrement", [
@ -333,14 +368,30 @@ class TransactionService
public function finalizeTransaction(array $data): Order
{
// 優先從 Root 讀取,再從 order 內讀取
$flowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
$rawFlowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
if (empty($rawFlowId)) {
throw new \Exception("Flow ID is required for finalization");
}
// 冪等性檢查:只有「終態(completed/failed/abandoned)」才直接回傳,避免重複出貨/扣庫存。
// main(晟崴/中國醫)的單永遠是 completed → 等同原本的 early-return行為不變。
// 既有單為 pending本分支先送過 pending時則「放行」往下交給 processTransaction
// 做 pending → paid/failed 的狀態轉移(含本次的 dispense/invoice
if ($flowId) {
$existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first();
// 跨機台防撞號App 端 flow_id 僅單機唯一(YYYYMMDDHHMMSSXXXX),加上機台序號前綴
// 才全域唯一(orders.flow_id 是全域 unique)。無分隔符——綠界 RelateNumber 禁特殊符號,
// 且 serial+flow_id 與線上既有 relateNumber 推導逐字等價(見 recordInvoice)。
// ACK 仍由 ProcessTransactionFinalized 回傳『原始』flow_idApp / outbox 對帳無感。
$flowId = $data['serial_no'] . $rawFlowId;
$data['flow_id'] = $flowId;
$data['order']['flow_id'] = $flowId;
return DB::transaction(function () use ($data, $flowId) {
$serialNo = $data['serial_no'];
// 冪等性 + 併發防護:終態檢查移進交易內並對既有 row 加排他鎖,
// 防止兩個 worker 同時穿透 pending 狀態而重複出貨/扣庫存/核銷。
// main(晟崴/中國醫)的單永遠是 completed → 等同原本的 early-return行為不變。
// 既有單為 pending 時放行,往下交給 processTransaction 做 pending → paid/failed 轉移。
$existingOrder = Order::withoutGlobalScopes()
->where('flow_id', $flowId)
->lockForUpdate()
->first();
if ($existingOrder && in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
Log::info("Transaction already finalized (terminal), returning existing order", [
'flow_id' => $flowId,
@ -348,12 +399,31 @@ class TransactionService
]);
return $existingOrder;
}
} else {
throw new \Exception("Flow ID is required for finalization");
}
return DB::transaction(function () use ($data, $flowId) {
$serialNo = $data['serial_no'];
// 領藥單(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'];
@ -396,6 +466,7 @@ class TransactionService
}
// 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) {
@ -408,6 +479,10 @@ class TransactionService
$dispenseItem['member_barcode'] = $order->member_barcode;
}
if (!empty($dispenseItem['slot_no'])) {
$dispensedSlots[] = (string) $dispenseItem['slot_no'];
}
$this->recordDispense($dispenseItem);
}
}
@ -433,9 +508,11 @@ class TransactionService
break;
case 6: // 取貨碼 (Pickup Code)
// 排他鎖:防並發核銷 lost-update 使 usage_count 突破 usage_limit。
$pickupCode = PickupCode::where('machine_id', $order->machine_id)
->where('id', $codeId)
->where('status', 'active')
->lockForUpdate()
->first();
if ($pickupCode) {
$newCount = $pickupCode->usage_count + 1;
@ -457,7 +534,11 @@ class TransactionService
'action' => $isUsedUp ? 'used' : 'consume',
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
'raw_data' => [
'slot' => $pickupCode->slot_no,
// 取貨碼綁「商品」時 pickupCode->slot_no 為 null
// 改記「實際出貨的貨道/櫃號」(dispense),取不到才退回取貨碼綁定貨道。
'slot' => !empty($dispensedSlots)
? implode(', ', array_unique($dispensedSlots))
: $pickupCode->slot_no,
'code' => $pickupCode->code,
'count' => $newCount,
'limit' => $pickupCode->usage_limit
@ -467,6 +548,8 @@ class TransactionService
break;
case 5: // 通行碼 (Pass Code)
// 注意:通行碼「用掉一次」的計數改在 B670 驗證(verifyPassCode)當下處理,
// 因為 App 對通行碼會略過交易結案上報,此 case 5 實務上不會被觸發。
// 建立通行碼核銷日誌
PassCodeLog::create([
'company_id' => $order->company_id,
@ -483,9 +566,11 @@ 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;
@ -517,6 +602,186 @@ class TransactionService
});
}
/**
* 後台手動補單(機台斷線/漏報時,由系統管理員人工補登銷售紀錄)。
*
* 與機台上報共用底層 recordDispense()(扣庫存)與 recordInvoice()+IssueInvoiceJob後台開發票
* 但訂單標記 order_type=manual、created_by=操作者flow_id 'MAN' 前綴與機台上報區隔,
* 一眼可辨識並天然避開 orders.flow_id 唯一鍵衝突(長度 ≤30、純英數亦相容綠界 RelateNumber
* 扣庫存與開發票皆為選配(由呼叫端決定);權限把關(限系統管理員)在 Controller。
*
* @param array $data {
* serial_no, created_by, payment_type, occurred_at?,
* items: [{product_id, slot_no?, product_name?, price, quantity}],
* deduct_stock?: bool, remark?,
* invoice?: null|{business_tax_id?, carrier_id?, love_code?} // 給定才開立
* }
*/
public function createManualOrder(array $data): Order
{
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$items = $data['items'] ?? [];
if (empty($items)) {
throw new \InvalidArgumentException('Manual order requires at least one item');
}
$totalAmount = collect($items)
->sum(fn ($i) => (float) $i['price'] * (int) $i['quantity']);
// 補單常為事後補登machine_time / created_at 採實際發生時間,使銷售報表落在正確日期。
$occurredAt = !empty($data['occurred_at'])
? \Carbon\Carbon::parse($data['occurred_at'])
: now();
$flowId = $this->generateManualFlowId();
return DB::transaction(function () use ($machine, $flowId, $items, $totalAmount, $occurredAt, $data) {
// 1. 建立訂單(終態 completed + 付款成功)
$order = Order::create([
'company_id' => $machine->company_id,
'flow_id' => $flowId,
'order_no' => $this->generateOrderNo(),
'order_type' => Order::TYPE_MANUAL,
'created_by' => $data['created_by'] ?? null,
'machine_id' => $machine->id,
'payment_type' => (int) ($data['payment_type'] ?? 0),
'total_amount' => $totalAmount,
'original_amount' => $totalAmount,
'discount_amount' => 0,
'pay_amount' => $totalAmount,
'payment_status' => Order::PAYMENT_STATUS_SUCCESS,
'payment_at' => $occurredAt,
'machine_time' => $occurredAt,
'status' => Order::STATUS_COMPLETED,
'delivery_status' => Order::DELIVERY_STATUS_SUCCESS,
'remark' => $data['remark'] ?? null,
'metadata' => ['source' => 'manual'],
]);
// created_at 對齊實際發生時間(補單事後補登,報表需落在真實日期而非補登當下)
$order->forceFill(['created_at' => $occurredAt])->save();
$this->createOrderItems($order, $items);
// 2. 扣庫存(選配):逐單位呼叫 recordDispense與機台上報一致成功才扣 1庫存為 0 時不為負)
if (!empty($data['deduct_stock'])) {
foreach ($items as $item) {
$qty = max(1, (int) ($item['quantity'] ?? 1));
for ($n = 0; $n < $qty; $n++) {
$this->recordDispense([
'serial_no' => $machine->serial_no,
'flow_id' => $flowId,
'order_id' => $order->id,
'slot_no' => $item['slot_no'] ?? null,
'product_id' => $item['product_id'] ?? null,
'amount' => $item['price'],
'dispense_status' => 1,
'machine_time' => $occurredAt,
]);
}
}
}
// 3. 開立電子發票(選配):建 pending 發票 → commit 後派 IssueInvoiceJob 去綠界開立。
// 閘門:唯有機台「電子發票開關」開啟才開立(防禦縱深,與 finalizeTransaction 一致;前端已隱藏選項)。
if (!empty($data['invoice']) && $machine->tax_invoice_enabled) {
$invoiceData = $data['invoice'];
$invoiceData['serial_no'] = $machine->serial_no;
$invoiceData['flow_id'] = $flowId;
$invoiceData['order_id'] = $order->id;
$invoiceData['amount'] = $totalAmount;
$invoiceData['status'] = Invoice::STATUS_PENDING;
$invoiceData['machine_time'] = $occurredAt;
$invoice = $this->recordInvoice($invoiceData);
if ($invoice->status === Invoice::STATUS_PENDING) {
DB::afterCommit(function () use ($invoice) {
\App\Jobs\Transaction\IssueInvoiceJob::dispatch($invoice->id);
});
}
} elseif (!empty($data['invoice'])) {
Log::warning('手動補單要求開立發票,但機台已關閉電子發票,略過開立', [
'machine_id' => $machine->id,
'flow_id' => $flowId,
'order_id' => $order->id,
]);
}
return $order;
});
}
/** 產生手動補單專用 flow_id'MAN' 前綴全域唯一、純英數、≤30 字)。 */
protected function generateManualFlowId(): string
{
do {
$flowId = 'MAN' . now()->format('YmdHis') . strtoupper(bin2hex(random_bytes(3)));
} while (Order::withoutGlobalScopes()->where('flow_id', $flowId)->exists());
return $flowId;
}
/**
* 領藥單出貨回報(獨立於銷售流程)。
*
* 機台逐道出貨後,以 transaction/finalize dispense[](每出貨一單位一筆,含 slot_no/dispense_status回報。
* 本方法:建立出貨紀錄(沿用 recordDispense成功則扣 1 庫存)→ 彙整 delivery_status
* 將訂單轉為終態 completed冪等重送會在 finalizeTransaction 開頭 early-return)→ 標記領藥碼已領 + 核銷日誌。
*/
protected function finalizePharmacyDispense(Order $order, array $data): Order
{
return DB::transaction(function () use ($order, $data) {
$serialNo = $data['serial_no'] ?? $order->machine?->serial_no;
if (!empty($data['dispense'])) {
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
foreach ($dispenseList as $dispenseItem) {
$dispenseItem['serial_no'] = $serialNo;
$dispenseItem['flow_id'] = $order->flow_id;
$dispenseItem['order_id'] = $order->id;
$this->recordDispense($dispenseItem); // 建 DispenseRecorddispense_status=1 時扣 1 庫存
}
$deliveryStatus = $this->resolveDeliveryStatusFromDispense($data['dispense']);
} else {
// 無出貨明細視為失敗
$deliveryStatus = Order::DELIVERY_STATUS_FAILED;
}
$order->update([
'delivery_status' => $deliveryStatus,
'status' => Order::STATUS_COMPLETED, // 終態 → 後續重送冪等
'machine_time' => $data['machine_time'] ?? $order->machine_time,
]);
// 標記領藥碼已領B660 通常已標 used此處保險並留核銷日誌
$pickupCode = $order->pickupCode;
if ($pickupCode) {
if ($pickupCode->status !== 'used') {
$pickupCode->update(['status' => 'used', 'used_at' => $pickupCode->used_at ?? now()]);
}
PickupCodeLog::create([
'company_id' => $order->company_id,
'machine_id' => $order->machine_id,
'pickup_code_id' => $pickupCode->id,
'order_id' => $order->id,
'action' => 'used',
'remark' => "log.pickup.pharmacy_dispensed",
'raw_data' => [
'order_no' => $order->order_no,
'delivery_status' => $deliveryStatus,
],
]);
}
Log::info('Pharmacy pickup dispense finalized', [
'flow_id' => $order->flow_id,
'delivery_status' => $deliveryStatus,
]);
return $order;
});
}
/**
* Resolve order-level delivery status from item-level dispense results.
*/

View File

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

View File

@ -183,7 +183,7 @@ return [
],
'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環境溫度監控",
'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',
@ -258,6 +258,7 @@ return [
'WelcomeGift' => false,
'MemberSystem' => false,
'AmbientTemp' => false,
'PharmacyPickup' => false,
],
'ShoppingMode' => 'basic',
'LangSet' => [

View File

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

View File

@ -0,0 +1,46 @@
<?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');
}
});
}
};

View File

@ -0,0 +1,27 @@
<?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();
});
}
};

View File

@ -0,0 +1,35 @@
<?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');
}
};

View File

@ -0,0 +1,28 @@
<?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
{
// 資料校正,無法可靠還原
}
};

View File

@ -0,0 +1,31 @@
<?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']);
});
}
};

View File

@ -0,0 +1,42 @@
<?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');
});
}
}
};

View File

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

View File

@ -0,0 +1,60 @@
<?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'));
}
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
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');
});
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 建立 Laravel 標準 cache / cache_locks 表。
* 對應 dev 的修正 55cf6b8CACHE_STORE=database 須有這兩張表,
* 否則 ProcessHeartbeat 等的 Cache::put 會因缺表而失敗。
*/
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('cache')) {
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
}
if (!Schema::hasTable('cache_locks')) {
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
}
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 取貨碼 / 通行碼 批次新增:以 batch_no 標記同一批產生的碼,供後台批次下載清單使用。
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->string('batch_no', 32)->nullable()->after('slug')->index();
});
Schema::table('pass_codes', function (Blueprint $table) {
$table->string('batch_no', 32)->nullable()->after('slug')->index();
});
}
public function down(): void
{
Schema::table('pickup_codes', function (Blueprint $table) {
$table->dropIndex(['batch_no']);
$table->dropColumn('batch_no');
});
Schema::table('pass_codes', function (Blueprint $table) {
$table->dropIndex(['batch_no']);
$table->dropColumn('batch_no');
});
}
};

View File

@ -0,0 +1,24 @@
<?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('order_items', function (Blueprint $table) {
// 消費者選擇的貨道/格子號(格子櫃=櫃號如 501VMC=貨道號)。
// 由 App transaction finalize 的 items[].slot_no 帶入;不管出貨成功或失敗都記錄。
$table->string('slot_no', 20)->nullable()->after('product_name');
});
}
public function down(): void
{
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('slot_no');
});
}
};

View File

@ -0,0 +1,24 @@
<?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('pass_codes', function (Blueprint $table) {
// 可用次數上限null=無限次(維持原行為)。勾「只能使用一次」時=1。
$table->unsignedInteger('usage_limit')->nullable()->after('status');
$table->unsignedInteger('usage_count')->default(0)->after('usage_limit');
});
}
public function down(): void
{
Schema::table('pass_codes', function (Blueprint $table) {
$table->dropColumn(['usage_limit', 'usage_count']);
});
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 現金支付面額明細orders 增加 cash_detail JSON 欄位。
* 記錄本筆現金交易「收」進的各面額張數(仟/伍佰//50/10/5/1
* 供銷售紀錄在支付金額下方顯示「收:仟:x、伍佰:x… :x」。
* 找零金額沿用既有 change_amount不另存。
* 僅現金(payment_type=9)交易會帶此欄,其餘交易為 null,不影響既有行為。
*/
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
if (!Schema::hasColumn('orders', 'cash_detail')) {
$table->json('cash_detail')->nullable()
->comment('現金收款面額明細 {b1000,b500,b100,c50,c10,c5,c1}')
->after('change_amount');
}
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
if (Schema::hasColumn('orders', 'cash_detail')) {
$table->dropColumn('cash_detail');
}
});
}
};

View File

@ -39,6 +39,7 @@ class RoleSeeder extends Seeder
'menu.sales.promotions',
'menu.sales.pass-codes',
'menu.sales.store-gifts',
'menu.sales.pharmacy-pickup',
'menu.analysis',
'menu.analysis.change-stock',
'menu.analysis.machine-reports',

View File

@ -1,4 +1,14 @@
{
"Search Product...": "Search Product...",
"Generate Quantity": "Generate Quantity",
"Batch mode: codes are auto-generated and the custom code is ignored.": "Batch mode: codes are auto-generated and the custom code is ignored.",
"Batch generated successfully": "Batch generated successfully",
"codes": "codes",
"Download List (CSV)": "Download List (CSV)",
":count pickup codes generated": ":count pickup codes generated",
":count pass codes created": ":count pass codes created",
"(deducted by issued pickup orders)": "(deducted by issued pickup orders)",
"-- Select Machine --": "-- Select Machine --",
"10s": "10s",
"15 Seconds": "15 Seconds",
"1920x1080 (Max 10MB)": "1920x1080 (Max 10MB)",
@ -27,7 +37,6 @@
"APP Version": "APP Version",
"APP_ID": "APP_ID",
"APP_KEY": "APP_KEY",
"Abandoned": "Abandoned",
"Abnormal": "Abnormal",
"Account": "Account",
"Account :name status has been changed to :status.": "Account :name status has been changed to :status.",
@ -111,9 +120,11 @@
"All Categories": "All Categories",
"All Command Types": "All Command Types",
"All Companies": "All Companies",
"All Dispense Statuses": "All Dispense Statuses",
"All Levels": "All Levels",
"All Machines": "All Machines",
"All Normal": "All Normal",
"All Payment Statuses": "All Payment Statuses",
"All Permissions": "All Permissions",
"All Products (ALL)": "All Products (ALL)",
"All Slots": "All Slots",
@ -217,9 +228,12 @@
"Auto-load succeeded but purchase failed": "Auto-load succeeded but purchase failed",
"Automatically calculate replenishment needs based on machine capacity": "Automatically calculate replenishment needs based on machine capacity",
"Availability": "Availability",
"Available": "Available",
"Available Machines": "Available Machines",
"Avatar updated successfully.": "Avatar updated successfully.",
"Avg Cycle": "Avg Cycle",
"Awaiting Pickup": "Awaiting Pickup",
"B2C (member carrier)": "B2C (member carrier)",
"Back to History": "Back to History",
"Back to List": "Back to List",
"Badge Settings": "Badge Settings",
@ -266,6 +280,7 @@
"Cancel Pass Code": "Cancel Pass Code",
"Cancel Pickup Code": "Cancel Pickup Code",
"Cancel Purchase": "Cancel Purchase",
"Cancel this pharmacy pickup order?": "Cancel this pharmacy pickup order?",
"Cancelled": "Cancelled",
"Cannot Delete Role": "Cannot Delete Role",
"Cannot cancel this order": "Cannot cancel this order",
@ -274,6 +289,7 @@
"Cannot delete company with active accounts.": "Cannot delete company with active accounts.",
"Cannot delete model that is currently in use by machines.": "Cannot delete model that is currently in use by machines.",
"Cannot delete role with active users.": "Cannot delete role with active users.",
"Cannot delete the company main account while other accounts exist. Please transfer the main account role first.": "Cannot delete the company main account while other accounts exist. Please transfer the main account role first.",
"Cannot delete warehouse with existing stock": "Cannot delete warehouse with existing stock",
"Cannot match original stored-value transaction": "Cannot match original stored-value transaction",
"Card Machine System": "Card Machine System",
@ -321,6 +337,7 @@
"Choose File": "Choose File",
"Choose Logo": "Choose Logo",
"Choose Overall BG": "Choose Overall BG",
"Citizen certificate carrier": "Citizen certificate carrier",
"Clear": "Clear",
"Clear Abnormal Status": "Clear Abnormal Status",
"Clear Filter": "Clear Filter",
@ -358,6 +375,7 @@
"Company Level": "Company Level",
"Company Name": "Company Name",
"Company Settings": "Company Settings",
"Company Tax ID": "Company Tax ID",
"Complete movement history log": "Complete movement history log",
"Completed": "Completed",
"Completed At": "Completed At",
@ -425,10 +443,13 @@
"Courier/Dispatcher": "Courier/Dispatcher",
"Courier/Replenisher": "Courier/Replenisher",
"Create": "Create",
"Create & Generate QR": "Create & Generate QR",
"Create Config": "Create Config",
"Create Machine": "Create Machine",
"Create New Role": "Create New Role",
"Create Order": "Create Order",
"Create Payment Config": "Create Payment Config",
"Create Pharmacy Pickup Order": "Create Pharmacy Pickup Order",
"Create Product": "Create Product",
"Create Replenishment Order": "Create Replenishment Order",
"Create Role": "Create Role",
@ -441,6 +462,7 @@
"Create stock replenishment for specific machines": "Create stock replenishment for specific machines",
"Create stock transfers between warehouses and machine returns": "Create stock transfers between warehouses and machine returns",
"Create stock-in orders": "Create stock-in orders",
"Created": "Created",
"Created At": "Created At",
"Created By": "Created By",
"Creation Time": "Creation Time",
@ -510,6 +532,7 @@
"Date Range": "Date Range",
"Day Before": "Day Before",
"Days": "Days",
"Deduct stock": "Deduct stock",
"Default": "Default",
"Default BG": "Default BG",
"Default Card BG": "Default Card BG",
@ -521,6 +544,7 @@
"Default Temp Limits": "Default Temp Limits",
"Default Temp Lower Limit (°C)": "Default Temp Lower Limit (°C)",
"Default Temp Upper Limit (°C)": "Default Temp Upper Limit (°C)",
"Defaults to now if empty": "Defaults to now if empty",
"Define and manage security roles and permissions.": "Define and manage security roles and permissions.",
"Define custom temperature limits or inherit from model": "Define custom temperature limits or inherit from model",
"Define new third-party payment parameters": "Define new third-party payment parameters",
@ -602,7 +626,10 @@
"Displayed below Logo on the left (defaults to customer name).": "Displayed below Logo on the left (defaults to customer name).",
"Displayed below main title on the left.": "Displayed below main title on the left.",
"Displaying": "Displaying",
"Donation": "Donation",
"Donation invoices cannot be printed": "Donation invoices cannot be printed",
"Donation love code": "Donation love code",
"Donation love code must be 3 to 7 digits": "Donation love code must be 3 to 7 digits",
"Done": "Done",
"Door Closed": "Door Closed",
"Door Opened": "Door Opened",
@ -750,14 +777,21 @@
"Expiry Time": "Expiry Time",
"Expiry date tracking and warnings": "Expiry date tracking and warnings",
"Export Report": "Export Report",
"Export": "Export",
"Export to CSV": "Export to CSV",
"Export Machine Inventory": "Export Machine Inventory",
"Please select at least one machine": "Please select at least one machine",
"No machines": "No machines",
"Export to Excel": "Export to Excel",
"External URL": "External URL",
"Failed": "Failed",
"Failed to create manual order": "Failed to create manual order",
"Failed to fetch machine data.": "Failed to fetch machine data.",
"Failed to get invoice print URL": "Failed to get invoice print URL",
"Failed to load permissions": "Failed to load permissions",
"Failed to load preview": "Failed to load preview",
"Failed to load pricing": "Failed to load pricing",
"Failed to load slots": "Failed to load slots",
"Failed to load tab content": "Failed to load tab content",
"Failed to resolve issues.": "Failed to resolve issues.",
"Failed to save permissions.": "Failed to save permissions.",
@ -869,6 +903,7 @@
"Flow ID / Slot / Time": "Flow ID / Slot / Time",
"Flow ID / Status": "Flow ID / Status",
"Flow ID / Time": "Flow ID / Time",
"For sales not reported due to disconnection. System administrators only.": "For sales not reported due to disconnection. System administrators only.",
"Force End Session": "Force End Session",
"Force end current session": "Force end current session",
"Forced Update": "Forced Update",
@ -890,6 +925,7 @@
"Generate Replenishment Order": "Generate Replenishment Order",
"Generate and manage one-time pickup codes for customers": "Generate and manage one-time pickup codes for customers",
"Gift Definitions": "Gift Definitions",
"Global": "Global",
"Global roles accessible by all administrators.": "Global roles accessible by all administrators.",
"Go Authorize": "Go Authorize",
"Go Back": "Go Back",
@ -928,10 +964,13 @@
"Identification": "Identification",
"Identity & Codes": "Identity & Codes",
"Identity and Codes": "Identity and Codes",
"Ignore busy state (use when stuck)": "Ignore busy state (use when stuck)",
"Image": "Image",
"Image Downloaded": "Image Downloaded",
"Immediate": "Immediate",
"Import Excel": "Import Excel",
"Export CSV": "Export CSV",
"Export Excel": "Export Excel",
"Import Products": "Import Products",
"Import Staff Cards": "Import Staff Cards",
"Import failed": "Import failed",
@ -952,8 +991,12 @@
"Initial contract registration": "Initial contract registration",
"Installation": "Installation",
"Installation Location": "Installation Location",
"Insufficient stock for :name (requested :qty, available :available).": "Insufficient stock for :name (requested :qty, available :available).",
"Insufficient stock for transfer": "Insufficient stock for transfer",
"Insufficient stored-value / e-wallet balance": "Insufficient stored-value / e-wallet balance",
"Invalid citizen digital certificate carrier": "Invalid citizen digital certificate carrier",
"Invalid invoice type": "Invalid invoice type",
"Invalid mobile barcode carrier": "Invalid mobile barcode carrier",
"Invalid personnel selection": "Invalid personnel selection",
"Invalid status transition": "Invalid status transition",
"Inventory": "Inventory",
@ -972,8 +1015,13 @@
"Invoice Number / Time": "Invoice Number / Time",
"Invoice Status": "Invoice Status",
"Invoice Store ID": "Invoice Store ID",
"Invoice Type": "Invoice Type",
"Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.": "Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.",
"Issue e-invoice": "Issue e-invoice",
"Issue pharmacy pickup orders from the backend; patient scans QR to dispense.": "Issue pharmacy pickup orders from the backend; patient scans QR to dispense.",
"Issued": "Issued",
"Issued At": "Issued At",
"Issued via ECPay in the background after submission.": "Issued via ECPay in the background after submission.",
"Item List": "Item List",
"Items": "Items",
"JKO Pay": "JKO Pay",
@ -1009,6 +1057,7 @@
"Latitude": "Latitude",
"Lease": "Lease",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "Leave blank to deploy immediately, or set a future time to schedule a background release.",
"Leave blank to use the global product price. Member price will be capped at the machine selling price.": "Leave blank to use the global product price. Member price will be capped at the machine selling price.",
"Leave empty for permanent code": "Leave empty for permanent code",
"Leave empty or 0 for permanent code": "Leave empty or 0 for permanent code",
"Leave empty to inherit company settings": "Leave empty to inherit company settings",
@ -1032,15 +1081,18 @@
"Loading Data": "Loading Data",
"Loading failed": "Loading failed",
"Loading machines...": "Loading machines...",
"Loading slots...": "Loading slots...",
"Location": "Location",
"Location found!": "Location found!",
"Location not found": "Location not found",
"Lock": "Lock",
"Lock (Pause Sale)": "Lock (Pause Sale)",
"Lock Now": "Lock Now",
"Lock Page": "Lock Page",
"Lock Page Lock": "Lock Page Lock",
"Lock Page Unlock": "Lock Page Unlock",
"Locked": "Locked",
"Locked (Sale Paused)": "Locked (Sale Paused)",
"Locked Page": "Locked Page",
"Log Details": "Log Details",
"Log Time": "Log Time",
@ -1078,6 +1130,7 @@
"Machine Flavor": "Machine Flavor",
"Machine Flow ID": "Machine Flow ID",
"Machine Flow ID / Time": "Machine Flow ID / Time",
"Machine Force Reboot": "Machine Force Reboot",
"Machine Images": "Machine Images",
"Machine Inbound Confirmation": "Machine Inbound Confirmation",
"Machine Info": "Machine Info",
@ -1093,6 +1146,8 @@
"Machine Model Settings": "Machine Model Settings",
"Machine Name": "Machine Name",
"Machine Permissions": "Machine Permissions",
"Machine Price": "Machine Price",
"Machine Pricing": "Machine Pricing",
"Machine Reboot": "Machine Reboot",
"Machine Registry": "Machine Registry",
"Machine Replenishment": "Machine Replenishment",
@ -1111,6 +1166,7 @@
"Machine Stock Movements": "Machine Stock Movements",
"Machine System Settings": "Machine System Settings",
"Machine Utilization": "Machine Utilization",
"Machine company changed": "Machine company changed",
"Machine created successfully.": "Machine created successfully.",
"Machine deleted successfully.": "Machine deleted successfully.",
"Machine has reconnected to the server and resumed normal heartbeat.": "Machine has reconnected to the server and resumed normal heartbeat.",
@ -1123,6 +1179,8 @@
"Machine normal": "Machine normal",
"Machine not found": "Machine not found",
"Machine permissions updated successfully.": "Machine permissions updated successfully.",
"Machine pricing saved and sync command pushed.": "Machine pricing saved and sync command pushed.",
"Machine pricing updated": "Machine pricing updated",
"Machine settings updated successfully.": "Machine settings updated successfully.",
"Machine temperature exceeds limit": "Machine temperature exceeds limit, purchase suspended",
"Machine to Warehouse": "Machine to Warehouse",
@ -1166,9 +1224,13 @@
"Manage your warehouses, including main and branch warehouses": "Manage your warehouses, including main and branch warehouses",
"Management of operational parameters": "Management of operational parameters",
"Management of operational parameters and models": "Management of operational parameters and models",
"Manual": "Manual",
"Manual Entry": "Manual Entry",
"Manual Order Entry": "Manual Order Entry",
"Manual Sync Ads": "Manual Sync Ads",
"Manual Sync Products": "Manual Sync Products",
"Manual Sync Settings": "Manual Sync Settings",
"Manual order created: :no": "Manual order created: :no",
"Manufacturer": "Manufacturer",
"Marketing and Loyalty": "Marketing and Loyalty",
"Material Code": "Material Code",
@ -1186,6 +1248,7 @@
"Max Capacity:": "Max Capacity:",
"Max Stock": "Max Stock",
"Maximum file size: 100MB": "Maximum file size: 100MB",
"Medicine": "Medicine",
"Member": "Member",
"Member & External": "Member & External",
"Member + 1": "Member + 1",
@ -1239,6 +1302,7 @@
"Missing RelateNumber, cannot query": "Missing RelateNumber, cannot query",
"Mobile Pay": "Mobile Pay",
"Mobile Payment": "Mobile Payment",
"Mobile barcode carrier": "Mobile barcode carrier",
"Model": "Model",
"Model Default": "Model Default",
"Model Name": "Model Name",
@ -1311,6 +1375,7 @@
"No history records": "No history records",
"No images uploaded": "No images uploaded",
"No invoice issued, cannot void": "No invoice issued, cannot void",
"No items added yet": "No items added yet",
"No location set": "No location set",
"No login history yet": "No login history yet",
"No logs found": "No logs found",
@ -1318,24 +1383,29 @@
"No machines assigned": "No machines assigned",
"No machines available": "No machines available",
"No machines available in this company.": "No machines available in this company.",
"No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).": "No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).",
"No machines found": "No machines found",
"No machines in this status": "No machines in this status",
"No machines match your criteria": "No machines match your criteria",
"No maintenance records found": "No maintenance records found",
"No matching logs found": "No matching logs found",
"No matching machines": "No matching machines",
"No matching products": "No matching products",
"No materials available": "No materials available",
"No movement records found": "No movement records found",
"No movements found": "No movements found",
"No operation logs found": "No operation logs found",
"No pass codes found": "No pass codes found",
"No permissions": "No permissions",
"No pharmacy pickup orders yet.": "No pharmacy pickup orders yet.",
"No pickup codes found": "No pickup codes found",
"No product data matching search criteria": "No product data matching search criteria",
"No product record found": "No product record found",
"No products available for this company": "No products available for this company",
"No products found": "No products found",
"No products found in this warehouse": "No products found in this warehouse",
"No products found matching your criteria.": "No products found matching your criteria.",
"No products on this machine yet": "No products on this machine yet",
"No records found": "No records found",
"No related detail found": "No related detail found",
"No remark": "No remark",
@ -1366,6 +1436,7 @@
"Not Used": "Not Used",
"Not Used Description": "Not Used Description",
"Not a co-branded card": "Not a co-branded card",
"Not stocked on this machine": "Not stocked on this machine",
"Note": "Note",
"Note (optional)": "Note (optional)",
"Notes": "Notes",
@ -1397,6 +1468,7 @@
"Offline Machines": "Offline Machines",
"Old": "Old",
"Old Value": "Old Value",
"On Sale": "On Sale",
"Once": "Once",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.",
@ -1415,6 +1487,7 @@
"Only machines under the same company can be cloned for security.": "Only machines under the same company can be cloned for security.",
"Only pending/failed invoices can be queried": "Only pending/failed invoices can be queried",
"Only pending/failed invoices can be re-issued": "Only pending/failed invoices can be re-issued",
"Only system administrators can create manual orders": "Only system administrators can create manual orders",
"Only system administrators can void invoices": "Only system administrators can void invoices",
"Only system roles can be assigned to platform administrative accounts.": "Only system roles can be assigned to platform administrative accounts.",
"Operation Logs": "Operation Logs",
@ -1496,6 +1569,7 @@
"Page 69": "Page 69",
"Page 7": "Page 7",
"Page Lock Status": "Page Lock Status",
"Paid": "Paid",
"Parameters": "Parameters",
"Partial Dispense Success": "Partial Dispense Success",
"Pass Code": "Pass Code",
@ -1516,6 +1590,7 @@
"Payment Amount": "Payment Amount",
"Payment Buffer Seconds": "Payment Buffer Seconds",
"Payment Config": "Payment Config",
"No payment config is selected (Not Used). Save this machine without any payment config?": "No payment config is selected (Not Used). Save this machine without any payment config?",
"Payment Configuration": "Payment Configuration",
"Payment Configuration created successfully.": "Payment Configuration created successfully.",
"Payment Configuration deleted successfully.": "Payment Configuration deleted successfully.",
@ -1536,13 +1611,25 @@
"Permissions": "Permissions",
"Permissions updated successfully": "Permissions updated successfully",
"Personnel assigned successfully": "Personnel assigned successfully",
"Pharmacy Pickup (Rx)": "Pharmacy Pickup (Rx)",
"Pharmacy Pickup Orders": "Pharmacy Pickup Orders",
"Pharmacy pickup order cancelled.": "Pharmacy pickup order cancelled.",
"Pharmacy pickup order created: :no": "Pharmacy pickup order created: :no",
"Phone": "Phone",
"Photo Slot": "Photo Slot",
"Pi Pay": "Pi Pay",
"Pi Wallet": "Pi Wallet",
"Picked Up": "Picked Up",
"Picked up": "Picked up",
"Picked up Time": "Picked up Time",
"Pickup Code": "Pickup Code",
"Pending Use": "Pending Use",
"Generate pickup code? (Valid for pickup within 7 days)": "Generate pickup code? (Valid for pickup within 7 days)",
"Pickup code generated.": "Pickup code generated.",
"Pickup code already generated.": "Pickup code already generated.",
"This order is not eligible for a pickup code.": "This order is not eligible for a pickup code.",
"No undelivered items to compensate.": "No undelivered items to compensate.",
"Machine not found.": "Machine not found.",
"Pickup Code (8 Digits)": "Pickup Code (8 Digits)",
"Pickup Codes": "Pickup Codes",
"Pickup Module": "Pickup Module",
@ -1585,8 +1672,10 @@
"Please select a machine to view and manage its advertisements.": "Please select a machine to view and manage its advertisements.",
"Please select a machine to view metrics": "Please select a machine to view metrics",
"Please select a material": "Please select a material",
"Please select a product": "Please select a product",
"Please select a slot": "Please select a slot",
"Please select an APK file to upload": "Please select an APK file to upload",
"Please select at least one medicine with quantity.": "Please select at least one medicine with quantity.",
"Please select warehouse and machine": "Please select warehouse and machine",
"Please use our standard template to ensure data compatibility.": "Please use our standard template to ensure data compatibility.",
"PlusPay": "PlusPay",
@ -1605,8 +1694,10 @@
"Preview Mode - Interactive Vending Portal Mockup": "Preview Mode - Interactive Vending Portal Mockup",
"Preview Mode: Login functionality is disabled.": "Preview Mode: Login functionality is disabled.",
"Previous": "Previous",
"Price": "Price",
"Price / Member": "Price / Member",
"Pricing Information": "Pricing Information",
"Pricing Slip No.": "Pricing Slip No.",
"Primary Theme Color": "Primary Theme Color",
"Print": "Print",
"Print & PDF": "Print & PDF",
@ -1714,6 +1805,7 @@
"Recent Commands": "Recent Commands",
"Recent Login": "Recent Login",
"Recently reported errors or warnings in logs": "Recently reported errors or warnings in logs",
"Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.": "Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.",
"Recommended: 320x320 (Auto-cropped)": "Recommended: 320x320 (Auto-cropped)",
"Records": "Records",
"Refresh Now": "Refresh Now",
@ -1793,7 +1885,9 @@
"Running Status": "Running Status",
"SYSTEM": "SYSTEM",
"Safety Stock": "Safety Stock",
"Sale Paused": "Sale Paused",
"Sale Price": "Sale Price",
"Sale Status": "Sale Status",
"Sales": "Sales",
"Sales Activity": "Sales Activity",
"Sales Amount": "Sales Amount",
@ -1805,6 +1899,7 @@
"Sales Today": "Sales Today",
"Sales revenue minus product cost net value": "Sales revenue minus product cost net value",
"Save": "Save",
"Save & Sync": "Save & Sync",
"Save Changes": "Save Changes",
"Save Config": "Save Config",
"Save Material": "Save Material",
@ -1812,6 +1907,8 @@
"Save Settings": "Save Settings",
"Save Version": "Save Version",
"Save error:": "Save error:",
"Save failed": "Save failed",
"Saved": "Saved",
"Saved.": "Saved.",
"Saving...": "Saving...",
"Scale level and access control": "Scale level and access control",
@ -1821,6 +1918,7 @@
"Scan QR Code": "Scan QR Code",
"Scan Store ID": "Scan Store ID",
"Scan Term ID": "Scan Term ID",
"Scan the QR code or enter the pickup code at the machine to collect your medicine.": "Scan the QR code or enter the pickup code at the machine to collect your medicine.",
"Scan this code at the machine or share the link with the customer.": "Scan this code at the machine or share the link with the customer.",
"Scan this code at the machine to authorize testing or maintenance.": "Scan this code at the machine to authorize testing or maintenance.",
"Scan this code to quickly access the maintenance form for this device.": "Scan this code to quickly access the maintenance form for this device.",
@ -1898,6 +1996,8 @@
"Select Target Slot": "Select Target Slot",
"Select Update Time (Optional)": "Select Update Time (Optional)",
"Select Warehouse": "Select Warehouse",
"Select a machine": "Select a machine",
"Select a machine to choose products": "Select a machine to choose products",
"Select a machine to copy settings from...": "Select a machine to copy settings from...",
"Select a machine to deep dive": "Select a machine to deep dive",
"Select a material to play on this machine": "Select a material to play on this machine",
@ -1907,6 +2007,8 @@
"Select date to sync data": "Select date to sync data",
"Select flavor...": "Select flavor...",
"Select future time...": "Select update time...",
"Select payment type": "Select payment type",
"Select slot / product": "Select slot / product",
"Select up to :max languages the machine can switch between. The first one is the default.": "Select up to :max languages the machine can switch between. The first one is the default.",
"Select...": "Select...",
"Selected": "Selected",
@ -1974,6 +2076,7 @@
"Source Machine": "Source Machine",
"Source Warehouse": "Source Warehouse",
"Source of temperature limit settings": "Source of temperature limit settings",
"Spec": "Spec",
"Special Permission": "Special Permission",
"Specification": "Specification",
"Specifications": "Specifications",
@ -2042,6 +2145,7 @@
"Sub Account Management": "Sub Account Management",
"Sub Account Roles": "Sub Account Roles",
"Sub Accounts": "Sub Accounts",
"Sub-accounts at this level cannot create further sub-accounts.": "Sub-accounts at this level cannot create further sub-accounts.",
"Sub-actions": "Sub-actions",
"Sub-machine": "Sub-machine",
"Sub-machine Status": "Sub-machine Status",
@ -2050,6 +2154,7 @@
"Submachine Exception": "Submachine Exception",
"Submachine Exception (B013)": "Submachine Exception (B013)",
"Submit Record": "Submit Record",
"Submitting...": "Submitting...",
"Subtotal": "Subtotal",
"Success": "Success",
"Super Admin": "Super Admin",
@ -2103,6 +2208,7 @@
"Target hardware flavor for this APK": "Target hardware flavor for this APK",
"Tax ID": "Tax ID",
"Tax ID (Optional)": "Tax ID (Optional)",
"Tax ID must be 8 digits": "Tax ID must be 8 digits",
"Tax System": "Tax System",
"Taxation System": "Taxation System",
"Temp Alert Range": "Temp Alert Range",
@ -2128,18 +2234,24 @@
"The Super Admin role cannot be deleted.": "The Super Admin role cannot be deleted.",
"The Super Admin role is immutable.": "The Super Admin role is immutable.",
"The Super Admin role name cannot be modified.": "The Super Admin role name cannot be modified.",
"The company admin role cannot be deleted.": "The company admin role cannot be deleted.",
"The image archive contains too many files": "The image archive contains too many files",
"The image archive is too large when extracted": "The image archive is too large when extracted",
"The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.",
"The image is too large. Please upload an image smaller than 5MB.": "The image is too large. Please upload an image smaller than 5MB.",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "The machine will be notified to fetch the latest system settings. It may take a moment to apply.",
"The selected product does not belong to this machine's company.": "The selected product does not belong to this machine's company.",
"This Month": "This Month",
"This Week": "This Week",
"This account does not belong to this company.": "This account does not belong to this company.",
"This invoice has no printable copy": "This invoice has no printable copy",
"This is a system administrator role. Its name is locked to ensure system stability.": "This is a system administrator role. Its name is locked to ensure system stability.",
"This machine currently has no medicine in stock.": "This machine currently has no medicine in stock.",
"This machine has a pending command. Please wait.": "This machine has a pending command. Please wait.",
"This machine has e-invoice disabled; cannot issue": "This machine has e-invoice disabled; cannot issue",
"This machine has no ECPay invoice settings": "This machine has no ECPay invoice settings",
"This pharmacy pickup order can no longer be cancelled.": "This pharmacy pickup order can no longer be cancelled.",
"This product is not stocked on the selected machine; the code may not be redeemable until restocked.": "This product is not stocked on the selected machine; the code may not be redeemable until restocked.",
"This role belongs to another company and cannot be assigned.": "This role belongs to another company and cannot be assigned.",
"This slot has a pending command. Please wait.": "This slot has a pending command. Please wait.",
"This slot has a pending update. Please wait for the previous command to complete.": "This slot has a pending update. Please wait for the previous command to complete.",
@ -2244,6 +2356,7 @@
"Unlock": "Unlock",
"Unlock Now": "Unlock Now",
"Unlock Page": "Unlock Page",
"Unpaid": "Unpaid",
"Unpublish": "Unpublish",
"Update": "Update",
"Update App": "Update App",
@ -2390,9 +2503,12 @@
"Yesterday": "Yesterday",
"You can assign or change the personnel handling this order": "You can assign or change the personnel handling this order",
"You can select at most :max languages.": "You can select at most :max languages.",
"You cannot assign a role with permissions you do not possess.": "You cannot assign a role with permissions you do not possess.",
"You cannot assign permissions you do not possess.": "You cannot assign permissions you do not possess.",
"You cannot delete your own account.": "You cannot delete your own account.",
"You do not have permission to access replenishment orders": "You do not have permission to access replenishment orders",
"You do not have permission to manage this account.": "You do not have permission to manage this account.",
"You do not have permission to manage this role.": "You do not have permission to manage this role.",
"Your account is disabled.": "Your account is disabled.",
"Your recent account activity": "Your recent account activity",
"[PickupCode] Verification failed: :code": "[PickupCode] Verification failed: :code",
@ -2422,6 +2538,7 @@
"command.lock": "Lock",
"command.reboot": "Reboot",
"command.reboot_card": "Reboot Card Reader",
"command.reboot_force": "Force Reboot",
"command.reload_stock": "Sync Stock",
"command.unlock": "Unlock",
"command.update_ads": "Sync Ads",
@ -2453,6 +2570,7 @@
"e.g. Taiwan Star": "e.g. Taiwan Star",
"e.g. Test Code for Maintenance": "e.g. Test Code for Maintenance",
"e.g. johndoe": "e.g. johndoe",
"e.g. the hospital pricing slip number": "e.g. the hospital pricing slip number",
"e.g., 12345678": "e.g., 12345678",
"e.g., Beverage": "e.g., Beverage",
"e.g., Company Standard Pay": "e.g., Company Standard Pay",
@ -2528,6 +2646,7 @@
"menu.sales": "Sales Management",
"menu.sales.orders": "Purchase Orders",
"menu.sales.pass-codes": "Pass Codes",
"menu.sales.pharmacy-pickup": "Pharmacy Pickup",
"menu.sales.pickup-codes": "Pickup Codes",
"menu.sales.promotions": "Promotions",
"menu.sales.records": "Sales Records",
@ -2558,12 +2677,15 @@
"name_dictionary_key": "name_dictionary_key",
"of": "of",
"of items": "items",
"optional": "optional",
"orders": "orders",
"overrides set": "overrides set",
"pending": "Pending",
"permissions": "Permission Settings",
"permissions.accounts": "Account Management",
"permissions.companies": "Customer Management",
"permissions.roles": "Role Permissions",
"physical": "physical",
"price": "price",
"product": "product",
"product_catalog_synced_to_all_machines": "product_catalog_synced_to_all_machines",
@ -2573,6 +2695,7 @@
"product_updated": "product_updated",
"remote": "Remote Management",
"reservation": "Reservation System",
"reserved": "reserved",
"roles": "Roles",
"s": "s",
"sales": "Sales Management",

View File

@ -1,4 +1,14 @@
{
"Search Product...": "商品を検索...",
"Generate Quantity": "生成数量",
"Batch mode: codes are auto-generated and the custom code is ignored.": "バッチモード:コードは自動生成され、カスタムコードは無視されます。",
"Batch generated successfully": "バッチ生成が完了しました",
"codes": "件",
"Download List (CSV)": "リストをダウンロード (CSV)",
":count pickup codes generated": ":count 件の受取コードを生成しました",
":count pass codes created": ":count 件の通行コードを作成しました",
"(deducted by issued pickup orders)": "(発行済み受取注文の予約分を差し引き済み)",
"-- Select Machine --": "-- 機器を選択 --",
"10s": "10秒",
"15 Seconds": "15秒",
"1920x1080 (Max 10MB)": "1920x1080 (最大10MB)",
@ -27,7 +37,6 @@
"APP Version": "アプリバージョン",
"APP_ID": "APP_ID",
"APP_KEY": "APP_KEY",
"Abandoned": "放棄済み",
"Abnormal": "異常",
"Account": "アカウント",
"Account :name status has been changed to :status.": "アカウント :name のステータスが :status に変更されました。",
@ -111,9 +120,11 @@
"All Categories": "すべてのカテゴリ",
"All Command Types": "すべてのコマンドタイプ",
"All Companies": "すべての会社",
"All Dispense Statuses": "すべての出庫ステータス",
"All Levels": "すべてのレベル",
"All Machines": "すべての機器",
"All Normal": "すべて正常",
"All Payment Statuses": "すべての決済ステータス",
"All Permissions": "すべての権限",
"All Products (ALL)": "全商品 (ALL)",
"All Slots": "すべてのスロット",
@ -217,9 +228,12 @@
"Auto-load succeeded but purchase failed": "オートチャージ成功、但し購買取引失敗",
"Automatically calculate replenishment needs based on machine capacity": "機器容量に基づいて補充必要量を自動計算",
"Availability": "可用性 (Availability)",
"Available": "利用可能在庫",
"Available Machines": "割り当て可能な機器",
"Avatar updated successfully.": "アバターが更新されました。",
"Avg Cycle": "平均サイクル",
"Awaiting Pickup": "受取待ち",
"B2C (member carrier)": "一般B2C会員キャリア",
"Back to History": "履歴に戻る",
"Back to List": "一覧に戻る",
"Badge Settings": "バッジ設定",
@ -266,6 +280,7 @@
"Cancel Pass Code": "パスコードキャンセル",
"Cancel Pickup Code": "受取コードキャンセル",
"Cancel Purchase": "購入キャンセル",
"Cancel this pharmacy pickup order?": "この薬品受取注文を取り消しますか?",
"Cancelled": "キャンセル済み",
"Cannot Delete Role": "ロールを削除できません",
"Cannot cancel this order": "この注文はキャンセルできません",
@ -274,6 +289,7 @@
"Cannot delete company with active accounts.": "有効なアカウントを持つ顧客は削除できません。",
"Cannot delete model that is currently in use by machines.": "機器で使用中のモデルは削除できません。",
"Cannot delete role with active users.": "ユーザーが紐付けられているロールは削除できません。",
"Cannot delete the company main account while other accounts exist. Please transfer the main account role first.": "他のアカウントが存在する間は、会社のメインアカウントを削除できません。先にメインアカウントの権限を移譲してください。",
"Cannot delete warehouse with existing stock": "在庫がある倉庫は削除できません",
"Cannot match original stored-value transaction": "元の電子マネー取引と照合できません",
"Card Machine System": "カードリーダーシステム",
@ -321,6 +337,7 @@
"Choose File": "ファイルを選択",
"Choose Logo": "ロゴを選択",
"Choose Overall BG": "全体背景画像を選択",
"Citizen certificate carrier": "自然人証明書キャリア",
"Clear": "クリア",
"Clear Abnormal Status": "異常状態のクリア",
"Clear Filter": "フィルターをクリア",
@ -358,6 +375,7 @@
"Company Level": "会社レベル",
"Company Name": "会社名",
"Company Settings": "会社グローバル設定",
"Company Tax ID": "統一番号",
"Complete movement history log": "完全な在庫変動履歴",
"Completed": "完了済み",
"Completed At": "完了日時",
@ -425,10 +443,13 @@
"Courier/Dispatcher": "配送担当",
"Courier/Replenisher": "配送・補充担当",
"Create": "作成",
"Create & Generate QR": "作成してQRを生成",
"Create Config": "設定作成",
"Create Machine": "機器追加",
"Create New Role": "新規ロール作成",
"Create Order": "注文を作成",
"Create Payment Config": "決済設定作成",
"Create Pharmacy Pickup Order": "薬品受取注文を作成",
"Create Product": "商品作成",
"Create Replenishment Order": "補充伝票作成",
"Create Role": "ロール作成",
@ -441,6 +462,7 @@
"Create stock replenishment for specific machines": "特定の機器の在庫補充伝票を作成",
"Create stock transfers between warehouses and machine returns": "倉庫間の移動または機器からの返品を作成",
"Create stock-in orders": "入庫伝票を作成",
"Created": "作成日時",
"Created At": "作成日時",
"Created By": "作成者",
"Creation Time": "作成時間",
@ -510,6 +532,7 @@
"Date Range": "日付範囲",
"Day Before": "一昨日",
"Days": "日",
"Deduct stock": "在庫を減算",
"Default": "デフォルト",
"Default BG": "デフォルト背景",
"Default Card BG": "デフォルトカード背景",
@ -521,6 +544,7 @@
"Default Temp Limits": "デフォルト温度制御範囲",
"Default Temp Lower Limit (°C)": "デフォルト温度下限 (°C)",
"Default Temp Upper Limit (°C)": "デフォルト温度上限 (°C)",
"Defaults to now if empty": "空欄の場合は現在時刻",
"Define and manage security roles and permissions.": "セキュリティロールと権限を定義・管理します。",
"Define custom temperature limits or inherit from model": "カスタム温度制限を設定するか、機器モデルから継承します",
"Define new third-party payment parameters": "新しい外部決済パラメータを定義",
@ -602,7 +626,10 @@
"Displayed below Logo on the left (defaults to customer name).": "左側のロゴの下に表示されるメインタイトル(未設定の場合はデフォルトで顧客名になります)。",
"Displayed below main title on the left.": "左側のメインタイトルの下に表示されるサブタイトル。",
"Displaying": "表示中",
"Donation": "寄付",
"Donation invoices cannot be printed": "寄付の領収書は印刷できません",
"Donation love code": "寄付の愛心コード",
"Donation love code must be 3 to 7 digits": "寄付の愛心コードは3〜7桁の数字である必要があります",
"Done": "完了",
"Door Closed": "扉が閉まりました",
"Door Opened": "扉が開きました",
@ -754,10 +781,13 @@
"Export to Excel": "Excelエクスポート",
"External URL": "External URL",
"Failed": "失敗",
"Failed to create manual order": "手動補登に失敗しました",
"Failed to fetch machine data.": "機器データの取得に失敗しました。",
"Failed to get invoice print URL": "領収書印刷URLの取得に失敗しました",
"Failed to load permissions": "権限の読み込みに失敗しました",
"Failed to load preview": "プレビューの読み込みに失敗しました",
"Failed to load pricing": "価格設定の読み込みに失敗しました",
"Failed to load slots": "スロットの読み込みに失敗しました",
"Failed to load tab content": "タブ内容の読み込みに失敗しました",
"Failed to resolve issues.": "異常の解消に失敗しました。",
"Failed to save permissions.": "権限の保存に失敗しました。",
@ -869,6 +899,7 @@
"Flow ID / Slot / Time": "フローID / スロット / 時間",
"Flow ID / Status": "フローID / ステータス",
"Flow ID / Time": "フローID / 時間",
"For sales not reported due to disconnection. System administrators only.": "切断などで未報告の売上の補登用。システム管理者のみ。",
"Force End Session": "セッションを強制終了",
"Force end current session": "現在のセッションを強制終了",
"Forced Update": "強制アップデート",
@ -890,6 +921,7 @@
"Generate Replenishment Order": "補充伝票生成",
"Generate and manage one-time pickup codes for customers": "顧客用のワンタイム受取コードを生成・管理",
"Gift Definitions": "ギフト設定",
"Global": "グローバル価格",
"Global roles accessible by all administrators.": "全管理者がアクセス可能なグローバルロール。",
"Go Authorize": "先に授権する",
"Go Back": "戻る",
@ -928,6 +960,7 @@
"Identification": "識別情報",
"Identity & Codes": "識別・コード",
"Identity and Codes": "Identity and Codes",
"Ignore busy state (use when stuck)": "ビジー状態を無視(フリーズ時に使用)",
"Image": "画像",
"Image Downloaded": "画像をダウンロードしました",
"Immediate": "即時",
@ -952,8 +985,12 @@
"Initial contract registration": "初期契約登録",
"Installation": "設置",
"Installation Location": "設置場所",
"Insufficient stock for :name (requested :qty, available :available).": ":name の在庫が不足しています(要求 :qty、利用可能 :available。",
"Insufficient stock for transfer": "在庫不足のため移動できません",
"Insufficient stored-value / e-wallet balance": "電子マネー/電子ウォレット残高不足",
"Invalid citizen digital certificate carrier": "自然人証明書キャリアの形式が無効です",
"Invalid invoice type": "インボイス種別が無効です",
"Invalid mobile barcode carrier": "モバイルバーコードキャリアの形式が無効です",
"Invalid personnel selection": "無効な担当者選択",
"Invalid status transition": "無効なステータス遷移",
"Inventory": "在庫概要",
@ -972,8 +1009,13 @@
"Invoice Number / Time": "領収書番号 / 時間",
"Invoice Status": "発行ステータス",
"Invoice Store ID": "請求書ストアID",
"Invoice Type": "インボイス種別",
"Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.": "薬品受取注文を発行し、QRチケットを印刷します。患者が機器でスキャンして払い出します。",
"Issue e-invoice": "電子インボイスを発行",
"Issue pharmacy pickup orders from the backend; patient scans QR to dispense.": "バックエンドで薬品受取注文を発行し、患者がQRをスキャンして払い出します。",
"Issued": "Issued",
"Issued At": "発行日時",
"Issued via ECPay in the background after submission.": "送信後にバックグラウンドでECPayにて発行。",
"Item List": "商品リスト",
"Items": "件",
"JKO Pay": "街口支付 (JKO Pay)",
@ -1009,6 +1051,7 @@
"Latitude": "緯度",
"Lease": "リース",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "空欄のままにすると即時配信されます。未来の日時を指定すると背景で自動予約配信されます。",
"Leave blank to use the global product price. Member price will be capped at the machine selling price.": "空欄の場合はグローバル商品価格を使用します。会員価格はマシン販売価格を上限に自動調整されます。",
"Leave empty for permanent code": "空欄で無期限コードになります",
"Leave empty or 0 for permanent code": "空欄または0で無期限コードになります",
"Leave empty to inherit company settings": "空白のままにすると会社のグローバル設定を継承します",
@ -1032,15 +1075,18 @@
"Loading Data": "データを読み込み中",
"Loading failed": "読み込み失敗",
"Loading machines...": "機器を読み込み中...",
"Loading slots...": "スロットを読み込み中...",
"Location": "場所",
"Location found!": "座標を取得しました!",
"Location not found": "場所が見つかりません。ランドマーク名(例:花蓮県庁)を使用してみてください",
"Lock": "ロック",
"Lock (Pause Sale)": "ロック(販売停止)",
"Lock Now": "今すぐロック",
"Lock Page": "ページロック",
"Lock Page Lock": "機器アプリロック",
"Lock Page Unlock": "機器アプリロック解除",
"Locked": "ロック中",
"Locked (Sale Paused)": "ロック中(販売停止)",
"Locked Page": "ロックページ",
"Log Details": "Log Details",
"Log Time": "記録時間",
@ -1078,6 +1124,7 @@
"Machine Flavor": "Machine Flavor",
"Machine Flow ID": "機器フローID",
"Machine Flow ID / Time": "機器フローID / 時間",
"Machine Force Reboot": "アプリ強制再起動",
"Machine Images": "機器写真",
"Machine Inbound Confirmation": "入庫確認",
"Machine Info": "機器情報",
@ -1093,6 +1140,8 @@
"Machine Model Settings": "機器モデル設定",
"Machine Name": "機器名",
"Machine Permissions": "機器権限",
"Machine Price": "マシン価格",
"Machine Pricing": "マシン価格設定",
"Machine Reboot": "機器アプリ再起動",
"Machine Registry": "機器リスト",
"Machine Replenishment": "機器補充伝票",
@ -1111,6 +1160,7 @@
"Machine Stock Movements": "機器在庫変動履歴",
"Machine System Settings": "機器システム設定",
"Machine Utilization": "機器稼働率",
"Machine company changed": "マシンの所属会社が変更されました",
"Machine created successfully.": "機器が正常に作成されました。",
"Machine deleted successfully.": "機器が正常に削除されました。",
"Machine has reconnected to the server and resumed normal heartbeat.": "マシンがサーバーに再接続し、通常のハートビートを再開しました。",
@ -1123,6 +1173,8 @@
"Machine normal": "デバイス正常",
"Machine not found": "機器が見つかりません",
"Machine permissions updated successfully.": "機器権限が正常に更新されました。",
"Machine pricing saved and sync command pushed.": "マシン価格を保存し、同期コマンドを送信しました。",
"Machine pricing updated": "マシン価格を更新しました",
"Machine settings updated successfully.": "機器設定が正常に更新されました。",
"Machine temperature exceeds limit": "設定温度超過のため購入一時停止",
"Machine to Warehouse": "機器から倉庫",
@ -1166,9 +1218,13 @@
"Manage your warehouses, including main and branch warehouses": "本庫と分庫を含む倉庫を管理",
"Management of operational parameters": "運用パラメータの管理",
"Management of operational parameters and models": "運用パラメータとモデルの管理",
"Manual": "手動",
"Manual Entry": "手動入力",
"Manual Order Entry": "手動受注入力",
"Manual Sync Ads": "手動広告同期",
"Manual Sync Products": "手動商品同期",
"Manual Sync Settings": "手動設定同期",
"Manual order created: :no": "手動補登を作成しました::no",
"Manufacturer": "製造メーカー",
"Marketing and Loyalty": "Marketing and Loyalty",
"Material Code": "資材コード",
@ -1186,6 +1242,7 @@
"Max Capacity:": "最大容量:",
"Max Stock": "在庫上限",
"Maximum file size: 100MB": "Maximum file size: 100MB",
"Medicine": "薬品",
"Member": "会員価格",
"Member & External": "会員および外部システム",
"Member + 1": "会員 + 1",
@ -1239,6 +1296,7 @@
"Missing RelateNumber, cannot query": "Missing RelateNumber, cannot query",
"Mobile Pay": "モバイル決済",
"Mobile Payment": "モバイル決済",
"Mobile barcode carrier": "モバイルバーコードキャリア",
"Model": "機器モデル",
"Model Default": "モデルデフォルト",
"Model Name": "モデル名",
@ -1311,6 +1369,7 @@
"No history records": "履歴記録なし",
"No images uploaded": "画像がアップロードされていません",
"No invoice issued, cannot void": "No invoice issued, cannot void",
"No items added yet": "商品が未追加です",
"No location set": "場所が設定されていません",
"No login history yet": "ログイン履歴はまだありません",
"No logs found": "ログが見つかりません",
@ -1318,24 +1377,29 @@
"No machines assigned": "機器が割り当てられていません",
"No machines available": "利用可能な機器がありません",
"No machines available in this company.": "この会社で利用可能な機器がありません。",
"No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).": "薬品受取が有効な機器がありません。「機器システム設定」(受取シートモード → 薬品受取スイッチ)で有効にしてください。",
"No machines found": "機器が見つかりません",
"No machines in this status": "このステータスのデバイスはありません",
"No machines match your criteria": "No machines match your criteria",
"No maintenance records found": "メンテナンス記録が見つかりません",
"No matching logs found": "一致するログが見つかりません",
"No matching machines": "一致する機器がありません",
"No matching products": "一致する商品がありません",
"No materials available": "利用可能な素材がありません",
"No movement records found": "変動記録が見つかりません",
"No movements found": "変動が見つかりません",
"No operation logs found": "No operation logs found",
"No pass codes found": "パスコードが見つかりません",
"No permissions": "権限なし",
"No pharmacy pickup orders yet.": "薬品受取注文はまだありません。",
"No pickup codes found": "受取コードが見つかりません",
"No product data matching search criteria": "検索条件に一致する商品データはありません",
"No product record found": "商品記録が見つかりません",
"No products available for this company": "この会社で販売可能な商品がありません",
"No products found": "商品が見つかりません",
"No products found in this warehouse": "この倉庫に商品は見つかりません",
"No products found matching your criteria.": "条件に一致する商品が見つかりません。",
"No products on this machine yet": "このマシンにはまだ商品がありません",
"No records found": "記録が見つかりません",
"No related detail found": "関連する詳細が見つかりません",
"No remark": "備考なし",
@ -1366,6 +1430,7 @@
"Not Used": "使用しない",
"Not Used Description": "サードパーティ決済を使用しない",
"Not a co-branded card": "提携カードではありません",
"Not stocked on this machine": "この機器に未陳列",
"Note": "備考",
"Note (optional)": "備考 (任意)",
"Notes": "備考",
@ -1397,6 +1462,7 @@
"Offline Machines": "オフライン機器",
"Old": "Old",
"Old Value": "Old Value",
"On Sale": "販売中",
"Once": "一回のみ",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "アカウントが削除されると、すべてのリソースとデータは完全に削除されます。アカウントを削除する前に、保持したいデータや情報をダウンロードしてください。",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "アカウントが削除されると、すべてのデータは永久に削除されます。完全に削除することを確認するためにパスワードを入力してください。",
@ -1415,6 +1481,7 @@
"Only machines under the same company can be cloned for security.": "セキュリティのため、同じ会社の機器設定のみコピーできます。",
"Only pending/failed invoices can be queried": "Only pending/failed invoices can be queried",
"Only pending/failed invoices can be re-issued": "Only pending/failed invoices can be re-issued",
"Only system administrators can create manual orders": "手動補登はシステム管理者のみ可能です",
"Only system administrators can void invoices": "システム管理者のみが請求書を無効化できます",
"Only system roles can be assigned to platform administrative accounts.": "プラットフォーム管理アカウントにはシステムロールのみを割り当てることができます。",
"Operation Logs": "Operation Logs",
@ -1496,6 +1563,7 @@
"Page 69": "ページ 69 (購入キャンセル)",
"Page 7": "ページ 7 (ロックページ)",
"Page Lock Status": "ページロックステータス",
"Paid": "支払い済み",
"Parameters": "パラメータ設定",
"Partial Dispense Success": "一部出庫成功",
"Pass Code": "パスコード",
@ -1536,10 +1604,15 @@
"Permissions": "権限",
"Permissions updated successfully": "権限が正常に更新されました",
"Personnel assigned successfully": "担当者が正常に割り当てられました",
"Pharmacy Pickup (Rx)": "薬品受取",
"Pharmacy Pickup Orders": "薬品受取注文一覧",
"Pharmacy pickup order cancelled.": "薬品受取注文を取り消しました。",
"Pharmacy pickup order created: :no": "薬品受取注文を作成しました::no",
"Phone": "電話番号",
"Photo Slot": "写真スロット",
"Pi Pay": "Pi 拍錢包 (Pi Pay)",
"Pi Wallet": "Pi Wallet",
"Picked Up": "受取済み",
"Picked up": "受取済み",
"Picked up Time": "受取時間",
"Pickup Code": "受取コード",
@ -1585,8 +1658,10 @@
"Please select a machine to view and manage its advertisements.": "機器を選択して、その広告を表示および管理してください。",
"Please select a machine to view metrics": "指標を表示する機器を選択してください",
"Please select a material": "素材を選択してください",
"Please select a product": "商品を選択してください",
"Please select a slot": "スロットを選択してください",
"Please select an APK file to upload": "Please select an APK file to upload",
"Please select at least one medicine with quantity.": "数量を入力した薬品を少なくとも1つ選択してください。",
"Please select warehouse and machine": "倉庫と機器を選択してください",
"Please use our standard template to ensure data compatibility.": "データの互換性を確保するために、標準テンプレートを使用してください。",
"PlusPay": "PlusPay",
@ -1605,8 +1680,10 @@
"Preview Mode - Interactive Vending Portal Mockup": "プレビューモード - カスタムブランドログインページのモックアップ",
"Preview Mode: Login functionality is disabled.": "プレビューモード:ログイン機能は無効です。",
"Previous": "前へ",
"Price": "価格",
"Price / Member": "価格 / 会員価格",
"Pricing Information": "価格情報",
"Pricing Slip No.": "請求伝票番号",
"Primary Theme Color": "プライマリーテーマカラー",
"Print": "印刷",
"Print & PDF": "印刷 & PDF",
@ -1714,6 +1791,7 @@
"Recent Commands": "最近のコマンド",
"Recent Login": "最近のログイン",
"Recently reported errors or warnings in logs": "最近のログにエラーまたは警告が報告されています",
"Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.": "機台が実際に出庫済みだが未報告の場合、クラウド在庫を整合するため推奨。",
"Recommended: 320x320 (Auto-cropped)": "推奨サイズ: 320x320 (自動トリミング)",
"Records": "記録",
"Refresh Now": "今すぐ更新",
@ -1793,7 +1871,9 @@
"Running Status": "稼働ステータス",
"SYSTEM": "システムレベル",
"Safety Stock": "安全在庫",
"Sale Paused": "販売停止",
"Sale Price": "販売価格",
"Sale Status": "販売状態",
"Sales": "販売管理",
"Sales Activity": "販売活動",
"Sales Amount": "売上金額",
@ -1805,6 +1885,7 @@
"Sales Today": "本日の販売数",
"Sales revenue minus product cost net value": "販売額から商品コストを差し引いた純利益",
"Save": "保存",
"Save & Sync": "保存して同期",
"Save Changes": "変更を保存",
"Save Config": "設定を保存",
"Save Material": "素材を保存",
@ -1812,6 +1893,8 @@
"Save Settings": "設定を保存",
"Save Version": "Save Version",
"Save error:": "保存エラー:",
"Save failed": "保存に失敗しました",
"Saved": "保存しました",
"Saved.": "保存しました。",
"Saving...": "保存中...",
"Scale level and access control": "スケールレベルとアクセス制御",
@ -1821,6 +1904,7 @@
"Scan QR Code": "QRコードをスキャン",
"Scan Store ID": "スキャンStoreID",
"Scan Term ID": "スキャンTermID",
"Scan the QR code or enter the pickup code at the machine to collect your medicine.": "機器でQRコードをスキャンするか、受取コードを入力して薬品を受け取ってください。",
"Scan this code at the machine or share the link with the customer.": "機器でこのコードをスキャンするか、リンクを顧客と共有してください。",
"Scan this code at the machine to authorize testing or maintenance.": "機器でこのコードをスキャンして、テストまたはメンテナンスを承認します。",
"Scan this code to quickly access the maintenance form for this device.": "このQRコードをスキャンすると、このデバイスのメンテナンスフォームに素早くアクセスできます。",
@ -1898,6 +1982,8 @@
"Select Target Slot": "対象のスロットを選択",
"Select Update Time (Optional)": "更新時間を選択してください(任意)",
"Select Warehouse": "倉庫を選択",
"Select a machine": "機台を選択",
"Select a machine to choose products": "商品を選ぶには先に機台を選択",
"Select a machine to copy settings from...": "設定のコピー元機器を選択...",
"Select a machine to deep dive": "詳細分析する機器を選択",
"Select a material to play on this machine": "この機器で再生する素材を選択",
@ -1907,6 +1993,8 @@
"Select date to sync data": "データを同期する日付を選択",
"Select flavor...": "Select flavor...",
"Select future time...": "配信日時を選択してください...",
"Select payment type": "支払方法を選択",
"Select slot / product": "スロット / 商品を選択",
"Select up to :max languages the machine can switch between. The first one is the default.": "機台が切り替え可能な言語を選択してください(最大 :max 種)。最初の言語がデフォルトになります。",
"Select...": "選択してください...",
"Selected": "選択中",
@ -1974,6 +2062,7 @@
"Source Machine": "移動元自販機",
"Source Warehouse": "移動元倉庫",
"Source of temperature limit settings": "温度制限設定のソース",
"Spec": "規格",
"Special Permission": "特別権限",
"Specification": "仕様",
"Specifications": "仕様",
@ -2042,6 +2131,7 @@
"Sub Account Management": "サブアカウント管理",
"Sub Account Roles": "サブアカウントロール",
"Sub Accounts": "サブアカウント",
"Sub-accounts at this level cannot create further sub-accounts.": "このレベルのサブアカウントは、これ以上下位のサブアカウントを作成できません。",
"Sub-actions": "サブアクション",
"Sub-machine": "サブマシン",
"Sub-machine Status": "Sub-machine Status",
@ -2050,6 +2140,7 @@
"Submachine Exception": "下位機ハードウェア異常",
"Submachine Exception (B013)": "下位機/商品ラック異常 (B013)",
"Submit Record": "記録を提出",
"Submitting...": "送信中...",
"Subtotal": "小計",
"Success": "成功",
"Super Admin": "特権管理者",
@ -2103,6 +2194,7 @@
"Target hardware flavor for this APK": "Target hardware flavor for this APK",
"Tax ID": "統一企業番号",
"Tax ID (Optional)": "統一企業番号 (任意)",
"Tax ID must be 8 digits": "統一番号は8桁の数字である必要があります",
"Tax System": "税務システム",
"Taxation System": "税務システム",
"Temp Alert Range": "アラート温度範囲",
@ -2128,18 +2220,24 @@
"The Super Admin role cannot be deleted.": "特権管理者ロールは削除できません。",
"The Super Admin role is immutable.": "特権管理者ロールは変更できません。",
"The Super Admin role name cannot be modified.": "特権管理者ロールの名前は変更できません。",
"The company admin role cannot be deleted.": "会社管理者ロールは削除できません。",
"The image archive contains too many files": "画像アーカイブのファイル数が多すぎます",
"The image archive is too large when extracted": "画像アーカイブの展開後のサイズが大きすぎます",
"The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。",
"The image is too large. Please upload an image smaller than 5MB.": "画像サイズが大きすぎます。5MB未満の画像をアップロードしてください。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "最新のシステム設定を取得するよう機台に通知しました。反映まで少々時間がかかる場合があります。",
"The selected product does not belong to this machine's company.": "選択した商品はこの機器の所属会社に属していません。",
"This Month": "今月",
"This Week": "今週",
"This account does not belong to this company.": "このアカウントは該当企業に所属していません。",
"This invoice has no printable copy": "この請求書は印刷可能な紙面がありません",
"This is a system administrator role. Its name is locked to ensure system stability.": "これはシステム管理者ロールです。システムの安定性を確保するため、名前はロックされています。",
"This machine currently has no medicine in stock.": "この機器には現在、払い出し可能な薬品の在庫がありません。",
"This machine has a pending command. Please wait.": "この機器には実行中のコマンドがあります。しばらくお待ちください。",
"This machine has e-invoice disabled; cannot issue": "この機台は電子インボイスが無効のため発行できません",
"This machine has no ECPay invoice settings": "This machine has no ECPay invoice settings",
"This pharmacy pickup order can no longer be cancelled.": "この薬品受取注文はこれ以上取り消せません。",
"This product is not stocked on the selected machine; the code may not be redeemable until restocked.": "この商品は選択した機器に陳列されていません。補充されるまでコードを引き換えできない場合があります。",
"This role belongs to another company and cannot be assigned.": "このロールは別の会社に属しているため、割り当てることができません。",
"This slot has a pending command. Please wait.": "このスロットには実行中のコマンドがあります。しばらくお待ちください。",
"This slot has a pending update. Please wait for the previous command to complete.": "このスロットには保留中の更新があります。前のコマンドが完了するまでお待ちください。",
@ -2244,6 +2342,7 @@
"Unlock": "ロック解除",
"Unlock Now": "今すぐロック解除",
"Unlock Page": "ページロック解除",
"Unpaid": "未払い",
"Unpublish": "非公開",
"Update": "更新",
"Update App": "アプリ更新",
@ -2390,9 +2489,12 @@
"Yesterday": "昨日",
"You can assign or change the personnel handling this order": "この注文を処理する担当者を割り当て、または変更できます",
"You can select at most :max languages.": "最大 :max 言語まで選択できます。",
"You cannot assign a role with permissions you do not possess.": "自分が保有していない権限を含むロールは割り当てできません。",
"You cannot assign permissions you do not possess.": "自身が持っていない権限を割り当てることはできません。",
"You cannot delete your own account.": "自分自身のアカウントは削除できません。",
"You do not have permission to access replenishment orders": "補充伝票のアクセス権限がありません",
"You do not have permission to manage this account.": "このアカウントを管理する権限がありません。",
"You do not have permission to manage this role.": "このロールを管理する権限がありません。",
"Your account is disabled.": "アカウントが無効化されています。",
"Your recent account activity": "最近のアカウントアクティビティ",
"[PickupCode] Verification failed: :code": "[受取コード] 検証失敗: :code",
@ -2422,6 +2524,7 @@
"command.lock": "ロック",
"command.reboot": "再起動",
"command.reboot_card": "カードリーダー再起動",
"command.reboot_force": "強制再起動",
"command.reload_stock": "在庫同期",
"command.unlock": "ロック解除",
"command.update_ads": "広告同期",
@ -2453,6 +2556,7 @@
"e.g. Taiwan Star": "例:台湾星",
"e.g. Test Code for Maintenance": "例:メンテナンステスト用コード",
"e.g. johndoe": "例yamadataro",
"e.g. the hospital pricing slip number": "例:病院の請求伝票番号",
"e.g., 12345678": "例12345678",
"e.g., Beverage": "例:飲料",
"e.g., Company Standard Pay": "例:会社標準決済",
@ -2528,6 +2632,7 @@
"menu.sales": "販売管理",
"menu.sales.orders": "購入注文",
"menu.sales.pass-codes": "パスコード",
"menu.sales.pharmacy-pickup": "薬品受取",
"menu.sales.pickup-codes": "受取コード",
"menu.sales.promotions": "プロモーション期間",
"menu.sales.records": "販売記録",
@ -2558,12 +2663,15 @@
"name_dictionary_key": "name_dictionary_key",
"of": "/全",
"of items": "件中",
"optional": "任意",
"orders": "件",
"overrides set": "件のマシン価格を設定済み",
"pending": "機器受取待機中",
"permissions": "権限設定",
"permissions.accounts": "アカウント管理",
"permissions.companies": "顧客管理",
"permissions.roles": "ロール権限管理",
"physical": "実在庫",
"price": "price",
"product": "product",
"product_catalog_synced_to_all_machines": "product_catalog_synced_to_all_machines",
@ -2573,6 +2681,7 @@
"product_updated": "product_updated",
"remote": "遠隔管理",
"reservation": "予約システム",
"reserved": "予約",
"roles": "ロール権限",
"s": "秒",
"sales": "販売管理",

View File

@ -1,4 +1,15 @@
{
"Single use only": "只能使用一次",
"Search Product...": "搜尋商品...",
"Generate Quantity": "產生數量",
"Batch mode: codes are auto-generated and the custom code is ignored.": "批次模式:系統自動產生隨機碼,將忽略自訂碼。",
"Batch generated successfully": "批次產生成功",
"codes": "筆",
"Download List (CSV)": "下載清單 (CSV)",
":count pickup codes generated": "已產生 :count 筆取貨碼",
":count pass codes created": "已建立 :count 筆通行碼",
"(deducted by issued pickup orders)": "(已扣除已生成領藥單的預留量)",
"-- Select Machine --": "-- 選擇機台 --",
"10s": "10秒",
"15 Seconds": "15 秒",
"1920x1080 (Max 10MB)": "1920x1080 (最大 10MB)",
@ -27,7 +38,6 @@
"APP Version": "APP版本號",
"APP_ID": "APP_ID",
"APP_KEY": "APP_KEY",
"Abandoned": "已棄單",
"Abnormal": "異常",
"Account": "帳號",
"Account :name status has been changed to :status.": "帳號 :name 的狀態已變更為 :status。",
@ -111,9 +121,11 @@
"All Categories": "所有分類",
"All Command Types": "所有指令類型",
"All Companies": "所有公司",
"All Dispense Statuses": "所有出貨狀態",
"All Levels": "所有層級",
"All Machines": "所有機台",
"All Normal": "運作正常",
"All Payment Statuses": "所有付款狀態",
"All Permissions": "全部權限",
"All Products (ALL)": "全部商品 (ALL)",
"All Slots": "所有貨道",
@ -217,9 +229,12 @@
"Auto-load succeeded but purchase failed": "自動加值成功,但購貨交易失敗",
"Automatically calculate replenishment needs based on machine capacity": "根據機台容量自動計算補貨需求",
"Availability": "可用性 (Availability)",
"Available": "可用庫存",
"Available Machines": "可供分配的機台",
"Avatar updated successfully.": "頭像已成功更新。",
"Avg Cycle": "平均週期",
"Awaiting Pickup": "待領取",
"B2C (member carrier)": "一般 B2C會員載具",
"Back to History": "返回紀錄",
"Back to List": "返回列表",
"Badge Settings": "識別證",
@ -266,6 +281,7 @@
"Cancel Pass Code": "取消通行碼",
"Cancel Pickup Code": "取消取貨碼",
"Cancel Purchase": "取消購買",
"Cancel this pharmacy pickup order?": "確定要作廢這張領藥單嗎?",
"Cancelled": "已取消",
"Cannot Delete Role": "無法刪除該角色",
"Cannot cancel this order": "無法取消此訂單",
@ -274,6 +290,7 @@
"Cannot delete company with active accounts.": "無法刪除仍有客用帳號的客戶。",
"Cannot delete model that is currently in use by machines.": "無法刪除目前正在被機台使用的型號。",
"Cannot delete role with active users.": "無法刪除已有綁定帳號的角色。",
"Cannot delete the company main account while other accounts exist. Please transfer the main account role first.": "公司主帳號為唯一主帳號,仍有其他帳號時不可刪除,請先轉移主帳號身分。",
"Cannot delete warehouse with existing stock": "無法刪除仍有庫存的倉庫",
"Cannot match original stored-value transaction": "無法比對原始電票交易",
"Card Machine System": "刷卡機系統",
@ -321,6 +338,7 @@
"Choose File": "選擇檔案",
"Choose Logo": "選擇公司 Logo",
"Choose Overall BG": "選擇大背景圖",
"Citizen certificate carrier": "自然人憑證載具",
"Clear": "清除",
"Clear Abnormal Status": "清除異常狀態",
"Clear Filter": "清除篩選",
@ -358,6 +376,7 @@
"Company Level": "公司層級",
"Company Name": "公司名稱",
"Company Settings": "公司全域設定",
"Company Tax ID": "統一編號",
"Complete movement history log": "完整的庫存異動歷程",
"Completed": "已完成",
"Completed At": "完成時間",
@ -425,10 +444,13 @@
"Courier/Dispatcher": "物流經辦/派送員",
"Courier/Replenisher": "物流經辦/補貨員",
"Create": "新增",
"Create & Generate QR": "建立並產生 QR",
"Create Config": "建立配置",
"Create Machine": "新增機台",
"Create New Role": "建立新角色",
"Create Order": "建立訂單",
"Create Payment Config": "建立金流配置",
"Create Pharmacy Pickup Order": "建立領藥單",
"Create Product": "建立商品",
"Create Replenishment Order": "建立補貨單",
"Create Role": "建立角色",
@ -441,6 +463,7 @@
"Create stock replenishment for specific machines": "為指定機台建立庫存補貨單",
"Create stock transfers between warehouses and machine returns": "建立倉庫間調撥或機台退庫單",
"Create stock-in orders": "建立入庫單",
"Created": "建立時間",
"Created At": "建立時間",
"Created By": "建立者",
"Creation Time": "建立時間",
@ -510,6 +533,7 @@
"Date Range": "日期區間",
"Day Before": "前日",
"Days": "天",
"Deduct stock": "扣除庫存",
"Default": "預設",
"Default BG": "預設大背景",
"Default Card BG": "預設卡片背景",
@ -521,6 +545,7 @@
"Default Temp Limits": "預設溫控範圍",
"Default Temp Lower Limit (°C)": "預設溫度下限 (°C)",
"Default Temp Upper Limit (°C)": "預設溫度上限 (°C)",
"Defaults to now if empty": "留空則為現在時間",
"Define and manage security roles and permissions.": "定義並管理系統安全角色與權限。",
"Define custom temperature limits or inherit from model": "設定自訂溫度限制,或繼承自機台型號",
"Define new third-party payment parameters": "定義新的第三方支付參數",
@ -602,7 +627,10 @@
"Displayed below Logo on the left (defaults to customer name).": "顯示於左側 Logo 下方的主標題(未設定則預設帶入客戶名稱)。",
"Displayed below main title on the left.": "顯示於左側主標題下方的副標題/英文名稱。",
"Displaying": "目前顯示",
"Donation": "捐贈",
"Donation invoices cannot be printed": "捐贈發票無法列印",
"Donation love code": "捐贈愛心碼",
"Donation love code must be 3 to 7 digits": "捐贈愛心碼須為 3 至 7 碼數字",
"Done": "已完成",
"Door Closed": "機門已關閉",
"Door Opened": "機門已開啟",
@ -750,14 +778,21 @@
"Expiry Time": "到期時間",
"Expiry date tracking and warnings": "有效期限追蹤與預警",
"Export Report": "匯出報表",
"Export": "匯出",
"Export to CSV": "匯出成 CSV",
"Export Machine Inventory": "匯出機台庫存",
"Please select at least one machine": "請至少選擇一台機台",
"No machines": "沒有機台",
"Export to Excel": "匯出成 Excel",
"External URL": "外連 URL",
"Failed": "失敗",
"Failed to create manual order": "手動補單失敗",
"Failed to fetch machine data.": "無法取得機台資料。",
"Failed to get invoice print URL": "取得發票列印網址失敗",
"Failed to load permissions": "載入權限失敗",
"Failed to load preview": "載入預覽失敗",
"Failed to load pricing": "載入定價失敗",
"Failed to load slots": "載入貨道失敗",
"Failed to load tab content": "載入分頁內容失敗",
"Failed to resolve issues.": "排除異常失敗。",
"Failed to save permissions.": "無法儲存權限設定。",
@ -869,6 +904,7 @@
"Flow ID / Slot / Time": "流水號 / 貨道 / 時間",
"Flow ID / Status": "流水號 / 狀態",
"Flow ID / Time": "流水號 / 時間",
"For sales not reported due to disconnection. System administrators only.": "用於因斷線未上報的銷售。僅限系統管理員。",
"Force End Session": "強制結束當前會話",
"Force end current session": "強制結束目前的連線",
"Forced Update": "強制更新",
@ -890,6 +926,7 @@
"Generate Replenishment Order": "產生補貨單",
"Generate and manage one-time pickup codes for customers": "產生並管理客戶的一次性取貨碼",
"Gift Definitions": "禮品設定",
"Global": "全域價",
"Global roles accessible by all administrators.": "適用於所有管理者的全域角色。",
"Go Authorize": "前往授權",
"Go Back": "返回",
@ -928,10 +965,13 @@
"Identification": "識別資訊",
"Identity & Codes": "識別與代碼",
"Identity and Codes": "識別與代碼",
"Ignore busy state (use when stuck)": "無視忙碌狀態(卡死時使用)",
"Image": "圖片",
"Image Downloaded": "圖片已下載",
"Immediate": "立即",
"Import Excel": "Excel 匯入",
"Export CSV": "匯出 CSV",
"Export Excel": "匯出 Excel",
"Import Products": "匯入商品",
"Import Staff Cards": "匯入員工識別卡",
"Import failed": "匯入失敗",
@ -952,8 +992,12 @@
"Initial contract registration": "初始合約註冊",
"Installation": "裝機",
"Installation Location": "安裝位置",
"Insufficient stock for :name (requested :qty, available :available).": ":name 庫存不足(需求 :qty可用 :available。",
"Insufficient stock for transfer": "庫存不足,無法調撥",
"Insufficient stored-value / e-wallet balance": "電票/電子錢包餘額不足",
"Invalid citizen digital certificate carrier": "自然人憑證載具格式錯誤",
"Invalid invoice type": "發票類型無效",
"Invalid mobile barcode carrier": "手機條碼載具格式錯誤",
"Invalid personnel selection": "無效的人員選擇",
"Invalid status transition": "無效的狀態轉換",
"Inventory": "庫存概覽",
@ -972,8 +1016,13 @@
"Invoice Number / Time": "發票號碼 / 時間",
"Invoice Status": "發票開立狀態",
"Invoice Store ID": "發票商店代號",
"Invoice Type": "發票類型",
"Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.": "開立領藥單、列印 QR 領藥單,病人到機台掃碼即可出貨。",
"Issue e-invoice": "開立電子發票",
"Issue pharmacy pickup orders from the backend; patient scans QR to dispense.": "由後台開立領藥單,病人到機台掃 QR 出貨。",
"Issued": "已開立",
"Issued At": "開立時間",
"Issued via ECPay in the background after submission.": "送出後於背景透過綠界開立。",
"Item List": "商品清單",
"Items": "筆",
"JKO Pay": "街口支付",
@ -1009,6 +1058,7 @@
"Latitude": "緯度",
"Lease": "租賃",
"Leave blank to deploy immediately, or set a future time to schedule a background release.": "留空將立即部署更新,或設定未來時間由背景自動排程發布。",
"Leave blank to use the global product price. Member price will be capped at the machine selling price.": "留空則沿用全域商品價。會員價會自動夾為不高於機台售價。",
"Leave empty for permanent code": "留空表示永久有效",
"Leave empty or 0 for permanent code": "留空或輸入 0 則為永久代碼",
"Leave empty to inherit company settings": "留空則繼承公司全域設定",
@ -1032,15 +1082,18 @@
"Loading Data": "載入資料中",
"Loading failed": "載入失敗",
"Loading machines...": "正在載入機台...",
"Loading slots...": "載入貨道中...",
"Location": "位置",
"Location found!": "已成功獲取座標!",
"Location not found": "找不到位置,您可以嘗試使用地標名稱(例如:花蓮縣政府)",
"Lock": "鎖定",
"Lock (Pause Sale)": "鎖定(暫停販售)",
"Lock Now": "立即鎖定",
"Lock Page": "頁面鎖定",
"Lock Page Lock": "機台 APP 鎖定",
"Lock Page Unlock": "機台 APP 解鎖",
"Locked": "尚未解鎖",
"Locked (Sale Paused)": "已鎖定(暫停販售)",
"Locked Page": "鎖定頁",
"Log Details": "操作紀錄詳情",
"Log Time": "記錄時間",
@ -1078,6 +1131,7 @@
"Machine Flavor": "機台風味",
"Machine Flow ID": "機台流水號",
"Machine Flow ID / Time": "機台流水號 / 時間",
"Machine Force Reboot": "強制重啟 APP",
"Machine Images": "機台照片",
"Machine Inbound Confirmation": "機台入庫確認",
"Machine Info": "機台資訊",
@ -1093,6 +1147,8 @@
"Machine Model Settings": "機台型號設定",
"Machine Name": "機台名稱",
"Machine Permissions": "機台權限",
"Machine Price": "機台價",
"Machine Pricing": "機台定價",
"Machine Reboot": "機台 APP 重啟",
"Machine Registry": "機台清冊",
"Machine Replenishment": "機台補貨單",
@ -1111,6 +1167,7 @@
"Machine Stock Movements": "機台庫存流水帳",
"Machine System Settings": "機台系統設定",
"Machine Utilization": "機台稼動率",
"Machine company changed": "機台所屬公司已變更",
"Machine created successfully.": "機台已成功建立。",
"Machine deleted successfully.": "機台已成功刪除。",
"Machine has reconnected to the server and resumed normal heartbeat.": "機台已重新連線至伺服器並恢復正常心跳。",
@ -1123,6 +1180,8 @@
"Machine normal": "機台正常",
"Machine not found": "找不到機台",
"Machine permissions updated successfully.": "機台權限已成功更新。",
"Machine pricing saved and sync command pushed.": "機台定價已儲存並推送同步指令。",
"Machine pricing updated": "機台定價已更新",
"Machine settings updated successfully.": "機台設定已成功更新。",
"Machine temperature exceeds limit": "機器超過設定溫度,暫停購買",
"Machine to Warehouse": "機台對倉庫",
@ -1166,9 +1225,13 @@
"Manage your warehouses, including main and branch warehouses": "管理您的倉庫,包含總倉與分倉設定",
"Management of operational parameters": "機台運作參數管理",
"Management of operational parameters and models": "管理運作參數與型號",
"Manual": "手動",
"Manual Entry": "手動補單",
"Manual Order Entry": "手動補單",
"Manual Sync Ads": "手動同步機台廣告",
"Manual Sync Products": "手動同步商品",
"Manual Sync Settings": "手動同步系統設定",
"Manual order created: :no": "已建立手動補單::no",
"Manufacturer": "製造商",
"Marketing and Loyalty": "行銷與點數",
"Material Code": "物料代碼",
@ -1186,6 +1249,7 @@
"Max Capacity:": "最大容量:",
"Max Stock": "庫存上限",
"Maximum file size: 100MB": "檔案大小上限100MB",
"Medicine": "藥品",
"Member": "會員價",
"Member & External": "會員與外部系統",
"Member + 1": "會員 + 1",
@ -1239,6 +1303,7 @@
"Missing RelateNumber, cannot query": "缺少 RelateNumber無法查詢",
"Mobile Pay": "手機支付",
"Mobile Payment": "手機支付",
"Mobile barcode carrier": "手機條碼載具",
"Model": "機台型號",
"Model Default": "型號預設",
"Model Name": "型號名稱",
@ -1311,6 +1376,7 @@
"No history records": "尚無歷史紀錄",
"No images uploaded": "尚未上傳照片",
"No invoice issued, cannot void": "尚未開立發票,無法作廢",
"No items added yet": "尚未新增商品",
"No location set": "尚未設定位置",
"No login history yet": "尚無登入紀錄",
"No logs found": "暫無相關日誌",
@ -1318,24 +1384,29 @@
"No machines assigned": "未分配機台",
"No machines available": "目前沒有可供分配的機台",
"No machines available in this company.": "此客戶目前沒有可供分配的機台。",
"No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).": "尚無啟用領藥單的機台。請至「機台系統設定」(取物單模式 → 領藥單開關)啟用。",
"No machines found": "未找到機台",
"No machines in this status": "此狀態下無設備",
"No machines match your criteria": "沒有符合條件的機台",
"No maintenance records found": "找不到維修紀錄",
"No matching logs found": "找不到符合條件的日誌",
"No matching machines": "查無匹配機台",
"No matching products": "找不到符合的商品",
"No materials available": "沒有可用的素材",
"No movement records found": "查無異動紀錄",
"No movements found": "尚無異動紀錄",
"No operation logs found": "找不到操作紀錄",
"No pass codes found": "找不到通行碼",
"No permissions": "無權限項目",
"No pharmacy pickup orders yet.": "目前尚無領藥單。",
"No pickup codes found": "找不到取貨碼",
"No product data matching search criteria": "沒有符合搜尋條件的商品資料",
"No product record found": "無商品紀錄",
"No products available for this company": "此公司尚無可販售商品",
"No products found": "未找到商品",
"No products found in this warehouse": "此倉庫目前無商品庫存",
"No products found matching your criteria.": "找不到符合條件的商品。",
"No products on this machine yet": "此機台尚無商品",
"No records found": "未找到相關紀錄",
"No related detail found": "無關聯詳細資料",
"No remark": "尚無備註",
@ -1366,6 +1437,7 @@
"Not Used": "不使用",
"Not Used Description": "不使用第三方支付介接",
"Not a co-branded card": "非聯名卡",
"Not stocked on this machine": "未在此機台上架",
"Note": "備註",
"Note (optional)": "備註 (選填)",
"Notes": "備註",
@ -1397,6 +1469,7 @@
"Offline Machines": "離線機台",
"Old": "舊值",
"Old Value": "舊資料",
"On Sale": "販售中",
"Once": "單次使用",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "一旦您的帳號被刪除,其所有資源和數據將被永久刪除。在刪除帳號之前,請下載您希望保留的任何數據或資訊。",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "帳號一旦刪除,所有關連數據將被永久移除。請輸入您的密碼以確認您希望永久刪除此帳號。",
@ -1415,6 +1488,7 @@
"Only machines under the same company can be cloned for security.": "基於安全性,僅限複製同公司旗下的機台設定。",
"Only pending/failed invoices can be queried": "僅待開立/失敗的發票可查詢",
"Only pending/failed invoices can be re-issued": "僅待開立/失敗的發票可補開",
"Only system administrators can create manual orders": "僅系統管理員可手動補單",
"Only system administrators can void invoices": "僅系統管理員可作廢發票",
"Only system roles can be assigned to platform administrative accounts.": "僅系統角色可指派給平台管理帳號。",
"Operation Logs": "操作紀錄",
@ -1496,6 +1570,7 @@
"Page 69": "取消購買",
"Page 7": "鎖定頁",
"Page Lock Status": "頁面鎖定狀態",
"Paid": "已付款",
"Parameters": "參數設定",
"Partial Dispense Success": "部分出貨成功",
"Pass Code": "通行碼",
@ -1516,6 +1591,7 @@
"Payment Amount": "支付金額",
"Payment Buffer Seconds": "金流緩衝時間(s)",
"Payment Config": "金流配置",
"No payment config is selected (Not Used). Save this machine without any payment config?": "尚未選擇金流配置(目前為「不使用」)。確定要在沒有金流配置的情況下儲存嗎?",
"Payment Configuration": "客戶金流設定",
"Payment Configuration created successfully.": "金流設定已成功建立。",
"Payment Configuration deleted successfully.": "金流設定已成功刪除。",
@ -1536,13 +1612,25 @@
"Permissions": "權限",
"Permissions updated successfully": "授權更新成功",
"Personnel assigned successfully": "人員指派成功",
"Pharmacy Pickup (Rx)": "領藥單",
"Pharmacy Pickup Orders": "領藥單列表",
"Pharmacy pickup order cancelled.": "領藥單已作廢。",
"Pharmacy pickup order created: :no": "領藥單已建立::no",
"Phone": "手機號碼",
"Photo Slot": "照片欄位",
"Pi Pay": "Pi 拍錢包",
"Pi Wallet": "PI 拍錢包",
"Picked Up": "已領取",
"Picked up": "領取",
"Picked up Time": "領取時間",
"Pickup Code": "取貨碼",
"Pending Use": "待使用",
"Generate pickup code? (Valid for pickup within 7 days)": "是否產生取貨碼?(取貨 7 天內有效)",
"Pickup code generated.": "取貨碼已產生。",
"Pickup code already generated.": "此訂單已產生過取貨碼。",
"This order is not eligible for a pickup code.": "此訂單不符合產生取貨碼的條件。",
"No undelivered items to compensate.": "沒有需要補償的未出貨商品。",
"Machine not found.": "找不到機台。",
"Pickup Code (8 Digits)": "取貨碼 (8 位數)",
"Pickup Codes": "取貨碼",
"Pickup Module": "取貨模組",
@ -1585,8 +1673,10 @@
"Please select a machine to view and manage its advertisements.": "請選擇一個機台以查看並管理其廣告。",
"Please select a machine to view metrics": "請選擇機台以查看數據",
"Please select a material": "請選擇素材",
"Please select a product": "請選擇商品",
"Please select a slot": "請選擇貨道",
"Please select an APK file to upload": "請選擇要上傳的 APK 檔案",
"Please select at least one medicine with quantity.": "請至少勾選一項藥品並填寫數量。",
"Please select warehouse and machine": "請選擇倉庫和機台",
"Please use our standard template to ensure data compatibility.": "請使用標準範例檔以確保資料相容性。",
"PlusPay": "全盈+Pay",
@ -1605,8 +1695,10 @@
"Preview Mode - Interactive Vending Portal Mockup": "預覽模式 - 客製化品牌登入頁面模擬",
"Preview Mode: Login functionality is disabled.": "預覽模式:不提供帳號登入功能。",
"Previous": "上一頁",
"Price": "價格",
"Price / Member": "售價 / 會員價",
"Pricing Information": "價格資訊",
"Pricing Slip No.": "批價單號",
"Primary Theme Color": "主題色系設定",
"Print": "列印",
"Print & PDF": "列印與 PDF",
@ -1714,6 +1806,7 @@
"Recent Commands": "最近指令",
"Recent Login": "最近登入",
"Recently reported errors or warnings in logs": "近期日誌中有錯誤或警告回報",
"Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.": "當機台實際已出貨但未上報時建議勾選,以校正雲端庫存。",
"Recommended: 320x320 (Auto-cropped)": "建議尺寸320x320 (系統將自動裁切)",
"Records": "筆",
"Refresh Now": "立即更新",
@ -1728,7 +1821,7 @@
"Remote Change": "遠端找零",
"Remote Checkout": "遠端結帳",
"Remote Command Center": "遠端指令中心",
"Remote Dispense": "遠端出貨",
"Remote Dispense": "遠端開櫃",
"Remote Lock": "遠端鎖定",
"Remote Management": "遠端管理",
"Remote Permissions": "遠端管理權限",
@ -1793,7 +1886,9 @@
"Running Status": "運行狀態",
"SYSTEM": "系統層級",
"Safety Stock": "安全庫存",
"Sale Paused": "暫停販售",
"Sale Price": "售價",
"Sale Status": "銷售狀態",
"Sales": "銷售管理",
"Sales Activity": "銷售活動",
"Sales Amount": "銷售金額",
@ -1805,6 +1900,7 @@
"Sales Today": "今日銷售",
"Sales revenue minus product cost net value": "銷售額扣除商品成本淨值",
"Save": "儲存",
"Save & Sync": "儲存並同步",
"Save Changes": "儲存變更",
"Save Config": "儲存配置",
"Save Material": "儲存素材",
@ -1812,6 +1908,8 @@
"Save Settings": "儲存設定",
"Save Version": "儲存版本",
"Save error:": "儲存錯誤:",
"Save failed": "儲存失敗",
"Saved": "已儲存",
"Saved.": "已儲存",
"Saving...": "儲存中...",
"Scale level and access control": "層級與存取控制",
@ -1821,6 +1919,7 @@
"Scan QR Code": "掃描 QR Code",
"Scan Store ID": "掃碼StoreID",
"Scan Term ID": "掃碼TermID",
"Scan the QR code or enter the pickup code at the machine to collect your medicine.": "請至機台掃描 QR 碼,或輸入領藥碼領取藥品。",
"Scan this code at the machine or share the link with the customer.": "請在機台掃描此碼,或將連結分享給客戶。",
"Scan this code at the machine to authorize testing or maintenance.": "請在機台上掃描此 QR Code 以進行測試或維護授權。",
"Scan this code to quickly access the maintenance form for this device.": "掃描此 QR Code 即可快速進入此設備的維修單填寫頁面。",
@ -1898,6 +1997,8 @@
"Select Target Slot": "選擇目標貨道",
"Select Update Time (Optional)": "選擇更新時間(選填)",
"Select Warehouse": "選擇倉庫",
"Select a machine": "選擇機台",
"Select a machine to choose products": "請先選擇機台以挑選商品",
"Select a machine to copy settings from...": "選擇要複製設定的來源機台...",
"Select a machine to deep dive": "請選擇機台以開始深度分析",
"Select a material to play on this machine": "選擇要在此機台播放的素材",
@ -1907,6 +2008,8 @@
"Select date to sync data": "選擇日期以同步數據",
"Select flavor...": "選擇風味...",
"Select future time...": "選擇更新時間",
"Select payment type": "選擇付款方式",
"Select slot / product": "選擇貨道 / 商品",
"Select up to :max languages the machine can switch between. The first one is the default.": "選擇機台可切換的語系(最多 :max 種),第一個為預設語系。",
"Select...": "請選擇...",
"Selected": "已選擇",
@ -1974,6 +2077,7 @@
"Source Machine": "來源機台",
"Source Warehouse": "來源倉庫",
"Source of temperature limit settings": "溫度限制設定來源",
"Spec": "規格",
"Special Permission": "特殊權限",
"Specification": "規格",
"Specifications": "規格",
@ -2042,6 +2146,7 @@
"Sub Account Management": "子帳號管理",
"Sub Account Roles": "子帳號角色",
"Sub Accounts": "子帳號",
"Sub-accounts at this level cannot create further sub-accounts.": "此層級的子帳號無法再建立下層子帳號。",
"Sub-actions": "子項目",
"Sub-machine": "下位機",
"Sub-machine Status": "下位機狀態",
@ -2050,6 +2155,7 @@
"Submachine Exception": "下位機硬體異常",
"Submachine Exception (B013)": "下位機/貨道異常 (B013)",
"Submit Record": "提交紀錄",
"Submitting...": "送出中...",
"Subtotal": "小計",
"Success": "成功",
"Super Admin": "超級管理員",
@ -2103,6 +2209,7 @@
"Target hardware flavor for this APK": "此 APK 的目標硬體風味",
"Tax ID": "統一編號",
"Tax ID (Optional)": "統一編號 (選填)",
"Tax ID must be 8 digits": "統一編號須為 8 碼數字",
"Tax System": "稅務系統",
"Taxation System": "稅務系統",
"Temp Alert Range": "告警溫度範圍",
@ -2128,18 +2235,24 @@
"The Super Admin role cannot be deleted.": "超級管理員角色不可刪除。",
"The Super Admin role is immutable.": "超級管理員角色不可修改。",
"The Super Admin role name cannot be modified.": "超級管理員角色的名稱無法修改。",
"The company admin role cannot be deleted.": "公司管理員角色不可刪除。",
"The image archive contains too many files": "圖片壓縮檔內的檔案數量過多",
"The image archive is too large when extracted": "圖片壓縮檔解壓後的體積過大",
"The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。",
"The image is too large. Please upload an image smaller than 5MB.": "圖片檔案太大,請上傳小於 5MB 的圖片。",
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "已通知機台回抓最新系統設定,套用可能需要一點時間。",
"The selected product does not belong to this machine's company.": "所選商品不屬於此機台所屬的公司。",
"This Month": "本月",
"This Week": "本週",
"This account does not belong to this company.": "此帳號不屬於該公司。",
"This invoice has no printable copy": "此發票無可列印紙本",
"This is a system administrator role. Its name is locked to ensure system stability.": "這是系統管理員角色,名稱已鎖定以確保系統穩定性。",
"This machine currently has no medicine in stock.": "此機台目前無可領藥品庫存。",
"This machine has a pending command. Please wait.": "此機台已有指令正在執行,請稍後。",
"This machine has e-invoice disabled; cannot issue": "此機台未啟用電子發票,無法開立",
"This machine has no ECPay invoice settings": "此機台未設定綠界電子發票",
"This pharmacy pickup order can no longer be cancelled.": "此領藥單已無法作廢。",
"This product is not stocked on the selected machine; the code may not be redeemable until restocked.": "此商品未在所選機台上架,補貨前取貨碼可能無法兌換。",
"This role belongs to another company and cannot be assigned.": "此角色屬於其他公司,無法指派。",
"This slot has a pending command. Please wait.": "此貨道已有指令正在執行,請稍後。",
"This slot has a pending update. Please wait for the previous command to complete.": "此貨道已有更新指令在執行中,請等候前一個指令完成。",
@ -2244,6 +2357,7 @@
"Unlock": "解鎖",
"Unlock Now": "立即解鎖",
"Unlock Page": "頁面解鎖",
"Unpaid": "未支付",
"Unpublish": "下架",
"Update": "編輯",
"Update App": "更新 App",
@ -2390,9 +2504,12 @@
"Yesterday": "昨日",
"You can assign or change the personnel handling this order": "您可以指派或變更處理此訂單的人員",
"You can select at most :max languages.": "最多只能選擇 :max 種語系。",
"You cannot assign a role with permissions you do not possess.": "您無法指派含有您本身未持有權限的角色。",
"You cannot assign permissions you do not possess.": "您無法指派您自身不具備的權限。",
"You cannot delete your own account.": "您無法刪除自己的帳號。",
"You do not have permission to access replenishment orders": "您沒有機台補貨單的權限",
"You do not have permission to manage this account.": "您沒有權限管理此帳號。",
"You do not have permission to manage this role.": "您沒有權限管理此角色。",
"Your account is disabled.": "您的帳號已被停用。",
"Your recent account activity": "最近的帳號活動",
"[PickupCode] Verification failed: :code": "[取貨碼] 驗證失敗: :code",
@ -2422,6 +2539,7 @@
"command.lock": "鎖機",
"command.reboot": "重啟",
"command.reboot_card": "讀卡機重啟",
"command.reboot_force": "強制重啟",
"command.reload_stock": "同步庫存",
"command.unlock": "解鎖",
"command.update_ads": "同步廣告",
@ -2453,6 +2571,7 @@
"e.g. Taiwan Star": "例如:台灣之星",
"e.g. Test Code for Maintenance": "例如:維修測試代碼",
"e.g. johndoe": "例如xiaoming",
"e.g. the hospital pricing slip number": "例如:醫院批價單上的單號",
"e.g., 12345678": "例12345678",
"e.g., Beverage": "例如:飲料",
"e.g., Company Standard Pay": "例如:公司標準支付",
@ -2528,6 +2647,7 @@
"menu.sales": "銷售管理",
"menu.sales.orders": "購買單",
"menu.sales.pass-codes": "通行碼",
"menu.sales.pharmacy-pickup": "領藥單",
"menu.sales.pickup-codes": "取貨碼",
"menu.sales.promotions": "促銷時段",
"menu.sales.records": "銷售紀錄",
@ -2558,12 +2678,15 @@
"name_dictionary_key": "多語系鍵值",
"of": "/共",
"of items": "筆項目",
"optional": "選填",
"orders": "筆",
"overrides set": "項已設定機台價",
"pending": "等待機台領取",
"permissions": "權限設定",
"permissions.accounts": "帳號管理",
"permissions.companies": "客戶管理",
"permissions.roles": "角色權限管理",
"physical": "實體",
"price": "售價",
"product": "商品",
"product_catalog_synced_to_all_machines": "商品目錄已同步至所有機台",
@ -2573,6 +2696,7 @@
"product_updated": "商品資訊已更新",
"remote": "遠端管理",
"reservation": "預約系統",
"reserved": "預留",
"roles": "角色權限",
"s": "秒",
"sales": "銷售管理",

10
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "star-cloud",
"name": "html",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -875,7 +875,6 @@
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Fuzzyma"
@ -901,7 +900,6 @@
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 14.18"
},
@ -1138,7 +1136,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@ -1804,7 +1801,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -2109,7 +2105,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -2532,7 +2527,6 @@
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -2629,7 +2623,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2717,7 +2710,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

37
phpunit.mysql.xml Normal file
View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
本機 MySQL 測試設定:跑需要 MySQL-only DDL(如 machine_stock_movements.type enum)的測試。
用法:./vendor/bin/sail test -c phpunit.mysql.xml
使用 Sail 自動建立的 `testing` 資料庫;RefreshDatabase 會在其上跑 migration。
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="testing"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

View File

@ -75,6 +75,11 @@
}));
return;
}
if (!this.$el.payment_config_id.value.trim()) {
if (!confirm('{{ __("No payment config is selected (Not Used). Save this machine without any payment config?") }}')) {
return;
}
}
this.$el.submit();
}
}" @submit.prevent="submitForm" action="{{ route('admin.basic-settings.machines.update', $machine) }}" method="POST" enctype="multipart/form-data" class="space-y-6">

View File

@ -204,6 +204,12 @@
pickup_code_enabled: settings.pickup_code_enabled === true || settings.pickup_code_enabled === 1 || settings.pickup_code_enabled === '1',
pass_code_enabled: settings.pass_code_enabled === true || settings.pass_code_enabled === 1 || settings.pass_code_enabled === '1',
// 領藥單(取物單模式下的開關)
pharmacy_pickup_enabled: settings.pharmacy_pickup_enabled === true || settings.pharmacy_pickup_enabled === 1 || settings.pharmacy_pickup_enabled === '1',
// 副櫃系統(格子櫃功能授權,基礎版專用)
subcabinet_enabled: settings.subcabinet_enabled === true || settings.subcabinet_enabled === 1 || settings.subcabinet_enabled === '1',
// 零售附加功能
shopping_cart_enabled: machine.shopping_cart_enabled === true || machine.shopping_cart_enabled === 1 || machine.shopping_cart_enabled === '1',
welcome_gift_enabled: machine.welcome_gift_enabled === true || machine.welcome_gift_enabled === 1 || machine.welcome_gift_enabled === '1',
@ -619,6 +625,25 @@
</div>
</div>
<!-- 取物單模式專屬:領藥單開關 (toggle switch,比照會員系統/稅務系統) -->
<div x-show="machineSettings.shopping_mode === 'pickup_sheet'" x-transition class="grid grid-cols-1 md:grid-cols-4 gap-4 items-center p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 border-l-4 border-l-emerald-500 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<div class="md:col-span-1">
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Pharmacy Pickup (Rx)') }}</h4>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{{ __('Issue pharmacy pickup orders from the backend; patient scans QR to dispense.') }}</p>
</div>
<div class="md:col-span-3 flex flex-wrap gap-x-8 gap-y-4">
<label class="flex items-center gap-3 cursor-pointer group">
<div class="relative inline-flex items-center">
<input type="hidden" name="settings[pharmacy_pickup_enabled]" value="0">
<input type="checkbox" name="settings[pharmacy_pickup_enabled]" value="1"
x-model="machineSettings.pharmacy_pickup_enabled" class="peer sr-only">
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500"></div>
</div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300 group-hover:text-slate-900 dark:group-hover:text-white transition-colors">{{ __('Pharmacy Pickup (Rx)') }}</span>
</label>
</div>
</div>
<!-- 零售/金流展開部分 (僅基礎版顯示) -->
<div x-show="machineSettings.shopping_mode === 'basic'" x-transition class="space-y-3 rounded-2xl border border-slate-200 dark:border-slate-700/60 border-l-4 border-l-cyan-500 bg-slate-50/40 dark:bg-slate-800/20 p-4">
@ -933,6 +958,24 @@
</label>
</div>
</div>
<!-- 副櫃系統(格子櫃功能授權;機台端據此顯示副櫃分頁/鎖控板設定/貨道管理) -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-center p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<div class="md:col-span-1">
<h4 class="text-sm font-black text-slate-700 dark:text-slate-200">副櫃系統</h4>
</div>
<div class="md:col-span-3 flex flex-wrap gap-x-8 gap-y-4">
<label class="flex items-center gap-3 cursor-pointer group">
<div class="relative inline-flex items-center">
<input type="hidden" name="settings[subcabinet_enabled]" value="0">
<input type="checkbox" name="settings[subcabinet_enabled]" value="1"
x-model="machineSettings.subcabinet_enabled" class="peer sr-only">
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500"></div>
</div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-300 group-hover:text-slate-900 dark:group-hover:text-white transition-colors">格子櫃功能</span>
</label>
</div>
</div>
@if(auth()->user()->isSystemAdmin())
<!-- 顯示語系(最多 N 種,第一個為預設)。僅系統管理員可設定。 -->
<div class="p-4 rounded-xl bg-slate-50/50 dark:bg-slate-800/50 border border-slate-100 dark:border-slate-700/50">

View File

@ -99,6 +99,9 @@
$activeFeatures[] = __('Staff Card');
} elseif ($shoppingMode === 'pickup_sheet') {
$activeFeatures[] = __('Pickup Sheet (Material No.)');
if ($machine->settings['pharmacy_pickup_enabled'] ?? false) {
$activeFeatures[] = __('Pharmacy Pickup (Rx)');
}
} else {
$activeFeatures[] = __('Basic Mode');
@ -172,6 +175,7 @@
if ($machine->shopping_cart_enabled) $activeFeatures[] = __('Shopping Cart');
if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift');
if ($machine->settings['subcabinet_enabled'] ?? false) $activeFeatures[] = '副櫃系統';
}
if ($machine->member_system_enabled) $activeFeatures[] = __('Member System');
@ -242,6 +246,9 @@
$activeFeatures[] = __('Staff Card');
} elseif ($shoppingMode === 'pickup_sheet') {
$activeFeatures[] = __('Pickup Sheet (Material No.)');
if ($machine->settings['pharmacy_pickup_enabled'] ?? false) {
$activeFeatures[] = __('Pharmacy Pickup (Rx)');
}
} else {
$activeFeatures[] = __('Basic Mode');
@ -315,6 +322,7 @@
if ($machine->shopping_cart_enabled) $activeFeatures[] = __('Shopping Cart');
if ($machine->welcome_gift_enabled) $activeFeatures[] = __('Welcome Gift');
if ($machine->settings['subcabinet_enabled'] ?? false) $activeFeatures[] = '副櫃系統';
}
if ($machine->member_system_enabled) $activeFeatures[] = __('Member System');

View File

@ -444,11 +444,19 @@
}
this.isStaffModalOpen = true;
// Re-init Preline select or set value after modal opens
// 回填所屬公司下拉:
// 1. Preline v3 getInstance(target, true) 回傳的是 collection wrapper 而非實例,
// 其上沒有 setValue 會丟錯導致回填失靈,故不可帶第二個參數。
// 2. Preline 的 option value 為字串company_id 為數字時嚴格比對會失配,統一轉字串;空值用 ' ' 對應 placeholder。
// 同步先設原生 <select>.value 再呼叫 setValue對齊 welcome-gifts / pickup-codes 既有可運作寫法)。
this.$nextTick(() => {
const select = HSSelect.getInstance('#modal-company-id', true);
if (select) {
select.setValue(this.staffFormFields.company_id || ' ');
const el = document.getElementById('modal-company-id');
if (el) {
const cid = this.staffFormFields.company_id;
const valStr = (cid !== undefined && cid !== null && cid.toString().trim() !== '') ? cid.toString() : ' ';
el.value = valStr;
const inst = window.HSSelect && window.HSSelect.getInstance(el);
if (inst) inst.setValue(valStr);
}
});
},

View File

@ -49,6 +49,7 @@
inventoryLoading: false,
startDate: '',
endDate: '',
selectedLevel: '',
tab: 'list',
viewMode: 'fleet',
selectedMachine: null,
@ -321,6 +322,7 @@
let url = '/admin/machines/' + this.currentMachineId + '/logs-ajax?type=' + this.activeTab + '&page=' + page;
if (this.startDate) url += '&start_date=' + this.startDate;
if (this.endDate) url += '&end_date=' + this.endDate;
if (this.selectedLevel) url += '&level=' + this.selectedLevel;
const res = await fetch(url);
const data = await res.json();
@ -720,6 +722,15 @@
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
</svg>
</button>
<a href="{{ route('admin.machines.pricing', $machine) }}"
class="p-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-amber-500 hover:bg-amber-500/5 dark:hover:bg-amber-500/10 border border-transparent hover:border-amber-500/20 transition-all duration-200"
title="{{ __('Machine Pricing') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</a>
</div>
</td>
</tr>
@ -821,6 +832,39 @@
<p class="text-[9px] font-bold text-slate-400 mt-1">{{ $machine->latest_status_log_time->diffForHumans() }}</p>
@endif
</div>
<div>
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Hardware Status') }}</p>
@php $hStatus = $machine->hardware_status; @endphp
<div class="flex flex-col gap-1.5">
{{-- 下位機 --}}
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full {{
$hStatus === 'error' ? 'bg-rose-500 animate-pulse' :
($hStatus === 'warning' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500/50')
}}"></span>
<span class="text-xs font-black {{
$hStatus === 'error' ? 'text-rose-500' :
($hStatus === 'warning' ? 'text-amber-500' : 'text-slate-400')
}} uppercase tracking-widest">{{ __('Sub-machine') }}</span>
</div>
{{-- 刷卡機 (信用卡/電子票證/手機支付共用同一台):僅基礎版且啟用刷卡機才顯示 --}}
@if($machine->show_card_terminal)
@php $ctStatus = $machine->card_terminal_status; @endphp
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full {{
$ctStatus === 'error' ? 'bg-rose-500 animate-pulse' :
($ctStatus === 'warning' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500/50')
}}"></span>
<span class="text-xs font-black {{
$ctStatus === 'error' ? 'text-rose-500' :
($ctStatus === 'warning' ? 'text-amber-500' : 'text-slate-400')
}} uppercase tracking-widest">{{ __('Card Terminal') }}</span>
</div>
@endif
</div>
</div>
<div class="col-span-2">
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
@ -854,6 +898,14 @@
</svg>
{{ __('Logs') }}
</button>
<a href="{{ route('admin.machines.pricing', $machine) }}"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-amber-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ __('Pricing') }}
</a>
</div>
</div>
@empty
@ -943,7 +995,7 @@
<!-- Responsive Search within Panel -->
<div class="mt-2 flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6">
<div class="grid grid-cols-1 sm:flex sm:items-center gap-3 sm:gap-4 flex-1">
<div class="grid grid-cols-1 sm:flex sm:flex-wrap sm:items-center gap-3 sm:gap-4 flex-1 min-w-0">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 group">
<label
class="text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest whitespace-nowrap">{{
@ -992,10 +1044,22 @@
class="luxury-input py-2.5 pl-12 pr-4 w-full sm:w-52 text-sm font-bold tracking-tight cursor-pointer">
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-center gap-2 group w-full sm:w-auto">
<label
class="text-[11px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest whitespace-nowrap">{{
__('Level') }}</label>
<x-searchable-select id="log-level-select" name="level" class="w-full sm:w-44"
:placeholder="__('All Levels')" :has-search="false"
@change="selectedLevel = $event.target.value.trim(); fetchLogs(1)">
<option value="info" data-title="{{ __('Info') }}">{{ __('Info') }}</option>
<option value="warning" data-title="{{ __('Warning') }}">{{ __('Warning') }}</option>
<option value="error" data-title="{{ __('Error') }}">{{ __('Error') }}</option>
</x-searchable-select>
</div>
</div>
<div class="flex flex-wrap items-center justify-start sm:justify-end gap-4 sm:gap-6">
<div class="flex flex-wrap items-center justify-start sm:justify-end gap-4 sm:gap-6 shrink-0">
<button @click="confirmResolve()"
class="text-[12px] font-bold text-rose-500 dark:text-rose-400 uppercase tracking-widest hover:text-rose-600 transition-colors flex items-center gap-1.5 px-3 py-1.5 rounded-xl hover:bg-rose-500/5 transition-all">
class="text-[12px] font-bold text-rose-500 dark:text-rose-400 uppercase tracking-widest hover:text-rose-600 transition-colors flex items-center gap-1.5 px-3 py-1.5 rounded-xl hover:bg-rose-500/5 transition-all whitespace-nowrap">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
@ -1003,8 +1067,10 @@
</svg>
{{ __('Clear Abnormal Status') }}
</button>
<button @click="startDate = ''; endDate = ''; fetchLogs(1)"
class="text-[12px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5">
<button @click="startDate = ''; endDate = ''; selectedLevel = '';
(() => { const s = document.getElementById('log-level-select'); const i = window.HSSelect?.getInstance(s); if (i) i.setValue(' '); })();
fetchLogs(1)"
class="text-[12px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-widest hover:text-cyan-500 transition-colors flex items-center gap-1.5 whitespace-nowrap">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"

View File

@ -0,0 +1,226 @@
@extends('layouts.admin')
@section('content')
<div class="space-y-6 pb-28"
x-data="machinePricing({{ Js::from($items) }}, '{{ route('admin.machines.pricing.update', $machine) }}')">
{{-- Header --}}
<div class="flex items-center gap-4">
<a href="{{ route('admin.machines.index') }}"
class="p-2.5 rounded-xl bg-white dark:bg-slate-900 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all border border-slate-200/50 dark:border-slate-700/50 shadow-sm hover:shadow-md active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</a>
<div class="min-w-0">
<h1 class="text-2xl sm:text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight flex items-center gap-3">
<span class="p-2 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20">
<svg class="w-6 h-6 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
{{ __('Machine Pricing') }}
</h1>
<div class="mt-2 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest overflow-hidden">
<span class="font-mono text-cyan-600 dark:text-cyan-400 truncate">{{ $machine->serial_no }}</span>
<span class="opacity-50"></span>
<span class="truncate">{{ $machine->name }}</span>
</div>
</div>
</div>
<div class="luxury-card rounded-3xl p-6 sm:p-8 space-y-6">
{{-- Hint + Search --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<p class="text-sm text-slate-500 dark:text-slate-400 font-medium leading-relaxed max-w-2xl">
{{ __('Leave blank to use the global product price. Member price will be capped at the machine selling price.') }}
</p>
<div class="relative w-full sm:w-72 flex-shrink-0">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<input type="text" x-model="search" class="py-2.5 pl-12 pr-6 block w-full luxury-input"
placeholder="{{ __('Search products...') }}">
</div>
</div>
{{-- Empty (no products in company) --}}
<template x-if="items.length === 0">
<div class="text-center py-20 text-slate-400 font-bold uppercase tracking-widest text-sm">
{{ __('No products available for this company') }}
</div>
</template>
{{-- Table --}}
<div x-show="items.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest border-b border-slate-200/60 dark:border-slate-700/50">
<th class="text-left py-3 px-3">{{ __('Product') }}</th>
<th class="text-center py-3 px-3 w-40">{{ __('Machine Price') }}</th>
<th class="text-center py-3 px-3 w-40">{{ __('Member Price') }}</th>
</tr>
</thead>
<tbody>
<template x-for="item in filteredItems" :key="item.product_id">
<tr class="border-b border-slate-100 dark:border-slate-800/60 hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors">
{{-- Product --}}
<td class="py-3 px-3">
<div class="flex items-center gap-3 min-w-0">
<div class="w-11 h-11 rounded-xl bg-slate-100 dark:bg-slate-800 overflow-hidden flex-shrink-0 flex items-center justify-center text-slate-300">
<template x-if="item.image_url">
<img :src="item.image_url" class="w-full h-full object-cover">
</template>
<template x-if="!item.image_url">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
</template>
</div>
<div class="min-w-0">
<div class="font-black text-slate-800 dark:text-white truncate" x-text="item.name"></div>
<div class="text-[11px] font-bold text-slate-400 uppercase tracking-widest">
{{ __('Global') }}: $<span x-text="Math.floor(item.global_price)"></span>
<template x-if="item.global_member_price !== null">
<span> · {{ __('Member') }} $<span x-text="Math.floor(item.global_member_price)"></span></span>
</template>
</div>
</div>
</div>
</td>
{{-- Machine Price --}}
<td class="py-3 px-3">
<div class="flex items-center h-12 rounded-xl border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all overflow-hidden">
<button type="button" @click="bump(item, 'override_price', -1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" /></svg>
</button>
<div class="flex-1 min-w-[56px]">
<input type="number" min="1" step="1" inputmode="numeric"
x-model="item.override_price"
:placeholder="Math.floor(item.global_price)"
class="w-full bg-transparent border-none text-center font-black text-slate-800 dark:text-white focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
</div>
<button type="button" @click="bump(item, 'override_price', 1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
</button>
</div>
</td>
{{-- Member Price --}}
<td class="py-3 px-3">
<div class="flex items-center h-12 rounded-xl border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all overflow-hidden">
<button type="button" @click="bump(item, 'override_member_price', -1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20 12H4" /></svg>
</button>
<div class="flex-1 min-w-[56px]">
<input type="number" min="1" step="1" inputmode="numeric"
x-model="item.override_member_price"
:placeholder="item.global_member_price !== null ? Math.floor(item.global_member_price) : '—'"
class="w-full bg-transparent border-none text-center font-black text-slate-800 dark:text-white focus:ring-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
</div>
<button type="button" @click="bump(item, 'override_member_price', 1)" class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
</button>
</div>
</td>
</tr>
</template>
<template x-if="items.length > 0 && filteredItems.length === 0">
<tr><td colspan="3" class="text-center py-12 text-slate-400 font-bold uppercase tracking-widest text-sm">{{ __('No matching products') }}</td></tr>
</template>
</tbody>
</table>
</div>
</div>
{{-- Sticky Save Bar --}}
<div class="fixed bottom-0 inset-x-0 sm:left-auto sm:right-8 sm:bottom-8 z-40 flex justify-end px-4 sm:px-0">
<div class="w-full sm:w-auto bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl border border-slate-200 dark:border-white/10 rounded-2xl shadow-2xl px-5 py-4 flex items-center justify-end gap-3">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest mr-auto sm:mr-2" x-text="overrideCount + ' {{ __('overrides set') }}'"></span>
<a href="{{ route('admin.machines.index') }}"
class="px-5 py-2.5 rounded-xl text-sm font-black text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 uppercase tracking-widest transition-colors">
{{ __('Cancel') }}
</a>
<button type="button" @click="save()" :disabled="saving || items.length === 0"
class="px-6 py-2.5 rounded-xl text-sm font-black bg-cyan-500 text-white shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 hover:-translate-y-0.5 active:translate-y-0 transition-all uppercase tracking-widest disabled:opacity-40 disabled:pointer-events-none flex items-center gap-2">
<template x-if="saving">
<span class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
</template>
{{ __('Save & Sync') }}
</button>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('machinePricing', (initialItems, saveUrl) => ({
// 空字串代表「沿用全域價」,以 placeholder 顯示全域價作為提示
items: initialItems.map(p => ({
...p,
override_price: p.override_price !== null ? p.override_price : '',
override_member_price: p.override_member_price !== null ? p.override_member_price : '',
})),
saveUrl,
search: '',
saving: false,
get filteredItems() {
const q = this.search.trim().toLowerCase();
if (!q) return this.items;
return this.items.filter(i => (i.name || '').toLowerCase().includes(q) || String(i.barcode || '').toLowerCase().includes(q));
},
// +/- 步進:欄位為空(沿用全域)時,從全域價起跳;最低 1與後端 min:1 一致)
bump(item, field, delta) {
const base = field === 'override_member_price'
? (item.global_member_price !== null ? item.global_member_price : item.global_price)
: item.global_price;
const cur = (item[field] === '' || item[field] === null) ? base : parseInt(item[field]);
item[field] = Math.max(1, (parseInt(cur) || base) + delta);
},
get overrideCount() {
return this.items.filter(i =>
(i.override_price !== '' && i.override_price !== null) ||
(i.override_member_price !== '' && i.override_member_price !== null)
).length;
},
async save() {
if (this.saving || this.items.length === 0) return;
this.saving = true;
const payload = this.items.map(p => ({
product_id: p.product_id,
price: (p.override_price === '' || p.override_price === null) ? null : p.override_price,
member_price: (p.override_member_price === '' || p.override_member_price === null) ? null : p.override_member_price,
}));
try {
const res = await fetch(this.saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify({ items: payload }),
});
const data = await res.json();
if (res.ok && data.success) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message || '{{ __('Saved') }}', type: 'success' } }));
} else {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message || '{{ __('Save failed') }}', type: 'error' } }));
}
} catch (e) {
console.error('Pricing save error:', e);
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Save failed') }}', type: 'error' } }));
} finally {
this.saving = false;
}
}
}));
});
</script>
@endsection

View File

@ -36,13 +36,6 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Add Product') }}</span>
</a>
<div class="order-2 w-full flex items-center gap-2 md:contents">
<button @click="isImportModalOpen = true" type="button"
class="btn-luxury-secondary whitespace-nowrap flex items-center gap-2 transition-all duration-300 flex-1 md:flex-none justify-center min-w-[126px] md:order-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Import Excel') }}</span>
</button>
<button @click="syncToAllMachines()"
class="btn-luxury-ghost group whitespace-nowrap flex items-center justify-center gap-2 transition-all duration-300 py-2 sm:py-2.5 px-4 sm:px-6 rounded-2xl border-slate-200 dark:border-white/10 hover:border-cyan-500/50 hover:bg-cyan-500/5 flex-1 md:flex-none min-w-[150px] md:order-2">
<svg class="w-4 h-4 text-cyan-500 group-hover:rotate-180 transition-transform duration-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">

View File

@ -62,6 +62,41 @@ $baseRoute = 'admin.data-config.products';
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button @click="isImportModalOpen = true" type="button"
class="px-4 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
title="{{ __('Import Excel') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="hidden sm:inline">{{ __('Import Excel') }}</span>
</button>
<div class="relative" x-data="{ exportOpen: false }">
<button type="button" @click="exportOpen = !exportOpen" @click.away="exportOpen = false"
class="px-4 py-2.5 rounded-xl bg-emerald-500 text-white hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
title="{{ __('Export') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span class="hidden sm:inline">{{ __('Export') }}</span>
<svg class="w-3 h-3 transition-transform duration-200" :class="exportOpen ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div x-show="exportOpen" x-transition
class="absolute right-0 top-full mt-2 bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl rounded-2xl p-2 z-30 min-w-[160px]"
x-cloak>
<a href="{{ route($baseRoute . '.export', array_merge(request()->only(['search','category_id','product_company_id']), ['export' => 'csv'])) }}"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📄 {{ __('Export to CSV') }}
</a>
<a href="{{ route($baseRoute . '.export', array_merge(request()->only(['search','category_id','product_company_id']), ['export' => 'excel'])) }}"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📊 {{ __('Export to Excel') }}
</a>
</div>
</div>
</div>
<input type="hidden" name="tab" value="products">

View File

@ -34,6 +34,7 @@
'No active cargo lanes found' => __('No active cargo lanes found'),
'Empty' => __('Empty'),
'Machine Reboot' => __('Machine Reboot'),
'Machine Force Reboot' => __('Machine Force Reboot'),
'Card Reader Reboot' => __('Card Reader Reboot'),
'Remote Settlement' => __('Remote Settlement'),
'Lock Page Lock' => __('Lock Page Lock'),
@ -444,6 +445,7 @@
getCommandName(type) {
const names = {
'reboot': this.translations['Machine Reboot'],
'reboot_force': this.translations['Machine Force Reboot'],
'reboot_card': this.translations['Card Reader Reboot'],
'checkout': this.translations['Remote Settlement'],
'lock': this.translations['Lock Page Lock'],
@ -661,6 +663,27 @@
</div>
</button>
<!-- Force Reboot (ignores busy / stuck dialog) -->
<button @click="sendCommand('reboot_force')"
class="p-6 rounded-3xl border border-amber-200 dark:border-amber-500/30 flex items-center gap-5 hover:border-amber-500/60 dark:hover:border-amber-400/70 hover:bg-amber-500/5 dark:hover:bg-amber-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">
<div
class="w-12 h-12 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500 dark:text-amber-400 group-hover:scale-110 transition-transform duration-500 border border-amber-500/20 dark:border-amber-400/20">
<svg class="w-6 h-6" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="text-left">
<div
class="text-sm font-black text-slate-800 dark:text-white group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors">
{{ __('Machine Force Reboot') }}</div>
<div class="text-[11px] text-slate-400 dark:text-slate-500 mt-0.5">
{{ __('Ignore busy state (use when stuck)') }}</div>
</div>
</button>
<!-- Card Reader Reboot -->
<button @click="sendCommand('reboot_card')"
class="p-6 rounded-3xl border border-slate-100 dark:border-slate-800 flex items-center gap-5 hover:border-cyan-500/50 dark:hover:border-cyan-400/60 hover:bg-cyan-500/5 dark:hover:bg-cyan-400/5 group transition-all bg-white/50 dark:bg-slate-900/40 shadow-sm">

View File

@ -85,6 +85,7 @@
<div class="w-full sm:w-48">
<x-searchable-select name="command_type" :options="[
'reboot' => __('Machine Reboot'),
'reboot_force' => __('Machine Force Reboot'),
'reboot_card' => __('Card Reader Reboot'),
'checkout' => __('Remote Settlement'),
'lock' => __('Lock Page Lock'),

View File

@ -251,6 +251,42 @@
}
},
async toggleLock() {
this.updating = true;
try {
const csrf = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const res = await fetch(`/admin/machines/${this.selectedMachine.id}/slots/lock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf
},
body: JSON.stringify({
slot_no: this.selectedSlot.slot_no,
is_locked: !this.selectedSlot.is_locked
})
});
const data = await res.json();
if (data.success) {
this.showEditModal = false;
window.location.href = "{{ route('admin.remote.stock') }}";
} else {
throw new Error(data.message || 'Unknown error');
}
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: '{{ __("Save error:") }} ' + e.message,
type: 'error'
}
}));
console.error('Lock toggle error:', e);
} finally {
this.updating = false;
}
},
getSlotColorClass(slot) {
const today = new Date().toISOString().split('T')[0];
if (slot.expiry_date && slot.expiry_date < today) {
@ -296,6 +332,7 @@
case 'sent': return 'bg-cyan-100 text-cyan-600 dark:bg-cyan-500/10 dark:text-cyan-400 border-cyan-200 dark:border-cyan-500/20';
case 'success': return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20';
case 'failed': return 'bg-rose-100 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-200 dark:border-rose-500/20';
case 'timeout': return 'bg-rose-100 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-200 dark:border-rose-500/20';
case 'superseded': return 'bg-slate-100 text-slate-500 dark:bg-slate-500/10 dark:text-slate-400 border-slate-200 dark:border-slate-500/20 opacity-80';
default: return 'bg-slate-100 text-slate-600 border-slate-200';
}
@ -304,6 +341,7 @@
getCommandName(type) {
const names = {
'reboot': {{ Js::from(__('Machine Reboot')) }},
'reboot_force': {{ Js::from(__('Machine Force Reboot')) }},
'reboot_card': {{ Js::from(__('Card Reader Reboot')) }},
'checkout': {{ Js::from(__('Remote Settlement')) }},
'lock': {{ Js::from(__('Lock Page Lock')) }},
@ -321,6 +359,7 @@
'sent': {{ Js::from(__('Sent')) }},
'success': {{ Js::from(__('Success')) }},
'failed': {{ Js::from(__('Failed')) }},
'timeout': {{ Js::from(__('Timeout')) }},
'superseded': {{ Js::from(__('Superseded')) }}
};
return statuses[status] || status;
@ -349,6 +388,14 @@
details += `{{ __('Batch') }} ${p.old.batch_no || 'N/A'} → ${p.new.batch_no || 'N/A'}`;
}
// 鎖定/解鎖暫停販售切換old/new 僅 is_locked 不同時,避免顯示空白徽章
if ((p.old.is_locked ?? false) !== (p.new.is_locked ?? false)) {
if (details !== `{{ __('Slot') }} ${p.slot_no}: `) details += ', ';
details += p.new.is_locked
? `{{ __('Lock (Pause Sale)') }}`
: `{{ __('Unlock') }}`;
}
return details;
}
return '';
@ -663,12 +710,21 @@
class="text-xs font-black uppercase tracking-tighter text-slate-800 dark:text-white"
x-text="slot.slot_no"></span>
</div>
<template x-if="slot.max_stock > 0 && slot.stock <= (slot.max_stock * 0.2)">
<div
class="px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
<div class="flex items-center gap-1.5">
<template x-if="slot.is_locked">
<div
class="flex items-center gap-1 px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 whitespace-nowrap select-none">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</div>
</template>
<template x-if="slot.max_stock > 0 && slot.stock <= (slot.max_stock * 0.2)">
<div
class="px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 animate-pulse whitespace-nowrap select-none">
{{ __('Low') }}
</div>
</template>
</div>
</div>
<!-- Product Image -->
@ -774,8 +830,16 @@
</template>
</div>
<div>
<div class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></div>
<div class="flex items-center gap-2">
<span class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></span>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-500 text-[10px] font-black uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Locked (Sale Paused)') }}
</span>
</template>
</div>
<template x-if="slot.product?.id">
<div class="mt-1">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 font-mono tracking-widest uppercase">{{ __('ID') }}: <span x-text="slot.product.id"></span></span>
@ -840,7 +904,12 @@
</div>
</div>
<div class="shrink-0 text-right">
<span class="text-xs font-black px-2.5 py-1.5 rounded-lg bg-slate-900/10 dark:bg-white/10" x-text="slot.slot_no"></span>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 text-[10px] font-black px-2.5 py-1.5 rounded-lg bg-rose-500/10 text-rose-500 uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</span>
</template>
</div>
</div>
@ -963,6 +1032,24 @@
</div>
</div>
<!-- Slot Lock (暫停販售):遠端鎖定/解鎖此貨道,與機台現場雙向同步 -->
<div
class="mt-6 flex items-center justify-between rounded-2xl border border-slate-100 dark:border-slate-800/50 px-5 py-4">
<div>
<div class="text-sm font-black text-slate-500 uppercase tracking-widest">{{
__('Sale Status') }}</div>
<div class="mt-1 text-xs font-bold"
:class="selectedSlot?.is_locked ? 'text-rose-500' : 'text-emerald-500'"
x-text="selectedSlot?.is_locked ? '{{ __('Locked (Sale Paused)') }}' : '{{ __('On Sale') }}'">
</div>
</div>
<button type="button" @click="toggleLock()" :disabled="updating"
class="px-6 py-3 rounded-xl font-bold transition disabled:opacity-50"
:class="selectedSlot?.is_locked ? 'bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20' : 'bg-rose-500/10 text-rose-600 hover:bg-rose-500/20'"
x-text="selectedSlot?.is_locked ? '{{ __('Unlock') }}' : '{{ __('Lock (Pause Sale)') }}'">
</button>
</div>
</div>
<!-- Footer Actions -->

View File

@ -2,7 +2,8 @@
@section('content')
<div class="space-y-2 pb-20" x-data="salesCenter()"
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)"
@manual-order-created.window="switchTab('orders'); fetchTabData('orders')">
{{-- Page Header --}}
<x-page-header :title="$title" :subtitle="$description" />
@ -107,6 +108,11 @@
<x-confirm-modal alpineVar="showReissueModal" confirmAction="submitInvoiceAction()" :title="__('Re-issue')"
:message="__('Re-issue this invoice?')" :confirmText="__('Re-issue')" :cancelText="__('Cancel')"
iconType="info" confirmColor="sky" />
{{-- 手動補單彈窗:僅系統管理員可見 / 使用(後端再次把關) --}}
@if(auth()->user()->isSystemAdmin())
@include('admin.sales.partials.manual-order-modal')
@endif
</div>
@endsection
@ -227,6 +233,24 @@ function salesCenter() {
}
},
async generatePickupCode(orderId) {
if (!confirm('{{ __('Generate pickup code? (Valid for pickup within 7 days)') }}')) return;
try {
const res = await fetch(`/admin/sales/orders/${orderId}/pickup-code`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
}
});
const data = await res.json();
window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message || '', type: data.success ? 'success' : 'error' } }));
if (data.success) this.fetchTabData('orders');
} catch (e) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Operation failed') }}', type: 'error' } }));
}
},
openDetail(orderId) {
this.showPanel = true;
document.getElementById('order-detail-content').innerHTML = `<div class="flex items-center justify-center h-full py-20"><x-luxury-spinner show="true" /></div>`;
@ -374,5 +398,230 @@ function salesCenter() {
}
}
}
function manualOrderForm(slotSelectConfig) {
return {
slotSelectConfig: slotSelectConfig,
show: false,
machineId: '',
taxInvoiceEnabled: false,
slots: [],
loadingSlots: false,
selectedSlot: '',
quantity: 1,
price: 0,
paymentType: '',
occurredAt: '',
deductStock: true,
issueInvoice: false,
invoiceType: 'b2c',
invoiceValue: '',
remark: '',
submitting: false,
_fp: null,
open() {
this.resetForm();
this.show = true;
this.$nextTick(() => {
this.initPicker();
// 重置/初始化 Preline 搜尋下拉的顯示值
this.syncSelect('manual-machine', ' ');
this.syncSelect('manual-payment', ' ');
this.syncSelect('manual-invoice-type', 'b2c');
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
this.updateSlotSelect();
});
},
resetForm() {
this.machineId = '';
this.taxInvoiceEnabled = false;
this.slots = [];
this.selectedSlot = '';
this.quantity = 1;
this.price = 0;
this.paymentType = '';
this.occurredAt = '';
this.deductStock = true;
this.issueInvoice = false;
this.invoiceType = 'b2c';
this.invoiceValue = '';
this.remark = '';
},
initPicker() {
if (this._fp) { this._fp.destroy(); this._fp = null; }
if (window.flatpickr && this.$refs.occurredAt) {
this._fp = flatpickr(this.$refs.occurredAt, {
dateFormat: 'Y-m-d H:i', enableTime: true, time_24hr: true, disableMobile: true,
locale: '{{ app()->getLocale() == 'zh_TW' ? 'zh_tw' : 'en' }}',
});
}
},
async onMachineChange() {
this.slots = [];
this.selectedSlot = '';
this.price = 0;
this.taxInvoiceEnabled = false;
this.issueInvoice = false;
this.updateSlotSelect();
if (!this.machineId) return;
this.loadingSlots = true;
try {
const res = await fetch(`{{ route('admin.sales.manual.machine-slots') }}?machine_id=${this.machineId}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
});
const data = await res.json();
if (data.success) {
this.slots = data.slots || [];
this.taxInvoiceEnabled = !!data.tax_invoice_enabled;
}
} catch (e) {
console.error('load slots failed', e);
window.showToast?.('{{ __('Failed to load slots') }}', 'error');
} finally {
this.loadingSlots = false;
this.updateSlotSelect();
}
},
// 依 slots 重建貨道/商品搜尋下拉(沿用全站 Preline searchable-select 樣式)
updateSlotSelect() {
this.$nextTick(() => {
const wrapper = document.getElementById('manual-slot-wrapper');
if (!wrapper) return;
wrapper.querySelectorAll('select').forEach(s => {
try { window.HSSelect?.getInstance(s)?.destroy(); } catch (e) {}
});
wrapper.innerHTML = '';
if (!this.machineId) {
const dummy = document.createElement('div');
dummy.className = 'luxury-input opacity-50 cursor-not-allowed flex items-center justify-between';
dummy.innerHTML = `<span class="text-slate-400">{{ __('Select a machine to choose products') }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
wrapper.appendChild(dummy);
return;
}
const selectEl = document.createElement('select');
selectEl.id = 'manual-slot-' + Date.now();
selectEl.className = 'hidden';
const cleanConfig = JSON.parse(JSON.stringify(this.slotSelectConfig), (key, value) => {
return typeof value === 'string' ? value.replace(/\r?\n|\r/g, ' ').trim() : value;
});
selectEl.setAttribute('data-hs-select', JSON.stringify(cleanConfig));
const ph = document.createElement('option');
ph.value = '';
ph.textContent = "{{ __('Select slot / product') }}";
ph.setAttribute('data-title', "{{ __('Select slot / product') }}");
selectEl.appendChild(ph);
this.slots.forEach(slot => {
const opt = document.createElement('option');
opt.value = slot.slot_no;
const text = `${slot.slot_no} · ${slot.product_name} ({{ __('Stock') }}: ${slot.stock})`;
opt.textContent = text;
opt.setAttribute('data-title', text);
if (String(this.selectedSlot) === String(slot.slot_no)) opt.selected = true;
selectEl.appendChild(opt);
});
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', e => this.onSlotPicked(e.target.value));
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit(['select']);
});
},
onSlotPicked(slotNo) {
this.selectedSlot = slotNo;
const slot = this.slots.find(s => String(s.slot_no) === String(slotNo));
this.price = slot ? Number(slot.price) || 0 : 0;
},
// 同步 Preline 下拉顯示值(沿用取貨碼模式)
syncSelect(id, value) {
const el = document.getElementById(id);
if (!el) return;
const v = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' ';
el.value = v;
try { window.HSSelect?.getInstance(el)?.setValue(v); } catch (e) {}
},
get total() {
return Math.round((Number(this.price) || 0) * (Number(this.quantity) || 0));
},
invoiceValueLabel() {
return {
taxid: '{{ __('Company Tax ID') }}',
mobile: '{{ __('Mobile barcode carrier') }}',
citizen: '{{ __('Citizen certificate carrier') }}',
donation: '{{ __('Donation love code') }}',
}[this.invoiceType] || '';
},
validate() {
if (!this.machineId) { window.showToast?.('{{ __('Select a machine') }}', 'error'); return false; }
if (!this.selectedSlot) { window.showToast?.('{{ __('Select slot / product') }}', 'error'); return false; }
if (!this.paymentType) { window.showToast?.('{{ __('Select payment type') }}', 'error'); return false; }
if (!(Number(this.quantity) > 0)) { return false; }
return true;
},
async submit() {
if (this.submitting || !this.validate()) return;
const slot = this.slots.find(s => String(s.slot_no) === String(this.selectedSlot));
if (!slot) { window.showToast?.('{{ __('Select slot / product') }}', 'error'); return; }
this.submitting = true;
try {
const payload = {
machine_id: this.machineId,
payment_type: this.paymentType,
occurred_at: this.occurredAt || null,
deduct_stock: this.deductStock,
issue_invoice: this.taxInvoiceEnabled && this.issueInvoice,
invoice_type: this.invoiceType,
invoice_value: this.invoiceValue,
remark: this.remark,
items: [{
product_id: slot.product_id,
slot_no: slot.slot_no,
price: this.price,
quantity: this.quantity,
}],
};
const res = await fetch('{{ route('admin.sales.manual.store') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok && data.success) {
window.showToast?.(data.message || 'OK', 'success');
this.show = false;
window.dispatchEvent(new CustomEvent('manual-order-created'));
} else {
window.showToast?.(data.message || '{{ __('Failed to create manual order') }}', 'error');
}
} catch (e) {
console.error('submit manual order failed', e);
window.showToast?.('{{ __('Failed to create manual order') }}', 'error');
} finally {
this.submitting = false;
}
},
};
}
</script>
@endsection

View File

@ -0,0 +1,29 @@
@if(session('code_batch'))
@php($batch = session('code_batch'))
<div class="luxury-card rounded-3xl p-6 border border-emerald-500/30 bg-emerald-500/5 flex flex-col sm:flex-row sm:items-center gap-4 animate-luxury-in">
<div class="flex items-center gap-3 flex-1">
<div class="w-11 h-11 rounded-2xl bg-emerald-500/15 text-emerald-500 flex items-center justify-center shrink-0">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p class="text-sm font-black text-slate-800 dark:text-white">{{ __('Batch generated successfully') }}</p>
<p class="text-xs font-bold text-slate-500">
{{ __('Batch No') }}: <span class="font-mono text-cyan-600 dark:text-cyan-400">{{ $batch['batch_no'] }}</span>
· {{ $batch['count'] }} {{ __('codes') }}
</p>
</div>
</div>
{{-- download 屬性讓全域連結攔截器layouts/admin跳過「導航載入遮罩」
否則檔案下載不會換頁、遮罩不會關閉,畫面會卡在模糊狀態。 --}}
<a href="{{ $batch['download_url'] }}" download
class="btn-luxury-primary whitespace-nowrap flex items-center justify-center gap-2">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 12l4.5 4.5m0 0l4.5-4.5M12 3v13.5" />
</svg>
{{ __('Download List (CSV)') }}
</a>
</div>
@endif

View File

@ -0,0 +1,223 @@
@php
// 貨道/商品搜尋下拉的 Preline 設定(沿用全站 searchable-select 樣式)
$manualSlotSelectConfig = [
"placeholder" => __('Select slot / product'),
"hasSearch" => true,
"searchPlaceholder" => __('Search...'),
"isHidePlaceholder" => false,
"searchClasses" => "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses" => "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses" => "hs-select-toggle luxury-select-toggle",
"toggleTemplate" => '<button type="button" aria-expanded="false"><span class="me-2" data-icon></span><span class="text-slate-800 dark:text-slate-200" data-title></span><div class="ms-auto"><svg class="size-4 text-slate-400 transition-transform duration-300" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg></div></button>',
"dropdownClasses" => "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[200] animate-luxury-in",
"optionClasses" => "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate" => '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>',
];
@endphp
{{-- 手動補單彈窗(機台斷線/漏報時人工補登銷售紀錄)— 僅系統管理員 --}}
<div x-data="manualOrderForm(@js($manualSlotSelectConfig))" x-cloak
@open-manual-order.window="open()"
@keydown.window.escape="show = false">
<div class="fixed inset-0 z-[110] overflow-y-auto" x-show="show">
{{-- Backdrop --}}
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
x-show="show"
x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@click="show = false"></div>
{{-- Dialog --}}
<div class="flex min-h-full items-center justify-center p-4">
<div class="relative w-full max-w-2xl transform transition-all"
x-show="show"
x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-6 scale-95" x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0 scale-95">
<div class="luxury-card rounded-3xl bg-white dark:bg-slate-900 shadow-2xl border border-slate-100 dark:border-slate-800 overflow-hidden">
{{-- Header --}}
<div class="flex items-start justify-between gap-4 px-6 sm:px-8 pt-6 pb-4 border-b border-slate-100 dark:border-slate-800">
<div>
<h3 class="text-lg font-black text-slate-800 dark:text-slate-100 tracking-tight flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-cyan-500"></span>
{{ __('Manual Order Entry') }}
</h3>
<p class="text-[11px] font-bold text-slate-400 dark:text-slate-500 mt-1 tracking-wide">
{{ __('For sales not reported due to disconnection. System administrators only.') }}
</p>
</div>
<button type="button" @click="show = false"
class="p-2 rounded-xl text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
{{-- Body --}}
<div class="px-6 sm:px-8 py-6 space-y-5 max-h-[68vh] overflow-y-auto">
{{-- 機台 + 發生時間 --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Machine') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="manual_machine_id" id="manual-machine"
:placeholder="__('Select a machine')"
x-model="machineId" @change="machineId = $event.target.value; onMachineChange()">
@foreach($machines as $m)
<option value="{{ $m->id }}" data-title="{{ $m->name }} ({{ $m->serial_no }})">{{ $m->name }} ({{ $m->serial_no }})</option>
@endforeach
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Transaction Time') }}</label>
<input type="text" x-ref="occurredAt" x-model="occurredAt"
placeholder="{{ __('Defaults to now if empty') }}"
class="luxury-input w-full text-sm py-2.5 px-4 cursor-pointer">
</div>
</div>
{{-- 貨道/商品(搜尋式,依機台動態載入) --}}
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Select slot / product') }} <span class="text-rose-500">*</span></label>
<div class="relative">
<div id="manual-slot-wrapper" class="relative">{{-- Rebuilt by Alpine --}}</div>
<div x-show="loadingSlots" class="absolute right-12 top-1/2 -translate-y-1/2 z-10">
<x-luxury-spinner show="loadingSlots" />
</div>
</div>
</div>
{{-- 付款方式 + 價格 --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Payment Type') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="manual_payment_type" id="manual-payment"
:placeholder="__('Select payment type')"
x-model="paymentType" @change="paymentType = $event.target.value">
@foreach($paymentTypes as $ptVal => $ptLabel)
<option value="{{ $ptVal }}" data-title="{{ $ptLabel }}">{{ $ptLabel }}</option>
@endforeach
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Price') }} <span class="text-rose-500">*</span></label>
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden w-full group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="price = Math.max(0, (Number(price)||0) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-0 flex items-center justify-center">
<span class="text-slate-400 font-black mr-0.5">$</span>
<input type="text" inputmode="decimal" x-model.number="price"
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="price = (Number(price)||0) + 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
</div>
</div>
{{-- 數量Luxury Counter +/- --}}
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Qty') }}</label>
<div class="flex flex-col sm:flex-row items-center gap-6">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 min-w-[200px] w-full group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="quantity > 1 ? quantity-- : 1"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<div class="flex-1 min-w-[60px]">
<input type="text" x-model.number="quantity" readonly
class="w-full bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0">
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity < 999 ? quantity++ : 999"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="flex items-center gap-1.5">
<template x-for="val in [1, 2, 3, 5]" :key="val">
<button type="button" @click="quantity = val"
class="w-10 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</div>
</div>
</div>
{{-- 小計 --}}
<div class="flex items-center justify-end gap-2 pt-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Total') }}</span>
<span class="text-lg font-black text-slate-800 dark:text-white" x-text="`$${total}`"></span>
</div>
{{-- 扣庫存 --}}
<label class="flex items-start gap-3 p-3 rounded-2xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800 cursor-pointer">
<input type="checkbox" x-model="deductStock" class="mt-0.5 rounded border-slate-300 text-cyan-500 focus:ring-cyan-500">
<span>
<span class="block text-sm font-bold text-slate-700 dark:text-slate-200">{{ __('Deduct stock') }}</span>
<span class="block text-[11px] text-slate-400 mt-0.5">{{ __('Recommended when the machine physically dispensed but did not report, to reconcile cloud stock.') }}</span>
</span>
</label>
{{-- 開立電子發票(僅機台啟用電子發票時顯示) --}}
<div x-show="taxInvoiceEnabled" x-cloak>
<label class="flex items-start gap-3 p-3 rounded-2xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800 cursor-pointer">
<input type="checkbox" x-model="issueInvoice" class="mt-0.5 rounded border-slate-300 text-cyan-500 focus:ring-cyan-500">
<span class="flex-1">
<span class="block text-sm font-bold text-slate-700 dark:text-slate-200">{{ __('Issue e-invoice') }}</span>
<span class="block text-[11px] text-slate-400 mt-0.5">{{ __('Issued via ECPay in the background after submission.') }}</span>
</span>
</label>
<div x-show="issueInvoice" x-cloak class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3 pl-1">
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Invoice Type') }}</label>
<x-searchable-select name="manual_invoice_type" id="manual-invoice-type"
:has-search="false" x-model="invoiceType" @change="invoiceType = $event.target.value">
<option value="b2c" data-title="{{ __('B2C (member carrier)') }}">{{ __('B2C (member carrier)') }}</option>
<option value="taxid" data-title="{{ __('Company Tax ID') }}">{{ __('Company Tax ID') }}</option>
<option value="mobile" data-title="{{ __('Mobile barcode carrier') }}">{{ __('Mobile barcode carrier') }}</option>
<option value="citizen" data-title="{{ __('Citizen certificate carrier') }}">{{ __('Citizen certificate carrier') }}</option>
<option value="donation" data-title="{{ __('Donation') }}">{{ __('Donation') }}</option>
</x-searchable-select>
</div>
<div class="space-y-2" x-show="invoiceType !== 'b2c'">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1" x-text="invoiceValueLabel()"></label>
<input type="text" x-model="invoiceValue" class="luxury-input w-full text-sm py-2.5 px-4">
</div>
</div>
</div>
{{-- 備註 --}}
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest pl-1">{{ __('Remark') }}</label>
<textarea x-model="remark" rows="2" maxlength="1000" placeholder="{{ __('Enter remark (optional)') }}"
class="luxury-input w-full text-sm py-2.5 px-4 resize-none"></textarea>
</div>
</div>
{{-- Footer --}}
<div class="flex items-center justify-end gap-3 px-6 sm:px-8 py-4 border-t border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/40">
<button type="button" @click="show = false"
class="px-5 py-2.5 rounded-xl text-sm font-bold text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all">
{{ __('Cancel') }}
</button>
<button type="button" @click="submit()" :disabled="submitting"
class="px-6 py-2.5 rounded-xl text-sm font-black text-white bg-cyan-500 hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 flex items-center gap-2">
<svg x-show="submitting" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span x-text="submitting ? '{{ __('Submitting...') }}' : '{{ __('Create Order') }}'"></span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -34,6 +34,7 @@
'pending' => 'pending',
'paid' => 'active',
'completed' => 'active',
'awaiting_pickup' => 'pending',
'failed' => 'error',
'abandoned' => 'disabled',
'cancelled' => 'disabled',
@ -43,8 +44,9 @@
'pending' => __('Pending'),
'paid' => __('Paid'),
'completed' => __('Completed'),
'awaiting_pickup' => __('Awaiting Pickup'),
'failed' => __('Failed'),
'abandoned' => __('Abandoned'),
'abandoned' => __('Unpaid'),
'cancelled' => __('Cancelled'),
'refunded' => __('Refunded'),
];
@ -63,6 +65,10 @@
<div class="space-y-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Dispense Status') }}</p>
<div>
{{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 顯示 '--' --}}
@if(! $order->hasDeliveryOutcome())
<span class="text-sm font-extrabold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@else
@php
$deliveryStatusMap = [
0 => ['label' => __('Dispense Failed'), 'color' => 'rose'],
@ -72,6 +78,7 @@
$ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate'];
@endphp
<x-status-badge :color="$ds['color']" :label="$ds['label']" size="sm" />
@endif
</div>
</div>
</div>
@ -97,7 +104,8 @@
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ __('Machine Flow ID') }}</p>
<p class="text-xs font-black text-slate-400 dark:text-slate-500 font-mono uppercase tracking-widest">{{ $order->flow_id }}</p>
{{-- 顯示去前綴序號的乾淨流水號DB 仍存完整 serial+flow_id 唯一值供後台判斷) --}}
<p class="text-xs font-black text-slate-400 dark:text-slate-500 font-mono uppercase tracking-widest">{{ $order->display_flow_id }}</p>
</div>
</div>
</section>
@ -227,6 +235,10 @@
<p class="text-base font-extrabold text-slate-800 dark:text-white truncate tracking-tight">{{ $item->product_name }}</p>
<div class="flex items-center gap-3 mt-1">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">${{ number_format($item->price, 0) }} × {{ $item->quantity }}</span>
@if(!empty($item->slot_no))
{{-- 消費者選擇的貨道/格子(不管出貨成功失敗都會記錄) --}}
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">{{ __('Slot') }}: {{ $item->slot_no }}</span>
@endif
</div>
</div>
<div class="text-right">

View File

@ -29,6 +29,34 @@
</x-searchable-select>
</div>
{{-- 付款狀態 --}}
<div class="w-full lg:w-44">
{{-- 不傳 placeholder避免產生 value=" " 的空值選項;「所有付款狀態」改為明確的 value=all 選項,預設為「已完成」 --}}
<x-searchable-select name="status" :selected="$filters['status']"
:has-search="false" @change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="all" {{ $filters['status']==='all' ? 'selected' : '' }} data-title="{{ __('All Payment Statuses') }}">{{ __('All Payment Statuses') }}</option>
<option value="pending" {{ $filters['status']==='pending' ? 'selected' : '' }} data-title="{{ __('Pending') }}">{{ __('Pending') }}</option>
<option value="paid" {{ $filters['status']==='paid' ? 'selected' : '' }} data-title="{{ __('Paid') }}">{{ __('Paid') }}</option>
<option value="completed" {{ $filters['status']==='completed' ? 'selected' : '' }} data-title="{{ __('Completed') }}">{{ __('Completed') }}</option>
<option value="awaiting_pickup" {{ $filters['status']==='awaiting_pickup' ? 'selected' : '' }} data-title="{{ __('Awaiting Pickup') }}">{{ __('Awaiting Pickup') }}</option>
<option value="failed" {{ $filters['status']==='failed' ? 'selected' : '' }} data-title="{{ __('Failed') }}">{{ __('Failed') }}</option>
<option value="abandoned" {{ $filters['status']==='abandoned' ? 'selected' : '' }} data-title="{{ __('Unpaid') }}">{{ __('Unpaid') }}</option>
<option value="cancelled" {{ $filters['status']==='cancelled' ? 'selected' : '' }} data-title="{{ __('Cancelled') }}">{{ __('Cancelled') }}</option>
<option value="refunded" {{ $filters['status']==='refunded' ? 'selected' : '' }} data-title="{{ __('Refunded') }}">{{ __('Refunded') }}</option>
</x-searchable-select>
</div>
{{-- 出貨狀態 --}}
<div class="w-full lg:w-44">
<x-searchable-select name="delivery_status" :placeholder="__('All Dispense Statuses')"
:selected="$filters['delivery_status']" :has-search="false"
@change="$el.closest('form').dispatchEvent(new Event('submit'))">
<option value="1" {{ (string)$filters['delivery_status']==='1' ? 'selected' : '' }} data-title="{{ __('Dispense Success') }}">{{ __('Dispense Success') }}</option>
<option value="2" {{ (string)$filters['delivery_status']==='2' ? 'selected' : '' }} data-title="{{ __('Partial Dispense Success') }}">{{ __('Partial Dispense Success') }}</option>
<option value="0" {{ (string)$filters['delivery_status']==='0' ? 'selected' : '' }} data-title="{{ __('Dispense Failed') }}">{{ __('Dispense Failed') }}</option>
</x-searchable-select>
</div>
{{-- 開始時間 --}}
<div class="relative group w-full lg:w-56 lg:flex-none"
x-data="{ fp: null }"
@ -76,6 +104,18 @@
</div>
<div class="flex items-center gap-2 ml-auto lg:ml-0 shrink-0">
@if(auth()->user()->isSystemAdmin())
{{-- 手動補單:機台斷線/漏報時人工補登銷售紀錄(僅系統管理員) --}}
<button type="button"
onclick="window.dispatchEvent(new CustomEvent('open-manual-order'))"
class="px-3.5 py-2.5 rounded-xl bg-slate-800 dark:bg-slate-700 text-white hover:bg-slate-900 dark:hover:bg-slate-600 shadow-lg transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
title="{{ __('Manual Order Entry') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
<span class="hidden sm:inline">{{ __('Manual Entry') }}</span>
</button>
@endif
<button type="submit"
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95"
title="{{ __('Search') }}">
@ -175,6 +215,10 @@
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Dispense Status') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Pickup Code') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Invoice Number') }}
@ -198,9 +242,14 @@
class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors cursor-pointer">
{{ $order->order_no }}
</span>
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">
{{ $order->created_at->format('Y-m-d H:i:s') }}
</span>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
{{ $order->created_at->format('Y-m-d H:i:s') }}
</span>
@if($order->is_manual)
<span class="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-black text-amber-600 dark:text-amber-400 uppercase tracking-widest">{{ __('Manual') }}</span>
@endif
</div>
</div>
</td>
<td class="px-6 py-6">
@ -243,6 +292,18 @@
{{ __('Discounted') }} ${{ number_format($order->discount_amount, 0) }}
</span>
@endif
@if($order->cash_received_summary)
<span class="text-[10px] text-emerald-600 dark:text-emerald-400 font-bold mt-1 tracking-tight leading-relaxed">
{{ $order->cash_received_summary }}
</span>
@endif
@if((int) $order->payment_type === 6 && !empty($order->member_barcode))
{{-- 取貨碼交易:於支付金額欄換行加註取貨碼序號 --}}
<span class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 mt-1.5 flex items-center gap-1">
{{ __('Pickup Code') }}:
<span class="font-mono tracking-tighter normal-case text-slate-700 dark:text-slate-200">{{ $order->member_barcode }}</span>
</span>
@endif
@if($order->payment_type == 41 && $order->staffCardLog && $order->staffCardLog->staffCard)
<span class="text-[10px] text-cyan-600 dark:text-cyan-400 font-extrabold mt-1.5 flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
@ -267,8 +328,10 @@
'pending' => ['label' => __('Pending'), 'color' => 'amber'],
'paid' => ['label' => __('Paid'), 'color' => 'emerald'],
'completed' => ['label' => __('Completed'), 'color' => 'emerald'],
'awaiting_pickup' => ['label' => __('Awaiting Pickup'), 'color' => 'amber'],
'awaiting_pickup' => ['label' => __('Awaiting Pickup'), 'color' => 'amber'],
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
'abandoned' => ['label' => __('Abandoned'), 'color' => 'slate'],
'abandoned' => ['label' => __('Unpaid'), 'color' => 'slate'],
'cancelled' => ['label' => __('Cancelled'), 'color' => 'slate'],
'refunded' => ['label' => __('Refunded'), 'color' => 'rose'],
];
@ -277,6 +340,10 @@
<x-status-badge :color="$s['color']" :label="$s['label']" size="xs" />
</td>
<td class="px-6 py-6 whitespace-nowrap">
{{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 顯示 '--' --}}
@if(! $order->hasDeliveryOutcome())
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@else
@php
$deliveryStatusMap = [
0 => ['label' => __('Dispense Failed'), 'color' => 'rose'],
@ -286,6 +353,37 @@
$ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate'];
@endphp
<x-status-badge :color="$ds['color']" :label="$ds['label']" size="xs" />
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
$qualifiesForPickup = in_array($order->status, ['completed','paid'], true) && in_array((int) $order->delivery_status, [0, 2], true);
$orderPickupCodes = $order->relationLoaded('compensationCodes') ? $order->compensationCodes : collect();
@endphp
@if($orderPickupCodes->count())
<div class="flex flex-col gap-1">
@foreach($orderPickupCodes as $cc)
<span class="inline-flex items-center gap-1.5 text-[11px] font-black">
<span class="font-mono tracking-tighter px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-slate-700 dark:text-slate-200">{{ $cc->code }}</span>
@if($cc->status === 'active' && $cc->usage_count < $cc->usage_limit)
<span class="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] text-amber-600 dark:text-amber-400 uppercase tracking-widest">{{ __('Pending Use') }}</span>
@else
<span class="px-1.5 py-0.5 rounded bg-slate-500/10 border border-slate-500/20 text-[9px] text-slate-500 uppercase tracking-widest">{{ __('Used') }}</span>
@endif
</span>
@endforeach
</div>
@elseif($qualifiesForPickup)
<button type="button" @click="generatePickupCode({{ $order->id }})"
title="{{ __('Generate Pickup Code') }}"
class="w-8 h-8 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20 flex items-center justify-center transition-all active:scale-95 border border-emerald-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
@else
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
@ -331,8 +429,8 @@
</tr>
@empty
<tr>
<td colspan="9" class="py-20 text-center">
<x-empty-state mode="table" colspan="9" :message="__('No transaction orders found')" />
<td colspan="10" class="py-20 text-center">
<x-empty-state mode="table" colspan="10" :message="__('No transaction orders found')" />
</td>
</tr>
@endforelse
@ -351,7 +449,7 @@
'paid' => ['label' => __('Paid'), 'color' => 'emerald'],
'completed' => ['label' => __('Completed'), 'color' => 'emerald'],
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
'abandoned' => ['label' => __('Abandoned'), 'color' => 'slate'],
'abandoned' => ['label' => __('Unpaid'), 'color' => 'slate'],
'cancelled' => ['label' => __('Cancelled'), 'color' => 'slate'],
'refunded' => ['label' => __('Refunded'), 'color' => 'rose'],
];
@ -374,6 +472,9 @@
<h3 @click="openDetail({{ $order->id }})"
class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-pointer">
{{ $order->order_no }}
@if($order->is_manual)
<span class="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] font-black text-amber-600 dark:text-amber-400 uppercase tracking-widest align-middle">{{ __('Manual') }}</span>
@endif
</h3>
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
@ -411,6 +512,11 @@
<span class="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800/50 text-slate-500">{{
$paymentTypes[$order->payment_type] ?? '??' }}</span>
</p>
@if($order->cash_received_summary)
<div class="text-[10px] text-emerald-600 dark:text-emerald-400 font-bold mt-1 leading-relaxed">
{{ $order->cash_received_summary }}
</div>
@endif
@if($order->payment_type == 41 && $order->staffCardLog && $order->staffCardLog->staffCard)
<div class="text-[10px] text-cyan-600 dark:text-cyan-400 font-extrabold mt-1 flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
@ -451,6 +557,10 @@
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{
__('Dispense Status') }}</p>
{{-- 非付款成功(未完成/支付失敗/未支付)的單從未出貨,出貨狀態無意義 顯示 '--' --}}
@if(! $order->hasDeliveryOutcome())
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@else
@php
$deliveryStatusMap = [
0 => ['label' => __('Dispense Failed'), 'color' => 'rose'],
@ -460,6 +570,38 @@
$ds = $deliveryStatusMap[$order->delivery_status] ?? ['label' => __('Unknown'), 'color' => 'slate'];
@endphp
<x-status-badge :color="$ds['color']" :label="$ds['label']" size="xs" />
@endif
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Pickup Code') }}</p>
@php
$qualifiesForPickup = in_array($order->status, ['completed','paid'], true) && in_array((int) $order->delivery_status, [0, 2], true);
$orderPickupCodes = $order->relationLoaded('compensationCodes') ? $order->compensationCodes : collect();
@endphp
@if($orderPickupCodes->count())
<div class="flex flex-col gap-1">
@foreach($orderPickupCodes as $cc)
<span class="inline-flex items-center gap-1.5 text-[11px] font-black">
<span class="font-mono tracking-tighter px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-slate-700 dark:text-slate-200">{{ $cc->code }}</span>
@if($cc->status === 'active' && $cc->usage_count < $cc->usage_limit)
<span class="px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-[9px] text-amber-600 dark:text-amber-400 uppercase tracking-widest">{{ __('Pending Use') }}</span>
@else
<span class="px-1.5 py-0.5 rounded bg-slate-500/10 border border-slate-500/20 text-[9px] text-slate-500 uppercase tracking-widest">{{ __('Used') }}</span>
@endif
</span>
@endforeach
</div>
@elseif($qualifiesForPickup)
<button type="button" @click="generatePickupCode({{ $order->id }})"
class="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20 text-xs font-black uppercase tracking-widest transition-all active:scale-95 border border-emerald-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ __('Generate Pickup Code') }}
</button>
@else
<span class="text-xs font-bold text-slate-300 dark:text-slate-700 tracking-widest">--</span>
@endif
</div>
</div>

View File

@ -12,6 +12,9 @@
passCodeName: '',
expiresDays: 7,
customCode: '',
quantity: 1,
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
status: '{{ request('status', '') }}',
showQrModal: false,
activeQrCode: '',
@ -145,7 +148,7 @@
window.showToast('{{ __('Please enter a description') }}', 'error');
return;
}
if (!this.customCode.trim()) {
if (parseInt(this.quantity) <= 1 && !this.customCode.trim()) {
window.showToast('{{ __('Please enter or generate a pass code') }}', 'error');
return;
}
@ -194,12 +197,15 @@
this.selectedMachine = '';
this.passCodeName = '';
this.expiresDays = 7;
this.quantity = 1;
this.generateRandomCode();
this.syncSelect('modal-pass-machine', '');
}
});
}
} " @ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
@include('admin.sales.partials.batch-result-banner')
{{-- Page Header --}}
<x-page-header :title="__('Pass Codes')"
:subtitle="__('Manage multi-use authorization codes for testing and maintenance')">
@ -302,6 +308,42 @@
</div>
</div>
{{-- Quantity (Batch Generate) --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
{{ __('Generate Quantity') }} (1-{{ $maxBatchQuantity ?? 20 }})
</label>
<div class="flex items-center gap-3">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="quantity = Math.max(1, parseInt(quantity || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="number" name="quantity" x-model="quantity" min="1" :max="maxBatchQty"
@input="if (parseInt(quantity) > maxBatchQty) quantity = maxBatchQty"
class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity = Math.min(maxBatchQty, parseInt(quantity || 1) + 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="flex items-center gap-1.5">
<template x-for="val in batchQuickOptions" :key="val">
<button type="button" @click="quantity = val"
class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</div>
</div>
<p class="text-[10px] font-bold text-amber-500 pl-1" x-show="parseInt(quantity) > 1" x-cloak>
{{ __('Batch mode: codes are auto-generated and the custom code is ignored.') }}
</p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
@ -317,8 +359,10 @@
</button>
</div>
<input type="text" name="custom_code" x-model="customCode"
:disabled="parseInt(quantity) > 1" :required="parseInt(quantity) <= 1"
:class="parseInt(quantity) > 1 ? 'opacity-40 cursor-not-allowed' : ''"
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5"
maxlength="12" required>
maxlength="12">
</div>
<div class="space-y-4">
@ -377,6 +421,13 @@
</div>
</div>
{{-- 只能使用一次:勾選 usage_limit=1;不勾 無限次(維持原行為) --}}
<label class="flex items-center gap-3 px-1 cursor-pointer select-none">
<input type="checkbox" name="single_use" value="1"
class="w-5 h-5 rounded border-slate-300 dark:border-slate-600 text-cyan-500 focus:ring-cyan-500 cursor-pointer">
<span class="text-xs font-black text-slate-500 uppercase tracking-widest">{{ __('Single use only') }}</span>
</label>
<div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">
{{ __('Cancel') }}

View File

@ -109,7 +109,7 @@
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$code->machine->name }}</div>
$code->machine?->name ?? '-' }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($code->expires_at)
@ -241,7 +241,7 @@
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">{{
$code->machine->name }}</p>
$code->machine?->name ?? '-' }}</p>
</div>
<div>
<p

View File

@ -0,0 +1,459 @@
@extends('layouts.admin')
@section('content')
@php
$statusLabels = [
'awaiting_pickup' => __('Awaiting Pickup'),
'completed' => __('Picked Up'),
'failed' => __('Voided'),
'pending' => __('Pending'),
'abandoned' => __('Abandoned'),
];
$statusColors = [
'awaiting_pickup' => 'bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400',
'completed' => 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400',
'failed' => 'bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400',
'pending' => 'bg-slate-500/10 border border-slate-500/20 text-slate-500',
'abandoned' => 'bg-slate-500/10 border border-slate-500/20 text-slate-500',
];
@endphp
<div class="space-y-4 pb-20" x-data="pharmacyPickupManager()">
{{-- Page Header --}}
<x-page-header
:title="__('menu.sales.pharmacy-pickup')"
:subtitle="__('Issue a pharmacy pickup order, print the QR ticket, patient scans at the machine to dispense.')">
@if($machines->isNotEmpty())
<button @click="openCreateModal()"
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" />
</svg>
<span class="text-sm sm:text-base">{{ __('Create Pharmacy Pickup Order') }}</span>
</button>
@endif
</x-page-header>
{{-- Flash / Errors --}}
@if(session('success'))
<div class="p-5 bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 rounded-2xl flex items-center gap-3 font-bold text-sm animate-luxury-in">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="p-5 bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400 rounded-2xl flex items-center gap-3 font-bold text-sm animate-luxury-in">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01M5.07 19h13.86a2 2 0 001.74-3L13.74 4a2 2 0 00-3.48 0L3.33 16a2 2 0 001.74 3z"/></svg>
{{ session('error') }}
</div>
@endif
@if($errors->any())
<div class="p-5 bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400 rounded-2xl text-sm animate-luxury-in">
<ul class="list-disc ps-5 space-y-1 font-bold">
@foreach($errors->all() as $err)<li>{{ $err }}</li>@endforeach
</ul>
</div>
@endif
@if($machines->isEmpty())
<div class="p-5 bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 rounded-2xl flex items-start gap-4 font-bold text-sm">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01M5.07 19h13.86a2 2 0 001.74-3L13.74 4a2 2 0 00-3.48 0L3.33 16a2 2 0 001.74 3z"/></svg>
{{ __('No machines enabled for pharmacy pickup. Enable it under Machine System Settings (Pickup Sheet mode → Pharmacy Pickup switch).') }}
</div>
@endif
{{-- 領藥單列表 --}}
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
{{-- Desktop Table --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Order No.') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Machine') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Items') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Pickup Code') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Created') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
@forelse($orders as $order)
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200 {{ session('created_order_id') == $order->id ? 'bg-emerald-50/60 dark:bg-emerald-500/5' : '' }}">
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-base font-black font-mono text-slate-800 dark:text-slate-100 tracking-tight">{{ $order->order_no }}</div>
@if($order->pricing_slip_no)
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mt-0.5">{{ __('Pricing Slip No.') }}: {{ $order->pricing_slip_no }}</div>
@endif
</td>
<td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">{{ $order->machine?->name ?? '-' }}</div>
<div class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-[0.2em]">{{ $order->machine?->serial_no }}</div>
</td>
<td class="px-6 py-6">
<div class="space-y-0.5">
@foreach($order->items as $it)
<div class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $it->product_name }} <span class="text-cyan-600 dark:text-cyan-400 font-black">× {{ $it->quantity }}</span></div>
@endforeach
</div>
</td>
<td class="px-6 py-6 text-center whitespace-nowrap">
@if($order->pickupCode?->code)
<span class="text-base font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest bg-cyan-50 dark:bg-cyan-500/10 px-3 py-1.5 rounded-xl border border-cyan-100 dark:border-cyan-500/20">{{ $order->pickupCode->code }}</span>
@else
<span class="text-slate-300 dark:text-slate-600">-</span>
@endif
</td>
<td class="px-6 py-6 text-center whitespace-nowrap">
<span class="inline-block px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest {{ $statusColors[$order->status] ?? 'bg-slate-500/10 border border-slate-500/20 text-slate-500' }}">{{ $statusLabels[$order->status] ?? $order->status }}</span>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono">{{ $order->created_at?->format('Y-m-d H:i') }}</div>
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">{{ $order->creator?->name }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($order->status !== 'failed' && $order->pickupCode)
<a href="{{ route('admin.sales.pharmacy-pickup.print', $order) }}" target="_blank"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all"
title="{{ __('Print') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247m10.5 0a48.536 48.536 0 00-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5zm-3 0h.008v.008H15V10.5z"/></svg>
</a>
@endif
@if($order->status === 'awaiting_pickup')
<form id="pp-cancel-desktop-{{ $order->id }}" method="POST" action="{{ route('admin.sales.pharmacy-pickup.cancel', $order) }}">
@csrf
<button type="button" @click="confirmCancel('pp-cancel-desktop-{{ $order->id }}')"
class="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 border border-transparent hover:border-rose-500/20 transition-all"
title="{{ __('Cancel') }}">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-3.38a2.25 2.25 0 00-2.25-2.25h-3.51a2.25 2.25 0 00-2.25 2.25v3.38"/></svg>
</button>
</form>
@endif
@if($order->status !== 'awaiting_pickup' && !($order->status !== 'failed' && $order->pickupCode))
<span class="text-slate-300 dark:text-slate-600 pr-2">-</span>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="7" :message="__('No pharmacy pickup orders yet.')" />
@endforelse
</tbody>
</table>
</div>
{{-- Mobile Card Grid --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse($orders as $order)
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group {{ session('created_order_id') == $order->id ? 'ring-2 ring-emerald-500/30' : '' }}">
{{-- Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 shadow-sm shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<div class="min-w-0">
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate tracking-tight font-mono">{{ $order->order_no }}</h3>
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">{{ $order->machine?->name ?? '-' }}</p>
</div>
</div>
<span class="inline-block px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest shrink-0 {{ $statusColors[$order->status] ?? 'bg-slate-500/10 border border-slate-500/20 text-slate-500' }}">{{ $statusLabels[$order->status] ?? $order->status }}</span>
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Pickup Code') }}</p>
<p class="text-sm font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-widest">{{ $order->pickupCode?->code ?? '-' }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Pricing Slip No.') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $order->pricing_slip_no ?: '-' }}</p>
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Items') }}</p>
<div class="space-y-0.5">
@foreach($order->items as $it)
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $it->product_name }} <span class="text-cyan-600 dark:text-cyan-400 font-black">× {{ $it->quantity }}</span></p>
@endforeach
</div>
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Created') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono">{{ $order->created_at?->format('Y-m-d H:i') }} <span class="text-slate-400 font-sans">· {{ $order->creator?->name }}</span></p>
</div>
</div>
{{-- Actions --}}
@if(($order->status !== 'failed' && $order->pickupCode) || $order->status === 'awaiting_pickup')
<div class="flex items-center gap-3">
@if($order->status !== 'failed' && $order->pickupCode)
<a href="{{ route('admin.sales.pharmacy-pickup.print', $order) }}" target="_blank"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0110.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0l.229 2.523a1.125 1.125 0 01-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0021 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 00-1.913-.247M6.34 18H5.25A2.25 2.25 0 013 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 011.913-.247"/></svg>
{{ __('Print') }}
</a>
@endif
@if($order->status === 'awaiting_pickup')
<form id="pp-cancel-mobile-{{ $order->id }}" method="POST" action="{{ route('admin.sales.pharmacy-pickup.cancel', $order) }}" class="flex-1">
@csrf
<button type="button" @click="confirmCancel('pp-cancel-mobile-{{ $order->id }}')"
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 text-rose-500 text-xs font-black uppercase tracking-widest border border-rose-500/20 hover:bg-rose-500 hover:text-white transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79"/></svg>
{{ __('Cancel') }}
</button>
</form>
@endif
</div>
@endif
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No pharmacy pickup orders yet.')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $orders->appends(request()->query())->links('vendor.pagination.luxury') }}
</div>
</div>
{{-- Create Pharmacy Pickup Order Modal --}}
<div x-show="showCreateModal" class="fixed inset-0 z-50 overflow-y-auto"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak style="display:none;">
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-3xl sm:w-full overflow-visible">
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Create Pharmacy Pickup Order') }}</h3>
<button @click="showCreateModal = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form action="{{ route('admin.sales.pharmacy-pickup.store') }}" method="POST" class="space-y-6"
@submit.prevent="validateAndSubmit" x-ref="createForm">
@csrf
<input type="hidden" name="machine_id" :value="selectedMachine">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{{-- Machine --}}
<div class="space-y-2 relative focus-within:z-[60]">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Select Machine') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="machine_select" id="pp-machine"
placeholder="{{ __('-- Select Machine --') }}" x-model="selectedMachine"
@change="selectedMachine = $event.target.value; fetchProducts()">
@foreach($machines as $m)
<option value="{{ $m->id }}" data-title="{{ $m->name }} ({{ $m->serial_no }})">{{ $m->name }} ({{ $m->serial_no }})</option>
@endforeach
</x-searchable-select>
</div>
{{-- Pricing Slip No. --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Pricing Slip No.') }} <span class="text-slate-300 normal-case font-bold">({{ __('optional') }})</span></label>
<input type="text" name="pricing_slip_no" x-model="pricingSlipNo" maxlength="64"
@keydown.enter.prevent
class="luxury-input w-full py-3 px-4 text-sm" placeholder="{{ __('e.g. the hospital pricing slip number') }}">
</div>
</div>
{{-- Medicine selection --}}
<div class="space-y-3">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Medicine') }} <span class="text-rose-500">*</span></label>
{{-- Empty: no machine selected --}}
<div x-show="!selectedMachine" class="rounded-[2rem] border border-dashed border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/30 p-10 text-center">
<p class="text-sm font-bold text-slate-400">{{ __('Please select a machine') }}</p>
</div>
{{-- Loading --}}
<div x-show="selectedMachine && loadingProducts" class="rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/30 p-10 flex items-center justify-center">
<x-luxury-spinner show="loadingProducts" />
</div>
{{-- No products --}}
<div x-show="selectedMachine && !loadingProducts && products.length === 0" class="rounded-[2rem] border border-amber-500/20 bg-amber-500/5 p-8 text-center">
<p class="text-sm font-bold text-amber-600 dark:text-amber-400">{{ __('This machine currently has no medicine in stock.') }}</p>
</div>
{{-- Product table --}}
<div x-show="selectedMachine && !loadingProducts && products.length > 0"
class="overflow-hidden rounded-[2rem] border border-slate-100 dark:border-slate-800">
<table class="w-full text-sm">
<thead class="bg-slate-50/70 dark:bg-slate-900/40">
<tr>
<th class="py-3 px-4 text-left w-10"></th>
<th class="py-3 px-4 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Medicine') }}</th>
<th class="py-3 px-4 text-left text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Spec') }}</th>
<th class="py-3 px-4 text-center text-[10px] font-black text-slate-400 uppercase tracking-widest">{{ __('Available') }}</th>
<th class="py-3 px-4 text-center text-[10px] font-black text-slate-400 uppercase tracking-widest w-40">{{ __('Quantity') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
<template x-for="p in products" :key="p.product_id">
<tr class="transition-colors duration-200"
:class="p.checked ? 'bg-cyan-50/40 dark:bg-cyan-500/5' : 'hover:bg-slate-50/50 dark:hover:bg-white/[0.02]'">
<td class="py-3 px-4 text-center">
<input type="checkbox" x-model="p.checked"
class="w-4 h-4 text-cyan-500 rounded border-slate-300 dark:border-slate-600 focus:ring-cyan-500/30 cursor-pointer">
</td>
<td class="py-3 px-4 font-bold text-slate-700 dark:text-slate-200" x-text="p.name"></td>
<td class="py-3 px-4 text-slate-500" x-text="p.spec || '-'"></td>
<td class="py-3 px-4 text-center">
<span class="font-black text-slate-700 dark:text-slate-200" x-text="p.available"></span>
<template x-if="p.reserved > 0">
<span class="block text-[10px] font-bold text-slate-400" x-text="'({{ __('physical') }} ' + p.physical + ' {{ __('reserved') }} ' + p.reserved + ')'"></span>
</template>
</td>
<td class="py-3 px-4">
{{-- Luxury +/- counter --}}
<div class="flex items-center h-10 mx-auto rounded-xl border border-slate-200/70 dark:border-slate-700/50 bg-white dark:bg-slate-900/50 overflow-hidden w-[140px] transition-all"
:class="{ 'opacity-40 pointer-events-none': !p.checked }">
<button type="button" @click="p.qty > 1 ? p.qty-- : 1"
class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="text" x-model.number="p.qty" readonly
class="flex-1 w-full min-w-0 bg-transparent border-none text-center text-base font-black text-slate-800 dark:text-white focus:ring-0 p-0">
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="p.qty < p.available ? p.qty++ : p.available"
class="shrink-0 w-10 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
{{-- Submitted only when checked (empty name = skipped) --}}
<input type="hidden" :name="p.checked ? 'products[]' : ''" :value="p.product_id">
<input type="hidden" :name="p.checked ? `qty[${p.product_id}]` : ''" :value="p.qty">
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p class="text-[10px] font-bold text-slate-400 pl-1">{{ __('(deducted by issued pickup orders)') }}</p>
</div>
<div class="flex justify-end gap-x-4 pt-4">
<button type="button" @click="showCreateModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-12 flex items-center gap-2">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
{{ __('Create & Generate QR') }}
</button>
</div>
</form>
</div>
</div>
</div>
{{-- Confirm Cancel Modal --}}
<x-confirm-modal alpineVar="showCancelModal" :title="__('Cancel Pickup Code')"
:message="__('Cancel this pharmacy pickup order?')"
:confirmText="__('Yes, Cancel')" confirmAction="submitCancel()" confirmColor="rose" iconType="danger" />
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('pharmacyPickupManager', () => ({
showCreateModal: false,
selectedMachine: '',
pricingSlipNo: '',
products: [],
loadingProducts: false,
showCancelModal: false,
cancelTargetForm: null,
openCreateModal() {
this.selectedMachine = '';
this.pricingSlipNo = '';
this.products = [];
this.showCreateModal = true;
this.syncSelect('pp-machine', ' ');
},
async fetchProducts() {
if (!this.selectedMachine) {
this.products = [];
return;
}
this.loadingProducts = true;
try {
const res = await fetch(`/admin/sales/pharmacy-pickup/${this.selectedMachine}/products-ajax`, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
});
const data = await res.json();
this.products = (data.products || []).map(p => ({
...p,
checked: false,
qty: 1,
}));
} catch (e) {
console.error('Error fetching products:', e);
window.showToast?.('{{ __("Loading failed") }}', 'error');
this.products = [];
} finally {
this.loadingProducts = false;
}
},
validateAndSubmit() {
if (!this.selectedMachine || this.selectedMachine.toString().trim() === '') {
window.showToast?.('{{ __("Please select a machine") }}', 'error');
return;
}
if (!this.products.some(p => p.checked)) {
window.showToast?.('{{ __("Please select at least one medicine with quantity.") }}', 'error');
return;
}
this.$refs.createForm.submit();
},
confirmCancel(formId) {
this.cancelTargetForm = formId;
this.showCancelModal = true;
},
submitCancel() {
if (this.cancelTargetForm) {
const form = document.getElementById(this.cancelTargetForm);
if (form) form.submit();
}
this.showCancelModal = false;
},
syncSelect(id, value) {
this.$nextTick(() => {
const el = document.getElementById(id);
if (el) {
const valStr = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' ';
el.value = valStr;
window.HSSelect?.getInstance(el)?.setValue(valStr);
}
});
},
init() {
this.$watch('selectedMachine', (val) => {
this.syncSelect('pp-machine', val);
});
}
}));
});
</script>
@endsection

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('menu.sales.pharmacy-pickup') }} {{ $order->order_no }}</title>
<style>
* { box-sizing: border-box; }
body { font-family: "Microsoft JhengHei", "PingFang TC", "Noto Sans TC", sans-serif; margin: 0; background: #f3f4f6; color: #111827; }
.actions { text-align: center; margin: 16px auto; }
.btn { display: inline-block; padding: 9px 22px; background: #06b6d4; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 700; cursor: pointer; text-decoration: none; }
.ticket { width: 360px; margin: 12px auto 28px; background: #fff; padding: 22px 24px; border: 1px solid #e5e7eb; border-radius: 8px; }
.head { text-align: center; border-bottom: 2px dashed #9ca3af; padding-bottom: 12px; margin-bottom: 12px; }
.head h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: 4px; }
.head .sub { font-size: 12px; color: #6b7280; }
.row { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; margin: 4px 0; }
.row .k { color: #6b7280; white-space: nowrap; }
.row .v { font-weight: 600; text-align: right; word-break: break-all; }
table { width: 100%; border-collapse: collapse; margin: 12px 0 6px; font-size: 13px; }
th, td { text-align: left; padding: 6px 4px; border-bottom: 1px solid #f0f0f0; }
th { color: #6b7280; font-weight: 700; border-bottom: 1px solid #d1d5db; }
th.qty, td.qty { text-align: center; width: 56px; }
.code-box { text-align: center; margin: 16px 0 4px; }
.code-box .label { font-size: 12px; color: #6b7280; }
.code-box .code { font-size: 32px; font-weight: 800; letter-spacing: 8px; font-family: "Courier New", monospace; }
.qr { text-align: center; margin: 8px 0; }
.qr svg { width: 200px; height: 200px; }
.note { font-size: 12px; color: #4b5563; text-align: center; margin-top: 12px; border-top: 2px dashed #9ca3af; padding-top: 12px; line-height: 1.6; }
@media print {
body { background: #fff; }
.no-print { display: none !important; }
.ticket { border: none; margin: 0 auto; }
}
</style>
</head>
<body>
<div class="actions no-print">
<button class="btn" onclick="window.print()">{{ __('Print') }}</button>
</div>
<div class="ticket">
<div class="head">
<h1>{{ __('menu.sales.pharmacy-pickup') }}</h1>
<div class="sub">{{ $order->machine?->name }}@if($order->machine?->location) · {{ $order->machine->location }}@endif</div>
</div>
<div class="row"><span class="k">{{ __('Order No.') }}</span><span class="v">{{ $order->order_no }}</span></div>
@if($order->pricing_slip_no)
<div class="row"><span class="k">{{ __('Pricing Slip No.') }}</span><span class="v">{{ $order->pricing_slip_no }}</span></div>
@endif
<div class="row"><span class="k">{{ __('Created') }}</span><span class="v">{{ $order->created_at?->format('Y-m-d H:i') }}</span></div>
@if($order->pickupCode?->expires_at)
<div class="row"><span class="k">{{ __('Valid Until') }}</span><span class="v">{{ $order->pickupCode->expires_at->format('Y-m-d H:i') }}</span></div>
@endif
<table>
<thead>
<tr><th>{{ __('Medicine') }}</th><th class="qty">{{ __('Quantity') }}</th></tr>
</thead>
<tbody>
@foreach($order->items as $it)
<tr><td>{{ $it->product_name }}</td><td class="qty">{{ $it->quantity }}</td></tr>
@endforeach
</tbody>
</table>
<div class="code-box">
<div class="label">{{ __('Pickup Code') }}</div>
<div class="code">{{ $order->pickupCode?->code ?? '--------' }}</div>
</div>
@if($qrSvg)
<div class="qr">{!! $qrSvg !!}</div>
@endif
<div class="note">{{ __('Scan the QR code or enter the pickup code at the machine to collect your medicine.') }}</div>
</div>
</body>
</html>

View File

@ -2,9 +2,9 @@
@php
$slotSelectConfig = [
"placeholder" => __("Select Slot"),
"placeholder" => __("Select Product"),
"hasSearch" => true,
"searchPlaceholder" => __("Search Slot..."),
"searchPlaceholder" => __("Search Product..."),
"isHidePlaceholder" => false,
"searchClasses" => "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg
focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200
@ -37,7 +37,7 @@ transition-all duration-300",
<div class="space-y-2 pb-20" x-data="pickupCodeManager(@js($slotSelectConfig))"
@ajax:navigate.window.prevent="fetchTabData(activeTab, $event.detail.url)">
@include('admin.sales.partials.batch-result-banner')
{{-- Page Header --}}
<x-page-header :title="__('Pickup Codes')"
@ -234,7 +234,7 @@ transition-all duration-300",
__('Target Machine') }} <span class="text-rose-500">*</span></label>
<x-searchable-select name="machine_id" id="modal-pickup-machine"
placeholder="{{ __('Please select a machine') }}" x-model="selectedMachine"
@change="selectedMachine = $event.target.value; fetchSlots()">
@change="selectedMachine = $event.target.value; fetchCompanyProducts()">
@foreach($machines as $machine)
<option value="{{ $machine->id }}"
data-title="{{ $machine->name }} ({{ $machine->serial_no }})">{{ $machine->name }}
@ -244,7 +244,7 @@ transition-all duration-300",
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Select Slot') }} <span class="text-rose-500">*</span></label>
__('Select Product') }} <span class="text-rose-500">*</span></label>
<div class="relative">
<div id="slot-select-wrapper" class="relative">
{{-- Rebuilt by Alpine --}}
@ -253,6 +253,10 @@ transition-all duration-300",
<x-luxury-spinner show="loadingSlots" />
</div>
</div>
<p x-show="productNotOnMachine" x-cloak class="text-[11px] font-bold text-amber-500 pl-1 flex items-center gap-1">
<svg class="size-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 0 0-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
{{ __('This product is not stocked on the selected machine; the code may not be redeemable until restocked.') }}
</p>
</div>
<div class="space-y-2">
@ -291,6 +295,41 @@ transition-all duration-300",
</div>
</div>
{{-- Quantity (Batch Generate) 獨立整列,避免與使用次數上限擠在同一列重疊。上限/快捷選項依角色分級 --}}
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Generate Quantity') }} (1-{{ $maxBatchQuantity ?? 20 }})</label>
<div class="flex items-center gap-3">
<div class="flex items-center h-12 rounded-2xl border border-slate-200/50 dark:border-slate-700/50 bg-slate-50/50 dark:bg-slate-900/50 overflow-hidden flex-1 group focus-within:ring-2 focus-within:ring-cyan-500/20 transition-all">
<button type="button" @click="quantity = Math.max(1, parseInt(quantity || 1) - 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 12H4"/></svg>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<input type="number" name="quantity" x-model="quantity" min="1" :max="maxBatchQty"
@input="if (parseInt(quantity) > maxBatchQty) quantity = maxBatchQty"
class="flex-1 min-w-0 bg-transparent border-none text-center text-lg font-black text-slate-800 dark:text-white focus:ring-0 p-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none">
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700/50"></div>
<button type="button" @click="quantity = Math.min(maxBatchQty, parseInt(quantity || 1) + 1)"
class="shrink-0 w-12 h-full flex items-center justify-center text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 active:scale-90 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<div class="flex items-center gap-1.5">
<template x-for="val in batchQuickOptions" :key="val">
<button type="button" @click="quantity = val"
class="px-2 h-10 rounded-xl border border-slate-200 dark:border-slate-700 flex items-center justify-center text-[11px] font-black transition-all shrink-0"
:class="quantity == val ? 'bg-cyan-500 text-white border-cyan-500 shadow-lg shadow-cyan-500/20' : 'bg-white dark:bg-slate-800 text-slate-500 hover:border-cyan-500/50'">
<span x-text="val"></span>
</button>
</template>
</div>
</div>
<p class="text-[10px] font-bold text-amber-500 pl-1" x-show="parseInt(quantity) > 1" x-cloak>
{{ __('Batch mode: codes are auto-generated and the custom code is ignored.') }}
</p>
</div>
{{-- Pickup Code Preview Section --}}
<div class="space-y-2">
<div class="flex items-center justify-between mb-1">
@ -307,8 +346,10 @@ transition-all duration-300",
</button>
</div>
<input type="text" name="custom_code" x-model="customCode"
:disabled="parseInt(quantity) > 1" :required="parseInt(quantity) <= 1"
:class="parseInt(quantity) > 1 ? 'opacity-40 cursor-not-allowed' : ''"
class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5"
maxlength="12" required>
maxlength="12">
</div>
{{-- Validity Period Section (Flexible) --}}
@ -494,16 +535,21 @@ transition-all duration-300",
tabLoading: null,
showCreateModal: false,
selectedMachine: '',
selectedSlot: '',
selectedProduct: '',
expiresHours: 24,
maxHours: 168,
expiryMode: 'hours',
customExpiry: '',
usageLimit: 1,
quantity: 1,
maxBatchQty: {{ (int) ($maxBatchQuantity ?? 20) }},
batchQuickOptions: @js($batchQuickOptions ?? [10, 20]),
showQrModal: false,
activeQrCode: '',
activeTicketUrl: '',
slots: [],
companyProducts: [],
machineProductIds: [],
productNotOnMachine: false,
tabLoading: null,
loadingSlots: false,
showCancelModal: false,
@ -599,18 +645,22 @@ transition-all duration-300",
return this.calculateExpiry();
},
async fetchSlots() {
// 取貨碼綁商品:下拉以「機台所屬公司的商品」為準(非僅機台貨道);
// machineProductIds 用來標示/提示「此機台目前未上架」的商品。
async fetchCompanyProducts() {
if (!this.selectedMachine) {
this.slots = [];
this.companyProducts = [];
this.machineProductIds = [];
return;
}
this.loadingSlots = true;
try {
const response = await fetch(`/admin/machines/${this.selectedMachine}/slots-ajax`);
const response = await fetch(`/admin/sales/pickup-codes/company-products/${this.selectedMachine}`);
const data = await response.json();
this.slots = data.slots || [];
this.companyProducts = data.products || [];
this.machineProductIds = (data.machine_product_ids || []).map(id => String(id));
} catch (error) {
console.error('Error fetching slots:', error);
console.error('Error fetching company products:', error);
} finally {
this.loadingSlots = false;
}
@ -621,8 +671,8 @@ transition-all duration-300",
window.showToast('{{ __("Please select a machine") }}', 'error');
return;
}
if (!this.selectedSlot) {
window.showToast('{{ __("Please select a slot") }}', 'error');
if (!this.selectedProduct) {
window.showToast('{{ __("Please select a product") }}', 'error');
return;
}
this.$refs.createForm.submit();
@ -674,6 +724,8 @@ transition-all duration-300",
this.showCancelModal = false;
},
// 取貨碼改綁商品:下拉改列「此機台有上架的商品」(由貨道清單去重),
// 提交 product_id貨道改由 App 刷碼當下依即時庫存挑選。
updateSlotSelect() {
this.$nextTick(() => {
const wrapper = document.getElementById('slot-select-wrapper');
@ -687,15 +739,15 @@ transition-all duration-300",
if (!this.selectedMachine) {
const dummy = document.createElement('div');
dummy.className = 'luxury-input opacity-50 cursor-not-allowed flex items-center justify-between';
dummy.innerHTML = `<span>{{ __("Select Slot") }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
dummy.innerHTML = `<span>{{ __("Select Product") }}</span><svg class="size-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/></svg>`;
wrapper.appendChild(dummy);
return;
}
const selectEl = document.createElement('select');
selectEl.name = 'slot_no';
selectEl.name = 'product_id';
// Remove required to allow our custom validation toast to trigger
selectEl.id = 'modal-pickup-slot-' + Date.now();
selectEl.id = 'modal-pickup-product-' + Date.now();
selectEl.className = 'hidden';
// 清理配置字串中的換行符
@ -706,22 +758,32 @@ transition-all duration-300",
const placeholderOpt = document.createElement('option');
placeholderOpt.value = "";
placeholderOpt.textContent = "{{ __('Select Slot') }}";
placeholderOpt.setAttribute('data-title', "{{ __('Select Slot') }}");
placeholderOpt.textContent = "{{ __('Select Product') }}";
placeholderOpt.setAttribute('data-title', "{{ __('Select Product') }}");
selectEl.appendChild(placeholderOpt);
this.slots.forEach(slot => {
// 列出機台所屬公司的商品;機台目前未上架者於名稱後標示提示
this.companyProducts.forEach(product => {
const opt = document.createElement('option');
opt.value = slot.slot_no;
const text = `${slot.slot_no} - ${slot.product ? slot.product.name : 'Empty'}`;
opt.value = product.id;
const onMachine = this.machineProductIds.includes(String(product.id));
const text = onMachine ? product.name : `${product.name}{{ __('Not stocked on this machine') }}`;
opt.textContent = text;
opt.setAttribute('data-title', text);
if (this.selectedSlot == slot.slot_no) opt.selected = true;
if (this.selectedProduct == product.id) opt.selected = true;
selectEl.appendChild(opt);
});
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', e => { this.selectedSlot = e.target.value; });
selectEl.addEventListener('change', e => {
this.selectedProduct = e.target.value;
// 選到機台未上架的商品 → 提示,但仍允許產碼
this.productNotOnMachine = !!this.selectedProduct
&& !this.machineProductIds.includes(String(this.selectedProduct));
if (this.productNotOnMachine) {
window.showToast('{{ __("This product is not stocked on the selected machine; the code may not be redeemable until restocked.") }}', 'warning');
}
});
if (window.HSStaticMethods?.autoInit) {
window.HSStaticMethods.autoInit(['select']);
@ -757,13 +819,14 @@ transition-all duration-300",
});
});
this.$watch('slots', () => this.updateSlotSelect());
this.$watch('companyProducts', () => this.updateSlotSelect());
this.$watch('selectedMachine', (val) => {
this.syncSelect('modal-pickup-machine', val);
this.selectedSlot = ''; // Reset slot when machine changes
this.fetchSlots();
this.selectedProduct = ''; // Reset product when machine changes
this.productNotOnMachine = false;
this.fetchCompanyProducts();
});
this.$watch('selectedSlot', (val) => {
this.$watch('selectedProduct', (val) => {
// Selection toast removed per user request
});
this.$watch('expiryMode', (mode) => {
@ -775,15 +838,18 @@ transition-all duration-300",
this.$watch('showCreateModal', (val) => {
if (val) {
this.selectedMachine = '';
this.selectedSlot = '';
this.selectedProduct = '';
this.productNotOnMachine = false;
this.usageLimit = 1;
this.quantity = 1;
this.expiryMode = 'hours';
this.expiresHours = 24;
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setMinutes(0);
this.customExpiry = this.formatDate(tomorrow);
this.slots = [];
this.companyProducts = [];
this.machineProductIds = [];
this.generateRandomCode();
this.updateSlotSelect();
}

View File

@ -80,7 +80,7 @@
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
{{ __('Product / Slot') }}
{{ __('Product') }}
</th>
<th
class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">
@ -117,23 +117,33 @@
<td class="px-6 py-6">
<div
class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $code->machine->name }}</div>
{{ $code->machine?->name ?? '-' }}</div>
<div
class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">
{{
$code->machine->serial_no }}</div>
$code->machine?->serial_no }}</div>
</td>
<td class="px-6 py-6">
@php
$slot = $code->machine->slots->where('slot_no', $code->slot_no)->first();
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
// 綁商品碼(新):直接取 product 名稱,不顯示貨道。
// 綁貨道碼(舊,向後相容):由貨道反查商品,並保留貨道資訊。
if ($code->product_id) {
$productName = $code->product?->name ?? __('Empty');
$slotLabel = null;
} else {
$slot = $code->machine?->slots->where('slot_no', $code->slot_no)->first();
$productName = $slot ? ($slot->product?->name ?? __('Empty')) : __('Empty');
$slotLabel = $code->slot_no;
}
@endphp
<div class="text-sm font-black text-cyan-600 dark:text-cyan-400 mb-0.5">{{
$productName
}}</div>
@if($slotLabel)
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
__('Slot')
}}: {{ $code->slot_no }}</div>
}}: {{ $slotLabel }}</div>
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300">
@ -251,7 +261,7 @@
@endif
<p
class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->machine->name }}
{{ $code->machine?->name ?? '-' }}
</p>
</div>
</div>
@ -281,19 +291,24 @@
<div
class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
@php
if ($code->product_id) {
$prodMobile = $code->product?->name ?? __('Empty');
$slotMobileLabel = null;
} else {
$slotMobile = $code->machine?->slots->where('slot_no', $code->slot_no)->first();
$prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) : __('Empty');
$slotMobileLabel = $code->slot_no;
}
@endphp
<p
class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Slot') }}</p>
{{ __('Product') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
{{ $code->slot_no }}
@php
$slotMobile = $code->machine->slots->where('slot_no',
$code->slot_no)->first();
$prodMobile = $slotMobile ? ($slotMobile->product?->name ?? __('Empty')) :
__('Empty');
@endphp
<span class="ml-1 text-cyan-600 dark:text-cyan-400">({{ $prodMobile
}})</span>
<span class="text-cyan-600 dark:text-cyan-400">{{ $prodMobile }}</span>
@if($slotMobileLabel)
<span class="ml-1 text-[10px] text-slate-400">{{ __('Slot') }}: {{ $slotMobileLabel }}</span>
@endif
</p>
</div>
<div>

View File

@ -108,7 +108,7 @@
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">
{{ $gift->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{
$gift->machine->name }}</div>
$gift->machine?->name ?? '-' }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<span class="text-sm font-black text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10 px-3 py-1 rounded-lg border border-indigo-100 dark:border-indigo-500/20">
@ -258,7 +258,7 @@
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">
{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">
{{ $gift->machine->name }}</p>
{{ $gift->machine?->name ?? '-' }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">

View File

@ -85,6 +85,13 @@
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button type="button" @click="openExportModal()"
class="p-2.5 rounded-xl bg-emerald-500 text-white hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 group transition-all active:scale-95"
title="{{ __('Export Machine Inventory') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
</div>
</form>
</div>
@ -96,6 +103,60 @@
</div>
{{-- Detail Mode --}}
{{-- 機台庫存批次匯出 Modal --}}
<div x-show="exportModalOpen" class="fixed inset-0 z-[100] overflow-y-auto" x-cloak
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 py-8">
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="exportModalOpen = false"></div>
<div class="relative bg-white dark:bg-slate-900 rounded-3xl shadow-2xl w-full max-w-lg border border-slate-100 dark:border-slate-800 max-h-[85vh] flex flex-col">
<div class="px-8 pt-8 pb-4 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Export Machine Inventory') }}</h3>
<button @click="exportModalOpen = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="px-8 py-4 border-b border-slate-50 dark:border-slate-800/50">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" :checked="exportAllChecked" @change="toggleExportAll($event)"
class="w-4 h-4 text-emerald-500 rounded border-slate-300 focus:ring-emerald-500 dark:bg-slate-800 dark:border-slate-700">
<span class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Select All') }}
<span class="text-slate-400 font-bold">(<span x-text="exportSelectedIds.length"></span>/{{ $exportMachines->count() }})</span>
</span>
</label>
</div>
<div class="px-6 py-3 overflow-y-auto flex-1 space-y-1">
@forelse($exportMachines as $m)
<label class="flex items-center gap-3 py-2 px-3 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer transition-colors">
<input type="checkbox" value="{{ $m->id }}" x-model.number="exportSelectedIds"
class="w-4 h-4 text-emerald-500 rounded border-slate-300 focus:ring-emerald-500 dark:bg-slate-800 dark:border-slate-700">
<span class="flex flex-col">
<span class="text-sm font-bold text-slate-700 dark:text-slate-200">{{ $m->name }}</span>
<span class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest">{{ $m->serial_no }}</span>
</span>
</label>
@empty
<p class="text-sm text-slate-400 text-center py-8">{{ __('No machines') }}</p>
@endforelse
</div>
<div class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex flex-wrap justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button @click="exportModalOpen = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
{{-- 暫時隱藏 CSV 匯出(保留備用;後端仍支援 csv/zip --}}
{{--
<button @click="doExport('csv')"
class="px-5 py-2.5 rounded-xl bg-emerald-500 text-white font-black text-sm uppercase tracking-widest hover:bg-emerald-600 transition-all active:scale-95 flex items-center gap-2">
<span>📄</span> {{ __('Export to CSV') }}
</button>
--}}
<button @click="doExport('excel')"
class="px-5 py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase tracking-widest hover:bg-emerald-700 transition-all active:scale-95 flex items-center gap-2">
<span>📊</span> {{ __('Export to Excel') }}
</button>
</div>
</div>
</div>
</div>
<div x-show="viewMode === 'detail'" class="space-y-8 animate-luxury-in" x-cloak>
<!-- Statistics Dashboard -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch">
@ -260,6 +321,32 @@
</svg>
{{ __('Grid View') }}
</button>
<div class="relative" x-data="{ exportOpen: false }" x-show="slots.length > 0" x-cloak>
<button type="button" @click="exportOpen = !exportOpen" @click.away="exportOpen = false"
class="flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-black text-sm uppercase tracking-widest text-emerald-500 hover:bg-emerald-500/10">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{{ __('Export') }}
<svg class="w-3 h-3 transition-transform duration-200" :class="exportOpen ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div x-show="exportOpen" x-transition
class="absolute left-0 top-full mt-2 bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl rounded-2xl p-2 z-30 min-w-[160px]"
x-cloak>
<a :href="`/admin/warehouses/machine-inventory/${selectedMachine.id}/export?export=csv`"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📄 {{ __('Export to CSV') }}
</a>
<a :href="`/admin/warehouses/machine-inventory/${selectedMachine.id}/export?export=excel`"
download @click="exportOpen = false"
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
📊 {{ __('Export to Excel') }}
</a>
</div>
</div>
</div>
<div class="flex items-center gap-4">
@ -296,6 +383,12 @@
<span class="text-sm font-black tracking-tighter text-slate-800 dark:text-white"
x-text="slot.slot_no"></span>
</div>
<template x-if="slot.is_locked">
<div class="absolute top-4 right-5 flex items-center gap-1 px-2.5 py-1.5 rounded-xl bg-rose-500 text-white text-[9px] font-black uppercase tracking-widest shadow-lg shadow-rose-500/30 whitespace-nowrap select-none">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</div>
</template>
<div class="relative w-16 h-16 mb-4 mt-2">
<div
@ -364,8 +457,16 @@
</template>
</div>
<div>
<div class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></div>
<div class="flex items-center gap-2">
<span class="text-base font-black text-slate-800 dark:text-slate-200"
x-text="slot.product?.name || '{{ __('Empty Slot') }}'"></span>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-500 text-[10px] font-black uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Locked (Sale Paused)') }}
</span>
</template>
</div>
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1 mt-1">
<template x-if="slot.product?.id">
<span class="text-xs font-bold text-slate-400 dark:text-slate-500 font-mono tracking-widest uppercase">{{ __('ID') }}: <span x-text="slot.product.id"></span></span>
@ -435,6 +536,12 @@
</div>
<div class="shrink-0 text-right">
<p class="text-base font-black text-emerald-500" x-text="'$' + Math.floor(slot.product?.price || 0)"></p>
<template x-if="slot.is_locked">
<span class="inline-flex items-center gap-1 mt-1 text-[10px] font-black px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-500 uppercase tracking-wider whitespace-nowrap">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>
{{ __('Sale Paused') }}
</span>
</template>
</div>
</div>
@ -783,6 +890,7 @@
</div>
</div>
</div>
</div>
@endsection
@ -797,6 +905,35 @@ document.addEventListener('alpine:init', () => {
loading: false,
hasReplenishPermission: {{ auth()->user()->can('menu.warehouses.replenishments') ? 'true' : 'false' }},
// 機台庫存批次匯出
exportModalOpen: false,
exportSelectedIds: [],
get exportAllChecked() {
const total = {{ $exportMachines->count() }};
return total > 0 && this.exportSelectedIds.length === total;
},
openExportModal() {
this.exportSelectedIds = @js($exportMachines->pluck('id'));
this.exportModalOpen = true;
},
toggleExportAll(e) {
this.exportSelectedIds = e.target.checked ? @js($exportMachines->pluck('id')) : [];
},
doExport(format) {
if (this.exportSelectedIds.length === 0) {
window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Please select at least one machine') }}', type: 'error' } }));
return;
}
const url = `/admin/warehouses/machine-inventory/export?export=${format}&ids=${this.exportSelectedIds.join(',')}`;
const a = document.createElement('a');
a.href = url;
a.setAttribute('download', '');
document.body.appendChild(a);
a.click();
a.remove();
this.exportModalOpen = false;
},
async init() {
const urlParams = new URLSearchParams(window.location.search);
const machineId = urlParams.get('machine_id');

View File

@ -104,6 +104,7 @@
'logs' => __('Machine Logs'),
'permissions' => __('Machine Permissions'),
'utilization' => __('Utilization Rate'),
'pricing' => __('Machine Pricing'),
'expiry' => __('Expiry Management'),
'maintenance' => __('Maintenance Records'),
'ui-elements' => __('UI Elements'),
@ -120,6 +121,7 @@
'promotions' => __('Promotions'),
'pass-codes' => __('Pass Codes'),
'store-gifts' => __('Store Gifts'),
'pharmacy-pickup' => __('menu.sales.pharmacy-pickup'),
'change-stock' => __('Change Stock'),
'machine-reports' => __('Machine Reports'),
'product-reports' => __('Product Reports'),

View File

@ -15,8 +15,12 @@
</head>
<body class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-start pt-8 pb-12 px-6 antialiased">
@php
$slot = $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first();
$product = $slot?->product;
// 取貨碼可能綁「商品」(product_idslot_no 為 null貨道刷碼當下挑) 或綁「貨道」(舊版)。
// 優先用直接綁定的商品;否則回退用 slot_no 對應貨道的商品。
$slot = $pickupCode->slot_no
? $pickupCode->machine->slots->where('slot_no', $pickupCode->slot_no)->first()
: null;
$product = $pickupCode->product ?? $slot?->product;
@endphp
<div class="w-full max-w-sm animate-luxury-in">
@ -53,7 +57,7 @@
<div class="flex items-center justify-between">
<div class="text-center flex-1">
<span class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Slot') }}</span>
<span class="text-2xl font-black text-slate-800 dark:text-white font-display">{{ $pickupCode->slot_no }}</span>
<span class="text-2xl font-black text-slate-800 dark:text-white font-display">{{ $pickupCode->slot_no ?: '—' }}</span>
</div>
<div class="w-px h-8 bg-slate-100 dark:bg-slate-800"></div>
<div class="text-center flex-1">

View File

@ -216,6 +216,12 @@
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('Store Gifts') }}</span>
</a></li>
@endcan
@can('menu.sales.pharmacy-pickup')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.sales.pharmacy-pickup') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.sales.pharmacy-pickup') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-3-3v6m-7 4h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v11a2 2 0 002 2z" /></svg>
<span class="whitespace-nowrap overflow-hidden transition-all duration-300" :class="sidebarCollapsed ? 'max-w-0 opacity-0' : 'max-w-[200px] opacity-100'">{{ __('menu.sales.pharmacy-pickup') }}</span>
</a></li>
@endcan
</ul>
</div>
</li>

View File

@ -68,6 +68,10 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::get('/utilization-ajax/{id?}', [App\Http\Controllers\Admin\MachineController::class, 'utilizationData'])->name('utilization-ajax');
Route::get('/{machine}/slots-ajax', [App\Http\Controllers\Admin\MachineController::class, 'slotsAjax'])->name('slots-ajax');
Route::post('/{machine}/slots/expiry', [App\Http\Controllers\Admin\MachineController::class, 'updateSlotExpiry'])->name('slots.expiry.update');
Route::post('/{machine}/slots/lock', [App\Http\Controllers\Admin\MachineController::class, 'toggleSlotLock'])->name('slots.lock.toggle');
// 機台專屬定價(獨立頁,列全公司目錄,可上貨前先定價)
Route::get('/{machine}/pricing', [App\Http\Controllers\Admin\MachineController::class, 'pricing'])->name('pricing');
Route::post('/{machine}/pricing', [App\Http\Controllers\Admin\MachineController::class, 'updatePricing'])->name('pricing.update');
Route::get('/{machine}/logs-ajax', [App\Http\Controllers\Admin\MachineController::class, 'logsAjax'])->name('logs-ajax');
Route::get('/{machine}/temperature-ajax', [App\Http\Controllers\Admin\MachineController::class, 'temperatureAjax'])->name('temperature-ajax');
Route::get('/{machine}/ambient-temperature-ajax', [App\Http\Controllers\Admin\MachineController::class, 'ambientTemperatureAjax'])->name('ambient-temperature-ajax');
@ -119,7 +123,9 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 模組 4機台庫存總覽 (Force Route Refresh)
Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory');
Route::get('/machine-inventory/export', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventoryExportBatch'])->name('machine-inventory.export-batch');
Route::get('/machine-inventory/{machine}/slots', [App\Http\Controllers\Admin\WarehouseController::class, 'machineSlots'])->name('machine-inventory.slots');
Route::get('/machine-inventory/{machine}/export', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventoryExport'])->name('machine-inventory.export');
Route::get('/machine-inventory/{machine}/movements', [App\Http\Controllers\Admin\WarehouseController::class, 'machineStockMovements'])->name('machine-inventory.movements');
// 模組 5機台補貨
@ -142,6 +148,10 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::prefix('sales')->name('sales.')->group(function () {
Route::get('/', [App\Http\Controllers\Admin\SalesController::class, 'index'])->name('index');
// 手動補單(機台斷線/漏報時人工補登銷售紀錄)— 限系統管理員Controller 內把關)
Route::get('/manual/machine-slots', [App\Http\Controllers\Admin\SalesController::class, 'manualMachineSlots'])->name('manual.machine-slots');
Route::post('/manual', [App\Http\Controllers\Admin\SalesController::class, 'storeManualOrder'])->name('manual.store');
// 電子發票對帳/補開/作廢/列印
Route::post('/invoices/{invoice}/print', [App\Http\Controllers\Admin\SalesController::class, 'printInvoice'])->name('invoices.print');
Route::post('/invoices/{invoice}/reconcile', [App\Http\Controllers\Admin\SalesController::class, 'reconcileInvoice'])->name('invoices.reconcile');
@ -150,19 +160,24 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 管理者備註(訂單/發票各自獨立)
Route::patch('/orders/{order}/remark', [App\Http\Controllers\Admin\SalesController::class, 'updateOrderRemark'])->name('orders.remark');
Route::post('/orders/{order}/pickup-code', [App\Http\Controllers\Admin\SalesController::class, 'generateOrderPickupCode'])->name('orders.pickup-code');
Route::patch('/invoices/{invoice}/remark', [App\Http\Controllers\Admin\SalesController::class, 'updateInvoiceRemark'])->name('invoices.remark');
// 取貨碼
Route::get('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'pickupCodes'])->name('pickup-codes');
// 產碼用:回傳機台所屬公司的商品清單 + 機台目前已上架的 product_id供前端標示「機台未上架」提示
Route::get('/pickup-codes/company-products/{machine}', [App\Http\Controllers\Admin\SalesController::class, 'pickupCompanyProducts'])->name('pickup-codes.company-products');
Route::post('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePickupCode'])->name('pickup-codes.store');
Route::patch('/pickup-codes/{pickupCode}', [App\Http\Controllers\Admin\SalesController::class, 'updatePickupCode'])->name('pickup-codes.update');
Route::delete('/pickup-codes/{pickupCode}', [App\Http\Controllers\Admin\SalesController::class, 'destroyPickupCode'])->name('pickup-codes.destroy');
Route::get('/pickup-codes/batch/{batchNo}/download', [App\Http\Controllers\Admin\SalesController::class, 'downloadPickupCodeBatch'])->name('pickup-codes.batch-download');
// 通行碼
Route::get('/pass-codes', [App\Http\Controllers\Admin\SalesController::class, 'passCodes'])->name('pass-codes');
Route::post('/pass-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePassCode'])->name('pass-codes.store');
Route::patch('/pass-codes/{passCode}', [App\Http\Controllers\Admin\SalesController::class, 'updatePassCode'])->name('pass-codes.update');
Route::delete('/pass-codes/{passCode}', [App\Http\Controllers\Admin\SalesController::class, 'destroyPassCode'])->name('pass-codes.destroy');
Route::get('/pass-codes/batch/{batchNo}/download', [App\Http\Controllers\Admin\SalesController::class, 'downloadPassCodeBatch'])->name('pass-codes.batch-download');
Route::get('/orders', [App\Http\Controllers\Admin\SalesController::class, 'orders'])->name('orders');
Route::get('/promotions', [App\Http\Controllers\Admin\SalesController::class, 'promotions'])->name('promotions');
@ -170,7 +185,14 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::post('/store-gifts', [App\Http\Controllers\Admin\SalesController::class, 'storeWelcomeGift'])->name('store-gifts.store');
Route::patch('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'updateWelcomeGift'])->name('store-gifts.update');
Route::delete('/store-gifts/{welcomeGift}', [App\Http\Controllers\Admin\SalesController::class, 'destroyWelcomeGift'])->name('store-gifts.destroy');
// 領藥單(取物單模式 / 領藥模組)— 受權限 menu.sales.pharmacy-pickup 控管
Route::get('/pharmacy-pickup', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'index'])->name('pharmacy-pickup')->middleware('can:menu.sales.pharmacy-pickup');
Route::post('/pharmacy-pickup', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'store'])->name('pharmacy-pickup.store')->middleware('can:menu.sales.pharmacy-pickup');
Route::get('/pharmacy-pickup/{machine}/products-ajax', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'productsAjax'])->name('pharmacy-pickup.products-ajax')->middleware('can:menu.sales.pharmacy-pickup');
Route::get('/pharmacy-pickup/{order}/print', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'print'])->name('pharmacy-pickup.print')->middleware('can:menu.sales.pharmacy-pickup');
Route::post('/pharmacy-pickup/{order}/cancel', [App\Http\Controllers\Admin\PharmacyPickupController::class, 'cancel'])->name('pharmacy-pickup.cancel')->middleware('can:menu.sales.pharmacy-pickup');
// 詳情路由必須放在最後,避免攔截其他具體路由
Route::get('/{order}', [App\Http\Controllers\Admin\SalesController::class, 'show'])->name('show');
});
@ -194,6 +216,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
Route::prefix('data-config')->name('data-config.')->group(function () {
Route::middleware('can:menu.data-config.products')->group(function () {
Route::get('/products/template', [App\Http\Controllers\Admin\ProductController::class, 'downloadTemplate'])->name('products.template');
Route::get('/products/export', [App\Http\Controllers\Admin\ProductController::class, 'export'])->name('products.export');
Route::post('/products/import', [App\Http\Controllers\Admin\ProductController::class, 'import'])->name('products.import');
Route::resource('products', App\Http\Controllers\Admin\ProductController::class)->except(['show']);
Route::patch('/products/{id}/toggle-status', [App\Http\Controllers\Admin\ProductController::class, 'toggleStatus'])->name('products.status.toggle');

View File

@ -0,0 +1,157 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 帳號樹狀層級隔離(第二+三批)行為測試:
* - 主帳號唯一性(第一個帳號自動成為主帳號)
* - parent_id / level 歸屬
* - 子帳號深度上限level 2 不可再建)
* - visibleTo 可見範圍隔離(只看自己直建的)
* - canManageAccount 越權防護(不能管旁線)
*/
class AccountHierarchyTest extends TestCase
{
use RefreshDatabase;
protected User $sysAdmin;
protected Company $company;
protected Role $tenantRole;
protected function setUp(): void
{
parent::setUp();
foreach (['menu.permissions.accounts', 'menu.data-config.sub-accounts'] as $p) {
Permission::create(['name' => $p, 'guard_name' => 'web']);
}
$superAdmin = Role::create(['name' => 'super-admin', 'company_id' => null, 'is_system' => true, 'guard_name' => 'web']);
$superAdmin->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
Role::create(['name' => '客戶管理員角色模板', 'company_id' => null, 'is_system' => true, 'guard_name' => 'web'])
->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
$this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']);
$this->sysAdmin = User::factory()->create(['company_id' => null]);
$this->sysAdmin->assignRole($superAdmin);
// 供租戶帳號使用、帶有子帳號管理權限的公司層級角色
$this->tenantRole = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$this->tenantRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
}
/** 建立一個租戶帳號(直接寫入層級欄位)並賦予可建子帳號的角色 */
private function makeTenantUser(array $attrs): User
{
$user = User::factory()->create(array_merge(['company_id' => $this->company->id], $attrs));
$user->assignRole($this->tenantRole);
return $user;
}
public function test_first_company_account_becomes_main(): void
{
$this->actingAs($this->sysAdmin)->post(route('admin.permission.accounts.store'), [
'name' => 'First', 'username' => 'first', 'email' => 'first@test.co', 'password' => 'password123',
'role' => '客戶管理員角色模板', 'status' => 1, 'company_id' => $this->company->id,
])->assertSessionHas('success');
$u = User::where('username', 'first')->first();
$this->assertTrue($u->is_admin);
$this->assertSame(0, $u->level);
$this->assertNull($u->parent_id);
}
public function test_second_company_account_is_not_main_and_is_child(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$this->actingAs($this->sysAdmin)->post(route('admin.permission.accounts.store'), [
'name' => 'Second', 'username' => 'second', 'email' => 'second@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1, 'company_id' => $this->company->id,
])->assertSessionHas('success');
$u = User::where('username', 'second')->first();
$this->assertFalse($u->is_admin);
$this->assertSame(1, $u->level);
$this->assertSame($main->id, $u->parent_id);
}
public function test_tenant_admin_creates_sub_account_sets_parent_and_level(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$this->actingAs($main)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'Sub', 'username' => 'sub', 'email' => 'sub@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1,
])->assertSessionHas('success');
$u = User::where('username', 'sub')->first();
$this->assertFalse($u->is_admin);
$this->assertSame(1, $u->level);
$this->assertSame($main->id, $u->parent_id);
$this->assertSame($this->company->id, $u->company_id);
}
public function test_level_two_sub_account_cannot_create_further(): void
{
$deep = $this->makeTenantUser(['is_admin' => false, 'level' => 2, 'parent_id' => null]);
$this->actingAs($deep)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'TooDeep', 'username' => 'toodeep', 'email' => 'toodeep@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1,
])->assertSessionHas('error');
$this->assertDatabaseMissing('users', ['username' => 'toodeep']);
}
public function test_visible_to_isolates_siblings_and_self(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
$b = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
$c = $this->makeTenantUser(['is_admin' => false, 'level' => 2, 'parent_id' => $a->id]);
// 子帳號 A 只看得到自己直建的 C看不到自己、旁線 B、主帳號
$visibleToA = User::visibleTo($a)->pluck('id')->all();
$this->assertSame([$c->id], $visibleToA);
// 主帳號看得到同公司全部
$visibleToMain = User::visibleTo($main)->pluck('id')->sort()->values()->all();
$this->assertEquals(collect([$main->id, $a->id, $b->id, $c->id])->sort()->values()->all(), $visibleToMain);
}
public function test_sub_account_cannot_manage_sibling(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
$b = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
// A 嘗試刪除旁線 B → 被擋
$this->actingAs($a)->delete(route('admin.data-config.sub-accounts.destroy', $b->id))
->assertSessionHas('error');
$this->assertDatabaseHas('users', ['id' => $b->id, 'deleted_at' => null]);
// A 嘗試停用旁線 B → 被擋
$this->actingAs($a)->patch(route('admin.data-config.sub-accounts.status.toggle', $b->id))
->assertSessionHas('error');
}
public function test_main_account_can_manage_company_sub_account(): void
{
$main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]);
$a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]);
// 主帳號可停用同公司子帳號
$this->actingAs($main)->patch(route('admin.data-config.sub-accounts.status.toggle', $a->id))
->assertSessionHas('success');
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 第四批:角色指派子集 機台授權子集 把關測試。
* - 操作者不可指派「含自身未持有權限」的角色(防權限提升)。
* - 操作者不可授權「自己未被授權」的機台給他人(防機台層級提升)。
* - 操作者不可對非管轄帳號操作機台授權(越權)。
*/
class AccountSubsetGuardTest extends TestCase
{
use RefreshDatabase;
protected Company $company;
protected User $main;
protected function setUp(): void
{
parent::setUp();
foreach (['menu.permissions.accounts', 'menu.data-config.sub-accounts', 'extra.high.perm'] as $p) {
Permission::create(['name' => $p, 'guard_name' => 'web']);
}
$this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']);
// 主帳號角色:僅持有兩個權限(不含 extra.high.perm
$mainRole = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$mainRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']);
$this->main = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => true, 'level' => 0, 'parent_id' => null,
]);
$this->main->assignRole($mainRole);
}
// ── 角色指派子集 ─────────────────────────────────────────────
public function test_operator_cannot_assign_role_exceeding_own_permissions(): void
{
// 高權限角色:多出操作者沒有的 extra.high.perm
$highRole = Role::create(['name' => 'high-role', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$highRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts', 'extra.high.perm']);
$this->actingAs($this->main)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'X', 'username' => 'escalated', 'email' => 'e@test.co', 'password' => 'password123',
'role' => 'high-role', 'status' => 1,
])->assertSessionHas('error');
$this->assertDatabaseMissing('users', ['username' => 'escalated']);
}
public function test_operator_can_assign_role_within_own_permissions(): void
{
$this->actingAs($this->main)->post(route('admin.data-config.sub-accounts.store'), [
'name' => 'X', 'username' => 'ok_sub', 'email' => 'ok@test.co', 'password' => 'password123',
'role' => 'tenant-mgr', 'status' => 1,
])->assertSessionHas('success');
$this->assertDatabaseHas('users', ['username' => 'ok_sub']);
}
// ── 機台授權子集 ─────────────────────────────────────────────
public function test_operator_cannot_grant_machine_they_are_not_assigned(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$target = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$m2 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id); // 操作者只被授權 m1
// 嘗試把自己沒有的 m2 授權給 target → 422
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [
'machine_ids' => [$m1->id, $m2->id],
])->assertStatus(422);
// 只授權自己有的 m1 → 成功
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [
'machine_ids' => [$m1->id],
])->assertStatus(200);
$this->assertDatabaseHas('machine_user', ['user_id' => $target->id, 'machine_id' => $m1->id]);
$this->assertDatabaseMissing('machine_user', ['user_id' => $target->id, 'machine_id' => $m2->id]);
}
public function test_operator_cannot_assign_machines_to_non_managed_account(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
// 旁線帳號(非 operator 的下層)
$sibling = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id);
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $sibling->id), [
'machine_ids' => [$m1->id],
])->assertStatus(403);
}
public function test_sync_preserves_authorizations_outside_operator_subset(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$target = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$m2 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id); // 操作者只可授權 m1
$target->machines()->attach([$m1->id, $m2->id]); // 目標既有 m1、m2m2 在操作者範圍外)
// 操作者送出清空([]):應只移除自己範圍內的 m1保留範圍外的 m2
$this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [
'machine_ids' => [],
])->assertStatus(200);
$this->assertDatabaseMissing('machine_user', ['user_id' => $target->id, 'machine_id' => $m1->id]);
$this->assertDatabaseHas('machine_user', ['user_id' => $target->id, 'machine_id' => $m2->id]);
}
public function test_get_account_machines_only_lists_operator_subset(): void
{
$operator = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id,
]);
$target = User::factory()->create([
'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id,
]);
$m1 = Machine::factory()->create(['company_id' => $this->company->id]);
$m2 = Machine::factory()->create(['company_id' => $this->company->id]);
$operator->machines()->attach($m1->id);
$response = $this->actingAs($operator)->getJson(route('admin.machines.permissions.accounts.get', $target->id));
$response->assertStatus(200);
$ids = collect($response->json('machines'))->pluck('id')->all();
$this->assertContains($m1->id, $ids);
$this->assertNotContains($m2->id, $ids); // 操作者沒被授權的機台不應出現在可授權清單
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 第五批:角色層級隔離測試。
* - 子帳號只看得到 / 只能管理 自己建立的角色(看不到上層/旁線建的)。
* - 主帳號看得到同公司全部角色。
* - storeRole 寫入 created_byupdate/destroy canBeManagedBy 守衛。
*/
class RoleHierarchyTest extends TestCase
{
use RefreshDatabase;
protected Company $company;
protected User $main;
protected User $subA;
protected User $subB;
protected function setUp(): void
{
parent::setUp();
Permission::create(['name' => 'menu.data-config.sub-accounts', 'guard_name' => 'web']);
$this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']);
$role = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$role->syncPermissions(['menu.data-config.sub-accounts']);
$this->main = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => true, 'level' => 0, 'parent_id' => null]);
$this->main->assignRole($role);
$this->subA = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]);
$this->subA->assignRole($role);
$this->subB = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]);
$this->subB->assignRole($role);
}
private function makeRole(string $name, User $creator): Role
{
return Role::create([
'name' => $name, 'company_id' => $this->company->id, 'is_system' => false,
'guard_name' => 'web', 'created_by' => $creator->id,
]);
}
public function test_role_visible_to_isolates_by_creator(): void
{
$roleMain = $this->makeRole('role-main', $this->main);
$roleA = $this->makeRole('role-a', $this->subA);
$roleB = $this->makeRole('role-b', $this->subB);
// 子帳號 A 只看得到自己建立的角色
$this->assertSame([$roleA->id], Role::visibleTo($this->subA)->pluck('id')->all());
// 主帳號看得到同公司全部角色(含 tenant-mgr 與三個新建的)
$mainVisible = Role::visibleTo($this->main)->pluck('id')->all();
foreach ([$roleMain->id, $roleA->id, $roleB->id] as $id) {
$this->assertContains($id, $mainVisible);
}
}
public function test_can_be_managed_by_creator_only_for_sub_account(): void
{
$roleMain = $this->makeRole('role-main', $this->main);
$roleA = $this->makeRole('role-a', $this->subA);
$this->assertTrue($roleA->canBeManagedBy($this->subA)); // 自己建的
$this->assertFalse($roleMain->canBeManagedBy($this->subA)); // 上層建的
$this->assertTrue($roleMain->canBeManagedBy($this->main)); // 主帳號管全公司
$this->assertTrue($roleA->canBeManagedBy($this->main)); // 主帳號管全公司
}
public function test_store_role_sets_created_by(): void
{
$this->actingAs($this->subA)->post(route('admin.data-config.sub-account-roles.store'), [
'name' => 'a-created-role',
'permissions' => ['menu.data-config.sub-accounts'],
])->assertSessionHas('success');
$role = Role::where('name', 'a-created-role')->where('company_id', $this->company->id)->first();
$this->assertNotNull($role);
$this->assertSame($this->subA->id, $role->created_by);
}
public function test_sub_account_cannot_update_others_role(): void
{
$roleB = $this->makeRole('role-b', $this->subB);
// A 嘗試改 B 建的角色 → 被擋
$this->actingAs($this->subA)->put(route('admin.data-config.sub-account-roles.update', $roleB->id), [
'name' => 'hijacked',
'permissions' => [],
])->assertSessionHas('error');
$this->assertDatabaseHas('roles', ['id' => $roleB->id, 'name' => 'role-b']);
}
public function test_sub_account_cannot_delete_others_role(): void
{
$roleMain = $this->makeRole('role-main', $this->main);
$this->actingAs($this->subA)->delete(route('admin.data-config.sub-account-roles.destroy', $roleMain->id))
->assertSessionHas('error');
$this->assertDatabaseHas('roles', ['id' => $roleMain->id]);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 銷售紀錄機台權限隔離:使用者只能看到「自己可存取機台」的交易訂單,
* 無該機台權限者不得看到其交易(修正先前訂單清單未依機台授權過濾的漏洞)。
*/
class SalesMachineAccessTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private User $main;
private Machine $mA;
private Machine $mB;
private int $orderA;
private int $orderB;
protected function setUp(): void
{
parent::setUp();
Permission::create(['name' => 'menu.sales', 'guard_name' => 'web']);
$this->company = Company::create(['name' => 'Co', 'code' => 'CO']);
$role = Role::create(['name' => 'r', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']);
$role->syncPermissions(['menu.sales']);
$this->main = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => true, 'level' => 0]);
$this->main->assignRole($role);
$this->mA = Machine::factory()->create(['company_id' => $this->company->id]);
$this->mB = Machine::factory()->create(['company_id' => $this->company->id]);
$this->orderA = $this->makeOrder($this->mA->id);
$this->orderB = $this->makeOrder($this->mB->id);
}
private function makeOrder(int $machineId): int
{
return DB::table('orders')->insertGetId([
'company_id' => $this->company->id,
'machine_id' => $machineId,
'payment_type' => 1,
'total_amount' => 100,
'pay_amount' => 100,
'payment_status' => 1,
'status' => 'completed',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function visibleOrderIds(User $user): array
{
$response = $this->actingAs($user)->get(route('admin.sales.index'));
$response->assertOk();
return collect($response->viewData('orders')->items())->pluck('id')->all();
}
public function test_sub_account_with_machine_access_sees_only_that_machine_orders(): void
{
$sub = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]);
$sub->assignRole(Role::where('company_id', $this->company->id)->first());
$sub->machines()->attach($this->mA->id); // 只被授權 mA
$ids = $this->visibleOrderIds($sub);
$this->assertContains($this->orderA, $ids);
$this->assertNotContains($this->orderB, $ids); // 未授權 mB → 看不到其訂單
}
public function test_sub_account_without_any_machine_access_sees_no_orders(): void
{
$sub = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]);
$sub->assignRole(Role::where('company_id', $this->company->id)->first());
// 不授權任何機台
$ids = $this->visibleOrderIds($sub);
$this->assertNotContains($this->orderA, $ids);
$this->assertNotContains($this->orderB, $ids);
}
public function test_system_admin_sees_all_orders(): void
{
$sysAdmin = User::factory()->create(['company_id' => null]);
$ids = $this->visibleOrderIds($sysAdmin);
$this->assertContains($this->orderA, $ids);
$this->assertContains($this->orderB, $ids);
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace Tests\Feature\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 售完(庫存歸 0)自動清空效期/批號 —— MySQLrecordDispense 內部寫
* machine_stock_movements type enum MySQL-only DDLsqlite :memory: 無法驗)。執行:
*
* ./vendor/bin/sail test -c phpunit.mysql.xml --filter ExpiryClearOnSelloutMysqlTest
*
* 在預設 sqlite 套件中自動 skip,不影響主套件綠燈。
*
* 動機:機台端「賣完清效期」已做,但 B009 補貨上報不回傳 expiry後台若殘留舊效期
* 日後管理員只補 stock 未改效期 update_inventory 把過期日下發 新批貨被誤鎖。
* 故後台扣庫存歸 0 時亦須清效期,與機台端對稱。
*/
class ExpiryClearOnSelloutMysqlTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private TransactionService $service;
protected function setUp(): void
{
parent::setUp();
if (DB::connection()->getDriverName() !== 'mysql') {
$this->markTestSkipped('需 MySQL:machine_stock_movements.type enum 為 MySQL-only DDL。');
}
$this->company = Company::create(['name' => 'Test Company']);
$this->service = app(TransactionService::class);
}
private function dispense(string $serial, string $slotNo): array
{
return [
'serial_no' => $serial,
'slot_no' => $slotNo,
'product_id' => 101,
'amount' => 50,
'dispense_status' => 1,
];
}
/** 庫存賣到 0 → 效期與批號被清空。 */
public function test_clears_expiry_and_batch_when_stock_hits_zero(): void
{
$serial = '1234567890';
$machine = Machine::factory()->create([
'company_id' => $this->company->id,
'serial_no' => $serial,
]);
$slot = $machine->slots()->create([
'slot_no' => '1',
'stock' => 1,
'max_stock' => 10,
'expiry_date' => '2030-12-31',
'batch_no' => 'B-001',
]);
$this->service->recordDispense($this->dispense($serial, '1'));
$fresh = $slot->fresh();
$this->assertEquals(0, $fresh->stock, '庫存應扣為 0');
$this->assertNull($fresh->expiry_date, '售完應清空效期');
$this->assertNull($fresh->batch_no, '售完應清空批號');
}
/** 庫存尚未歸 0 → 效期與批號保留(同批貨還在販售)。 */
public function test_keeps_expiry_when_stock_remains(): void
{
$serial = '1234567891';
$machine = Machine::factory()->create([
'company_id' => $this->company->id,
'serial_no' => $serial,
]);
$slot = $machine->slots()->create([
'slot_no' => '2',
'stock' => 5,
'max_stock' => 10,
'expiry_date' => '2030-12-31',
'batch_no' => 'B-001',
]);
$this->service->recordDispense($this->dispense($serial, '2'));
$fresh = $slot->fresh();
$this->assertEquals(4, $fresh->stock, '庫存應扣為 4');
$this->assertEquals('2030-12-31', $fresh->expiry_date->format('Y-m-d'), '未售完應保留效期');
$this->assertEquals('B-001', $fresh->batch_no, '未售完應保留批號');
}
}

View File

@ -0,0 +1,165 @@
<?php
namespace Tests\Feature\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\Transaction\DispenseRecord;
use App\Models\Transaction\Invoice;
use App\Models\Transaction\Order;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 交易 finalize 的冪等性、跨機台防撞號與 RelateNumber 等價性測試。
*
* 對應改動:flow_id 入口以 serial 前綴命名空間化(無分隔符)+ 終態短路移入交易內加排他鎖。
* :sqlite :memory: 無真實併發,故此處驗的是「順序重送」的冪等(由終態短路保證);
* lockForUpdate 的真實併發效果需 MySQL 環境另測。
*/
class FinalizeIdempotencyTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private TransactionService $service;
protected function setUp(): void
{
parent::setUp();
$this->company = Company::create(['name' => 'Test Company']);
$this->service = app(TransactionService::class);
}
private function makeMachine(string $serialNo, bool $taxInvoice = false): Machine
{
return Machine::factory()->create([
'company_id' => $this->company->id,
'serial_no' => $serialNo,
'tax_invoice_enabled' => $taxInvoice,
]);
}
private function completedPayload(string $serialNo, string $rawFlowId, array $dispense = []): array
{
return [
'serial_no' => $serialNo,
'flow_id' => $rawFlowId,
'payment_type' => 1,
'order' => [
'flow_id' => $rawFlowId,
'total_amount' => 50,
'pay_amount' => 50,
'payment_type' => 1,
'status' => 'completed',
'payment_status' => 1,
],
'dispense' => $dispense,
];
}
/** 順序重送同一筆 → 只建一張單、出貨一次、庫存只扣一次。 */
public function test_resending_same_transaction_is_idempotent(): void
{
$serial = '1234567890';
$raw = '202606181530450001';
$this->makeMachine($serial);
// 不建貨道:recordDispense 在 slot=null 時仍建出貨紀錄,但跳過 decrement/stockMovement
// (stockMovement 的 type enum 是 MySQL-only DDL,sqlite :memory: 無法驗,屬既有環境限制)。
// 「出貨紀錄只一筆」與「庫存只扣一次」由同一道終態短路保證,此處以前者代證。
$dispense = [[
'slot_no' => '99',
'product_id' => 101,
'amount' => 50,
'dispense_status' => 1,
'remaining_stock' => 4,
]];
$first = $this->service->finalizeTransaction($this->completedPayload($serial, $raw, $dispense));
$second = $this->service->finalizeTransaction($this->completedPayload($serial, $raw, $dispense));
// 同一張單(終態短路)、出貨紀錄只有一筆(重送不重複扣庫存的同源保證)
$this->assertEquals($first->id, $second->id);
$this->assertEquals(1, Order::where('flow_id', $serial . $raw)->count());
$this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count());
}
/** 不同機台、相同原始 flow_id → 命名空間化後為兩張獨立單,不互吞。 */
public function test_same_raw_flow_id_across_machines_creates_separate_orders(): void
{
$raw = '202606181530450001';
$machineA = $this->makeMachine('1111111111');
$machineB = $this->makeMachine('2222222222');
$orderA = $this->service->finalizeTransaction($this->completedPayload('1111111111', $raw));
$orderB = $this->service->finalizeTransaction($this->completedPayload('2222222222', $raw));
$this->assertNotEquals($orderA->id, $orderB->id);
$this->assertEquals('1111111111' . $raw, $orderA->flow_id);
$this->assertEquals('2222222222' . $raw, $orderB->flow_id);
}
/** display_flow_id 顯示用 accessor:剝除機台序號前綴,DB 仍存完整 serial+raw 唯一值。 */
public function test_display_flow_id_strips_serial_prefix(): void
{
$serial = '2501000062';
$raw = '202606190839470011';
$this->makeMachine($serial);
$order = $this->service->finalizeTransaction($this->completedPayload($serial, $raw));
// DB 值維持完整 28 碼;顯示值剝掉序號前綴 → 乾淨 18 碼
$this->assertEquals($serial . $raw, $order->flow_id);
$this->assertEquals($raw, $order->fresh()->display_flow_id);
}
/** RelateNumber = serial + rawFlowId(無分隔符、≤30、與線上推導逐字等價,不可雙重 serial)。 */
public function test_relate_number_equals_serial_plus_raw_flow_id(): void
{
$serial = '1234567890';
$raw = '202606181530450001';
$this->makeMachine($serial, taxInvoice: true);
$payload = $this->completedPayload($serial, $raw);
$payload['invoice'] = ['status' => 'pending', 'amount' => 50];
$order = $this->service->finalizeTransaction($payload);
$invoice = Invoice::where('flow_id', $serial . $raw)->first();
$this->assertNotNull($invoice);
$this->assertEquals($serial . $raw, $invoice->relate_number);
$this->assertLessThanOrEqual(30, strlen($invoice->relate_number));
// 不可出現雙重 serial 前綴
$this->assertStringStartsNotWith($serial . $serial, $invoice->relate_number);
}
/** pending 先送、completed 後送 → 同一張單狀態轉移(命名空間化前後一致)。 */
public function test_pending_then_completed_transitions_same_order(): void
{
$serial = '1234567890';
$raw = '202606181530450001';
$this->makeMachine($serial);
$pending = $this->completedPayload($serial, $raw);
$pending['order']['status'] = 'pending';
$pending['order']['payment_status'] = 0;
$pending['dispense'] = [];
$pendingOrder = $this->service->finalizeTransaction($pending);
$this->assertEquals('pending', $pendingOrder->status);
// slot_no=99 不存在 → 跳過 stockMovement(MySQL-only),仍建出貨紀錄
$completed = $this->completedPayload($serial, $raw, [[
'slot_no' => '99', 'product_id' => 101, 'amount' => 50,
'dispense_status' => 1, 'remaining_stock' => 4,
]]);
$completedOrder = $this->service->finalizeTransaction($completed);
// 同一張單(命名空間化前後 key 一致)、轉為終態、出貨紀錄只一筆
$this->assertEquals($pendingOrder->id, $completedOrder->id);
$this->assertEquals('completed', $completedOrder->fresh()->status);
$this->assertEquals(1, Order::where('flow_id', $serial . $raw)->count());
$this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count());
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace Tests\Feature\Transaction;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\Transaction\DispenseRecord;
use App\Models\Transaction\Order;
use App\Services\Transaction\TransactionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 真實庫存扣減的冪等驗證 —— MySQLmachine_stock_movements.type enum MySQL-only DDL
* sqlite :memory: 無法驗)。執行:
*
* ./vendor/bin/sail test -c phpunit.mysql.xml --filter FinalizeStockMysqlTest
*
* 在預設 sqlite 套件中自動 skip,不影響主套件綠燈。
*/
class FinalizeStockMysqlTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private TransactionService $service;
protected function setUp(): void
{
parent::setUp();
if (DB::connection()->getDriverName() !== 'mysql') {
$this->markTestSkipped('需 MySQL:machine_stock_movements.type enum 為 MySQL-only DDL。');
}
$this->company = Company::create(['name' => 'Test Company']);
$this->service = app(TransactionService::class);
}
private function completedPayloadWithDispense(string $serialNo, string $rawFlowId, string $slotNo): array
{
return [
'serial_no' => $serialNo,
'flow_id' => $rawFlowId,
'payment_type' => 1,
'order' => [
'flow_id' => $rawFlowId,
'total_amount' => 50,
'pay_amount' => 50,
'payment_type' => 1,
'status' => 'completed',
'payment_status' => 1,
],
'dispense' => [[
'slot_no' => $slotNo,
'product_id' => 101,
'amount' => 50,
'dispense_status' => 1,
'remaining_stock' => 4,
]],
];
}
/** 出貨成功扣庫存一次;重送同一筆 → 庫存不再被扣、出貨/異動紀錄不重複(終態短路 + 命名空間化)。 */
public function test_dispense_decrements_stock_once_and_resend_is_idempotent(): void
{
$serial = '1234567890';
$raw = '202606181530450001';
$machine = Machine::factory()->create([
'company_id' => $this->company->id,
'serial_no' => $serial,
]);
$slot = $machine->slots()->create(['slot_no' => '1', 'stock' => 5, 'max_stock' => 10]);
$payload = $this->completedPayloadWithDispense($serial, $raw, '1');
// 第一次:扣庫存一次
$first = $this->service->finalizeTransaction($payload);
$this->assertEquals(4, $slot->fresh()->stock);
$this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count());
$this->assertEquals(1, DB::table('machine_stock_movements')
->where('slot_no', '1')->where('machine_id', $machine->id)->count());
// 重送:庫存維持 4,紀錄不重複
$second = $this->service->finalizeTransaction($payload);
$this->assertEquals($first->id, $second->id);
$this->assertEquals(4, $slot->fresh()->stock, '重送不可再扣庫存');
$this->assertEquals(1, DispenseRecord::where('flow_id', $serial . $raw)->count());
$this->assertEquals(1, DB::table('machine_stock_movements')
->where('slot_no', '1')->where('machine_id', $machine->id)->count());
$this->assertEquals(1, Order::where('flow_id', $serial . $raw)->count());
}
}