484 Commits

Author SHA1 Message Date
dfd0a047f2 Merge branch 'dev' into demo
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 59s
ERP-Deploy-Production / deploy-production (push) Successful in 1m15s
2026-03-13 16:23:37 +08:00
e3ceedc579 [STYLE] 移除冗餘的簡報生成腳本,改由全域技能處理 2026-03-13 16:23:02 +08:00
f2e5ef121e Merge branch 'dev' into demo
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m9s
2026-03-13 16:19:57 +08:00
7a1fc02dfc [REFACTOR] 移除 package.json 中不使用的 pptxgenjs 套件 2026-03-13 16:19:56 +08:00
5f34855233 Merge branch 'dev' into demo
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Has been cancelled
2026-03-13 16:19:00 +08:00
bee8ecb55b [FIX] 修正盤調單明細插入時的欄位名稱錯誤並更新簡報/圖片處理套件 2026-03-13 16:18:13 +08:00
0198b7de90 [FIX] 嚴格限制 now-push 工作流的 main 合併鏈路
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m6s
- 修改 now-push.md 確保 main 只能從 demo 合併

- 明列 dev -> demo -> main 的強制合併順序
2026-03-10 15:41:31 +08:00
b57a4feeab [FIX] 嚴格限制 now-push 工作流的 main 合併鏈路
- 修改 now-push.md 確保 main 只能從 demo 合併
- 明列 dev -> demo -> main 的強制合併順序
2026-03-10 15:39:37 +08:00
55a806051d Merge branch 'dev' into main for hotfix
All checks were successful
ERP-Deploy-Production / deploy-production (push) Successful in 1m11s
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-03-10 15:33:27 +08:00
6ca0bafd60 [FEAT] 新增生產工單實際產量欄位與 UI 規範
- 新增 database/migrations/tenant 實際產量與耗損原因
- ProductionOrder API 狀態推進與實際產量計算
- 完工入庫新增實際產出數量原生數字輸入框 (step=1)
- Create.tsx 補上前端資料驗證與狀態保護
- 建立並更新 UI 數字輸入框設計規範
2026-03-10 15:32:52 +08:00
adf13410ba [DOCS] 更新 API 文件,補充 api-test-01 測試倉說明
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-03-10 11:57:34 +08:00
d52a215916 [FEAT] 優化庫存分析邏輯,增加銷售 Reference Type 追蹤並修正 InventoryService 閉包變數問題
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m20s
2026-03-10 11:15:55 +08:00
197df3bec4 [FIX] 修復所有 E2E 模組測試的標題定位器以及將測試帳號還原為 admin 權限
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-03-09 16:53:06 +08:00
2437aa2672 [FIX] 更新技能觸發路徑:修正因技能更名導致的 broken links 並指向新的全域規範路徑。 2026-03-09 15:04:16 +08:00
a987f4345e [REFACTOR] 優化資料庫查詢效能:在多個 Service 與 Controller 中加入 select 欄位限制,並新增租戶資料表索引 Migration。 2026-03-09 14:59:37 +08:00
89291918fd [FEAT] 實作配方與生產工單自動搜尋,優化分頁 RWD,將倉庫地址改為選填並更新文件
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m22s
2026-03-09 13:48:06 +08:00
3f7a625191 [DOCS] 整理 README.md 多租戶架構說明
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m8s
2026-03-06 16:58:29 +08:00
e11193c2a7 [FEAT] 導入 Playwright E2E 測試環境與登入功能測試腳本
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m36s
2026-03-06 15:38:27 +08:00
02e5f5d4ea [DOCS] 重構 Git 發布規範:將安全性檢查規則收攏至 SKILL.md 並與 now-push 工作流解耦
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s
2026-03-06 14:54:54 +08:00
36b90370a8 [FEAT] 優化會計報表:新增稅額、發票日期與付款方式等會計專用欄位並支援 CSV 完整匯出
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s
2026-03-06 14:40:14 +08:00
5290dd2cbe [DOCS] 更新 AI 開發規範與 .gitignore 忽略多租戶儲存目錄
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m9s
2026-03-06 13:26:27 +08:00
8e0252e8fc [FEAT] 實作公共事業費附件上傳管理與更新 UI 協作規範防呆機制
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-03-06 13:21:14 +08:00
2fd5de96b2 [Merge] Merge demo into main
All checks were successful
ERP-Deploy-Production / deploy-production (push) Successful in 1m11s
2026-03-06 11:08:59 +08:00
951005c616 [DOCS] 更新開發框架規範說明書:加入 CI/CD 自動化部署與查修主機連線資訊
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 56s
2026-03-06 11:06:27 +08:00
ee0bacafc2 [REFACTOR] 修正技能觸發規範文件檔名與內容
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m20s
2026-03-06 10:48:01 +08:00
93390aad80 docs: 調整技能觸發規範格式 2026-03-06 10:44:39 +08:00
6b6e840f35 docs: 整合與優化 Agent Skills 規範及新增技能觸發準則 2026-03-06 10:43:23 +08:00
e4c83ebd6d [DOCS] 優化 now-push 工作流:新增 main 分支從 demo 分支合併之規範提醒
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 58s
ERP-Deploy-Production / deploy-production (push) Successful in 57s
2026-03-05 16:59:46 +08:00
d9edc603c7 [FEAT] 新增專案專屬的 now-push 工作流
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m1s
2026-03-05 16:51:18 +08:00
dda92393d2 [STYLE] 調整生產環境部署腳本格式
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 57s
2026-03-05 16:32:25 +08:00
47deab9804 [FEAT] 優化公共事業費提醒信邏輯:到期前 7, 3, 0 天發信,逾期每日發信
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 57s
2026-03-05 16:23:57 +08:00
e921810f70 [FEAT] 在部署流程中加入 SystemSettingSeeder 以自動補齊系統設定資料
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s
2026-03-05 16:09:15 +08:00
07b7d9b327 [FEAT] 實作公共事業費逾期提醒、租戶自訂通知設定及發送測試信功能
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 56s
2026-03-05 16:01:00 +08:00
016366407c [FIX] 修正生產工單完成入庫時未寫入成本與總價值的問題
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
ERP-Deploy-Production / deploy-production (push) Has been cancelled
2026-03-05 13:34:02 +08:00
ba50905626 [FIX] 修正公共事業費清單日期顯示少一天的問題 2026-03-05 11:58:32 +08:00
f4ed358393 [FEAT] 實作跨倉庫及時庫存批號搜尋與 Debounce 搜尋體驗
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 56s
2026-03-05 11:51:13 +08:00
7c395c89b5 [STYLE] 修正 git 工作流文件命名規範
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m3s
2026-03-05 11:22:32 +08:00
ed264b031a [DOCS] 優化 Git 規範:明確 dev->demo->main 鏈路與發布時段限制
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m21s
ERP-Deploy-Production / deploy-production (push) Successful in 1m11s
2026-03-05 09:02:46 +08:00
dd2e63c08b [DOCS] 移除冗餘規範文件並同步開發規則至所有分支
All checks were successful
ERP-Deploy-Production / deploy-production (push) Successful in 1m1s
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m22s
2026-03-05 08:53:41 +08:00
b498fe93ff [DOCS] 更新 Git 規範:加入新功能合併至 main 的時段限制 2026-03-05 08:51:43 +08:00
6b324b4bd0 [DOCS] 將 Git 規範整合至開發 Rules 並移除重複文件 2026-03-05 08:49:05 +08:00
a898873211 [FIX] 修正採購單大單位換算問題並建立 Git 開發規範
All checks were successful
ERP-Deploy-Production / deploy-production (push) Successful in 1m19s
2026-03-05 08:46:26 +08:00
f543b98d0f feat(inventory): 實作進貨單草稿編輯功能、價格雙向連動與批號 UI 優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 57s
ERP-Deploy-Production / deploy-production (push) Successful in 1m14s
1. 實作進貨單編輯功能,支援草稿資料預填與 PUT 更新。
2. 修復進貨單儲存時 received_date 與 expiry_date 的日期格式錯誤 (Y-m-d)。
3. 實作非標準進貨類型(雜項、其他)的單價與小計雙向連動邏輯。
4. 優化品項批號 UI 為 SearchableSelect 整合模式,支援不使用批號 (NO-BATCH) 與建立新批號,與倉庫管理頁面風格統一。
2026-03-03 16:57:28 +08:00
183583c739 feat: API調整訂單與販賣機訂單同步強制使用warehouse_code,更新API對接文件,及優化生產與配方模組UI顯示
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-03-03 14:28:15 +08:00
58bd995cd8 feat: 新增採購統計分析功能並優化 API 文件顯示樣式
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m13s
ERP-Deploy-Production / deploy-production (push) Successful in 1m13s
- 在 RoleController 中新增 procurement_analysis 權限群組名稱
- 在 Procurement 模組中新增採購統計分析路由
- 在 PermissionSeeder 中新增 procurement_analysis.view 權限並分配給角色
- 在側邊欄「報表與分析」分組中新增「採購統計分析」項目
- 優化 API 文件視圖中的表格外觀樣式
2026-03-03 11:38:04 +08:00
036f4a4fb6 優化採購單與進貨單操作紀錄:新增品項明細、ID 轉名稱解析、前端多數量 key 通用顯示
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
ERP-Deploy-Production / deploy-production (push) Successful in 1m12s
- 重構 PurchaseOrder@tapActivity:支援 vendor_id/warehouse_id/user_id 自動解析為名稱
- 修改 PurchaseOrderController@store:改用 saveQuietly + 手動日誌,建立時紀錄品項明細
- 修正 PurchaseOrderController update/destroy snapshot 跨模組取值為 null 的問題
- 修改 GoodsReceiptService@store:改用 saveQuietly + 手動日誌,建立時紀錄品項明細
- 修改 ActivityDetailDialog.tsx:支援 quantity/quantity_received/requested_qty 多 key 通用渲染
- 新增項目顯示金額與備註,更新項目增加金額與備註變更對比
2026-03-02 17:30:55 +08:00
0a955fb993 feat: 整合門市領料日誌、API 文件存取、修改庫存與併發編號問題、供應商商品內聯編輯及日誌 UI 優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s
2026-03-02 16:42:12 +08:00
7dac2d1f77 實作產品與庫存匯入邏輯 (ProductImport, InventoryImport) 並更新相關 Service 與 Controller
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 56s
2026-03-02 11:58:04 +08:00
649af40919 實作 InventoryService 的批量入庫 (processIncomingInventory) 與庫存調整 (adjustInventory) 邏輯
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-03-02 10:47:43 +08:00
5f8b2a1c2d 新增 POS 庫存查詢 API:實作 InventorySyncController 與相關 Service 邏輯,並更新 API 整合手冊
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m24s
2026-03-02 10:19:38 +08:00
4bbbde685d feat: 更新系統操作手冊內容並新增本地代理配置
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
2026-02-26 13:54:35 +08:00
5e32526471 style(Frontend): 將側邊欄與麵包屑導覽的『報表管理』更名為『報表與分析』
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m16s
2026-02-26 10:44:43 +08:00
f960aaaeb2 feat(Inventory): 實作批號溯源完整功能與 UI 呈現,包含文字敘述卡片與更完整的關聯屬性 2026-02-26 10:39:24 +08:00
63e4f88a14 優化門市叫貨流程:實作庫存預扣機制、鎖定自動產生的調撥單明細、修復自動販賣機貨道數量連動 Bug 及狀態同步問題
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 56s
2026-02-25 17:32:28 +08:00
e3df090afd feat: 統一各模組分頁組件佈局並新增系統設定功能相關檔案
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m5s
2026-02-25 16:16:49 +08:00
878b90e2ad UI: 統一各單據詳情頁面標題與基本資訊排版 2026-02-25 14:56:15 +08:00
299cf37054 fix: 修正全系統側邊欄捲軸重置問題
在所有報表與管理頁面的 router.get 調用中加入 preserveScroll: true。
受影響模組包括:
- 財務管理 (會計報表、公用事業費)
- 庫存管理 (庫存查詢、倉庫管理、進貨、調整、調撥)
- 生產管理 (工單管理、配方管理)
- 採購管理 (採購單)
- 銷售與發貨管理 (銷售單、發貨單、匯入管理)
- 系統管理 (使用者、角色、操作紀錄)
2026-02-25 14:04:22 +08:00
5668e17e61 style: 暫時隱藏採購退回單與出貨單側邊欄項目
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 52s
2026-02-25 13:51:41 +08:00
c4908533a8 feat(procurement): 實作採購退回單模組並修復商品選單報錯
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 58s
2026-02-25 13:49:02 +08:00
deef3baacc refactor: 重構模組通訊與調整儀表板功能
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 57s
- 依循跨模組通訊規範,將 Sales 與 Production 模組中對 Inventory 的直接模型關聯改為透過 InventoryServiceInterface 取得
- 於 InventoryService 實作獲取最高庫存價值、即將過期商品等方法,供儀表板使用
- 確保所有跨模組調用皆採用手動水和(Manual Hydration)方式組合資料
- 移除本地已歸檔的 .agent 規範檔案
2026-02-25 11:48:52 +08:00
ad91b08dbc refactor: 重構 VendorProduct API 與新增進貨單重複檢查前端邏輯
All checks were successful
ERP-Deploy-Production / deploy-production (push) Successful in 1m9s
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m10s
1. 將 VendorProductController 中的 Eloquent 關聯操作改為透過 ProcurementService 使用 DB 操作,解除跨模組 Model 直接依賴。
2. ProcurementService 加入 vendor product 的資料存取方法。
3. 進貨單建立前端 (GoodsReceipt/Create.tsx) 新增重複進貨檢查與警告對話框邏輯。
2026-02-25 11:11:28 +08:00
e406ecd63d feat: 實作應付帳款與銷售訂單權限管理與進貨單權限修正
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m17s
2026-02-24 17:29:09 +08:00
455f945296 feat: 完成進貨單自動拋轉應付帳款流程與AP介面優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m8s
1. 新增 AccountPayable (應付帳款) 模組,包含 Migration、Model、Service 與 Controller
2. 修改 GoodsReceipt (進貨單) 流程,在確認進貨時自動產生對應的應付帳款單 (AP-YYYYMMDD-XX)
3. 實作應付帳款詳細頁面 (Show.tsx),包含發票登記與標記付款功能
4. 修正應付帳款 Show 頁面的排版,將發票資訊套用標準的綠色背景區塊,並調整按鈕位置
5. 更新相關的 Service Provider 與 Routes
2026-02-24 16:46:55 +08:00
aaa93a921e feat: 實作 Demo 環境網域路由轉發,設定 ERP Proxy 統一接管 Port 80
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Has been cancelled
ERP-Deploy-Production / deploy-production (push) Successful in 1m28s
2026-02-24 09:58:21 +08:00
922b1fc877 fix: 統一容器名稱為 star-erp- 前綴,修正 Proxy 與 CI/CD 連線失敗問題
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m48s
ERP-Deploy-Production / deploy-production (push) Successful in 1m5s
2026-02-24 08:44:18 +08:00
3d4682a405 fix: 解決 Demo 環境埠號衝突並新增中央後台網域支援
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 39s
ERP-Deploy-Production / deploy-production (push) Successful in 1m21s
2026-02-24 08:42:16 +08:00
3ba6b3a1cd fix: 還原 compose.demo.yaml 的 port 映射為 80:80 + 8080:8080
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 40s
ERP-Deploy-Production / deploy-production (push) Successful in 59s
2026-02-23 17:35:37 +08:00
ec239279f4 fix: 修正 Demo 環境 port 映射,避免特權端口權限錯誤
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 29s
ERP-Deploy-Production / deploy-production (push) Successful in 1m14s
Demo 主機的 Docker 沒有綁定特權端口 (80) 的權限,
將映射從 80:80 改為 8080:80(主機 8080 → 容器 Nginx 80)。
2026-02-23 17:27:59 +08:00
e2c36e9c0f chore: 推送當前部署與配置修改到所有分支
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 25s
ERP-Deploy-Production / deploy-production (push) Successful in 1m9s
2026-02-23 17:25:40 +08:00
30bf8ef79c fix: 解決部署初期因缺少 vendor 導致容器啟動崩潰的問題
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 32s
ERP-Deploy-Production / deploy-production (push) Successful in 59s
2026-02-23 17:23:53 +08:00
590d1ea9e9 fix: 移除 compose.yaml 中重複的鍵值
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 41s
ERP-Deploy-Production / deploy-production (push) Successful in 1m4s
2026-02-23 17:20:58 +08:00
cd0f454c98 refactor: 根據環境資訊還原容器名稱並維持多檔案 Compose 結構
Some checks failed
ERP-Deploy-Production / deploy-production (push) Has been cancelled
ERP-Deploy-Demo / deploy-demo (push) Has been cancelled
2026-02-23 17:20:44 +08:00
54e1e5df5a fix: 隔離正式與 Demo 環境的容器名稱以修復 CI/CD 衝突
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 26s
2026-02-23 17:17:07 +08:00
8e3d951d0d feat: 為 demo 環境增加 80 埠口對應
Some checks failed
ERP-Deploy-Production / deploy-production (push) Successful in 57s
ERP-Deploy-Demo / deploy-demo (push) Failing after 51s
2026-02-23 17:12:43 +08:00
d04e5bbffb docs: 修正 demo-proxy.conf 中的環境註解文字
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 26s
2026-02-23 17:12:20 +08:00
27273bfee4 fix: 更新 demo-proxy.conf 以符合正式環境配置並優化 SSL 轉發
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Has been cancelled
2026-02-23 17:11:58 +08:00
2a88649f75 feat: 拆分 Docker Compose 配置為多檔案繼承模式並優化部署工作流
Some checks failed
ERP-Deploy-Production / deploy-production (push) Successful in 1m0s
ERP-Deploy-Demo / deploy-demo (push) Failing after 24s
2026-02-23 17:06:15 +08:00
e9313158ba 為了在 gitea_work (LXC) 順利部署 demo,將網路模式改為 host 並同步相關配置 2026-02-23 16:52:27 +08:00
f3da49a76a 觸發 Demo CI/CD 部署 (更新 SSH Key 後再次重試)
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 34s
2026-02-23 16:10:11 +08:00
747f70865d 修正 CI/CD 部署後 npm run build 殘留的 public/hot 導致 Vite HMR 及 CORS 報錯問題
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 24s
ERP-Deploy-Production / deploy-production (push) Successful in 58s
2026-02-23 16:02:40 +08:00
6bb2afa3b7 移除 Dockerfile 中的 setcap 以修復 LXC sysctl 權限問題,並將內部 port 改為 8080
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 25s
ERP-Deploy-Production / deploy-production (push) Successful in 53s
2026-02-23 15:58:52 +08:00
59008eb59e 更名 CI/CD 工作流名稱,移除 Koori 前綴
Some checks failed
ERP-Deploy-Demo / deploy-demo (push) Failing after 28s
ERP-Deploy-Production / deploy-production (push) Successful in 1m0s
2026-02-23 15:51:15 +08:00
a33e470e4d 拆分 CI/CD 流程:將 demo 與正式環境的部署拆分至獨立檔案
Some checks failed
Koori-ERP-Deploy-Production / deploy-production (push) Successful in 55s
Koori-ERP-Deploy-Demo / deploy-demo (push) Failing after 9m37s
2026-02-23 15:32:42 +08:00
71b676b533 修正 CI/CD deploy-production 連線埠號為 2224 (正式環境)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 56s
2026-02-23 15:29:14 +08:00
406d03297a 再次觸發 CI/CD (修復正式機 Port 2227 上的 Docker 權限問題)
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 1m8s
2026-02-23 15:25:07 +08:00
4259c7745b 移除 deploy.yaml 結尾多餘的空白行
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 28s
2026-02-23 15:20:58 +08:00
8169ff3f59 還原 Dockerfile 與 Nginx proxy 設定至原始 port 80 配置
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 24s
2026-02-23 15:04:40 +08:00
1acc4daebb 修復正式機 sysctl 權限錯誤:移除 setcap,PHP 改用 port 8080 搭配 Nginx proxy
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 29s
2026-02-23 15:01:12 +08:00
1acbfb7246 移除 supervisord 的 npm program 區塊,修復正式機容器啟動權限錯誤
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 23s
2026-02-23 14:58:22 +08:00
e02d7c7125 chore: 微調 deploy.yaml 並準備同步至 main
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 23s
2026-02-23 14:53:12 +08:00
a133b94a05 fix(docker): 僅在 local/testing 環境執行 npm run dev,避免正式環境啟動
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 2m26s
2026-02-23 14:43:08 +08:00
acd0590a38 merge: 合併 demo 分支的 deploy.yaml 修正回 dev
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Has been cancelled
2026-02-23 14:33:28 +08:00
a2fe7b5a95 fix: 同步正式環境部署目標至 gitea_work (220.132.7.82:2227)
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Failing after 24s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-23 14:32:30 +08:00
5f1f08869f chore: deploy demo site to gitea_work
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Failing after 7m26s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-23 14:12:00 +08:00
e85c1fa95a fix: 移除不存在的 is_active 欄位引用
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 56s
2026-02-23 13:52:59 +08:00
62dcf04e95 refactor: 調整倉庫自動建立機制,統一使用門市倉類型 (retail)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 54s
2026-02-23 13:49:36 +08:00
6dd3396fb7 fix: 修正 WarehouseType Enum 缺失 system_sales 導致正式機 500 錯誤
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 55s
2026-02-23 13:43:31 +08:00
2f30a78118 feat(integration): 實作並測試 POS 與販賣機訂單同步 API
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 56s
主要變更:
- 實作 POS 與販賣機訂單同步邏輯,支援多租戶與 Sanctum 驗證。
- 修正多租戶識別中間件與 Sanctum 驗證順序問題。
- 切換快取驅動至 Redis 以支援 Tenancy 標籤功能。
- 新增商品同步 API (Upsert) 及相關單元測試。
- 新增手動測試腳本 tests/manual/test_integration_api.sh。
- 前端新增銷售訂單來源篩選與欄位顯示。
2026-02-23 13:27:12 +08:00
904132e460 feat(integration): 擴充產品同步 API 欄位與驗證強化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m14s
1. ProductSync API 新增防護機制,為既有欄位加上字串長度與金額上限限制
2. 開放並接收 ERP Product Model 實用欄位(品牌、規格、成本價、會員價、批發價)
3. 更新 ProductService 寫入邏輯以支援新增的可選欄位
4. 同步更新 api-integration.md 手冊,加入新欄位說明與 JSON 範例
2026-02-23 11:02:25 +08:00
a05acd96dc feat(integration): 完善外部 API 對接邏輯與安全性
1. 新增 API Rate Limiting (每分鐘 60 次)
2. 實作 ProductServiceInterface 與 findOrCreateWarehouseByName 解決跨模組耦合問題
3. 強化 OrderSync API 驗證 (price 欄位限制最小 0、payment_method 加上允許白名單)
4. 實作 OrderSync API 冪等性處理,重複訂單直接回傳現有資訊
5. 修正 ProductSync API 同步邏輯,每次同步皆會更新產品分類與單位
6. 完善 integration API 對接手冊內容與 UI 排版
2026-02-23 10:10:03 +08:00
29cdf37b71 style: 簡化操作手冊標題為『操作指南』
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 56s
2026-02-13 16:04:04 +08:00
d7d1be81a9 style: 強化操作手冊排版樣式鎖定,確保間距維持極簡緊湊
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m2s
2026-02-13 16:02:03 +08:00
227cfec0d2 style: 大幅壓縮操作手冊內容間距,提升資訊密度
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Has been cancelled
2026-02-13 16:01:16 +08:00
034a21cd31 style: 優化操作手冊排版間距,使其更緊湊
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Has been cancelled
2026-02-13 16:00:55 +08:00
6358e23816 fix: 修正操作手冊捲軸行為與容器高度
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 55s
2026-02-13 15:59:26 +08:00
ac149533f0 fix: 簡化 prose 類別以解決 Tailwind v4 排版失效問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m3s
2026-02-13 15:57:56 +08:00
b20a47f710 style: 優化操作手冊 Markdown 排版與 UI 佈局
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Has been cancelled
2026-02-13 15:57:23 +08:00
d017d7e5e0 fix: 修正操作手冊選單顯示邏輯並強化內容
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 54s
2026-02-13 15:55:56 +08:00
8207e6fe94 docs: 撰寫操作手冊正式內容 (新手上路、採購、庫存、FAQ)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 56s
2026-02-13 15:54:37 +08:00
e6cf03b991 feat: 實作系統操作手冊模組 (Markdown 渲染與導覽)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 54s
2026-02-13 15:51:51 +08:00
8ef82d49cb feat(inventory): 新增庫存分析模組
- 實作 InventoryAnalysisController 與 TurnoverService
- 新增庫存分析前端頁面 (Inventory/Analysis/Index.tsx)
- 整合路由與選單
- 統一分頁邏輯與狀態顯示
- 更新 UI Consistency Skill 文件
2026-02-13 15:43:12 +08:00
bb2cf77ccb fix: 修正 deploy.yaml 重複定義錯誤並優化版本號注入腳本
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m6s
2026-02-13 15:05:08 +08:00
d543e6e810 docs: 稍微調整 README.md 結尾格式
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 56s
2026-02-13 15:03:44 +08:00
64e039cc71 fix: 改用 YAML 模板變數直接注入 github.sha 以修復版本號為空的問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 53s
2026-02-13 15:02:12 +08:00
cce8dd3c8b fix: 修正部署工作流中的版本號變數名稱與計算方式
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m2s
2026-02-13 14:59:43 +08:00
6a0f57c86c fix: 重新格式化 deploy-demo 任務以修正 YAML 語法錯誤
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 24s
2026-02-13 14:58:02 +08:00
0251540365 fix: 修正 deploy.yaml 第 94 行縮排錯誤 2026-02-13 14:56:40 +08:00
ab5b4bde0b docs: 調整 README.md 格式 2026-02-13 14:55:38 +08:00
f85f06f3e1 fix: 修正 deploy.yaml 中的 YAML 縮排錯誤 2026-02-13 14:55:04 +08:00
6671e4221f docs: 在 .env.example 中加入 APP_VERSION 2026-02-13 14:53:33 +08:00
24f73a2585 fix: 修正部署腳本中的版本號注入邏輯 2026-02-13 14:50:10 +08:00
2e9ff6c832 feat: 實現版本號自動化更新與修復側邊欄 RWD 問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 50s
2026-02-13 14:46:26 +08:00
77a7d31dc1 fix(dashboard): 修正儀表板待處理數字邏輯與依賴更新
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 54s
2026-02-13 14:29:21 +08:00
e141a45eb9 feat(dashboard): 新增庫存積壓、熱銷數量與即將過期排行,優化熱銷商品顯示與 Tooltip
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 41s
2026-02-13 14:27:43 +08:00
4fa87925a2 UI優化: 全系統狀態標籤 (StatusBadge) 統一化重構完成 (Phase 3 & 4)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m8s
2026-02-13 13:16:05 +08:00
097708aab7 優化: 門市叫貨模組 UI 調整、權限標籤中文化及調撥單動態導覽 2026-02-13 10:39:10 +08:00
b8cbf0bb6d Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 51s
2026-02-12 17:13:57 +08:00
882091ce5f feat(notification): 實作通知輪詢與優化顯示名稱
- 新增通知輪詢 API 與前端自動更新機制
- 修正生產工單單號格式為 PRO-YYYYMMDD-XX
- 確保通知顯示實際建立者名稱而非系統
2026-02-12 17:13:09 +08:00
245553280a Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 50s
2026-02-12 16:41:08 +08:00
299602d3b1 docs: 微調 README 格式 2026-02-12 16:36:51 +08:00
96f2ccee95 fix(production): 移除 Create.tsx 中未使用的 units 變數與重複屬性 2026-02-12 16:34:51 +08:00
c9113544ee Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 51s
2026-02-12 16:31:46 +08:00
5be4d49679 feat: 修正 BOM 單位顯示與完工入庫彈窗 UI 統一規範 2026-02-12 16:30:34 +08:00
b118ea0c39 Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 52s
2026-02-12 13:41:43 +08:00
eb5ab58093 test: debug 日誌測試 2026-02-12 13:41:42 +08:00
57e633c3e9 Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m14s
2026-02-12 13:39:57 +08:00
448b37ca90 test: 驗證 Runner 重複掛載修復 2026-02-12 13:39:56 +08:00
6c146ac717 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 0s
2026-02-12 13:37:14 +08:00
cb433035fe docs: 移除 README 尾部多餘空行 2026-02-12 13:37:12 +08:00
e646c6ffd8 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 0s
2026-02-12 13:31:42 +08:00
83e1c82b11 trigger: definitive fix confirmed (CONFIG_FILE env applied) 2026-02-12 13:31:41 +08:00
19397db2e9 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 2s
2026-02-12 13:29:07 +08:00
db285a6b69 trigger: final repair test (correct network key) 2026-02-12 13:29:06 +08:00
74eeb449f8 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 4s
2026-02-12 13:27:43 +08:00
28ece9fda4 trigger: re-run deploy 3 (force config network host) 2026-02-12 13:27:15 +08:00
bd292b0868 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 5s
2026-02-12 13:23:10 +08:00
ac705a1e58 docs: 更新 README 文檔格式 2026-02-12 13:22:27 +08:00
936abc943e trigger: re-run deploy 2 (valid config)
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 3s
2026-02-12 13:17:45 +08:00
eabde37d15 trigger: re-run deploy (fix runner permissions)
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 1s
2026-02-12 13:14:48 +08:00
921f6e48fb docs: 更新 README 文檔
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 2s
2026-02-12 13:10:49 +08:00
ba4ceb7ff6 fix(ci): 還原正式環境部署配置至 erp.koori.tw:2224
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 1s
2026-02-12 13:09:15 +08:00
3be5d099c9 test
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 48s
2026-02-12 13:02:14 +08:00
9537e48f08 chore: 更新 CI/CD 部署目標至新主機 (220.132.7.82) 2026-02-12 11:56:04 +08:00
165737750c chore: 測試 Gitea 推送功能 2026-02-12 09:46:14 +08:00
220478641d feat: 更新庫存報表、銷售匯入及採購單相關功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m3s
2026-02-10 17:18:59 +08:00
593ce94734 修正庫存報表分頁參數衝突導致明細顯示為空的問題 2026-02-10 16:07:31 +08:00
8b950f6529 feat: 實作庫存列表展開狀態保留 (使用 sessionStorage) 改良顯示與修正相關問題 2026-02-10 13:02:11 +08:00
e098e40fb8 feat: 庫存紀錄顯示儲位並優化返回狀態保持 2026-02-10 11:28:58 +08:00
83d26de6f9 refactor: 調整統計基準為明細筆數,並恢復庫存查詢為單一細目顯示模式 2026-02-10 11:15:08 +08:00
38642cc58b feat: 統一度量衡,確保儀表板統計與庫存查詢清單數據精確一致 2026-02-10 11:09:22 +08:00
a6393e03d8 feat: 實作即時庫存查詢功能、儀表板庫存導盤,及優化手動入庫批號與儲位連動與選單顯示 2026-02-10 10:47:31 +08:00
6980eac1a4 fix(sales): correct import start row instruction to row 3
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-09 17:22:38 +08:00
08e360464e refactor(sales): remove import template download and update dialog instructions
Some checks failed
Koori-ERP-Deploy-System / deploy-production (push) Has been cancelled
Koori-ERP-Deploy-System / deploy-demo (push) Has been cancelled
2026-02-09 17:21:35 +08:00
7cf640b2f4 feat(sales): replace import page with dialog UI and support template download
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-09 17:16:00 +08:00
613eb555ba feat(inventory): 強化調撥單功能,支援販賣機貨道欄位、開放商品重複加入及優化過帳庫存檢核
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-09 16:52:35 +08:00
65eb1a1b64 feat: 實作銷售單匯入權限控管並全面精簡權限顯示名稱
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-09 15:04:08 +08:00
b6fe9ad9f3 feat: 實作銷售單匯入管理、貨道扣庫優化及 UI 細節調整
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m8s
2026-02-09 14:36:47 +08:00
590580e20a refactor: 移除 SKU 欄位,統一使用 code 作為商品代碼
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m22s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-09 11:15:52 +08:00
c2e0ff726d feat(inventory): 在庫存調整與調撥對話框中加入商品編號顯示,方便區分重複品名 2026-02-09 10:40:44 +08:00
5e542752ba feat(inventory): 販賣機視覺優化、修復匯入日期缺失與倉庫刪除權限錯誤 2026-02-09 10:19:46 +08:00
f22df90e01 fix(Inventory): 修復庫存列表批號欄位與新增庫存頁面儲位欄位遺失問題,並還原批號輸入佈局 2026-02-06 17:35:50 +08:00
e018b75783 feat(inventory): 開放倉庫編號編輯、優化調撥單條碼搜尋與庫存匯入範本雙分頁說明 2026-02-06 16:36:14 +08:00
200d1989bd feat(inventory): 支援庫存新增不使用批號模式與自動累加邏輯 2026-02-06 15:56:50 +08:00
6c259859cf feat(procurement): 開放具核准權限人員可在「待核准」狀態下編輯採購單 2026-02-06 15:38:47 +08:00
6bfdd92347 feat(procurement): 統一採購單按鈕樣式與術語更名為「作廢」,並加強權限控管
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m28s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-06 15:32:12 +08:00
70f1709bd0 修正:單位管理中的代碼 (code) 無法儲存的問題 2026-02-06 14:33:32 +08:00
3fd333085b feat: 實作 POS API 整合功能,包含商品與銷售訂單同步及韌性機制
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 57s
2026-02-06 11:56:29 +08:00
906b094c18 feat: [商品管理] 優化商品匯入邏輯,支援 13 碼條碼自動生成、Upsert 更新機制與 Excel 說明工作表
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m7s
2026-02-06 09:26:50 +08:00
e1aa452b3c fix(product): 補回清單頁面的 is_active 資料回傳並修正表格 colSpan
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 50s
2026-02-05 16:19:13 +08:00
397a8a6484 fix(product): 設定 is_active 欄位預設為 true 並更新現有資料為啟用 2026-02-05 16:18:03 +08:00
24aed44cd3 feat(product): 恢復並實作商品起停用狀態功能,包含表單開關與列表顯示 2026-02-05 16:15:06 +08:00
196fec3120 feat(product): 修正控制器邏輯並徹底移除商品起停用開關,統一設為啟用 2026-02-05 16:13:24 +08:00
096a114457 feat(product): 移除商品起停用功能,後端預設所有商品為啟用狀態 2026-02-05 16:12:55 +08:00
af06ca7695 fix(product): 修正商品詳情與編輯頁面的麵包屑層級,支援動態導航 2026-02-05 16:02:20 +08:00
1d5bc68444 feat(product): 優化編輯後的跳轉邏輯,支援依來源回傳詳情頁或列表 2026-02-05 16:01:29 +08:00
075b9f1c98 feat(product): 新增商品詳情查看功能 2026-02-05 15:58:59 +08:00
49bb05d85a style(warehouse): 根據使用者要求調整統計標籤文字 2026-02-05 15:54:24 +08:00
687af254bd style(warehouse): 優化瑕疵倉顯示邏輯並簡化標籤為「過期統計」 2026-02-05 15:53:24 +08:00
a518d390bd feat(inventory): 實作過期與瑕疵庫存總計顯示,並強化庫存明細過期提示 2026-02-05 15:50:14 +08:00
ba3c10ac13 feat(warehouse): 庫存統計卡片加入總金額顯示 (可用/帳面)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 48s
2026-02-05 13:18:22 +08:00
dada3a6512 feat(product): 商品代號加入隨機產生按鈕 (8碼大寫英數)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 58s
2026-02-05 13:12:52 +08:00
b99e391cc6 feat(inventory): 盤點單列印格式加入批號欄位(位於品名之後)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 53s
2026-02-05 13:05:50 +08:00
0aa7fd1f75 fix(supply-chain): 修正出貨單詳情頁組件匯入錯誤以修復正式站編譯失敗
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 46s
2026-02-05 12:08:25 +08:00
3ce96537b3 feat: 標準化全系統數值輸入欄位與擴充商品價格功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m0s
1. UI 標準化:
   - 針對全系統數值輸入欄位統一加上 step='any' 以支援小數點。
   - 表格形式 (Table) 的數值輸入欄位統一加上 text-right 靠右對齊。
   - 修正 Components 與 Pages 中所有涉及金額與數量的輸入框。

2. 功能擴充與修正:
   - 擴充 Product 模型與相關 Dialog 以支援多種價格設定。
   - 修正 Inventory/GoodsReceipt/Create.tsx 未使用的變數錯誤。
   - 優化庫存相關頁面的 UI 一致性。

3. 其他:
   - 更新相關的 Type 定義與 Controller 邏輯。
2026-02-05 11:45:08 +08:00
04f3891275 feat: 實作出貨單模組並暫時導向通用製作中頁面,同步優化盤點與調撥功能的活動日誌顯示
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m11s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-02-05 09:33:36 +08:00
4299e985e9 feat: 優化庫存調撥單操作紀錄與 UI 佈局 2026-02-04 17:51:29 +08:00
2eb136d280 feat(inventory): 完善庫存盤調更新與日誌邏輯,新增「無需盤調」狀態判定
1. 修正 AdjustDocController 缺失 update 方法導致的錯誤。
2. 修正 ActivityDetailDialog 前端 map 渲染 undefined 的 TypeError。
3. 優化盤調單「過帳」日誌,現在會同步包含當時的商品明細快照。
4. 實作盤點單「無需盤調」(no_adjust) 自動判定邏輯:
   - 當盤點數量與庫存完全一致時,自動標記為 no_adjust 結案。
   - 更新前端標籤樣式與操作按鈕對應邏輯。
   - 限制 no_adjust 單據不可重複建立盤調單。
5. 統一盤點單與盤調單的日誌配置,優化 ID 轉名稱顯示。
2026-02-04 16:56:08 +08:00
88415505fb docs(skill): 更新操作紀錄實作規範
整合全域 ID 轉名稱邏輯、日誌合併策略以及針對 Collection 修改錯誤的修復方案。
2026-02-04 15:39:05 +08:00
702af0a259 feat(inventory): 重構庫存盤點流程與優化操作日誌
1. 重構盤點流程:實作自動狀態轉換(盤點中/盤點完成)、整合按鈕為「儲存盤點結果」、更名 UI 狀態標籤。
2. 優化操作日誌:
   - 實作全域 ID 轉名稱邏輯(倉庫、使用者)。
   - 合併單次操作的日誌記錄,避免重複產生。
   - 修復日誌產生過程中的 Collection 修改錯誤。
3. 修正 TypeScript lint 錯誤(Index, Show 頁面)。
2026-02-04 15:12:10 +08:00
f4f597e96d fix(inventory): 修復 Controller 語法錯誤並補齊操作記錄 2026-02-04 13:25:49 +08:00
a8b88b3375 feat(inventory): 實作盤點、盤調與調撥操作紀錄,並支援前端本地化顯示 2026-02-04 13:24:33 +08:00
95fdec8a06 feat(procurement): 修正採購單與進貨單日期標籤、狀態與操作紀錄本地化 2026-02-04 13:20:18 +08:00
4ba85ce446 feat(production): 優化生產單 BOM 原物料選取邏輯,支援商品 -> 倉庫 -> 批號連動與 API 分佈查詢 2026-02-04 13:08:05 +08:00
a0c450d229 refactor(role): 重構角色權限選擇介面並新增快速搜尋功能
1. 新增 PermissionSelector 組件,採用 Accordion 折疊式設計
2. 實作全選/取消全選、展開/收合全部功能
3. 新增權限搜尋過濾器,支援自動展開與中文關鍵字搜尋
4. 優化 UI細節:修正邊框顯示、調整全選框位置與邏輯
2026-02-04 11:07:32 +08:00
16967fc25d ci: 修正 tenants:run 參數語法
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 52s
2026-02-03 17:48:49 +08:00
29842510c4 ci: 自動化權限同步與快取清理邏輯
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 52s
2026-02-03 17:41:01 +08:00
19216f5846 feat(Inventory): 同步調撥管理權限邏輯至盤點管理標準
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m5s
2026-02-03 17:29:32 +08:00
bd999c7bb6 feat: 統一庫存管理分頁 UI 與寬度規範,並更新 SKILL 規範文件 2026-02-03 17:24:34 +08:00
15aaa039e4 feat: 完成 2026-01 月會報告 PPT 製作與視覺美化 2026-02-03 15:11:30 +08:00
27626e6aa8 feat(inventory): 商品管理新增儲位欄位
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 47s
2026-02-03 13:17:46 +08:00
a160e3f15f fix: 修復 ProfileController 缺失的 Request 引用問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m2s
2026-02-03 13:05:47 +08:00
d671c08338 feat: 實作使用者啟停用功能與安全性強化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m1s
- 新增使用者「啟用/停用」狀態切換功能 (含後端 API、權限控管、活動紀錄)
- 強化安全性:隱藏超級管理員角色的可見度與操作權限
- 更新開發規範:加入多租戶資料同步規範於 framework.md
- 前端優化:使用 Switch 元件進行狀態快速切換,調整表格欄位順序
2026-02-03 11:51:46 +08:00
0185843c62 style: 優化規格 Tooltip 支援多行換行顯示
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-02-02 17:29:58 +08:00
be5c121146 feat: 優化商品管理規格顯示與修復重複通知問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 47s
2026-02-02 17:24:49 +08:00
f87310e707 fix: 更新商品代號驗證規則為 2-8 碼
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 52s
1. ProductImport.php: 匯入規則調整
2. ProductController.php: 新增/編輯 API 規則調整
3. UI: 匯入與編輯視窗提示更新
2026-02-02 15:07:12 +08:00
b0192e9b66 fix(nginx): 正確轉發 X-Forwarded-Proto 標頭 (解決 Mixed Content 根源問題)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 45s
2026-02-02 14:57:18 +08:00
8a34aae312 fix: 強制應用層 HTTPS (解決 Mixed Content 分頁問題)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 52s
2026-02-02 14:51:27 +08:00
6204f0d915 feat: 新增商品 Excel 匯入功能與修復 HTTPS 混合內容問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m4s
1. 新增商品 Excel 匯入功能 (ProductImport, Export Template)
2. 調整商品代號驗證規則為 1-5 碼 (Controller & Import)
3. 修正 HTTPS Mixed Content 問題 (AppServiceProvider)
2026-02-02 14:39:13 +08:00
df3db38dd4 預設分類 2026-02-02 13:16:06 +08:00
75c634ffe4 fix(inventory): 修復倉庫低庫存警告計算與全站租戶名稱動態化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 44s
2026-02-02 11:03:09 +08:00
1748eb007e feat(warehouse): 合併撥補單至調撥單流程並移除舊組件 2026-02-02 10:07:36 +08:00
313b95ceb9 fix(activity-log): 補足庫存對象 unit_cost 與 total_value 欄位翻譯 2026-02-02 09:37:27 +08:00
5e897e4197 fix(inventory): 修復調撥單明細庫存顯示與統一過帳按鈕樣式 2026-02-02 09:34:24 +08:00
71458dd976 feat(inventory): 實作撥補單建立即自動過帳邏輯並修正參數對齊問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 53s
2026-02-02 09:27:02 +08:00
36ef411975 fix(inventory): 修正撥補單儲存時的 Ziggy 路由名稱錯誤
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 56s
2026-02-02 09:19:34 +08:00
bb78a432f5 fix(product): 修復條碼掃描自動送出問題並優化手動輸入體驗
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m1s
2026-02-02 09:06:06 +08:00
0d720f3515 Refactor: Standardize Transfer Order Doc Numbering
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 57s
- Updated InventoryTransferOrder boot method to use sequential numbering (TRF+Ymd+Seq) matching InventoryAdjustDoc logic.
2026-01-29 16:48:01 +08:00
2e71a1cb29 Feature: Tenant Short Name and Branding Implementation
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
- Added short_name to Tenant model and controller
- Updated Landlord/Tenant pages (Create, Edit, Show, Index)
- Implemented branding customization (Favicon, Login Copyright, Sidebar Title)
- Updated HandleInertiaRequests to share branding data
2026-01-29 16:28:34 +08:00
746eeb6f01 更新:優化配方詳情彈窗 UI 與一般修正
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-29 16:13:56 +08:00
7619dc24f7 feat(inventory): 統一庫存調整與調撥模組 UI,實作多選、搜尋與明細欄位重構
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m4s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-29 14:37:21 +08:00
2efaded77b 統一庫存盤點與盤調 UI 及邏輯:修正狀態顯示、操作權限與列表樣式
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-29 13:41:31 +08:00
a31c8d6052 feat: add void action to inventory count index
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m9s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-29 13:12:02 +08:00
56e30a85bb refactor: changes to inventory status (approved/unapprove)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m6s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-29 13:04:54 +08:00
46753cc3bc fix(auth): 使用 Inertia::location 修復登入後重定向失敗問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-29 10:25:37 +08:00
7f726e80bd fix(config): 更新 Session Cookie 名稱以強制解決瀏覽器舊 Cookie 衝突
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 44s
2026-01-29 10:18:00 +08:00
8bc95db43d fix(auth): 登出時強制清除 Session Cookie 以解決二次登入問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 47s
2026-01-29 10:08:57 +08:00
95a1763d04 fix(framework): 修正 TrustProxies 配置以解決 HTTPS 識別問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 42s
2026-01-29 10:04:32 +08:00
90cb7a82de fix(deploy): 恢復 node_modules 排除清單
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 47s
2026-01-29 10:00:04 +08:00
bbb2c4c4a3 style(deploy): 移除多餘空行
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-29 09:56:13 +08:00
8cb95e1a56 fix(deploy): 修正正式環境部署漏掉 storage 排除清單導致檔案遺失的問題 2026-01-29 09:51:36 +08:00
fc59c86305 fix(deploy): 確保每次部署後重建 storage 軟連結
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 44s
2026-01-29 09:48:59 +08:00
b613cdb796 chore(docker): 啟動時自動檢查並建立 storage 軟連結
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-29 09:45:12 +08:00
b1745555cc feat(tenancy): 租戶初始化流程新增自動補全基本單位資料 2026-01-29 09:38:23 +08:00
1833ca192d feat(inventory): 優化盤點顯示與權限設定 2026-01-29 09:36:07 +08:00
e5edad4fd0 style: 修正盤點與盤調畫面 Table Padding 並統一 UI 規範
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m4s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-28 18:04:45 +08:00
852370cfe0 fix(db): add activity_log migrations to central database
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 49s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-28 14:10:53 +08:00
965418077b fix(ui): provide default branding for central admin
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 40s
2026-01-28 14:01:08 +08:00
c3af92c85c feat(ui): dynamic page title based on tenant context
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
2026-01-28 13:58:54 +08:00
cca49b5fe8 feat(assets): add default tenant logo and login background
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 44s
2026-01-28 13:49:24 +08:00
d4cef2cd84 fix(tenancy): force seeders in production and set default branding
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 51s
2026-01-28 13:45:28 +08:00
4c959efc8b feat: 補齊生產管理與進貨單權限、功能實作及 UI 優化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 17:40:56 +08:00
95d8dc2e84 feat: 統一進貨單 UI、修復庫存異動紀錄與廠商詳情顯示報錯
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 51s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 17:23:31 +08:00
a7c445bd3f fix: 修正部分進貨採購單更新失敗與狀態顯示問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 13:27:28 +08:00
293358df62 refactor(inventory): 重構倉庫管理邏輯,移除 is_sellable 欄位並改由類型判定可用庫存 2026-01-27 10:23:49 +08:00
1ed3d6a29d docs(ui-consistency): 優化規範文件,明確標準操作優先使用主題色 2026-01-27 10:15:50 +08:00
646435f87a style(production): 修正檢視按鈕樣式為主題色並保留權限控制 2026-01-27 10:11:16 +08:00
f10c31abd0 style(production): 補齊生產工單列表檢視按鈕 UI 與權限控制 2026-01-27 10:10:10 +08:00
046e0a028b style(production): 統一生產模組操作圖示 UI、權限控制與 AlertDialog 2026-01-27 10:09:43 +08:00
ce0a7b3409 feat(procurement): 採購單號格式增加 PO 前綴 2026-01-27 10:05:46 +08:00
084bbc9f53 docs: 再次確認 framework.md 更新內容並同步
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 09:18:07 +08:00
3af4a1e298 docs: 更新開發框架規範,加入嚴格模組化通訊規範
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 09:15:05 +08:00
9b0e3b4f6f refactor(modular): 完成第三與第四階段深層掃描與 Model 清理
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m5s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 09:09:55 +08:00
0e51992cb4 refactor(modular): 完成第二階段儀表板解耦與模型清理
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m1s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 08:59:45 +08:00
ac6a81b3d2 feat: 倉庫業務屬性、庫存成本追蹤與採購單功能更新
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 58s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
1. 倉庫管理:新增業務類型 (Owned/External/Customer) 與車牌資訊與司機欄位。
2. 庫存管理:實作成本追蹤 (unit_cost, total_value),更新列表與撥補單顯示。
3. 採購單:新增採購日期 (order_date),調整欄位名稱與順序。
4. 前端優化:更新相關 TS Type 定義與 UI 顯示。
2026-01-26 17:27:34 +08:00
106de4e945 feat: 修正庫存與撥補單邏輯並整合文件
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
2026-01-26 14:59:24 +08:00
b0848a6bb8 chore: 完善模組化架構遷移與修復前端顯示錯誤
- 修正所有模組 Controller 的 Model 引用路徑 (App\Modules\...)
- 更新 ProductionOrder 與 ProductionOrderItem 模型結構以符合新版邏輯
- 修復 resources/js/utils/format.ts 在處理空值時導致 toLocaleString 崩潰的問題
- 清除全域路徑與 Controller 遷移殘留檔案
2026-01-26 10:37:47 +08:00
db0c1ce3af docs: 更新系統設計說明文件與環境設定
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 58s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-26 09:19:35 +08:00
1d134c9ad8 生產工單BOM以及批號完善
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 57s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-22 15:39:35 +08:00
1ae21febb5 feat(生產/庫存): 實作生產管理模組與批號追溯功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-21 17:19:36 +08:00
fc20c6d813 feat(商品): 調整商品代號顯示與會計報表樣式
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 54s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-21 16:30:50 +08:00
af5f2f55ab 新增總後台域名 erp.mamaiclub.com 到 Nginx 配置
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 50s
2026-01-21 14:21:50 +08:00
eab9e2ce93 Update deploy.yaml to patch compose.yaml for production Nginx settings
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 47s
2026-01-21 13:36:44 +08:00
8215b42e43 新增正式機 Nginx Proxy 設定檔
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 48s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-21 13:34:10 +08:00
db49f417df 新增 stancl/jobpipeline 依賴
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 43s
2026-01-21 13:14:26 +08:00
9e574fea85 更新 CI/CD 設定:正式機路徑改為 star-erp
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m47s
2026-01-21 13:06:01 +08:00
7eed761861 優化公共事業費操作紀錄與新增操作紀錄規範 Skill
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 54s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-21 11:46:16 +08:00
b3299618ce 完善公共事業費用與會計報表權限設定
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
- 新增 utility_fees 與 accounting 相關權限至 PermissionSeeder
- 更新 RoleController 加入權限群組中文標題映射
- 為會計報表匯出功能加上權限保護
- 前端加入 Can 組件保護按鈕顯示
- 更新權限管理 Skill 文件,補充 UI 顯示設定步驟
2026-01-21 10:55:11 +08:00
9a50bbf887 feat(accounting): 優化會計報表與公共事業費 UI,並統一全域日期處理格式
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m7s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 17:45:38 +08:00
89183ca124 feat: 實作使用者管理與公共事業費分頁標準化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 15:53:15 +08:00
74728c47b9 feat(ui): standardize collapsible filters and date selection UI
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 14:03:59 +08:00
daae429cd4 更新 README.md:新增財務管理與公共事業費選單說明
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 45s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 13:04:02 +08:00
b2a63bd1ed 優化公共事業費:修正日期顯示、改善發票號碼輸入UX與調整介面欄位順序
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 44s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 13:02:05 +08:00
7bf892db19 修正日期格式化函式,確保直接使用字串解析避免時區偏移
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 57s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 11:06:31 +08:00
a41d3d8f55 修正日期時區偏移錯誤
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 54s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 11:00:54 +08:00
239e547a5d 修正日期時區偏移導致顯示少一天的問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 57s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 10:57:39 +08:00
c1d302f03e 更新 UI 一致性規範與公共事業費樣式
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 10:41:35 +08:00
32c2612a5f feat(accounting): 實作公共事業費管理與會計支出報表功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 56s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 09:44:05 +08:00
8928a84ff9 文件:更新 README.md 新增系統選單結構說明
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 55s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 09:36:20 +08:00
23682b3ffe 新功能:為操作紀錄資料表新增效能索引
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m3s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-20 09:17:18 +08:00
7367577f6a feat: 統一採購單與操作紀錄 UI、增強各模組操作紀錄功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
- 統一採購單篩選列與表單樣式 (移除舊元件、標準化 Input)
- 增強操作紀錄功能 (加入篩選、快照、詳細異動比對)
- 統一刪除確認視窗與按鈕樣式
- 修復庫存編輯頁面樣式
- 實作採購單品項異動紀錄
- 實作角色分配異動紀錄
- 擴充供應商與倉庫模組紀錄
2026-01-19 17:07:45 +08:00
5c4693577a fix(activity): 修正操作紀錄列表描述中未顯示使用者名稱的問題
- 在 User 模型中加入 tapActivity 自動記錄 snapshot (name, username)

- 在 UserController 手動紀錄的邏輯中補上 snapshot
2026-01-19 16:13:22 +08:00
632dee13a5 fix(activity): 修正使用者更新時產生雙重紀錄的問題
- 使用 saveQuietly 避免原生 update 事件觸發紀錄

- 手動合併屬性變更 (Attributes) 與角色變更 (Roles) 為單一操作紀錄
2026-01-19 16:10:59 +08:00
cdcc0f4ce3 feat(activity): 實作使用者角色分配操作紀錄
- 在使用者建立 (store) 時,將角色名稱寫入操作紀錄

- 在使用者更新 (update) 時,手動比對與紀錄角色名稱異動
2026-01-19 16:06:40 +08:00
f6167fdaec fix(ui): 隱藏操作紀錄中的密碼並中文化帳號欄位
- 在 ActivityDetailDialog 中將 password 欄位顯示為 ******

- 將 username 欄位名稱從 Username 翻譯為 登入帳號
2026-01-19 16:01:27 +08:00
b29278aa12 fix(i18n): 使用者密碼驗證訊息中文化
- 新增/編輯使用者時,密碼欄位的驗證錯誤訊息改為繁體中文顯示
2026-01-19 15:58:47 +08:00
ed6fb37ec3 docs(skill): 更新 UI 統一規範,新增對話框滾動規則
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-19 15:42:55 +08:00
6bd52fe3db refactor(ui): 統一 ActivityDetailDialog 滾動行為
- 移除 ScrollArea,改用原生的 overflow-y-auto 於 DialogContent

- 參考 VendorDialog 與 ProductDialog 的標準實作方式
2026-01-19 15:38:44 +08:00
f83baffddb fix(ui): 修正操作詳情對話框滾動問題
- 移除 ScrollArea 的高度計算,改用 h-full 配合 Flex 佈局自動填滿剩餘空間
2026-01-19 15:35:12 +08:00
a8091276b8 feat: 優化採購單操作紀錄與統一刪除確認 UI
- 優化採購單更新與刪除的活動紀錄邏輯 (PurchaseOrderController)
  - 整合更新異動為單一紀錄,包含品項差異
  - 刪除時記錄當下品項快照
- 統一採購單刪除確認介面,使用 AlertDialog 取代原生 confirm (PurchaseOrderActions)
- Refactor: 將 ActivityDetailDialog 移至 Components/ActivityLog 並優化樣式與大數據顯示
- 調整 UI 文字:將「總金額」統一為「小計」
- 其他模型與 Controller 的活動紀錄支援更新
2026-01-19 15:32:41 +08:00
18edb3cb69 feat: 優化操作紀錄顯示與邏輯 (恢復描述欄位、支援來源標記、改進快照)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 45s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-19 11:47:10 +08:00
74417e2e31 style: 統一所有表格標題樣式為一般粗細並修正排序功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 56s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-19 09:30:02 +08:00
0d7bb2758d feat: 實作操作紀錄與商品分類單位異動紀錄 (Operation Logs for System, Products, Categories, Units)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 58s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 17:36:37 +08:00
19c2eeba7b fix: 修正租戶品牌樣式注入邏輯與清除深色模式殘留
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 48s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 15:14:37 +08:00
55272d5d43 feat: 新增租戶品牌客製化系統(Logo、主色系)、修正 hardcoded 顏色為 CSS 變數
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 14:36:24 +08:00
a2c99e3a36 修正:UI 優化與使用者角色分配改為單選
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 51s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 12:05:45 +08:00
43d7cada34 fix: tenancy middleware order and ui consistency for user profile
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 44s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 11:56:44 +08:00
5b15ca2cd6 docs: 重新撰寫客戶端後台 UI 統一規範技能 2026-01-16 10:33:39 +08:00
aa4143ccf1 feat: 優化中央後台 UI (用語調整、移除連結) 與實作 RWD 支援
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 44s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 10:14:59 +08:00
8a9b8135bd feat: translate landlord login page and remove security text
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 45s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 10:03:02 +08:00
736a01f198 docs: update README with comprehensive setup guide
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 45s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 09:56:13 +08:00
32f993a6e1 refactor: use DEMO_TENANT_PORT env var for demo logic isolation
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 55s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 09:28:29 +08:00
231d1ad029 fix: make DashboardController respect port 8081 for tenant
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m1s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 09:17:01 +08:00
c7e1154af8 fix: 支援 Port 8081 直接訪問租戶 (IP-based tenancy support)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 08:58:32 +08:00
d28671b60c fix: 配置端口代理與 TrustProxies
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 49s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-16 08:39:25 +08:00
4b2ccd36b8 feat: 添加 Nginx 反向代理並統一容器名稱為 star-erp
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m38s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 17:35:09 +08:00
b685c818a4 fix: 停用 asset_helper_tenancy 以修復 Vite 資源路徑
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m15s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 17:16:00 +08:00
bf48fe0c35 chore: 修正部署腳本中的容器名稱 (koori-erp -> star-erp)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m1s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 17:08:02 +08:00
2b752b51ff chore: 更新部署工作流程
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Failing after 41s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 17:06:03 +08:00
9bc7c8514b feat: 租戶建立自動產生預設網域與管理員帳號
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m0s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
- 修改 TenantController 自動產生預設網域 ({tenant_id}.{TENANT_DEFAULT_DOMAIN})
- 新增 TenantDatabaseSeeder 自動建立 admin 帳號
- 啟用 SeedDatabase Job 在建立租戶時自動執行 seeder
- 新增 TENANT_DEFAULT_DOMAIN 環境變數支援不同環境
- 補充中央資料庫所需的 migrations
2026-01-15 16:55:24 +08:00
287ac6faa3 feat(auth): separate landlord and tenant login experience based on domain
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 13:56:11 +08:00
9ce8ff4e06 fix(middleware): create missing PreventAccessFromTenantDomains middleware
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 55s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 13:43:22 +08:00
a6b5496529 fix(tenancy): implement UniversalTenancy middleware to handle central domain on IP
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 52s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 13:39:04 +08:00
79e5916d19 fix(routes): implementing universal routes to resolve 404 on central domain
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 46s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 13:36:59 +08:00
a6ed2720d5 fix: 修復 central_domains 從環境變數讀取
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m4s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 13:25:33 +08:00
190d6c2bd9 fix: 修復 PermissionSeeder 使用 firstOrCreate 避免重複建立
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 13:23:02 +08:00
a64a4682f3 fix: 修復權限 migration 使用 firstOrCreate 避免重複建立錯誤
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 13:19:35 +08:00
4f745c1021 feat: 實作 Multi-tenancy 多租戶架構 (stancl/tenancy)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m3s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
- 安裝並設定 stancl/tenancy 套件
- 分離 Central / Tenant migrations
- 建立 Tenant Model 與資料遷移指令
- 建立房東後台 CRUD (Landlord Dashboard)
- 新增租戶管理頁面 (列表、新增、編輯、詳情)
- 新增域名管理功能
- 更新部署手冊
2026-01-15 13:15:18 +08:00
3e3d8ffb6c git
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-15 11:23:51 +08:00
74a084d938 refactor: optimize user role display and update ui skills
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-14 14:57:05 +08:00
f7238c2860 fix: 統一 UI 按鈕樣式並新增 button-outlined-error hover 效果
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 51s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
- 修正 5 處硬編碼顏色樣式改用預定義按鈕類別
- 新增 button-outlined-error 的 hover 狀態(bg-red-50)
- 修正倉庫模組刪除按鈕樣式統一性
- 角色管理權限 Badge 改用標準組件
- 新增 UI 統一性規範 skill
- 修復 1 處 lint 警告(移除未使用參數)

變更檔案:
- resources/css/app.css: 新增 button-outlined-error hover 樣式
- resources/js/Components/Warehouse/WarehouseDialog.tsx
- resources/js/Pages/Admin/Role/Index.tsx
- resources/js/Pages/Warehouse/EditInventory.tsx
- resources/js/Pages/Warehouse/Inventory.tsx
- resources/js/Pages/Warehouse/SafetyStockSettings.tsx
- .agent/skills/ui-consistency/SKILL.md (新增)
2026-01-14 11:31:36 +08:00
7dfe46ff9a refactor: 優化使用者管理介面與角色顯示
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 54s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
- 移除 Header 中的角色名稱顯示
- 調整使用者表單欄位順序(使用者名稱、姓名並排)
- 將角色分配區塊移至基本資料下方
- 修復 email 欄位 null 值警告
- 修復角色選擇無限迴圈錯誤
- 統一角色顯示格式(中文名稱在上,代號在下)
2026-01-14 09:52:56 +08:00
8e364bc2f7 fix: update role display names and seeder
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 55s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-14 08:56:02 +08:00
2e166d44d2 fix: Ensure db:seed runs during deployment
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m3s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-13 17:34:05 +08:00
78a7ca4261 fix: DatabaseSeeder 呼叫 PermissionSeeder 並在部署時執行 Seed
Some checks failed
Koori-ERP-Deploy-System / deploy-production (push) Has been cancelled
Koori-ERP-Deploy-System / deploy-demo (push) Has been cancelled
2026-01-13 17:33:31 +08:00
e3afc0b64a fix: 顯示使用者角色以除錯 & 強化權限指派邏輯
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-13 17:27:19 +08:00
4d6d37743e fix: 前端權限檢查邏輯讓 super-admin 自動通過
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 49s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-13 17:24:42 +08:00
a0a61ba683 feat: 確保 super-admin 角色擁有系統所有權限且開啟 Gate bypass
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-13 17:21:36 +08:00
2e7aeef367 feat: 新增 Migration 確保 admin 使用者自動被設為 super-admin 角色
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 45s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-13 17:15:32 +08:00
566dfa31ae feat: 統一清單頁面分頁與每頁顯示 UI
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m11s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-13 17:09:52 +08:00
f18fb169f3 feat: 統一全系統頁面標題樣式、優化側邊欄與實作角色成員查看功能 2026-01-13 17:00:58 +08:00
6600cde3bc style: 統一全系統按鈕樣式、新增表格序號欄位、移除裝飾性圖示並同步頁面邊距 2026-01-13 14:23:45 +08:00
f0e6c6e4d1 style: 移除麵包屑首頁項,側邊欄新增儀表板選單並修正選中狀態 2026-01-13 13:37:51 +08:00
ecfcbb93ed feat: 完成權限管理系統、統一頁面標題樣式與表格對齊規範 2026-01-13 13:30:51 +08:00
6770a4ec2f chore: 更新部署設定與文件 2026-01-13 10:00:36 +08:00
b17e305374 chore: 清理 deploy.yaml,移除冗餘註解
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 44s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 11:03:39 +08:00
7ffbc2b1ea fix: 在 rsync 中排除 public/build,避免 Vite manifest 錯誤
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 48s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 10:37:28 +08:00
4f85f80f8e chore: 格式調整
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 49s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 10:34:38 +08:00
4e24b70af3 refactor: 移除維護模式啟用/停用步驟
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m0s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 10:32:55 +08:00
ff66b295e1 perf: 純程式碼更新時跳過 docker compose,減少停機時間
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 10:29:24 +08:00
d736bf9802 feat: 新增條件式容器重建邏輯,減少 502 停機時間
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 10:22:29 +08:00
7c1ee40882 feat: 新增部署維護模式步驟,減少 500 錯誤發生
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 54s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 10:15:07 +08:00
9793ab774b refactor: revert composer install location and clean up workflow
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 55s
2026-01-12 09:56:22 +08:00
5da14da58e chore: formatting and minor updates to deploy workflow
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 09:42:49 +08:00
6e174a09a5 refactor: move composer install to host level in deploy workflow
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 49s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 09:32:28 +08:00
0f45a539de feat: add common exclusions to rsync in deploy workflow
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 2m20s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 09:19:52 +08:00
268fc10ded feat: implement SSH action for container management and health checks
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Failing after 45s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 09:12:16 +08:00
822a83f700 feat: configure SSH key for demo deployment
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 32s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 08:59:30 +08:00
c84d6f7600 feat: install rsync and openssh-client in deploy workflow
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Failing after 29s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 08:45:45 +08:00
1dd50473ce chore: update deploy workflow
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Failing after 5s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-12 08:44:07 +08:00
24ae6f3eee feat: 新增採購單發票欄位、更新 SearchableSelect 樣式與搜尋門檻至 10 個項目
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m17s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-09 10:18:52 +08:00
d60367ac57 Save progress on test-demo before deploy
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 37s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-09 09:07:27 +08:00
3088959c7c 管理採購單的商品金額修正
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 29s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-08 17:51:06 +08:00
cbd8d11848 採購單單位更新bug修復
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 34s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-08 16:52:03 +08:00
0b60dab208 大更新
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 58s
2026-01-08 16:32:10 +08:00
7848976a06 單位顯示修正 2026-01-08 12:00:36 +08:00
48115082e5 新增單位管理以及一些功能修正 2026-01-08 11:52:25 +08:00
eca2f38395 footer copyright
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m2s
2026-01-08 10:08:52 +08:00
807790a7fc 只留user的seeder
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 47s
2026-01-08 09:54:46 +08:00
f9f0d09195 預設管理員的seeder
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 59s
2026-01-08 09:48:12 +08:00
ef1fc47cff 登入驗證以及使用者按鈕
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 55s
2026-01-07 14:44:01 +08:00
19c60a6126 麵包屑點擊問題修正
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 51s
2026-01-07 13:12:07 +08:00
8ea1ce1515 麵包屑功能完善 2026-01-07 13:06:49 +08:00
d852d7b2ec 調整漢堡及header調整
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 52s
2026-01-07 11:39:54 +08:00
720c9a176a 文字與icon修正
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 54s
2026-01-07 09:34:00 +08:00
1bd89dcf2e rwd調整
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 51s
2026-01-07 09:03:25 +08:00
2c7d249014 icon替換
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 50s
2026-01-07 08:23:45 +08:00
bd51911561 側邊攔響應式
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m9s
2026-01-06 16:17:12 +08:00
bd29410191 deletet不需要的deploy
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 40s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-06 15:50:23 +08:00
be315a76cc 修正CICD 2026-01-06 15:49:11 +08:00
fad74df6ac 更新採購單跟商品資料一些bug
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 52s
2026-01-06 15:45:13 +08:00
7160a7e780 test
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 10m24s
2026-01-06 14:21:13 +08:00
3e28067c97 tt
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 47s
2026-01-06 14:15:47 +08:00
fd3ddd0bac mama
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 57s
2026-01-06 14:12:23 +08:00
5797ff118d OK1
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m41s
2026-01-06 14:03:56 +08:00
41d5e8e7fc mama
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m7s
2026-01-06 13:59:12 +08:00
f4ca6b09e8 mana
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 1m7s
2026-01-06 13:56:33 +08:00
1c8c3009ec ggg
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 57s
2026-01-06 13:51:51 +08:00
315cce467e OK
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 1m0s
2026-01-06 13:48:13 +08:00
001ba33335 OK
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m2s
2026-01-06 13:46:10 +08:00
6209b28345 f
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 1m3s
2026-01-06 13:43:51 +08:00
1759fceaed m
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 53s
2026-01-06 13:41:27 +08:00
54d36f51e7 main test
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 44s
2026-01-06 13:38:38 +08:00
981d887ae8 main test
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 58s
2026-01-06 13:32:14 +08:00
8e91f28ef4 main
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 59s
2026-01-06 13:26:40 +08:00
fbcdcd05b0 main go
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 41s
2026-01-06 13:21:31 +08:00
8d838ee6f6 main ok
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m6s
2026-01-06 12:46:56 +08:00
fd86ae0153 main oo
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 59s
2026-01-06 11:58:59 +08:00
cdf434d63c main OK
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 53s
2026-01-06 11:56:14 +08:00
ccdbe48b88 main gotest
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 1m7s
2026-01-06 11:48:31 +08:00
564c6588c1 main ii
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 9m30s
2026-01-06 11:22:02 +08:00
21d0ea4cc2 main push
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 29s
2026-01-06 11:11:10 +08:00
6b0f3c9bcd main test
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 7m55s
2026-01-06 10:58:04 +08:00
0aaa761a47 123
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Has been cancelled
2026-01-06 10:47:28 +08:00
d683861233 test
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 31s
2026-01-06 10:43:53 +08:00
b240877d40 rrr
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 7s
2026-01-06 10:32:10 +08:00
e31715becb main test
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 27s
2026-01-06 10:30:13 +08:00
6631f64e4b JB
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
2026-01-06 10:23:00 +08:00
04e4ec0dca ddd
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m11s
2026-01-06 10:19:18 +08:00
5dacae85d6 tetet
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Failing after 16s
2026-01-06 10:17:17 +08:00
ab2d41720a test2
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Failing after 14s
2026-01-06 10:14:26 +08:00
e5715b7274 test1
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Failing after 28s
2026-01-06 10:09:46 +08:00
c40e331f2b demo
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m7s
2026-01-05 23:50:51 +08:00
91c86bffff vff
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Has been cancelled
2026-01-05 23:43:56 +08:00
3a6522bfe0 dddd
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Has been cancelled
2026-01-05 23:42:51 +08:00
36032e3d08 ffff
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 4m53s
2026-01-05 23:29:12 +08:00
6fdcf74d2c lll
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m59s
2026-01-05 22:47:05 +08:00
8a5ff37860 nmhn
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m33s
2026-01-05 22:45:06 +08:00
56e2f92e6b yyy
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 37s
2026-01-05 22:42:24 +08:00
01386b7aa2 fff
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m44s
2026-01-05 22:31:38 +08:00
94e3fac0f5 hjid
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Has been cancelled
2026-01-05 22:29:12 +08:00
488b5925da demo-server2
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 0s
2026-01-05 22:26:25 +08:00
fd65fa23e0 demo-server
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 9s
2026-01-05 22:25:29 +08:00
ca34636246 uuu
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m30s
2026-01-05 22:18:47 +08:00
a6112b8a65 fgg
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 2m0s
2026-01-05 22:01:17 +08:00
cc83a26375 iii
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m16s
2026-01-05 21:56:23 +08:00
b8995ece05 test2
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 42s
2026-01-05 21:46:01 +08:00
0112672b0a test
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m16s
2026-01-05 21:42:55 +08:00
b1b381115a pp
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m7s
2026-01-05 21:39:10 +08:00
fbc5fb962a ff
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m6s
2026-01-05 21:37:14 +08:00
9f0bf51b7f h
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m36s
2026-01-05 21:31:02 +08:00
1150dafe13 kkk
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m19s
2026-01-05 21:28:56 +08:00
97935f513c ii
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 40s
2026-01-05 21:22:47 +08:00
e6ef25951a hhh
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m58s
2026-01-05 20:24:54 +08:00
10fd897685 nk
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m34s
2026-01-05 20:21:28 +08:00
6d87e3e030 op
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m31s
2026-01-05 20:06:52 +08:00
78325bf0c7 ooo
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 1m48s
2026-01-05 20:04:28 +08:00
3590da83c3 yy
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m46s
2026-01-05 19:59:09 +08:00
91cd942239 jj
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m42s
2026-01-05 19:57:04 +08:00
a81fc17c60 ff
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 3s
2026-01-05 19:56:14 +08:00
c078d0184b ddd
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 41s
2026-01-05 19:51:44 +08:00
09e075a56d rg
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 2s
2026-01-05 19:50:16 +08:00
17b6382bf1 fd
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 39s
2026-01-05 19:46:18 +08:00
5e10459652 td
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 34s
2026-01-05 19:40:25 +08:00
b948ebd7fe www
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m46s
2026-01-05 19:19:25 +08:00
841f987935 ggg
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m22s
2026-01-05 17:36:12 +08:00
1ac8c70bd7 RRR
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 9s
2026-01-05 17:34:04 +08:00
a74709b75d gggg
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m44s
2026-01-05 17:28:43 +08:00
a2bad91b86 bc
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 1s
2026-01-05 17:26:33 +08:00
64990f0fb2 gfd
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Has been cancelled
2026-01-05 17:23:39 +08:00
ebbd5cdc80 rgg
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 0s
2026-01-05 17:23:13 +08:00
477186bc3c yte
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 1s
2026-01-05 17:22:50 +08:00
080000102b 543
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 0s
2026-01-05 17:19:37 +08:00
a275931f97 tt
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m40s
2026-01-05 17:15:58 +08:00
5fe0cb8cb6 tyt
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 10s
2026-01-05 17:15:01 +08:00
cda5650962 tt
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 2s
2026-01-05 17:06:04 +08:00
6ecb513bb0 666
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m44s
2026-01-05 16:52:01 +08:00
e11c1d3bb3 7777
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 1s
2026-01-05 16:41:58 +08:00
dfce5287fc edee
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 12s
2026-01-05 16:41:04 +08:00
9be253a548 7654
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 0s
2026-01-05 16:39:31 +08:00
a3372cc24c 66633
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m43s
2026-01-05 16:28:03 +08:00
13c960a90e 8765
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Has been cancelled
2026-01-05 16:21:55 +08:00
158c7154f3 675
Some checks are pending
Koori-ERP-Demo-Deploy / sync-update (push) Waiting to run
2026-01-05 16:20:45 +08:00
58fc0a7de4 777
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Has been cancelled
2026-01-05 16:20:34 +08:00
fe85566d48 7777
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 10s
2026-01-05 16:15:17 +08:00
a5023d3318 6666
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 40s
2026-01-05 16:06:15 +08:00
3b6442f487 444
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m8s
2026-01-05 15:55:11 +08:00
0746f752ac 222
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 1m24s
2026-01-05 15:52:05 +08:00
af26e5f1b2 ty8
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 1s
2026-01-05 15:51:16 +08:00
3d6560fd46 tt8
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Has been cancelled
2026-01-05 15:50:21 +08:00
35cf9e3cd3 tt7
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 45s
2026-01-05 15:32:19 +08:00
695799e6bc tt6
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 37s
2026-01-05 15:21:49 +08:00
128dc50939 tt5
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 58s
2026-01-05 15:19:50 +08:00
f4cf16ddec tt4
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 37s
2026-01-05 14:52:46 +08:00
f73284420b tt3
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Has been cancelled
2026-01-05 14:35:09 +08:00
4c7abf463e tt2
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 14s
2026-01-05 14:33:45 +08:00
e76b8e6a90 tt
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Has been cancelled
2026-01-05 14:33:08 +08:00
38b9f96ee4 1112
All checks were successful
Koori-ERP-Demo-Deploy / sync-update (push) Successful in 3m43s
2026-01-05 14:25:11 +08:00
31ecccf913 1111
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 10s
2026-01-05 14:21:28 +08:00
da758b9891 b4
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 1s
2026-01-05 14:16:28 +08:00
b857f7a29d b3
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 12s
2026-01-05 14:14:15 +08:00
24c694b201 B2
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 1s
2026-01-05 14:10:13 +08:00
b573030aac main
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 2s
2026-01-05 13:57:36 +08:00
6d0b3497fa demo1
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 1s
2026-01-05 13:56:06 +08:00
8879ad6295 demo
Some checks failed
Koori-ERP-Demo-Deploy / sync-update (push) Failing after 11s
2026-01-05 13:54:34 +08:00
584 changed files with 73889 additions and 9558 deletions

View File

@@ -1,74 +0,0 @@
---
trigger: always_on
---
---
trigger: always_on
---
預設專案運行於 WSL2 的 Laravel Sail (Docker) 環境。
開發框架規範說明書ERP 系統 (koori-erp)
1. 專案概述
目標: 打造一個強大且穩定的 ERP 後台管理系統。
核心架構: 採用 單體式架構配現代化前端 (Monolith with a Modern Frontend)。使用 Laravel、Inertia.js 及 React。
工作流程: 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。
2. 技術棧 (Tech Stack)
後端: PHP 8.5 / Laravel 12
前端橋樑: Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
前端庫: React (以 Functional Components 與 Hooks 為主)
樣式處理: Tailwind CSS (確保與 UI/UX 設計稿完全一致)
資料庫: MySQL 8.0
開發環境: Laravel Sail (Docker / WSL2)
未來擴充: 針對高併發或跨平台模組,預留 Golang 微服務接口。
3. 目錄結構與慣例
3.1 後端 (Laravel)
Controllers 必須回傳 Inertia::render() 來渲染頁面。
Models 嚴格執行型別標註,使用 Eloquent 進行資料庫操作。
Routes 統一在 routes/web.php 定義 Inertia 路由。
3.2 前端 (React)
Pages (頁面) 位於 resources/js/Pages/。每個檔案代表一個完整的路由視圖。
Components (組件) 位於 resources/js/Components/。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
Layouts (版面) 位於 resources/js/Layouts/。定義 ERP 的通用版面(例如:包含側邊欄 Sidebar 與導覽列 Navbar 的後台主框架)。
4. 整合指南 (UI/UX 轉換至 Laravel)
組件遷移: 將 UI/UX 的 React 原始碼移入 resources/js/ 時,應進行「原子化」拆解,提高元件複用率。
資料傳遞: 透過 Laravel Controller 的 props 傳送動態資料給 React。除非是後續的異步請求否則避免在 React 初次渲染時使用 axios 抓取資料,應優先使用 Inertia 的資料流。
狀態管理: 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
5. 開發標準 (Coding Standards)
命名規範:
Controllers: PascalCaseController.php
React Components: PascalCase.jsx
Routes: kebab-case (小寫橫線分隔)
回傳格式: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
6. AI 協作規則 (給 Antigravity AI)
角色設定: 你是一位專業的全端開發工程師助手。
代碼生成指令:
所有的解釋說明請使用 繁體中文。
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。

100
.agents/rules/framework.md Normal file
View File

@@ -0,0 +1,100 @@
---
trigger: always_on
---
# 開發框架規範說明書ERP 系統 (star-erp)
## 1. 專案概述
* **目標** 打造一個強大且穩定的 ERP 後台管理系統。
* **核心架構** 採用 **模組化單體式架構 (Modular Monolith)** 配現代化前端。使用 Laravel、Inertia.js 及 React。
* **工作流程** 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。後端邏輯依據「業務領域」拆分為獨立模組。
## 2. 技術棧 (Tech Stack)
* **後端** PHP 8.5 / Laravel 12
* **前端橋樑** Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議)
* **前端庫** React (以 Functional Components 與 Hooks 為主)
* **樣式處理** Tailwind CSS (確保與 UI/UX 設計稿完全一致)
* **資料庫** MySQL 8.0
* **開發環境** Laravel Sail (Docker / WSL2)
* **未來擴充** 針對高併發或跨平台模組,預留 Golang 微服務接口。
## 3. 目錄結構與慣例
### 3.1 後端 (Laravel - Modular Monolith)
系統採用模組化架構,核心邏輯位於 `app/Modules/` 下:
* **Modules** 位於 `app/Modules/{ModuleName}/`
* **Controllers** `app/Modules/{ModuleName}/Controllers/`。必須回傳 `Inertia::render()`
* **Models** `app/Modules/{ModuleName}/Models/`
* **Routes** `app/Modules/{ModuleName}/Routes/web.php`。各模組獨立管理路由。
* **Global Routes** `routes/web.php` 僅保留全域通用路由或作為模組路由的載入點。
### 3.2 前端 (React)
* **Pages (頁面)** 位於 `resources/js/Pages/`。每個檔案代表一個完整的路由視圖。
* **Components (組件)** 位於 `resources/js/Components/`。存放由 UI/UX 團隊提供的可重複使用 UI 元件。
* **Layouts (版面)** 位於 `resources/js/Layouts/`。定義 ERP 的通用版面。
## 4. 整合指南 (UI/UX 轉換至 Laravel)
* **組件遷移** 將 UI/UX 的 React 原始碼移入 `resources/js/` 時,應進行「原子化」拆解,提高元件複用率。
* **資料傳遞** 透過 Laravel Controller 的 props 傳送動態資料給 React。優先使用 Inertia 資料流,避免初次渲染時使用 axios。
* **狀態管理** 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。
## 5. 開發標準 (Coding Standards)
* **命名規範**
* Controllers: `PascalCaseController.php`
* React Components: `PascalCase.jsx`
* Routes: `kebab-case` (小寫橫線分隔)
* **回傳格式** 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。
## 6. 嚴格模組化通訊規範 (Strict Modular Communication)
為了確保系統的可維護性與獨立性,所有模組必須遵守以下「實體解耦」規範:
* **禁止跨模組 Eloquent 關聯**:禁止在 Model 中定義指向其他模組的 `belongsTo`, `hasMany` 等關聯。
* **介面化通訊 (Contracts)**:模組間的資料交換與功能調用必須透過 `app/Modules/{ModuleName}/Contracts/` 下定義的介面進行。
* **禁止跨模組 Model 引用**Controller 與 Service 禁止 `use` 其他模組的 Model (除非是該模組自身的 Contracts)。
* **手動資料水和 (Manual Hydration)**若頁面需要顯示跨模組資料訂單顯示使用者名稱Controller 應透過 Service 獲取基本資料,再手動組合成前端所需的 JSON/Props 結構。
* **資料一致性**:跨模組的資料操作應由各模組的 Service 處理其內部的 transaction 完整性。
## 7. AI 協作規則 (給 Antigravity AI)
* **角色設定** 你是一位專業的全端開發工程師助手。
* **代碼生成指令**
* 所有的解釋說明請使用 **繁體中文**。
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
* 新增功能時,請先判斷應歸屬於哪個 Module並建立在 `app/Modules/` 對應目錄下。
* **核心要求UI 規範與彈性設計 (重要)**
* 在開發「新功能」或「新頁面」前,產出的 `implementation_plan.md` 中**必須包含「UI 規範核對清單」**,明確列出將使用哪些已定義於 `ui-consistency/SKILL.md` 的元件(例如:`AlertDialog`、特定圖示名稱等)。
* **已規範部分**:絕對遵循《客戶端後台 UI 統一規範》進行實作。
* **未規範部分**:若遇到規範外的新 UI 區塊,請保有設計彈性,運用 Tailwind 打造符合 ERP 調性的初版設計,依據使用者的實際感受進行後續調整與收錄。
## 8. 多租戶開發規範 (Multi-tenancy Standards)
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
* **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。
* **指令執行**
* **Seeders**: 必須執行 `./vendor/bin/sail php artisan tenants:run db:seed` 以確保所有租戶均獲得更新。
* **Tinker**: 檢查租戶資料時應使用 `./vendor/bin/sail php artisan tenants:run tinker`
* **Migrations**: 租戶相關的 Schema 異動應放在 `database/migrations/tenant/` 並執行 `./vendor/bin/sail artisan tenants:migrate`
## 9. 運行機制 (Docker / Sail)
由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令:
* **啟動環境** `./vendor/bin/sail up -d`
* **執行 PHP 指令** `./vendor/bin/sail php -v`
* **執行 Artisan 指令** `./vendor/bin/sail artisan route:list`
* **執行 Composer** `./vendor/bin/sail composer install`
* **執行 Node/NPM** `./vendor/bin/sail npm run dev`
## 10. 部署與查修環境 (CI/CD & Troubleshooting)
* **自動化部署**:本專案使用 CI/CD 自動化部署,開發者只需 push 程式碼至對應分支即可。
* **Demo 環境 (對應 `demo` 分支)**:若需查修測試站問題(例如查看 Error Log 或資料庫),請連線 `ssh gitea_work`
* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`
## 11. 瀏覽器測試規範 (Browser Testing)
當需要進行瀏覽器自動化測試或手動驗證時,請遵守以下連線資訊:
* **本地測試網址**`http://localhost:8081/`
* **預設管理員帳號**`admin`
* **預設管理員密碼**`password`
> [!IMPORTANT]
> 在執行 browser subagent 或進行 E2E 測試時,請務必確認為 `8081` Port以避免連線至錯誤的服務環境。

View File

@@ -0,0 +1,57 @@
---
trigger: always_on
---
# 技能觸發規範 (Skill Trigger Rules)
本文件確保 AI 助手在對話中能**主動辨識**需要參照技能 (Skill) 的時機。
Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
**若對話內容命中以下任一觸發條件,必須先使用 `view_file` 讀取對應的 `SKILL.md` 後再進行作業。**
---
## 觸發對照表
| 觸發詞 / 情境 | 對應 Skill | 路徑 |
|---|---|---|
| 操作紀錄、Activity Log、日誌、`tapActivity``LogsActivity``saveQuietly``activity()``items_diff` | **操作紀錄實作規範** | `.agents/skills/activity-logging/SKILL.md` |
| 權限、permission、角色、role、`usePermission``<Can>``PermissionSeeder`、middleware protection | **權限管理與實作規範** | `.agents/skills/permission-management/SKILL.md` |
| 跨模組、Service Interface、`Contracts`、模組間通訊、`ServiceProvider` 綁定、禁止跨模組引用 | **跨模組調用與通訊規範** | `.agents/skills/cross-module-communication/SKILL.md` |
| 按鈕樣式、表格規範、圖標、分頁、Badge、Toast、表單、UI 統一、頁面佈局、`button-filled-*``button-outlined-*``lucide-react`、色彩系統 | **客戶端後台 UI 統一規範** | `.agents/skills/ui-consistency/SKILL.md` |
| Git 分支、commit、push、合併、部署、`feature/``hotfix/``develop``main` | **Git 分支管理與開發規範** | `.agents/skills/git-workflows/SKILL.md` |
| E2E、端到端測試、Playwright、`spec.ts`、功能驗證、自動化測試、回歸測試 | **E2E 端到端測試規範** | `.agents/skills/e2e-testing/SKILL.md` |
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
---
## 強制觸發場景
以下場景**無論對話中是否出現觸發詞**,都必須主動載入對應 Skill
### 🔴 新增功能或頁面時
必須同時讀取:
1. **permission-management** — 設定權限
2. **ui-consistency** — 遵循 UI 規範
3. **activity-logging** — 若涉及 Model CRUD需加上操作紀錄
4. **e2e-testing** — 確認是否需要新增對應的 E2E 測試
### 🔴 新增或修改 Model 時
必須讀取:
1. **activity-logging**`tapActivity` 實作
2. **cross-module-communication** — 確認是否涉及跨模組引用
### 🔴 Git 操作時
必須讀取:
1. **git-workflows** — 分支命名與 commit 格式
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
必須讀取:
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
---
## 注意事項
> [!IMPORTANT]
> 即使你「記得」Skill 的大致內容,仍必須重新讀取 `SKILL.md`
> 因為 Skill 文件可能已經更新,且記憶中的內容可能不完整。

View File

@@ -0,0 +1,285 @@
---
name: 操作紀錄實作規範 (Activity Logging Skill)
description: 規範系統內 Activity Log 的實作標準,包含自動名稱解析、複雜單據合併記錄、與前端顯示優化。
---
# 操作紀錄實作規範 (Activity Logging Skill)
本技能定義了 Star ERP 系統中操作紀錄的最高實作標準,旨在確保每筆日誌都具有「高度可讀性」與「單一性」。
---
## 1. 啟用 Activity Log (Model 基本設定)
在 Model 中引用 `LogsActivity` trait 並實作 `getActivitylogOptions` 方法。
```php
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Product extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty() // ✅ 關鍵:只記錄有變動的欄位
->dontSubmitEmptyLogs(); // 若無變動則不記錄
}
}
```
---
## 2. `tapActivity` 實作規範 (Backend 核心)
### 2.1 型別宣告:統一使用 `Contracts\Activity`
```php
// ✅ 正確:使用介面
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
// ❌ 禁止:使用具體類別
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
```
### 2.2 必須 `toArray()` 避免 Indirect modification error
```php
// 🚩 核心:轉換為陣列以避免 Indirect modification error
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
// ... 操作 $properties ...
$activity->properties = $properties; // 最後整體回寫
```
### 2.3 Snapshot 快照策略
為確保資料被刪除後仍能辨識操作對象,**必須**在 `properties.snapshot` 中儲存關鍵識別資訊。
```php
$snapshot = $properties['snapshot'] ?? [];
$snapshot['doc_no'] = $this->doc_no; // 單號
$snapshot['name'] = $this->name; // 名稱
$snapshot['warehouse_name'] = $this->warehouse?->name; // 關聯名稱
$properties['snapshot'] = $snapshot;
```
### 2.4 全域 ID 轉名稱邏輯 (ID Resolution)
所有的 ID`warehouse_id`, `created_by`)在記錄時應自動解析為名稱。
#### 模組內 Model可直接查詢
```php
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
// 同模組內的 Model 可以直接查詢
foreach (['created_by', 'updated_by', 'completed_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name;
}
}
};
```
#### 跨模組 Model必須透過 Service Interface
> [!IMPORTANT]
> 依據跨模組通訊規範,若需解析其他模組的 ID例如在 `Procurement` 模組中解析 `warehouse_id`
> **禁止**直接 `Warehouse::find()`,必須透過 Service Interface。
```php
// ✅ 正確:透過 Service Interface 取得跨模組資料
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)
->getWarehouse($data['warehouse_id']);
$data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id'];
}
```
> [!NOTE]
> `Core` 模組的 `User`, `Role`, `Tenant` 屬於全域例外,其他模組可直接查詢。
> 詳見 [跨模組通訊規範](file:///home/mama/projects/star-erp/.agents/skills/cross-module-communication/SKILL.md)。
### 2.5 完整 `tapActivity` 範例(參考 PurchaseOrder
```php
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
// 🚩 轉換為陣列
$properties = $activity->properties instanceof \Illuminate\Support\Collection
? $activity->properties->toArray()
: $activity->properties;
// 1. Snapshot 快照
$snapshot = $properties['snapshot'] ?? [];
$snapshot['po_number'] = $this->code;
$snapshot['vendor_name'] = $this->vendor?->name;
$properties['snapshot'] = $snapshot;
// 2. ID 轉名稱
$resolver = function (&$data) {
if (empty($data) || !is_array($data)) return;
// 全域例外User 可直接查
foreach (['user_id', 'created_by', 'updated_by'] as $f) {
if (isset($data[$f]) && is_numeric($data[$f])) {
$data[$f] = \App\Modules\Core\Models\User::find($data[$f])?->name ?? $data[$f];
}
}
// 同模組:可直接查
if (isset($data['vendor_id']) && is_numeric($data['vendor_id'])) {
$data['vendor_id'] = Vendor::find($data['vendor_id'])?->name ?? $data['vendor_id'];
}
// 跨模組:必須透過 Service Interface
if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) {
$warehouse = app(\App\Modules\Inventory\Contracts\InventoryServiceInterface::class)
->getWarehouse($data['warehouse_id']);
$data['warehouse_id'] = $warehouse?->name ?? $data['warehouse_id'];
}
};
if (isset($properties['attributes'])) $resolver($properties['attributes']);
if (isset($properties['old'])) $resolver($properties['old']);
// 3. 合併 activityProperties (手動傳入的 items_diff 等)
if (!empty($this->activityProperties)) {
$properties = array_merge($properties, $this->activityProperties);
}
$activity->properties = $properties;
}
```
---
## 3. 複雜操作的日誌合併 (Log Consolidation)
當一個操作同時涉及「多個品項異動」與「單據狀態變更」時,**嚴禁**產生多筆重複日誌。
### 3.1 手動記錄必須自行過濾差異
```php
// ✅ 正確:自行比對差異,只存變動值
$changedAttributes = [];
$changedOldAttributes = [];
foreach ($newAttributes as $key => $value) {
if ($value != ($oldAttributes[$key] ?? null)) {
$changedAttributes[$key] = $value;
$changedOldAttributes[$key] = $oldAttributes[$key] ?? null;
}
}
if (!empty($changedAttributes)) {
activity()
->withProperties(['attributes' => $changedAttributes, 'old' => $changedOldAttributes])
->log('updated');
}
```
### 3.2 `saveQuietly()` + 手動日誌 合併策略
```php
DB::transaction(function () use ($doc, $items) {
// 1. 更新品項 (記錄變動細節)
$updatedItems = $this->getUpdatedItems($doc, $items);
// 2. 靜默更新單據狀態 (避免 Trait 產生冗餘日誌)
$doc->status = 'completed';
$doc->saveQuietly();
// 3. 手動觸發單一合併日誌
activity()
->performedOn($doc)
->withProperties([
'items_diff' => ['updated' => $updatedItems],
'attributes' => ['status' => 'completed'],
'old' => ['status' => 'counting']
])
->log('updated');
});
```
> [!WARNING]
> 使用 `saveQuietly()` 會繞過 Model Events如自動單號產生
> 若 Model 有 `creating`/`updating` 事件產生單號,需在 Service 中手動處理。
---
## 4. 後端 Controller 映射 (Subject Map)
新增 Model 時,必須同步在 `ActivityLogController::getSubjectMap()` 加入中文映射。
**位置**: `app/Modules/Core/Controllers/ActivityLogController.php`
```php
private function getSubjectMap()
{
return [
'App\Modules\Inventory\Models\Product' => '商品',
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
// ... 新增此行
];
}
```
---
## 5. 前端介面規範 (Frontend)
### 5.1 標籤命名規範 (Field Labels)
前端顯示應完全移除「ID」字眼提供最友善的閱讀體驗。
**位置**: `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx`
```typescript
const fieldLabels: Record<string, string> = {
warehouse_id: '倉庫', // ❌ 禁用「倉庫 ID」
created_by: '建立者', // ❌ 禁用「建立者 ID」
completed_by: '完成者',
status: '狀態',
// 新增 Model 的欄位翻譯 ...
};
```
### 5.2 `nameParams` 必須在兩處同步更新
> [!IMPORTANT]
> `nameParams``LogTable.tsx``ActivityDetailDialog.tsx` 中各有一份,
> 新增時**必須兩處同步更新**,否則會導致列表與詳情頁顯示不一致。
| 檔案 | 用途 |
|---|---|
| `resources/js/Components/ActivityLog/LogTable.tsx` | 列表頁的描述文字 |
| `resources/js/Components/ActivityLog/ActivityDetailDialog.tsx` | 對話框標題 |
### 5.3 特殊結構顯示
* **品項異動**:前端已能渲染 `items_diff` 結構,以「品項名稱 + 數值變動」方式呈現表格。
* **顯示過濾邏輯**(已內建於 `ActivityDetailDialog`
- **Created**: 顯示初始化欄位
- **Updated**: 僅顯示有變動的欄位 (`isChanged` 判斷)
- **Deleted**: 顯示刪除前的完整資料
---
## 6. 開發檢核清單 (Checklist)
- [ ] **Model**: 是否已設定 `logOnlyDirty` + `dontSubmitEmptyLogs`
- [ ] **Model**: `tapActivity` 型別是否使用 `Contracts\Activity`
- [ ] **Model**: `tapActivity` 是否已使用 `toArray()` 處理 Collection
- [ ] **Model**: 是否已實作 Snapshot關鍵識別資訊
- [ ] **Model**: ID 轉名稱是否遵守跨模組規範Core 例外,其餘需透過 Interface
- [ ] **Service**: 是否使用 `saveQuietly()` 搭配手動 `activity()` 避免重複日誌?
- [ ] **Controller**: `ActivityLogController::getSubjectMap()` 是否已新增 Model 中文映射?
- [ ] **UI**: `fieldLabels` 是否已新增欄位中文翻譯?
- [ ] **UI**: `nameParams` 是否已在 `LogTable``ActivityDetailDialog` 兩處同步?

View File

@@ -0,0 +1,137 @@
---
name: 跨模組調用與通訊規範 (Cross-Module Communication)
description: 規範 Laravel Modular Monolith 架構下不同業務模組中如何彼此調用資料與邏輯包含禁止項目、Interface 實作、與 Service 綁定規則。
---
# 跨模組調用與通訊規範 (Cross-Module Communication)
為了確保專案的「模組化單體架構 (Modular Monolith)」的獨立性與可維護性,當遇到**需要跨越不同業務模組存取資料或調用功能**的情境時,請嚴格遵守以下規範。
## 🚫 絕對禁止的行為 (Strict Prohibitions)
* **禁止跨模組 Eloquent 關聯(例外除外)**
* **禁止跨模組直接引入 (use) Model**
* **禁止跨模組直接實例化 (new) Service**
---
## 🌟 允許的全域例外 (Global Exceptions)
雖然我們嚴格禁止跨模組直接相依,但為了開發效率與框架機制的完整性,**`Core` 模組下的特定基礎設施模型 (Infrastructure Models) 被視為全域例外**。
其他業務模組 **可以** 透過 Eloquent (`belongsTo` / `hasMany`) 直接關聯以下 Model
1. **`App\Modules\Core\Models\User`**
2. **`App\Modules\Core\Models\Role`**
3. **`App\Modules\Core\Models\Tenant`**
> **⚠️ 注意**:這項例外是單向的。`Core` 模組內的業務邏輯(如 `DashboardController`**絕對不能**反過來直接 `use` 外部業務模組的 Model仍必須透過外部模組的 Service Interface 來索取資料。
---
## ✅ 正確的跨模組調用流程:合約與依賴反轉
所有的跨模組資料交換與功能調用,必須透過**介面化通訊 (Contracts)** 進行。
### Step 1: 在被調用的模組定義合約 (Interface)
如果 `Inventory` 模組需要提供功能給外部使用,請在 `app/Modules/Inventory/Contracts/` 建立 Interface 檔案。
```php
namespace App\Modules\Inventory\Contracts;
use Illuminate\Support\Collection;
interface InventoryServiceInterface
{
public function getActiveWarehouses(): Collection;
}
```
### Step 2: 實作介面並在自己模組的 ServiceProvider 註冊
`Inventory` 模組自己的 Service 來實作上述介面。
```php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Support\Collection;
class InventoryService implements InventoryServiceInterface
{
public function getActiveWarehouses(): Collection
{
return Warehouse::where('is_active', true)
->select(['id', 'name', 'code'])
->get();
}
}
```
然後進入 `app/Modules/Inventory/InventoryServiceProvider.php` 完成綁定:
```php
namespace App\Modules\Inventory;
use Illuminate\Support\ServiceProvider;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Services\InventoryService;
class InventoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
}
}
```
### Step 3: 調用方透過依賴注入 (DI) 使用服務
`Procurement` 模組需要取得倉庫資料時,必須透過**建構子注入**或**方法注入**取得 `InventoryServiceInterface`
```php
namespace App\Modules\Procurement\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Inertia\Inertia;
class PurchaseOrderController extends Controller
{
public function __construct(
protected InventoryServiceInterface $inventoryService
) {}
public function create()
{
$warehouses = $this->inventoryService->getActiveWarehouses();
return Inertia::render('Procurement/PurchaseOrder/Create', [
'warehouses' => $warehouses
]);
}
}
```
---
## ⚠️ 跨模組資料回傳的注意事項 (Data Hydration)
* **回傳純粹資料**:建議在 Service 中用 `with()` 載入好關聯,或者直接轉為原生的 Array 或有具體結構的 DTO避免依賴 Lazy Loading。
* **手動組合 (Manual Hydration)**:若某個頁面需要合併兩個模組的資料,必須在 Controller 層級呼叫兩個不同的 Service Interface 後,手動合併。
### 範例:手動合併資料
```php
// 正確示範:在各自模組取資料,並手動組裝
$orders = $this->orderService->getOrders();
$userIds = $orders->pluck('user_id')->unique()->toArray();
$users = $this->coreUserService->getUsersByIds($userIds)->keyBy('id');
$mergedData = $orders->map(function ($order) use ($users) {
// 將使用者資料手動附加上去
$order->user_name = $users->get($order->user_id)->name ?? 'Unknown';
return $order;
});
```

View File

@@ -0,0 +1,266 @@
---
name: E2E 端到端測試規範 (E2E Testing with Playwright)
description: 規範 Playwright 端到端測試的撰寫慣例、目錄結構、共用工具與執行方式,確保所有 E2E 測試保持一致性與可維護性。
---
# E2E 端到端測試規範 (E2E Testing with Playwright)
本技能定義了 Star ERP 系統中端到端 (E2E) 測試的實作標準,使用 Playwright 模擬真實使用者操作瀏覽器,驗證 UI 顯示與功能流程的正確性。
---
## 1. 專案結構
### 1.1 目錄配置
```
star-erp/
├── playwright.config.ts # Playwright 設定檔
├── e2e/ # E2E 測試根目錄
│ ├── helpers/ # 共用工具函式
│ │ └── auth.ts # 登入 helper
│ ├── screenshots/ # 測試截圖存放
│ ├── auth.spec.ts # 認證相關測試(登入、登出)
│ ├── inventory.spec.ts # 庫存模組測試
│ ├── products.spec.ts # 商品模組測試
│ └── {module}.spec.ts # 依模組命名
├── playwright-report/ # HTML 測試報告(自動產生,已 gitignore
└── test-results/ # 失敗截圖與錄影(自動產生,已 gitignore
```
### 1.2 命名規範
| 項目 | 規範 | 範例 |
|---|---|---|
| 測試檔案 | 小寫,依模組命名 `.spec.ts` | `inventory.spec.ts` |
| 測試群組 | `test.describe('中文功能名稱')` | `test.describe('庫存查詢')` |
| 測試案例 | 中文描述「**應**」開頭 | `test('應顯示庫存清單')` |
| 截圖檔案 | `{module}-{scenario}.png` | `inventory-search-result.png` |
---
## 2. 設定檔 (playwright.config.ts)
### 2.1 核心設定
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:8081', // Sail 開發伺服器
screenshot: 'only-on-failure', // 失敗時自動截圖
video: 'retain-on-failure', // 失敗時保留錄影
trace: 'on-first-retry', // 重試時收集 trace
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
```
### 2.2 重要注意事項
> [!IMPORTANT]
> `baseURL` 必須指向本機 Sail 開發伺服器(預設 `http://localhost:8081`)。
> 確保測試前已執行 `./vendor/bin/sail up -d``./vendor/bin/sail npm run dev`
---
## 3. 共用工具 (Helpers)
### 3.1 登入 Helper
位置:`e2e/helpers/auth.ts`
```typescript
import { Page } from '@playwright/test';
/**
* 共用登入函式
* 使用測試帳號登入 ERP 系統
*/
export async function login(page: Page, username = 'mama', password = 'mama9453') {
await page.goto('/');
await page.fill('#username', username);
await page.fill('#password', password);
await page.getByRole('button', { name: '登入系統' }).click();
// 等待儀表板載入完成
await page.waitForSelector('text=系統概況', { timeout: 10000 });
}
```
### 3.2 使用方式
```typescript
import { login } from './helpers/auth';
test('應顯示庫存清單', async ({ page }) => {
await login(page);
await page.goto('/inventory/stock-query');
// ...斷言
});
```
---
## 4. 測試撰寫規範
### 4.1 測試結構模板
```typescript
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
test.describe('模組功能名稱', () => {
// 若整個 describe 都需要登入,使用 beforeEach
test.beforeEach(async ({ page }) => {
await login(page);
});
test('應正確顯示頁面標題與關鍵元素', async ({ page }) => {
await page.goto('/target-page');
// 驗證頁面標題
await expect(page.getByText('頁面標題')).toBeVisible();
// 驗證表格存在
await expect(page.locator('table')).toBeVisible();
});
test('應能執行 CRUD 操作', async ({ page }) => {
// ...
});
});
```
### 4.2 斷言 (Assertions) 慣例
| 場景 | 優先使用 | 避免使用 |
|---|---|---|
| 驗證頁面載入 | `page.getByText('關鍵文字')` | `page.waitForURL()` ※ |
| 驗證元素存在 | `expect(locator).toBeVisible()` | `.count() > 0` |
| 驗證表格資料 | `page.locator('table tbody tr')` | 硬編碼行數 |
| 等待操作完成 | `expect().toBeVisible({ timeout })` | `page.waitForTimeout()` |
> [!NOTE]
> ※ Star ERP 使用 Inertia.js頁面導航不一定改變 URL例如儀表板路由為 `/`)。
> 因此**優先使用頁面內容驗證**,而非依賴 URL 變化。
### 4.3 選擇器優先順序
依照 Playwright 官方建議,選擇器優先順序為:
1. **Role**`page.getByRole('button', { name: '登入系統' })`
2. **Text**`page.getByText('系統概況')`
3. **Label**`page.getByLabel('帳號')`
4. **Placeholder**`page.getByPlaceholder('請輸入...')`
5. **Test ID**`page.getByTestId('submit-btn')`(需在元件加 `data-testid`
6. **CSS**`page.locator('#username')`(最後手段)
### 4.4 禁止事項
```typescript
// ❌ 禁止:硬等待(不可預期的等待時間)
await page.waitForTimeout(5000);
// ✅ 正確:等待特定條件
await expect(page.getByText('操作成功')).toBeVisible({ timeout: 5000 });
// ❌ 禁止:在測試中寫死測試資料的 ID
await page.goto('/products/42/edit');
// ✅ 正確:從頁面互動導航
await page.locator('table tbody tr').first().getByRole('button', { name: '編輯' }).click();
```
---
## 5. 截圖與視覺回歸
### 5.1 手動截圖(文件用途)
```typescript
// 成功截圖存於 e2e/screenshots/
await page.screenshot({
path: 'e2e/screenshots/inventory-list.png',
fullPage: true,
});
```
### 5.2 視覺回歸測試(偵測 UI 變化)
```typescript
test('庫存頁面 UI 應保持一致', async ({ page }) => {
await login(page);
await page.goto('/inventory/stock-query');
// 比對截圖pixel 級差異會報錯
await expect(page).toHaveScreenshot('stock-query.png', {
maxDiffPixelRatio: 0.01, // 容許 1% 差異(動態資料)
});
});
```
> [!NOTE]
> 首次執行 `toHaveScreenshot()` 會自動建立基準截圖。
> 後續執行會與基準比對,更新基準用:`npx playwright test --update-snapshots`
---
## 6. 執行指令速查
```bash
# 執行所有 E2E 測試
npx playwright test
# 執行特定模組測試
npx playwright test e2e/login.spec.ts
# UI 互動模式(可視化瀏覽器操作)
npx playwright test --ui
# 帶頭模式(顯示瀏覽器畫面)
npx playwright test --headed
# 產生 HTML 報告並開啟
npx playwright test --reporter=html
npx playwright show-report
# 更新視覺回歸基準截圖
npx playwright test --update-snapshots
# 只執行特定測試案例(用 -g 篩選名稱)
npx playwright test -g "登入"
# Debug 模式(逐步執行)
npx playwright test --debug
```
---
## 7. 開發檢核清單 (Checklist)
### 新增頁面或功能時:
- [ ] 是否已為新頁面建立對應的 `.spec.ts` 測試檔?
- [ ] 測試是否覆蓋主要的 Happy Path正常操作流程
- [ ] 測試是否覆蓋關鍵的 Error Path錯誤處理
- [ ] 共用的登入步驟是否使用 `helpers/auth.ts`
- [ ] 斷言是否優先使用頁面內容而非 URL
- [ ] 選擇器是否遵循優先順序Role > Text > Label > CSS
- [ ] 測試是否可獨立執行(不依賴其他測試的狀態)?
### 提交程式碼前:
- [ ] 全部 E2E 測試是否通過?(`npx playwright test`
- [ ] 是否有遺留的 `test.only``test.skip`

View File

@@ -0,0 +1,51 @@
---
name: Git 分支管理與開發規範 (Git Workflow)
description: 規範開發過程中的 Git 分支架構、合併限制、環境部署流程以及提交訊息格式。
---
# Git 分支管理與開發規範 (Git Workflow)
為了確保系統穩定性與發布紀律,所有開發者與 AI 助手必須嚴格遵守以下環境發布流程與時段限制。
## 1. 分支架構與環境定義
| 分支 | 環境 | 用途描述 | 合併來源 |
| :--- | :--- | :--- | :--- |
| **`dev`** | 本機開發 | 日常開發與功能實作。 | `feature/*` |
| **`demo`** | 測試/預佈署 | 鏡像生產環境。用於正式上線前的最終驗證。 | `dev` |
| **`main`** | 生產環境 | 正式版本分支。僅存放透過 `demo` 驗證後的代碼。 | `demo` |
## 2. 發布時段與約束 (Release Window)
### Main 分支發布限制 (Mandatory)
1. **強制規範**:若執行推送/合併指令時未明確包含目標分支,**嚴禁** 自行預設或推論為 `main`。我必須先詢問使用者:「請問要推送到哪一個目標分支?(dev / demo / main)」。
2. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。
3. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`
- AI 助手**必須攔截並主動提示風險**(例如:週末災難風險)。
- 必須取得使用者明確書面同意(如:「我確定現在要上線」)方可執行。
4. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。
## 3. 開發與修復流程 (SOP)
### 標準開發流程
1. `feature/*` -> `dev` (隨時合併,主要測試點)。
2. `dev` -> `demo` (隨時合併,進行類生產環境測試)。
3. `demo` -> `main` (僅限允許時段進行,正式上線)。
### 緊急修復流程 (Hotfix)
1. 直接從 `main` 建立 `hotfix/*` 分支進行修復。
2. 修復完成並通過測試後合併回 `main`
3. **重要同步**:修復後的程式碼必須立即合併回 `demo``dev`,確保各環境修復同步。
## 4. 提交訊息規範 (Commit Messages)
提交訊息必須包含以下前綴:
- `[FIX]`:修復 Bug。
- `[FEAT]`:新增功能。
- `[DOCS]`:文件更新。
- `[STYLE]`UI/格式調整。
- `[REFACTOR]`:重構。
---
> [!IMPORTANT]
> 身為 AI 助手 (Antigravity),我會監控合併對象與當前時間。若您的命令涉及合併至 `main` 且不在允許時段內,我會優先進行安全提醒。

View File

@@ -0,0 +1,206 @@
---
name: 權限管理與實作規範
description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
---
# 權限管理與實作規範
本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
---
## 1. 定義權限 (Backend Seeder)
所有權限皆定義於 `database/seeders/PermissionSeeder.php`
### 步驟:
1. 開啟 `database/seeders/PermissionSeeder.php`
2. 在 `$permissions` 關聯陣列中新增功能對應的權限。
* **命名慣例**`{resource}.{action}`(例如:`system.view_logs`, `products.create`
* **格式**`'權限字串' => '中文動作名稱'`
* 常用動作:`view`, `create`, `edit`, `delete`, `approve`, `cancel`, `export`
3. 在下方「角色分配」區段,將新權限分配給適合的角色。
### 範例:
```php
// 1. 新增權限(注意:是 key => value 格式)
$permissions = [
// ... 現有權限
'utility_fees.view' => '檢視',
'utility_fees.create' => '建立',
'utility_fees.edit' => '編輯',
'utility_fees.delete' => '刪除',
];
// 2. 分配給角色
$admin->givePermissionTo([
// ... 現有權限
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
]);
```
### 現有角色定義:
| 角色 | 說明 | 權限範圍 |
|---|---|---|
| `super-admin` | 系統管理員 | 自動擁有所有權限(`Permission::all()` |
| `admin` | 一般管理員 | 大部分權限(除角色管理外) |
| `warehouse-manager` | 倉庫管理員 | 庫存、盤點、調撥、進貨、門市叫貨 |
| `purchaser` | 採購人員 | 商品檢視、採購單、退貨、供應商、進貨 |
| `viewer` | 檢視人員 | 僅限各模組的 `.view` 權限 |
---
## 2. 套用資料庫變更 (Multi-tenancy)
修改 Seeder 後,必須在**中央與所有租戶**同步執行。
```bash
# 對所有租戶執行 Seeder
./vendor/bin/sail php artisan tenants:seed --class=PermissionSeeder
```
> [!WARNING]
> 僅執行 `db:seed` 只會更新中央資料庫。務必使用 `tenants:seed` 確保所有租戶同步。
---
## 3. 路由保護 (Backend Middleware)
路由保護定義在各模組自己的 `app/Modules/{ModuleName}/Routes/web.php` 中。
> [!IMPORTANT]
> 路由檔在各模組內(如 `app/Modules/Finance/Routes/web.php`**不是**全域的 `routes/web.php`
### 範例:
```php
// 單一權限保護
Route::middleware('permission:utility_fees.view')->group(function () {
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
Route::get('/utility-fees/{utilityFee}', [UtilityFeeController::class, 'show'])->name('utility-fees.show');
});
// 巢狀權限群組
Route::middleware('permission:utility_fees.create')->group(function () {
Route::get('/utility-fees/create', [UtilityFeeController::class, 'create'])->name('utility-fees.create');
Route::post('/utility-fees', [UtilityFeeController::class, 'store'])->name('utility-fees.store');
});
// 單行 middleware
Route::delete('/utility-fees/{utilityFee}', [UtilityFeeController::class, 'destroy'])
->middleware('permission:utility_fees.delete')
->name('utility-fees.destroy');
```
---
## 4. 配置權限群組名稱 (Backend UI Config)
為了讓新權限在「角色與權限」管理介面中正確分組並顯示中文標題,需修改 Controller。
**位置**: `app/Modules/Core/Controllers/RoleController.php``getGroupedPermissions()`
```php
$groupDefinitions = [
'products' => '商品資料管理',
'warehouses' => '倉庫管理',
'inventory' => '庫存資料管理',
// ...
'utility_fees' => '公共事業費管理', // ✅ 新增此行
];
```
> [!NOTE]
> 未加入 `$groupDefinitions` 的權限群組仍會顯示,但標題會以原始 key英文呈現。
---
## 5. 前端權限判斷 (React)
### 5.1 方式一:`usePermission` Hook在邏輯中判斷
**位置**: `resources/js/hooks/usePermission.ts`
```tsx
import { usePermission } from "@/hooks/usePermission";
export default function ProductIndex() {
const { can, canAny, isSuperAdmin } = usePermission();
return (
<div>
{can('products.create') && <Button>新增商品</Button>}
{canAny(['products.edit', 'products.delete']) && <ManageDropdown />}
</div>
);
}
```
#### Hook 完整介面:
| 方法 | 說明 |
|---|---|
| `can(permission)` | 檢查是否擁有**指定**權限 |
| `canAny(permissions[])` | 檢查是否擁有**任一**權限 |
| `canAll(permissions[])` | 檢查是否擁有**所有**權限 |
| `hasRole(role)` | 檢查是否擁有**指定**角色 |
| `hasAnyRole(roles[])` | 檢查是否擁有**任一**角色 |
| `hasAllRoles(roles[])` | 檢查是否擁有**所有**角色 |
| `isSuperAdmin()` | 是否為超級管理員 |
> 所有方法對 `super-admin` 角色自動回傳 `true`
### 5.2 方式二:`<Can>` / `<HasRole>` / `<CanAll>` 元件(在 JSX 中包裹)
**位置**: `resources/js/Components/Permission/Can.tsx`
```tsx
import { Can, HasRole, CanAll } from '@/Components/Permission/Can';
// 單一權限
<Can permission="products.create">
<Button>新增商品</Button>
</Can>
// 任一權限OR 邏輯)
<Can permission={['products.edit', 'products.delete']}>
<ManageDropdown />
</Can>
// 所有權限都必須有AND 邏輯)
<CanAll permissions={['products.edit', 'products.delete']}>
<Button>完整管理</Button>
</CanAll>
// 角色判斷
<HasRole role="admin">
<Link href="/admin">管理後台</Link>
</HasRole>
// Fallback 支援
<Can permission="products.delete" fallback={<span className="text-gray-400">無權限</span>}>
<Button variant="destructive">刪除</Button>
</Can>
```
> [!IMPORTANT]
> UI 規範要求:所有可操作按鈕(新增、編輯、刪除)**必須**包裹 `<Can>` 元件或使用 `can()` 判斷。
> 詳見 [UI 統一規範](file:///home/mama/projects/star-erp/.agents/skills/ui-consistency/SKILL.md)。
---
## 6. 開發檢核清單 (Checklist)
### 後端
- [ ] `PermissionSeeder.php` 已新增權限字串(`'key' => '中文動作名稱'` 格式)。
- [ ] `PermissionSeeder.php` 已將新權限分配給 `admin` 及其他適用角色。
- [ ] 已執行 `./vendor/bin/sail php artisan tenants:seed --class=PermissionSeeder` 同步所有租戶。
- [ ] `RoleController.php``$groupDefinitions` 已新增權限群組中文名稱。
- [ ] 模組路由 (`app/Modules/{ModuleName}/Routes/web.php`) 已加上 `middleware('permission:...')` 保護。
### 前端
- [ ] 頁面按鈕已使用 `usePermission` Hook 或 `<Can>` 元件進行權限控制。
- [ ] 所有可操作按鈕都包裹於權限判斷中(符合 UI 統一規範)。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
---
description: 將目前的變更提交並推送至指定的遠端分支 (遵守專案規範)
---
# 快速推送工作流 (now-push)
本工作流旨在規範化 Git 提交與推送流程,確保符合專案的開發規範 (繁體中文、規範前綴) 與發布紀律 (Release Window)。
## 執行步驟
1. **讀取規範 (Mandatory)**
在執行任何 Git 操作前,**必須** 先讀取 Git 分支管理與開發規範:
`view_file` -> [Git SKILL.md](file:///home/mama/projects/star-erp/.agents/skills/git-workflows/SKILL.md)
2. **檢查與準備**
- 執行 `git status` 檢查目前工作目錄。
- 根據 **SKILL.md** 的規範撰寫繁體中文提交訊息。
3. **目標分支安全檢查**
- 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。
- 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`
- **【最嚴格限制】**`main` 分支的程式碼**只能**, **必須**從 `demo` 分支合併而來。絕對禁止將 `dev` (或 `feature/*`) 直接合併進 `main`
4. **執行推送 (Push) 與嚴格合併鏈路**
- **若目標為 `dev`**:直接 `git push origin [目前分支]:dev` 或 commit 後 merge 到 dev。
- **若目標為 `demo`**:必須先確保變更已在 `dev` 且無衝突,然後 `git checkout demo && git merge dev && git push origin demo`
- **若目標為 `main`**
必須確保變更已經依照順序通過前置環境,嚴格執行以下流程(缺一不可):
1. `git checkout dev && git merge [目前分支] && git push origin dev`
2. `git checkout demo && git merge dev && git push origin demo`
3. `git checkout main && git merge demo && git push origin main`
*(就算遭遇衝突,也必須在對應的分支上解衝突,絕對不可略過 `demo` 直接 `dev -> main`)*
5. **後續同步 (針對 Hotfix)**
- 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」:若有從 main 開出來的 hotfix 分支直接併回 main 的例外情況(需使用者明確指示),**必須**同步將 main 分支 merge 回 `demo``dev` 分支,維持全環境版本一致。

View File

@@ -1,8 +1,14 @@
APP_NAME=KooriERP
APP_NAME=StarERP
COMPOSE_PROJECT_NAME=star-erp
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_VERSION=v1.0.0
# Multi-tenancy 設定 (用逗號分隔多個中央網域)
CENTRAL_DOMAINS=localhost,127.0.0.1
TENANT_DEFAULT_DOMAIN=star-erp.test
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -23,7 +29,7 @@ LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=koori_erp
DB_DATABASE=star_erp
DB_USERNAME=sail
DB_PASSWORD=password
FORWARD_DB_PORT=3307
@@ -38,7 +44,7 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_STORE=redis
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1

View File

@@ -0,0 +1,101 @@
name: ERP-Deploy-Demo
on:
push:
branches:
- demo
jobs:
deploy-demo:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
github-server-url: https://gitea.taiwan-star.com.tw
repository: ${{ github.repository }}
- name: Step 1 - Push Code to Demo
run: |
apt-get update && apt-get install -y rsync openssh-client
mkdir -p ~/.ssh
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
chmod 600 ~/.ssh/id_rsa_demo
rsync -avz --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='vendor' \
--exclude='storage' \
--exclude='.env' \
--exclude='public/build' \
-e "ssh -p 2227 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
./ root@220.132.7.82:/var/www/star-erp-demo/
rm ~/.ssh/id_rsa_demo
- name: Step 2 - Check if Rebuild Needed
id: check_rebuild
uses: appleboy/ssh-action@master
with:
host: 220.132.7.82
port: 2227
username: root
key: ${{ secrets.DEMO_SSH_KEY }}
script: |
cd /var/www/star-erp-demo
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
echo "REBUILD_NEEDED=true"
else
echo "REBUILD_NEEDED=false"
fi
- name: Step 3 - Container Up & Health Check
uses: appleboy/ssh-action@master
with:
host: 220.132.7.82
port: 2227
username: root
key: ${{ secrets.DEMO_SSH_KEY }}
script: |
cd /var/www/star-erp-demo
chown -R 1000:1000 .
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --build --wait
else
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
echo "容器未運行,正在啟動..."
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --wait
else
echo "容器已運行,跳過 docker compose直接進行程式碼部署..."
fi
fi
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
- name: Step 4 - Composer & NPM Build
uses: appleboy/ssh-action@master
with:
host: 220.132.7.82
port: 2227
username: root
key: ${{ secrets.DEMO_SSH_KEY }}
script: |
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
composer install --no-dev --optimize-autoloader --no-interaction &&
npm install &&
npm run build &&
rm -f public/hot &&
php artisan storage:link &&
php artisan migrate --force &&
php artisan tenants:migrate --force &&
php artisan db:seed --force &&
php artisan tenants:run db:seed --option=\"class=PermissionSeeder\" --option=\"force=true\" &&
php artisan tenants:run db:seed --option=\"class=SystemSettingSeeder\" --option=\"force=true\" &&
php artisan permission:cache-reset &&
php artisan optimize:clear &&
php artisan optimize &&
php artisan view:cache
"
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache

View File

@@ -0,0 +1,94 @@
name: ERP-Deploy-Production
on:
push:
branches:
- main
jobs:
deploy-production:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
repository: ${{ github.repository }}
- name: Step 1 - Push Code to Production
run: |
apt-get update && apt-get install -y rsync openssh-client
mkdir -p ~/.ssh
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_rsa_prod
chmod 600 ~/.ssh/id_rsa_prod
rsync -avz --delete \
--exclude='.git' \
--exclude='.env' \
--exclude='node_modules' \
--exclude='vendor' \
--exclude='storage' \
--exclude='public/build' \
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
./ root@220.132.7.82:/var/www/star-erp/
rm ~/.ssh/id_rsa_prod
- name: Step 2 - Check if Rebuild Needed
id: check_rebuild_prod
uses: appleboy/ssh-action@master
with:
host: 220.132.7.82
port: 2224
username: root
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/star-erp
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
echo "REBUILD_NEEDED=true"
else
echo "REBUILD_NEEDED=false"
fi
- name: Step 3 - Container Up & Health Check
uses: appleboy/ssh-action@master
with:
host: 220.132.7.82
port: 2224
username: root
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/star-erp
chown -R 1000:1000 .
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.prod\.yaml|docker-compose\.yaml)'; then
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.prod.yaml up -d --build --wait
else
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
echo "容器未運行,正在啟動..."
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
else
echo "容器已運行,跳過 docker compose直接進行程式碼部署..."
fi
fi
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
composer install --no-dev --optimize-autoloader &&
npm install &&
npm run build &&
rm -f public/hot
php artisan storage:link &&
php artisan migrate --force &&
php artisan tenants:migrate --force &&
php artisan db:seed --force &&
php artisan tenants:run db:seed --option=\"class=PermissionSeeder\" --option=\"force=true\" &&
php artisan tenants:run db:seed --option=\"class=SystemSettingSeeder\" --option=\"force=true\" &&
php artisan permission:cache-reset &&
php artisan optimize:clear &&
php artisan optimize &&
php artisan view:cache
"
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache

View File

@@ -1,52 +0,0 @@
name: Koori-ERP-Sync-Only
on:
push:
branches:
- main
jobs:
sync-update:
runs-on: ubuntu-latest
steps:
- name: 1. Checkout New Code
uses: actions/checkout@v3
with:
github-server-url: http://192.168.0.103:3000
repository: ${{ gitea.repository }}
- name: 2. Sync Files to Running Container
run: |
# B. 執行複製
cp .env.example .env
sed -i "s|^APP_KEY=.*|APP_KEY=${{ secrets.APP_KEY }}|g" .env
docker compose up -d --build --force-recreate --wait
# 這一步最關鍵!它把剛抓下來的新 Code 塞進去「正在跑」的容器
# 它不會刪掉你的 .env因為它是用 tar 覆蓋進去
tar -cf - . | docker exec -i koori-erp-laravel.test-1 tar -xf - -C /var/www/html
- name: 3. Refresh Backend (Only if needed)
run: |
# 執行 Composer (確保你有加新套件的話會抓到)
docker exec -w /var/www/html koori-erp-laravel.test-1 composer install --optimize-autoloader
# 同步資料庫欄位 (如果你有改 Migration 的話)
docker exec koori-erp-laravel.test-1 php artisan migrate --force
- name: 4. Build New React Assets
run: |
# 既然你改了前端,就要在容器內重新跑一次打包
docker exec -w /var/www/html koori-erp-laravel.test-1 npm install
docker exec -w /var/www/html koori-erp-laravel.test-1 npm run build
- name: 5. Final Permission Fix
run: |
# 統一修正權限
docker exec koori-erp-laravel.test-1 chown -R 1000:1000 /var/www/html
docker exec koori-erp-laravel.test-1 chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
- name: 6. Clear Old Cache
run: |
# 讓 Laravel 重新讀取最新的路由與設定
docker exec koori-erp-laravel.test-1 php artisan view:clear
docker exec koori-erp-laravel.test-1 php artisan config:clear
docker exec koori-erp-laravel.test-1 php artisan route:clear

21
.gitignore vendored
View File

@@ -18,7 +18,28 @@
/public/storage
/storage/*.key
/storage/pail
/storage/tenant*
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
酒水客戶導入規劃.md
智慧補貨系統分析報告.md
/docs/pptx_build
/docs/presentation
docs/Monthly_Report_2026_01.pptx
docs/f6_1770350984272.xlsx
公共事業費-描述.md
.gitignore
BOM表自動計算成本.md
公共事業費-類別維護.md
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
e2e/screenshots/

211
README.md
View File

@@ -1,81 +1,182 @@
# Koori ERP
# Star ERP (Koori ERP)
本專案是一個基於 Laravel 12, Inertia.js (React) 與 Tailwind CSS 開發的 ERP 系統。
Star ERP 是一個基於 **Laravel 12**、**Inertia.js (React)****Tailwind CSS** 構建的現代化多租戶 ERP 系統。
本專案專為高效能、SaaS 架構設計,並預設配置了完整的 Docker 開發環境。
## 開發環境需求
## 🌟 專案架構
- **WSL2** (Windows 建議環境)
- **Docker Desktop** 或 **Docker Engine**
- **PHP 8.5+** (本地端若需執行基礎 composer 指令,或直接使用 Sail 容器)
- **Node.js 20+**
- **核心框架**: Laravel 12 (PHP 8.5)
- **多租戶引擎**: stancl/tenancy (Single Database per Tenant)
- **前端架構**: React 18, Inertia.js (單體式/Monolith)
- **UI 框架**: Tailwind CSS
- **基礎設施**: Docker (Laravel Sail), Nginx Reverse Proxy, MySQL 8.0, Redis
## 啟動步驟
## 📂 系統功能詳細說明
本專案使用 [Laravel Sail](https://laravel.com/docs/12.x/sail) 作為 Docker 開發環境。
### 🌳 預計系統功能架構樹 (含 2.0 升級規劃)
```text
Star ERP
├── 🏠 儀表板 (Dashboard)
│ ├── 📊 數據看板 (原有)
│ ├── 🔔 營運警示 (原有)
│ ├── ✨ 銷售熱力圖 (新)
│ ├── ✨ 庫存效期預警 (新)
│ └── ✨ 待出貨監控 (新)
├── ✨ 🤝 銷售與全通路 (Sales & CRM) 【New】
│ ├── ✨ 全通路訂單整合
│ ├── ✨ 客戶管理 (CRM)
│ └── ✨ 促銷活動
├── 📦 商品與庫存管理
│ ├── 📄 商品資料 (原有)
│ ├── 🏢 倉庫管理 (原有)
│ ├── 🚚 內調撥 (原有)
│ ├── ✨ 屬性管理 (過敏原/成分)
│ ├── ✨ 效期監控 (FEFO)
│ └── ✨ 智慧補貨建議 (AI)
├── ✨ 🚚 智慧物流 (Logistics) 【New】
│ ├── ✨ 路徑規劃
│ └── ✨ 裝車單管理
├── 🏭 生產與品質管理
│ ├── 📝 生產工單 (原有)
│ ├── 🧪 原料耗用 (原有)
│ ├── ✨ 配方管理 (Recipe)
│ ├── ✨ 品質檢驗 (QC)
│ └── ✨ 雙向溯源 (原料<->成品)
├── 🛒 採購與廠商
│ ├── 👥 廠商資料 (原有)
│ ├── 📝 採購單 (原有)
│ └── ✨ 供應商評鑑 (新)
├── 💰 財務管理
│ ├── 🧾 公共事業費 (原有)
│ ├── ✨ 應收/應付帳款 (AR/AP)
│ └── ✨ 成本精算 (料工費)
├── 📊 報表管理
│ └── 📑 會計報表 (原有)
└── ⚙️ 系統管理 (原有)
├── 👤 使用者管理
├── 🛡️ 角色與權限
└── 📜 操作紀錄
```
### 1. 安裝依賴 (初次啟動)
---
建立目錄mkdir 檔案名稱 && cd 檔案名稱
#### 1. 🏠 儀表板 (Dashboard)
- **數據看板**:顯示商品總數、供應商數、活躍倉庫數等。
- **營運警示**:低庫存商品與待辦事項警示。
- **✨ 強化功能**:銷售熱力圖、庫存效期預警、待出貨監控。
抓取代碼git clone http://git網址/帳號/專案.git .
#### 2. ✨ 🤝 銷售與全通路 (Sales & CRM)
- **全通路訂單**:整合 POS、品牌電商、智慧販賣機訂單。
- **客戶管理 (CRM)**:會員資料庫、消費歷史與等級。
- **促銷活動**:滿額折、買一送一、組合價等折扣引擎。
如果您是第一次 clone 專案,請先安裝 PHP 與 JS 依賴:
#### 3. 📦 商品與庫存管理
- **商品資料**:品名、規格、多單位換算。
- **倉庫管理**:多站點庫存監控、銷售設定。
- **內調撥**:倉庫間庫存轉移。
- **✨ 強化功能**:過敏原/成分管理、**FEFO (先到期先出)** 效期監控、AI 智慧補貨建議。
#### 4. 🏭 生產與品質管理
- **生產工單**:排程管理、生產入庫。
- **✨ 強化功能**:配方管理 (Recipe V.C.)、QC 檢驗流程 (IQC/IPQC/FQC)、**雙向溯源** (原料 <-> 成品)。
#### 5. 🛒 採購與廠商
- **採購單**:詢價、下單、收貨與驗收流程。
- **✨ 強化功能**:供應商評鑑系統。
#### 6. 💰 財務管理
- **公共事業費**:水電氣網等固定支出。
- **✨ 強化功能**:應收/應付帳款 (AR/AP) 管理、**成本精算** (料工費分攤)。
#### 7. ⚙️ 系統管理
- **使用者與權限**RBAC 細緻權限控管。
- **操作紀錄**:全系統關鍵行為軌跡 (Audit Log)。
## 🚀 快速開始
### 1. 環境準備
請確保您的開發環境已安裝:
- Docker Desktop 或 Docker Engine
- Git
- WSL2 (Windows 用戶建議)
### 2. 初始化專案
```bash
# 1. 下載專案
git clone <repository_url> star-erp
cd star-erp
# 初始化 .env 檔案
# 2. 設定環境變數
cp .env.example .env
# 請檢查 .env 內容,本機開發預設配置:
# APP_PORT=8080 (總後台)
# DEMO_TENANT_PORT=8081 (租戶測試)
# VITE_PORT=5174
```
### 2. 啟動 Docker 容器
在專案根目錄執行:
```bash
# 背景執行容器
# 3. 啟動容器
docker compose up -d --build
docker exec -it koori-erp-laravel.test-1 composer install
# 生成 App Key
docker exec -it koori-erp-laravel.test-1 php artisan key:generate
```
### 3. 資料庫遷移與初始化
### 3. 安裝依賴與初始化
```bash
# (選填) 如果有種子資料
docker exec -it koori-erp-laravel.test-1 php artisan migrate --seed
# 安裝 PHP 依賴
docker exec -it star-erp-laravel composer install
# 生成 Application Key
docker exec -it star-erp-laravel php artisan key:generate
# 執行資料庫遷移與種子資料 (建立基礎表格)
docker exec -it star-erp-laravel php artisan migrate --seed
# 安裝與編譯前端資源
docker exec -it star-erp-laravel npm install
docker exec -it star-erp-laravel npm run dev
```
### 4. 啟動前端開發伺服器 (Vite)
## 🌐 服務訪問 (開發與 Demo 模式)
本專案使用獨立的 Nginx 容器 (`star-erp-proxy`) 進行反向代理,以模擬多租戶環境的分流。
| 服務 | URL | 說明 |
| --- | --- | --- |
| **總後台 (Landlord)** | `http://localhost:8080` | 中央管理介面,用於新增與管理租戶 |
| **租戶演示 (Demo)** | `http://localhost:8081` | 模擬租戶環境,預設存取 `koori` 租戶 |
| **Vite HMR** | `http://localhost:5174` | 前端開發熱更新服務 |
> **開發小撇步**:為了方便測試,本機與 Demo 環境啟用了 `DEMO_TENANT_PORT=8081` 功能,允許透過端口直接識別租戶,無需修改 hosts 檔案。
## 🏢 正式環境運作流程
在正式環境 (Production) 下,系統採用標準的 **域名識別 (Domain Identification)** 模式:
1. **進入總後台**:透過中央域名登入 (如 `admin.star-erp.com`)。
2. **新增租戶**:在總後台建立新租戶 (例如 ID: `client-a`),並綁定專屬域名 (如 `erp.client-a.com`)。
3. **DNS 設定**:在 DNS 服務商將該租戶域名 (CNAME 或 A 紀錄) 指向伺服器 IP。
4. **訪問**:使用者直接瀏覽 `http://erp.client-a.com`,系統會自動切換至該租戶的專屬資料庫。
## 🛠 常用指令
```bash
docker exec -it koori-erp-laravel.test-1 npm install
docker exec -it koori-erp-laravel.test-1 npm run build
# 進入 Laravel 容器 Shell
docker exec -it star-erp-laravel bash
# 清除快取 (Config/Route/View) - 修改 .env 後建議執行
docker exec -it star-erp-laravel php artisan optimize:clear
# 執行 Tinker (互動式 Shell)
docker exec -it star-erp-laravel php artisan tinker
# 停止容器
docker compose down
```
## 🧪 開發規範
啟動後,您可以透過以下連結瀏覽專案:
- **後台網址**: [http://localhost](http://localhost)
- **Vite 伺服器**: [http://localhost:5174](http://localhost:5174)
## 常用 Sail 指令
- **停止服務**: `./vendor/bin/sail stop`
- **執行 Artisan 指令**: `./vendor/bin/sail artisan ...`
- **執行 Composer 指令**: `./vendor/bin/sail composer ...`
- **執行測試**: `./vendor/bin/sail test`
## 技術棧
- **Backend**: Laravel 12
- **Frontend**: React (Functional Components) via Inertia.js
- **Styling**: Tailwind CSS
- **Database**: MySQL 8.0
- **Cache/Session**: Redis
## 開發規範
請參考專案內的開發文件或 AI 指導規則,確保 UI/UX 元件與後端邏輯符合專案架構。
- **後端**: Follow Laravel 12 最佳實踐,使用 Service/Action 模式處理複雜邏輯。
- **前端**: React Functional Components + Hooks。UI 元件位於 `resources/js/Components`
- **樣式**: 全面使用 Tailwind CSS避免手寫 CSS。
- **多租戶**:
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Console\Commands;
use App\Modules\Core\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 將現有資料遷移到租戶資料庫
*
* 此指令用於初次設定多租戶時,將現有的 ERP 資料遷移到第一個租戶
*/
class MigrateToTenant extends Command
{
protected $signature = 'tenancy:migrate-data {tenant_id} {--dry-run : 只顯示會遷移的表,不實際執行}';
protected $description = '將現有 central DB 資料遷移到指定租戶資料庫';
/**
* 需要遷移的表 (依賴順序排列)
*/
protected array $tablesToMigrate = [
'users',
'password_reset_tokens',
'sessions',
'cache',
'cache_locks',
'jobs',
'job_batches',
'failed_jobs',
'categories',
'units',
'vendors',
'products',
'product_vendor',
'warehouses',
'inventories',
'inventory_transactions',
'purchase_orders',
'purchase_order_items',
'permissions',
'roles',
'model_has_permissions',
'model_has_roles',
'role_has_permissions',
];
public function handle(): int
{
$tenantId = $this->argument('tenant_id');
$dryRun = $this->option('dry-run');
// 檢查租戶是否存在
$tenant = Tenant::find($tenantId);
if (!$tenant) {
$this->error("租戶 '{$tenantId}' 不存在!請先建立租戶。");
return self::FAILURE;
}
$this->info("開始遷移資料到租戶: {$tenantId}");
$this->info("租戶資料庫: tenant{$tenantId}");
if ($dryRun) {
$this->warn('⚠️ Dry Run 模式 - 不會實際遷移資料');
}
// 取得 central 資料庫連線
$centralConnection = config('database.default');
$tenantDbName = 'tenant' . $tenantId;
foreach ($this->tablesToMigrate as $table) {
// 檢查表是否存在於 central
if (!Schema::connection($centralConnection)->hasTable($table)) {
$this->line(" ⏭️ 跳過 {$table} (表不存在)");
continue;
}
// 計算資料筆數
$count = DB::connection($centralConnection)->table($table)->count();
if ($count === 0) {
$this->line(" ⏭️ 跳過 {$table} (無資料)");
continue;
}
if ($dryRun) {
$this->info(" 📋 {$table}: {$count} 筆資料");
continue;
}
// 實際遷移資料
$this->info(" 🔄 遷移 {$table}: {$count} 筆資料...");
try {
// 使用租戶上下文執行
$tenant->run(function () use ($centralConnection, $table) {
// 取得 central 資料
$data = DB::connection($centralConnection)->table($table)->get();
if ($data->isEmpty()) {
return;
}
// 關閉外鍵檢查
DB::statement('SET FOREIGN_KEY_CHECKS=0');
// 清空目標表
DB::table($table)->truncate();
// 分批插入 (每批 100 筆)
foreach ($data->chunk(100) as $chunk) {
DB::table($table)->insert($chunk->map(fn($item) => (array) $item)->toArray());
}
// 恢復外鍵檢查
DB::statement('SET FOREIGN_KEY_CHECKS=1');
});
$this->info("{$table} 遷移完成");
} catch (\Exception $e) {
$this->error("{$table} 遷移失敗: " . $e->getMessage());
return self::FAILURE;
}
}
$this->newLine();
$this->info('🎉 資料遷移完成!');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class NotifyUtilityFeeStatus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'finance:notify-utility-fees';
/**
* The console command description.
*
* @var string
*/
protected $description = '檢查公共事業費狀態並寄送 Email 通知管理員';
/**
* Execute the console command.
*/
public function handle()
{
$this->info("正在掃描公共事業費狀態...");
// 1. 更新逾期狀態 (pending -> overdue)
\App\Modules\Finance\Models\UtilityFee::where('payment_status', \App\Modules\Finance\Models\UtilityFee::STATUS_PENDING)
->whereNotNull('due_date')
->where('due_date', '<', now()->startOfDay())
->update(['payment_status' => \App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE]);
// 2. 獲取可能需要處理的單據 (pending 或 overdue)
$feesToCheck = \App\Modules\Finance\Models\UtilityFee::whereIn('payment_status', [
\App\Modules\Finance\Models\UtilityFee::STATUS_PENDING,
\App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE
])
->whereNotNull('due_date')
->orderBy('due_date', 'asc')
->get();
if ($feesToCheck->isEmpty()) {
$this->info("目前沒有未繳納的公共事業費。");
return 0;
}
// 3. 根據業務規則過濾出今天「真正」需要發信的單據
$today = now()->startOfDay();
$unpaidFees = $feesToCheck->filter(function ($fee) use ($today) {
$dueDate = \Illuminate\Support\Carbon::parse($fee->due_date)->startOfDay();
$diffInDays = $today->diffInDays($dueDate, false);
// 如果已經逾期 (overdue),每天都要發信
if ($fee->payment_status === \App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE) {
return true;
}
// 如果是待繳納 (pending),僅在特定天數發信
// 規則:到期前 7 天、3 天、當天 (0 天)
return in_array($diffInDays, [7, 3, 0]);
});
if ($unpaidFees->isEmpty()) {
$this->info("今日無符合發信條件的公共事業費提醒。");
return 0;
}
// 4. 讀取系統設定
$senderEmail = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_email');
$senderPassword = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_password');
$recipientEmailsStr = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_recipient_emails');
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
$this->warn("系統設定中缺乏完整的 Email 通知參數,跳過寄送通知。請至「系統設定」->「通知設定」完善資料。");
return 0;
}
// 4. 動態覆寫應用程式名稱與 SMTP Config
$tenantName = tenant('name') ?? config('app.name');
config([
'app.name' => $tenantName,
'mail.mailers.smtp.username' => $senderEmail,
'mail.mailers.smtp.password' => $senderPassword,
'mail.from.address' => $senderEmail,
'mail.from.name' => $tenantName . ' (系統通知)'
]);
// 清理原先可能的 Mailer 實例,確保使用新的 Config
\Illuminate\Support\Facades\Mail::purge();
// 5. 解析收件者並寄送 Email
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
if (empty($validRecipients)) {
$this->warn("無效的收件者 Email 格式,跳過寄送通知。");
return 0;
}
try {
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\PaymentReminderMail($unpaidFees));
$this->info("通知郵件已成功寄送至: " . implode(', ', $validRecipients));
} catch (\Exception $e) {
$this->error("Email 寄送失敗: " . $e->getMessage());
}
return 0;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Enums;
enum WarehouseType: string
{
case STANDARD = 'standard'; // 標準倉/總倉
case PRODUCTION = 'production'; // 生產倉/廚房
case RETAIL = 'retail'; // 門市倉/前台
case VENDING = 'vending'; // 販賣機倉/IoT
case TRANSIT = 'transit'; // 在途倉/移動倉
case QUARANTINE = 'quarantine'; // 瑕疵倉/報廢倉
public function label(): string
{
return match($this) {
self::STANDARD => '標準倉 (總倉)',
self::PRODUCTION => '生產倉 (廚房/加工)',
self::RETAIL => '門市倉 (前台销售)',
self::VENDING => '販賣機 (IoT設備)',
self::TRANSIT => '在途倉 (物流車)',
self::QUARANTINE => '瑕疵倉 (報廢/檢驗)',
};
}
}

View File

@@ -1,325 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class InventoryController extends Controller
{
public function index(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
{
$warehouse->load([
'inventories.product.category',
'inventories.lastIncomingTransaction',
'inventories.lastOutgoingTransaction'
]);
$allProducts = \App\Models\Product::with('category')->get();
// 1. 準備 availableProducts
$availableProducts = $allProducts->map(function ($product) {
return [
'id' => (string) $product->id, // Frontend expects string
'name' => $product->name,
'type' => $product->category ? $product->category->name : '其他', // 暫時用 Category Name 當 Type
];
});
// 2. 準備 inventories (模擬批號)
// 2. 準備 inventories
// 資料庫結構為 (warehouse_id, product_id) 唯一,故為扁平列表
$inventories = $warehouse->inventories->map(function ($inv) {
return [
'id' => (string) $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product->name,
'productCode' => $inv->product->code ?? 'N/A',
'unit' => $inv->product->base_unit ?? '個',
'quantity' => (float) $inv->quantity,
'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null,
'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態
'batchNumber' => 'BATCH-' . $inv->id, // DB 無批號,暫時模擬,某些 UI 可能還會用到
'expiryDate' => '2099-12-31', // DB 無效期,暫時模擬
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
];
});
// 3. 準備 safetyStockSettings
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
return !is_null($inv->safety_stock);
})->map(function ($inv) {
return [
'id' => 'ss-' . $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product->name,
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
'safetyStock' => (float) $inv->safety_stock,
'createdAt' => $inv->created_at->toIso8601String(),
'updatedAt' => $inv->updated_at->toIso8601String(),
];
})->values();
return \Inertia\Inertia::render('Warehouse/Inventory', [
'warehouse' => $warehouse,
'inventories' => $inventories,
'safetyStockSettings' => $safetyStockSettings,
'availableProducts' => $availableProducts,
]);
}
public function create(\App\Models\Warehouse $warehouse)
{
// 取得所有商品供前端選單使用
$products = \App\Models\Product::select('id', 'name', 'base_unit')->get()->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'unit' => $product->base_unit,
];
});
return \Inertia\Inertia::render('Warehouse/AddInventory', [
'warehouse' => $warehouse,
'products' => $products,
]);
}
public function store(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse)
{
$validated = $request->validate([
'inboundDate' => 'required|date',
'reason' => 'required|string',
'notes' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
]);
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['items'] as $item) {
// 取得或建立庫存紀錄
$inventory = $warehouse->inventories()->firstOrCreate(
['product_id' => $item['productId']],
['quantity' => 0, 'safety_stock' => null]
);
$currentQty = $inventory->quantity;
$newQty = $currentQty + $item['quantity'];
// 更新庫存
$inventory->update(['quantity' => $newQty]);
// 寫入異動紀錄
$inventory->transactions()->create([
'type' => '手動入庫',
'quantity' => $item['quantity'],
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
'actual_time' => $validated['inboundDate'],
'user_id' => auth()->id(),
]);
}
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存記錄已儲存成功');
});
}
public function edit(\App\Models\Warehouse $warehouse, $inventoryId)
{
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
if (str_starts_with($inventoryId, 'mock-inv-')) {
return redirect()->back()->with('error', '無法編輯範例資料');
}
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
// 轉換為前端需要的格式
$inventoryData = [
'id' => (string) $inventory->id,
'warehouseId' => (string) $inventory->warehouse_id,
'productId' => (string) $inventory->product_id,
'productName' => $inventory->product->name,
'quantity' => (float) $inventory->quantity,
'batchNumber' => 'BATCH-' . $inventory->id, // Mock
'expiryDate' => '2099-12-31', // Mock
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
'lastOutboundDate' => null,
];
// 整理異動紀錄
$transactions = $inventory->transactions->map(function ($tx) {
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
];
});
return \Inertia\Inertia::render('Warehouse/EditInventory', [
'warehouse' => $warehouse,
'inventory' => $inventoryData,
'transactions' => $transactions,
]);
}
public function update(\Illuminate\Http\Request $request, \App\Models\Warehouse $warehouse, $inventoryId)
{
// 若是 product ID (舊邏輯),先轉為 inventory
// 但新路由我們傳的是 inventory ID
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
$inventory = \App\Models\Inventory::find($inventoryId);
// 如果找不到 (可能是舊路由傳 product ID)
if (!$inventory) {
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
}
if (!$inventory) {
return redirect()->back()->with('error', '找不到庫存紀錄');
}
$validated = $request->validate([
'quantity' => 'required|numeric|min:0',
// 以下欄位改為 nullable支援新表單
'type' => 'nullable|string',
'operation' => 'nullable|in:add,subtract,set',
'reason' => 'nullable|string',
'notes' => 'nullable|string',
// 新增日期欄位驗證 (雖然暫不儲存到 DB)
'batchNumber' => 'nullable|string',
'expiryDate' => 'nullable|date',
'lastInboundDate' => 'nullable|date',
'lastOutboundDate' => 'nullable|date',
]);
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) {
$currentQty = $inventory->quantity;
$newQty = $validated['quantity'];
// 判斷操作模式
if (isset($validated['operation'])) {
$changeQty = 0;
switch ($validated['operation']) {
case 'add':
$changeQty = $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'subtract':
$changeQty = -$validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'set':
$changeQty = $newQty - $currentQty;
break;
}
} else {
// 來自編輯頁面,直接 Set
$changeQty = $newQty - $currentQty;
}
// 更新庫存
$inventory->update(['quantity' => $newQty]);
// 異動類型映射
$type = $validated['type'] ?? 'adjustment';
$typeMapping = [
'adjustment' => '盤點調整',
'purchase_in' => '採購進貨',
'sales_out' => '銷售出庫',
'return_in' => '退貨入庫',
'return_out' => '退貨出庫',
'transfer_in' => '撥補入庫',
'transfer_out' => '撥補出庫',
];
$chineseType = $typeMapping[$type] ?? $type;
// 如果是編輯頁面來的,可能沒有 type預設為 "盤點調整" 或 "手動編輯"
if (!isset($validated['type'])) {
$chineseType = '手動編輯';
}
// 寫入異動紀錄
// 如果數量沒變,是否要寫紀錄?通常編輯頁面按儲存可能只改了其他欄位(如果有)
// 但因為我們目前只存 quantity如果 quantity 沒變,可以不寫異動,或者寫一筆 0 的異動代表更新屬性
if (abs($changeQty) > 0.0001) {
$inventory->transactions()->create([
'type' => $chineseType,
'quantity' => $changeQty,
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => ($validated['reason'] ?? '編輯頁面更新') . ($validated['notes'] ?? ''),
'actual_time' => now(), // 手動調整設定為當下
'user_id' => auth()->id(),
]);
}
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
->with('success', '庫存資料已更新');
});
}
public function destroy(\App\Models\Warehouse $warehouse, $inventoryId)
{
$inventory = \App\Models\Inventory::findOrFail($inventoryId);
// 歸零異動
if ($inventory->quantity > 0) {
$inventory->transactions()->create([
'type' => '手動編輯',
'quantity' => -$inventory->quantity,
'balance_before' => $inventory->quantity,
'balance_after' => 0,
'reason' => '刪除庫存品項',
'actual_time' => now(),
'user_id' => auth()->id(),
]);
}
$inventory->delete();
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存品項已刪除');
}
public function history(\App\Models\Warehouse $warehouse, $inventoryId)
{
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
$transactions = $inventory->transactions->map(function ($tx) {
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
];
});
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => (string) $inventory->id,
'productName' => $inventory->product->name,
'productCode' => $inventory->product->code,
'quantity' => (float) $inventory->quantity,
],
'transactions' => $transactions
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Landlord;
use App\Http\Controllers\Controller;
use App\Modules\Core\Models\Tenant;
use Inertia\Inertia;
class DashboardController extends Controller
{
public function index()
{
$stats = [
'totalTenants' => Tenant::count(),
'activeTenants' => Tenant::whereJsonContains('data->is_active', true)->count(),
'recentTenants' => Tenant::latest()->take(5)->get()->map(function ($tenant) {
return [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'is_active' => $tenant->is_active ?? true,
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
'domains' => $tenant->domains->pluck('domain')->toArray(),
];
}),
];
return Inertia::render('Landlord/Dashboard', $stats);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Landlord;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
class ProfileController extends Controller
{
/**
* 顯示使用者設定頁面
*/
public function edit(Request $request)
{
return Inertia::render('Landlord/Profile/Edit', [
'user' => $request->user(),
]);
}
/**
* 更新使用者基本資料
*/
public function update(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $request->user()->id],
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
]);
$request->user()->update($validated);
return back()->with('success', '個人資料已更新');
}
/**
* 更新密碼
*/
public function updatePassword(Request $request)
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('success', '密碼已更新');
}
}

View File

@@ -0,0 +1,318 @@
<?php
namespace App\Http\Controllers\Landlord;
use App\Http\Controllers\Controller;
use App\Modules\Core\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class TenantController extends Controller
{
/**
* 顯示租戶列表
*/
public function index()
{
$tenants = Tenant::with('domains')->get()->map(function ($tenant) {
return [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'short_name' => $tenant->short_name ?? null,
'email' => $tenant->email ?? null,
'is_active' => $tenant->is_active ?? true,
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
'domains' => $tenant->domains->pluck('domain')->toArray(),
];
});
return Inertia::render('Landlord/Tenant/Index', [
'tenants' => $tenants,
]);
}
/**
* 顯示新增租戶表單
*/
public function create()
{
return Inertia::render('Landlord/Tenant/Create');
}
/**
* 儲存新租戶
*/
public function store(Request $request)
{
$validated = $request->validate([
'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')],
'name' => ['required', 'string', 'max:100'],
'short_name' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:100'],
'domain' => ['nullable', 'string', 'max:100'],
]);
$tenant = Tenant::create([
'id' => $validated['id'],
'name' => $validated['name'],
'short_name' => $validated['short_name'] ?? null,
'email' => $validated['email'] ?? null,
'is_active' => true,
'branding' => [
'logo_path' => 'defaults/logo.png', // 預設 Logo 路徑
'login_background_path' => 'defaults/login_bg.jpg', // 預設登入背景
'primary_color' => '#4F46E5', // 預設主色系 (Indigo-600)
],
]);
// 綁定網域(如果沒有輸入,使用預設網域)
$defaultDomain = env('TENANT_DEFAULT_DOMAIN', 'star-erp.test');
$domain = !empty($validated['domain'])
? $validated['domain']
: $validated['id'] . '.' . $defaultDomain;
$tenant->domains()->create(['domain' => $domain]);
return redirect()->route('landlord.tenants.index')
->with('success', "租戶 {$validated['name']} 建立成功!");
}
/**
* 顯示單一租戶詳情
*/
public function show(string $id)
{
$tenant = Tenant::with('domains')->findOrFail($id);
$tokens = [];
try {
tenancy()->initialize($tenant);
$user = \App\Modules\Core\Models\User::first();
if ($user) {
$tokens = $user->tokens()->orderBy('created_at', 'desc')->get(['id', 'name', 'last_used_at', 'created_at'])->map(function($token) {
return [
'id' => $token->id,
'name' => $token->name,
'last_used_at' => $token->last_used_at ? $token->last_used_at->format('Y-m-d H:i') : '未使用',
'created_at' => $token->created_at->format('Y-m-d H:i'),
];
});
}
} catch (\Exception $e) {
\Log::warning("Failed to fetch tokens for tenant {$id}: " . $e->getMessage());
}
return Inertia::render('Landlord/Tenant/Show', [
'tenant' => [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'short_name' => $tenant->short_name ?? null,
'email' => $tenant->email ?? null,
'is_active' => $tenant->is_active ?? true,
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
'updated_at' => $tenant->updated_at->format('Y-m-d H:i'),
'domains' => $tenant->domains->map(fn($d) => [
'id' => $d->id,
'domain' => $d->domain,
])->toArray(),
],
'tokens' => $tokens,
]);
}
/**
* 顯示租戶樣式管理頁面
*/
public function showBranding(Tenant $tenant)
{
$logoUrl = null;
if (isset($tenant->branding['logo_path'])) {
$logoUrl = \Storage::url($tenant->branding['logo_path']);
}
return Inertia::render('Landlord/Tenant/Branding', [
'tenant' => [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'branding' => $tenant->branding ?? [],
],
'logo_url' => $logoUrl,
]);
}
/**
* 顯示編輯租戶表單
*/
public function edit(string $id)
{
$tenant = Tenant::findOrFail($id);
return Inertia::render('Landlord/Tenant/Edit', [
'tenant' => [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'short_name' => $tenant->short_name ?? null,
'email' => $tenant->email ?? null,
'is_active' => $tenant->is_active ?? true,
],
]);
}
/**
* 更新租戶
*/
public function update(Request $request, string $id)
{
$tenant = Tenant::findOrFail($id);
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'short_name' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:100'],
'is_active' => ['boolean'],
]);
$tenant->update($validated);
return redirect()->route('landlord.tenants.index')
->with('success', "租戶 {$validated['name']} 更新成功!");
}
/**
* 刪除租戶
*/
public function destroy(string $id)
{
$tenant = Tenant::findOrFail($id);
$name = $tenant->name ?? $id;
$tenant->delete();
return redirect()->route('landlord.tenants.index')
->with('success', "租戶 {$name} 已刪除!");
}
/**
* 新增域名到租戶
*/
public function addDomain(Request $request, string $id)
{
$tenant = Tenant::findOrFail($id);
$validated = $request->validate([
'domain' => ['required', 'string', 'max:100', Rule::unique('domains', 'domain')],
]);
$tenant->domains()->create(['domain' => $validated['domain']]);
return back()->with('success', "域名 {$validated['domain']} 已綁定!");
}
/**
* 移除租戶的域名
*/
public function removeDomain(string $id, int $domainId)
{
$tenant = Tenant::findOrFail($id);
$domain = $tenant->domains()->findOrFail($domainId);
$domainName = $domain->domain;
$domain->delete();
return back()->with('success', "域名 {$domainName} 已移除!");
}
/**
* 更新租戶品牌樣式設定
*/
public function updateBranding(Request $request, Tenant $tenant)
{
$validated = $request->validate([
'logo' => 'nullable|image|max:2048',
'primary_color' => 'required|regex:/^#[0-9A-Fa-f]{6}$/',
'text_color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
]);
$branding = $tenant->branding ?? [];
// 處理 Logo 上傳
if ($request->hasFile('logo')) {
// 刪除舊 Logo
if (isset($branding['logo_path'])) {
\Storage::disk('public')->delete($branding['logo_path']);
}
// 儲存新 Logo
$path = $request->file('logo')->store('tenant-logos', 'public');
$branding['logo_path'] = $path;
}
// 更新主色系
$branding['primary_color'] = $validated['primary_color'];
// 如果有傳入字體顏色則更新,否則保留原值(或預設值)
if (isset($validated['text_color'])) {
$branding['text_color'] = $validated['text_color'];
} elseif (!isset($branding['text_color'])) {
$branding['text_color'] = '#1a1a1a';
}
$tenant->update(['branding' => $branding]);
return redirect()->back()->with('success', '樣式設定已更新');
}
/**
* 建立 API Token (用於 POS)
*/
public function createToken(Request $request, Tenant $tenant)
{
$request->validate([
'name' => 'required|string|max:50',
]);
try {
// 切換至租戶環境
tenancy()->initialize($tenant);
// 尋找超級管理員 (假設 ID 1, 或者根據 Role)
// 這裡簡單取第一個使用者,通常是 Admin
$user = \App\Modules\Core\Models\User::first();
if (!$user) {
return back()->with('error', '該租戶尚無使用者,無法建立 Token。');
}
// 建立 Token
$token = $user->createToken($request->name);
return back()->with('success', 'Token 建立成功')->with('new_token', $token->plainTextToken);
} catch (\Exception $e) {
\Log::error("Token creation failed: " . $e->getMessage());
return back()->with('error', 'Token 建立失敗');
} finally {
// tenancy()->end(); // Laravel Tenancy 自動處理 scope 結束? 通常 Controller request life-cycle?
// Landlord controller is Central. Tenancy initialization persists for request.
// We should explicit end if we want to be safe, but redirect ends request anyway.
}
}
/**
* 撤銷 API Token
*/
public function revokeToken(Request $request, Tenant $tenant, string $tokenId)
{
try {
tenancy()->initialize($tenant);
$user = \App\Modules\Core\Models\User::first();
if ($user) {
$user->tokens()->where('id', $tokenId)->delete();
}
return back()->with('success', 'Token 已撤銷');
} catch (\Exception $e) {
return back()->with('error', 'Token 撤銷失敗');
}
}
}

View File

@@ -1,136 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProductController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request): Response
{
$query = Product::with('category');
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('brand', 'like', "%{$search}%");
});
}
if ($request->filled('category_id') && $request->category_id !== 'all') {
$query->where('category_id', $request->category_id);
}
$perPage = $request->input('per_page', 10);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = 10;
}
$sortField = $request->input('sort_field', 'id');
$sortDirection = $request->input('sort_direction', 'desc');
// Define allowed sort fields to prevent SQL injection
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit', 'conversion_rate'];
if (!in_array($sortField, $allowedSorts)) {
$sortField = 'id';
}
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
$sortDirection = 'desc';
}
// Handle relation sorting (category name) separately if needed, or simple join
if ($sortField === 'category_id') {
// Join categories for sorting by name? Or just by ID?
// Simple approach: sort by ID for now, or join if user wants name sort.
// Let's assume standard field sorting first.
$query->orderBy('category_id', $sortDirection);
} else {
$query->orderBy($sortField, $sortDirection);
}
$products = $query->paginate($perPage)->withQueryString();
$categories = \App\Models\Category::where('is_active', true)->get();
return Inertia::render('Product/Index', [
'products' => $products,
'categories' => $categories,
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit' => 'required|string|max:50',
'large_unit' => 'nullable|string|max:50',
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
'purchase_unit' => 'nullable|string|max:50',
], [
'name.required' => '商品名稱為必填',
'category_id.required' => '請選擇分類',
'category_id.exists' => '所選分類不存在',
'base_unit.required' => '基本庫存單位為必填',
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
'conversion_rate.numeric' => '換算率必須為數字',
'conversion_rate.min' => '換算率最小為 0.0001',
]);
// Auto-generate code
$prefix = 'P';
$lastProduct = Product::withTrashed()->latest('id')->first();
$nextId = $lastProduct ? $lastProduct->id + 1 : 1;
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
$validated['code'] = $code;
$product = Product::create($validated);
return redirect()->back()->with('success', '商品已建立');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit' => 'required|string|max:50',
'large_unit' => 'nullable|string|max:50',
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
]);
$product->update($validated);
return redirect()->back()->with('success', '商品已更新');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Product $product)
{
$product->delete();
return redirect()->back()->with('success', '商品已刪除');
}
}

View File

@@ -1,293 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\PurchaseOrder;
use App\Models\Vendor;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class PurchaseOrderController extends Controller
{
public function index(Request $request)
{
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
// Search
if ($request->search) {
$query->where(function($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhereHas('vendor', function($vq) use ($request) {
$vq->where('name', 'like', "%{$request->search}%");
});
});
}
// Filters
if ($request->status && $request->status !== 'all') {
$query->where('status', $request->status);
}
if ($request->warehouse_id && $request->warehouse_id !== 'all') {
$query->where('warehouse_id', $request->warehouse_id);
}
// Sorting
$sortField = $request->sort_field ?? 'id';
$sortDirection = $request->sort_direction ?? 'desc';
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
if (in_array($sortField, $allowedSortFields)) {
$query->orderBy($sortField, $sortDirection);
}
$orders = $query->paginate(15)->withQueryString();
return Inertia::render('PurchaseOrder/Index', [
'orders' => $orders,
'filters' => $request->only(['search', 'status', 'warehouse_id', 'sort_field', 'sort_direction']),
'warehouses' => Warehouse::all(['id', 'name']),
]);
}
public function create()
{
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'unit' => $product->base_unit,
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
];
})
];
});
$warehouses = Warehouse::all()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
];
});
return Inertia::render('PurchaseOrder/Create', [
'suppliers' => $vendors,
'warehouses' => $warehouses,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id',
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unitPrice' => 'required|numeric|min:0',
]);
try {
DB::beginTransaction();
// 生成單號YYYYMMDD001
$today = now()->format('Ymd');
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc')
->first();
if ($lastOrder) {
// 取得最後 3 碼序號並加 1
$lastSequence = intval(substr($lastOrder->code, -3));
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
} else {
$sequence = '001';
}
$code = $today . $sequence;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['quantity'] * $item['unitPrice'];
}
// Simple tax calculation (e.g., 5%)
$taxAmount = round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = \App\Models\User::first();
if (!$user) {
$user = \App\Models\User::create([
'name' => '系統管理員',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
]);
}
$userId = $user->id;
}
$order = PurchaseOrder::create([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
]);
foreach ($validated['items'] as $item) {
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_price' => $item['unitPrice'],
'subtotal' => $item['quantity'] * $item['unitPrice'],
]);
}
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '建立失敗:' . $e->getMessage()]);
}
}
public function show($id)
{
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product'])->findOrFail($id);
return Inertia::render('PurchaseOrder/Show', [
'order' => $order
]);
}
public function edit($id)
{
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'unit' => $product->base_unit,
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
];
})
];
});
$warehouses = Warehouse::all()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
];
});
return Inertia::render('PurchaseOrder/Create', [
'order' => $order,
'suppliers' => $vendors,
'warehouses' => $warehouses,
]);
}
public function update(Request $request, $id)
{
$order = PurchaseOrder::findOrFail($id);
$validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id',
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unitPrice' => 'required|numeric|min:0',
]);
try {
DB::beginTransaction();
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['quantity'] * $item['unitPrice'];
}
// Simple tax calculation (e.g., 5%)
$taxAmount = round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
$order->update([
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount,
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'status' => $validated['status'],
]);
// Sync items
$order->items()->delete();
foreach ($validated['items'] as $item) {
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_price' => $item['unitPrice'],
'subtotal' => $item['quantity'] * $item['unitPrice'],
]);
}
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已更新');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '更新失敗:' . $e->getMessage()]);
}
}
public function destroy($id)
{
try {
DB::beginTransaction();
$order = PurchaseOrder::findOrFail($id);
// Delete associated items first (due to FK constraints if not cascade)
$order->items()->delete();
$order->delete();
DB::commit();
return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除');
} catch (\Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
}
}
}

View File

@@ -1,123 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Warehouse;
use App\Models\Inventory;
use App\Models\Product;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class SafetyStockController extends Controller
{
/**
* 顯示安全庫存設定頁面
*/
public function index(Warehouse $warehouse)
{
$warehouse->load(['inventories.product.category']);
$allProducts = Product::with('category')->get();
// 準備可選商品列表
$availableProducts = $allProducts->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'type' => $product->category ? $product->category->name : '其他',
'unit' => $product->base_unit,
];
});
// 準備現有庫存列表 (用於狀態計算)
$inventories = $warehouse->inventories->map(function ($inv) {
return [
'id' => (string) $inv->id,
'productId' => (string) $inv->product_id,
'quantity' => (float) $inv->quantity,
'safetyStock' => (float) $inv->safety_stock,
];
});
// 準備安全庫存設定列表
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
return !is_null($inv->safety_stock);
})->map(function ($inv) {
return [
'id' => (string) $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product->name,
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
'safetyStock' => (float) $inv->safety_stock,
'unit' => $inv->product->base_unit,
'updatedAt' => $inv->updated_at->toIso8601String(),
];
})->values();
return Inertia::render('Warehouse/SafetyStockSettings', [
'warehouse' => $warehouse,
'safetyStockSettings' => $safetyStockSettings,
'inventories' => $inventories,
'availableProducts' => $availableProducts,
]);
}
/**
* 批量儲存安全庫存設定
*/
public function store(Request $request, Warehouse $warehouse)
{
$validated = $request->validate([
'settings' => 'required|array|min:1',
'settings.*.productId' => 'required|exists:products,id',
'settings.*.quantity' => 'required|numeric|min:0',
]);
DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['settings'] as $item) {
Inventory::updateOrCreate(
[
'warehouse_id' => $warehouse->id,
'product_id' => $item['productId'],
],
[
'safety_stock' => $item['quantity'],
]
);
}
});
return redirect()->back()->with('success', '安全庫存設定已更新');
}
/**
* 更新單筆安全庫存設定
*/
public function update(Request $request, Warehouse $warehouse, Inventory $inventory)
{
$validated = $request->validate([
'safetyStock' => 'required|numeric|min:0',
]);
$inventory->update([
'safety_stock' => $validated['safetyStock'],
]);
return redirect()->back()->with('success', '安全庫存已更新');
}
/**
* 刪除 (歸零) 安全庫存設定
*/
public function destroy(Warehouse $warehouse, Inventory $inventory)
{
$inventory->update([
'safety_stock' => null,
]);
return redirect()->back()->with('success', '安全庫存設定已移除');
}
}

View File

@@ -1,115 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Inventory;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class TransferOrderController extends Controller
{
/**
* 儲存撥補單(建立調撥單並執行庫存轉移)
*/
public function store(Request $request)
{
$validated = $request->validate([
'sourceWarehouseId' => 'required|exists:warehouses,id',
'targetWarehouseId' => 'required|exists:warehouses,id|different:sourceWarehouseId',
'productId' => 'required|exists:products,id',
'quantity' => 'required|numeric|min:0.01',
'transferDate' => 'required|date',
'status' => 'required|in:待處理,處理中,已完成,已取消', // 目前僅支援立即完成或單純記錄
'notes' => 'nullable|string',
'batchNumber' => 'nullable|string', // 暫時接收,雖然 DB 可能沒存
]);
return DB::transaction(function () use ($validated) {
// 1. 檢查來源倉庫庫存
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
->where('product_id', $validated['productId'])
->first();
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
throw ValidationException::withMessages([
'quantity' => ['來源倉庫庫存不足'],
]);
}
// 2. 獲取或建立目標倉庫庫存
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $validated['targetWarehouseId'],
'product_id' => $validated['productId'],
],
[
'quantity' => 0,
'safety_stock' => null, // 預設為 null (未設定),而非 0
]
);
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
// 3. 執行庫存轉移 (扣除來源)
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $validated['quantity'];
$sourceInventory->update(['quantity' => $newSourceQty]);
// 記錄來源異動
$sourceInventory->transactions()->create([
'type' => '撥補出庫',
'quantity' => -$validated['quantity'],
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
'actual_time' => $validated['transferDate'],
'user_id' => auth()->id(),
]);
// 4. 執行庫存轉移 (增加目標)
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $validated['quantity'];
$targetInventory->update(['quantity' => $newTargetQty]);
// 記錄目標異動
$targetInventory->transactions()->create([
'type' => '撥補入庫',
'quantity' => $validated['quantity'],
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
'actual_time' => $validated['transferDate'],
'user_id' => auth()->id(),
]);
// TODO: 未來若有獨立的 TransferOrder 模型,可在此建立紀錄
return redirect()->back()->with('success', '撥補單已建立且庫存已轉移');
});
}
/**
* 獲取特定倉庫的庫存列表 (API)
*/
public function getWarehouseInventories(Warehouse $warehouse)
{
$inventories = $warehouse->inventories()
->with(['product:id,name,base_unit,category_id', 'product.category'])
->where('quantity', '>', 0) // 只回傳有庫存的
->get()
->map(function ($inv) {
return [
'productId' => (string) $inv->product_id,
'productName' => $inv->product->name,
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
'availableQty' => (float) $inv->quantity,
'unit' => $inv->product->base_unit,
];
});
return response()->json($inventories);
}
}

View File

@@ -1,124 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Vendor;
use Illuminate\Http\Request;
class VendorController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(\Illuminate\Http\Request $request): \Inertia\Response
{
$query = Vendor::query();
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('tax_id', 'like', "%{$search}%")
->orWhere('owner', 'like', "%{$search}%")
->orWhere('contact_name', 'like', "%{$search}%");
});
}
$sortField = $request->input('sort_field', 'id');
$sortDirection = $request->input('sort_direction', 'desc');
$allowedSorts = ['id', 'code', 'name', 'owner', 'contact_name', 'phone'];
if (!in_array($sortField, $allowedSorts)) {
$sortField = 'id';
}
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
$sortDirection = 'desc';
}
$vendors = $query->orderBy($sortField, $sortDirection)
->paginate(10)
->withQueryString();
return \Inertia\Inertia::render('Vendor/Index', [
'vendors' => $vendors,
'filters' => $request->only(['search', 'sort_field', 'sort_direction']),
]);
}
/**
* Display the specified resource.
*/
public function show(Vendor $vendor): \Inertia\Response
{
$vendor->load('products');
return \Inertia\Inertia::render('Vendor/Show', [
'vendor' => $vendor,
'products' => \App\Models\Product::all(),
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(\Illuminate\Http\Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'short_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:8',
'owner' => 'nullable|string|max:255',
'contact_name' => 'nullable|string|max:255',
'tel' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:50',
'email' => 'nullable|email|max:255',
'address' => 'nullable|string',
'remark' => 'nullable|string',
]);
// Auto-generate code
$prefix = 'V';
$lastVendor = Vendor::latest('id')->first();
$nextId = $lastVendor ? $lastVendor->id + 1 : 1;
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
$validated['code'] = $code;
Vendor::create($validated);
return redirect()->back()->with('success', '廠商已建立');
}
/**
* Update the specified resource in storage.
*/
public function update(\Illuminate\Http\Request $request, Vendor $vendor)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'short_name' => 'nullable|string|max:255',
'tax_id' => 'nullable|string|max:8',
'owner' => 'nullable|string|max:255',
'contact_name' => 'nullable|string|max:255',
'tel' => 'nullable|string|max:50',
'phone' => 'nullable|string|max:50',
'email' => 'nullable|email|max:255',
'address' => 'nullable|string',
'remark' => 'nullable|string',
]);
$vendor->update($validated);
return redirect()->back()->with('success', '廠商資料已更新');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Vendor $vendor)
{
$vendor->delete();
return redirect()->back()->with('success', '廠商已刪除');
}
}

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Vendor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class VendorProductController extends Controller
{
/**
* 新增供貨商品 (Attach)
*/
public function store(Request $request, Vendor $vendor)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'last_price' => 'nullable|numeric|min:0',
]);
// 檢查是否已存在
if ($vendor->products()->where('product_id', $validated['product_id'])->exists()) {
return redirect()->back()->with('error', '該商品已在供貨清單中');
}
$vendor->products()->attach($validated['product_id'], [
'last_price' => $validated['last_price'] ?? null
]);
return redirect()->back()->with('success', '供貨商品已新增');
}
/**
* 更新供貨商品資訊 (Update Pivot)
*/
public function update(Request $request, Vendor $vendor, $productId)
{
$validated = $request->validate([
'last_price' => 'nullable|numeric|min:0',
]);
$vendor->products()->updateExistingPivot($productId, [
'last_price' => $validated['last_price'] ?? null
]);
return redirect()->back()->with('success', '供貨資訊已更新');
}
/**
* 移除供貨商品 (Detach)
*/
public function destroy(Vendor $vendor, $productId)
{
$vendor->products()->detach($productId);
return redirect()->back()->with('success', '供貨商品已移除');
}
}

View File

@@ -1,80 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Warehouse;
use Inertia\Inertia;
class WarehouseController extends Controller
{
public function index(Request $request)
{
$query = Warehouse::query();
if ($request->has('search')) {
$search = $request->input('search');
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
});
}
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
->withCount(['inventories as low_stock_count' => function ($query) {
$query->whereColumn('quantity', '<', 'safety_stock');
}])
->orderBy('created_at', 'desc')
->paginate(10)
->withQueryString();
return Inertia::render('Warehouse/Index', [
'warehouses' => $warehouses,
'filters' => $request->only(['search']),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'address' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
// Auto-generate code
$prefix = 'WH';
$lastWarehouse = Warehouse::latest('id')->first();
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
$code = $prefix . str_pad($nextId, 3, '0', STR_PAD_LEFT);
$validated['code'] = $code;
Warehouse::create($validated);
return redirect()->back()->with('success', '倉庫已建立');
}
public function update(Request $request, Warehouse $warehouse)
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'address' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$warehouse->update($validated);
return redirect()->back()->with('success', '倉庫資訊已更新');
}
public function destroy(Warehouse $warehouse)
{
// 真實刪除
$warehouse->delete();
return redirect()->back()->with('success', '倉庫已刪除');
}
}

View File

@@ -35,9 +35,68 @@ class HandleInertiaRequests extends Middleware
*/
public function share(Request $request): array
{
$user = $request->user();
$tenant = tenancy()->tenant;
$appName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
// 分享給 Blade View (給 app.blade.php 使用)
\Illuminate\Support\Facades\View::share('appName', $appName);
return [
...parent::share($request),
//
'appName' => $appName,
'app_version' => config('app.version'),
'auth' => [
'user' => $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'username' => $user->username ?? null,
// 權限資料
'roles' => $user->getRoleNames(),
'role_labels' => $user->roles->pluck('display_name'),
'permissions' => $user->getAllPermissions()->pluck('name')->toArray(),
] : null,
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
'new_token' => $request->session()->get('new_token'),
],
'branding' => function () {
$tenant = tenancy()->tenant;
// 決定名稱顯示邏輯
$fullName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
$shortName = $tenant ? ($tenant->short_name ?? $fullName) : 'Start ERP';
$logoUrl = null;
if ($tenant && isset($tenant->branding['logo_path'])) {
$logoUrl = \Storage::url($tenant->branding['logo_path']);
} elseif (!$tenant) {
$logoUrl = \Storage::url('defaults/logo.png');
}
$brandingData = [
'name' => $fullName,
'short_name' => $shortName,
'logo_url' => $logoUrl,
'primary_color' => $tenant->branding['primary_color'] ?? ($tenant ? '#01ab83' : '#4F46E5'),
'text_color' => $tenant->branding['text_color'] ?? '#1a1a1a',
];
// 同步分享給 Blade View (給 app.blade.php 使用 Favicon)
\Illuminate\Support\Facades\View::share('branding', $brandingData);
return $brandingData;
},
'notifications' => function () use ($request) {
return $request->user() ? [
'latest' => $request->user()->notifications()->latest()->limit(10)->get(),
'unread_count' => $request->user()->unreadNotifications()->count(),
] : null;
},
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Stancl\Tenancy\Tenancy;
class PreventAccessFromTenantDomains
{
public function handle(Request $request, Closure $next)
{
// 如果租戶已初始化 (代表是從租戶域名存取),則禁止訪問 Landlord 路由
if (tenancy()->initialized) {
abort(404);
}
return $next($request);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Symfony\Component\HttpFoundation\Response;
class UniversalTenancy
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// 判斷是否為中央域名
$centralDomains = config('tenancy.central_domains', []);
// [Hack] Demo 環境特殊規則:
// 如果設定了 demo_tenant_port (e.g. 8081),且請求端口相符,強制視為租戶請求
$demoPort = config('tenancy.demo_tenant_port');
if ($demoPort && $request->getPort() == $demoPort) {
return app(InitializeTenancyByDomain::class)->handle($request, $next);
}
if (in_array($request->getHost(), $centralDomains)) {
// 如果是中央域名,不進行租戶初始化,直接繼續往下執行 (使用預設資料庫)
return $next($request);
}
// 如果不是中央域名,嘗試透過域名初始化租戶
// 若找不到租戶InitializeTenancyByDomain 會拋出異常
return app(InitializeTenancyByDomain::class)->handle($request, $next);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentReminderMail extends Mailable
{
use Queueable, SerializesModels;
public $fees;
/**
* Create a new message instance.
*/
public function __construct($fees)
{
$this->fees = $fees;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
$tenantName = tenant('name') ?? '系統';
return new Envelope(
subject: "{$tenantName}】公共事業費繳費/逾期通知",
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.payment-reminder',
with: [
'fees' => $this->fees,
],
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TestNotificationMail extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct()
{
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
$tenantName = tenant('name') ?? '系統';
return new Envelope(
subject: "{$tenantName}】電子郵件通知測試",
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.test-notification',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
/**
* Get the products for the category.
*/
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
}

View File

@@ -1,55 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Inventory extends Model
{
/** @use HasFactory<\Database\Factories\InventoryFactory> */
use HasFactory;
protected $fillable = [
'warehouse_id',
'product_id',
'quantity',
'safety_stock',
'location',
];
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Product::class);
}
public function transactions(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(InventoryTransaction::class);
}
public function lastOutgoingTransaction()
{
return $this->hasOne(InventoryTransaction::class)->ofMany([
'actual_time' => 'max',
'id' => 'max',
], function ($query) {
$query->where('quantity', '<', 0);
});
}
public function lastIncomingTransaction()
{
return $this->hasOne(InventoryTransaction::class)->ofMany([
'actual_time' => 'max',
'id' => 'max',
], function ($query) {
$query->where('quantity', '>', 0);
});
}
}

View File

@@ -1,46 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\Inventory;
use App\Models\User;
class InventoryTransaction extends Model
{
/** @use HasFactory<\Database\Factories\InventoryTransactionFactory> */
use HasFactory;
protected $fillable = [
'inventory_id',
'type',
'quantity',
'balance_before',
'balance_after',
'reason',
'reference_type',
'reference_id',
'user_id',
'actual_time',
];
protected $casts = [
'actual_time' => 'datetime',
];
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Inventory::class);
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
public function reference(): \Illuminate\Database\Eloquent\Relations\MorphTo
{
return $this->morphTo();
}
}

View File

@@ -1,54 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Product extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'code',
'name',
'category_id',
'brand',
'specification',
'base_unit',
'large_unit',
'conversion_rate',
'purchase_unit',
];
protected $casts = [
'conversion_rate' => 'decimal:4',
];
/**
* Get the category that owns the product.
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();
}
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Inventory::class);
}
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Warehouse::class, 'inventories')
->withPivot(['quantity', 'safety_stock', 'location'])
->withTimestamps();
}
}

View File

@@ -1,104 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PurchaseOrder extends Model
{
use HasFactory;
protected $fillable = [
'code',
'vendor_id',
'warehouse_id',
'user_id',
'status',
'expected_delivery_date',
'total_amount',
'tax_amount',
'grand_total',
'remark',
];
protected $casts = [
'expected_delivery_date' => 'date',
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'grand_total' => 'decimal:2',
];
protected $appends = [
'poNumber',
'supplierId',
'supplierName',
'expectedDate',
'totalAmount',
'createdBy',
'warehouse_name',
'createdAt',
];
public function getCreatedAtAttribute()
{
return $this->attributes['created_at'];
}
public function getPoNumberAttribute(): string
{
return $this->code;
}
public function getSupplierIdAttribute(): string
{
return (string) $this->vendor_id;
}
public function getSupplierNameAttribute(): string
{
return $this->vendor ? $this->vendor->name : '';
}
public function getExpectedDateAttribute(): ?string
{
return $this->expected_delivery_date ? $this->expected_delivery_date->format('Y-m-d') : null;
}
public function getTotalAmountAttribute(): float
{
return (float) ($this->attributes['total_amount'] ?? 0);
}
public function getCreatedByAttribute(): string
{
return $this->user ? $this->user->name : '系統';
}
public function getWarehouseNameAttribute(): string
{
return $this->warehouse ? $this->warehouse->name : '';
}
public function vendor(): BelongsTo
{
return $this->belongsTo(Vendor::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(PurchaseOrderItem::class);
}
}

View File

@@ -1,65 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PurchaseOrderItem extends Model
{
use HasFactory;
protected $fillable = [
'purchase_order_id',
'product_id',
'quantity',
'unit_price',
'subtotal',
'received_quantity',
];
protected $casts = [
'quantity' => 'decimal:2',
'unit_price' => 'decimal:2',
'subtotal' => 'decimal:2',
'received_quantity' => 'decimal:2',
];
protected $appends = [
'productName',
'unit',
'productId',
'unitPrice',
];
public function getProductIdAttribute(): string
{
return (string) $this->attributes['product_id'];
}
public function getUnitPriceAttribute(): float
{
return (float) $this->attributes['unit_price'];
}
public function getProductNameAttribute(): string
{
return $this->product ? $this->product->name : '';
}
public function getUnitAttribute(): string
{
return $this->product ? $this->product->base_unit : '';
}
public function purchaseOrder(): BelongsTo
{
return $this->belongsTo(PurchaseOrder::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -1,48 +0,0 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Vendor extends Model
{
protected $fillable = [
'code',
'name',
'short_name',
'tax_id',
'owner',
'contact_name',
'tel',
'phone',
'email',
'address',
'remark'
];
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'product_vendor')
->withPivot('last_price')
->withTimestamps();
}
public function purchaseOrders(): HasMany
{
return $this->hasMany(PurchaseOrder::class);
}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Warehouse extends Model
{
/** @use HasFactory<\Database\Factories\WarehouseFactory> */
use HasFactory;
protected $fillable = [
'code',
'name',
'address',
'description',
];
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Inventory::class);
}
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrder::class);
}
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Product::class, 'inventories')
->withPivot(['quantity', 'safety_stock', 'location'])
->withTimestamps();
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Modules\Core\Contracts;
use Illuminate\Support\Collection;
interface CoreServiceInterface
{
/**
* Get multiple users by their IDs.
*
* @param array $ids
* @return Collection
*/
public function getUsersByIds(array $ids): Collection;
/**
* Get a specific user by ID.
*
* @param int $id
* @return object|null
*/
public function getUser(int $id): ?object;
/**
* Get all users.
*
* @return Collection
*/
public function getAllUsers(): Collection;
/**
* Get the system user or create one if not exists.
*
* @return object
*/
public function ensureSystemUserExists();
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Activitylog\Models\Activity;
class ActivityLogController extends Controller
{
private function getSubjectMap()
{
return [
'App\Modules\Core\Models\User' => '使用者',
'App\Modules\Core\Models\Role' => '角色',
'App\Modules\Inventory\Models\Product' => '商品',
'App\Modules\Procurement\Models\Vendor' => '廠商',
'App\Modules\Inventory\Models\Category' => '商品分類',
'App\Modules\Inventory\Models\Unit' => '單位',
'App\Modules\Procurement\Models\PurchaseOrder' => '採購單',
'App\Modules\Inventory\Models\Warehouse' => '倉庫',
'App\Modules\Inventory\Models\Inventory' => '庫存',
'App\Modules\Inventory\Models\InventoryTransaction' => '庫存異動紀錄',
'App\Modules\Finance\Models\UtilityFee' => '公共事業費',
'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單',
'App\Modules\Production\Models\ProductionOrder' => '生產工單',
'App\Modules\Production\Models\Recipe' => '生產配方',
'App\Modules\Production\Models\RecipeItem' => '配方品項',
'App\Modules\Production\Models\ProductionOrderItem' => '工單品項',
'App\Modules\Inventory\Models\InventoryCountDoc' => '庫存盤點單',
'App\Modules\Inventory\Models\InventoryAdjustDoc' => '庫存盤調單',
'App\Modules\Inventory\Models\InventoryTransferOrder' => '庫存調撥單',
'App\Modules\Inventory\Models\StoreRequisition' => '門市叫貨單',
];
}
public function index(Request $request)
{
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$sortBy = $request->input('sort_by', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
$search = $request->input('search');
$dateStart = $request->input('date_start');
$dateEnd = $request->input('date_end');
$event = $request->input('event');
$subjectType = $request->input('subject_type');
$causerId = $request->input('causer_id');
$query = Activity::with('causer');
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('log_name', 'like', "%{$search}%")
->orWhere('properties', 'like', "%{$search}%");
});
}
if ($dateStart) {
$query->whereDate('created_at', '>=', $dateStart);
}
if ($dateEnd) {
$query->whereDate('created_at', '<=', $dateEnd);
}
if ($event) {
$query->where('event', $event);
}
if ($subjectType) {
$query->where('subject_type', $subjectType);
}
if ($causerId) {
$query->where('causer_id', $causerId);
}
if ($sortBy === 'created_at') {
$query->orderBy($sortBy, $sortOrder);
} else {
$query->latest();
}
$activities = $query->paginate($perPage)
->withQueryString()
->through(function ($activity) {
$subjectMap = $this->getSubjectMap();
$eventMap = [
'created' => '新增',
'updated' => '更新',
'deleted' => '刪除',
];
return [
'id' => $activity->id,
'description' => $eventMap[$activity->event] ?? $activity->event,
'subject_type' => $subjectMap[$activity->subject_type] ?? class_basename($activity->subject_type),
'event' => $activity->event,
'causer' => $activity->causer ? $activity->causer->name : 'System',
'created_at' => $activity->created_at->format('Y-m-d H:i:s'),
'properties' => $activity->properties,
];
});
// 準備用於前端篩選的主題類型
$subjectTypes = collect($this->getSubjectMap())->map(function ($label, $value) {
return ['label' => $label, 'value' => $value];
})->values();
// 取得用於操作者篩選的使用者
$users = \App\Modules\Core\Models\User::select('id', 'name')->orderBy('name')->get()
->map(function ($user) {
return ['label' => $user->name, 'value' => (string) $user->id];
});
return Inertia::render('Admin/ActivityLog/Index', [
'activities' => $activities,
'filters' => [
'per_page' => $request->input('per_page', '10'),
'sort_by' => $request->input('sort_by'),
'sort_order' => $request->input('sort_order'),
'search' => $request->input('search'),
'date_start' => $request->input('date_start'),
'date_end' => $request->input('date_end'),
'event' => $request->input('event'),
'subject_type' => $request->input('subject_type'),
'causer_id' => $request->input('causer_id'),
],
'subject_types' => $subjectTypes,
'users' => $users,
]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Modules\Core\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Cookie;
class LoginController extends Controller
{
/**
* Display the login view.
*/
public function show()
{
$centralDomains = config('tenancy.central_domains', []);
// [Hack] Demo 環境特殊規則
$demoPort = config('tenancy.demo_tenant_port');
if ((!$demoPort || request()->getPort() != $demoPort) && in_array(request()->getHost(), $centralDomains)) {
return Inertia::render('Landlord/Auth/Login');
}
return Inertia::render('Auth/Login');
}
/**
* Handle an incoming authentication request.
*/
public function store(Request $request)
{
$request->validate([
'username' => ['required', 'string'],
'password' => ['required', 'string'],
], [
'username.required' => '請輸入帳號',
'password.required' => '請輸入密碼',
]);
$credentials = $request->only('username', 'password');
if (Auth::attempt($credentials, $request->boolean('remember'))) {
// Check activation status
if (!Auth::user()->is_active) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
throw ValidationException::withMessages([
'username' => '此帳號已被停用,請聯繫管理員。',
]);
}
$request->session()->regenerate();
$centralDomains = config('tenancy.central_domains', []);
// [Hack] Demo 環境特殊規則
$demoPort = config('tenancy.demo_tenant_port');
if ((!$demoPort || $request->getPort() != $demoPort) && in_array($request->getHost(), $centralDomains)) {
return Inertia::location(route('landlord.dashboard'));
}
return Inertia::location(route('dashboard'));
}
throw ValidationException::withMessages([
'username' => '帳號或密碼錯誤。',
]);
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request)
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// 強制清除 Session Cookie (對付 HTTPS/Proxy 環境下的殘留問題)
$sessionCookieName = config('session.cookie');
Cookie::queue(Cookie::forget($sessionCookieName));
return redirect('/');
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Sales\Contracts\SalesServiceInterface;
use App\Modules\Production\Contracts\ProductionServiceInterface;
use Inertia\Inertia;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
protected $inventoryService;
protected $procurementService;
protected $salesService;
protected $productionService;
public function __construct(
InventoryServiceInterface $inventoryService,
ProcurementServiceInterface $procurementService,
SalesServiceInterface $salesService,
ProductionServiceInterface $productionService
) {
$this->inventoryService = $inventoryService;
$this->procurementService = $procurementService;
$this->salesService = $salesService;
$this->productionService = $productionService;
}
public function index()
{
$centralDomains = config('tenancy.central_domains', []);
$demoPort = config('tenancy.demo_tenant_port');
if ((!$demoPort || request()->getPort() != $demoPort) && in_array(request()->getHost(), $centralDomains)) {
return redirect()->route('landlord.dashboard');
}
$invStats = $this->inventoryService->getDashboardStats();
$procStats = $this->procurementService->getDashboardStats();
// 銷售統計 (本月營收)
$thisMonthRevenue = $this->salesService->getThisMonthRevenue();
// 生產統計 (待核准工單)
$pendingProductionCount = $this->productionService->getPendingProductionCount();
// 生產狀態分佈
// 近30日銷售趨勢 (Area Chart)
$salesTrend = $this->salesService->getSalesTrend();
// 本月熱銷商品 Top 5 (Bar Chart)
$topSellingItems = $this->salesService->getTopSellingProducts();
$productIds = $topSellingItems->pluck('product_id')->filter()->unique()->toArray();
$productsMap = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$topSellingProducts = $topSellingItems->map(function ($item) use ($productsMap) {
$product = $productsMap->get($item->product_id);
return [
'name' => $product ? $product->name : $item->product_code,
'amount' => (int)$item->total_amount,
];
});
// 庫存積壓排行 (Top Inventory Value)
$topInventoryValueItems = $this->inventoryService->getTopInventoryValue();
$invProductIds = $topInventoryValueItems->pluck('product_id')->filter()->unique()->toArray();
$invProductsMap = $this->inventoryService->getProductsByIds($invProductIds)->keyBy('id');
$topInventoryValue = $topInventoryValueItems->map(function ($item) use ($invProductsMap) {
$product = $invProductsMap->get($item->product_id);
return [
'name' => $product ? $product->name : 'Unknown Product',
'code' => $product ? $product->code : '',
'value' => (int)$item->total_value,
];
});
// 熱銷數量排行 (Top Selling by Quantity)
$topSellingQtyItems = $this->salesService->getTopSellingByQuantity();
$qtyProductIds = $topSellingQtyItems->pluck('product_id')->filter()->unique()->toArray();
$qtyProductsMap = $this->inventoryService->getProductsByIds($qtyProductIds)->keyBy('id');
$topSellingByQuantity = $topSellingQtyItems->map(function ($item) use ($qtyProductsMap) {
$product = $qtyProductsMap->get($item->product_id);
return [
'name' => $product ? $product->name : $item->product_code,
'code' => $item->product_code,
'value' => (int)$item->total_quantity,
];
});
// 即將過期商品 (Expiring Soon)
$expiringItems = $this->inventoryService->getExpiringSoon();
$expiringProductIds = $expiringItems->pluck('product_id')->filter()->unique()->toArray();
$expiringProductsMap = $this->inventoryService->getProductsByIds($expiringProductIds)->keyBy('id');
$expiringSoon = $expiringItems->map(function ($item) use ($expiringProductsMap) {
$product = $expiringProductsMap->get($item->product_id);
return [
'name' => $product ? $product->name : 'Unknown Product',
'batch_number' => $item->batch_number,
'expiry_date' => $item->expiry_date->format('Y-m-d'),
'quantity' => (int)$item->quantity,
];
});
return Inertia::render('Dashboard', [
'stats' => [
'totalItems' => $invStats['productsCount'],
'lowStockCount' => $invStats['lowStockCount'],
'negativeCount' => $invStats['negativeCount'] ?? 0,
'expiringCount' => $invStats['expiringCount'] ?? 0,
'totalInventoryValue' => $invStats['totalInventoryValue'] ?? 0,
'thisMonthRevenue' => $thisMonthRevenue,
'pendingOrdersCount' => $procStats['pendingOrdersCount'] ?? 0,
'pendingTransferCount' => $invStats['pendingTransferCount'] ?? 0,
'pendingProductionCount' => $pendingProductionCount,
'todoCount' => ($procStats['pendingOrdersCount'] ?? 0) + ($invStats['pendingTransferCount'] ?? 0) + $pendingProductionCount,
'salesTrend' => $salesTrend,
'topSellingProducts' => $topSellingProducts,
'topInventoryValue' => $topInventoryValue,
'topSellingByQuantity' => $topSellingByQuantity,
'expiringSoon' => $expiringSoon,
],
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
/**
* Mark a specific notification as read.
*/
public function markAsRead(Request $request, string $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->markAsRead();
return back();
}
/**
* Mark all notifications as read.
*/
public function markAllAsRead(Request $request)
{
$request->user()->unreadNotifications->markAsRead();
return back();
}
/**
* Check for new notifications.
*/
public function check(Request $request)
{
return response()->json([
'unread_count' => $request->user()->unreadNotifications()->count(),
'latest' => $request->user()->notifications()->latest()->limit(10)->get(),
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
class ProfileController extends Controller
{
/**
* 顯示使用者設定頁面
*/
public function edit(Request $request)
{
return Inertia::render('Profile/Edit', [
'user' => $request->user(),
]);
}
/**
* 更新使用者基本資料
*/
public function update(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:users,username,' . $request->user()->id],
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
]);
$request->user()->update($validated);
return back()->with('success', '個人資料已更新');
}
/**
* 更新密碼
*/
public function updatePassword(Request $request)
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('success', '密碼已更新');
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Inertia\Inertia;
use Illuminate\Validation\Rule;
class RoleController extends Controller
{
/**
* 顯示資源列表。
*/
public function index(Request $request)
{
$sortBy = $request->input('sort_by', 'id');
$sortOrder = $request->input('sort_order', 'asc');
$query = Role::withCount('users', 'permissions')
->with('users:id,name,username');
// 處理排序
if (in_array($sortBy, ['users_count', 'permissions_count', 'created_at', 'id'])) {
$query->orderBy($sortBy, $sortOrder);
} else {
$query->orderBy('id', 'asc');
}
$roles = $query->get();
return Inertia::render('Admin/Role/Index', [
'roles' => $roles,
'filters' => $request->only(['sort_by', 'sort_order']),
]);
}
/**
* 顯示建立新資源的表單。
*/
public function create()
{
$permissions = $this->getGroupedPermissions();
return Inertia::render('Admin/Role/Create', [
'groupedPermissions' => $permissions
]);
}
/**
* 將新建立的資源儲存到儲存體中。
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', 'unique:roles,name'],
'display_name' => ['required', 'string', 'max:255'],
'permissions' => ['array'],
'permissions.*' => ['exists:permissions,name']
]);
$role = Role::create([
'name' => $validated['name'],
'display_name' => $validated['display_name']
]);
if (!empty($validated['permissions'])) {
$role->syncPermissions($validated['permissions']);
}
return redirect()->route('roles.index')->with('success', '角色建立成功');
}
/**
* 顯示編輯指定資源的表單。
*/
public function edit(string $id)
{
$role = Role::with('permissions')->findOrFail($id);
// 禁止編輯超級管理員角色
if ($role->name === 'super-admin') {
return redirect()->route('roles.index')->with('error', '超級管理員角色不可編輯');
}
$groupedPermissions = $this->getGroupedPermissions();
$currentPermissions = $role->permissions->pluck('name')->toArray();
return Inertia::render('Admin/Role/Edit', [
'role' => $role,
'groupedPermissions' => $groupedPermissions,
'currentPermissions' => $currentPermissions
]);
}
/**
* 更新儲存體中的指定資源。
*/
public function update(Request $request, string $id)
{
$role = Role::findOrFail($id);
if ($role->name === 'super-admin') {
return redirect()->route('roles.index')->with('error', '超級管理員角色不可變更');
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')->ignore($role->id)],
'display_name' => ['required', 'string', 'max:255'],
'permissions' => ['array'],
'permissions.*' => ['exists:permissions,name']
]);
$role->update([
'name' => $validated['name'],
'display_name' => $validated['display_name']
]);
if (isset($validated['permissions'])) {
$role->syncPermissions($validated['permissions']);
}
return back()->with('success', '角色更新成功');
}
/**
* 從儲存體中移除指定資源。
*/
public function destroy(string $id)
{
$role = Role::withCount('users')->findOrFail($id);
if ($role->name === 'super-admin') {
return back()->with('error', '超級管理員角色不可刪除');
}
if ($role->users_count > 0) {
return back()->with('error', "尚有 {$role->users_count} 位使用者屬於此角色,無法刪除");
}
$role->delete();
return redirect()->route('roles.index')->with('success', '角色已刪除');
}
/**
* 取得並分組權限
*/
private function getGroupedPermissions()
{
$allPermissions = Permission::select('id', 'name', 'guard_name')->orderBy('name')->get();
$grouped = [];
foreach ($allPermissions as $permission) {
$parts = explode('.', $permission->name);
$group = $parts[0];
$action = $parts[1] ?? '';
// 特定權限遷移邏輯
if ($permission->name === 'inventory.view_cost') {
$group = 'inventory';
}
// 移除不再使用的權限選項
if (in_array($permission->name, ['inventory.count', 'inventory.transfer'])) {
continue;
}
if (!isset($grouped[$group])) {
$grouped[$group] = [];
}
$grouped[$group][] = $permission;
}
// 依照側邊欄順序定義
$groupDefinitions = [
'products' => '商品資料管理',
'warehouses' => '倉庫管理',
'inventory' => '庫存資料管理',
'inventory_count' => '庫存盤點管理',
'inventory_adjust' => '庫存盤調管理',
'inventory_transfer' => '庫存調撥管理',
'inventory_report' => '庫存報表',
'inventory_traceability' => '批號溯源',
'vendors' => '廠商資料管理',
'purchase_orders' => '採購單管理',
'purchase_returns' => '採購退回管理',
'goods_receipts' => '進貨單管理',
'delivery_notes' => '出貨單管理',
'recipes' => '配方管理',
'production_orders' => '生產工單管理',
'utility_fees' => '公共事業費管理',
'accounting' => '會計報表',
'account_payables' => '應付帳款',
'sales_imports' => '銷售單匯入管理',
'sales_orders' => '銷售訂單管理',
'store_requisitions' => '門市叫貨申請',
'procurement_analysis' => '採購統計分析',
'users' => '使用者管理',
'roles' => '角色與權限',
'system' => '系統管理',
];
$result = [];
foreach ($groupDefinitions as $key => $displayName) {
if (isset($grouped[$key])) {
$result[] = [
'key' => $key,
'name' => $displayName,
'permissions' => $grouped[$key]
];
unset($grouped[$key]); // 從待處理中移除
}
}
// 處理剩餘未定義在 groupDefinitions 中的群組 (安全機制)
foreach ($grouped as $key => $permissions) {
$result[] = [
'key' => $key,
'name' => ucfirst($key),
'permissions' => $permissions
];
}
return $result;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Core\Models\SystemSetting;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SystemSettingController extends Controller
{
/**
* 顯示系統設定頁面
*/
public function index()
{
$settings = SystemSetting::all()->groupBy('group');
return Inertia::render('Admin/Setting/Index', [
'settings' => $settings,
]);
}
/**
* 更新系統設定
*/
public function update(Request $request)
{
$validated = $request->validate([
'settings' => 'required|array',
'settings.*.key' => 'required|string|exists:system_settings,key',
'settings.*.value' => 'nullable',
]);
foreach ($validated['settings'] as $item) {
SystemSetting::where('key', $item['key'])->update([
'value' => $item['value']
]);
}
// 清除記憶體快取,確保後續讀取拿到最新值
SystemSetting::clearCache();
return redirect()->back()->with('success', '系統設定已更新');
}
/**
* 測試發送通知信
*/
public function testNotification(Request $request)
{
$validated = $request->validate([
'settings' => 'required|array',
'settings.*.key' => 'required|string',
'settings.*.value' => 'nullable|string',
]);
$settings = collect($validated['settings'])->pluck('value', 'key');
$senderEmail = $settings['notification.utility_fee_sender_email'] ?? null;
$senderPassword = $settings['notification.utility_fee_sender_password'] ?? null;
$recipientEmailsStr = $settings['notification.utility_fee_recipient_emails'] ?? null;
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
return back()->with('error', '請先填寫完整發信帳號、密碼及收件者信箱。');
}
// 動態覆寫應用程式名稱與 SMTP Config
$tenantName = tenant('name') ?? config('app.name');
config([
'app.name' => $tenantName,
'mail.mailers.smtp.username' => $senderEmail,
'mail.mailers.smtp.password' => $senderPassword,
'mail.from.address' => $senderEmail,
'mail.from.name' => $tenantName . ' (系統通知)'
]);
// 清理原先可能的 Mailer 實例,確保使用新的 Config
\Illuminate\Support\Facades\Mail::purge();
// 解析收件者
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
if (empty($validRecipients)) {
return back()->with('error', '無效的收件者 Email 格式。');
}
try {
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\TestNotificationMail());
return back()->with('success', '測試信件已成功發送,請檢查收件匣。');
} catch (\Exception $e) {
return back()->with('error', '測試發信失敗: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,354 @@
<?php
namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Core\Models\User;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Inertia\Inertia;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Hash;
class UserController extends Controller
{
/**
* 顯示資源列表。
*/
public function index(Request $request)
{
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$sortBy = $request->input('sort_by', 'id');
$sortOrder = $request->input('sort_order', 'asc');
$search = $request->input('search');
$roleId = $request->input('role');
$isActive = $request->input('is_active'); // 'all', '1', '0'
$query = User::query();
// 隱藏超級管理員:若非 super-admin則不可看到 super-admin 過往
if (!auth()->user()->hasRole('super-admin')) {
$query->whereDoesntHave('roles', function ($q) {
$q->where('name', 'super-admin');
});
// 預載入角色時也過濾掉 super-admin 標籤
$query->with(['roles' => function ($q) {
$q->select('id', 'name', 'display_name')
->where('name', '!=', 'super-admin');
}]);
} else {
$query->with(['roles:id,name,display_name']);
}
// 處理搜尋
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%");
});
}
// 處理角色篩選
if ($roleId && $roleId !== 'all') {
$query->whereHas('roles', function ($q) use ($roleId) {
$q->where('id', $roleId);
});
}
// 處理狀態篩選
if ($isActive !== null && $isActive !== 'all') {
$query->where('is_active', $isActive === '1' || $isActive === 'true');
}
// 處理排序
if (in_array($sortBy, ['name', 'created_at'])) {
$query->orderBy($sortBy, $sortOrder);
} else {
$query->orderBy('id', 'asc');
}
$users = $query->paginate($perPage)->withQueryString();
// 只能看到自己權限以下的角色
$rolesQuery = Role::select('id', 'name', 'display_name');
if (!auth()->user()->hasRole('super-admin')) {
$rolesQuery->where('name', '!=', 'super-admin');
}
$roles = $rolesQuery->get();
return Inertia::render('Admin/User/Index', [
'users' => $users,
'users' => $users,
'roles' => $roles,
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role', 'is_active']),
]);
}
/**
* 顯示建立新資源的表單。
*/
public function create()
{
$rolesQuery = Role::query();
if (!auth()->user()->hasRole('super-admin')) {
$rolesQuery->where('name', '!=', 'super-admin');
}
$roles = $rolesQuery->pluck('display_name', 'name');
return Inertia::render('Admin/User/Create', [
'roles' => $roles
]);
}
/**
* 將新建立的資源儲存到儲存體中。
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users'],
'username' => ['required', 'string', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'roles' => ['array'],
'is_active' => ['boolean'],
], [
'password.required' => '請輸入密碼',
'password.min' => '密碼長度至少需 :min 個字元',
'password.confirmed' => '密碼確認不符',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'username' => $validated['username'],
'password' => Hash::make($validated['password']),
'is_active' => $request->boolean('is_active', true),
]);
if (!empty($validated['roles'])) {
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
abort(403, '您沒有權限指派系統管理員角色');
}
$user->syncRoles($validated['roles']);
// 更新 'created' 紀錄以包含角色資訊
$activity = \Spatie\Activitylog\Models\Activity::where('subject_type', get_class($user))
->where('subject_id', $user->id)
->where('event', 'created')
->latest()
->first();
if ($activity) {
$roleNames = $user->roles()->pluck('display_name')->join(', ');
$properties = $activity->properties->toArray();
$properties['attributes']['role_id'] = $roleNames;
$activity->properties = $properties;
$activity->save();
}
}
return redirect()->route('users.index')->with('success', '使用者建立成功');
}
/**
* 顯示編輯指定資源的表單。
*/
public function edit(string $id)
{
$user = User::with('roles')->findOrFail($id);
// 安全檢查:非 super-admin 不能編輯 super-admin
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
abort(403, '您沒有權限編輯系統管理員');
}
$rolesQuery = Role::select('id', 'name', 'display_name');
if (!auth()->user()->hasRole('super-admin')) {
$rolesQuery->where('name', '!=', 'super-admin');
}
$roles = $rolesQuery->get();
return Inertia::render('Admin/User/Edit', [
'user' => $user,
'roles' => $roles,
'currentRoles' => $user->getRoleNames()
]);
}
/**
* 更新儲存體中的指定資源。
*/
public function update(Request $request, string $id)
{
$user = User::findOrFail($id);
// 安全檢查:非 super-admin 不能更新 super-admin
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
abort(403, '您沒有權限編輯系統管理員');
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'username' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)],
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
'roles' => ['array'],
'is_active' => ['boolean'],
], [
'password.min' => '密碼長度至少需 :min 個字元',
'password.confirmed' => '密碼確認不符',
]);
// 1. 準備資料並偵測變更
$userData = [
'name' => $validated['name'],
'email' => $validated['email'],
'username' => $validated['username'],
];
$user->fill($userData);
// 捕捉變更屬性以進行手動記錄
$dirty = $user->getDirty();
$oldAttributes = [];
$newAttributes = [];
foreach ($dirty as $key => $value) {
$oldAttributes[$key] = $user->getOriginal($key);
$newAttributes[$key] = $value;
}
// 儲存但不觸發事件(防止重複記錄)
$user->saveQuietly();
// 2. 處理角色
$roleChanges = null;
if (isset($validated['roles'])) {
// 安全檢查:非 super-admin 不能賦予 super-admin 角色
if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) {
abort(403, '您沒有權限指派系統管理員角色');
}
$oldRoles = $user->roles()->pluck('display_name')->join(', ');
$user->syncRoles($validated['roles']);
$newRoles = $user->roles()->pluck('display_name')->join(', ');
if ($oldRoles !== $newRoles) {
$roleChanges = [
'old' => $oldRoles,
'new' => $newRoles
];
}
}
// 3. 手動記錄活動(單一整合記錄)
if (!empty($newAttributes) || $roleChanges) {
$properties = [
'attributes' => $newAttributes,
'old' => $oldAttributes,
];
if ($roleChanges) {
$properties['attributes']['role_id'] = $roleChanges['new'];
$properties['old']['role_id'] = $roleChanges['old'];
}
activity()
->performedOn($user)
->causedBy(auth()->user())
->event('updated')
->withProperties($properties)
->tap(function (\Spatie\Activitylog\Contracts\Activity $activity) use ($user) {
// 手動加入快照,因為使用 saveQuietly 所以不使用模型的 LogOptions
$activity->properties = $activity->properties->merge([
'snapshot' => [
'name' => $user->name,
'username' => $user->username,
]
]);
})
->log('updated');
}
return redirect()->route('users.index')->with('success', '使用者更新成功');
}
/**
* 從儲存體中移除指定資源。
*/
public function destroy(string $id)
{
$user = User::findOrFail($id);
// 安全檢查:非 super-admin 不能刪除 super-admin
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
abort(403, '您沒有權限刪除系統管理員');
}
if ($user->hasRole('super-admin')) {
return back()->with('error', '無法刪除超級管理員帳號');
}
if ($user->id === auth()->id()) {
return back()->with('error', '無法刪除自己');
}
$user->delete();
return redirect()->route('users.index')->with('success', "使用者「{$user->name}」已刪除");
}
/**
* 切換使用者啟用/停用狀態
*/
public function toggleActive(string $id)
{
$user = User::findOrFail($id);
// 安全檢查:不能停用自己
if ($user->id === auth()->id() && $user->is_active) {
return back()->with('error', '無法停用自己的帳號');
}
// 安全檢查:非 super-admin 不能停用 super-admin
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
abort(403, '您沒有權限變更系統管理員狀態');
}
$oldStatus = $user->is_active;
$user->is_active = !$oldStatus;
$user->save();
// 記錄活動
activity()
->performedOn($user)
->causedBy(auth()->user())
->event('updated')
->withProperties([
'attributes' => ['is_active' => $user->is_active],
'old' => ['is_active' => $oldStatus],
'snapshot' => [
'name' => $user->name,
'username' => $user->username,
]
])
->log('updated');
$statusText = $user->is_active ? '已啟用' : '已停用';
return back()->with('success', "使用者「{$user->name}{$statusText}");
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Core;
use Illuminate\Support\ServiceProvider;
use App\Modules\Core\Contracts\CoreServiceInterface;
use App\Modules\Core\Services\CoreService;
class CoreServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(CoreServiceInterface::class, CoreService::class);
}
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Core\Models;
use Spatie\Permission\Models\Role as SpatieRole;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Role extends SpatieRole
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Modules\Core\Models;
use Illuminate\Database\Eloquent\Model;
class SystemSetting extends Model
{
protected $fillable = [
'group',
'key',
'value',
'type',
'description',
];
/**
* 同請求內的記憶體快取,避免重複查詢 DB
* PHP 請求結束後自動釋放,無需額外處理失效
*/
protected static array $cache = [];
/**
* 取得特定設定值(含記憶體快取)
*/
public static function getVal(string $key, $default = null)
{
if (array_key_exists($key, static::$cache)) {
return static::$cache[$key];
}
$setting = self::where('key', $key)->first();
if (!$setting) {
static::$cache[$key] = $default;
return $default;
}
$value = $setting->value;
// 根據 type 進行類別轉換
$resolved = match ($setting->type) {
'integer', 'number' => (int) $value,
'boolean', 'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
'json', 'array' => json_decode($value, true),
default => $value,
};
static::$cache[$key] = $resolved;
return $resolved;
}
/**
* 清除記憶體快取(儲存設定後應呼叫)
*/
public static function clearCache(): void
{
static::$cache = [];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Modules\Core\Models;
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;
/**
* 租戶 Model
*
* 代表 ERP 系統中的每一個客戶公司 (如:小小冰室、酒水客戶等)
*
* 自訂屬性 (存在 data JSON 欄位中,可透過 $tenant->name 存取):
* - name: 租戶名稱 (: 小小冰室)
* - email: 聯絡信箱
* - is_active: 是否啟用
*/
class Tenant extends BaseTenant implements TenantWithDatabase
{
use HasDatabase, HasDomains;
/**
* 定義獨立欄位 ( data JSON)
* 只有 id 是獨立欄位,其他自訂屬性都存在 data JSON
*/
public static function getCustomColumns(): array
{
return [
'id',
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Modules\Core\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasRoles, LogsActivity, HasApiTokens;
/**
* 可批量賦值的屬性。
*
* @var list<string>
*/
/**
* 建立模型的新工廠實例。
*
* @return \Illuminate\Database\Eloquent\Factories\Factory
*/
protected static function newFactory()
{
return \Database\Factories\UserFactory::new();
}
protected $fillable = [
'name',
'email',
'username',
'password',
'is_active',
];
/**
* 序列化時應隱藏的屬性。
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* 取得應進行轉換的屬性。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_active' => 'boolean',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$activity->properties = $activity->properties->merge([
'snapshot' => [
'name' => $this->name,
'username' => $this->username,
]
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Core\Controllers\Auth\LoginController;
use App\Modules\Core\Controllers\DashboardController;
use App\Modules\Core\Controllers\ProfileController;
use App\Modules\Core\Controllers\RoleController;
use App\Modules\Core\Controllers\UserController;
use App\Modules\Core\Controllers\ActivityLogController;
use App\Modules\Core\Controllers\SystemSettingController;
// 登入/登出路由
Route::get('/login', [LoginController::class, 'show'])->name('login');
Route::post('/login', [LoginController::class, 'store']);
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
Route::middleware('auth')->group(function () {
// 通知
Route::post('/notifications/read-all', [\App\Modules\Core\Controllers\NotificationController::class, 'markAllAsRead'])->name('notifications.read-all');
Route::post('/notifications/{id}/read', [\App\Modules\Core\Controllers\NotificationController::class, 'markAsRead'])->name('notifications.read');
Route::get('/notifications/check', [\App\Modules\Core\Controllers\NotificationController::class, 'check'])->name('notifications.check');
// 儀表板 - 所有登入使用者皆可存取
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
// 使用者帳號設定
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password');
// 系統管理
Route::prefix('admin')->group(function () {
Route::middleware('permission:roles.view')->group(function () {
Route::get('/roles', [RoleController::class, 'index'])->name('roles.index');
Route::middleware('permission:roles.create')->group(function () {
Route::get('/roles/create', [RoleController::class, 'create'])->name('roles.create');
Route::post('/roles', [RoleController::class, 'store'])->name('roles.store');
});
Route::get('/roles/{role}/edit', [RoleController::class, 'edit'])->middleware('permission:roles.edit')->name('roles.edit');
Route::put('/roles/{role}', [RoleController::class, 'update'])->middleware('permission:roles.edit')->name('roles.update');
Route::delete('/roles/{role}', [RoleController::class, 'destroy'])->middleware('permission:roles.delete')->name('roles.destroy');
});
Route::middleware('permission:users.view')->group(function () {
Route::get('/users', [UserController::class, 'index'])->name('users.index');
Route::middleware('permission:users.create')->group(function () {
Route::get('/users/create', [UserController::class, 'create'])->name('users.create');
Route::post('/users', [UserController::class, 'store'])->name('users.store');
});
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
Route::patch('/users/{user}/toggle-active', [UserController::class, 'toggleActive'])->middleware('permission:users.activate')->name('users.toggle-active');
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
});
Route::middleware('permission:system.view_logs')->group(function () {
Route::get('/activity-logs', [ActivityLogController::class, 'index'])->name('activity-logs.index');
});
Route::middleware('permission:system.settings.view')->group(function () {
Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index');
Route::post('/settings', [SystemSettingController::class, 'update'])->name('settings.update');
Route::post('/settings/test-notification', [SystemSettingController::class, 'testNotification'])->name('settings.test-notification');
});
});
});

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Modules\Core\Services;
use App\Modules\Core\Contracts\CoreServiceInterface;
use App\Modules\Core\Models\User;
use Illuminate\Support\Collection;
class CoreService implements CoreServiceInterface
{
/**
* Get multiple users by their IDs.
*
* @param array $ids
* @return Collection
*/
public function getUsersByIds(array $ids): Collection
{
return User::select('id', 'name')->whereIn('id', $ids)->get();
}
/**
* Get a specific user by ID.
*
* @param int $id
* @return object|null
*/
public function getUser(int $id): ?object
{
return User::find($id);
}
/**
* Get all users.
*
* @return Collection
*/
public function getAllUsers(): Collection
{
return User::select('id', 'name')->get();
}
public function ensureSystemUserExists()
{
$user = User::first();
if (!$user) {
$user = User::create([
'name' => '系統管理員',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
]);
}
return $user;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Modules\Finance\Contracts;
use Illuminate\Support\Collection;
interface FinanceServiceInterface
{
/**
* Get accounting report data.
*
* @param string $start
* @param string $end
* @return array
*/
public function getAccountingReportData(string $start, string $end): array;
/**
* Get all utility fees with filters.
*
* @param array $filters
* @return mixed
*/
public function getUtilityFees(array $filters);
/**
* Get unique categories of utility fees.
*
* @return Collection
*/
public function getUniqueCategories(): Collection;
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Models\AccountPayable;
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
class AccountPayableController extends Controller
{
protected $procurementService;
protected $inventoryService;
public function __construct(
ProcurementServiceInterface $procurementService,
InventoryServiceInterface $inventoryService
) {
$this->procurementService = $procurementService;
$this->inventoryService = $inventoryService;
}
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$query = AccountPayable::with(['creator']);
// 關鍵字搜尋 (單號、供應商名稱)
if ($request->filled('search')) {
$search = $request->search;
// 透過 ProcurementService 查詢符合關鍵字的 Vendor IDs
$matchedVendors = $this->procurementService->searchVendors($search);
$vendorIds = $matchedVendors->pluck('id')->toArray();
$query->where(function ($q) use ($search, $vendorIds) {
$q->where('document_number', 'like', "%{$search}%");
if (!empty($vendorIds)) {
$q->orWhereIn('vendor_id', $vendorIds);
}
});
}
// 狀態過濾
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
}
// 供應商過濾
if ($request->filled('vendor_id') && $request->vendor_id !== 'all') {
$query->where('vendor_id', $request->vendor_id);
}
// 日期區間過濾
if ($request->filled('date_start')) {
$query->where('due_date', '>=', $request->date_start);
}
if ($request->filled('date_end')) {
$query->where('due_date', '<=', $request->date_end);
}
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$payables = $query->latest()->paginate($perPage)->withQueryString();
// Manual Hydration for Vendors
$allVendorIds = collect($payables->items())->pluck('vendor_id')->unique()->filter()->toArray();
$vendorsMap = $this->procurementService->getVendorsByIds($allVendorIds)->keyBy('id');
$payables->getCollection()->transform(function ($item) use ($vendorsMap) {
$item->vendor = $vendorsMap->get($item->vendor_id);
return $item;
});
$vendors = $this->procurementService->getAllVendors();
return Inertia::render('AccountPayable/Index', [
'payables' => $payables,
'filters' => $request->all(['search', 'status', 'vendor_id', 'date_start', 'date_end', 'per_page']),
'vendors' => $vendors,
]);
}
/**
* Display the specified resource.
*/
public function show(AccountPayable $accountPayable)
{
$accountPayable->load(['creator']);
if ($accountPayable->vendor_id) {
$accountPayable->vendor = $this->procurementService->getVendorsByIds([$accountPayable->vendor_id])->first();
}
// 嘗試加載來源單據資訊 (目前支援 goods_receipt)
$sourceDocumentCode = null;
if ($accountPayable->source_document_type === 'goods_receipt') {
$receiptData = app(\App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class)
->getGoodsReceiptData($accountPayable->source_document_id);
if ($receiptData) {
$sourceDocumentCode = $receiptData['code'] ?? null;
}
}
return Inertia::render('AccountPayable/Show', [
// 將 model 轉換成 array 加入額外資訊
'payable' => array_merge($accountPayable->toArray(), ['source_document_code' => $sourceDocumentCode]),
]);
}
/**
* 更新發票資訊
*/
public function updateInvoice(Request $request, AccountPayable $accountPayable)
{
$validated = $request->validate([
'invoice_number' => 'nullable|string|max:50',
'invoice_date' => 'nullable|date',
]);
$accountPayable->update([
'invoice_number' => $validated['invoice_number'],
'invoice_date' => $validated['invoice_date'],
]);
return back()->with('success', '發票資訊已更新');
}
/**
* 標記已付款
*/
public function pay(Request $request, AccountPayable $accountPayable)
{
$validated = $request->validate([
'payment_method' => 'required|string|max:50',
'paid_at' => 'required|date',
'payment_note' => 'nullable|string|max:255',
]);
if ($accountPayable->status === AccountPayable::STATUS_PAID) {
return back()->with('error', '該帳款已經標記為已付款');
}
$accountPayable->update([
'status' => AccountPayable::STATUS_PAID,
'payment_method' => $validated['payment_method'],
'paid_at' => $validated['paid_at'],
'payment_note' => $validated['payment_note'],
]);
return back()->with('success', '帳款已成功標記為已付款');
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Carbon;
use Illuminate\Pagination\LengthAwarePaginator;
class AccountingReportController extends Controller
{
protected $financeService;
public function __construct(FinanceServiceInterface $financeService)
{
$this->financeService = $financeService;
}
public function index(Request $request)
{
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
$allRecords = $reportData['records'];
// 3. Manual Pagination
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$page = $request->input('page', 1);
$offset = ($page - 1) * $perPage;
$paginatedRecords = new LengthAwarePaginator(
$allRecords->slice($offset, $perPage)->values(),
$allRecords->count(),
$perPage,
$page,
['path' => $request->url(), 'query' => $request->query()]
);
return Inertia::render('Accounting/Report', [
'records' => $paginatedRecords,
'summary' => $reportData['summary'],
'filters' => [
'date_start' => $dateStart,
'date_end' => $dateEnd,
'per_page' => (int)$perPage,
],
]);
}
public function export(Request $request)
{
$dateStart = $request->input('date_start', Carbon::now()->toDateString());
$dateEnd = $request->input('date_end', Carbon::now()->toDateString());
$selectedIdsParam = $request->input('selected_ids');
$reportData = $this->financeService->getAccountingReportData($dateStart, $dateEnd);
$allRecords = $reportData['records'];
if ($selectedIdsParam) {
$ids = explode(',', $selectedIdsParam);
$allRecords = $allRecords->whereIn('id', $ids);
}
$exportData = $allRecords->map(function ($record) {
$taxAmount = (float)($record['tax_amount'] ?? 0);
$totalAmount = (float)($record['amount'] ?? 0);
$untaxedAmount = $totalAmount - $taxAmount;
return [
$record['date'],
$record['source'],
$record['category'],
$record['item'],
$record['reference'],
$record['invoice_date'] ?? '-',
$record['invoice_number'] ?? '-',
$untaxedAmount,
$taxAmount,
$totalAmount,
$record['payment_method'] ?? '-',
$record['payment_note'] ?? '-',
$record['remarks'] ?? '-',
$record['status'] ?? '-',
];
});
$filename = "accounting_report_{$dateStart}_{$dateEnd}.csv";
$headers = [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
];
$callback = function () use ($exportData) {
$file = fopen('php://output', 'w');
// BOM for Excel compatibility with UTF-8
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($file, [
'日期', '來源', '類別', '項目', '參考單號',
'發票日期', '發票號碼', '未稅金額', '稅額', '總金額',
'付款方式', '付款備註', '內部備註', '狀態'
]);
foreach ($exportData as $row) {
fputcsv($file, $row);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Models\UtilityFee;
use App\Modules\Finance\Models\UtilityFeeAttachment;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
class UtilityFeeController extends Controller
{
protected $financeService;
public function __construct(FinanceServiceInterface $financeService)
{
$this->financeService = $financeService;
}
public function index(Request $request)
{
$filters = $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']);
$fees = $this->financeService->getUtilityFees($filters)->withQueryString();
$availableCategories = $this->financeService->getUniqueCategories();
return Inertia::render('UtilityFee/Index', [
'fees' => $fees,
'availableCategories' => $availableCategories,
'filters' => $filters,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'transaction_date' => 'nullable|date',
'due_date' => 'required|date',
'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$validated['payment_status'] = $this->determineStatus($validated);
$fee = UtilityFee::create($validated);
activity()
->performedOn($fee)
->causedBy(auth()->user())
->event('created')
->log('created');
return redirect()->back();
}
public function update(Request $request, UtilityFee $utility_fee)
{
$validated = $request->validate([
'transaction_date' => 'nullable|date',
'due_date' => 'required|date',
'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$validated['payment_status'] = $this->determineStatus($validated);
$utility_fee->update($validated);
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('updated')
->log('updated');
return redirect()->back();
}
/**
* 判定繳費狀態
*/
private function determineStatus(array $data): string
{
if (!empty($data['transaction_date'])) {
return UtilityFee::STATUS_PAID;
}
if (!empty($data['due_date']) && now()->startOfDay()->gt(\Illuminate\Support\Carbon::parse($data['due_date']))) {
return UtilityFee::STATUS_OVERDUE;
}
return UtilityFee::STATUS_PENDING;
}
public function destroy(UtilityFee $utility_fee)
{
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('deleted')
->log('deleted');
// 刪除實體檔案 (如果 cascade 沒處理或是想要手動清理)
foreach ($utility_fee->attachments as $attachment) {
Storage::disk('public')->delete($attachment->file_path);
}
$utility_fee->delete();
return redirect()->back();
}
/**
* 獲取附件列表
*/
public function attachments(UtilityFee $utility_fee)
{
return response()->json([
'attachments' => $utility_fee->attachments()->orderBy('created_at', 'desc')->get()
]);
}
/**
* 上傳附件
*/
public function uploadAttachment(Request $request, UtilityFee $utility_fee)
{
$request->validate([
'file' => 'required|file|mimes:jpeg,jpg,png,webp,pdf|max:2048', // 2MB
]);
// 檢查數量限制 (最多 3 張)
if ($utility_fee->attachments()->count() >= 3) {
return response()->json(['message' => '附件數量已達上限 (最多 3 個)'], 422);
}
$file = $request->file('file');
$path = $file->store("utility-fee-attachments/{$utility_fee->id}", 'public');
$attachment = $utility_fee->attachments()->create([
'file_path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
]);
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('attachment_uploaded')
->log("uploaded attachment: {$attachment->original_name}");
return response()->json([
'message' => '上傳成功',
'attachment' => $attachment
]);
}
/**
* 刪除附件
*/
public function deleteAttachment(UtilityFee $utility_fee, UtilityFeeAttachment $attachment)
{
// 確保附件屬於該費用
if ($attachment->utility_fee_id !== $utility_fee->id) {
abort(403);
}
Storage::disk('public')->delete($attachment->file_path);
$attachment->delete();
activity()
->performedOn($utility_fee)
->causedBy(auth()->user())
->event('attachment_deleted')
->log("deleted attachment: {$attachment->original_name}");
return response()->json(['message' => '刪除成功']);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Modules\Finance;
use Illuminate\Support\ServiceProvider;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use App\Modules\Finance\Services\FinanceService;
class FinanceServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(FinanceServiceInterface::class, FinanceService::class);
}
public function boot(): void
{
\Illuminate\Support\Facades\Event::listen(
\App\Modules\Inventory\Events\GoodsReceiptApprovedEvent::class,
\App\Modules\Finance\Listeners\CreateAccountPayableFromGoodsReceipt::class
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Finance\Listeners;
use App\Modules\Inventory\Events\GoodsReceiptApprovedEvent;
use App\Modules\Finance\Services\AccountPayableService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
class CreateAccountPayableFromGoodsReceipt
{
protected AccountPayableService $accountPayableService;
/**
* Create the event listener.
*/
public function __construct(AccountPayableService $accountPayableService)
{
$this->accountPayableService = $accountPayableService;
}
/**
* Handle the event.
*/
public function handle(GoodsReceiptApprovedEvent $event): void
{
try {
// 目前使用系統預設 User ID 或 0 作為自動生成的建立者,若能從 event 取得更好
$userId = auth()->id() ?? 1; // 假設 1 為系統管理員或預設使用者
$this->accountPayableService->createFromGoodsReceipt($event->goodsReceiptId, $userId);
Log::info("已成功為進貨單 ID: {$event->goodsReceiptId} 建立應付帳款");
} catch (\Exception $e) {
Log::error("建立應付帳款失敗 (進貨單 ID: {$event->goodsReceiptId}): " . $e->getMessage());
// 根據需求決定是否拋出 exception 或只記錄 log
throw $e;
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Modules\Finance\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AccountPayable extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_PARTIALLY_PAID = 'partially_paid';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'vendor_id',
'source_document_type',
'source_document_id',
'document_number',
'total_amount',
'tax_amount',
'status',
'due_date',
'invoice_number',
'invoice_date',
'paid_at',
'payment_method',
'payment_note',
'remarks',
'created_by',
];
protected $casts = [
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'due_date' => 'date',
'invoice_date' => 'date',
'paid_at' => 'datetime',
];
// vendor 關聯移至 service (跨模組)
/**
* 關聯:建立者
* @return BelongsTo
*/
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Modules\Core\Models\User::class, 'created_by');
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Modules\Finance\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UtilityFee extends Model
{
use HasFactory;
/**
* 此公共事業費的附件
*/
public function attachments()
{
return $this->hasMany(UtilityFeeAttachment::class);
}
// 狀態常數
const STATUS_PENDING = 'pending';
const STATUS_PAID = 'paid';
const STATUS_OVERDUE = 'overdue';
protected $fillable = [
'transaction_date',
'due_date',
'category',
'amount',
'payment_status',
'invoice_number',
'description',
];
protected $casts = [
'transaction_date' => 'date:Y-m-d',
'due_date' => 'date:Y-m-d',
'amount' => 'decimal:2',
];
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$snapshot = [
'transaction_date' => $this->transaction_date?->format('Y-m-d'),
'due_date' => $this->due_date?->format('Y-m-d'),
'category' => $this->category,
'amount' => $this->amount,
'payment_status' => $this->payment_status,
'invoice_number' => $this->invoice_number,
];
$activity->properties = $activity->properties->put('snapshot', $snapshot);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Modules\Finance\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class UtilityFeeAttachment extends Model
{
protected $fillable = [
'utility_fee_id',
'file_path',
'original_name',
'mime_type',
'size',
];
protected $appends = ['url'];
/**
* 附件所属的公共事業費
*/
public function utilityFee(): BelongsTo
{
return $this->belongsTo(UtilityFee::class);
}
/**
* 獲取附件的全路徑 URL
*/
public function getUrlAttribute()
{
return tenant_asset($this->file_path);
}
}

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Finance\Controllers\UtilityFeeController;
use App\Modules\Finance\Controllers\AccountingReportController;
use App\Modules\Finance\Controllers\AccountPayableController;
Route::middleware('auth')->group(function () {
// 應付帳款
Route::group(['prefix' => 'finance'], function () {
Route::middleware('permission:account_payables.view')->group(function () {
Route::get('/account-payables', [AccountPayableController::class, 'index'])->name('account-payables.index');
Route::get('/account-payables/{accountPayable}', [AccountPayableController::class, 'show'])->name('account-payables.show');
});
Route::middleware('permission:account_payables.edit')->group(function () {
Route::post('/account-payables/{accountPayable}/invoice', [AccountPayableController::class, 'updateInvoice'])->name('account-payables.invoice');
});
Route::middleware('permission:account_payables.pay')->group(function () {
Route::post('/account-payables/{accountPayable}/pay', [AccountPayableController::class, 'pay'])->name('account-payables.pay');
});
});
// 公共事業費管理
Route::middleware('permission:utility_fees.view')->group(function () {
Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index');
});
Route::middleware('permission:utility_fees.create')->group(function () {
Route::post('/utility-fees', [UtilityFeeController::class, 'store'])->name('utility-fees.store');
});
Route::middleware('permission:utility_fees.edit')->group(function () {
Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update');
// 附件管理 (Ajax)
Route::get('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'attachments'])->name('utility-fees.attachments');
Route::post('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'uploadAttachment'])->name('utility-fees.upload-attachment');
Route::delete('/utility-fees/{utility_fee}/attachments/{attachment}', [UtilityFeeController::class, 'deleteAttachment'])->name('utility-fees.delete-attachment');
});
Route::middleware('permission:utility_fees.delete')->group(function () {
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');
});
// 會計報表
Route::middleware('permission:accounting.view')->prefix('accounting-report')->group(function () {
Route::get('/', [AccountingReportController::class, 'index'])->name('accounting.report');
Route::get('/export', [AccountingReportController::class, 'export'])
->middleware('permission:accounting.export')
->name('accounting.export');
});
});

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Modules\Finance\Services;
use App\Modules\Finance\Models\AccountPayable;
use Illuminate\Support\Facades\DB;
use App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface;
class AccountPayableService
{
protected GoodsReceiptServiceInterface $goodsReceiptService;
public function __construct(GoodsReceiptServiceInterface $goodsReceiptService)
{
$this->goodsReceiptService = $goodsReceiptService;
}
/**
* 根據進貨單建立應付帳款
*
* @param int $goodsReceiptId
* @param int $userId 執行操作的使用者 ID
* @return AccountPayable
* @throws \Exception
*/
public function createFromGoodsReceipt(int $goodsReceiptId, int $userId): AccountPayable
{
// 透過 Contract 取得 Inventory 模組的資料,避免直接依賴 Model
$receiptData = $this->goodsReceiptService->getGoodsReceiptData($goodsReceiptId);
if (!$receiptData) {
throw new \Exception("找不到對應的進貨單資料 (ID: {$goodsReceiptId})");
}
// 檢查是否已經建立過(密等性)
$existingAp = AccountPayable::where('source_document_type', 'goods_receipt')
->where('source_document_id', $goodsReceiptId)
->first();
if ($existingAp) {
return $existingAp;
}
return DB::transaction(function () use ($receiptData, $userId) {
$ap = AccountPayable::create([
'vendor_id' => $receiptData['vendor_id'],
'source_document_type' => 'goods_receipt',
'source_document_id' => $receiptData['id'],
'document_number' => $this->generateApNumber(),
'total_amount' => collect($receiptData['items'] ?? [])->sum('total_amount'),
'tax_amount' => 0, // 假設後續會實作稅額計算,目前預設為 0
'status' => AccountPayable::STATUS_PENDING,
// 設定應付日期,預設為進貨後天數 (由系統設定決定,預設 30 天)
'due_date' => now()->addDays(\App\Modules\Core\Models\SystemSetting::getVal('finance.ap_payment_days', 30))->toDateString(),
'created_by' => $userId,
'remarks' => "由進貨單 {$receiptData['code']} 自動生成",
]);
return $ap;
});
}
/**
* 產生應付帳款單號
*/
protected function generateApNumber(): string
{
$prefix = 'AP-' . date('Ymd') . '-';
$lastPrefix = "{$prefix}%";
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
->orderBy('document_number', 'desc')
->lockForUpdate()
->first();
if (!$latest) {
return $prefix . '01';
}
$parts = explode('-', $latest->document_number);
$lastNumber = intval(end($parts));
$newNumber = str_pad((string)($lastNumber + 1), 2, '0', STR_PAD_LEFT);
return $prefix . $newNumber;
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Modules\Finance\Services;
use App\Modules\Finance\Contracts\FinanceServiceInterface;
use App\Modules\Finance\Models\UtilityFee;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Carbon;
class FinanceService implements FinanceServiceInterface
{
protected $procurementService;
public function __construct(ProcurementServiceInterface $procurementService)
{
$this->procurementService = $procurementService;
}
public function getAccountingReportData(string $start, string $end): array
{
// 1. 獲取應付帳款資料 (已付款)
$accountPayables = \App\Modules\Finance\Models\AccountPayable::where('status', \App\Modules\Finance\Models\AccountPayable::STATUS_PAID)
->whereNotNull('paid_at')
->whereBetween('paid_at', [$start, $end])
->get();
// 取得供應商資料 (Manual Hydration)
$vendorIds = $accountPayables->pluck('vendor_id')->unique()->filter()->toArray();
$vendorsMap = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
// 付款方式對映
$paymentMethodMap = [
'cash' => '現金',
'bank_transfer' => '銀行轉帳',
'check' => '支票',
'credit_card' => '信用卡',
];
$payableRecords = $accountPayables->map(function ($ap) use ($vendorsMap, $paymentMethodMap) {
$vendorName = isset($vendorsMap[$ap->vendor_id]) ? $vendorsMap[$ap->vendor_id]->name : '未知廠商';
$mappedPaymentMethod = $paymentMethodMap[$ap->payment_method] ?? $ap->payment_method;
return [
'id' => 'AP-' . $ap->id,
'date' => Carbon::parse($ap->paid_at)->timezone(config('app.timezone'))->toDateString(),
'source' => '應付帳款',
'category' => '進貨支出',
'item' => $vendorName,
'reference' => $ap->document_number,
'invoice_date' => $ap->invoice_date ? $ap->invoice_date->format('Y-m-d') : null,
'invoice_number' => $ap->invoice_number,
'amount' => (float)$ap->total_amount,
'tax_amount' => (float)$ap->tax_amount,
'status' => $ap->status,
'payment_method' => $mappedPaymentMethod,
'payment_note' => $ap->payment_note,
'remarks' => $ap->remarks,
];
});
// 2. 獲取公共事業費 (已繳費)
$utilityFees = UtilityFee::where('payment_status', UtilityFee::STATUS_PAID)
->whereBetween('transaction_date', [$start, $end])
->get()
->map(function ($fee) {
return [
'id' => 'UF-' . $fee->id,
'date' => $fee->transaction_date->format('Y-m-d'),
'source' => '公共事業費',
'category' => $fee->category,
'item' => $fee->description ?: $fee->category,
'reference' => '-',
'invoice_date' => null,
'invoice_number' => $fee->invoice_number,
'amount' => (float)$fee->amount,
'tax_amount' => 0.0,
'status' => $fee->payment_status,
'payment_method' => null,
'payment_note' => null,
'remarks' => $fee->description,
];
});
$allRecords = $payableRecords->concat($utilityFees)
->sortByDesc('date')
->values();
return [
'records' => $allRecords,
'summary' => [
'total_amount' => $allRecords->sum('amount'),
'payable_total' => $payableRecords->sum('amount'),
'utility_total' => $utilityFees->sum('amount'),
'record_count' => $allRecords->count(),
]
];
}
public function getUtilityFees(array $filters)
{
$query = UtilityFee::withCount('attachments');
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function($q) use ($search) {
$q->where('category', 'like', "%{$search}%")
->orWhere('invoice_number', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
if (!empty($filters['category']) && $filters['category'] !== 'all') {
$query->where('category', $filters['category']);
}
if (!empty($filters['date_start'])) {
$query->where('transaction_date', '>=', $filters['date_start']);
}
if (!empty($filters['date_end'])) {
$query->where('transaction_date', '<=', $filters['date_end']);
}
$sortField = $filters['sort_field'] ?? 'created_at';
$sortDirection = $filters['sort_direction'] ?? 'desc';
$query->orderBy($sortField, $sortDirection);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) ($filters['per_page'] ?? $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
return $query->paginate($perPage);
}
public function getUniqueCategories(): Collection
{
return UtilityFee::distinct()->pluck('category');
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Modules\Integration\Actions;
use App\Modules\Integration\Models\SalesOrder;
use App\Modules\Integration\Models\SalesOrderItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;
class SyncOrderAction
{
protected $inventoryService;
protected $productService;
public function __construct(
InventoryServiceInterface $inventoryService,
ProductServiceInterface $productService
) {
$this->inventoryService = $inventoryService;
$this->productService = $productService;
}
/**
* 執行訂單同步
*
* @param array $data
* @return array 包含 orders 建立結果的資訊
* @throws ValidationException
* @throws \Exception
*/
public function execute(array $data)
{
$externalOrderId = $data['external_order_id'];
// 使用 Cache::lock 防護高併發,鎖定該訂單號 10 秒
// 此處需要 cache store 支援鎖 (如 memcached, dynamodb, redis, database, file, array)
// Laravel 預設的 file/redis 都支援。若無法取得鎖,表示有另一個相同的請求正在處理
$lock = Cache::lock("sync_order_{$externalOrderId}", 10);
if (!$lock->get()) {
throw ValidationException::withMessages([
'external_order_id' => ["The order {$externalOrderId} is currently being processed by another transaction. Please try again later."]
]);
}
try {
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
$existingOrder = SalesOrder::where('external_order_id', $externalOrderId)->first();
if ($existingOrder) {
return [
'status' => 'exists',
'message' => 'Order already exists',
'order_id' => $existingOrder->id,
];
}
// --- 預檢 (Pre-flight check) N+1 優化 ---
$items = $data['items'];
$posProductIds = array_column($items, 'pos_product_id');
// 一次性查出所有相關的 Product
$products = $this->productService->findByExternalPosIds($posProductIds)->keyBy('external_pos_id');
$missingIds = [];
foreach ($posProductIds as $id) {
if (!$products->has($id)) {
$missingIds[] = $id;
}
}
if (!empty($missingIds)) {
// 回報所有缺漏的 ID
throw ValidationException::withMessages([
'items' => ["The following products are not found: " . implode(', ', $missingIds) . ". Please sync products first."]
]);
}
// --- 執行寫入交易 ---
$result = DB::transaction(function () use ($data, $items, $products) {
// 1. 建立訂單
$order = SalesOrder::create([
'external_order_id' => $data['external_order_id'],
'status' => 'completed',
'payment_method' => $data['payment_method'] ?? 'cash',
'total_amount' => 0,
'sold_at' => $data['sold_at'] ?? now(),
'raw_payload' => $data,
'source' => $data['source'] ?? 'pos',
'source_label' => $data['source_label'] ?? null,
]);
// 2. 查找倉庫
$warehouseCode = $data['warehouse_code'];
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
if ($warehouses->isEmpty()) {
throw ValidationException::withMessages([
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
]);
}
$warehouseId = $warehouses->first()->id;
$totalAmount = 0;
// 3. 處理訂單明細
$orderItemsData = [];
foreach ($items as $itemData) {
$product = $products->get($itemData['pos_product_id']);
$qty = $itemData['qty'];
$price = $itemData['price'];
$lineTotal = $qty * $price;
$totalAmount += $lineTotal;
$orderItemsData[] = [
'sales_order_id' => $order->id,
'product_id' => $product->id,
'product_name' => $product->name,
'quantity' => $qty,
'price' => $price,
'total' => $lineTotal,
'created_at' => now(),
'updated_at' => now(),
];
// 4. 扣除庫存(強制模式,允許負庫存)
$this->inventoryService->decreaseStock(
$product->id,
$warehouseId,
$qty,
"POS Order: " . $order->external_order_id,
true,
null,
\App\Modules\Integration\Models\SalesOrder::class,
$order->id
);
}
// Batch insert order items
SalesOrderItem::insert($orderItemsData);
$order->update(['total_amount' => $totalAmount]);
return [
'status' => 'created',
'message' => 'Order synced and stock deducted successfully',
'order_id' => $order->id,
];
});
return $result;
} finally {
// 無論成功失敗,最後釋放鎖定
$lock->release();
}
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Modules\Integration\Actions;
use App\Modules\Integration\Models\SalesOrder;
use App\Modules\Integration\Models\SalesOrderItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;
class SyncVendingOrderAction
{
protected $inventoryService;
protected $productService;
public function __construct(
InventoryServiceInterface $inventoryService,
ProductServiceInterface $productService
) {
$this->inventoryService = $inventoryService;
$this->productService = $productService;
}
/**
* 執行販賣機訂單同步
*
* @param array $data
* @return array 包含訂單建立結果的資訊
* @throws ValidationException
* @throws \Exception
*/
public function execute(array $data)
{
$externalOrderId = $data['external_order_id'];
// 使用 Cache::lock 防護高併發
$lock = Cache::lock("sync_order_{$externalOrderId}", 10);
if (!$lock->get()) {
throw ValidationException::withMessages([
'external_order_id' => ["The order {$externalOrderId} is currently being processed by another transaction. Please try again later."]
]);
}
try {
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
$existingOrder = SalesOrder::where('external_order_id', $externalOrderId)->first();
if ($existingOrder) {
return [
'status' => 'exists',
'message' => 'Order already exists',
'order_id' => $existingOrder->id,
];
}
// --- 預檢:以 ERP 商品代碼查詢 ---
$items = $data['items'];
$productCodes = array_column($items, 'product_code');
// 一次性查出所有相關的 Product以 code 查詢)
$products = $this->productService->findByCodes($productCodes)->keyBy('code');
$missingCodes = [];
foreach ($productCodes as $code) {
if (!$products->has($code)) {
$missingCodes[] = $code;
}
}
if (!empty($missingCodes)) {
throw ValidationException::withMessages([
'items' => ["The following products are not found by code: " . implode(', ', $missingCodes) . ". Please ensure these products exist in the system."]
]);
}
// --- 執行寫入交易 ---
$result = DB::transaction(function () use ($data, $items, $products) {
// 1. 建立訂單
$order = SalesOrder::create([
'external_order_id' => $data['external_order_id'],
'status' => 'completed',
'payment_method' => $data['payment_method'] ?? 'electronic',
'total_amount' => 0,
'sold_at' => $data['sold_at'] ?? now(),
'raw_payload' => $data,
'source' => 'vending',
'source_label' => $data['machine_id'] ?? null,
]);
// 2. 查找倉庫
$warehouseCode = $data['warehouse_code'];
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
if ($warehouses->isEmpty()) {
throw ValidationException::withMessages([
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
]);
}
$warehouseId = $warehouses->first()->id;
$totalAmount = 0;
// 3. 處理訂單明細
$orderItemsData = [];
foreach ($items as $itemData) {
$product = $products->get($itemData['product_code']);
$qty = $itemData['qty'];
$price = $itemData['price'];
$lineTotal = $qty * $price;
$totalAmount += $lineTotal;
$orderItemsData[] = [
'sales_order_id' => $order->id,
'product_id' => $product->id,
'product_name' => $product->name,
'quantity' => $qty,
'price' => $price,
'total' => $lineTotal,
'created_at' => now(),
'updated_at' => now(),
];
// 4. 扣除庫存(強制模式,允許負庫存)
$this->inventoryService->decreaseStock(
$product->id,
$warehouseId,
$qty,
"Vending Order: " . $order->external_order_id,
true,
null,
\App\Modules\Integration\Models\SalesOrder::class,
$order->id
);
}
// Batch insert order items
SalesOrderItem::insert($orderItemsData);
$order->update(['total_amount' => $totalAmount]);
return [
'status' => 'created',
'message' => 'Vending order synced and stock deducted successfully',
'order_id' => $order->id,
];
});
return $result;
} finally {
$lock->release();
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Modules\Integration\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\JsonResponse;
class InventorySyncController extends Controller
{
protected $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
/**
* 提供外部 POS 查詢指定倉庫的商品庫存餘額
*
* @param string $warehouseCode
* @return JsonResponse
*/
public function show(string $warehouseCode): JsonResponse
{
// 透過 Service 調用跨模組庫存查詢功能
$inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode($warehouseCode);
// 若回傳 null表示尋無此倉庫代碼
if (is_null($inventoryData)) {
return response()->json([
'status' => 'error',
'message' => "Warehouse with code '{$warehouseCode}' not found.",
], 404);
}
// 以 JSON 格式回傳組合好的商品庫存列表
return response()->json([
'status' => 'success',
'warehouse_code' => $warehouseCode,
'data' => $inventoryData->map(function ($item) {
return [
'external_pos_id' => $item->external_pos_id,
'product_code' => $item->product_code,
'product_name' => $item->product_name,
'quantity' => (float) $item->total_quantity,
];
})
], 200);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Modules\Integration\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Integration\Requests\SyncOrderRequest;
use App\Modules\Integration\Actions\SyncOrderAction;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class OrderSyncController extends Controller
{
protected $syncOrderAction;
public function __construct(SyncOrderAction $syncOrderAction)
{
$this->syncOrderAction = $syncOrderAction;
}
/**
* 接收並同步外部交易訂單
*
* @param SyncOrderRequest $request
* @return JsonResponse
*/
public function store(SyncOrderRequest $request): JsonResponse
{
try {
// 所有驗證皆已透過 SyncOrderRequest 自動處理
// 將通過驗證的資料交由 Action 處理(包含併發鎖、預先驗證、與資料庫異動)
$result = $this->syncOrderAction->execute($request->validated());
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
return response()->json([
'message' => $result['message'],
'order_id' => $result['order_id'] ?? null,
], $statusCode);
} catch (\Illuminate\Validation\ValidationException $e) {
// 捕捉 Action 中拋出的預先驗證錯誤 (如查無商品、或鎖定逾時)
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
// 系統層級的錯誤
Log::error('Order Sync Failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'payload' => $request->all()
]);
return response()->json([
'message' => 'Sync failed: An unexpected error occurred.'
], 500);
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Modules\Integration\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use Illuminate\Support\Facades\Log;
class ProductSyncController extends Controller
{
protected $productService;
public function __construct(ProductServiceInterface $productService)
{
$this->productService = $productService;
}
public function upsert(Request $request)
{
$request->validate([
'external_pos_id' => 'required|string|max:255',
'name' => 'required|string|max:255',
'price' => 'nullable|numeric|min:0|max:99999999.99',
'barcode' => 'nullable|string|max:100',
'category' => 'nullable|string|max:100',
'unit' => 'nullable|string|max:100',
'brand' => 'nullable|string|max:100',
'specification' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0|max:99999999.99',
'member_price' => 'nullable|numeric|min:0|max:99999999.99',
'wholesale_price' => 'nullable|numeric|min:0|max:99999999.99',
'updated_at' => 'nullable|date',
]);
try {
$product = $this->productService->upsertFromPos($request->all());
return response()->json([
'message' => 'Product synced successfully',
'data' => [
'id' => $product->id,
'external_pos_id' => $product->external_pos_id,
]
]);
} catch (\Exception $e) {
Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
return response()->json([
'message' => 'Sync failed: ' . $e->getMessage(),
], 500);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Modules\Integration\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Integration\Models\SalesOrder;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SalesOrderController extends Controller
{
/**
* 顯示銷售訂單列表
*/
public function index(Request $request)
{
$query = SalesOrder::query();
// 搜尋篩選 (外部訂單號)
if ($request->filled('search')) {
$query->where('external_order_id', 'like', '%' . $request->search . '%');
}
// 來源篩選
if ($request->filled('source')) {
$query->where('source', $request->source);
}
// 排序
$query->orderBy('sold_at', 'desc');
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) $request->input('per_page', $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$orders = $query->paginate($perPage)
->withQueryString();
return Inertia::render('Integration/SalesOrders/Index', [
'orders' => $orders,
'filters' => $request->only(['search', 'per_page', 'source']),
]);
}
/**
* 顯示單一銷售訂單詳情
*/
public function show(SalesOrder $salesOrder)
{
$salesOrder->load(['items']);
return Inertia::render('Integration/SalesOrders/Show', [
'order' => $salesOrder,
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Modules\Integration\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Integration\Requests\SyncVendingOrderRequest;
use App\Modules\Integration\Actions\SyncVendingOrderAction;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class VendingOrderSyncController extends Controller
{
protected $syncVendingOrderAction;
public function __construct(SyncVendingOrderAction $syncVendingOrderAction)
{
$this->syncVendingOrderAction = $syncVendingOrderAction;
}
/**
* 接收並同步販賣機交易訂單
*
* @param SyncVendingOrderRequest $request
* @return JsonResponse
*/
public function store(SyncVendingOrderRequest $request): JsonResponse
{
try {
$result = $this->syncVendingOrderAction->execute($request->validated());
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
return response()->json([
'message' => $result['message'],
'order_id' => $result['order_id'] ?? null,
], $statusCode);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
Log::error('Vending Order Sync Failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'payload' => $request->all()
]);
return response()->json([
'message' => 'Sync failed: An unexpected error occurred.'
], 500);
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Modules\Integration;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use App\Modules\Integration\Middleware\TenantIdentificationMiddleware;
class IntegrationServiceProvider extends ServiceProvider
{
public function boot()
{
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
$this->loadRoutesFrom(__DIR__ . '/Routes/web.php');
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
// 註冊 Middleware 別名
Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class);
// 定義 Integration API 速率限制(每分鐘 60 次,依 Token 使用者識別)
RateLimiter::for('integration', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
public function register()
{
//
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Modules\Integration\Middleware;
use Closure;
use Illuminate\Http\Request;
use Stancl\Tenancy\Facades\Tenancy;
use Symfony\Component\HttpFoundation\Response;
class TenantIdentificationMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// 1. Check for X-Tenant-Domain header
$domain = $request->header('X-Tenant-Domain');
if (! $domain) {
return response()->json([
'message' => 'Missing X-Tenant-Domain header.',
], 400);
}
// 2. Find Tenant by domain
// Assuming domains are stored in 'domains' table and linked to tenants
// Or using Stancl's tenant finder.
// Stancl Tenancy usually finds by domain automatically for web routes, but for API
// we are doing manual identification because we might not be using subdomains for API calls (or maybe we are).
// If the API endpoint is centrally hosted (e.g. api.star-erp.com/v1/...), we need this header.
// Let's try to initialize tenancy manually.
// We need to find the tenant model that has this domain.
try {
$tenant = \App\Modules\Core\Models\Tenant::whereHas('domains', function ($query) use ($domain) {
$query->where('domain', $domain);
})->first();
if (! $tenant) {
return response()->json([
'message' => 'Tenant not found.',
], 404);
}
Tenancy::initialize($tenant);
} catch (\Exception $e) {
return response()->json([
'message' => 'Tenant initialization failed: ' . $e->getMessage(),
], 500);
}
return $next($request);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Modules\Integration\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SalesOrder extends Model
{
protected $table = 'sales_orders';
protected $fillable = [
'external_order_id',
'status',
'payment_method',
'total_amount',
'sold_at',
'raw_payload',
'source',
'source_label',
];
protected $casts = [
'sold_at' => 'datetime',
'raw_payload' => 'array',
'total_amount' => 'decimal:4',
];
public function items(): HasMany
{
return $this->hasMany(SalesOrderItem::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Integration\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SalesOrderItem extends Model
{
protected $table = 'sales_order_items';
protected $fillable = [
'sales_order_id',
'product_id',
'product_name',
'quantity',
'price',
'total',
];
protected $casts = [
'quantity' => 'decimal:4',
'price' => 'decimal:4',
'total' => 'decimal:4',
];
public function order(): BelongsTo
{
return $this->belongsTo(SalesOrder::class, 'sales_order_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Modules\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SyncOrderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'external_order_id' => 'required|string',
'warehouse_code' => 'required|string',
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
'sold_at' => 'nullable|date',
'items' => 'required|array|min:1',
'items.*.pos_product_id' => 'required|string',
'items.*.qty' => 'required|numeric|min:0.0001',
'items.*.price' => 'required|numeric|min:0',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Modules\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SyncVendingOrderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* 販賣機訂單同步的驗證規則
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'external_order_id' => 'required|string',
'machine_id' => 'nullable|string',
'warehouse_code' => 'required|string',
'payment_method' => 'nullable|string|in:cash,electronic,line_pay,other',
'sold_at' => 'nullable|date',
'items' => 'required|array|min:1',
'items.*.product_code' => 'required|string', // 使用 ERP 商品代碼
'items.*.qty' => 'required|numeric|min:0.0001',
'items.*.price' => 'required|numeric|min:0',
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Integration\Controllers\ProductSyncController;
use App\Modules\Integration\Controllers\OrderSyncController;
use App\Modules\Integration\Controllers\VendingOrderSyncController;
use App\Modules\Integration\Controllers\InventorySyncController;
Route::prefix('api/v1/integration')
->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum'])
->group(function () {
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
Route::post('orders', [OrderSyncController::class, 'store']);
Route::post('vending/orders', [VendingOrderSyncController::class, 'store']);
Route::get('inventory/{warehouse_code}', [InventorySyncController::class, 'show']);
});

View File

@@ -0,0 +1,13 @@
<?php
use App\Modules\Integration\Controllers\SalesOrderController;
use Illuminate\Support\Facades\Route;
Route::middleware(['web', 'auth', 'verified'])->group(function () {
Route::prefix('integration')->name('integration.')->group(function () {
Route::middleware('permission:sales_orders.view')->group(function () {
Route::get('sales-orders', [SalesOrderController::class, 'index'])->name('sales-orders.index');
Route::get('sales-orders/{salesOrder}', [SalesOrderController::class, 'show'])->name('sales-orders.show');
});
});
});

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Modules\Inventory\Contracts;
interface GoodsReceiptServiceInterface
{
/**
* 獲取指定的進貨單資訊
*
* @param int $goodsReceiptId
* @return array|null 返回進貨單的純陣列資料,若找不到則回傳 null
*/
public function getGoodsReceiptData(int $goodsReceiptId): ?array;
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Modules\Inventory\Contracts;
interface InventoryServiceInterface
{
/**
* Check if a product has sufficient stock in a specific warehouse.
*
* @param int $productId
* @param int $warehouseId
* @param float $quantity
* @return bool
*/
public function checkStock(int $productId, int $warehouseId, float $quantity): bool;
/**
* @param int $productId
* @param int $warehouseId
* @param float $quantity
* @param string|null $reason
* @param bool $force
* @param string|null $slot
* @return void
*/
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null): void;
/**
* Get all active warehouses.
*
* @return \Illuminate\Support\Collection
*/
public function getAllWarehouses();
/**
* Get multiple products by their IDs.
*
* @param array $ids
* @return \Illuminate\Support\Collection
*/
public function getProductsByIds(array $ids);
/**
* Get multiple warehouses by their codes.
*
* @param array $codes
* @return \Illuminate\Support\Collection
*/
public function getWarehousesByCodes(array $codes);
/**
* Search products by name.
*
* @param string $name
* @return \Illuminate\Support\Collection
*/
public function getProductsByName(string $name);
/**
* Get a specific product by ID.
*
* @param int $id
* @return object|null
*/
public function getProduct(int $id);
/**
* Get a specific warehouse by ID.
*
* @param int $id
* @return object|null
*/
public function getWarehouse(int $id);
/**
* Get all available inventories in a specific warehouse.
*
* @param int $warehouseId
* @return \Illuminate\Support\Collection
*/
public function getInventoriesByWarehouse(int $warehouseId);
/**
* Get all products.
*
* @return \Illuminate\Support\Collection
*/
public function getAllProducts();
/**
* Get all units.
*
* @return \Illuminate\Support\Collection
*/
public function getUnits();
/**
* Create a new inventory record (e.g., for finished goods).
*
* @param array $data
* @return object
*/
public function createInventoryRecord(array $data);
/**
* Decrease quantity of a specific inventory record.
*
* @param int $inventoryId
* @param float $quantity
* @param string|null $reason
* @param string|null $referenceType
* @param int|string|null $referenceId
* @return void
*/
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null);
/**
* Find a specific inventory record by warehouse, product and batch.
*
* @param int $warehouseId
* @param int $productId
* @param string|null $batchNumber
* @return object|null
*/
public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber);
/**
* 取得即時庫存查詢資料(含統計卡片 + 分頁明細)。
*
* @param array $filters 篩選條件
* @param int $perPage 每頁筆數
* @return array
*/
public function getStockQueryData(array $filters = [], ?int $perPage = null): array;
/**
* Get statistics for the dashboard.
*
* @return array
*/
public function getDashboardStats(): array;
/**
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
*
* @param string $warehouseName
* @return object
*/
public function findOrCreateWarehouseByName(string $warehouseName);
/**
* Get top inventory value for dashboard.
*/
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection;
/**
* Get items expiring soon for dashboard.
*/
public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection;
/**
* Get inventory summary (group by product) for a specific warehouse code
*
* @param string $code
* @return \Illuminate\Support\Collection|null
*/
public function getPosInventoryByWarehouseCode(string $code);
/**
* 處理批量入庫邏輯 (含批號產生與現有批號累加)
*
* @param \App\Modules\Inventory\Models\Warehouse $warehouse
* @param array $items 入庫品項清單
* @param array $meta 資料包含 inboundDate, reason, notes
* @return void
*/
public function processIncomingInventory(\App\Modules\Inventory\Models\Warehouse $warehouse, array $items, array $meta): void;
/**
* 處理單一庫存項目的調整。
*
* @param \App\Modules\Inventory\Models\Inventory $inventory
* @param array $data 包含 quantity, operation, type, reason, unit_cost
* @return void
*/
public function adjustInventory(\App\Modules\Inventory\Models\Inventory $inventory, array $data): void;
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Modules\Inventory\Contracts;
/**
* 產品服務介面 供跨模組使用(如 Integration 模組)。
*/
interface ProductServiceInterface
{
/**
* 透過外部 POS ID 進行產品新增或更新Upsert
*
* @param array $data
* @return object
*/
public function upsertFromPos(array $data);
/**
* 透過外部 POS ID 查找產品。
*
* @param string $externalPosId
* @return object|null
*/
public function findByExternalPosId(string $externalPosId);
/**
* 透過多個外部 POS ID 查找產品。
*
* @param array $externalPosIds
* @return \Illuminate\Database\Eloquent\Collection
*/
public function findByExternalPosIds(array $externalPosIds);
/**
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
*
* @param array $codes
* @return \Illuminate\Database\Eloquent\Collection
*/
public function findByCodes(array $codes);
/**
* 建立新商品。
*
* @param array $data
* @return \App\Modules\Inventory\Models\Product
*/
public function createProduct(array $data);
/**
* 更新現有商品。
*
* @param \App\Modules\Inventory\Models\Product $product
* @param array $data
* @return \App\Modules\Inventory\Models\Product
*/
public function updateProduct(\App\Modules\Inventory\Models\Product $product, array $data);
/**
* 生成隨機 8 碼代號 (大寫英文+數字)
*
* @return string
*/
public function generateRandomCode();
/**
* 生成隨機 13 碼條碼 (純數字)
*
* @return string
*/
public function generateRandomBarcode();
/**
* 根據條碼或代號查找商品。
*
* @param string|null $barcode
* @param string|null $code
* @return \App\Modules\Inventory\Models\Product|null
*/
public function findByBarcodeOrCode(?string $barcode, ?string $code);
}

View File

@@ -0,0 +1,246 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\InventoryAdjustDoc;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\AdjustService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class AdjustDocController extends Controller
{
protected $adjustService;
public function __construct(AdjustService $adjustService)
{
$this->adjustService = $adjustService;
}
public function index(Request $request)
{
$query = InventoryAdjustDoc::query()
->with(['createdBy', 'postedBy', 'warehouse']);
// 搜尋
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('doc_no', 'like', "%{$search}%")
->orWhere('reason', 'like', "%{$search}%")
->orWhere('remarks', 'like', "%{$search}%");
});
}
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->warehouse_id);
}
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$docs = $query->orderByDesc('created_at')
->paginate($perPage)
->withQueryString()
->through(function ($doc) {
return [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'status' => $doc->status,
'warehouse_name' => $doc->warehouse->name,
'reason' => $doc->reason,
'created_at' => $doc->created_at->format('Y-m-d H:i'),
'posted_at' => $doc->posted_at ? $doc->posted_at->format('Y-m-d H:i') : '-',
'created_by' => $doc->createdBy?->name,
'remarks' => $doc->remarks,
];
});
return Inertia::render('Inventory/Adjust/Index', [
'docs' => $docs,
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
]);
}
public function store(Request $request)
{
// 模式 1: 從盤點單建立
if ($request->filled('count_doc_id')) {
$countDoc = InventoryCountDoc::findOrFail($request->count_doc_id);
if ($countDoc->status !== 'completed') {
$errorMsg = $countDoc->status === 'no_adjust'
? '此盤點單無庫存差異,無需建立盤調單'
: '只有已完成盤點的單據可以建立盤調單';
return redirect()->back()->with('error', $errorMsg);
}
// 檢查是否已存在對應的盤調單 (避免重複建立)
if (InventoryAdjustDoc::where('count_doc_id', $countDoc->id)->exists()) {
return redirect()->back()->with('error', '此盤點單已建立過盤調單');
}
$doc = $this->adjustService->createFromCountDoc($countDoc, auth()->id());
return redirect()->route('inventory.adjust.show', [$doc->id])
->with('success', '已從盤點單生成盤調單');
}
// 模式 2: 一般手動調整 (保留原始邏輯但更新訊息)
$validated = $request->validate([
'warehouse_id' => 'required',
'reason' => 'required|string',
'remarks' => 'nullable|string',
]);
$doc = $this->adjustService->createDoc(
$validated['warehouse_id'],
$validated['reason'],
$validated['remarks'],
auth()->id()
);
return redirect()->route('inventory.adjust.show', [$doc->id])
->with('success', '已建立盤調單');
}
/**
* API: 獲取可盤調的已完成盤點單 (支援掃描單號)
*/
public function getPendingCounts(Request $request)
{
$query = InventoryCountDoc::where('status', 'completed')
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('inventory_adjust_docs')
->whereColumn('inventory_adjust_docs.count_doc_id', 'inventory_count_docs.id');
});
if ($request->filled('search')) {
$search = $request->search;
$query->where('doc_no', 'like', "%{$search}%");
}
$counts = $query->limit(10)->get()->map(function($c) {
return [
'id' => (string)$c->id,
'doc_no' => $c->doc_no,
'warehouse_name' => $c->warehouse->name,
'completed_at' => $c->completed_at->format('Y-m-d H:i'),
];
});
return response()->json($counts);
}
public function update(Request $request, InventoryAdjustDoc $doc)
{
$action = $request->input('action', 'update');
if ($action === 'post') {
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只有草稿狀態的單據可以過帳');
}
$this->adjustService->post($doc, auth()->id());
return redirect()->back()->with('success', '單據已過帳');
}
if ($action === 'void') {
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只有草稿狀態的單據可以作廢');
}
$this->adjustService->void($doc, auth()->id());
return redirect()->back()->with('success', '單據已作廢');
}
// 一般更新 (更新品項與基本資訊)
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只有草稿狀態的單據可以修改');
}
$request->validate([
'reason' => 'required|string',
'remarks' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required',
'items.*.adjust_qty' => 'required|numeric',
]);
$doc->update([
'reason' => $request->reason,
'remarks' => $request->remarks,
]);
$this->adjustService->updateItems($doc, $request->items);
return redirect()->back()->with('success', '單據已更新');
}
public function show(InventoryAdjustDoc $doc)
{
$doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']);
// Pre-fetch relevant Inventory information (mainly for expiry date)
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
->where('warehouse_id', $doc->warehouse_id)
->whereIn('product_id', $doc->items->pluck('product_id'))
->whereIn('batch_number', $doc->items->pluck('batch_number'))
->get()
->mapWithKeys(function ($inv) {
return [$inv->product_id . '-' . $inv->batch_number => $inv];
});
$docData = [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'warehouse_id' => (string) $doc->warehouse_id,
'warehouse_name' => $doc->warehouse->name,
'status' => $doc->status,
'reason' => $doc->reason,
'remarks' => $doc->remarks,
'created_at' => $doc->created_at->format('Y-m-d H:i'),
'created_by' => $doc->createdBy?->name,
'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null,
'count_doc_no' => $doc->countDoc?->doc_no,
'items' => $doc->items->map(function ($item) use ($inventoryMap) {
$inv = $inventoryMap->get($item->product_id . '-' . $item->batch_number);
return [
'id' => (string) $item->id,
'product_id' => (string) $item->product_id,
'product_name' => $item->product->name,
'product_code' => $item->product->code,
'batch_number' => $item->batch_number,
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
'unit' => $item->product->baseUnit?->name,
'qty_before' => (float) $item->qty_before,
'adjust_qty' => (float) $item->adjust_qty,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Adjust/Show', [
'doc' => $docData,
]);
}
public function destroy(InventoryAdjustDoc $doc)
{
if ($doc->status !== 'draft') {
return redirect()->back()->with('error', '只能刪除草稿狀態的單據');
}
$doc->items()->delete();
$doc->delete();
return redirect()->route('inventory.adjust.index')
->with('success', '盤調單已刪除');
}
}

View File

@@ -1,8 +1,10 @@
<?php
namespace App\Http\Controllers;
namespace App\Modules\Inventory\Controllers;
use App\Models\Category;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Category;
use Illuminate\Http\Request;
class CategoryController extends Controller

View File

@@ -0,0 +1,239 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\InventoryCountDoc;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\CountService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CountDocController extends Controller
{
protected $countService;
public function __construct(CountService $countService)
{
$this->countService = $countService;
}
public function index(Request $request)
{
$query = InventoryCountDoc::query()
->with(['createdBy', 'completedBy', 'warehouse']);
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->warehouse_id);
}
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('doc_no', 'like', "%{$search}%")
->orWhere('remarks', 'like', "%{$search}%");
});
}
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$countQuery = function ($query) {
$query->whereNotNull('counted_qty');
};
$docs = $query->withCount(['items', 'items as counted_items_count' => $countQuery])
->orderByDesc('created_at')
->paginate($perPage)
->withQueryString()
->through(function ($doc) {
return [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'status' => $doc->status,
'warehouse_name' => $doc->warehouse->name,
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : '-',
'completed_at' => $doc->completed_at ? $doc->completed_at->format('Y-m-d H:i') : '-',
'created_by' => $doc->createdBy?->name,
'remarks' => $doc->remarks,
'total_items' => $doc->items_count,
'counted_items' => $doc->counted_items_count,
];
});
return Inertia::render('Inventory/Count/Index', [
'docs' => $docs,
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'warehouse_id' => 'required|exists:warehouses,id',
'remarks' => 'nullable|string|max:255',
]);
$doc = $this->countService->createDoc(
$validated['warehouse_id'],
$validated['remarks'] ?? null,
auth()->id()
);
// 自動執行快照
$this->countService->snapshot($doc, false);
return redirect()->route('inventory.count.show', [$doc->id])
->with('success', '已建立盤點單並完成庫存快照');
}
public function show(InventoryCountDoc $doc)
{
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
// 預先抓取相關的 Inventory 資訊 (主要為了取得效期)
$inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed()
->where('warehouse_id', $doc->warehouse_id)
->whereIn('product_id', $doc->items->pluck('product_id'))
->whereIn('batch_number', $doc->items->pluck('batch_number'))
->get()
->mapWithKeys(function ($inv) {
return [$inv->product_id . '-' . $inv->batch_number => $inv];
});
$docData = [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'warehouse_id' => (string) $doc->warehouse_id,
'warehouse_name' => $doc->warehouse->name,
'status' => $doc->status,
'remarks' => $doc->remarks,
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null,
'created_by' => $doc->createdBy?->name,
'items' => $doc->items->map(function ($item) use ($inventoryMap) {
$key = $item->product_id . '-' . $item->batch_number;
$inv = $inventoryMap->get($key);
return [
'id' => (string) $item->id,
'product_name' => $item->product->name,
'product_code' => $item->product->code,
'batch_number' => $item->batch_number,
'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, // 新增效期
'unit' => $item->product->baseUnit?->name,
'system_qty' => (float) $item->system_qty,
'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty,
'diff_qty' => (float) $item->diff_qty,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Count/Show', [
'doc' => $docData,
]);
}
public function print(InventoryCountDoc $doc)
{
$doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']);
$docData = [
'id' => (string) $doc->id,
'doc_no' => $doc->doc_no,
'warehouse_name' => $doc->warehouse->name,
'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d') : date('Y-m-d'), // Use date only
'created_at' => $doc->created_at->format('Y-m-d'),
'print_date' => date('Y-m-d'),
'created_by' => $doc->createdBy?->name,
'items' => $doc->items->map(function ($item) {
return [
'id' => (string) $item->id,
'product_name' => $item->product->name,
'product_code' => $item->product->code,
'specification' => $item->product->specification,
'unit' => $item->product->baseUnit?->name,
'quantity' => (float) ($item->counted_qty ?? $item->system_qty), // Default to system qty if counted is null, or just counted? User wants "Count Sheet" -> maybe blank if not counted?
// Actually, if it's "Completed", we show counted. If it's "Pending", we usually show blank or system.
// The 'Show' page logic suggests we show counted_qty.
'counted_qty' => $item->counted_qty,
'notes' => $item->notes,
];
}),
];
return Inertia::render('Inventory/Count/Print', [
'doc' => $docData,
]);
}
public function update(Request $request, InventoryCountDoc $doc)
{
if ($doc->status === 'completed') {
return redirect()->back()->with('error', '此盤點單已完成,無法修改');
}
$validated = $request->validate([
'items' => 'array',
'items.*.id' => 'required|exists:inventory_count_items,id',
'items.*.counted_qty' => 'nullable|numeric|min:0',
'items.*.notes' => 'nullable|string',
]);
if (isset($validated['items'])) {
$this->countService->updateCount($doc, $validated['items']);
}
// 重新讀取以獲取最新狀態
$doc->refresh();
if ($doc->status === 'completed') {
return redirect()->route('inventory.count.index')
->with('success', '盤點完成,單據已自動存檔並完成。');
}
return redirect()->back()->with('success', '盤點資料已暫存');
}
public function reopen(InventoryCountDoc $doc)
{
// 權限檢查 (通常僅允許有權限者執行,例如 inventory.adjust)
// 注意:前端已經用 <Can> 保護按鈕,後端這裡最好也加上檢查
if (!auth()->user()->can('inventory.adjust')) {
abort(403);
}
if (!in_array($doc->status, ['completed', 'no_adjust'])) {
return redirect()->back()->with('error', '僅能針對已完成或無需盤調的盤點單重新開啟盤點');
}
// 執行取消核准邏輯
$doc->update([
'status' => 'counting', // 回復為盤點中
'completed_at' => null, // 清除完成時間
'completed_by' => null, // 清除完成者
]);
return redirect()->back()->with('success', '已重新開啟盤點,單據回復為盤點中狀態');
}
public function destroy(InventoryCountDoc $doc)
{
if ($doc->status === 'completed') {
return redirect()->back()->with('error', '已完成的盤點單無法刪除');
}
// Activity Log handled by Model Trait
$doc->items()->delete();
$doc->delete();
return redirect()->route('inventory.count.index')
->with('success', '盤點單已刪除');
}
}

View File

@@ -0,0 +1,488 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Services\GoodsReceiptService;
use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Modules\Inventory\Models\GoodsReceipt;
use Illuminate\Support\Facades\DB;
use App\Modules\Inventory\Services\DuplicateCheckService;
class GoodsReceiptController extends Controller
{
protected $goodsReceiptService;
protected $inventoryService;
protected $procurementService;
protected $duplicateCheckService;
public function __construct(
GoodsReceiptService $goodsReceiptService,
InventoryService $inventoryService,
ProcurementServiceInterface $procurementService,
DuplicateCheckService $duplicateCheckService
) {
$this->goodsReceiptService = $goodsReceiptService;
$this->inventoryService = $inventoryService;
$this->procurementService = $procurementService;
$this->duplicateCheckService = $duplicateCheckService;
}
public function index(Request $request)
{
$query = GoodsReceipt::query()
->select(['id', 'code', 'type', 'warehouse_id', 'vendor_id', 'received_date', 'status', 'created_at'])
->with(['warehouse'])
->withSum('items', 'total_amount');
// 關鍵字搜尋(單號)
if ($request->filled('search')) {
$search = $request->input('search');
$query->where('code', 'like', "%{$search}%");
}
// 狀態篩選
if ($request->filled('status') && $request->input('status') !== 'all') {
$query->where('status', $request->input('status'));
}
// 倉庫篩選
if ($request->filled('warehouse_id') && $request->input('warehouse_id') !== 'all') {
$query->where('warehouse_id', $request->input('warehouse_id'));
}
// 日期範圍篩選
if ($request->filled('date_start')) {
$query->whereDate('received_date', '>=', $request->input('date_start'));
}
if ($request->filled('date_end')) {
$query->whereDate('received_date', '<=', $request->input('date_end'));
}
// 每頁筆數
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$receipts = $query->orderBy('created_at', 'desc')
->paginate($perPage)
->withQueryString();
// Manual Hydration for Vendors (Cross-Module)
$vendorIds = collect($receipts->items())->pluck('vendor_id')->unique()->filter()->toArray();
$vendors = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
$receipts->getCollection()->transform(function ($receipt) use ($vendors) {
$receipt->vendor = $vendors->get($receipt->vendor_id);
return $receipt;
});
// 取得倉庫列表用於篩選
$warehouses = $this->inventoryService->getAllWarehouses();
return Inertia::render('Inventory/GoodsReceipt/Index', [
'receipts' => $receipts,
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'per_page']),
'warehouses' => $warehouses,
]);
}
public function show(Request $request, $id)
{
$receipt = GoodsReceipt::with([
'warehouse',
'items.product.category',
'items.product.baseUnit'
])->findOrFail($id);
// Manual Hydration for Vendor (Cross-Module)
if ($receipt->vendor_id) {
$receipt->vendor = $this->procurementService->getVendorsByIds([$receipt->vendor_id])->first();
}
// 手動計算統計資訊 (如果 Model 沒有定義對應的 Attribute)
$receipt->items_sum_total_amount = $receipt->items->sum('total_amount');
return Inertia::render('Inventory/GoodsReceipt/Show', [
'receipt' => $receipt,
'navigation' => [
'from' => $request->query('from'),
'from_id' => $request->query('from_id'),
'from_label' => $request->query('from_label'),
]
]);
}
public function create()
{
// 取得待進貨的採購單列表(用於標準採購類型選擇)
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
// 提取所有產品 ID 以便跨模組水和資料
$productIds = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
// 處理採購單資料,計算剩餘可收貨數量
$formattedPOs = $pendingPOs->map(function ($po) use ($products) {
return [
'id' => $po->id,
'code' => $po->code,
'status' => $po->status,
'supplierId' => $po->vendor_id, // Alias for frontend
'vendor_id' => $po->vendor_id,
'vendor_name' => $po->vendor?->name ?? '',
'warehouse_id' => $po->warehouse_id,
'order_date' => $po->order_date,
'items' => $po->items->map(function ($item) use ($products) {
$product = $products->get($item->product_id);
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
// 獲取單位名稱
$baseUnitName = $product?->baseUnit?->name ?? '個';
$largeUnitName = $product?->largeUnit?->name ?? '';
// 判斷當前採購使用的單位 (這需要從 PurchaseOrderItem 獲取 unit_id 並與產品的 large_unit_id 比較)
$selectedUnit = 'base';
if ($item->unit_id && $product && $item->unit_id == $product->large_unit_id) {
$selectedUnit = 'large';
}
return [
'id' => $item->id,
'productId' => $item->product_id, // Alias for frontend
'product_id' => $item->product_id,
'productName' => $product?->name ?? '', // Alias for frontend
'product_name' => $product?->name ?? '',
'product_code' => $product?->code ?? '',
'unit' => $product?->baseUnit?->name ?? '個', // 預設顯示文字
'selectedUnit' => $selectedUnit,
'base_unit_id' => $product?->base_unit_id,
'base_unit_name' => $baseUnitName,
'large_unit_id' => $product?->large_unit_id,
'large_unit_name' => $largeUnitName,
'conversion_rate' => $product?->conversion_rate ?? 1,
'quantity' => $item->quantity,
'received_quantity' => $item->received_quantity ?? 0,
'remaining' => $remaining,
'unitPrice' => $item->unit_price, // Alias for frontend
'unit_price' => $item->unit_price,
];
})->filter(fn($item) => $item['remaining'] > 0)->values(),
];
})->filter(fn($po) => $po['items']->count() > 0)->values();
// 取得所有廠商列表(用於雜項入庫/其他類型選擇)
$vendors = $this->procurementService->getAllVendors();
return Inertia::render('Inventory/GoodsReceipt/Create', [
'warehouses' => $this->inventoryService->getAllWarehouses(),
'pendingPurchaseOrders' => $formattedPOs,
'vendors' => $vendors,
]);
}
public function store(Request $request)
{
$validated = $request->validate($this->getItemValidationRules());
try {
$this->goodsReceiptService->store($request->all());
return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* 編輯進貨單(僅草稿/退回狀態)
*/
public function edit(GoodsReceipt $goodsReceipt)
{
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, 'rejected'])) {
return redirect()->route('goods-receipts.show', $goodsReceipt->id)
->with('error', '只有草稿或被退回的進貨單可以編輯。');
}
// 載入品項與產品資訊
$goodsReceipt->load('items');
// 取得品項關聯的商品資訊
$productIds = $goodsReceipt->items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
// 如果是標準採購,取得對應採購單的品項,以帶出預定數量與已收數量
$poItems = collect();
$po = null;
if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id) {
$po = clone $this->procurementService->getPurchaseOrdersByIds([$goodsReceipt->purchase_order_id], ['items', 'vendor'])->first();
if ($po) {
$poItems = $po->items->keyBy('id');
}
}
// 格式化品項資料
$formattedItems = $goodsReceipt->items->map(function ($item) use ($products, $poItems) {
$product = $products->get($item->product_id);
$poItem = $poItems->get($item->purchase_order_item_id);
// 判斷單位
$selectedUnit = 'base';
if ($poItem && $product && $poItem->unit_id && $poItem->unit_id == $product->large_unit_id) {
$selectedUnit = 'large';
}
return [
'product_id' => $item->product_id,
'purchase_order_item_id' => $item->purchase_order_item_id,
'product_name' => $product?->name ?? '',
'product_code' => $product?->code ?? '',
'unit' => $poItem && $selectedUnit === 'large' ? ($product?->largeUnit?->name ?? '') : ($product?->baseUnit?->name ?? '個'),
'selectedUnit' => $selectedUnit,
'base_unit_id' => $product?->base_unit_id,
'base_unit_name' => $product?->baseUnit?->name ?? '個',
'large_unit_id' => $product?->large_unit_id,
'large_unit_name' => $product?->largeUnit?->name ?? '',
'conversion_rate' => $product?->conversion_rate ?? 1,
'quantity_ordered' => $poItem ? $poItem->quantity : null,
'quantity_received_so_far' => $poItem ? ($poItem->received_quantity ?? 0) : null,
'quantity_received' => (float) $item->quantity_received,
'unit_price' => (float) $item->unit_price,
'subtotal' => (float) $item->total_amount,
'batch_number' => $item->batch_number ?? '',
'batchMode' => 'existing',
'originCountry' => 'TW',
'expiry_date' => $item->expiry_date ?? '',
];
})->values();
// 同 create() 一樣傳入所需的 props
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
$productIdsForPOs = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
$productsForPOs = $this->inventoryService->getProductsByIds($productIdsForPOs)->keyBy('id');
$formattedPOs = $pendingPOs->map(function ($po) use ($productsForPOs) {
return [
'id' => $po->id,
'code' => $po->code,
'status' => $po->status,
'supplierId' => $po->vendor_id,
'vendor_id' => $po->vendor_id,
'vendor_name' => $po->vendor?->name ?? '',
'warehouse_id' => $po->warehouse_id,
'order_date' => $po->order_date,
'items' => $po->items->map(function ($item) use ($productsForPOs) {
$product = $productsForPOs->get($item->product_id);
$remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
$selectedUnit = 'base';
if ($item->unit_id && $product && $item->unit_id == $product->large_unit_id) {
$selectedUnit = 'large';
}
return [
'id' => $item->id,
'productId' => $item->product_id,
'product_id' => $item->product_id,
'productName' => $product?->name ?? '',
'product_name' => $product?->name ?? '',
'product_code' => $product?->code ?? '',
'unit' => $item->unit_id && $product && $item->unit_id == $product->large_unit_id ? ($product?->largeUnit?->name ?? '') : ($product?->baseUnit?->name ?? '個'),
'selectedUnit' => $selectedUnit,
'base_unit_id' => $product?->base_unit_id,
'base_unit_name' => $product?->baseUnit?->name ?? '個',
'large_unit_id' => $product?->large_unit_id,
'large_unit_name' => $product?->largeUnit?->name ?? '',
'conversion_rate' => $product?->conversion_rate ?? 1,
'quantity' => $item->quantity,
'received_quantity' => $item->received_quantity ?? 0,
'remaining' => $remaining,
'unitPrice' => $item->unit_price,
'unit_price' => $item->unit_price,
];
})->filter(fn($item) => $item['remaining'] > 0)->values(),
];
})->filter(fn($po) => $po['items']->count() > 0)->values();
$vendors = $this->procurementService->getAllVendors();
// Manual Hydration for Vendor
$vendor = null;
if ($goodsReceipt->vendor_id) {
$vendor = $this->procurementService->getVendorsByIds([$goodsReceipt->vendor_id])->first();
}
// 格式化 Purchase Order 給前端顯示
$formattedPO = null;
if ($po) {
$formattedPO = [
'id' => $po->id,
'code' => $po->code,
'status' => $po->status,
'vendor_id' => $po->vendor_id,
'vendor_name' => $po->vendor?->name ?? '',
'warehouse_id' => $po->warehouse_id,
'order_date' => $po->order_date,
'items' => $po->items->toArray(), // simplified since we just need items.length for display
];
}
return Inertia::render('Inventory/GoodsReceipt/Create', [
'warehouses' => $this->inventoryService->getAllWarehouses(),
'pendingPurchaseOrders' => $formattedPOs,
'vendors' => $vendors,
'receipt' => [
'id' => $goodsReceipt->id,
'code' => $goodsReceipt->code,
'type' => $goodsReceipt->type,
'warehouse_id' => $goodsReceipt->warehouse_id,
'vendor_id' => $goodsReceipt->vendor_id,
'vendor' => $vendor,
'purchase_order_id' => $goodsReceipt->purchase_order_id,
'purchase_order' => $formattedPO,
'received_date' => \Carbon\Carbon::parse($goodsReceipt->received_date)->format('Y-m-d'),
'remarks' => $goodsReceipt->remarks,
'items' => $formattedItems,
],
]);
}
/**
* 更新進貨單
*/
public function update(Request $request, GoodsReceipt $goodsReceipt)
{
$validated = $request->validate($this->getItemValidationRules());
try {
$this->goodsReceiptService->update($goodsReceipt, $request->all());
return redirect()->route('goods-receipts.show', $goodsReceipt->id)->with('success', '進貨單已更新');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* 取得品項驗證規則
*/
private function getItemValidationRules(): array
{
return [
'warehouse_id' => 'required|exists:warehouses,id',
'type' => 'required|in:standard,miscellaneous,other',
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id',
'vendor_id' => 'nullable|integer',
'received_date' => 'required|date',
'remarks' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|integer|exists:products,id',
'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer',
'items.*.quantity_received' => 'required|numeric|min:0',
'items.*.unit_price' => 'nullable|numeric|min:0',
'items.*.subtotal' => 'nullable|numeric|min:0',
'items.*.batch_number' => 'nullable|string',
'items.*.expiry_date' => 'nullable|date',
'force' => 'nullable|boolean',
];
}
/**
* 預檢重複進貨 API
*/
public function checkDuplicate(Request $request)
{
$result = $this->duplicateCheckService->checkDuplicateReceipt($request->all());
return response()->json($result);
}
public function submit(GoodsReceipt $goodsReceipt)
{
if (!auth()->user()->can('goods_receipts.edit')) {
return back()->with('error', '您沒有權限確認點收');
}
try {
$this->goodsReceiptService->submit($goodsReceipt);
return back()->with('success', '進貨單已點收完成,庫存已增加並拋轉應付帳款');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
// API to search POs
public function searchPOs(Request $request)
{
$search = $request->input('query');
if (!$search) {
return response()->json([]);
}
$pos = $this->procurementService->searchPendingPurchaseOrders($search);
return response()->json($pos);
}
// API to search Products for Manual Entry
// 支援 query='*' 回傳所有商品(用於 SearchableSelect 下拉選單)
public function searchProducts(Request $request)
{
$search = $request->input('query');
if (!$search) {
return response()->json([]);
}
// 萬用字元:回傳所有商品
if ($search === '*') {
$products = $this->inventoryService->getProductsByName('');
} else {
$products = $this->inventoryService->getProductsByName($search);
}
// Format for frontend
$mapped = $products->map(function($product) {
return [
'id' => $product->id,
'name' => $product->name,
'code' => $product->code,
'unit' => $product->baseUnit?->name ?? '個',
'price' => $product->purchase_price ?? 0,
];
});
return response()->json($mapped);
}
// API to search Vendors
public function searchVendors(Request $request)
{
$search = $request->input('query');
if (!$search) {
return response()->json([]);
}
$vendors = $this->procurementService->searchVendors($search);
return response()->json($vendors);
}
/**
* 刪除進貨單
*/
public function destroy(GoodsReceipt $goodsReceipt)
{
// 只有有權限的人可以刪除
if (!auth()->user()->can('goods_receipts.delete')) {
return redirect()->back()->with('error', '您沒有權限刪除進貨單');
}
// 簡單刪除邏輯:刪除進貨單(品項由資料庫級聯刪除或手動處理)
// 注意:實務上可能需要處理已入庫的庫存回滾,但在這個簡易 ERP 中通常是行政刪除
$goodsReceipt->delete();
return redirect()->route('goods-receipts.index')->with('success', '進貨單已刪除');
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\TurnoverService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class InventoryAnalysisController extends Controller
{
protected $turnoverService;
public function __construct(TurnoverService $turnoverService)
{
$this->turnoverService = $turnoverService;
}
public function index(Request $request)
{
$filters = $request->only([
'warehouse_id', 'category_id', 'search', 'per_page', 'sort_by', 'sort_order', 'status'
]);
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) $request->input('per_page', $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$analysisData = $this->turnoverService->getAnalysisData($filters, $perPage);
$kpis = $this->turnoverService->getKPIs($filters);
return Inertia::render('Inventory/Analysis/Index', [
'analysisData' => $analysisData,
'kpis' => $kpis,
'warehouses' => Warehouse::select('id', 'name')->get(),
'categories' => Category::select('id', 'name')->get(),
'filters' => $filters,
]);
}
}

View File

@@ -0,0 +1,528 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\InventoryTransaction;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
use App\Modules\Inventory\Imports\InventoryImport;
use App\Modules\Inventory\Exports\InventoryTemplateExport;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use App\Modules\Core\Contracts\CoreServiceInterface;
class InventoryController extends Controller
{
protected $coreService;
protected $inventoryService;
public function __construct(
CoreServiceInterface $coreService,
\App\Modules\Inventory\Contracts\InventoryServiceInterface $inventoryService
) {
$this->coreService = $coreService;
$this->inventoryService = $inventoryService;
}
public function index(Request $request, Warehouse $warehouse)
{
// ... (existing code for index) ...
$warehouse->load([
'inventories.product.category',
'inventories.product.baseUnit',
'inventories.lastIncomingTransaction',
'inventories.lastOutgoingTransaction'
]);
$allProducts = Product::select('id', 'name', 'code', 'category_id')->with('category:id,name')->get();
// 1. 準備 availableProducts
$availableProducts = $allProducts->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'type' => $product->category?->name ?? '其他',
];
});
// 2. 從新表格讀取安全庫存設定 (商品-倉庫層級)
$safetyStockMap = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
->pluck('safety_stock', 'product_id')
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
$query = $warehouse->inventories()
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction']);
// 加入搜尋過濾
if ($request->filled('search')) {
$search = $request->input('search');
$query->where(function ($q) use ($search) {
$q->where('batch_number', 'like', "%{$search}%")
->orWhere(\Illuminate\Support\Facades\DB::raw("CONCAT('BATCH-', inventories.id)"), 'like', "%{$search}%")
->orWhereHas('product', function ($pq) use ($search) {
$pq->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
});
});
}
// 加入類型過濾
if ($request->filled('type') && $request->input('type') !== 'all') {
$type = $request->input('type');
$query->whereHas('product.category', function ($cq) use ($type) {
$cq->where('name', $type);
});
}
$items = $query->get();
// 判斷是否為販賣機並調整分組
$isVending = $warehouse->type === 'vending';
$inventories = $items->groupBy(function ($item) use ($isVending) {
return $isVending
? $item->product_id . '-' . ($item->location ?? 'NO-SLOT')
: $item->product_id;
})->map(function ($batchItems) use ($safetyStockMap, $isVending) {
$firstItem = $batchItems->first();
$product = $firstItem->product;
$totalQuantity = $batchItems->sum('quantity');
$totalValue = $batchItems->sum('total_value'); // 計算總價值
// 從獨立表格讀取安全庫存
$safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null;
// 計算狀態
$status = '正常';
if (!is_null($safetyStock)) {
if ($totalQuantity < $safetyStock) {
$status = '低於';
}
}
return [
'productId' => (string) $firstItem->product_id,
'productName' => $product?->name ?? '未知商品',
'productCode' => $product?->code ?? 'N/A',
'baseUnit' => $product?->baseUnit?->name ?? '個',
'totalQuantity' => (float) $totalQuantity,
'totalValue' => (float) $totalValue,
'safetyStock' => $safetyStock,
'status' => $status,
'batches' => $batchItems->map(function ($inv) {
return [
'id' => (string) $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product?->name ?? '未知商品',
'productCode' => $inv->product?->code ?? 'N/A',
'unit' => $inv->product?->baseUnit?->name ?? '個',
'quantity' => (float) $inv->quantity,
'unit_cost' => (float) $inv->unit_cost,
'total_value' => (float) $inv->total_value,
'safetyStock' => null, // 批號層級不再有安全庫存
'status' => '正常',
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
'location' => $inv->location,
'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? substr($inv->lastIncomingTransaction->actual_time, 0, 10) : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? substr($inv->lastOutgoingTransaction->actual_time, 0, 10) : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
];
})->values(),
];
})->values();
// 4. 準備 safetyStockSettings (從新表格讀取)
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
->with(['product.category'])
->get()
->map(function ($setting) {
return [
'id' => (string) $setting->id,
'warehouseId' => (string) $setting->warehouse_id,
'productId' => (string) $setting->product_id,
'productName' => $setting->product?->name ?? '未知商品',
'productType' => $setting->product?->category?->name ?? '其他',
'safetyStock' => (float) $setting->safety_stock,
'createdAt' => $setting->created_at->toIso8601String(),
'updatedAt' => $setting->updated_at->toIso8601String(),
];
});
return Inertia::render('Warehouse/Inventory', [
'warehouse' => $warehouse,
'inventories' => $inventories,
'safetyStockSettings' => $safetyStockSettings,
'availableProducts' => $availableProducts,
]);
}
public function create(Warehouse $warehouse)
{
// ... (unchanged) ...
$products = Product::select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
->with(['baseUnit:id,name', 'largeUnit:id,name'])
->get()
->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'code' => $product->code,
'barcode' => $product->barcode,
'baseUnit' => $product->baseUnit?->name ?? '個',
'largeUnit' => $product->largeUnit?->name, // 可能為 null
'conversionRate' => (float) $product->conversion_rate,
'costPrice' => (float) $product->cost_price,
];
});
return Inertia::render('Warehouse/AddInventory', [
'warehouse' => $warehouse,
'products' => $products,
]);
}
public function store(Request $request, Warehouse $warehouse)
{
// ... (unchanged) ...
$validated = $request->validate([
'inboundDate' => 'required|date',
'reason' => 'required|string',
'notes' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unit_cost' => 'nullable|numeric|min:0', // 新增成本驗證
'items.*.batchMode' => 'required|in:existing,new,none',
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2',
'items.*.expiryDate' => 'nullable|date',
'items.*.location' => 'nullable|string|max:50',
]);
return DB::transaction(function () use ($validated, $warehouse) {
// 修正時間精度:使用 Carbon 解析,若含時間則保留並補上秒數,若只有日期則補上當前時間
$dt = \Illuminate\Support\Carbon::parse($validated['inboundDate']);
if ($dt->hour === 0 && $dt->minute === 0 && $dt->second === 0) {
$dt->setTimeFrom(now());
} else {
$dt->setSecond(now()->second);
}
$inboundDateTime = $dt->toDateTimeString();
$this->inventoryService->processIncomingInventory($warehouse, $validated['items'], [
'inboundDate' => $inboundDateTime,
'reason' => $validated['reason'],
'notes' => $validated['notes'] ?? '',
]);
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存記錄已儲存成功');
});
}
// ... (getBatches unchanged) ...
public function getBatches(Warehouse $warehouse, $productId, Request $request)
{
$originCountry = $request->query('originCountry', 'TW');
$arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d'));
$batches = Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->get()
->map(function ($inventory) {
return [
'inventoryId' => (string) $inventory->id,
'batchNumber' => $inventory->batch_number,
'originCountry' => $inventory->origin_country,
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
'quantity' => (float) $inventory->quantity,
'unitCost' => (float) $inventory->unit_cost,
'location' => $inventory->location,
];
});
// 計算下一個流水號
$product = Product::find($productId);
$nextSequence = '01';
if ($product) {
$batchNumber = Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$arrivalDate
);
$nextSequence = substr($batchNumber, -2);
}
return response()->json([
'batches' => $batches,
'nextSequence' => $nextSequence
]);
}
public function edit(Request $request, Warehouse $warehouse, $inventoryId)
{
if (str_starts_with($inventoryId, 'mock-inv-')) {
return redirect()->back()->with('error', '無法編輯範例資料');
}
// 移除 'transactions.user' 預載入
$inventory = Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}])->findOrFail($inventoryId);
// 手動 Hydrate 使用者資料
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
// 轉換為前端需要的格式
$inventoryData = [
'id' => (string) $inventory->id,
'warehouseId' => (string) $inventory->warehouse_id,
'productId' => (string) $inventory->product_id,
'productName' => $inventory->product?->name ?? '未知商品',
'quantity' => (float) $inventory->quantity,
'unit_cost' => (float) $inventory->unit_cost,
'total_value' => (float) $inventory->total_value,
'batchNumber' => $inventory->batch_number ?? '-',
'expiryDate' => $inventory->expiry_date ?? null,
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
'lastOutboundDate' => null,
];
// 整理異動紀錄
$transactions = $inventory->transactions->map(function ($tx) use ($users) {
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'unit_cost' => (float) $tx->unit_cost,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $user ? $user->name : '系統', // 手動對應
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
];
});
return Inertia::render('Warehouse/EditInventory', [
'warehouse' => $warehouse,
'inventory' => $inventoryData,
'transactions' => $transactions,
]);
}
public function update(Request $request, Warehouse $warehouse, $inventoryId)
{
// ... (unchanged) ...
$inventory = Inventory::find($inventoryId);
// 如果找不到 (可能是舊路由傳 product ID)
if (!$inventory) {
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
}
if (!$inventory) {
return redirect()->back()->with('error', '找不到庫存紀錄');
}
$validated = $request->validate([
'quantity' => 'required|numeric|min:0',
// 以下欄位改為 nullable支援新表單
'type' => 'nullable|string',
'operation' => 'nullable|in:add,subtract,set',
'reason' => 'nullable|string',
'notes' => 'nullable|string',
'unit_cost' => 'nullable|numeric|min:0', // 新增成本
// ...
'batchNumber' => 'nullable|string',
'expiryDate' => 'nullable|date',
'lastInboundDate' => 'nullable|date',
'lastOutboundDate' => 'nullable|date',
]);
return DB::transaction(function () use ($validated, $inventory) {
$this->inventoryService->adjustInventory($inventory, $validated);
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
->with('success', '庫存資料已更新');
});
}
public function destroy(Warehouse $warehouse, $inventoryId)
{
// ... (unchanged) ...
$inventory = Inventory::findOrFail($inventoryId);
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
if ($inventory->quantity > 0) {
return redirect()->back()->with('error', '庫存數量大於 0無法刪除。請先進行出庫或調整。');
}
// 歸零異動 (因為已經限制為 0 才能刪,這段邏輯可以簡化,但為了保險起見,若有微小殘值仍可記錄歸零)
if (abs($inventory->quantity) > 0.0001) {
$inventory->transactions()->create([
'type' => '手動編輯',
'quantity' => -$inventory->quantity,
'unit_cost' => $inventory->unit_cost,
'balance_before' => $inventory->quantity,
'balance_after' => 0,
'reason' => '刪除庫存品項',
'actual_time' => now(),
'user_id' => auth()->id(),
]);
}
$inventory->delete();
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存品項已刪除');
}
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
{
// ... (前端 history 頁面可能也需要 unit_cost這裡可補上) ...
$inventoryId = $request->query('inventoryId');
$productId = $request->query('productId');
if ($productId) {
$product = Product::findOrFail($productId);
// 取得該倉庫中該商品的所有批號 ID
$inventoryIds = Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->pluck('id')
->toArray();
$transactionsRaw = InventoryTransaction::whereIn('inventory_id', $inventoryIds)
->with('inventory') // 需要批號資訊
->orderBy('actual_time', 'desc')
->orderBy('id', 'desc')
->get();
// 手動 Hydrate 使用者資料
$userIds = $transactionsRaw->pluck('user_id')->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
// 計算商品在該倉庫的總量(不分批號)
$currentRunningTotal = (float) Inventory::whereIn('id', $inventoryIds)->sum('quantity');
$transactions = $transactionsRaw->map(function ($tx) use ($users, &$currentRunningTotal) {
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
$balanceAfter = $currentRunningTotal;
// 為下一筆(較舊的)紀錄更新 Running Total
$currentRunningTotal -= (float) $tx->quantity;
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'unit_cost' => (float) $tx->unit_cost,
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
'reason' => $tx->reason,
'userName' => $user ? $user->name : '系統',
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
'slot' => $tx->inventory?->location, // 加入貨道資訊
];
});
// 重新計算目前的總量(用於 Header 顯示,確保一致性)
$totalQuantity = Inventory::whereIn('id', $inventoryIds)->sum('quantity');
return Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => null, // 跨批號查詢沒有單一 ID
'productName' => $product->name,
'productCode' => $product->code,
'batchNumber' => '所有批號',
'quantity' => (float) $totalQuantity,
],
'transactions' => $transactions
]);
}
if ($inventoryId) {
// 單一批號查詢
// 移除 'transactions.user'
$inventory = Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}])->findOrFail($inventoryId);
// 手動 Hydrate 使用者資料
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$transactions = $inventory->transactions->map(function ($tx) use ($users, $inventory) {
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'unit_cost' => (float) $tx->unit_cost,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $user ? $user->name : '系統', // 手動對應
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
'slot' => $inventory->location, // 加入貨道資訊
];
});
return Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => (string) $inventory->id,
'productName' => $inventory->product?->name ?? '未知商品',
'productCode' => $inventory->product?->code ?? 'N/A',
'batchNumber' => $inventory->batch_number ?? '-',
'quantity' => (float) $inventory->quantity,
'unit_cost' => (float) $inventory->unit_cost,
'total_value' => (float) $inventory->total_value,
],
'transactions' => $transactions
]);
}
return redirect()->back()->with('error', '未提供查詢參數');
}
/**
* 匯入入庫
*/
public function import(Request $request, Warehouse $warehouse)
{
$request->validate([
'file' => 'required|mimes:xlsx,xls,csv',
'inboundDate' => 'required|date',
'notes' => 'nullable|string',
]);
try {
Excel::import(
new InventoryImport($warehouse, $request->inboundDate, $request->notes),
$request->file('file')
);
return back()->with('success', '庫存資料匯入成功');
} catch (\Exception $e) {
return back()->withErrors(['file' => '匯入過程中發生錯誤: ' . $e->getMessage()]);
}
}
/**
* 下載匯入範本 (.xlsx)
*/
public function template()
{
return Excel::download(new InventoryTemplateExport, '庫存匯入範本.xlsx');
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\InventoryReportService;
use App\Modules\Inventory\Exports\InventoryReportExport;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
class InventoryReportController extends Controller
{
protected $reportService;
public function __construct(InventoryReportService $reportService)
{
$this->reportService = $reportService;
}
public function index(Request $request)
{
$filters = $request->only([
'date_from', 'date_to', 'warehouse_id', 'category_id', 'search', 'per_page',
'sort_by', 'sort_order'
]);
if (!isset($filters['date_from'])) {
$filters['date_from'] = date('Y-m-d');
}
if (!isset($filters['date_to'])) {
$filters['date_to'] = date('Y-m-d');
}
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = (int) $request->input('per_page', $defaultPerPage);
if (!in_array($perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$reportData = $this->reportService->getReportData($filters, $perPage);
$summary = $this->reportService->getSummary($filters);
return Inertia::render('Inventory/Report/Index', [
'reportData' => $reportData,
'summary' => $summary,
'warehouses' => Warehouse::select('id', 'name')->get(),
'categories' => Category::select('id', 'name')->get(),
'filters' => $filters,
]);
}
public function export(Request $request)
{
$filters = $request->only([
'period', 'date_from', 'date_to', 'warehouse_id', 'category_id', 'search'
]);
return Excel::download(new InventoryReportExport($this->reportService, $filters), 'inventory_report_' . date('YmdHis') . '.xlsx');
}
public function show(Request $request, $productId)
{
// 明細頁面自身使用的篩選條件
$filters = $request->only([
'date_from', 'date_to', 'warehouse_id'
]);
// 報表頁面的完整篩選狀態(用於返回時恢復)
$reportFilters = $request->only([
'date_from', 'date_to', 'warehouse_id',
'category_id', 'search', 'per_page'
]);
// 將傳入的 report_page 轉回 page 以便 Link 元件正確生成回報表頁的連結
if ($request->has('report_page')) {
$reportFilters['page'] = $request->input('report_page');
}
// 取得商品資訊 (用於顯示標題,含基本單位)
$product = \App\Modules\Inventory\Models\Product::with('baseUnit')->findOrFail($productId);
$transactions = $this->reportService->getProductDetails($productId, $filters, 20);
return Inertia::render('Inventory/Report/Show', [
'product' => [
'id' => $product->id,
'code' => $product->code,
'name' => $product->name,
'unit_name' => $product->baseUnit?->name ?? '-',
],
'transactions' => $transactions,
'filters' => $filters,
'reportFilters' => $reportFilters,
'warehouses' => Warehouse::select('id', 'name')->get(),
]);
}
}

View File

@@ -0,0 +1,313 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Unit;
use App\Modules\Inventory\Models\Category;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Maatwebsite\Excel\Facades\Excel;
use App\Modules\Inventory\Exports\ProductTemplateExport;
use App\Modules\Inventory\Imports\ProductImport;
class ProductController extends Controller
{
protected $productService;
public function __construct(\App\Modules\Inventory\Contracts\ProductServiceInterface $productService)
{
$this->productService = $productService;
}
/**
* 顯示資源列表。
*/
public function index(Request $request): Response
{
$query = Product::with(['category', 'baseUnit', 'largeUnit', 'purchaseUnit']);
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('brand', 'like', "%{$search}%");
});
}
if ($request->filled('category_id') && $request->category_id !== 'all') {
$query->where('category_id', $request->category_id);
}
$defaultPerPage = \App\Modules\Core\Models\SystemSetting::getVal('display.per_page', 10);
$perPage = $request->input('per_page', $defaultPerPage);
if (!in_array((int)$perPage, [10, 20, 50, 100])) {
$perPage = $defaultPerPage;
}
$sortField = $request->input('sort_field', 'id');
$sortDirection = $request->input('sort_direction', 'desc');
// 定義允許的排序欄位以防止 SQL 注入
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
if (!in_array($sortField, $allowedSorts)) {
$sortField = 'id';
}
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
$sortDirection = 'desc';
}
// 如果需要,分別處理關聯排序(分類名稱),或簡單的 join
if ($sortField === 'category_id') {
// 加入分類以便按名稱排序?還是僅按 ID
// 簡單方法:目前按 ID 排序,如果使用者想要按名稱排序則 join。
// 先假設標準欄位排序。
$query->orderBy('category_id', $sortDirection);
} else {
$query->orderBy($sortField, $sortDirection);
}
$products = $query->paginate($perPage)->withQueryString();
$products->getCollection()->transform(function ($product) {
return (object) [
'id' => (string) $product->id,
'code' => $product->code,
'barcode' => $product->barcode,
'name' => $product->name,
'categoryId' => $product->category_id,
'category' => $product->category ? (object) [
'id' => $product->category->id,
'name' => $product->category->name,
] : null,
'brand' => $product->brand,
'specification' => $product->specification,
'baseUnitId' => $product->base_unit_id,
'baseUnit' => $product->baseUnit ? (object) [
'id' => $product->baseUnit->id,
'name' => $product->baseUnit->name,
] : null,
'largeUnitId' => $product->large_unit_id,
'largeUnit' => $product->largeUnit ? (object) [
'id' => $product->largeUnit->id,
'name' => $product->largeUnit->name,
] : null,
'purchaseUnitId' => $product->purchase_unit_id,
'purchaseUnit' => $product->purchaseUnit ? (object) [
'id' => $product->purchaseUnit->id,
'name' => $product->purchaseUnit->name,
] : null,
'conversionRate' => (float) $product->conversion_rate,
'location' => $product->location,
'cost_price' => (float) $product->cost_price,
'price' => (float) $product->price,
'member_price' => (float) $product->member_price,
'wholesale_price' => (float) $product->wholesale_price,
'is_active' => (bool) $product->is_active,
];
});
$categories = Category::select('id', 'name')->where('is_active', true)->get();
return Inertia::render('Product/Index', [
'products' => $products,
'categories' => $categories->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
]);
}
/**
* 顯示指定的資源。
*/
public function show(Product $product): Response
{
return Inertia::render('Product/Show', [
'product' => (object) [
'id' => (string) $product->id,
'code' => $product->code,
'barcode' => $product->barcode,
'name' => $product->name,
'categoryId' => $product->category_id,
'category' => $product->category ? (object) [
'id' => $product->category->id,
'name' => $product->category->name,
] : null,
'brand' => $product->brand,
'specification' => $product->specification,
'baseUnitId' => $product->base_unit_id,
'baseUnit' => $product->baseUnit ? (object) [
'id' => $product->baseUnit->id,
'name' => $product->baseUnit->name,
] : null,
'largeUnitId' => $product->large_unit_id,
'largeUnit' => $product->largeUnit ? (object) [
'id' => $product->largeUnit->id,
'name' => $product->largeUnit->name,
] : null,
'purchaseUnitId' => $product->purchase_unit_id,
'purchaseUnit' => $product->purchaseUnit ? (object) [
'id' => $product->purchaseUnit->id,
'name' => $product->purchaseUnit->name,
] : null,
'conversionRate' => (float) $product->conversion_rate,
'location' => $product->location,
'cost_price' => (float) $product->cost_price,
'price' => (float) $product->price,
'member_price' => (float) $product->member_price,
'wholesale_price' => (float) $product->wholesale_price,
'is_active' => (bool) $product->is_active,
]
]);
}
/**
* 顯示建立表單。
*/
public function create(): Response
{
return Inertia::render('Product/Create', [
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
]);
}
/**
* 將新建立的資源儲存到儲存體中。
*/
public function store(Request $request)
{
$validated = $request->validate([
'code' => 'nullable|unique:products,code',
'barcode' => 'nullable|unique:products,barcode',
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit_id' => 'required|exists:units,id',
'large_unit_id' => 'nullable|exists:units,id',
'purchase_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'nullable|numeric|min:0',
'location' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0',
'price' => 'nullable|numeric|min:0',
'member_price' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0',
'is_active' => 'boolean',
]);
$product = $this->productService->createProduct($validated);
return redirect()->route('products.index')->with('success', '商品已建立');
}
/**
* 顯示編輯表單。
*/
public function edit(Product $product): Response
{
return Inertia::render('Product/Edit', [
'product' => (object) [
'id' => (string) $product->id,
'code' => $product->code,
'barcode' => $product->barcode,
'name' => $product->name,
'categoryId' => $product->category_id,
'brand' => $product->brand,
'specification' => $product->specification,
'baseUnitId' => $product->base_unit_id,
'largeUnitId' => $product->large_unit_id,
'conversionRate' => (float) $product->conversion_rate,
'purchaseUnitId' => $product->purchase_unit_id,
'location' => $product->location,
'cost_price' => (float) $product->cost_price,
'price' => (float) $product->price,
'member_price' => (float) $product->member_price,
'wholesale_price' => (float) $product->wholesale_price,
'is_active' => (bool) $product->is_active,
],
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
]);
}
/**
* 更新儲存體中的指定資源。
*/
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'code' => 'nullable|unique:products,code,' . $product->id,
'barcode' => 'nullable|unique:products,barcode,' . $product->id,
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'brand' => 'nullable|string|max:255',
'specification' => 'nullable|string',
'base_unit_id' => 'required|exists:units,id',
'large_unit_id' => 'nullable|exists:units,id',
'purchase_unit_id' => 'nullable|exists:units,id',
'conversion_rate' => 'nullable|numeric|min:0',
'location' => 'nullable|string|max:255',
'cost_price' => 'nullable|numeric|min:0',
'price' => 'nullable|numeric|min:0',
'member_price' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0',
'is_active' => 'boolean',
]);
$this->productService->updateProduct($product, $validated);
if ($request->input('from') === 'show') {
return redirect()->route('products.show', $product->id)->with('success', '商品已更新');
}
return redirect()->route('products.index')->with('success', '商品已更新');
}
/**
* 從儲存體中移除指定資源。
*/
public function destroy(Product $product)
{
$product->delete();
return redirect()->back()->with('success', '商品已刪除');
}
/**
* 下載匯入範本
*/
public function template()
{
return Excel::download(new ProductTemplateExport, '商品匯入範本.xlsx');
}
/**
* 匯入商品
*/
public function import(Request $request)
{
$request->validate([
'file' => 'required|file|mimes:xlsx,xls',
]);
try {
Excel::import(new ProductImport, $request->file('file'));
return redirect()->back()->with('success', '商品匯入成功');
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
$failures = $e->failures();
$messages = [];
foreach ($failures as $failure) {
$messages[] = '第 ' . $failure->row() . ' 行: ' . implode(', ', $failure->errors());
}
return redirect()->back()->withErrors(['file' => implode("\n", $messages)]);
} catch (\Exception $e) {
return redirect()->back()->withErrors(['file' => '匯入失敗: ' . $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class SafetyStockController extends Controller
{
/**
* 顯示安全庫存設定頁面
*/
public function index(Warehouse $warehouse)
{
$allProducts = Product::select('id', 'name', 'category_id', 'base_unit_id')->with(['category:id,name', 'baseUnit:id,name'])->get();
// 準備可選商品列表
$availableProducts = $allProducts->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'type' => $product->category ? $product->category->name : '其他',
'unit' => $product->baseUnit?->name ?? '個',
];
});
// 獲取現有庫存 (用於抓取「已在倉庫中」的商品)
$inventoryProductIds = Inventory::where('warehouse_id', $warehouse->id)->pluck('product_id')->unique();
// 準備安全庫存設定列表 (從資料庫讀取)
$existingSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
->with(['product.category', 'product.baseUnit'])
->get();
$existingProductIds = $existingSettings->pluck('product_id')->toArray();
// 找出:有庫存但是「還沒設定過安全庫存」的商品
$missingProductIds = $inventoryProductIds->diff($existingProductIds);
$missingProducts = Product::whereIn('id', $missingProductIds)
->with(['category', 'baseUnit'])
->get();
// 合併:已設定的 + 有庫存未設定的 (預設值 0)
$safetyStockSettings = $existingSettings->map(function ($setting) {
return [
'id' => (string) $setting->id,
'warehouseId' => (string) $setting->warehouse_id,
'productId' => (string) $setting->product_id,
'productName' => $setting->product->name,
'productType' => $setting->product->category ? $setting->product->category->name : '其他',
'safetyStock' => (float) $setting->safety_stock,
'unit' => $setting->product->baseUnit?->name ?? '個',
'updatedAt' => $setting->updated_at->toIso8601String(),
'isNew' => false, // 標記為舊有設定
];
})->concat($missingProducts->map(function ($product) use ($warehouse) {
return [
'id' => 'temp_' . $product->id, // 暫時 ID
'warehouseId' => (string) $warehouse->id,
'productId' => (string) $product->id,
'productName' => $product->name,
'productType' => $product->category ? $product->category->name : '其他',
'safetyStock' => 0, // 預設 0
'unit' => $product->baseUnit?->name ?? '個',
'updatedAt' => now()->toIso8601String(),
'isNew' => true, // 標記為建議新增
];
}))->values();
// 原本的 inventories 映射 (供顯示對比)
$inventories = Inventory::where('warehouse_id', $warehouse->id)
->select('product_id', DB::raw('SUM(quantity) as total_quantity'))
->groupBy('product_id')
->get()
->map(function ($inv) {
return [
'productId' => (string) $inv->product_id,
'quantity' => (float) $inv->total_quantity,
];
});
return Inertia::render('Warehouse/SafetyStockSettings', [
'warehouse' => $warehouse,
'safetyStockSettings' => $safetyStockSettings,
'inventories' => $inventories,
'availableProducts' => $availableProducts,
]);
}
/**
* 批量儲存安全庫存設定
*/
public function store(Request $request, Warehouse $warehouse)
{
$validated = $request->validate([
'settings' => 'required|array|min:1',
'settings.*.productId' => 'required|exists:products,id',
'settings.*.quantity' => 'required|numeric|min:0',
]);
DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['settings'] as $item) {
WarehouseProductSafetyStock::updateOrCreate(
[
'warehouse_id' => $warehouse->id,
'product_id' => $item['productId'],
],
[
'safety_stock' => $item['quantity'],
]
);
}
});
return redirect()->back()->with('success', '安全庫存設定已更新');
}
/**
* 更新單筆安全庫存設定
*/
public function update(Request $request, Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
{
$validated = $request->validate([
'safetyStock' => 'required|numeric|min:0',
]);
$safetyStock->update([
'safety_stock' => $validated['safetyStock'],
]);
return redirect()->back()->with('success', '安全庫存已更新');
}
/**
* 刪除安全庫存設定
*/
public function destroy(Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
{
$safetyStock->delete();
return redirect()->back()->with('success', '安全庫存設定已移除');
}
}

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