71 Commits

Author SHA1 Message Date
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
142 changed files with 10643 additions and 1706 deletions

View File

@@ -84,4 +84,10 @@ trigger: always_on
* **執行 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`
* **執行 Node/NPM** `./vendor/bin/sail npm run dev`
## 10. 日期處理 (Date Handling)
- 前端顯示日期時預設使用 `resources/js/lib/date.ts` 提供的 `formatDate` 工具。
- 避免直接顯示原始 ISO 字串(如 `...Z` 結尾的格式)。
- **智慧格式切換**`formatDate` 會自動判斷原始資料,若時間部分為 `00:00:00` 則僅顯示 `YYYY-MM-DD`,否則顯示 `YYYY-MM-DD HH:mm:ss`

View File

@@ -569,6 +569,7 @@ const handlePerPageChange = (value: string) => {
---
## 7. Badge 與狀態顯示
### 7.1 基本 Badge
@@ -614,6 +615,48 @@ import { Badge } from "@/Components/ui/badge";
</div>
```
### 7.3 統一狀態標籤 (StatusBadge)
系統提供統一的 `StatusBadge` 元件來顯示各種業務狀態,確保顏色與樣式的一致性。
**引入方式**
```tsx
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
```
**支援的變體 (Variant)**
| Variant | 顏色 | 適用情境 |
|---|---|---|
| `neutral` | 灰色 | 草稿、取消、關閉、缺貨 |
| `info` | 藍色 | 處理中、啟用中 |
| `warning` | 黃色 | 待審核、庫存預警、週轉慢 |
| `success` | 綠色 | 已完成、已核准、正常 |
| `destructive` | 紅色 | 作廢、駁回、滯銷、異常 |
**實作模式**
建議定義一個 `getStatusVariant` 函式將業務狀態對應到 UI 變體,保持程式碼整潔。
```tsx
// 1. 定義狀態映射函式
const getStatusVariant = (status: string): StatusVariant => {
switch (status) {
case 'normal': return 'success'; // 正常 -> 綠色
case 'slow': return 'warning'; // 週轉慢 -> 黃色
case 'dead': return 'destructive'; // 滯銷 -> 紅色
case 'out_of_stock': return 'neutral';// 缺貨 -> 灰色
default: return 'neutral';
}
};
// 2. 在表格中使用
<StatusBadge variant={getStatusVariant(item.status)}>
{item.status_label}
</StatusBadge>
```
---
## 8. 頁面佈局規範

View File

@@ -4,6 +4,7 @@ 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
@@ -43,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,100 @@
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 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,93 @@
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 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,208 +0,0 @@
name: Koori-ERP-Deploy-System
on:
push:
branches:
- demo
- main
jobs:
# --- 1. Demo 環境部署 (103 本機) ---
deploy-demo:
if: false # github.ref == 'refs/heads/demo' (暫時停用)
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
# github-server-url: ${{ github.server_url }} # 自動偵測
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 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
./ amba@192.168.0.103:/home/amba/star-erp/
rm ~/.ssh/id_rsa_demo
# 2. 檢查是否需要重建容器(只有 Dockerfile 或 compose.yaml 變動時才重建)
- name: Step 2 - Check if Rebuild Needed
id: check_rebuild
uses: appleboy/ssh-action@master
with:
host: 192.168.0.103
port: 22
username: amba
key: ${{ secrets.DEMO_SSH_KEY }}
script: |
cd /home/amba/star-erp
# 檢查最近的 commit 是否包含 Dockerfile 或 compose.yaml 的變更
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
# 3. 啟動或重建容器(根據檢查結果決定是否加 --build
- name: Step 3 - Container Up & Health Check
uses: appleboy/ssh-action@master
with:
host: 192.168.0.103
port: 22
username: amba
key: ${{ secrets.DEMO_SSH_KEY }}
script: |
cd /home/amba/koori-erp
chown -R 1000:1000 .
# 檢查是否需要重建
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --build --wait
else
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
# 確保容器正在運行(若未運行則啟動)
if ! docker ps --format '{{.Names}}' | grep -q 'koori-erp-laravel'; then
echo "容器未運行,正在啟動..."
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --wait
else
echo "容器已運行,跳過 docker compose直接進行程式碼部署..."
fi
fi
echo "容器狀態:" && docker ps --filter "name=koori-erp-laravel"
- name: Step 4 - Composer & NPM Build
uses: appleboy/ssh-action@master
with:
host: 192.168.0.103
port: 22
username: amba
key: ${{ secrets.DEMO_SSH_KEY }}
script: |
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
# 1. 後端依賴 (Demo 環境建議加上 --no-interaction 避免卡住)
composer install --no-dev --optimize-autoloader --no-interaction &&
# 2. 前端編譯
npm install &&
npm run build &&
# 3. Laravel 初始化與優化
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 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
# --- 2. 正式環境部署 (erp.koori.tw:2224) ---
deploy-production:
if: github.ref == 'refs/heads/main'
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@erp.koori.tw:/var/www/star-erp/
rm ~/.ssh/id_rsa_prod
# 2. 檢查是否需要重建容器(只有 Dockerfile 或 compose.yaml 變動時才重建)
- name: Step 2 - Check if Rebuild Needed
id: check_rebuild_prod
uses: appleboy/ssh-action@master
with:
host: erp.koori.tw
port: 2224
username: root
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/star-erp
# [Patch] 修正正式機 Nginx Proxy 配置 (對應外部 SSL/OpenResty)
sed -i "s/- '8080:8080'/- '80:80'\n - '8080:8080'/" compose.yaml
sed -i "s/demo-proxy.conf/prod-proxy.conf/" compose.yaml
# 檢查最近的 commit 是否包含 Dockerfile 或 compose.yaml 的變更
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
# 3. 啟動或重建容器(根據檢查結果決定是否加 --build
- name: Step 3 - Container Up & Health Check
uses: appleboy/ssh-action@master
with:
host: erp.koori.tw
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|docker-compose\.yaml)'; then
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
WWWGROUP=1000 WWWUSER=1000 docker compose 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 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
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 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

@@ -172,7 +172,6 @@ docker exec -it star-erp-laravel php artisan tinker
# 停止容器
docker compose down
```
## 🧪 開發規範
- **後端**: Follow Laravel 12 最佳實踐,使用 Service/Action 模式處理複雜邏輯。
@@ -181,3 +180,4 @@ docker compose down
- **多租戶**:
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。

View File

@@ -46,6 +46,7 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'appName' => $appName,
'app_version' => config('app.version'),
'auth' => [
'user' => $user ? [
'id' => $user->id,
@@ -90,6 +91,12 @@ class HandleInertiaRequests extends Middleware
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

@@ -32,6 +32,102 @@ class DashboardController extends Controller
}
$invStats = $this->inventoryService->getDashboardStats();
$procStats = $this->procurementService->getDashboardStats();
// 銷售統計 (本月營收)
$thisMonthRevenue = \App\Modules\Sales\Models\SalesImportItem::whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->sum('amount');
// 生產統計 (待核准工單)
$pendingProductionCount = \App\Modules\Production\Models\ProductionOrder::where('status', 'pending')->count();
// 生產狀態分佈
// 近30日銷售趨勢 (Area Chart)
$startDate = now()->subDays(29)->startOfDay();
$salesData = \App\Modules\Sales\Models\SalesImportItem::where('transaction_at', '>=', $startDate)
->selectRaw('DATE(transaction_at) as date, SUM(amount) as total')
->groupBy('date')
->orderBy('date')
->get()
->mapWithKeys(function ($item) {
return [$item->date => (int)$item->total];
});
$salesTrend = [];
for ($i = 0; $i < 30; $i++) {
$date = $startDate->copy()->addDays($i)->format('Y-m-d');
$salesTrend[] = [
'date' => $startDate->copy()->addDays($i)->format('m/d'),
'amount' => $salesData[$date] ?? 0,
];
}
// 本月熱銷商品 Top 5 (Bar Chart)
$topSellingProducts = \App\Modules\Sales\Models\SalesImportItem::with('product')
->whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(amount) as total_amount'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_amount')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : $item->product_code,
'amount' => (int)$item->total_amount,
];
});
// 庫存積壓排行 (Top Inventory Value)
$topInventoryValue = \App\Modules\Inventory\Models\Inventory::with('product')
->select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value'))
->where('quantity', '>', 0)
->groupBy('product_id')
->orderByDesc('total_value')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : 'Unknown Product',
'code' => $item->product ? $item->product->code : '',
'value' => (int)$item->total_value,
];
});
// 熱銷數量排行 (Top Selling by Quantity)
$topSellingByQuantity = \App\Modules\Sales\Models\SalesImportItem::with('product')
->whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_quantity'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_quantity')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : $item->product_code,
'code' => $item->product_code,
'value' => (int)$item->total_quantity,
];
});
// 即將過期商品 (Expiring Soon)
$expiringSoon = \App\Modules\Inventory\Models\Inventory::with('product')
->where('quantity', '>', 0)
->whereNotNull('expiry_date')
->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的
->orderBy('expiry_date', 'asc')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->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' => [
@@ -39,8 +135,18 @@ class DashboardController extends Controller
'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,
],
'abnormalItems' => $invStats['abnormalItems'] ?? [],
]);
}
}

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

@@ -188,11 +188,13 @@ class RoleController extends Controller
'vendors' => '廠商資料管理',
'purchase_orders' => '採購單管理',
'goods_receipts' => '進貨單管理',
'delivery_notes' => '出貨單管理',
'recipes' => '配方管理',
'production_orders' => '生產工單管理',
'utility_fees' => '公共事業費管理',
'accounting' => '會計報表',
'sales_imports' => '銷售單匯入管理',
'store_requisitions' => '門市叫貨申請',
'users' => '使用者管理',
'roles' => '角色與權限',
'system' => '系統管理',

View File

@@ -14,6 +14,11 @@ 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');

View File

@@ -0,0 +1,156 @@
<?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. 查找或建立倉庫
$warehouseId = $data['warehouse_id'] ?? null;
if (empty($warehouseId)) {
$warehouseName = $data['warehouse'] ?? '銷售倉庫';
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
$warehouseId = $warehouse->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
);
}
// 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,152 @@
<?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. 查找或建立倉庫
$warehouseId = $data['warehouse_id'] ?? null;
if (empty($warehouseId)) {
$warehouseName = $data['warehouse'] ?? '販賣機倉庫';
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
$warehouseId = $warehouse->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
);
}
// 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

@@ -3,107 +3,58 @@
namespace App\Modules\Integration\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Modules\Integration\Models\SalesOrder;
use App\Modules\Integration\Models\SalesOrderItem;
use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Warehouse;
use Illuminate\Support\Facades\DB;
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 $inventoryService;
protected $syncOrderAction;
public function __construct(InventoryService $inventoryService)
public function __construct(SyncOrderAction $syncOrderAction)
{
$this->inventoryService = $inventoryService;
$this->syncOrderAction = $syncOrderAction;
}
public function store(Request $request)
/**
* 接收並同步外部交易訂單
*
* @param SyncOrderRequest $request
* @return JsonResponse
*/
public function store(SyncOrderRequest $request): JsonResponse
{
$request->validate([
'external_order_id' => 'required|string|unique:sales_orders,external_order_id',
'warehouse' => 'nullable|string',
'warehouse_id' => 'nullable|exists:warehouses,id',
'items' => 'required|array',
'items.*.pos_product_id' => 'required|string',
'items.*.qty' => 'required|numeric|min:0.0001',
'items.*.price' => 'required|numeric',
]);
try {
return DB::transaction(function () use ($request) {
// 1. Create Order
$order = SalesOrder::create([
'external_order_id' => $request->external_order_id,
'status' => 'completed',
'payment_method' => $request->payment_method ?? 'cash',
'total_amount' => 0, // Will calculate
'sold_at' => $request->sold_at ?? now(),
'raw_payload' => $request->all(),
]);
// 所有驗證皆已透過 SyncOrderRequest 自動處理
// 將通過驗證的資料交由 Action 處理(包含併發鎖、預先驗證、與資料庫異動)
$result = $this->syncOrderAction->execute($request->validated());
// Find Warehouse (Default to "銷售倉庫")
$warehouseId = $request->warehouse_id;
if (empty($warehouseId)) {
$warehouseName = $request->warehouse ?: '銷售倉庫';
$warehouse = Warehouse::firstOrCreate(['name' => $warehouseName], [
'code' => 'SALES-' . strtoupper(bin2hex(random_bytes(4))),
'type' => 'system_sales',
'is_active' => true,
]);
$warehouseId = $warehouse->id;
}
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
$totalAmount = 0;
return response()->json([
'message' => $result['message'],
'order_id' => $result['order_id'] ?? null,
], $statusCode);
foreach ($request->items as $itemData) {
// Find product by external ID (Strict Check)
$product = Product::where('external_pos_id', $itemData['pos_product_id'])->first();
if (!$product) {
throw new \Exception("Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first.");
}
$qty = $itemData['qty'];
$price = $itemData['price'];
$lineTotal = $qty * $price;
$totalAmount += $lineTotal;
// 2. Create Order Item
SalesOrderItem::create([
'sales_order_id' => $order->id,
'product_id' => $product->id,
'product_name' => $product->name, // Snapshot name
'quantity' => $qty,
'price' => $price,
'total' => $lineTotal,
]);
// 3. Deduct Stock (Force negative allowed for POS orders)
$this->inventoryService->decreaseStock(
$product->id,
$warehouseId,
$qty,
"POS Order: " . $order->external_order_id,
true // Force = true
);
}
$order->update(['total_amount' => $totalAmount]);
return response()->json([
'message' => 'Order synced and stock deducted successfully',
'order_id' => $order->id,
], 201);
});
} 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(), 'payload' => $request->all()]);
return response()->json(['message' => 'Sync failed: ' . $e->getMessage()], 400);
// 系統層級的錯誤
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

@@ -4,14 +4,14 @@ namespace App\Modules\Integration\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Modules\Inventory\Services\ProductService;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use Illuminate\Support\Facades\Log;
class ProductSyncController extends Controller
{
protected $productService;
public function __construct(ProductService $productService)
public function __construct(ProductServiceInterface $productService)
{
$this->productService = $productService;
}
@@ -19,12 +19,17 @@ class ProductSyncController extends Controller
public function upsert(Request $request)
{
$request->validate([
'external_pos_id' => 'required|string',
'name' => 'required|string',
'price' => 'nullable|numeric',
'barcode' => 'nullable|string',
'category' => 'nullable|string',
'unit' => 'nullable|string',
'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',
]);
@@ -40,7 +45,9 @@ class ProductSyncController extends Controller
]);
} catch (\Exception $e) {
Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
return response()->json(['message' => 'Sync failed'], 500);
return response()->json([
'message' => 'Sync failed: ' . $e->getMessage(),
], 500);
}
}
}

View File

@@ -0,0 +1,52 @@
<?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');
$orders = $query->paginate($request->input('per_page', 10))
->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

@@ -4,6 +4,9 @@ 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
@@ -11,10 +14,16 @@ class IntegrationServiceProvider extends ServiceProvider
public function boot()
{
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
$this->loadRoutesFrom(__DIR__ . '/Routes/web.php');
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
// Register Middleware Alias
// 註冊 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

@@ -16,6 +16,8 @@ class SalesOrder extends Model
'total_amount',
'sold_at',
'raw_payload',
'source',
'source_label',
];
protected $casts = [

View File

@@ -0,0 +1,36 @@
<?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' => 'nullable|string',
'warehouse_id' => 'nullable|integer',
'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,37 @@
<?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' => 'nullable|string',
'warehouse_id' => 'nullable|integer',
'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

@@ -3,10 +3,12 @@
use Illuminate\Support\Facades\Route;
use App\Modules\Integration\Controllers\ProductSyncController;
use App\Modules\Integration\Controllers\OrderSyncController;
use App\Modules\Integration\Controllers\VendingOrderSyncController;
Route::prefix('api/v1/integration')
->middleware(['api', 'integration.tenant', 'auth:sanctum']) // integration.tenant middleware to identify tenant
->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']);
});

View File

@@ -0,0 +1,11 @@
<?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::get('sales-orders', [SalesOrderController::class, 'index'])->name('sales-orders.index');
Route::get('sales-orders/{salesOrder}', [SalesOrderController::class, 'show'])->name('sales-orders.show');
});
});

View File

@@ -131,4 +131,12 @@ interface InventoryServiceInterface
* @return array
*/
public function getDashboardStats(): array;
/**
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
*
* @param string $warehouseName
* @return object
*/
public function findOrCreateWarehouseByName(string $warehouseName);
}

View File

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

View File

@@ -0,0 +1,38 @@
<?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'
]);
$analysisData = $this->turnoverService->getAnalysisData($filters, $request->input('per_page', 10));
$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,352 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\StoreRequisition;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Services\StoreRequisitionService;
use App\Modules\Core\Contracts\CoreServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
class StoreRequisitionController extends Controller
{
protected StoreRequisitionService $service;
protected CoreServiceInterface $coreService;
public function __construct(
StoreRequisitionService $service,
CoreServiceInterface $coreService
) {
$this->service = $service;
$this->coreService = $coreService;
}
/**
* 叫貨單列表
*/
public function index(Request $request)
{
$query = StoreRequisition::query();
// 搜尋(單號)
if ($request->search) {
$query->where('doc_no', 'like', "%{$request->search}%");
}
// 狀態篩選
if ($request->status && $request->status !== 'all') {
$query->where('status', $request->status);
}
// 倉庫篩選
if ($request->warehouse_id) {
$query->where('store_warehouse_id', $request->warehouse_id);
}
// 日期範圍
if ($request->date_start) {
$query->whereDate('created_at', '>=', $request->date_start);
}
if ($request->date_end) {
$query->whereDate('created_at', '<=', $request->date_end);
}
// 排序
$sortField = $request->input('sort_by', 'id');
$sortOrder = $request->input('sort_order', 'desc');
$allowedSorts = ['id', 'doc_no', 'status', 'created_at', 'submitted_at'];
if (in_array($sortField, $allowedSorts)) {
$query->orderBy($sortField, $sortOrder);
} else {
$query->orderBy('id', 'desc');
}
$perPage = $request->input('per_page', 10);
$requisitions = $query->paginate($perPage)->withQueryString();
// 水和倉庫名稱與使用者名稱
$warehouses = Warehouse::select('id', 'name', 'type')->get();
$warehouseMap = $warehouses->keyBy('id');
$userIds = $requisitions->getCollection()
->pluck('created_by')
->merge($requisitions->getCollection()->pluck('approved_by'))
->filter()
->unique()
->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$requisitions->getCollection()->transform(function ($req) use ($warehouseMap, $users) {
$req->store_warehouse_name = $warehouseMap->get($req->store_warehouse_id)?->name ?? '-';
$req->supply_warehouse_name = $warehouseMap->get($req->supply_warehouse_id)?->name ?? '-';
$req->creator_name = $users->get($req->created_by)?->name ?? '-';
$req->approver_name = $users->get($req->approved_by)?->name ?? '-';
return $req;
});
return Inertia::render('StoreRequisition/Index', [
'requisitions' => $requisitions,
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_by', 'sort_order', 'per_page']),
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
]);
}
/**
* 新增頁面
*/
public function create()
{
$warehouses = Warehouse::select('id', 'name', 'type')->get();
$products = Product::select('id', 'name', 'code', 'base_unit_id')
->with('baseUnit:id,name')
->where('is_active', true)
->get();
return Inertia::render('StoreRequisition/Create', [
'warehouses' => $warehouses->map(fn($w) => [
'id' => $w->id,
'name' => $w->name,
'type' => $w->type?->value,
]),
'products' => $products->map(fn($p) => [
'id' => $p->id,
'name' => $p->name,
'code' => $p->code,
'unit_name' => $p->baseUnit?->name,
]),
]);
}
/**
* 儲存叫貨單
*/
public function store(Request $request)
{
$request->validate([
'store_warehouse_id' => 'required|exists:warehouses,id',
'remark' => 'nullable|string|max:500',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.requested_qty' => 'required|numeric|min:0.01',
'items.*.remark' => 'nullable|string|max:200',
], [
'items.required' => '至少需要一項商品',
'items.min' => '至少需要一項商品',
'items.*.requested_qty.min' => '需求數量必須大於 0',
]);
$requisition = $this->service->create(
$request->only(['store_warehouse_id', 'remark']),
$request->items,
auth()->id()
);
// 如果需要直接提交
if ($request->boolean('submit_immediately')) {
$this->service->submit($requisition, auth()->id());
return redirect()->route('store-requisitions.index')
->with('success', '叫貨單已提交審核');
}
return redirect()->route('store-requisitions.show', $requisition->id)
->with('success', '叫貨單已儲存為草稿');
}
/**
* 叫貨單詳情
*/
public function show($id)
{
$requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id);
// 水和倉庫
$warehouses = Warehouse::select('id', 'name', 'type')->get();
$warehouseMap = $warehouses->keyBy('id');
$requisition->store_warehouse_name = $warehouseMap->get($requisition->store_warehouse_id)?->name ?? '-';
$requisition->supply_warehouse_name = $warehouseMap->get($requisition->supply_warehouse_id)?->name ?? '-';
// 水和使用者
$userIds = collect([$requisition->created_by, $requisition->approved_by])->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$requisition->creator_name = $users->get($requisition->created_by)?->name ?? '-';
$requisition->approver_name = $users->get($requisition->approved_by)?->name ?? '-';
// 水和明細商品資訊
$requisition->items->transform(function ($item) {
$item->product_name = $item->product?->name ?? '-';
$item->product_code = $item->product?->code ?? '-';
$item->unit_name = $item->product?->baseUnit?->name ?? '-';
return $item;
});
// 取得庫存資訊(顯示該商品在申請倉庫的現有庫存量)
$productIds = $requisition->items->pluck('product_id')->toArray();
$inventories = Inventory::where('warehouse_id', $requisition->store_warehouse_id)
->whereIn('product_id', $productIds)
->select('product_id')
->selectRaw('SUM(quantity) as total_qty')
->groupBy('product_id')
->get()
->keyBy('product_id');
$requisition->items->transform(function ($item) use ($inventories) {
$item->current_stock = $inventories->get($item->product_id)?->total_qty ?? 0;
return $item;
});
// 操作紀錄
$activities = \Spatie\Activitylog\Models\Activity::where('subject_type', StoreRequisition::class)
->where('subject_id', $requisition->id)
->orderBy('created_at', 'desc')
->get();
return Inertia::render('StoreRequisition/Show', [
'requisition' => $requisition,
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
'activities' => $activities,
]);
}
/**
* 編輯頁面
*/
public function edit($id)
{
$requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id);
if (!in_array($requisition->status, ['draft', 'rejected'])) {
return redirect()->route('store-requisitions.show', $id)
->with('error', '僅能編輯草稿或被駁回的叫貨單');
}
$warehouses = Warehouse::select('id', 'name', 'type')->get();
$products = Product::select('id', 'name', 'code', 'base_unit_id')
->with('baseUnit:id,name')
->where('is_active', true)
->get();
return Inertia::render('StoreRequisition/Create', [
'requisition' => $requisition,
'warehouses' => $warehouses->map(fn($w) => [
'id' => $w->id,
'name' => $w->name,
'type' => $w->type?->value,
]),
'products' => $products->map(fn($p) => [
'id' => $p->id,
'name' => $p->name,
'code' => $p->code,
'unit_name' => $p->baseUnit?->name,
]),
]);
}
/**
* 更新叫貨單
*/
public function update(Request $request, $id)
{
$requisition = StoreRequisition::findOrFail($id);
$request->validate([
'store_warehouse_id' => 'required|exists:warehouses,id',
'remark' => 'nullable|string|max:500',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.requested_qty' => 'required|numeric|min:0.01',
'items.*.remark' => 'nullable|string|max:200',
]);
$requisition = $this->service->update(
$requisition,
$request->only(['store_warehouse_id', 'remark']),
$request->items
);
// 如果需要直接提交
if ($request->boolean('submit_immediately')) {
$this->service->submit($requisition, auth()->id());
return redirect()->route('store-requisitions.index')
->with('success', '叫貨單已重新提交審核');
}
return redirect()->route('store-requisitions.show', $requisition->id)
->with('success', '叫貨單已更新');
}
/**
* 提交審核
*/
public function submit($id)
{
$requisition = StoreRequisition::findOrFail($id);
$this->service->submit($requisition, auth()->id());
return redirect()->route('store-requisitions.show', $id)
->with('success', '叫貨單已提交審核');
}
/**
* 核准叫貨單
*/
public function approve(Request $request, $id)
{
$requisition = StoreRequisition::findOrFail($id);
$request->validate([
'supply_warehouse_id' => 'required|exists:warehouses,id',
'items' => 'required|array',
'items.*.id' => 'required|exists:store_requisition_items,id',
'items.*.approved_qty' => 'required|numeric|min:0',
], [
'supply_warehouse_id.required' => '請選擇供貨倉庫',
]);
$this->service->approve($requisition, $request->only(['supply_warehouse_id', 'items']), auth()->id());
return redirect()->route('store-requisitions.show', $id)
->with('success', '叫貨單已核准,調撥單已自動產生');
}
/**
* 駁回叫貨單
*/
public function reject(Request $request, $id)
{
$requisition = StoreRequisition::findOrFail($id);
$request->validate([
'reject_reason' => 'required|string|max:500',
], [
'reject_reason.required' => '請填寫駁回原因',
]);
$this->service->reject($requisition, $request->reject_reason, auth()->id());
return redirect()->route('store-requisitions.show', $id)
->with('success', '叫貨單已駁回');
}
/**
* 刪除叫貨單(僅限草稿)
*/
public function destroy($id)
{
$requisition = StoreRequisition::findOrFail($id);
if ($requisition->status !== 'draft') {
return back()->withErrors(['error' => '僅能刪除草稿狀態的叫貨單']);
}
$requisition->items()->delete();
$requisition->delete();
return redirect()->route('store-requisitions.index')
->with('success', '叫貨單已刪除');
}
}

View File

@@ -3,11 +3,13 @@
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Enums\WarehouseType;
use App\Modules\Inventory\Models\InventoryTransferOrder;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Services\TransferService;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
class TransferOrderController extends Controller
@@ -65,6 +67,7 @@ class TransferOrderController extends Controller
$validated = $request->validate([
'from_warehouse_id' => 'required_without:sourceWarehouseId|exists:warehouses,id',
'to_warehouse_id' => 'required_without:targetWarehouseId|exists:warehouses,id|different:from_warehouse_id',
'transit_warehouse_id' => 'nullable|exists:warehouses,id',
'remarks' => 'nullable|string',
'notes' => 'nullable|string',
'instant_post' => 'boolean',
@@ -75,20 +78,22 @@ class TransferOrderController extends Controller
]);
$remarks = $validated['remarks'] ?? $validated['notes'] ?? null;
$transitWarehouseId = $validated['transit_warehouse_id'] ?? null;
$order = $this->transferService->createOrder(
$fromId,
$toId,
$remarks,
auth()->id()
auth()->id(),
$transitWarehouseId
);
if ($request->input('instant_post') === true) {
try {
$this->transferService->post($order, auth()->id());
$this->transferService->dispatch($order, auth()->id());
return redirect()->back()->with('success', '撥補成功,庫存已更新');
} catch (\Exception $e) {
// 如果過帳失敗,雖然單據已建立,但應回報錯誤
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
}
}
@@ -99,22 +104,37 @@ class TransferOrderController extends Controller
public function show(InventoryTransferOrder $order)
{
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'transitWarehouse', 'createdBy', 'postedBy', 'dispatchedBy', 'receivedBy', 'storeRequisition']);
$orderData = [
'id' => (string) $order->id,
'doc_no' => $order->doc_no,
'from_warehouse_id' => (string) $order->from_warehouse_id,
'from_warehouse_name' => $order->fromWarehouse->name,
'from_warehouse_default_transit' => $order->fromWarehouse->default_transit_warehouse_id ? (string)$order->fromWarehouse->default_transit_warehouse_id : null,
'to_warehouse_id' => (string) $order->to_warehouse_id,
'to_warehouse_name' => $order->toWarehouse->name,
'to_warehouse_type' => $order->toWarehouse->type->value, // 用於判斷是否為販賣機
'to_warehouse_type' => $order->toWarehouse->type->value,
// 在途倉資訊
'transit_warehouse_id' => $order->transit_warehouse_id ? (string) $order->transit_warehouse_id : null,
'transit_warehouse_name' => $order->transitWarehouse?->name,
'transit_warehouse_plate' => $order->transitWarehouse?->license_plate,
'transit_warehouse_driver' => $order->transitWarehouse?->driver_name,
'status' => $order->status,
'remarks' => $order->remarks,
'created_at' => $order->created_at->format('Y-m-d H:i'),
'created_by' => $order->createdBy?->name,
'posted_at' => $order->posted_at?->format('Y-m-d H:i'),
'posted_by' => $order->postedBy?->name,
'dispatched_at' => $order->dispatched_at?->format('Y-m-d H:i'),
'dispatched_by' => $order->dispatchedBy?->name,
'received_at' => $order->received_at?->format('Y-m-d H:i'),
'received_by' => $order->receivedBy?->name,
'requisition' => $order->storeRequisition ? [
'id' => (string) $order->storeRequisition->id,
'doc_no' => $order->storeRequisition->doc_no,
] : null,
'items' => $order->items->map(function ($item) use ($order) {
// 獲取來源倉庫的當前庫存
$stock = Inventory::where('warehouse_id', $order->from_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
@@ -136,18 +156,51 @@ class TransferOrderController extends Controller
}),
];
// 取得在途倉庫列表供前端選擇
$transitWarehouses = Warehouse::where('type', WarehouseType::TRANSIT)
->get()
->map(fn($w) => [
'id' => (string) $w->id,
'name' => $w->name,
'license_plate' => $w->license_plate,
'driver_name' => $w->driver_name,
]);
return Inertia::render('Inventory/Transfer/Show', [
'order' => $orderData,
'transitWarehouses' => $transitWarehouses,
]);
}
public function update(Request $request, InventoryTransferOrder $order)
{
// 收貨動作:僅限 dispatched 狀態
if ($request->input('action') === 'receive') {
if ($order->status !== 'dispatched') {
return redirect()->back()->with('error', '僅能對已出貨的調撥單進行收貨確認');
}
try {
$this->transferService->receive($order, auth()->id());
return redirect()->route('inventory.transfer.index')
->with('success', '調撥單已收貨完成');
} catch (ValidationException $e) {
return redirect()->back()->withErrors($e->errors());
} catch (\Exception $e) {
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
}
}
// 以下操作僅限草稿
if ($order->status !== 'draft') {
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
}
// 1. 更新資料 (如果請求中包含 items則先執行儲存)
// 1. 更新在途倉庫(如果前端有傳)
if ($request->has('transit_warehouse_id')) {
$order->transit_warehouse_id = $request->input('transit_warehouse_id') ?: null;
}
// 2. 先更新資料 (如果請求中包含 items則先執行儲存)
$itemsChanged = false;
if ($request->has('items')) {
$validated = $request->validate([
@@ -167,20 +220,21 @@ class TransferOrderController extends Controller
$order->remarks = $request->input('remarks');
}
if ($itemsChanged || $remarksChanged) {
// [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌
if ($itemsChanged || $remarksChanged || $order->isDirty()) {
$order->touch();
$message = '儲存成功';
} else {
$message = '資料未變更';
}
// 2. 判斷是否需要過帳
// 3. 判斷是否需要出貨/過帳
if ($request->input('action') === 'post') {
try {
$this->transferService->post($order, auth()->id());
$this->transferService->dispatch($order, auth()->id());
$hasTransit = !empty($order->transit_warehouse_id);
$successMsg = $hasTransit ? '調撥單已出貨,庫存已轉入在途倉' : '調撥單已過帳完成';
return redirect()->route('inventory.transfer.index')
->with('success', '調撥單已過帳完成');
->with('success', $successMsg);
} catch (ValidationException $e) {
return redirect()->back()->withErrors($e->errors());
} catch (\Exception $e) {

View File

@@ -113,9 +113,22 @@ class WarehouseController extends Controller
'book_amount' => \App\Modules\Inventory\Models\Inventory::sum('total_value'),
];
// 取得在途倉列表供前端選擇「預設在途倉」
$transitWarehouses = Warehouse::where('type', \App\Enums\WarehouseType::TRANSIT)
->select('id', 'name', 'license_plate', 'driver_name')
->orderBy('name')
->get()
->map(fn ($w) => [
'id' => (string) $w->id,
'name' => $w->name,
'license_plate' => $w->license_plate,
'driver_name' => $w->driver_name,
]);
return Inertia::render('Warehouse/Index', [
'warehouses' => $warehouses,
'totals' => $totals,
'transitWarehouses' => $transitWarehouses,
'filters' => $request->only(['search', 'per_page']),
]);
}
@@ -130,6 +143,7 @@ class WarehouseController extends Controller
'type' => 'required|string',
'license_plate' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
'default_transit_warehouse_id' => 'nullable|exists:warehouses,id',
]);
Warehouse::create($validated);
@@ -147,6 +161,7 @@ class WarehouseController extends Controller
'type' => 'required|string',
'license_plate' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
'default_transit_warehouse_id' => 'nullable|exists:warehouses,id',
]);
$warehouse->update($validated);

View File

@@ -4,13 +4,16 @@ namespace App\Modules\Inventory;
use Illuminate\Support\ServiceProvider;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Inventory\Services\ProductService;
class InventoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
$this->app->bind(ProductServiceInterface::class, ProductService::class);
}
public function boot(): void

View File

@@ -106,16 +106,23 @@ class InventoryTransferOrder extends Model
'doc_no',
'from_warehouse_id',
'to_warehouse_id',
'transit_warehouse_id',
'status',
'remarks',
'posted_at',
'created_by',
'updated_by',
'posted_by',
'dispatched_at',
'dispatched_by',
'received_at',
'received_by',
];
protected $casts = [
'posted_at' => 'datetime',
'dispatched_at' => 'datetime',
'received_at' => 'datetime',
];
protected static function boot()
@@ -163,8 +170,28 @@ class InventoryTransferOrder extends Model
return $this->belongsTo(User::class, 'created_by');
}
public function storeRequisition(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(StoreRequisition::class, 'transfer_order_id');
}
public function postedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'posted_by');
}
public function transitWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'transit_warehouse_id');
}
public function dispatchedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'dispatched_by');
}
public function receivedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'received_by');
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Core\Models\User;
class StoreRequisition extends Model
{
use HasFactory, LogsActivity;
protected $fillable = [
'doc_no',
'store_warehouse_id',
'supply_warehouse_id',
'status',
'remark',
'reject_reason',
'created_by',
'approved_by',
'submitted_at',
'approved_at',
'transfer_order_id',
];
protected $casts = [
'submitted_at' => 'datetime',
'approved_at' => 'datetime',
];
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
/**
* 自定義日誌屬性,解析 ID 為名稱
*/
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
{
$properties = $activity->properties->toArray();
// 基本單據資訊快照
$properties['snapshot'] = [
'doc_no' => $this->doc_no,
'store_warehouse_name' => $this->storeWarehouse?->name,
'supply_warehouse_name' => $this->supplyWarehouse?->name,
'status' => $this->status,
];
// 移除雜訊欄位
if (isset($properties['attributes'])) {
unset($properties['attributes']['updated_at']);
}
if (isset($properties['old'])) {
unset($properties['old']['updated_at']);
}
$activity->properties = collect($properties);
}
/**
* 自動產生單號 SR-YYYYMMDD-XX
*/
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->doc_no)) {
$today = date('Ymd');
$prefix = 'SR-' . $today . '-';
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
->orderBy('doc_no', 'desc')
->first();
if ($lastDoc) {
$lastNumber = substr($lastDoc->doc_no, -2);
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
} else {
$nextNumber = '01';
}
$model->doc_no = $prefix . $nextNumber;
}
});
}
// ===== 關聯 =====
/**
* 申請倉庫
*/
public function storeWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'store_warehouse_id');
}
/**
* 供貨倉庫(審核時填入)
*/
public function supplyWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'supply_warehouse_id');
}
/**
* 叫貨明細
*/
public function items(): HasMany
{
return $this->hasMany(StoreRequisitionItem::class);
}
/**
* 申請人
*/
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 審核人
*/
public function approvedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by');
}
/**
* 關聯調撥單
*/
public function transferOrder(): BelongsTo
{
return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StoreRequisitionItem extends Model
{
use HasFactory;
protected $fillable = [
'store_requisition_id',
'product_id',
'requested_qty',
'approved_qty',
'remark',
];
protected $casts = [
'requested_qty' => 'decimal:2',
'approved_qty' => 'decimal:2',
];
/**
* 所屬叫貨單
*/
public function requisition(): BelongsTo
{
return $this->belongsTo(StoreRequisition::class, 'store_requisition_id');
}
/**
* 關聯商品(同模組)
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -20,6 +20,7 @@ class Warehouse extends Model
'description',
'license_plate',
'driver_name',
'default_transit_warehouse_id',
];
protected $casts = [
@@ -50,7 +51,13 @@ class Warehouse extends Model
return $this->hasMany(Inventory::class);
}
/**
* 預設在途倉庫
*/
public function defaultTransitWarehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(self::class, 'default_transit_warehouse_id');
}
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Modules\Inventory\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Modules\Inventory\Models\StoreRequisition;
class StoreRequisitionNotification extends Notification
{
use Queueable;
protected StoreRequisition $requisition;
protected string $action;
protected string $actorName;
/**
* 建立通知實例
*
* @param StoreRequisition $requisition 叫貨單
* @param string $action 操作類型submitted / approved / rejected
* @param string $actorName 操作者名稱
*/
public function __construct(StoreRequisition $requisition, string $action, string $actorName)
{
$this->requisition = $requisition;
$this->action = $action;
$this->actorName = $actorName;
}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
$messages = [
'submitted' => "{$this->actorName} 提交了叫貨申請:{$this->requisition->doc_no}",
'approved' => "{$this->actorName} 核准了叫貨申請:{$this->requisition->doc_no}",
'rejected' => "{$this->actorName} 駁回了叫貨申請:{$this->requisition->doc_no}",
];
return [
'type' => 'store_requisition',
'action' => $this->action,
'store_requisition_id' => $this->requisition->id,
'doc_no' => $this->requisition->doc_no,
'actor_name' => $this->actorName,
'message' => $messages[$this->action] ?? "{$this->actorName} 操作了叫貨申請:{$this->requisition->doc_no}",
'link' => route('store-requisitions.show', $this->requisition->id),
];
}
}

View File

@@ -14,6 +14,7 @@ use App\Modules\Inventory\Controllers\AdjustDocController;
use App\Modules\Inventory\Controllers\InventoryReportController;
use App\Modules\Inventory\Controllers\StockQueryController;
use App\Modules\Inventory\Controllers\InventoryAnalysisController;
Route::middleware('auth')->group(function () {
@@ -32,6 +33,11 @@ Route::middleware('auth')->group(function () {
Route::get('/inventory/report/{product}', [InventoryReportController::class, 'show'])->name('inventory.report.show');
});
// 庫存分析 (Inventory Analysis)
Route::middleware('permission:inventory_report.view')->group(function () {
Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index');
});
// 類別管理 (用於商品對話框) - 需要商品權限
Route::middleware('permission:products.view')->group(function () {
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
@@ -141,6 +147,32 @@ Route::middleware('auth')->group(function () {
->middleware('permission:inventory_transfer.view')
->name('inventory.transfer.template');
// 門市叫貨申請 (Store Requisitions)
Route::middleware('permission:store_requisitions.view')->group(function () {
Route::get('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'index'])->name('store-requisitions.index');
Route::middleware('permission:store_requisitions.create')->group(function () {
Route::get('/store-requisitions/create', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'create'])->name('store-requisitions.create');
Route::post('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'store'])->name('store-requisitions.store');
});
Route::get('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'show'])->name('store-requisitions.show');
Route::middleware('permission:store_requisitions.edit')->group(function () {
Route::get('/store-requisitions/{id}/edit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'edit'])->name('store-requisitions.edit');
Route::put('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'update'])->name('store-requisitions.update');
});
Route::post('/store-requisitions/{id}/submit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'submit'])->name('store-requisitions.submit');
Route::middleware('permission:store_requisitions.approve')->group(function () {
Route::post('/store-requisitions/{id}/approve', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'approve'])->name('store-requisitions.approve');
Route::post('/store-requisitions/{id}/reject', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'reject'])->name('store-requisitions.reject');
});
Route::delete('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'destroy'])->middleware('permission:store_requisitions.delete')->name('store-requisitions.destroy');
});
// 進貨單 (Goods Receipts)
Route::middleware('permission:goods_receipts.view')->group(function () {
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');

View File

@@ -6,6 +6,7 @@ use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\InventoryTransferOrder;
use Illuminate\Support\Facades\DB;
class InventoryService implements InventoryServiceInterface
@@ -584,8 +585,35 @@ class InventoryService implements InventoryServiceInterface
'negativeCount' => $negativeCount,
'expiringCount' => $expiringCount,
'totalInventoryQuantity' => Inventory::sum('quantity'),
'totalInventoryValue' => Inventory::sum('total_value'),
'pendingTransferCount' => InventoryTransferOrder::whereIn('status', ['draft', 'dispatched'])->count(), // 新增:待處理調撥單
'abnormalItems' => $abnormalItems,
];
}
}
/**
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
*
* @param string $warehouseName
* @return Warehouse
*/
public function findOrCreateWarehouseByName(string $warehouseName)
{
// 1. 優先查找名稱完全匹配的倉庫(不限類型)
$warehouse = Warehouse::where('name', $warehouseName)
->first();
if ($warehouse) {
return $warehouse;
}
// 2. 若找不到對應倉庫,則統一進入「整合銷售倉」(類型retail)
return Warehouse::firstOrCreate(
['name' => '整合銷售倉'],
[
'code' => 'INT-RETAIL-001',
'type' => 'retail',
]
);
}
}

View File

@@ -2,13 +2,14 @@
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Unit;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProductService
class ProductService implements ProductServiceInterface
{
/**
* Upsert product from external POS source.
@@ -40,13 +41,20 @@ class ProductService
$product->barcode = $data['barcode'] ?? $product->barcode;
$product->price = $data['price'] ?? 0;
// Map newly added extended fields
if (isset($data['brand'])) $product->brand = $data['brand'];
if (isset($data['specification'])) $product->specification = $data['specification'];
if (isset($data['cost_price'])) $product->cost_price = $data['cost_price'];
if (isset($data['member_price'])) $product->member_price = $data['member_price'];
if (isset($data['wholesale_price'])) $product->wholesale_price = $data['wholesale_price'];
// Generate Code if missing (use code or external_id)
if (empty($product->code)) {
$product->code = $data['code'] ?? $product->external_pos_id;
}
// Handle Category (Default: 未分類)
if (empty($product->category_id)) {
// Handle Category — 每次同步都更新(若有傳入)
if (!empty($data['category']) || empty($product->category_id)) {
$categoryName = $data['category'] ?? '未分類';
$category = Category::firstOrCreate(
['name' => $categoryName],
@@ -55,8 +63,8 @@ class ProductService
$product->category_id = $category->id;
}
// Handle Base Unit (Default: 個)
if (empty($product->base_unit_id)) {
// Handle Base Unit — 每次同步都更新(若有傳入)
if (!empty($data['unit']) || empty($product->base_unit_id)) {
$unitName = $data['unit'] ?? '個';
$unit = Unit::firstOrCreate(['name' => $unitName]);
$product->base_unit_id = $unit->id;
@@ -69,4 +77,37 @@ class ProductService
return $product;
});
}
/**
* 透過外部 POS ID 查找產品。
*
* @param string $externalPosId
* @return Product|null
*/
public function findByExternalPosId(string $externalPosId)
{
return Product::where('external_pos_id', $externalPosId)->first();
}
/**
* 透過多個外部 POS ID 查找產品。
*
* @param array $externalPosIds
* @return \Illuminate\Database\Eloquent\Collection
*/
public function findByExternalPosIds(array $externalPosIds)
{
return Product::whereIn('external_pos_id', $externalPosIds)->get();
}
/**
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
*
* @param array $codes
* @return \Illuminate\Database\Eloquent\Collection
*/
public function findByCodes(array $codes)
{
return Product::whereIn('code', $codes)->get();
}
}

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\StoreRequisition;
use App\Modules\Inventory\Models\StoreRequisitionItem;
use App\Modules\Inventory\Models\InventoryTransferOrder;
use App\Modules\Inventory\Models\InventoryTransferItem;
use App\Modules\Inventory\Notifications\StoreRequisitionNotification;
use App\Modules\Core\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class StoreRequisitionService
{
protected TransferService $transferService;
public function __construct(TransferService $transferService)
{
$this->transferService = $transferService;
}
/**
* 建立叫貨單(含明細)
*/
public function create(array $data, array $items, int $userId): StoreRequisition
{
return DB::transaction(function () use ($data, $items, $userId) {
$requisition = StoreRequisition::create([
'store_warehouse_id' => $data['store_warehouse_id'],
'status' => 'draft',
'remark' => $data['remark'] ?? null,
'created_by' => $userId,
]);
foreach ($items as $item) {
$requisition->items()->create([
'product_id' => $item['product_id'],
'requested_qty' => $item['requested_qty'],
'remark' => $item['remark'] ?? null,
]);
}
return $requisition->load('items');
});
}
/**
* 更新叫貨單(僅限 draft / rejected 狀態)
*/
public function update(StoreRequisition $requisition, array $data, array $items): StoreRequisition
{
if (!in_array($requisition->status, ['draft', 'rejected'])) {
throw ValidationException::withMessages([
'status' => '僅能編輯草稿或被駁回的叫貨單',
]);
}
return DB::transaction(function () use ($requisition, $data, $items) {
$requisition->update([
'store_warehouse_id' => $data['store_warehouse_id'],
'remark' => $data['remark'] ?? null,
'reject_reason' => null, // 清除駁回原因
]);
// 重建明細
$requisition->items()->delete();
foreach ($items as $item) {
$requisition->items()->create([
'product_id' => $item['product_id'],
'requested_qty' => $item['requested_qty'],
'remark' => $item['remark'] ?? null,
]);
}
return $requisition->load('items');
});
}
/**
* 提交審核draft pending
*/
public function submit(StoreRequisition $requisition, int $userId): StoreRequisition
{
if ($requisition->status !== 'draft' && $requisition->status !== 'rejected') {
throw ValidationException::withMessages([
'status' => '僅能提交草稿或被駁回的叫貨單',
]);
}
if ($requisition->items()->count() === 0) {
throw ValidationException::withMessages([
'items' => '叫貨單必須至少有一項商品',
]);
}
$requisition->update([
'status' => 'pending',
'submitted_at' => now(),
'reject_reason' => null,
]);
// 通知有審核權限的使用者
$this->notifyApprovers($requisition, 'submitted', $userId);
return $requisition;
}
/**
* 核准叫貨單pending approved選擇供貨倉庫並自動產生調撥單
*/
public function approve(StoreRequisition $requisition, array $data, int $userId): StoreRequisition
{
if ($requisition->status !== 'pending') {
throw ValidationException::withMessages([
'status' => '僅能核准待審核的叫貨單',
]);
}
return DB::transaction(function () use ($requisition, $data, $userId) {
// 更新核准數量
if (isset($data['items'])) {
foreach ($data['items'] as $itemData) {
StoreRequisitionItem::where('id', $itemData['id'])
->where('store_requisition_id', $requisition->id)
->update(['approved_qty' => $itemData['approved_qty']]);
}
}
// 查詢供貨倉庫是否有預設在途倉
$supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($data['supply_warehouse_id']);
$defaultTransitId = $supplyWarehouse?->default_transit_warehouse_id;
// 產生調撥單(供貨倉庫 → 門市倉庫)
$transferOrder = $this->transferService->createOrder(
fromWarehouseId: $data['supply_warehouse_id'],
toWarehouseId: $requisition->store_warehouse_id,
remarks: "由叫貨單 {$requisition->doc_no} 自動產生",
userId: $userId,
transitWarehouseId: $defaultTransitId,
);
// 將核准的明細寫入調撥單
$requisition->load('items');
$transferItems = [];
foreach ($requisition->items as $item) {
$qty = $item->approved_qty ?? $item->requested_qty;
if ($qty > 0) {
$transferItems[] = [
'product_id' => $item->product_id,
'quantity' => $qty,
];
}
}
if (!empty($transferItems)) {
$this->transferService->updateItems($transferOrder, $transferItems);
}
// 更新叫貨單狀態
$requisition->update([
'status' => 'approved',
'supply_warehouse_id' => $data['supply_warehouse_id'],
'approved_by' => $userId,
'approved_at' => now(),
'transfer_order_id' => $transferOrder->id,
]);
// 通知申請人
$this->notifyCreator($requisition, 'approved', $userId);
return $requisition->load(['items', 'transferOrder']);
});
}
/**
* 駁回叫貨單pending rejected
*/
public function reject(StoreRequisition $requisition, string $reason, int $userId): StoreRequisition
{
if ($requisition->status !== 'pending') {
throw ValidationException::withMessages([
'status' => '僅能駁回待審核的叫貨單',
]);
}
$requisition->update([
'status' => 'rejected',
'reject_reason' => $reason,
'approved_by' => $userId,
'approved_at' => now(),
]);
// 通知申請人
$this->notifyCreator($requisition, 'rejected', $userId);
return $requisition;
}
/**
* 取消叫貨單
*/
public function cancel(StoreRequisition $requisition): StoreRequisition
{
if (!in_array($requisition->status, ['draft', 'pending'])) {
throw ValidationException::withMessages([
'status' => '僅能取消草稿或待審核的叫貨單',
]);
}
$requisition->update(['status' => 'cancelled']);
return $requisition;
}
/**
* 通知有審核權限的使用者
*/
protected function notifyApprovers(StoreRequisition $requisition, string $action, int $actorId): void
{
$actor = User::find($actorId);
$actorName = $actor?->name ?? 'System';
// 找出有 store_requisitions.approve 權限的使用者
$approvers = User::permission('store_requisitions.approve')->get();
foreach ($approvers as $approver) {
if ($approver->id !== $actorId) {
$approver->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
}
}
}
/**
* 通知叫貨單申請人
*/
protected function notifyCreator(StoreRequisition $requisition, string $action, int $actorId): void
{
$actor = User::find($actorId);
$actorName = $actor?->name ?? 'System';
$creator = User::find($requisition->created_by);
if ($creator && $creator->id !== $actorId) {
$creator->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
}
}
}

View File

@@ -14,27 +14,32 @@ class TransferService
/**
* 建立調撥單草稿
*/
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId): InventoryTransferOrder
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId, ?int $transitWarehouseId = null): InventoryTransferOrder
{
// 若未指定在途倉,嘗試使用來源倉庫的預設在途倉 (一次性設定)
if (is_null($transitWarehouseId)) {
$fromWarehouse = Warehouse::find($fromWarehouseId);
if ($fromWarehouse && $fromWarehouse->default_transit_warehouse_id) {
$transitWarehouseId = $fromWarehouse->default_transit_warehouse_id;
}
}
return InventoryTransferOrder::create([
'from_warehouse_id' => $fromWarehouseId,
'to_warehouse_id' => $toWarehouseId,
'transit_warehouse_id' => $transitWarehouseId,
'status' => 'draft',
'remarks' => $remarks,
'created_by' => $userId,
]);
}
/**
* 更新調撥單明細
*/
/**
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
*/
public function updateItems(InventoryTransferOrder $order, array $itemsData): bool
{
return DB::transaction(function () use ($order, $itemsData) {
// 1. 準備舊資料索引 (Key: product_id . '_' . batch_number)
$oldItemsMap = $order->items->mapWithKeys(function ($item) {
$key = $item->product_id . '_' . ($item->batch_number ?? '');
return [$key => $item];
@@ -46,13 +51,7 @@ class TransferService
'updated' => [],
];
// 2. 處理新資料 (Deleted and Re-inserted currently for simplicity, but logic simulates update)
// 為了保持 ID 當作外鍵的穩定性,最佳做法是 update 存在的create 新的delete 舊的。
// 但考量現有邏輯是 delete all -> create all我們維持原策略但優化 Diff 計算。
// 由於採用全刪重建,我們必須手動計算 Diff
$order->items()->delete();
$newItemsKeys = [];
foreach ($itemsData as $data) {
@@ -66,13 +65,10 @@ class TransferService
'position' => $data['position'] ?? null,
'notes' => $data['notes'] ?? null,
]);
// Eager load product for name
$item->load('product');
// 比對邏輯
if ($oldItemsMap->has($key)) {
$oldItem = $oldItemsMap->get($key);
// 檢查數值是否有變動
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
$oldItem->notes !== ($data['notes'] ?? null) ||
$oldItem->position !== ($data['position'] ?? null)) {
@@ -92,7 +88,6 @@ class TransferService
];
}
} else {
// 新增 (使用者需求:顯示為更新,從 0 -> X)
$diff['updated'][] = [
'product_name' => $item->product->name,
'old' => [
@@ -107,7 +102,6 @@ class TransferService
}
}
// 3. 處理被移除的項目
foreach ($oldItemsMap as $key => $oldItem) {
if (!in_array($key, $newItemsKeys)) {
$diff['removed'][] = [
@@ -120,7 +114,6 @@ class TransferService
}
}
// 4. 將 Diff 注入到 Model 的暫存屬性中
$hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
if ($hasChanged) {
$order->activityProperties['items_diff'] = $diff;
@@ -131,16 +124,24 @@ class TransferService
}
/**
* 過帳 (Post) - 執行調撥 (直接扣除來源,增加目的)
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
*
* 有在途倉:來源倉扣除 在途倉增加,狀態改為 dispatched
* 無在途倉:來源倉扣除 目的倉增加,狀態改為 completed維持原有邏輯
*/
public function post(InventoryTransferOrder $order, int $userId): void
public function dispatch(InventoryTransferOrder $order, int $userId): void
{
// [IMPORTANT] 強制重新載入品項,因為在 Controller 中可能剛執行過 updateItems導致記憶體中快取的 items 是舊的或空的
$order->load('items.product');
DB::transaction(function () use ($order, $userId) {
$fromWarehouse = $order->fromWarehouse;
$toWarehouse = $order->toWarehouse;
$hasTransit = !empty($order->transit_warehouse_id);
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
$outType = '調撥出庫';
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
foreach ($order->items as $item) {
if ($item->quantity <= 0) continue;
@@ -162,46 +163,41 @@ class TransferService
$oldSourceQty = $sourceInventory->quantity;
$newSourceQty = $oldSourceQty - $item->quantity;
// 儲存庫存快照
$item->update(['snapshot_quantity' => $oldSourceQty]);
$sourceInventory->quantity = $newSourceQty;
// 更新總值 (假設成本不變)
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
$sourceInventory->save();
// 記錄來源交易
$sourceInventory->transactions()->create([
'type' => '調撥出庫',
'type' => $outType,
'quantity' => -$item->quantity,
'unit_cost' => $sourceInventory->unit_cost,
'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty,
'reason' => "調撥單 {$order->doc_no}{$toWarehouse->name}",
'reason' => "調撥單 {$order->doc_no}{$targetWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
// 2. 處理目的倉 (增加)
// 2. 處理目的倉/在途倉 (增加)
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $order->to_warehouse_id,
'warehouse_id' => $targetWarehouseId,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
'location' => $item->position, // 同步貨道至庫存位置
'location' => $hasTransit ? null : ($item->position ?? null),
],
[
'quantity' => 0,
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
'unit_cost' => $sourceInventory->unit_cost,
'total_value' => 0,
// 繼承其他屬性
'expiry_date' => $sourceInventory->expiry_date,
'quality_status' => $sourceInventory->quality_status,
'origin_country' => $sourceInventory->origin_country,
]
);
// 若是新建立的且成本為0確保繼承成本
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
$targetInventory->unit_cost = $sourceInventory->unit_cost;
}
@@ -213,9 +209,8 @@ class TransferService
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
$targetInventory->save();
// 記錄目的交易
$targetInventory->transactions()->create([
'type' => '調撥入庫',
'type' => $inType,
'quantity' => $item->quantity,
'unit_cost' => $targetInventory->unit_cost,
'balance_before' => $oldTargetQty,
@@ -226,28 +221,126 @@ class TransferService
]);
}
// 準備品項快照供日誌使用
$itemsSnapshot = $order->items->map(function($item) {
return [
'product_name' => $item->product->name,
'old' => [
'quantity' => (float)$item->quantity,
'notes' => $item->notes,
if ($hasTransit) {
$order->status = 'dispatched';
$order->dispatched_at = now();
$order->dispatched_by = $userId;
} else {
$order->status = 'completed';
$order->posted_at = now();
$order->posted_by = $userId;
}
$order->save();
});
}
/**
* 收貨確認 (Receive) - 在途倉扣除 目的倉增加
* 僅適用於有在途倉且狀態為 dispatched 的調撥單
*/
public function receive(InventoryTransferOrder $order, int $userId): void
{
if ($order->status !== 'dispatched') {
throw new \Exception('僅能對已出貨的調撥單進行收貨確認');
}
if (empty($order->transit_warehouse_id)) {
throw new \Exception('此調撥單未設定在途倉庫');
}
$order->load('items.product');
DB::transaction(function () use ($order, $userId) {
$transitWarehouse = $order->transitWarehouse;
$toWarehouse = $order->toWarehouse;
foreach ($order->items as $item) {
if ($item->quantity <= 0) continue;
// 1. 在途倉扣除
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
$availableQty = $transitInventory->quantity ?? 0;
throw ValidationException::withMessages([
'items' => ["商品 {$item->product->name} 在途倉庫存不足。現有:{$availableQty},需要:{$item->quantity}"],
]);
}
$oldTransitQty = $transitInventory->quantity;
$newTransitQty = $oldTransitQty - $item->quantity;
$transitInventory->quantity = $newTransitQty;
$transitInventory->total_value = $transitInventory->quantity * $transitInventory->unit_cost;
$transitInventory->save();
$transitInventory->transactions()->create([
'type' => '在途出庫',
'quantity' => -$item->quantity,
'unit_cost' => $transitInventory->unit_cost,
'balance_before' => $oldTransitQty,
'balance_after' => $newTransitQty,
'reason' => "調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
// 2. 目的倉增加
$targetInventory = Inventory::firstOrCreate(
[
'warehouse_id' => $order->to_warehouse_id,
'product_id' => $item->product_id,
'batch_number' => $item->batch_number,
'location' => $item->position,
],
'new' => [
'quantity' => (float)$item->quantity,
'notes' => $item->notes,
[
'quantity' => 0,
'unit_cost' => $transitInventory->unit_cost,
'total_value' => 0,
'expiry_date' => $transitInventory->expiry_date,
'quality_status' => $transitInventory->quality_status,
'origin_country' => $transitInventory->origin_country,
]
];
})->toArray();
);
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
$targetInventory->unit_cost = $transitInventory->unit_cost;
}
$oldTargetQty = $targetInventory->quantity;
$newTargetQty = $oldTargetQty + $item->quantity;
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
$targetInventory->save();
$targetInventory->transactions()->create([
'type' => '調撥入庫',
'quantity' => $item->quantity,
'unit_cost' => $targetInventory->unit_cost,
'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty,
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
'actual_time' => now(),
'user_id' => $userId,
]);
}
$order->status = 'completed';
$order->posted_at = now();
$order->posted_by = $userId;
$order->save(); // 觸發自動日誌
$order->received_at = now();
$order->received_by = $userId;
$order->save();
});
}
/**
* 作廢 (Void) - 僅限草稿狀態
*/
public function void(InventoryTransferOrder $order, int $userId): void
{
if ($order->status !== 'draft') {

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\InventoryTransaction;
use App\Modules\Inventory\Models\Product;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class TurnoverService
{
/**
* Get inventory turnover analysis data
*/
public function getAnalysisData(array $filters, int $perPage = 20)
{
$warehouseId = $filters['warehouse_id'] ?? null;
$categoryId = $filters['category_id'] ?? null;
$search = $filters['search'] ?? null;
$statusFilter = $filters['status'] ?? null; // 'dead', 'slow', 'normal'
// Base query for products with their current inventory sum
$query = Product::query()
->select([
'products.id',
'products.code',
'products.name',
'categories.name as category_name',
'products.cost_price', // Assuming cost_price exists for value calculation
])
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
->leftJoin('inventories', 'products.id', '=', 'inventories.product_id')
->groupBy(['products.id', 'products.code', 'products.name', 'categories.name', 'products.cost_price']);
// Filter by Warehouse (Current Inventory)
if ($warehouseId) {
$query->where('inventories.warehouse_id', $warehouseId);
}
// Filter by Category
if ($categoryId) {
$query->where('products.category_id', $categoryId);
}
// Filter by Search
if ($search) {
$query->where(function($q) use ($search) {
$q->where('products.name', 'like', "%{$search}%")
->orWhere('products.code', 'like', "%{$search}%");
});
}
// Add Aggregated Columns
// 1. Current Inventory Quantity
$query->addSelect(DB::raw('COALESCE(SUM(inventories.quantity), 0) as current_stock'));
// 2. Sales in last 30 days (Outbound)
// We need a subquery or join for this to be efficient, or we use a separate query and map.
// Given potentially large data, subquery per row might be slow, but for pagination it's okay-ish.
// Better approach: Join with a subquery of aggregated transactions.
$thirtyDaysAgo = Carbon::now()->subDays(30);
// Subquery for 30-day sales
$salesSubquery = InventoryTransaction::query()
->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data
->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo)
->groupBy('inventories.product_id');
if ($warehouseId) {
$salesSubquery->where('inventories.warehouse_id', $warehouseId);
}
$query->leftJoinSub($salesSubquery, 'sales_30d', function ($join) {
$join->on('products.id', '=', 'sales_30d.product_id');
});
$query->addSelect(DB::raw('COALESCE(sales_30d.sales_qty_30d, 0) as sales_30d'));
// 3. Last Sale Date
// Use max actual_time from outbound transactions
$lastSaleSubquery = InventoryTransaction::query()
->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->where('inventory_transactions.type', '出庫')
->groupBy('inventories.product_id');
if ($warehouseId) {
$lastSaleSubquery->where('inventories.warehouse_id', $warehouseId);
}
$query->leftJoinSub($lastSaleSubquery, 'last_sales', function ($join) {
$join->on('products.id', '=', 'last_sales.product_id');
});
$query->addSelect('last_sales.last_sale_date');
// Apply Status Filter (Dead Stock etc) requires having clauses or wrapper query.
// Dead Stock: stock > 0 AND (last_sale_date < 90 days ago OR last_sale_date IS NULL)
// Slow Moving: turnover days > X?
// Let's modify query to handle ordering and filtering on calculated fields if possible.
// For simplicity in Laravel, we might fetch and transform, but pagination breaks.
// We'll use HAVING for status filtering if needed.
// Order by
$sortBy = $filters['sort_by'] ?? 'turnover_days'; // Default sort
$sortOrder = $filters['sort_order'] ?? 'desc';
// Turnover Days Calculation in SQL: (stock / (sales_30d / 30)) => (stock * 30) / sales_30d
// Handle division by zero: if sales_30d is 0, turnover is 'Inf' (or very high number like 9999)
$turnoverDaysSql = "CASE WHEN COALESCE(sales_30d.sales_qty_30d, 0) > 0
THEN (COALESCE(SUM(inventories.quantity), 0) * 30) / sales_30d.sales_qty_30d
ELSE 9999 END";
$query->addSelect(DB::raw("$turnoverDaysSql as turnover_days"));
// Only show items with stock > 0 ? User might want to see out of stock items too?
// Usually analysis focuses on what IS in stock. But Dead Stock needs items with stock.
// Stock-out analysis needs items with 0 stock.
// Let's filter stock > 0 by default for "Turnover Analysis".
// $query->havingRaw('current_stock > 0');
// Wait, better to let user filter?
// For dead stock, definitive IS stock > 0.
if ($statusFilter === 'dead') {
$ninetyDaysAgo = Carbon::now()->subDays(90);
$query->havingRaw("current_stock > 0 AND (last_sale_date < ? OR last_sale_date IS NULL)", [$ninetyDaysAgo]);
}
// Apply Sorting
if ($sortBy === 'turnover_days') {
$query->orderByRaw("$turnoverDaysSql $sortOrder");
} else if (in_array($sortBy, ['current_stock', 'sales_30d', 'last_sale_date'])) {
$query->orderBy($sortBy, $sortOrder);
} else {
$query->orderBy('products.code', 'asc');
}
return $query->paginate($perPage)->withQueryString()->through(function($item) {
// Post-processing for display
$item->turnover_days_display = $item->turnover_days >= 9999 ? '∞' : number_format($item->turnover_days, 1);
// Determine Status Label
$lastSale = $item->last_sale_date ? Carbon::parse($item->last_sale_date) : null;
$daysSinceSale = $lastSale ? $lastSale->diffInDays(Carbon::now()) : 9999;
if ($item->current_stock > 0 && $daysSinceSale > 90) {
$item->status = 'dead'; // 滯銷
$item->status_label = '滯銷';
} elseif ($item->current_stock > 0 && $item->turnover_days > 60) {
$item->status = 'slow'; // 週轉慢
$item->status_label = '週轉慢';
} elseif ($item->current_stock == 0) {
$item->status = 'out_of_stock';
$item->status_label = '缺貨';
} else {
$item->status = 'normal';
$item->status_label = '正常';
}
return $item;
});
}
public function getKPIs(array $filters)
{
// Calculates aggregate KPIs
$warehouseId = $filters['warehouse_id'] ?? null;
$categoryId = $filters['category_id'] ?? null;
// Helper to build base inv query
$buildInvQuery = function() use ($warehouseId, $categoryId) {
$q = DB::table('inventories')
->join('products', 'inventories.product_id', '=', 'products.id')
->where('inventories.quantity', '>', 0);
if ($warehouseId) $q->where('inventories.warehouse_id', $warehouseId);
if ($categoryId) $q->where('products.category_id', $categoryId);
return $q;
};
// 1. Total Inventory Value (Cost)
$totalValue = (clone $buildInvQuery())
->sum(DB::raw('inventories.quantity * COALESCE(products.cost_price, 0)'));
// 2. Dead Stock Value (No sale in 90 days)
// Need last sale date for each product-location or just product?
// Assuming dead stock is product-level logic for simplicity.
$ninetyDaysAgo = Carbon::now()->subDays(90);
// Get IDs of products sold in last 90 days
$soldProductIds = InventoryTransaction::query()
->where('type', '出庫')
->where('actual_time', '>=', $ninetyDaysAgo)
->distinct()
->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
// We need product_id.
->map(function($id) {
return DB::table('inventories')->where('id', $id)->value('product_id');
})
->filter()
->unique()
->toArray();
// Optimization: Use join in subquery
$soldProductIdsQuery = DB::table('inventory_transactions')
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->where('inventory_transactions.type', '出庫')
->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo)
->select('inventories.product_id')
->distinct();
$deadStockQuery = (clone $buildInvQuery())
->whereNotIn('products.id', $soldProductIdsQuery);
$deadStockValue = $deadStockQuery->sum(DB::raw('inventories.quantity * COALESCE(products.cost_price, 0)'));
$deadStockCount = $deadStockQuery->count('products.id'); // Count of inventory records (batches) or products?
// Let's count distinct products
$deadStockProductCount = $deadStockQuery->distinct('products.id')->count('products.id');
// 3. Average Turnover Days (Company wide)
// Formula: (Avg Inventory / COGS) * 365 ?
// Simplified: (Total Stock / Total Sales 30d) * 30
$totalStock = (clone $buildInvQuery())->sum('inventories.quantity');
$totalSales30d = DB::table('inventory_transactions')
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
->join('products', 'inventories.product_id', '=', 'products.id')
->where('inventory_transactions.type', '出庫')
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays(30))
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
->sum(DB::raw('ABS(inventory_transactions.quantity)'));
$avgTurnoverDays = $totalSales30d > 0 ? ($totalStock * 30) / $totalSales30d : 0;
return [
'total_stock_value' => $totalValue,
'dead_stock_value' => $deadStockValue,
'dead_stock_count' => $deadStockProductCount,
'avg_turnover_days' => round($avgTurnoverDays, 1),
];
}
}

View File

@@ -62,6 +62,11 @@ class PurchaseOrder extends Model
return $this->belongsTo(Vendor::class);
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Modules\Core\Models\User::class);
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Modules\Procurement\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Modules\Procurement\Models\PurchaseOrder;
class NewPurchaseOrder extends Notification
{
use Queueable;
protected $purchaseOrder;
protected $creatorName;
/**
* Create a new notification instance.
*/
public function __construct(PurchaseOrder $purchaseOrder, string $creatorName)
{
$this->purchaseOrder = $purchaseOrder;
$this->creatorName = $creatorName;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'type' => 'purchase_order',
'action' => 'created',
'purchase_order_id' => $this->purchaseOrder->id,
'code' => $this->purchaseOrder->code,
'creator_name' => $this->creatorName,
'message' => "{$this->creatorName} 建立了新的採購單:{$this->purchaseOrder->code}",
'link' => route('purchase-orders.index', ['search' => $this->purchaseOrder->code]), // 暫時導向列表並搜尋,若有詳情頁可改
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Procurement\Observers;
use App\Modules\Procurement\Models\PurchaseOrder;
use App\Modules\Procurement\Notifications\NewPurchaseOrder;
use App\Modules\Core\Models\User;
use Illuminate\Support\Facades\Notification;
class PurchaseOrderObserver
{
/**
* Handle the PurchaseOrder "created" event.
*/
public function created(PurchaseOrder $purchaseOrder): void
{
// 找出有檢視採購單權限的使用者
$users = User::permission('purchase_orders.view')->get();
// 排除建立者自己(避免自己收到自己的通知)
// $users = $users->reject(function ($user) use ($purchaseOrder) {
// return $user->id === $purchaseOrder->user_id;
// });
$creatorName = $purchaseOrder->user ? $purchaseOrder->user->name : '系統';
if ($users->isNotEmpty()) {
Notification::send($users, new NewPurchaseOrder($purchaseOrder, $creatorName));
}
}
}

View File

@@ -6,6 +6,10 @@ use Illuminate\Support\ServiceProvider;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Procurement\Services\ProcurementService;
use App\Modules\Procurement\Models\PurchaseOrder;
use App\Modules\Procurement\Observers\PurchaseOrderObserver;
class ProcurementServiceProvider extends ServiceProvider
{
public function register(): void
@@ -15,6 +19,6 @@ class ProcurementServiceProvider extends ServiceProvider
public function boot(): void
{
//
PurchaseOrder::observe(PurchaseOrderObserver::class);
}
}

View File

@@ -26,7 +26,7 @@ class ProcurementService implements ProcurementServiceInterface
return [
'vendorsCount' => \App\Modules\Procurement\Models\Vendor::count(),
'purchaseOrdersCount' => PurchaseOrder::count(),
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
'pendingOrdersCount' => PurchaseOrder::whereIn('status', ['approved', 'partial'])->count(), // 改為真正待進貨的狀態
];
}

View File

@@ -106,23 +106,16 @@ class ProductionOrderController extends Controller
{
$status = $request->input('status', 'draft');
$baseRules = [
$rules = [
'product_id' => 'required',
'output_batch_number' => 'required|string|max:50',
'status' => 'nullable|in:draft,completed',
'warehouse_id' => $status === 'completed' ? 'required' : 'nullable',
'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric',
'items' => 'nullable|array',
'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable',
'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric',
];
$completedRules = [
'warehouse_id' => 'required',
'output_quantity' => 'required|numeric|min:0.01',
'production_date' => 'required|date',
'items' => 'required|array|min:1',
'items.*.inventory_id' => 'required',
'items.*.quantity_used' => 'required|numeric|min:0.0001',
];
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
$validated = $request->validate($rules);
DB::transaction(function () use ($validated, $request, $status) {
@@ -132,12 +125,12 @@ class ProductionOrderController extends Controller
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? null,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_batch_number' => $request->output_batch_number, // 建立時改為選填
'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'production_date' => $request->production_date,
'expiry_date' => $request->expiry_date,
'user_id' => auth()->id(),
'status' => $status,
'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿
'remark' => $request->remark,
]);
@@ -155,43 +148,12 @@ class ProductionOrderController extends Controller
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
if ($status === 'completed') {
$this->inventoryService->decreaseInventoryQuantity(
$item['inventory_id'],
$item['quantity_used'],
"生產單 #{$productionOrder->code} 耗料",
ProductionOrder::class,
$productionOrder->id
);
}
}
}
// 3. 成品入庫
if ($status === 'completed') {
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $validated['warehouse_id'],
'product_id' => $validated['product_id'],
'quantity' => $validated['output_quantity'],
'batch_number' => $validated['output_batch_number'],
'box_number' => $request->output_box_count,
'arrival_date' => $validated['production_date'],
'expiry_date' => $request->expiry_date,
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('completed');
}
});
return redirect()->route('production-orders.index')
->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存');
->with('success', '生產單草稿已建立');
}
/**
@@ -204,7 +166,9 @@ class ProductionOrderController extends Controller
if ($productionOrder->product) {
$productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first();
}
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
$productionOrder->warehouse = $productionOrder->warehouse_id
? $this->inventoryService->getWarehouse($productionOrder->warehouse_id)
: null;
$productionOrder->user = $this->coreService->getUser($productionOrder->user_id);
// 手動水和明細資料
@@ -214,7 +178,7 @@ class ProductionOrderController extends Controller
// 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor
$inventories = $this->inventoryService->getInventoriesByIds(
$inventoryIds,
['product.baseUnit']
['product.baseUnit', 'warehouse']
)->keyBy('id');
// 手動載入 Purchase Orders
@@ -238,6 +202,7 @@ class ProductionOrderController extends Controller
return Inertia::render('Production/Show', [
'productionOrder' => $productionOrder,
'warehouses' => $this->inventoryService->getAllWarehouses(),
]);
}
@@ -308,7 +273,9 @@ class ProductionOrderController extends Controller
// 基本水和
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
$productionOrder->warehouse = $productionOrder->warehouse_id
? $this->inventoryService->getWarehouse($productionOrder->warehouse_id)
: null;
// 手動水和明細資料
$items = $productionOrder->items;
@@ -346,39 +313,27 @@ class ProductionOrderController extends Controller
$status = $request->input('status', 'draft');
// 基礎驗證規則
$baseRules = [
'product_id' => 'required|exists:products,id',
'output_batch_number' => 'required|string|max:50',
'status' => 'required|in:draft,completed',
$rules = [
'product_id' => 'required',
'remark' => 'nullable|string',
'warehouse_id' => 'nullable',
'output_quantity' => 'nullable|numeric',
'items' => 'nullable|array',
'items.*.inventory_id' => 'required',
'items.*.quantity_used' => 'required|numeric',
];
// 完工時的嚴格驗證規則
$completedRules = [
'warehouse_id' => 'required|exists:warehouses,id',
'output_quantity' => 'required|numeric|min:0.01',
'production_date' => 'required|date',
'expiry_date' => 'nullable|date',
'items' => 'required|array|min:1',
'items.*.inventory_id' => 'required|exists:inventories,id',
'items.*.quantity_used' => 'required|numeric|min:0.0001',
];
// 若狀態切換為 completed需合併驗證規則
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
$validated = $request->validate($rules);
DB::transaction(function () use ($validated, $request, $status, $productionOrder) {
DB::transaction(function () use ($validated, $request, $productionOrder) {
$productionOrder->update([
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_batch_number' => $request->output_batch_number ?? $productionOrder->output_batch_number,
'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $request->expiry_date,
'status' => $status,
'production_date' => $request->production_date ?? $productionOrder->production_date,
'expiry_date' => $request->expiry_date ?? $productionOrder->expiry_date,
'remark' => $request->remark,
]);
@@ -398,38 +353,8 @@ class ProductionOrderController extends Controller
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
if ($status === 'completed') {
$this->inventoryService->decreaseInventoryQuantity(
$item['inventory_id'],
$item['quantity_used'],
"生產單 #{$productionOrder->code} 耗料",
ProductionOrder::class,
$productionOrder->id
);
}
}
}
if ($status === 'completed') {
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $validated['warehouse_id'],
'product_id' => $validated['product_id'],
'quantity' => $validated['output_quantity'],
'batch_number' => $validated['output_batch_number'],
'box_number' => $request->output_box_count,
'arrival_date' => $validated['production_date'],
'expiry_date' => $request->expiry_date,
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('completed');
}
});
return redirect()->route('production-orders.index')
@@ -437,23 +362,102 @@ class ProductionOrderController extends Controller
}
/**
* 刪除生產單
* 更新生產工單狀態
*/
public function updateStatus(Request $request, ProductionOrder $productionOrder)
{
$newStatus = $request->input('status');
if (!$productionOrder->canTransitionTo($newStatus)) {
return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403);
}
DB::transaction(function () use ($newStatus, $productionOrder, $request) {
$oldStatus = $productionOrder->status;
// 1. 執行特定狀態的業務邏輯
if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) {
// 開始製作 -> 扣除原料庫存
$items = $productionOrder->items;
foreach ($items as $item) {
$this->inventoryService->decreaseInventoryQuantity(
$item->inventory_id,
$item->quantity_used,
"生產單 #{$productionOrder->code} 開始製作 (扣料)",
ProductionOrder::class,
$productionOrder->id
);
}
}
elseif ($oldStatus === ProductionOrder::STATUS_IN_PROGRESS && $newStatus === ProductionOrder::STATUS_COMPLETED) {
// 完成製作 -> 成品入庫
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
if (!$warehouseId) {
throw new \Exception('必須選擇入庫倉庫');
}
if (!$batchNumber) {
throw new \Exception('必須提供成品批號');
}
// 更新單據資訊:批號、效期與自動記錄生產日期
$productionOrder->output_batch_number = $batchNumber;
$productionOrder->expiry_date = $expiryDate;
$productionOrder->production_date = now()->toDateString();
$productionOrder->warehouse_id = $warehouseId;
$this->inventoryService->createInventoryRecord([
'warehouse_id' => $warehouseId,
'product_id' => $productionOrder->product_id,
'quantity' => $productionOrder->output_quantity,
'batch_number' => $batchNumber,
'box_number' => $productionOrder->output_box_count,
'arrival_date' => now()->toDateString(),
'expiry_date' => $expiryDate,
'reason' => "生產單 #{$productionOrder->code} 製作完成 (入庫)",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
}
// 2. 更新狀態
$productionOrder->status = $newStatus;
$productionOrder->save();
// 3. 紀錄 Activity Log
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->withProperties([
'old_status' => $oldStatus,
'new_status' => $newStatus
])
->log("status_updated_to_{$newStatus}");
});
return back()->with('success', '狀態已更新');
}
/**
* 從儲存體中移除指定資源。
*/
public function destroy(ProductionOrder $productionOrder)
{
if ($productionOrder->status === 'completed') {
return redirect()->back()->with('error', '已完工的生產單無法刪除');
// 僅允許刪除草稿或已作廢的單據
if (!in_array($productionOrder->status, [ProductionOrder::STATUS_DRAFT, ProductionOrder::STATUS_CANCELLED])) {
return redirect()->back()->with('error', '僅有草稿或已作廢的生產單可以刪除');
}
DB::transaction(function () use ($productionOrder) {
// 紀錄刪除動作 (需在刪除前或使用軟刪除)
$productionOrder->items()->delete();
$productionOrder->delete();
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('deleted');
$productionOrder->items()->delete();
$productionOrder->delete();
});
return redirect()->route('production-orders.index')->with('success', '生產單已刪除');

View File

@@ -11,6 +11,14 @@ class ProductionOrder extends Model
{
use HasFactory, LogsActivity;
// 狀態常數
const STATUS_DRAFT = 'draft';
const STATUS_PENDING = 'pending';
const STATUS_APPROVED = 'approved';
const STATUS_IN_PROGRESS = 'in_progress';
const STATUS_COMPLETED = 'completed';
const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'code',
'product_id',
@@ -25,6 +33,51 @@ class ProductionOrder extends Model
'remark',
];
/**
* 檢查是否可以轉移至新狀態,並驗證權限。
*/
public function canTransitionTo(string $newStatus, $user = null): bool
{
$user = $user ?? auth()->user();
if (!$user) return false;
if ($user->hasRole('super-admin')) return true;
$currentStatus = $this->status;
// 定義合法的狀態轉移路徑與所需權限
$transitions = [
self::STATUS_DRAFT => [
self::STATUS_PENDING => 'production_orders.view', // 基本檢視者即可送審
self::STATUS_CANCELLED => 'production_orders.cancel',
],
self::STATUS_PENDING => [
self::STATUS_APPROVED => 'production_orders.approve',
self::STATUS_DRAFT => 'production_orders.approve', // 退回草稿
self::STATUS_CANCELLED => 'production_orders.cancel',
],
self::STATUS_APPROVED => [
self::STATUS_IN_PROGRESS => 'production_orders.edit', // 啟動製作需要編輯權限
self::STATUS_CANCELLED => 'production_orders.cancel',
],
self::STATUS_IN_PROGRESS => [
self::STATUS_COMPLETED => 'production_orders.edit', // 完成製作需要編輯權限
self::STATUS_CANCELLED => 'production_orders.cancel',
],
];
if (!isset($transitions[$currentStatus])) {
return false;
}
if (!array_key_exists($newStatus, $transitions[$currentStatus])) {
return false;
}
$requiredPermission = $transitions[$currentStatus][$newStatus];
return $requiredPermission ? $user->can($requiredPermission) : true;
}
protected $casts = [
'production_date' => 'date',
'expiry_date' => 'date',
@@ -59,13 +112,17 @@ class ProductionOrder extends Model
public static function generateCode()
{
$prefix = 'PO' . now()->format('Ymd');
$lastOrder = self::where('code', 'like', $prefix . '%')->latest()->first();
$prefix = 'PRO-' . now()->format('Ymd') . '-';
$lastOrder = self::where('code', 'like', $prefix . '%')
->lockForUpdate()
->orderBy('code', 'desc')
->first();
if ($lastOrder) {
$lastSequence = intval(substr($lastOrder->code, -3));
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
$lastSequence = intval(substr($lastOrder->code, -2));
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
} else {
$sequence = '001';
$sequence = '01';
}
return $prefix . $sequence;
}
@@ -74,4 +131,9 @@ class ProductionOrder extends Model
{
return $this->hasMany(ProductionOrderItem::class);
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Modules\Core\Models\User::class);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Modules\Production\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Modules\Production\Models\ProductionOrder;
class NewProductionOrder extends Notification
{
use Queueable;
protected $productionOrder;
protected $creatorName;
/**
* Create a new notification instance.
*/
public function __construct(ProductionOrder $productionOrder, string $creatorName)
{
$this->productionOrder = $productionOrder;
$this->creatorName = $creatorName;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'type' => 'production_order',
'action' => 'created',
'production_order_id' => $this->productionOrder->id,
'code' => $this->productionOrder->code,
'creator_name' => $this->creatorName,
'message' => "{$this->creatorName} 建立了新的生產工單:{$this->productionOrder->code}",
'link' => route('production-orders.index', ['search' => $this->productionOrder->code]),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Modules\Production\Observers;
use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Notifications\NewProductionOrder;
use App\Modules\Core\Models\User;
use Illuminate\Support\Facades\Notification;
class ProductionOrderObserver
{
/**
* Handle the ProductionOrder "created" event.
*/
public function created(ProductionOrder $productionOrder): void
{
// 找出有檢視生產工單權限的使用者
$users = User::permission('production_orders.view')->get();
$creatorName = $productionOrder->user ? $productionOrder->user->name : '系統';
if ($users->isNotEmpty()) {
Notification::send($users, new NewProductionOrder($productionOrder, $creatorName));
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Production;
use Illuminate\Support\ServiceProvider;
use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Observers\ProductionOrderObserver;
class ProductionServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
ProductionOrder::observe(ProductionOrderObserver::class);
}
}

View File

@@ -23,6 +23,12 @@ Route::middleware('auth')->group(function () {
Route::get('/production-orders/{productionOrder}/edit', [ProductionOrderController::class, 'edit'])->name('production-orders.edit');
Route::put('/production-orders/{productionOrder}', [ProductionOrderController::class, 'update'])->name('production-orders.update');
});
Route::patch('/production-orders/{productionOrder}/update-status', [ProductionOrderController::class, 'updateStatus'])->name('production-orders.update-status');
Route::middleware('permission:production_orders.delete')->group(function () {
Route::delete('/production-orders/{productionOrder}', [ProductionOrderController::class, 'destroy'])->name('production-orders.destroy');
});
});
// 生產管理 API

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Modules\System\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Inertia\Inertia;
use Illuminate\Support\Str;
class ManualController extends Controller
{
/**
* Display the user manual page.
*/
public function index(Request $request, $slug = null)
{
$tocPath = resource_path('markdown/manual/toc.json');
if (!File::exists($tocPath)) {
// Create a default TOC if it doesn't exist
$this->createDefaultManualStructure();
}
$toc = json_decode(File::get($tocPath), true);
// If no slug provided, pick the first one from TOC
if (!$slug) {
foreach ($toc as $section) {
if (!empty($section['pages'])) {
$slug = $section['pages'][0]['slug'];
break;
}
}
}
$content = '';
$filePath = resource_path("markdown/manual/{$slug}.md");
if (File::exists($filePath)) {
$content = File::get($filePath);
} else {
$content = "# 檔案未找到\n\n抱歉,您所要求的「{$slug}」頁面目前不存在。";
}
return Inertia::render('System/Manual/Index', [
'toc' => $toc,
'currentSlug' => $slug,
'content' => $content,
]);
}
/**
* Helper to initialize the manual structure if empty
*/
protected function createDefaultManualStructure()
{
$dir = resource_path('markdown/manual');
if (!File::isDirectory($dir)) {
File::makeDirectory($dir, 0755, true);
}
$toc = [
[
'title' => '新手上路',
'pages' => [
['title' => '登入與帳號設定', 'slug' => 'getting-started']
]
],
[
'title' => '核心流程',
'pages' => [
['title' => '採購流程說明', 'slug' => 'purchasing-workflow'],
['title' => '庫存管理規範', 'slug' => 'inventory-management']
]
],
[
'title' => '其他區域',
'pages' => [
['title' => '常見問題 (FAQ)', 'slug' => 'faq']
]
]
];
File::put($dir . '/toc.json', json_encode($toc, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// Create dummy files
$files = [
'getting-started' => "# 登入與帳號設定\n\n歡迎使用 Star ERP在本章節中我們將介紹...",
'purchasing-workflow' => "# 採購流程說明\n\n完整的採購循環包含以下步驟:\n\n1. 建立請購單\n2. 核准並轉成採購單\n3. 供應商發貨",
'inventory-management' => "# 庫存管理規範\n\n本系統支援多倉庫管理與即時庫存追蹤...",
'faq' => "# 常見問題 (FAQ)\n\n### 1. 忘記密碼怎麼辦?\n請聯繫系統管理員進行密碼重設。"
];
foreach ($files as $name => $body) {
File::put($dir . "/{$name}.md", $body);
}
}
}

View File

@@ -0,0 +1,9 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\System\Controllers\ManualController;
Route::middleware(['auth'])->group(function () {
// 系統管理 - 操作手冊
Route::get('/system/manual/{slug?}', [ManualController::class, 'index'])->name('system.manual.index');
});

View File

@@ -24,6 +24,9 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(prepend: [
\App\Http\Middleware\UniversalTenancy::class,
]);
$middleware->api(prepend: [
\App\Http\Middleware\UniversalTenancy::class,
]);
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
]);

7
compose.demo.yaml Normal file
View File

@@ -0,0 +1,7 @@
services:
proxy:
ports:
- '80:80'
- '8080:8080'
volumes:
- './nginx/demo-proxy.conf:/etc/nginx/conf.d/default.conf:ro'

7
compose.prod.yaml Normal file
View File

@@ -0,0 +1,7 @@
services:
proxy:
ports:
- '80:80'
- '8080:8080'
volumes:
- './nginx/prod-proxy.conf:/etc/nginx/conf.d/default.conf:ro'

View File

@@ -6,8 +6,8 @@ services:
args:
WWWGROUP: '${WWWGROUP}'
image: 'sail-8.5/app'
container_name: star-erp-laravel
hostname: star-erp-laravel
container_name: laravel
hostname: laravel
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
@@ -29,8 +29,8 @@ services:
# - mailpit
mysql:
image: 'mysql/mysql-server:8.0'
container_name: star-erp-mysql
hostname: star-erp-mysql
container_name: mysql
hostname: mysql
ports:
- '${FORWARD_DB_PORT:-3306}:3306'
environment:
@@ -56,8 +56,8 @@ services:
timeout: 5s
redis:
image: 'redis:alpine'
container_name: star-erp-redis
hostname: star-erp-redis
container_name: redis
hostname: redis
# ports:
# - '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
@@ -74,11 +74,6 @@ services:
proxy:
image: 'nginx:alpine'
container_name: star-erp-proxy
ports:
- '8080:8080'
- '8081:8081'
volumes:
- './nginx/demo-proxy.conf:/etc/nginx/conf.d/default.conf:ro'
networks:
- sail
depends_on:

View File

@@ -15,6 +15,8 @@ return [
'name' => env('APP_NAME', 'Laravel'),
'version' => env('APP_VERSION', '1.0.0'),
/*
|--------------------------------------------------------------------------
| Application Environment

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$guard = 'web';
$permissions = [
'production_orders.approve' => '核准生產工單',
'production_orders.cancel' => '作廢生產工單',
];
foreach ($permissions as $name => $description) {
Permission::firstOrCreate(
['name' => $name, 'guard_name' => $guard],
['name' => $name, 'guard_name' => $guard]
);
}
// 授予 super-admin 所有新權限
$superAdmin = Role::where('name', 'super-admin')->first();
if ($superAdmin) {
$superAdmin->givePermissionTo(array_keys($permissions));
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$permissions = [
'production_orders.approve',
'production_orders.cancel',
];
foreach ($permissions as $name) {
Permission::where('name', $name)->delete();
}
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->enum('status', ['draft', 'pending', 'approved', 'in_progress', 'completed', 'cancelled'])
->default('draft')
->comment('狀態:草稿/待審/核准/製作中/完成/取消')
->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->enum('status', ['draft', 'completed', 'cancelled'])
->default('completed')
->comment('狀態:草稿/完成/取消')
->change();
});
}
};

View File

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

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->date('production_date')->nullable()->change();
$table->date('expiry_date')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->date('production_date')->nullable(false)->change();
$table->date('expiry_date')->nullable(false)->change();
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 門市叫貨申請主表
*/
public function up(): void
{
Schema::create('store_requisitions', function (Blueprint $table) {
$table->id();
$table->string('doc_no')->unique()->comment('單號 SR-YYYYMMDD-XX');
$table->unsignedBigInteger('store_warehouse_id')->comment('申請倉庫(任意類型)');
$table->unsignedBigInteger('supply_warehouse_id')->nullable()->comment('供貨倉庫(審核時填入)');
$table->enum('status', ['draft', 'pending', 'approved', 'rejected', 'completed', 'cancelled'])
->default('draft');
$table->text('remark')->nullable()->comment('申請備註');
$table->text('reject_reason')->nullable()->comment('駁回原因');
$table->unsignedBigInteger('created_by')->comment('申請人');
$table->unsignedBigInteger('approved_by')->nullable()->comment('審核人');
$table->timestamp('submitted_at')->nullable()->comment('提交時間');
$table->timestamp('approved_at')->nullable()->comment('審核時間');
$table->unsignedBigInteger('transfer_order_id')->nullable()->comment('關聯調撥單');
$table->timestamps();
$table->index('status');
$table->index('store_warehouse_id');
$table->index('created_by');
});
}
public function down(): void
{
Schema::dropIfExists('store_requisitions');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 門市叫貨申請明細表
*/
public function up(): void
{
Schema::create('store_requisition_items', function (Blueprint $table) {
$table->id();
$table->foreignId('store_requisition_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('product_id');
$table->decimal('requested_qty', 12, 2)->comment('需求數量');
$table->decimal('approved_qty', 12, 2)->nullable()->comment('核准數量(審核時填入)');
$table->text('remark')->nullable();
$table->timestamps();
$table->index('product_id');
});
}
public function down(): void
{
Schema::dropIfExists('store_requisition_items');
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 在調撥單中新增在途倉庫相關欄位
*/
public function up(): void
{
Schema::table('inventory_transfer_orders', function (Blueprint $table) {
// 在途倉庫(可選)
$table->foreignId('transit_warehouse_id')
->nullable()
->after('to_warehouse_id')
->constrained('warehouses')
->nullOnDelete();
// 出貨資訊
$table->timestamp('dispatched_at')->nullable()->after('posted_at');
$table->foreignId('dispatched_by')->nullable()->after('dispatched_at')->constrained('users')->nullOnDelete();
// 收貨確認資訊
$table->timestamp('received_at')->nullable()->after('dispatched_by');
$table->foreignId('received_by')->nullable()->after('received_at')->constrained('users')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_transfer_orders', function (Blueprint $table) {
$table->dropForeign(['transit_warehouse_id']);
$table->dropForeign(['dispatched_by']);
$table->dropForeign(['received_by']);
$table->dropColumn([
'transit_warehouse_id',
'dispatched_at',
'dispatched_by',
'received_at',
'received_by',
]);
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 在倉庫表中新增預設在途倉庫欄位
*/
public function up(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->foreignId('default_transit_warehouse_id')
->nullable()
->after('driver_name')
->constrained('warehouses')
->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->dropForeign(['default_transit_warehouse_id']);
$table->dropColumn('default_transit_warehouse_id');
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (!Schema::hasColumn('warehouses', 'default_transit_warehouse_id')) {
Schema::table('warehouses', function (Blueprint $table) {
$table->foreignId('default_transit_warehouse_id')
->nullable()
->after('driver_name')
->comment('預設使用的在途倉(物流車)')
->constrained('warehouses')
->nullOnDelete();
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->dropForeign(['default_transit_warehouse_id']);
$table->dropColumn('default_transit_warehouse_id');
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* sales_orders 新增來源標記欄位,支援多來源 API 寫入
*/
public function up(): void
{
Schema::table('sales_orders', function (Blueprint $table) {
$table->string('source')->default('pos')->after('raw_payload');
$table->string('source_label')->nullable()->after('source');
$table->index('source');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sales_orders', function (Blueprint $table) {
$table->dropIndex(['source']);
$table->dropColumn(['source', 'source_label']);
});
}
};

View File

@@ -55,6 +55,8 @@ class PermissionSeeder extends Seeder
'inventory_transfer.create' => '建立',
'inventory_transfer.edit' => '編輯',
'inventory_transfer.delete' => '刪除',
'inventory_transfer.dispatch' => '確認出貨',
'inventory_transfer.receive' => '確認收貨',
// 庫存報表
'inventory_report.view' => '檢視',
@@ -77,6 +79,8 @@ class PermissionSeeder extends Seeder
'production_orders.create' => '建立',
'production_orders.edit' => '編輯',
'production_orders.delete' => '刪除',
'production_orders.approve' => '核准',
'production_orders.cancel' => '作廢',
// 配方管理
'recipes.view' => '檢視',
@@ -127,6 +131,17 @@ class PermissionSeeder extends Seeder
'sales_imports.create' => '建立',
'sales_imports.confirm' => '確認',
'sales_imports.delete' => '刪除',
// 門市叫貨申請
'store_requisitions.view' => '檢視',
'store_requisitions.create' => '建立',
'store_requisitions.edit' => '編輯',
'store_requisitions.delete' => '刪除',
'store_requisitions.approve' => '核準',
'store_requisitions.cancel' => '取消',
// 銷售訂單管理 (API)
'sales_orders.view' => '檢視',
];
foreach ($permissions as $name => $displayName) {
@@ -156,7 +171,7 @@ class PermissionSeeder extends Seeder
'inventory.view', 'inventory.view_cost', 'inventory.delete',
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete',
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
'inventory_report.view', 'inventory_report.export',
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete',
@@ -170,6 +185,8 @@ class PermissionSeeder extends Seeder
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
'accounting.view', 'accounting.export',
'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete',
'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit',
'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel',
]);
// warehouse-manager 管理庫存與倉庫
@@ -178,12 +195,14 @@ class PermissionSeeder extends Seeder
'inventory.view', 'inventory.delete',
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete',
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
'inventory_report.view', 'inventory_report.export',
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'production_orders.view', 'production_orders.create', 'production_orders.edit',
'warehouses.view', 'warehouses.create', 'warehouses.edit',
'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit',
'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel',
]);
// purchaser 管理採購與供應商
@@ -206,6 +225,7 @@ class PermissionSeeder extends Seeder
'utility_fees.view',
'inventory_report.view',
'accounting.view',
'sales_orders.view',
]);
// 將現有使用者設為 super-admin如果存在的話

View File

@@ -11,7 +11,7 @@ WORKDIR /var/www/html
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=UTC
ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80"
ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=8080"
ENV SUPERVISOR_PHP_USER="sail"
ENV PLAYWRIGHT_BROWSERS_PATH=0
@@ -28,32 +28,32 @@ RUN apt-get update && apt-get upgrade -y \
&& echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
&& apt-get update \
&& apt-get install -y \
libgd3 \
php8.5-cli \
php8.5-dev \
php8.5-pgsql \
php8.5-sqlite3 \
php8.5-gd \
php8.5-curl \
php8.5-mongodb \
php8.5-imap \
php8.5-mysql \
php8.5-mbstring \
php8.5-xml \
php8.5-zip \
php8.5-bcmath \
php8.5-soap \
php8.5-intl \
php8.5-readline \
php8.5-ldap \
php8.5-msgpack \
php8.5-igbinary \
php8.5-redis \
#php8.5-swoole \
php8.5-memcached \
php8.5-pcov \
php8.5-imagick \
php8.5-xdebug \
libgd3 \
php8.5-cli \
php8.5-dev \
php8.5-pgsql \
php8.5-sqlite3 \
php8.5-gd \
php8.5-curl \
php8.5-mongodb \
php8.5-imap \
php8.5-mysql \
php8.5-mbstring \
php8.5-xml \
php8.5-zip \
php8.5-bcmath \
php8.5-soap \
php8.5-intl \
php8.5-readline \
php8.5-ldap \
php8.5-msgpack \
php8.5-igbinary \
php8.5-redis \
#php8.5-swoole \
php8.5-memcached \
php8.5-pcov \
php8.5-imagick \
php8.5-xdebug \
&& curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
@@ -75,8 +75,6 @@ RUN apt-get update && apt-get upgrade -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.5
RUN userdel -r ubuntu
RUN groupadd --force -g $WWWGROUP sail
RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail
@@ -87,6 +85,6 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY php.ini /etc/php/8.5/cli/conf.d/99-sail.ini
RUN chmod +x /usr/local/bin/start-container
EXPOSE 80/tcp
EXPOSE 8080/tcp
ENTRYPOINT ["start-container"]

View File

@@ -12,3 +12,4 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View File

@@ -1,29 +1,22 @@
# 總後台 (landlord) - 端口 8080
server {
listen 8080;
server_name 192.168.0.103;
# Demo 環境 (Demo) - 端口 80
# 外部 SSL 終止後(如 Cloudflare/NPM轉發至此端口
location / {
proxy_pass http://star-erp-laravel:80;
proxy_set_header Host star-erp.demo;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
}
# 定義 map 以正確處理 X-Forwarded-Proto
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
default $http_x_forwarded_proto;
'' $scheme;
}
# koori 租戶 - 端口 8081
server {
listen 8081;
server_name 192.168.0.103;
listen 80;
server_name demo-erp.taiwan-star.com.tw;
location / {
proxy_pass http://star-erp-laravel:80;
proxy_pass http://star-erp-laravel:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host;
}
}

View File

@@ -12,7 +12,7 @@ server {
server_name erp.koori.tw erp.mamaiclub.com;
location / {
proxy_pass http://star-erp-laravel:80;
proxy_pass http://star-erp-laravel:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

1925
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/typography": "^0.5.19",
"@types/lodash": "^4.17.21",
"@vitejs/plugin-react": "^5.1.2",
"class-variance-authority": "^0.7.1",
@@ -49,6 +50,9 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
}

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--font-sans: 'Noto Sans TC', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from "react";
import axios from "axios";
import { Link, router, usePage } from "@inertiajs/react";
import { Bell, CheckCheck } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { Button } from "@/Components/ui/button";
import { ScrollArea } from "@/Components/ui/scroll-area";
import { formatDate } from "@/lib/date";
import { cn } from "@/lib/utils";
interface NotificationData {
message: string;
link?: string;
action?: string;
[key: string]: any;
}
interface Notification {
id: string;
type: string;
data: NotificationData;
read_at: string | null;
created_at: string;
}
interface NotificationsProp {
latest: Notification[];
unread_count: number;
}
export default function NotificationDropdown() {
const { notifications } = usePage<{ notifications?: NotificationsProp }>().props;
if (!notifications) return null;
// 使用整體的 notifications 物件作為初始狀態,方便後續更新
const [data, setData] = useState<NotificationsProp>(notifications);
const { latest, unread_count } = data;
const [isOpen, setIsOpen] = useState(false);
// 輪詢機制
useEffect(() => {
const intervalId = setInterval(() => {
axios.get(route('notifications.check'))
.then(response => {
setData(response.data);
})
.catch(error => {
console.error("Failed to fetch notifications:", error);
});
}, 30000); // 30 秒
return () => clearInterval(intervalId);
}, []);
// 當 Inertia props 更新時(例如頁面跳轉),同步更新本地狀態
useEffect(() => {
if (notifications) {
setData(notifications);
}
}, [notifications]);
const handleMarkAllAsRead = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// 樂觀更新 (Optimistic Update)
setData(prev => ({
...prev,
unread_count: 0,
latest: prev.latest.map(n => ({ ...n, read_at: new Date().toISOString() }))
}));
router.post(route('notifications.read-all'), {}, {
preserveScroll: true,
preserveState: true,
onSuccess: () => {
// 成功後重新整理一次確保數據正確 (可選)
}
});
};
const handleNotificationClick = (notification: Notification) => {
if (!notification.read_at) {
// 樂觀更新
setData(prev => ({
...prev,
unread_count: Math.max(0, prev.unread_count - 1),
latest: prev.latest.map(n =>
n.id === notification.id
? { ...n, read_at: new Date().toISOString() }
: n
)
}));
router.post(route('notifications.read', { id: notification.id }));
}
if (notification.data.link) {
router.visit(notification.data.link);
}
setIsOpen(false);
};
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen} modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative text-slate-500 hover:text-slate-700 hover:bg-slate-100">
<Bell className="h-5 w-5" />
{unread_count > 0 && (
<span className="absolute top-1.5 right-1.5 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500"></span>
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0 z-[100]" sideOffset={8}>
<div className="flex items-center justify-between p-4 pb-2">
<h4 className="font-semibold text-sm"></h4>
{unread_count > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs text-primary-main hover:text-primary-dark"
onClick={handleMarkAllAsRead}
>
<CheckCheck className="mr-1 h-3 w-3" />
</Button>
)}
</div>
<DropdownMenuSeparator />
<ScrollArea className="h-[300px]">
{latest.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-slate-500">
<Bell className="h-8 w-8 mb-2 opacity-20" />
<p className="text-sm"></p>
</div>
) : (
<div className="flex flex-col">
{latest.map((notification) => (
<button
key={notification.id}
className={cn(
"w-full text-left px-4 py-3 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0",
!notification.read_at && "bg-blue-50/50"
)}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex items-start gap-3">
<div className={cn(
"mt-1 h-2 w-2 rounded-full flex-shrink-0",
!notification.read_at ? "bg-primary-main" : "bg-slate-200"
)} />
<div className="flex-1 space-y-1">
<p className={cn(
"text-sm leading-tight",
!notification.read_at ? "font-medium text-slate-900" : "text-slate-600"
)}>
{notification.data.message}
</p>
<p className="text-xs text-slate-400">
{formatDate(notification.created_at)}
</p>
</div>
</div>
</button>
))}
</div>
)}
</ScrollArea>
<DropdownMenuSeparator />
<div className="p-2 text-center">
<Link
href="#"
className="text-xs text-slate-500 hover:text-primary-main transition-colors"
>
</Link>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,9 +1,9 @@
import { Badge } from "@/Components/ui/badge";
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" }> = {
processing: { label: "處理中", variant: "warning" },
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: StatusVariant }> = {
processing: { label: "處理中", variant: "info" },
completed: { label: "已完成", variant: "success" },
cancelled: { label: "已取消", variant: "destructive" },
};
@@ -19,28 +19,9 @@ export default function GoodsReceiptStatusBadge({
}: GoodsReceiptStatusBadgeProps) {
const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
// Apply custom styling based on variant mapping if not using standard badge variants
let badgeClass = "";
switch (config.variant) {
case "success":
badgeClass = "bg-green-100 text-green-800 hover:bg-green-200 border-green-200";
break;
case "warning":
badgeClass = "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-200";
break;
case "destructive":
badgeClass = "bg-red-100 text-red-800 hover:bg-red-200 border-red-200";
break;
default:
badgeClass = "bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-200";
}
return (
<Badge
variant="outline"
className={`${className} font-medium px-2.5 py-0.5 rounded-full border ${badgeClass}`}
>
<StatusBadge variant={config.variant} className={className}>
{config.label}
</Badge>
</StatusBadge>
);
}

View File

@@ -4,7 +4,7 @@
*/
import { useState } from "react";
import { AlertTriangle, Edit, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
import { Edit, ChevronDown, ChevronRight, Package } from "lucide-react";
import {
Table,
TableBody,
@@ -14,14 +14,14 @@ import {
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/Components/ui/collapsible";
import { WarehouseInventory, SafetyStockSetting } from "@/types/warehouse";
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
import { getSafetyStockStatus } from "@/utils/inventory";
import { formatDate } from "@/utils/format";
export type InventoryItemWithId = WarehouseInventory & { inventoryId: string };
@@ -74,31 +74,28 @@ export default function InventoryTable({
// 獲取狀態徽章
const getStatusBadge = (status: string) => {
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "接近":
return (
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於":
return (
<Badge className="bg-red-100 text-red-700 border-red-300">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
default:
return null;
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '接近') {
return (
<StatusBadge variant="warning">
</StatusBadge>
);
}
if (status === '低於') {
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null;
};
return (
@@ -108,12 +105,12 @@ export default function InventoryTable({
(sum, item) => sum + item.quantity,
0
);
// 計算安全庫存狀態
const status = group.safetySetting
? getSafetyStockStatus(totalQuantity, group.safetySetting.safetyStock)
: null;
const isLowStock = status === "低於";
const isExpanded = expandedProducts.has(group.productId);
const hasInventory = group.items.length > 0;
@@ -127,10 +124,9 @@ export default function InventoryTable({
<div className="border rounded-lg overflow-hidden">
{/* 商品標題 - 可點擊折疊 */}
<CollapsibleTrigger asChild>
<div
className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${
isLowStock ? "bg-red-50" : "bg-gray-50"
}`}
<div
className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${isLowStock ? "bg-red-50" : "bg-gray-50"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -164,9 +160,9 @@ export default function InventoryTable({
</>
)}
{!group.safetySetting && (
<Badge variant="outline" className="text-gray-500">
<StatusBadge variant="neutral">
</Badge>
</StatusBadge>
)}
</div>
</div>

View File

@@ -7,7 +7,7 @@ import {
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown, Eye } from "lucide-react";
import {
Tooltip,
@@ -122,15 +122,15 @@ export default function ProductTable({
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-grey-0">{product.name}</span>
{product.brand && <Badge variant="secondary" className="text-[10px] h-4 px-1 bg-gray-100 text-gray-500 border-none">{product.brand}</Badge>}
{product.brand && <StatusBadge variant="neutral" className="text-[10px] h-4 px-1">{product.brand}</StatusBadge>}
</div>
<span className="text-xs text-gray-400 font-mono">: {product.code}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
<StatusBadge variant="neutral">
{product.category?.name || '-'}
</Badge>
</StatusBadge>
</TableCell>
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
<TableCell>
@@ -163,9 +163,9 @@ export default function ProductTable({
</TableCell>
<TableCell className="text-center">
{product.is_active ? (
<Badge className="bg-green-100 text-green-700 hover:bg-green-100 border-none"></Badge>
<StatusBadge variant="success"></StatusBadge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 hover:bg-gray-100 border-none"></Badge>
<StatusBadge variant="neutral"></StatusBadge>
)}
</TableCell>
<TableCell className="text-center">

View File

@@ -0,0 +1,42 @@
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
import { ProductionOrderStatus, STATUS_CONFIG } from "@/constants/production-order";
interface ProductionOrderStatusBadgeProps {
status: ProductionOrderStatus;
className?: string;
}
export default function ProductionOrderStatusBadge({
status,
className,
}: ProductionOrderStatusBadgeProps) {
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
const getVariant = (status: string): StatusVariant => {
switch (status) {
case 'draft':
return 'neutral';
case 'pending':
return 'warning';
case 'approved':
return 'success';
case 'in_progress':
return 'info';
case 'completed':
return 'success';
case 'cancelled':
return 'destructive';
default:
return 'neutral';
}
};
return (
<StatusBadge
variant={getVariant(status)}
className={className}
>
{config.label}
</StatusBadge>
);
}

View File

@@ -0,0 +1,94 @@
/**
* 生產工單狀態流程條組件
*/
import { Check } from "lucide-react";
import { ProductionOrderStatus, PRODUCTION_ORDER_STATUS } from "@/constants/production-order";
interface ProductionStatusProgressBarProps {
currentStatus: ProductionOrderStatus;
}
// 流程步驟定義
const FLOW_STEPS: { key: ProductionOrderStatus; label: string }[] = [
{ key: PRODUCTION_ORDER_STATUS.DRAFT, label: "草稿" },
{ key: PRODUCTION_ORDER_STATUS.PENDING, label: "簽核中" },
{ key: PRODUCTION_ORDER_STATUS.APPROVED, label: "已核准" },
{ key: PRODUCTION_ORDER_STATUS.IN_PROGRESS, label: "製作中" },
{ key: PRODUCTION_ORDER_STATUS.COMPLETED, label: "製作完成" },
];
export function ProductionStatusProgressBar({ currentStatus }: ProductionStatusProgressBarProps) {
// 對於已作廢狀態,我們顯示到它作廢前的最後一個有效狀態(通常顯示到核准後或簽核中)
// 這裡我們比照採購單邏輯,如果已作廢,可能停在最後一個有效位置
const effectiveStatus = currentStatus === PRODUCTION_ORDER_STATUS.CANCELLED ? PRODUCTION_ORDER_STATUS.PENDING : currentStatus;
// 找到當前狀態在流程中的位置
const currentIndex = FLOW_STEPS.findIndex((step) => step.key === effectiveStatus);
return (
<div className="bg-white rounded-lg border shadow-sm p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-6"></h3>
<div className="relative px-4">
{/* 進度條背景 */}
<div className="absolute top-5 left-8 right-8 h-0.5 bg-gray-100" />
{/* 進度條進度 */}
{currentIndex >= 0 && (
<div
className="absolute top-5 left-8 h-0.5 bg-primary transition-all duration-500"
style={{
width: `${(currentIndex / (FLOW_STEPS.length - 1)) * 100}%`,
maxWidth: "calc(100% - 4rem)"
}}
/>
)}
{/* 步驟標記 */}
<div className="relative flex justify-between">
{FLOW_STEPS.map((step, index) => {
const isCompleted = index < currentIndex;
const isCurrent = index === currentIndex;
const isRejectedAtThisStep = currentStatus === PRODUCTION_ORDER_STATUS.CANCELLED && step.key === PRODUCTION_ORDER_STATUS.PENDING;
return (
<div key={step.key} className="flex flex-col items-center flex-1">
{/* 圓點 */}
<div
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 z-10 transition-all duration-300 ${isRejectedAtThisStep
? "bg-red-500 border-red-500 text-white"
: isCompleted
? "bg-primary border-primary text-white"
: isCurrent
? "bg-white border-primary text-primary ring-4 ring-primary/10 font-bold"
: "bg-white border-gray-200 text-gray-400"
}`}
>
{isCompleted && !isRejectedAtThisStep ? (
<Check className="h-5 w-5" />
) : (
<span className="text-sm">{index + 1}</span>
)}
</div>
{/* 標籤 */}
<div className="mt-3 text-center">
<p
className={`text-xs whitespace-nowrap transition-colors ${isRejectedAtThisStep
? "text-red-600 font-bold"
: isCompleted || isCurrent
? "text-gray-900 font-bold"
: "text-gray-400"
}`}
>
{isRejectedAtThisStep ? "已作廢" : step.label}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
/**
* 生產工單完工入庫 - 選擇倉庫彈窗
*/
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react";
interface Warehouse {
id: number;
name: string;
}
interface WarehouseSelectionModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (data: {
warehouseId: number;
batchNumber: string;
expiryDate: string;
}) => void;
warehouses: Warehouse[];
processing?: boolean;
// 新增商品資訊以利產生批號
productCode?: string;
productId?: number;
}
export default function WarehouseSelectionModal({
isOpen,
onClose,
onConfirm,
warehouses,
processing = false,
productCode,
productId,
}: WarehouseSelectionModalProps) {
const [selectedId, setSelectedId] = React.useState<number | null>(null);
const [batchNumber, setBatchNumber] = React.useState<string>("");
const [expiryDate, setExpiryDate] = React.useState<string>("");
// 當開啟時,嘗試產生成品批號 (若有資訊)
React.useEffect(() => {
if (isOpen && productCode && productId) {
const today = new Date().toISOString().split('T')[0].replace(/-/g, '');
const originCountry = 'TW';
// 先放一個預設值,實際序號由後端在儲存時再次確認或提供 API
fetch(`/api/warehouses/${selectedId || warehouses[0]?.id || 1}/inventory/batches/${productId}?originCountry=${originCountry}&arrivalDate=${new Date().toISOString().split('T')[0]}`)
.then(res => res.json())
.then(result => {
const seq = result.nextSequence || '01';
setBatchNumber(`${productCode}-${originCountry}-${today}-${seq}`);
})
.catch(() => {
setBatchNumber(`${productCode}-${originCountry}-${today}-01`);
});
}
}, [isOpen, productCode, productId]);
const handleConfirm = () => {
if (selectedId && batchNumber) {
onConfirm({
warehouseId: selectedId,
batchNumber,
expiryDate
});
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-primary-main">
<WarehouseIcon className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<div className="py-6 space-y-6">
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<WarehouseIcon className="h-3 w-3" />
*
</Label>
<SearchableSelect
options={warehouses.map(w => ({ value: w.id.toString(), label: w.name }))}
value={selectedId?.toString() || ""}
onValueChange={(val) => setSelectedId(parseInt(val))}
placeholder="請選擇倉庫..."
className="w-full"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<Tag className="h-3 w-3" />
*
</Label>
<Input
value={batchNumber}
onChange={(e) => setBatchNumber(e.target.value)}
placeholder="輸入成品批號"
className="h-9 font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<CalendarIcon className="h-3 w-3" />
()
</Label>
<Input
type="date"
value={expiryDate}
onChange={(e) => setExpiryDate(e.target.value)}
className="h-9"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={processing}
className="gap-2 button-outlined-error"
>
<X className="h-4 w-4" />
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedId || !batchNumber || processing}
className="gap-2 button-filled-primary"
>
<CheckCircle2 className="h-4 w-4" />
{processing ? "處理中..." : "確認完工入庫"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,7 +2,7 @@
* 採購單狀態標籤組件
*/
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { PurchaseOrderStatus } from "@/types/purchase-order";
import { STATUS_CONFIG } from "@/constants/purchase-order";
@@ -15,14 +15,11 @@ export default function PurchaseOrderStatusBadge({
status,
className,
}: PurchaseOrderStatusBadgeProps) {
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
const config = STATUS_CONFIG[status] || { label: "未知", variant: "neutral" };
return (
<Badge
variant={config.variant}
className={`${className} font-medium px-2.5 py-0.5 rounded-full`}
>
<StatusBadge variant={config.variant} className={className}>
{config.label}
</Badge>
</StatusBadge>
);
}

View File

@@ -16,7 +16,7 @@ import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { SafetyStockSetting } from "@/types/warehouse";
import { toast } from "sonner";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface EditSafetyStockDialogProps {
open: boolean;
@@ -66,7 +66,7 @@ export default function EditSafetyStockDialog({
<Label></Label>
<div className="flex items-center gap-2">
<span className="font-medium">{setting.productName}</span>
<Badge variant="outline">{setting.productType}</Badge>
<StatusBadge variant="neutral">{setting.productType}</StatusBadge>
</div>
</div>

View File

@@ -2,7 +2,7 @@
* 安全庫存列表組件
*/
import { Edit, Trash2, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
import { Trash2, Pencil } from "lucide-react";
import {
Table,
TableBody,
@@ -13,7 +13,7 @@ import {
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { SafetyStockSetting, WarehouseInventory, SafetyStockStatus } from "@/types/warehouse";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface SafetyStockListProps {
settings: SafetyStockSetting[];
@@ -35,29 +35,28 @@ function getSafetyStockStatus(
// 獲取狀態徽章
function getStatusBadge(status: SafetyStockStatus) {
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "接近":
return (
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於":
return (
<Badge className="bg-red-100 text-red-700 border-red-300">
<AlertCircle className="mr-1 h-3 w-3" />
</Badge>
);
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '接近') {
return (
<StatusBadge variant="warning">
</StatusBadge>
);
}
if (status === '低於') {
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null; // Should not happen if SafetyStockStatus is exhaustive
}
export default function SafetyStockList({
@@ -108,7 +107,7 @@ export default function SafetyStockList({
<TableCell className="text-grey-2">{index + 1}</TableCell>
<TableCell className="font-medium">{setting.productName}</TableCell>
<TableCell>
<Badge variant="outline">{setting.productType}</Badge>
<StatusBadge variant="neutral">{setting.productType}</StatusBadge>
</TableCell>
<TableCell>
<span className={isLowStock ? "text-red-600 font-medium" : ""}>
@@ -126,7 +125,7 @@ export default function SafetyStockList({
onClick={() => onEdit(setting)}
className="hover:bg-primary/10 hover:text-primary"
>
<Edit className="h-4 w-4 mr-1" />
<Pencil className="h-4 w-4 mr-1" />
</Button>
<Button

View File

@@ -5,7 +5,7 @@
import { useState, useEffect } from "react";
import { AlertTriangle, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
import { Trash2, Eye, ChevronDown, ChevronRight, Package, AlertTriangle } from "lucide-react";
import {
Table,
TableBody,
@@ -15,7 +15,7 @@ import {
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Collapsible,
CollapsibleContent,
@@ -98,25 +98,22 @@ export default function InventoryTable({
// 獲取狀態徽章
const getStatusBadge = (status: string) => {
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於":
return (
<Badge className="bg-red-100 text-red-700 border-red-300">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
default:
return null;
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '低於') {
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null;
};
return (
@@ -168,10 +165,9 @@ export default function InventoryTable({
{isVending ? '' : (hasInventory ? `${group.batches.length} 個批號` : '無庫存')}
</span>
{group.batches.some(b => b.expiryDate && new Date(b.expiryDate) < new Date()) && (
<Badge className="bg-red-50 text-red-600 border-red-200">
<AlertTriangle className="mr-1 h-3 w-3" />
<StatusBadge variant="destructive">
</Badge>
</StatusBadge>
)}
</div>
<div className="flex items-center gap-4">
@@ -199,9 +195,9 @@ export default function InventoryTable({
</div>
</>
) : (
<Badge variant="outline" className="text-gray-500">
<StatusBadge variant="neutral">
</Badge>
</StatusBadge>
)}
{onViewProduct && (
<Button

View File

@@ -18,7 +18,7 @@ import { Label } from "@/Components/ui/label";
import { Checkbox } from "@/Components/ui/checkbox";
import { SafetyStockSetting, Product } from "@/types/warehouse";
import { toast } from "sonner";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface AddSafetyStockDialogProps {
open: boolean;
@@ -193,7 +193,7 @@ export default function AddSafetyStockDialog({
<div className="flex-1">
<div className="font-medium">{product.name}</div>
</div>
<Badge variant="outline">{product.type}</Badge>
<StatusBadge variant="neutral">{product.type}</StatusBadge>
</div>
);
})}
@@ -223,7 +223,7 @@ export default function AddSafetyStockDialog({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium">{product.name}</span>
<Badge variant="outline">{product.type}</Badge>
<StatusBadge variant="neutral">{product.type}</StatusBadge>
</div>
</div>
<div className="flex items-center gap-2">

View File

@@ -2,7 +2,7 @@
* 安全庫存設定列表
*/
import { Trash2, Pencil, CheckCircle, Package, AlertTriangle } from "lucide-react";
import { Trash2, Pencil, Package } from "lucide-react";
import {
Table,
TableBody,
@@ -12,7 +12,7 @@ import {
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { SafetyStockSetting, WarehouseInventory } from "@/types/warehouse";
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
import { Can } from "@/Components/Permission/Can";
@@ -57,38 +57,35 @@ export default function SafetyStockList({
// 如果是自動帶入的品項且尚未存檔,顯示「未設定」
if (isNew) {
return (
<Badge variant="outline" className="text-gray-400 border-gray-200 font-normal">
<StatusBadge variant="neutral" className="border-gray-200 font-normal text-gray-400">
</Badge>
</StatusBadge>
);
}
const status = getSafetyStockStatus(quantity, safetyStock);
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300 hover:bg-green-100">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "接近": // 數量 <= 安全庫存 * 1.2
return (
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300 hover:bg-yellow-100">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於": // 數量 < 安全庫存
return (
<Badge className="bg-orange-100 text-orange-700 border-orange-300 hover:bg-orange-100">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
default:
return null;
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '接近') { // 數量 <= 安全庫存 * 1.2
return (
<StatusBadge variant="warning">
</StatusBadge>
);
}
if (status === '低於') { // 數量 < 安全庫存
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null;
};
return (
@@ -118,9 +115,9 @@ export default function SafetyStockList({
{setting.productName}
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
<StatusBadge variant="neutral">
{setting.productType}
</Badge>
</StatusBadge>
</TableCell>
<TableCell className="text-right font-semibold">
{setting.safetyStock} {setting.unit || '個'}

View File

@@ -17,7 +17,7 @@ import {
} from "lucide-react";
import { Warehouse, WarehouseStats } from "@/types/warehouse";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Card, CardContent } from "@/Components/ui/card";
import {
Dialog,
@@ -101,13 +101,12 @@ export default function WarehouseCard({
</button>
</div>
<div className="flex gap-2 mt-1">
<Badge
variant={warehouse.type === 'quarantine' ? "secondary" : "outline"}
className={`text-xs font-normal ${warehouse.type === 'quarantine' ? 'bg-red-100 text-red-700 border-red-200' : ''}`}
<StatusBadge
variant={warehouse.type === 'quarantine' ? "destructive" : "neutral"}
>
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
{warehouse.type === 'quarantine' ? ' (不計入可用)' : ' (計入可用)'}
</Badge>
</StatusBadge>
</div>
</div>
</div>

View File

@@ -32,12 +32,20 @@ import { validateWarehouse } from "@/utils/validation";
import { toast } from "sonner";
import { SearchableSelect } from "@/Components/ui/searchable-select";
interface TransitWarehouseOption {
id: string;
name: string;
license_plate?: string;
driver_name?: string;
}
interface WarehouseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
warehouse: Warehouse | null;
onSave: (warehouse: Omit<Warehouse, "id" | "createdAt" | "updatedAt">) => void;
onDelete?: (warehouseId: string) => void;
transitWarehouses?: TransitWarehouseOption[];
}
const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [
@@ -55,6 +63,7 @@ export default function WarehouseDialog({
warehouse,
onSave,
onDelete,
transitWarehouses = [],
}: WarehouseDialogProps) {
const [formData, setFormData] = useState<{
code: string;
@@ -64,6 +73,7 @@ export default function WarehouseDialog({
type: WarehouseType;
license_plate: string;
driver_name: string;
default_transit_warehouse_id: string | null;
}>({
code: "",
name: "",
@@ -72,6 +82,7 @@ export default function WarehouseDialog({
type: "standard",
license_plate: "",
driver_name: "",
default_transit_warehouse_id: null,
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -86,6 +97,7 @@ export default function WarehouseDialog({
type: warehouse.type || "standard",
license_plate: warehouse.license_plate || "",
driver_name: warehouse.driver_name || "",
default_transit_warehouse_id: warehouse.default_transit_warehouse_id ? String(warehouse.default_transit_warehouse_id) : null,
});
} else {
setFormData({
@@ -96,6 +108,7 @@ export default function WarehouseDialog({
type: "standard",
license_plate: "",
driver_name: "",
default_transit_warehouse_id: null,
});
}
}, [warehouse, open]);
@@ -216,6 +229,32 @@ export default function WarehouseDialog({
</div>
)}
{/* 預設在途倉設定(僅非 transit 類型顯示) */}
{formData.type !== 'transit' && transitWarehouses.length > 0 && (
<div className="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-100">
<div className="border-b border-blue-200 pb-2">
<h4 className="text-sm text-blue-800 font-medium">調</h4>
</div>
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-gray-500">調</p>
<SearchableSelect
value={formData.default_transit_warehouse_id || ""}
onValueChange={(val) => setFormData({ ...formData, default_transit_warehouse_id: val || null })}
options={[
{ label: "不指定", value: "" },
...transitWarehouses.map((tw) => ({
label: `${tw.name}${tw.license_plate ? ` (${tw.license_plate})` : ''}`,
value: tw.id,
})),
]}
placeholder="選擇預設在途倉"
className="h-9 bg-white"
/>
</div>
</div>
)}
{/* 區塊 B位置 */}

View File

@@ -0,0 +1,34 @@
import { Badge } from "@/Components/ui/badge";
import { cn } from "@/lib/utils";
export type StatusVariant =
| "neutral"
| "info"
| "warning"
| "success"
| "destructive";
interface StatusBadgeProps {
variant: StatusVariant;
children: React.ReactNode;
className?: string;
}
const variantStyles: Record<StatusVariant, string> = {
neutral: "bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-100", // Draft, Cancelled(sometimes), Closed
info: "bg-blue-100 text-blue-800 border-blue-200 hover:bg-blue-100", // Processing, Active
warning: "bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-100", // Pending, Review
success: "bg-green-100 text-green-800 border-green-200 hover:bg-green-100", // Completed, Approved
destructive: "bg-red-100 text-red-800 border-red-200 hover:bg-red-100", // Voided, Rejected, High Risk
};
export function StatusBadge({ variant, children, className }: StatusBadgeProps) {
return (
<Badge
variant="outline"
className={cn(variantStyles[variant], "font-medium border", className)}
>
{children}
</Badge>
);
}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import {
@@ -36,6 +36,8 @@ interface SearchableSelectProps {
searchThreshold?: number;
/** 強制控制是否顯示搜尋框。若設定此值,則忽略 searchThreshold */
showSearch?: boolean;
/** 是否可清除選取 */
isClearable?: boolean;
}
export function SearchableSelect({
@@ -49,6 +51,7 @@ export function SearchableSelect({
className,
searchThreshold = 10,
showSearch,
isClearable = false,
}: SearchableSelectProps) {
const [open, setOpen] = React.useState(false);
@@ -86,7 +89,18 @@ export function SearchableSelect({
<span className="truncate">
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 text-grey-2" />
<div className="flex items-center gap-1 shrink-0">
{isClearable && value && !disabled && (
<X
className="h-4 w-4 text-grey-3 hover:text-grey-1 transition-colors pointer-events-auto cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onValueChange("");
}}
/>
)}
<ChevronsUpDown className="h-4 w-4 opacity-50 text-grey-2" />
</div>
</button>
</PopoverTrigger>
<PopoverContent

View File

@@ -25,7 +25,8 @@ import {
ClipboardCheck,
ArrowLeftRight,
TrendingUp,
FileUp
FileUp,
Store
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo, useRef } from "react";
@@ -44,6 +45,7 @@ import { usePermission } from "@/hooks/usePermission";
import ApplicationLogo from "@/Components/ApplicationLogo";
import { generateLightestColor, generateLightColor, generateDarkColor, generateActiveColor } from "@/utils/colorUtils";
import { PageProps } from "@/types/global";
import NotificationDropdown from "@/Components/Header/NotificationDropdown";
interface MenuItem {
id: string;
@@ -130,6 +132,13 @@ export default function AuthenticatedLayout({
route: "/inventory/transfer-orders",
permission: "inventory_transfer.view",
},
{
id: "store-requisition",
label: "門市叫貨",
icon: <Store className="h-4 w-4" />,
route: "/store-requisitions",
permission: "store_requisitions.view",
},
],
},
{
@@ -181,6 +190,13 @@ export default function AuthenticatedLayout({
route: "/sales/imports",
permission: "sales_imports.view",
},
{
id: "sales-order-list",
label: "銷售訂單管理",
icon: <ShoppingCart className="h-4 w-4" />,
route: "/integration/sales-orders",
permission: "sales_orders.view",
},
],
},
{
@@ -240,13 +256,20 @@ export default function AuthenticatedLayout({
route: "/inventory/report",
permission: "inventory_report.view",
},
{
id: "inventory-analysis",
label: "庫存分析",
icon: <BarChart3 className="h-4 w-4" />,
route: "/inventory/analysis",
permission: "inventory_report.view",
},
],
},
{
id: "system-management",
label: "系統管理",
icon: <Settings className="h-5 w-5" />,
permission: ["users.view", "roles.view"],
permission: ["users.view", "roles.view", "system.view_logs"],
children: [
{
id: "user-management",
@@ -269,6 +292,13 @@ export default function AuthenticatedLayout({
route: "/admin/activity-logs",
permission: "system.view_logs",
},
{
id: "manual",
label: "操作手冊",
icon: <BookOpen className="h-4 w-4" />,
route: "/system/manual",
// 手冊開放給所有登入使用者
},
],
},
];
@@ -286,19 +316,23 @@ export default function AuthenticatedLayout({
const menuItems = useMemo(() => {
return allMenuItems
.map((item) => {
// 如果有子項目,先過濾子項目
// 如果有子項目
if (item.children && item.children.length > 0) {
const filteredChildren = item.children.filter(hasPermissionForItem);
// 若所有子項目都無權限,則隱藏整個群組
if (filteredChildren.length === 0) return null;
return { ...item, children: filteredChildren };
// 若有子項目符合權限,則顯示該群組(群組本身的權限僅作為額外過濾)
if (filteredChildren.length > 0) {
return { ...item, children: filteredChildren };
}
return null;
}
// 無子項目的單一選單,直接檢查權限
if (!hasPermissionForItem(item)) return null;
return item;
})
.filter((item): item is MenuItem => item !== null);
}, [can, canAny]);
}, [allMenuItems, hasPermissionForItem]);
// 初始化狀態:優先讀取 localStorage
const [expandedItems, setExpandedItems] = useState<string[]>(() => {
@@ -377,13 +411,15 @@ export default function AuthenticatedLayout({
});
};
const renderMenuItem = (item: MenuItem, level: number = 0) => {
const renderMenuItem = (item: MenuItem, level: number = 0, forceExpand: boolean = false) => {
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.includes(item.id);
const isActive = item.route
? (item.route === '/' ? url === '/' : url.startsWith(item.route))
: false;
const effectivelyCollapsed = isCollapsed && !forceExpand;
return (
<div key={item.id} className="mb-1">
{hasChildren ? (
@@ -392,21 +428,21 @@ export default function AuthenticatedLayout({
className={cn(
"w-full flex items-center transition-all rounded-lg group",
level === 0 ? "px-3 py-2.5" : "px-3 py-2 pl-10",
level === 0 && !isCollapsed && "hover:bg-slate-100",
isCollapsed && level === 0 && "justify-center px-0 h-10 w-10 mx-auto hover:bg-slate-100"
level === 0 && !effectivelyCollapsed && "hover:bg-slate-100",
effectivelyCollapsed && level === 0 && "justify-center px-0 h-10 w-10 mx-auto hover:bg-slate-100"
)}
title={isCollapsed ? item.label : ""}
title={effectivelyCollapsed ? item.label : ""}
>
{level === 0 && (
<span className={cn(
"flex-shrink-0 transition-all",
isCollapsed ? "mr-0" : "mr-3 text-slate-500 group-hover:text-slate-900"
effectivelyCollapsed ? "mr-0" : "mr-3 text-slate-500 group-hover:text-slate-900"
)}>
{item.icon}
</span>
)}
{!isCollapsed && (
{!effectivelyCollapsed && (
<>
<span className="flex-1 text-left text-base font-medium text-slate-700 group-hover:text-slate-900 truncate">
{item.label}
@@ -429,22 +465,22 @@ export default function AuthenticatedLayout({
className={cn(
"w-full flex items-center transition-all rounded-lg group",
level === 0 ? "px-3 py-2.5" : "px-3 py-2",
level > 0 && !isCollapsed && "pl-11",
level > 0 && !effectivelyCollapsed && "pl-11",
isActive ? "bg-primary-lightest text-primary-main" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
isCollapsed && level === 0 && "justify-center px-0 h-10 w-10 mx-auto"
effectivelyCollapsed && level === 0 && "justify-center px-0 h-10 w-10 mx-auto"
)}
title={isCollapsed ? item.label : ""}
title={effectivelyCollapsed ? item.label : ""}
>
{item.icon && (
<span className={cn(
"flex-shrink-0 transition-all",
isCollapsed ? "mr-0" : "mr-3",
effectivelyCollapsed ? "mr-0" : "mr-3",
isActive ? "text-primary-main" : "text-slate-500 group-hover:text-slate-900"
)}>
{item.icon}
</span>
)}
{!isCollapsed && (
{!effectivelyCollapsed && (
<span className="text-base font-medium truncate">
{item.label}
</span>
@@ -452,9 +488,9 @@ export default function AuthenticatedLayout({
</Link>
)}
{hasChildren && isExpanded && !isCollapsed && (
{hasChildren && isExpanded && !effectivelyCollapsed && (
<div className="mt-1 space-y-1">
{item.children?.map((child) => renderMenuItem(child, level + 1))}
{item.children?.map((child) => renderMenuItem(child, level + 1, forceExpand))}
</div>
)}
</div>
@@ -491,47 +527,51 @@ export default function AuthenticatedLayout({
</div>
{/* User Menu */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
<div className="hidden md:flex flex-col items-end mr-1">
<span className="text-sm font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
{user.name} ({user.username})
</span>
<span className="text-xs text-slate-500">
{user.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
</span>
</div>
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all">
<User className="h-5 w-5" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
<DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('profile.edit')}
preserveScroll={true}
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
>
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
<span>使</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('logout')}
method="post"
as="button"
className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<NotificationDropdown />
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
<div className="hidden md:flex flex-col items-end mr-1">
<span className="text-sm font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
{user.name} ({user.username})
</span>
<span className="text-xs text-slate-500">
{user.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
</span>
</div>
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all">
<User className="h-5 w-5" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
<DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('profile.edit')}
preserveScroll={true}
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
>
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
<span>使</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('logout')}
method="post"
as="button"
className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
{/* Sidebar Desktop */}
@@ -560,7 +600,7 @@ export default function AuthenticatedLayout({
</div>
<div className="p-4 border-t border-slate-100 flex items-center justify-between">
{!isCollapsed && <p className="text-[10px] font-medium text-slate-400 uppercase tracking-wider px-2">Version 1.0.0</p>}
{!isCollapsed && <p className="text-[10px] font-medium text-slate-400 uppercase tracking-wider px-2">Version {props.app_version || '1.0.0'}</p>}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
@@ -600,20 +640,20 @@ export default function AuthenticatedLayout({
</div>
<div className="flex-1 overflow-y-auto p-4" scroll-region="true">
<nav className="space-y-1">
{menuItems.map((item) => renderMenuItem(item))}
{menuItems.map((item) => renderMenuItem(item, 0, true))}
</nav>
</div>
</aside>
{/* Main Content */}
<main className={cn(
"flex-1 flex flex-col transition-all duration-300 min-h-screen overflow-auto",
"flex-1 flex flex-col transition-all duration-300 min-h-screen",
"lg:ml-64",
isCollapsed && "lg:ml-20",
"pt-16" // 始終為頁首保留空間
)}>
<div className="relative">
<div className="container mx-auto px-6 pt-6 max-w-7xl">
<div className="relative flex-1 flex flex-col min-h-0">
<div className="container mx-auto px-6 pt-6 max-w-7xl shrink-0">
{breadcrumbs && breadcrumbs.length > 1 && (
<BreadcrumbNav items={breadcrumbs} className="mb-2" />
)}

View File

@@ -4,7 +4,7 @@ import { Head, router } from '@inertiajs/react';
import { PageProps } from '@/types/global';
import Pagination from '@/Components/shared/Pagination';
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { FileText, Search, RotateCcw, Calendar, ChevronDown, ChevronUp } from 'lucide-react';
import { FileText, Search, RotateCcw, Calendar } from 'lucide-react';
import LogTable, { Activity } from '@/Components/ActivityLog/LogTable';
import ActivityDetailDialog from '@/Components/ActivityLog/ActivityDetailDialog';
import { Button } from '@/Components/ui/button';
@@ -57,10 +57,7 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
const [causer, setCauser] = useState(filters.causer_id || 'all');
const [dateRangeType, setDateRangeType] = useState('custom');
// Advanced Filter Toggle
const [showAdvancedFilter, setShowAdvancedFilter] = useState(
!!(filters.date_start || filters.date_end)
);
const handleDateRangeChange = (type: string) => {
setDateRangeType(type);
@@ -161,75 +158,12 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
</div>
{/* 篩選區塊 */}
<div className="bg-white p-5 rounded-lg shadow-sm border border-grey-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 mb-4">
{/* 關鍵字搜尋 */}
<div className="md:col-span-4 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜尋描述、內容..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 h-9 block"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/>
</div>
</div>
{/* 事件類型 */}
<div className="md:col-span-2 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<Select value={event} onValueChange={setEvent}>
<SelectTrigger className="h-9">
<SelectValue placeholder="所有事件" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="created"> (Created)</SelectItem>
<SelectItem value="updated"> (Updated)</SelectItem>
<SelectItem value="deleted"> (Deleted)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 操作對象 */}
<div className="md:col-span-3 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<SearchableSelect
value={subjectType}
onValueChange={setSubjectType}
options={[
{ label: "所有對象", value: "all" },
...subject_types
]}
placeholder="選擇對象"
className="w-full h-9"
/>
</div>
{/* 操作人員 */}
<div className="md:col-span-3 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<SearchableSelect
value={causer}
onValueChange={setCauser}
options={[
{ label: "所有人員", value: "all" },
...users
]}
placeholder="選擇人員"
className="w-full h-9"
/>
</div>
</div>
{/* Row 2: Date Filters (Collapsible) */}
{showAdvancedFilter && (
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
<div className="md:col-span-6 space-y-2">
<Label className="text-xs font-medium text-grey-1"></Label>
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
<div className="space-y-4">
{/* Top Config: Date Range & Quick Buttons */}
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
<div className="flex-none space-y-2">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="flex flex-wrap gap-2">
{[
{ label: "今日", value: "today" },
@@ -254,10 +188,11 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
</div>
</div>
<div className="md:col-span-6">
<div className="grid grid-cols-2 gap-4 items-end">
{/* Date Inputs */}
<div className="w-full lg:flex-1">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
@@ -267,13 +202,12 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
setDateStart(e.target.value);
setDateRangeType('custom');
}}
// block w-full to ensure it fills space
className="pl-9 block w-full h-9 bg-white"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
@@ -290,43 +224,88 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
</div>
</div>
</div>
)}
{/* Action Bar */}
<div className="flex items-center justify-end border-t border-grey-4 pt-5 gap-3 mt-4">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFilter(!showAdvancedFilter)}
className="mr-auto text-gray-500 hover:text-gray-900 h-9"
>
{showAdvancedFilter ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{(dateStart || dateEnd) && (
<span className="ml-2 w-2 h-2 rounded-full bg-primary-main" />
)}
</>
)}
</Button>
<Button
variant="outline"
onClick={handleReset}
className="flex items-center gap-2 button-outlined-primary h-9"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button onClick={handleFilter} className="button-filled-primary h-9 px-6 gap-2">
<Search className="h-4 w-4" />
</Button>
{/* Detailed Filters row */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
{/* 事件類型 */}
<div className="md:col-span-2 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Select value={event} onValueChange={setEvent}>
<SelectTrigger className="h-9 bg-white">
<SelectValue placeholder="所有事件" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="created"> (Created)</SelectItem>
<SelectItem value="updated"> (Updated)</SelectItem>
<SelectItem value="deleted"> (Deleted)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 操作對象 */}
<div className="md:col-span-2 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<SearchableSelect
value={subjectType}
onValueChange={setSubjectType}
options={[
{ label: "所有對象", value: "all" },
...subject_types
]}
placeholder="選擇對象"
className="w-full h-9 bg-white"
/>
</div>
{/* 操作人員 */}
<div className="md:col-span-2 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<SearchableSelect
value={causer}
onValueChange={setCauser}
options={[
{ label: "所有人員", value: "all" },
...users
]}
placeholder="選擇人員"
className="w-full h-9 bg-white"
/>
</div>
{/* 關鍵字搜尋 */}
<div className="md:col-span-3 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜尋內容..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 h-9 block bg-white"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/>
</div>
</div>
{/* Action Buttons Integrated */}
<div className="md:col-span-3 flex items-center gap-2">
<Button
variant="outline"
onClick={handleReset}
className="flex-1 items-center gap-2 button-outlined-primary h-9"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleFilter}
className="flex-1 button-filled-primary h-9 gap-2 shadow-sm"
>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>

View File

@@ -1,34 +1,30 @@
import { Head, Link } from "@inertiajs/react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import {
Package,
AlertTriangle,
MinusCircle,
Clock,
ArrowRight,
LayoutDashboard,
TrendingUp,
DollarSign,
ClipboardCheck,
Trophy,
Package,
} from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button";
interface AbnormalItem {
id: number;
product_code: string;
product_name: string;
warehouse_name: string;
quantity: number;
safety_stock: number | null;
expiry_date: string | null;
statuses: string[];
}
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
} from "recharts";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/Components/ui/tooltip";
interface Props {
stats: {
@@ -36,45 +32,71 @@ interface Props {
lowStockCount: number;
negativeCount: number;
expiringCount: number;
totalInventoryValue: number;
thisMonthRevenue: number;
pendingOrdersCount: number;
pendingTransferCount: number;
pendingProductionCount: number;
todoCount: number;
salesTrend: { date: string; amount: number }[];
topSellingProducts: { name: string; amount: number }[];
topInventoryValue: { name: string; code: string; value: number }[];
topSellingByQuantity: { name: string; code: string; value: number }[];
expiringSoon: { name: string; batch_number: string; expiry_date: string; quantity: number }[];
};
abnormalItems: AbnormalItem[];
}
// 狀態 Badge 映射
const statusConfig: Record<string, { label: string; className: string }> = {
negative: {
label: "負庫存",
className: "bg-red-100 text-red-800 border-red-200",
},
low_stock: {
label: "低庫存",
className: "bg-amber-100 text-amber-800 border-amber-200",
},
expiring: {
label: "即將過期",
className: "bg-yellow-100 text-yellow-800 border-yellow-200",
},
expired: {
label: "已過期",
className: "bg-red-100 text-red-800 border-red-200",
},
};
export default function Dashboard({ stats, abnormalItems }: Props) {
const cards = [
export default function Dashboard({ stats }: Props) {
const mainCards = [
{
label: "庫存明細數",
value: stats.totalItems,
icon: <Package className="h-6 w-6" />,
color: "text-primary-main",
bgColor: "bg-primary-lightest",
borderColor: "border-primary-light",
href: "/inventory/stock-query",
label: "庫存總值",
value: `NT$ ${Math.round(stats.totalInventoryValue).toLocaleString()}`,
description: `品項總數: ${stats.totalItems}`,
icon: <TrendingUp className="h-5 w-5" />,
color: "text-blue-600",
bgColor: "bg-blue-50",
borderColor: "border-blue-100",
},
{
label: "本月銷售營收",
value: `NT$ ${Math.round(stats.thisMonthRevenue).toLocaleString()}`,
description: "基於銷售導入數據",
icon: <DollarSign className="h-5 w-5" />,
color: "text-emerald-600",
bgColor: "bg-emerald-50",
borderColor: "border-emerald-100",
},
{
label: "待辦任務",
value: stats.todoCount,
description: (
<div className="flex items-center gap-1 font-medium">
<Link href={route('purchase-orders.index')} className="text-purple-600 hover:text-purple-800 hover:underline transition-colors">
: {stats.pendingOrdersCount}
</Link>
<span className="mx-1 text-gray-400">|</span>
<Link href={route('production-orders.index')} className="text-purple-600 hover:text-purple-800 hover:underline transition-colors">
: {stats.pendingProductionCount}
</Link>
<span className="mx-1 text-gray-400">|</span>
<Link href={route('inventory.transfer.index')} className="text-purple-600 hover:text-purple-800 hover:underline transition-colors">
調: {stats.pendingTransferCount}
</Link>
</div>
),
icon: <ClipboardCheck className="h-5 w-5" />,
color: "text-purple-600",
bgColor: "bg-purple-50",
borderColor: "border-purple-100",
alert: stats.todoCount > 0,
},
];
const alertCards = [
{
label: "低庫存",
value: stats.lowStockCount,
icon: <AlertTriangle className="h-6 w-6" />,
icon: <AlertTriangle className="h-4 w-4" />,
color: "text-amber-600",
bgColor: "bg-amber-50",
borderColor: "border-amber-200",
@@ -84,7 +106,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
{
label: "負庫存",
value: stats.negativeCount,
icon: <MinusCircle className="h-6 w-6" />,
icon: <MinusCircle className="h-4 w-4" />,
color: "text-red-600",
bgColor: "bg-red-50",
borderColor: "border-red-200",
@@ -94,7 +116,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
{
label: "即將過期",
value: stats.expiringCount,
icon: <Clock className="h-6 w-6" />,
icon: <Clock className="h-4 w-4" />,
color: "text-yellow-600",
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
@@ -105,155 +127,214 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
return (
<AuthenticatedLayout
breadcrumbs={[
{
label: "儀表板",
href: "/",
isPage: true,
},
]}
breadcrumbs={[{ label: "儀表板", href: "/", isPage: true }]}
>
<Head title="儀表板" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面標題 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<LayoutDashboard className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
<div className="container mx-auto p-6 max-w-7xl space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<LayoutDashboard className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1"></p>
</div>
<div className="flex gap-2">
{alertCards.map((card) => (
<Link key={card.label} href={card.href} className="flex-1 md:flex-none">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${card.borderColor} ${card.bgColor} transition-colors hover:shadow-sm`}>
<div className={card.color}>{card.icon}</div>
<span className="text-xs font-medium text-gray-700">{card.label}</span>
<span className={`text-sm font-bold ${card.color}`}>{card.value}</span>
</div>
</Link>
))}
</div>
</div>
{/* 統計卡片 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{cards.map((card) => (
<Link key={card.label} href={card.href}>
<div
className={`relative rounded-xl border ${card.borderColor} ${card.bgColor} p-5 transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer`}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{mainCards.map((card) => (
<div key={card.label} className={`relative rounded-xl border ${card.borderColor} bg-white p-6 shadow-sm`}>
<div className="flex items-center justify-between mb-4">
<div className={`p-2 rounded-lg ${card.bgColor} ${card.color}`}>
{card.icon}
</div>
{card.alert && (
<span className="absolute top-3 right-3 h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse" />
<span className="flex h-2 w-2 rounded-full bg-red-500 animate-pulse" />
)}
<div className="flex items-center gap-3 mb-3">
<div className={card.color}>
{card.icon}
</div>
<span className="text-sm font-medium text-grey-1">
{card.label}
</span>
</div>
<div
className={`text-3xl font-bold ${card.color}`}
>
{card.value.toLocaleString()}
</div>
</div>
</Link>
<div className="text-sm font-medium text-gray-500 mb-1">{card.label}</div>
<div className="text-2xl font-bold text-gray-900 mb-1">{card.value}</div>
<div className="text-xs text-gray-400">{card.description}</div>
</div>
))}
</div>
{/* 異常庫存清單 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-grey-0 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
</h2>
<Link href="/inventory/stock-query?status=abnormal">
<Button
variant="outline"
size="sm"
className="button-outlined-primary gap-1"
>
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
{/* 銷售趨勢 & 熱銷排行 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 銷售趨勢 - Area Chart */}
<div className="lg:col-span-2 bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-2 mb-6">
<TrendingUp className="h-5 w-5 text-emerald-500" />
<h2 className="text-lg font-semibold text-gray-800"> 30 </h2>
</div>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.salesTrend} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" />
<YAxis tickFormatter={(value) => `$${value / 1000}k`} />
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<RechartsTooltip formatter={(value) => `NT$ ${Number(value).toLocaleString()}`} />
<Area type="monotone" dataKey="amount" stroke="#10b981" fillOpacity={1} fill="url(#colorAmount)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">
#
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">
</TableHead>
<TableHead className="text-center">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{abnormalItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center py-8 text-gray-500"
>
🎉
</TableCell>
</TableRow>
) : (
abnormalItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{index + 1}
</TableCell>
<TableCell className="font-mono text-sm">
{item.product_code}
</TableCell>
<TableCell className="font-medium">
{item.product_name}
</TableCell>
<TableCell>
{item.warehouse_name}
</TableCell>
<TableCell
className={`text-right font-medium ${item.quantity < 0
? "text-red-600"
: ""
}`}
>
{item.quantity}
</TableCell>
<TableCell className="text-center">
<div className="flex flex-wrap items-center justify-center gap-1">
{item.statuses.map(
(status) => {
const config =
statusConfig[
status
];
if (!config)
return null;
return (
<Badge
key={status}
variant="outline"
className={
config.className
}
>
{config.label}
</Badge>
);
}
)}
{/* 熱銷商品排行 (金額) - Bar Chart */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-2 mb-6">
<Trophy className="h-5 w-5 text-indigo-500" />
<h2 className="text-lg font-semibold text-gray-800"> Top 5</h2>
</div>
<div className="h-[300px] w-full flex flex-col justify-center space-y-6">
{stats.topSellingProducts.length > 0 ? (
(() => {
const maxAmount = Math.max(...stats.topSellingProducts.map(p => p.amount));
return stats.topSellingProducts.map((product, index) => (
<div key={index} className="space-y-1">
<div className="flex justify-between items-end">
<div className="min-w-0 flex-1 pr-4">
<Tooltip>
<TooltipTrigger asChild>
<span className="block text-sm font-medium text-gray-700 truncate cursor-help">
{product.name}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{product.name}</p>
</TooltipContent>
</Tooltip>
</div>
<span className="text-sm font-bold text-indigo-600 shrink-0">
NT$ {product.amount.toLocaleString()}
</span>
</div>
</TableCell>
</TableRow>
))
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div
className="bg-indigo-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${(product.amount / maxAmount) * 100}%` }}
/>
</div>
</div>
));
})()
) : (
<div className="h-full flex items-center justify-center text-gray-400 text-sm"></div>
)}
</TableBody>
</Table>
</div>
</div>
</div>
{/* 其他排行資訊 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* 庫存積壓排行 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
<DollarSign className="h-4 w-4 text-blue-500" />
<h3 className="font-semibold text-gray-700"> Top 5</h3>
</div>
<div className="divide-y divide-gray-100">
{stats.topInventoryValue.length > 0 ? stats.topInventoryValue.map((item, idx) => (
<div key={idx} className="p-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div className="min-w-0 flex-1 pr-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-sm font-medium text-gray-900 truncate cursor-help">{item.name}</div>
</TooltipTrigger>
<TooltipContent>
<p>{item.name}</p>
</TooltipContent>
</Tooltip>
<div className="text-xs text-gray-500 truncate">{item.code}</div>
</div>
<div className="text-right">
<div className="text-sm font-bold text-gray-700">NT$ {item.value.toLocaleString()}</div>
</div>
</div>
)) : (
<div className="p-8 text-center text-gray-400 text-sm"></div>
)}
</div>
</div>
{/* 熱銷數量排行 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
<Package className="h-4 w-4 text-emerald-500" />
<h3 className="font-semibold text-gray-700"> Top 5</h3>
</div>
<div className="divide-y divide-gray-100">
{stats.topSellingByQuantity.length > 0 ? stats.topSellingByQuantity.map((item, idx) => (
<div key={idx} className="p-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div className="min-w-0 flex-1 pr-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-sm font-medium text-gray-900 truncate cursor-help">{item.name}</div>
</TooltipTrigger>
<TooltipContent>
<p>{item.name}</p>
</TooltipContent>
</Tooltip>
<div className="text-xs text-gray-500 truncate">{item.code}</div>
</div>
<div className="text-right">
<div className="text-sm font-bold text-gray-700">{item.value.toLocaleString()} <span className="text-xs font-normal text-gray-500"></span></div>
</div>
</div>
)) : (
<div className="p-8 text-center text-gray-400 text-sm"></div>
)}
</div>
</div>
{/* 即將過期商品 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
<Clock className="h-4 w-4 text-red-500" />
<h3 className="font-semibold text-gray-700"> Top 5</h3>
</div>
<div className="divide-y divide-gray-100">
{stats.expiringSoon.length > 0 ? stats.expiringSoon.map((item, idx) => (
<div key={idx} className="p-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div className="min-w-0 flex-1 pr-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-sm font-medium text-gray-900 truncate cursor-help">{item.name}</div>
</TooltipTrigger>
<TooltipContent>
<p>{item.name}</p>
</TooltipContent>
</Tooltip>
<div className="text-xs text-gray-500 truncate">: {item.batch_number}</div>
</div>
<div className="text-right">
<div className="text-sm font-bold text-red-600">{item.expiry_date}</div>
<div className="text-xs text-gray-500">: {item.quantity}</div>
</div>
</div>
)) : (
<div className="p-8 text-center text-green-500 text-sm"></div>
)}
</div>
</div>
</div>
</div>
</AuthenticatedLayout>

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