Compare commits
70 Commits
a2fe7b5a95
...
a33e470e4d
| Author | SHA1 | Date | |
|---|---|---|---|
| a33e470e4d | |||
| 71b676b533 | |||
| 406d03297a | |||
| 4259c7745b | |||
| 8169ff3f59 | |||
| 1acc4daebb | |||
| 1acbfb7246 | |||
| e02d7c7125 | |||
| a133b94a05 | |||
| acd0590a38 | |||
| e85c1fa95a | |||
| 62dcf04e95 | |||
| 6dd3396fb7 | |||
| 2f30a78118 | |||
| 904132e460 | |||
| a05acd96dc | |||
| 29cdf37b71 | |||
| d7d1be81a9 | |||
| 227cfec0d2 | |||
| 034a21cd31 | |||
| 6358e23816 | |||
| ac149533f0 | |||
| b20a47f710 | |||
| d017d7e5e0 | |||
| 8207e6fe94 | |||
| e6cf03b991 | |||
| 8ef82d49cb | |||
| bb2cf77ccb | |||
| d543e6e810 | |||
| 64e039cc71 | |||
| cce8dd3c8b | |||
| 6a0f57c86c | |||
| 0251540365 | |||
| ab5b4bde0b | |||
| f85f06f3e1 | |||
| 6671e4221f | |||
| 24f73a2585 | |||
| 2e9ff6c832 | |||
| 77a7d31dc1 | |||
| e141a45eb9 | |||
| 4fa87925a2 | |||
| 097708aab7 | |||
| b8cbf0bb6d | |||
| 882091ce5f | |||
| 245553280a | |||
| 299602d3b1 | |||
| 96f2ccee95 | |||
| c9113544ee | |||
| 5be4d49679 | |||
| b118ea0c39 | |||
| eb5ab58093 | |||
| 57e633c3e9 | |||
| 448b37ca90 | |||
| 6c146ac717 | |||
| cb433035fe | |||
| e646c6ffd8 | |||
| 83e1c82b11 | |||
| 19397db2e9 | |||
| db285a6b69 | |||
| 74eeb449f8 | |||
| 28ece9fda4 | |||
| bd292b0868 | |||
| ac705a1e58 | |||
| 936abc943e | |||
| eabde37d15 | |||
| 921f6e48fb | |||
| ba4ceb7ff6 | |||
| 3be5d099c9 | |||
| 9537e48f08 | |||
| 165737750c |
@@ -84,4 +84,10 @@ trigger: always_on
|
|||||||
* **執行 PHP 指令**: `./vendor/bin/sail php -v`
|
* **執行 PHP 指令**: `./vendor/bin/sail php -v`
|
||||||
* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list`
|
* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list`
|
||||||
* **執行 Composer**: `./vendor/bin/sail composer install`
|
* **執行 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`。
|
||||||
@@ -569,6 +569,7 @@ const handlePerPageChange = (value: string) => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## 7. Badge 與狀態顯示
|
## 7. Badge 與狀態顯示
|
||||||
|
|
||||||
### 7.1 基本 Badge
|
### 7.1 基本 Badge
|
||||||
@@ -614,6 +615,48 @@ import { Badge } from "@/Components/ui/badge";
|
|||||||
</div>
|
</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. 頁面佈局規範
|
## 8. 頁面佈局規範
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ APP_ENV=local
|
|||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
APP_VERSION=v1.0.0
|
||||||
|
|
||||||
# Multi-tenancy 設定 (用逗號分隔多個中央網域)
|
# Multi-tenancy 設定 (用逗號分隔多個中央網域)
|
||||||
CENTRAL_DOMAINS=localhost,127.0.0.1
|
CENTRAL_DOMAINS=localhost,127.0.0.1
|
||||||
@@ -43,7 +44,7 @@ BROADCAST_CONNECTION=log
|
|||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=redis
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|||||||
99
.gitea/workflows/deploy-demo.yaml
Normal file
99
.gitea/workflows/deploy-demo.yaml
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
name: Koori-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|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"
|
||||||
|
|
||||||
|
- 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 &&
|
||||||
|
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
|
||||||
95
.gitea/workflows/deploy-prod.yaml
Normal file
95
.gitea/workflows/deploy-prod.yaml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: Koori-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
|
||||||
|
# [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
|
||||||
|
|
||||||
|
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|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
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
name: Koori-ERP-Deploy-System
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- demo
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# --- 1. Demo 環境部署 (gitea_work) ---
|
|
||||||
deploy-demo:
|
|
||||||
if: github.ref == 'refs/heads/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
|
|
||||||
|
|
||||||
# 2. 檢查是否需要重建容器(只有 Dockerfile 或 compose.yaml 變動時才重建)
|
|
||||||
- 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
|
|
||||||
# 檢查最近的 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: 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|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"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 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 "
|
|
||||||
# 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. 正式環境部署 (220.132.7.82:2227) ---
|
|
||||||
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 2227 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
|
|
||||||
./ root@220.132.7.82:/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: 220.132.7.82
|
|
||||||
port: 2227
|
|
||||||
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: 220.132.7.82
|
|
||||||
port: 2227
|
|
||||||
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
|
|
||||||
@@ -107,6 +107,7 @@ Star ERP
|
|||||||
git clone <repository_url> star-erp
|
git clone <repository_url> star-erp
|
||||||
cd star-erp
|
cd star-erp
|
||||||
|
|
||||||
|
|
||||||
# 2. 設定環境變數
|
# 2. 設定環境變數
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 請檢查 .env 內容,本機開發預設配置:
|
# 請檢查 .env 內容,本機開發預設配置:
|
||||||
@@ -171,7 +172,6 @@ docker exec -it star-erp-laravel php artisan tinker
|
|||||||
# 停止容器
|
# 停止容器
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧪 開發規範
|
## 🧪 開發規範
|
||||||
|
|
||||||
- **後端**: Follow Laravel 12 最佳實踐,使用 Service/Action 模式處理複雜邏輯。
|
- **後端**: Follow Laravel 12 最佳實踐,使用 Service/Action 模式處理複雜邏輯。
|
||||||
@@ -180,3 +180,4 @@ docker compose down
|
|||||||
- **多租戶**:
|
- **多租戶**:
|
||||||
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
|
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
|
||||||
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。
|
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'appName' => $appName,
|
'appName' => $appName,
|
||||||
|
'app_version' => config('app.version'),
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $user ? [
|
'user' => $user ? [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
@@ -90,6 +91,12 @@ class HandleInertiaRequests extends Middleware
|
|||||||
|
|
||||||
return $brandingData;
|
return $brandingData;
|
||||||
},
|
},
|
||||||
|
'notifications' => function () use ($request) {
|
||||||
|
return $request->user() ? [
|
||||||
|
'latest' => $request->user()->notifications()->latest()->limit(10)->get(),
|
||||||
|
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||||
|
] : null;
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,102 @@ class DashboardController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$invStats = $this->inventoryService->getDashboardStats();
|
$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', [
|
return Inertia::render('Dashboard', [
|
||||||
'stats' => [
|
'stats' => [
|
||||||
@@ -39,8 +135,18 @@ class DashboardController extends Controller
|
|||||||
'lowStockCount' => $invStats['lowStockCount'],
|
'lowStockCount' => $invStats['lowStockCount'],
|
||||||
'negativeCount' => $invStats['negativeCount'] ?? 0,
|
'negativeCount' => $invStats['negativeCount'] ?? 0,
|
||||||
'expiringCount' => $invStats['expiringCount'] ?? 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'] ?? [],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Modules/Core/Controllers/NotificationController.php
Normal file
41
app/Modules/Core/Controllers/NotificationController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,11 +188,13 @@ class RoleController extends Controller
|
|||||||
'vendors' => '廠商資料管理',
|
'vendors' => '廠商資料管理',
|
||||||
'purchase_orders' => '採購單管理',
|
'purchase_orders' => '採購單管理',
|
||||||
'goods_receipts' => '進貨單管理',
|
'goods_receipts' => '進貨單管理',
|
||||||
|
'delivery_notes' => '出貨單管理',
|
||||||
'recipes' => '配方管理',
|
'recipes' => '配方管理',
|
||||||
'production_orders' => '生產工單管理',
|
'production_orders' => '生產工單管理',
|
||||||
'utility_fees' => '公共事業費管理',
|
'utility_fees' => '公共事業費管理',
|
||||||
'accounting' => '會計報表',
|
'accounting' => '會計報表',
|
||||||
'sales_imports' => '銷售單匯入管理',
|
'sales_imports' => '銷售單匯入管理',
|
||||||
|
'store_requisitions' => '門市叫貨申請',
|
||||||
'users' => '使用者管理',
|
'users' => '使用者管理',
|
||||||
'roles' => '角色與權限',
|
'roles' => '角色與權限',
|
||||||
'system' => '系統管理',
|
'system' => '系統管理',
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ Route::post('/login', [LoginController::class, 'store']);
|
|||||||
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
|
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
|
// 通知
|
||||||
|
Route::post('/notifications/read-all', [\App\Modules\Core\Controllers\NotificationController::class, 'markAllAsRead'])->name('notifications.read-all');
|
||||||
|
Route::post('/notifications/{id}/read', [\App\Modules\Core\Controllers\NotificationController::class, 'markAsRead'])->name('notifications.read');
|
||||||
|
Route::get('/notifications/check', [\App\Modules\Core\Controllers\NotificationController::class, 'check'])->name('notifications.check');
|
||||||
|
|
||||||
// 儀表板 - 所有登入使用者皆可存取
|
// 儀表板 - 所有登入使用者皆可存取
|
||||||
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
|
||||||
|
|||||||
156
app/Modules/Integration/Actions/SyncOrderAction.php
Normal file
156
app/Modules/Integration/Actions/SyncOrderAction.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal file
152
app/Modules/Integration/Actions/SyncVendingOrderAction.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,107 +3,58 @@
|
|||||||
namespace App\Modules\Integration\Controllers;
|
namespace App\Modules\Integration\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use App\Modules\Integration\Requests\SyncOrderRequest;
|
||||||
use App\Modules\Integration\Models\SalesOrder;
|
use App\Modules\Integration\Actions\SyncOrderAction;
|
||||||
use App\Modules\Integration\Models\SalesOrderItem;
|
use Illuminate\Http\JsonResponse;
|
||||||
use App\Modules\Inventory\Services\InventoryService;
|
|
||||||
use App\Modules\Inventory\Models\Product;
|
|
||||||
use App\Modules\Inventory\Models\Warehouse;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class OrderSyncController extends Controller
|
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 {
|
try {
|
||||||
return DB::transaction(function () use ($request) {
|
// 所有驗證皆已透過 SyncOrderRequest 自動處理
|
||||||
// 1. Create Order
|
// 將通過驗證的資料交由 Action 處理(包含併發鎖、預先驗證、與資料庫異動)
|
||||||
$order = SalesOrder::create([
|
$result = $this->syncOrderAction->execute($request->validated());
|
||||||
'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(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Find Warehouse (Default to "銷售倉庫")
|
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalAmount = 0;
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
'order_id' => $result['order_id'] ?? null,
|
||||||
|
], $statusCode);
|
||||||
|
|
||||||
foreach ($request->items as $itemData) {
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||||
// Find product by external ID (Strict Check)
|
// 捕捉 Action 中拋出的預先驗證錯誤 (如查無商品、或鎖定逾時)
|
||||||
$product = Product::where('external_pos_id', $itemData['pos_product_id'])->first();
|
return response()->json([
|
||||||
|
'message' => 'Validation failed',
|
||||||
if (!$product) {
|
'errors' => $e->errors()
|
||||||
throw new \Exception("Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first.");
|
], 422);
|
||||||
}
|
|
||||||
|
|
||||||
$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 (\Exception $e) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ namespace App\Modules\Integration\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Modules\Inventory\Services\ProductService;
|
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class ProductSyncController extends Controller
|
class ProductSyncController extends Controller
|
||||||
{
|
{
|
||||||
protected $productService;
|
protected $productService;
|
||||||
|
|
||||||
public function __construct(ProductService $productService)
|
public function __construct(ProductServiceInterface $productService)
|
||||||
{
|
{
|
||||||
$this->productService = $productService;
|
$this->productService = $productService;
|
||||||
}
|
}
|
||||||
@@ -19,12 +19,17 @@ class ProductSyncController extends Controller
|
|||||||
public function upsert(Request $request)
|
public function upsert(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'external_pos_id' => 'required|string',
|
'external_pos_id' => 'required|string|max:255',
|
||||||
'name' => 'required|string',
|
'name' => 'required|string|max:255',
|
||||||
'price' => 'nullable|numeric',
|
'price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||||
'barcode' => 'nullable|string',
|
'barcode' => 'nullable|string|max:100',
|
||||||
'category' => 'nullable|string',
|
'category' => 'nullable|string|max:100',
|
||||||
'unit' => 'nullable|string',
|
'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',
|
'updated_at' => 'nullable|date',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -40,7 +45,9 @@ class ProductSyncController extends Controller
|
|||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
52
app/Modules/Integration/Controllers/SalesOrderController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ namespace App\Modules\Integration;
|
|||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Route;
|
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;
|
use App\Modules\Integration\Middleware\TenantIdentificationMiddleware;
|
||||||
|
|
||||||
class IntegrationServiceProvider extends ServiceProvider
|
class IntegrationServiceProvider extends ServiceProvider
|
||||||
@@ -11,10 +14,16 @@ class IntegrationServiceProvider extends ServiceProvider
|
|||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
|
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
|
||||||
|
$this->loadRoutesFrom(__DIR__ . '/Routes/web.php');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
|
||||||
|
|
||||||
// Register Middleware Alias
|
// 註冊 Middleware 別名
|
||||||
Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class);
|
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()
|
public function register()
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class SalesOrder extends Model
|
|||||||
'total_amount',
|
'total_amount',
|
||||||
'sold_at',
|
'sold_at',
|
||||||
'raw_payload',
|
'raw_payload',
|
||||||
|
'source',
|
||||||
|
'source_label',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
36
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
36
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
37
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Modules\Integration\Controllers\ProductSyncController;
|
use App\Modules\Integration\Controllers\ProductSyncController;
|
||||||
use App\Modules\Integration\Controllers\OrderSyncController;
|
use App\Modules\Integration\Controllers\OrderSyncController;
|
||||||
|
use App\Modules\Integration\Controllers\VendingOrderSyncController;
|
||||||
|
|
||||||
Route::prefix('api/v1/integration')
|
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 () {
|
->group(function () {
|
||||||
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
|
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
|
||||||
Route::post('orders', [OrderSyncController::class, 'store']);
|
Route::post('orders', [OrderSyncController::class, 'store']);
|
||||||
|
Route::post('vending/orders', [VendingOrderSyncController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|||||||
11
app/Modules/Integration/Routes/web.php
Normal file
11
app/Modules/Integration/Routes/web.php
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,4 +131,12 @@ interface InventoryServiceInterface
|
|||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function getDashboardStats(): array;
|
public function getDashboardStats(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
|
||||||
|
*
|
||||||
|
* @param string $warehouseName
|
||||||
|
* @return object
|
||||||
|
*/
|
||||||
|
public function findOrCreateWarehouseByName(string $warehouseName);
|
||||||
}
|
}
|
||||||
41
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal file
41
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
352
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal file
352
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal 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', '叫貨單已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
namespace App\Modules\Inventory\Controllers;
|
namespace App\Modules\Inventory\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Enums\WarehouseType;
|
||||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||||
use App\Modules\Inventory\Models\Warehouse;
|
use App\Modules\Inventory\Models\Warehouse;
|
||||||
use App\Modules\Inventory\Models\Inventory;
|
use App\Modules\Inventory\Models\Inventory;
|
||||||
use App\Modules\Inventory\Services\TransferService;
|
use App\Modules\Inventory\Services\TransferService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class TransferOrderController extends Controller
|
class TransferOrderController extends Controller
|
||||||
@@ -65,6 +67,7 @@ class TransferOrderController extends Controller
|
|||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'from_warehouse_id' => 'required_without:sourceWarehouseId|exists:warehouses,id',
|
'from_warehouse_id' => 'required_without:sourceWarehouseId|exists:warehouses,id',
|
||||||
'to_warehouse_id' => 'required_without:targetWarehouseId|exists:warehouses,id|different:from_warehouse_id',
|
'to_warehouse_id' => 'required_without:targetWarehouseId|exists:warehouses,id|different:from_warehouse_id',
|
||||||
|
'transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||||
'remarks' => 'nullable|string',
|
'remarks' => 'nullable|string',
|
||||||
'notes' => 'nullable|string',
|
'notes' => 'nullable|string',
|
||||||
'instant_post' => 'boolean',
|
'instant_post' => 'boolean',
|
||||||
@@ -75,20 +78,22 @@ class TransferOrderController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$remarks = $validated['remarks'] ?? $validated['notes'] ?? null;
|
$remarks = $validated['remarks'] ?? $validated['notes'] ?? null;
|
||||||
|
$transitWarehouseId = $validated['transit_warehouse_id'] ?? null;
|
||||||
|
|
||||||
$order = $this->transferService->createOrder(
|
$order = $this->transferService->createOrder(
|
||||||
$fromId,
|
$fromId,
|
||||||
$toId,
|
$toId,
|
||||||
$remarks,
|
$remarks,
|
||||||
auth()->id()
|
auth()->id(),
|
||||||
|
$transitWarehouseId
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($request->input('instant_post') === true) {
|
if ($request->input('instant_post') === true) {
|
||||||
try {
|
try {
|
||||||
$this->transferService->post($order, auth()->id());
|
$this->transferService->dispatch($order, auth()->id());
|
||||||
|
|
||||||
return redirect()->back()->with('success', '撥補成功,庫存已更新');
|
return redirect()->back()->with('success', '撥補成功,庫存已更新');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// 如果過帳失敗,雖然單據已建立,但應回報錯誤
|
|
||||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,22 +104,37 @@ class TransferOrderController extends Controller
|
|||||||
|
|
||||||
public function show(InventoryTransferOrder $order)
|
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 = [
|
$orderData = [
|
||||||
'id' => (string) $order->id,
|
'id' => (string) $order->id,
|
||||||
'doc_no' => $order->doc_no,
|
'doc_no' => $order->doc_no,
|
||||||
'from_warehouse_id' => (string) $order->from_warehouse_id,
|
'from_warehouse_id' => (string) $order->from_warehouse_id,
|
||||||
'from_warehouse_name' => $order->fromWarehouse->name,
|
'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_id' => (string) $order->to_warehouse_id,
|
||||||
'to_warehouse_name' => $order->toWarehouse->name,
|
'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,
|
'status' => $order->status,
|
||||||
'remarks' => $order->remarks,
|
'remarks' => $order->remarks,
|
||||||
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
||||||
'created_by' => $order->createdBy?->name,
|
'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) {
|
'items' => $order->items->map(function ($item) use ($order) {
|
||||||
// 獲取來源倉庫的當前庫存
|
|
||||||
$stock = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
$stock = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||||
->where('product_id', $item->product_id)
|
->where('product_id', $item->product_id)
|
||||||
->where('batch_number', $item->batch_number)
|
->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', [
|
return Inertia::render('Inventory/Transfer/Show', [
|
||||||
'order' => $orderData,
|
'order' => $orderData,
|
||||||
|
'transitWarehouses' => $transitWarehouses,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, InventoryTransferOrder $order)
|
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') {
|
if ($order->status !== 'draft') {
|
||||||
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
|
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;
|
$itemsChanged = false;
|
||||||
if ($request->has('items')) {
|
if ($request->has('items')) {
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
@@ -167,20 +220,21 @@ class TransferOrderController extends Controller
|
|||||||
$order->remarks = $request->input('remarks');
|
$order->remarks = $request->input('remarks');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($itemsChanged || $remarksChanged) {
|
if ($itemsChanged || $remarksChanged || $order->isDirty()) {
|
||||||
// [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌
|
|
||||||
$order->touch();
|
$order->touch();
|
||||||
$message = '儲存成功';
|
$message = '儲存成功';
|
||||||
} else {
|
} else {
|
||||||
$message = '資料未變更';
|
$message = '資料未變更';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 判斷是否需要過帳
|
// 3. 判斷是否需要出貨/過帳
|
||||||
if ($request->input('action') === 'post') {
|
if ($request->input('action') === 'post') {
|
||||||
try {
|
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')
|
return redirect()->route('inventory.transfer.index')
|
||||||
->with('success', '調撥單已過帳完成');
|
->with('success', $successMsg);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
return redirect()->back()->withErrors($e->errors());
|
return redirect()->back()->withErrors($e->errors());
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -113,9 +113,22 @@ class WarehouseController extends Controller
|
|||||||
'book_amount' => \App\Modules\Inventory\Models\Inventory::sum('total_value'),
|
'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', [
|
return Inertia::render('Warehouse/Index', [
|
||||||
'warehouses' => $warehouses,
|
'warehouses' => $warehouses,
|
||||||
'totals' => $totals,
|
'totals' => $totals,
|
||||||
|
'transitWarehouses' => $transitWarehouses,
|
||||||
'filters' => $request->only(['search', 'per_page']),
|
'filters' => $request->only(['search', 'per_page']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -130,6 +143,7 @@ class WarehouseController extends Controller
|
|||||||
'type' => 'required|string',
|
'type' => 'required|string',
|
||||||
'license_plate' => 'nullable|string|max:20',
|
'license_plate' => 'nullable|string|max:20',
|
||||||
'driver_name' => 'nullable|string|max:50',
|
'driver_name' => 'nullable|string|max:50',
|
||||||
|
'default_transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Warehouse::create($validated);
|
Warehouse::create($validated);
|
||||||
@@ -147,6 +161,7 @@ class WarehouseController extends Controller
|
|||||||
'type' => 'required|string',
|
'type' => 'required|string',
|
||||||
'license_plate' => 'nullable|string|max:20',
|
'license_plate' => 'nullable|string|max:20',
|
||||||
'driver_name' => 'nullable|string|max:50',
|
'driver_name' => 'nullable|string|max:50',
|
||||||
|
'default_transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$warehouse->update($validated);
|
$warehouse->update($validated);
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ namespace App\Modules\Inventory;
|
|||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||||
|
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||||
use App\Modules\Inventory\Services\InventoryService;
|
use App\Modules\Inventory\Services\InventoryService;
|
||||||
|
use App\Modules\Inventory\Services\ProductService;
|
||||||
|
|
||||||
class InventoryServiceProvider extends ServiceProvider
|
class InventoryServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
|
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
|
||||||
|
$this->app->bind(ProductServiceInterface::class, ProductService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
|
|||||||
@@ -106,16 +106,23 @@ class InventoryTransferOrder extends Model
|
|||||||
'doc_no',
|
'doc_no',
|
||||||
'from_warehouse_id',
|
'from_warehouse_id',
|
||||||
'to_warehouse_id',
|
'to_warehouse_id',
|
||||||
|
'transit_warehouse_id',
|
||||||
'status',
|
'status',
|
||||||
'remarks',
|
'remarks',
|
||||||
'posted_at',
|
'posted_at',
|
||||||
'created_by',
|
'created_by',
|
||||||
'updated_by',
|
'updated_by',
|
||||||
'posted_by',
|
'posted_by',
|
||||||
|
'dispatched_at',
|
||||||
|
'dispatched_by',
|
||||||
|
'received_at',
|
||||||
|
'received_by',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'posted_at' => 'datetime',
|
'posted_at' => 'datetime',
|
||||||
|
'dispatched_at' => 'datetime',
|
||||||
|
'received_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
@@ -163,8 +170,28 @@ class InventoryTransferOrder extends Model
|
|||||||
return $this->belongsTo(User::class, 'created_by');
|
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
|
public function postedBy(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'posted_by');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
147
app/Modules/Inventory/Models/StoreRequisition.php
Normal file
147
app/Modules/Inventory/Models/StoreRequisition.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Modules/Inventory/Models/StoreRequisitionItem.php
Normal file
41
app/Modules/Inventory/Models/StoreRequisitionItem.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ class Warehouse extends Model
|
|||||||
'description',
|
'description',
|
||||||
'license_plate',
|
'license_plate',
|
||||||
'driver_name',
|
'driver_name',
|
||||||
|
'default_transit_warehouse_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -50,7 +51,13 @@ class Warehouse extends Model
|
|||||||
return $this->hasMany(Inventory::class);
|
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
|
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use App\Modules\Inventory\Controllers\AdjustDocController;
|
|||||||
use App\Modules\Inventory\Controllers\InventoryReportController;
|
use App\Modules\Inventory\Controllers\InventoryReportController;
|
||||||
|
|
||||||
use App\Modules\Inventory\Controllers\StockQueryController;
|
use App\Modules\Inventory\Controllers\StockQueryController;
|
||||||
|
use App\Modules\Inventory\Controllers\InventoryAnalysisController;
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
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');
|
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::middleware('permission:products.view')->group(function () {
|
||||||
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||||
@@ -141,6 +147,32 @@ Route::middleware('auth')->group(function () {
|
|||||||
->middleware('permission:inventory_transfer.view')
|
->middleware('permission:inventory_transfer.view')
|
||||||
->name('inventory.transfer.template');
|
->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)
|
// 進貨單 (Goods Receipts)
|
||||||
Route::middleware('permission:goods_receipts.view')->group(function () {
|
Route::middleware('permission:goods_receipts.view')->group(function () {
|
||||||
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
|
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
|||||||
use App\Modules\Inventory\Models\Inventory;
|
use App\Modules\Inventory\Models\Inventory;
|
||||||
use App\Modules\Inventory\Models\Warehouse;
|
use App\Modules\Inventory\Models\Warehouse;
|
||||||
use App\Modules\Inventory\Models\Product;
|
use App\Modules\Inventory\Models\Product;
|
||||||
|
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class InventoryService implements InventoryServiceInterface
|
class InventoryService implements InventoryServiceInterface
|
||||||
@@ -584,8 +585,35 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
'negativeCount' => $negativeCount,
|
'negativeCount' => $negativeCount,
|
||||||
'expiringCount' => $expiringCount,
|
'expiringCount' => $expiringCount,
|
||||||
'totalInventoryQuantity' => Inventory::sum('quantity'),
|
'totalInventoryQuantity' => Inventory::sum('quantity'),
|
||||||
|
'totalInventoryValue' => Inventory::sum('total_value'),
|
||||||
|
'pendingTransferCount' => InventoryTransferOrder::whereIn('status', ['draft', 'dispatched'])->count(), // 新增:待處理調撥單
|
||||||
'abnormalItems' => $abnormalItems,
|
'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',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Modules\Inventory\Services;
|
namespace App\Modules\Inventory\Services;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||||
use App\Modules\Inventory\Models\Product;
|
use App\Modules\Inventory\Models\Product;
|
||||||
use App\Modules\Inventory\Models\Category;
|
use App\Modules\Inventory\Models\Category;
|
||||||
use App\Modules\Inventory\Models\Unit;
|
use App\Modules\Inventory\Models\Unit;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class ProductService
|
class ProductService implements ProductServiceInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Upsert product from external POS source.
|
* Upsert product from external POS source.
|
||||||
@@ -40,13 +41,20 @@ class ProductService
|
|||||||
$product->barcode = $data['barcode'] ?? $product->barcode;
|
$product->barcode = $data['barcode'] ?? $product->barcode;
|
||||||
$product->price = $data['price'] ?? 0;
|
$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)
|
// Generate Code if missing (use code or external_id)
|
||||||
if (empty($product->code)) {
|
if (empty($product->code)) {
|
||||||
$product->code = $data['code'] ?? $product->external_pos_id;
|
$product->code = $data['code'] ?? $product->external_pos_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Category (Default: 未分類)
|
// Handle Category — 每次同步都更新(若有傳入)
|
||||||
if (empty($product->category_id)) {
|
if (!empty($data['category']) || empty($product->category_id)) {
|
||||||
$categoryName = $data['category'] ?? '未分類';
|
$categoryName = $data['category'] ?? '未分類';
|
||||||
$category = Category::firstOrCreate(
|
$category = Category::firstOrCreate(
|
||||||
['name' => $categoryName],
|
['name' => $categoryName],
|
||||||
@@ -55,8 +63,8 @@ class ProductService
|
|||||||
$product->category_id = $category->id;
|
$product->category_id = $category->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Base Unit (Default: 個)
|
// Handle Base Unit — 每次同步都更新(若有傳入)
|
||||||
if (empty($product->base_unit_id)) {
|
if (!empty($data['unit']) || empty($product->base_unit_id)) {
|
||||||
$unitName = $data['unit'] ?? '個';
|
$unitName = $data['unit'] ?? '個';
|
||||||
$unit = Unit::firstOrCreate(['name' => $unitName]);
|
$unit = Unit::firstOrCreate(['name' => $unitName]);
|
||||||
$product->base_unit_id = $unit->id;
|
$product->base_unit_id = $unit->id;
|
||||||
@@ -69,4 +77,37 @@ class ProductService
|
|||||||
return $product;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
247
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal file
247
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([
|
return InventoryTransferOrder::create([
|
||||||
'from_warehouse_id' => $fromWarehouseId,
|
'from_warehouse_id' => $fromWarehouseId,
|
||||||
'to_warehouse_id' => $toWarehouseId,
|
'to_warehouse_id' => $toWarehouseId,
|
||||||
|
'transit_warehouse_id' => $transitWarehouseId,
|
||||||
'status' => 'draft',
|
'status' => 'draft',
|
||||||
'remarks' => $remarks,
|
'remarks' => $remarks,
|
||||||
'created_by' => $userId,
|
'created_by' => $userId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新調撥單明細
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
|
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
|
||||||
*/
|
*/
|
||||||
public function updateItems(InventoryTransferOrder $order, array $itemsData): bool
|
public function updateItems(InventoryTransferOrder $order, array $itemsData): bool
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($order, $itemsData) {
|
return DB::transaction(function () use ($order, $itemsData) {
|
||||||
// 1. 準備舊資料索引 (Key: product_id . '_' . batch_number)
|
|
||||||
$oldItemsMap = $order->items->mapWithKeys(function ($item) {
|
$oldItemsMap = $order->items->mapWithKeys(function ($item) {
|
||||||
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
||||||
return [$key => $item];
|
return [$key => $item];
|
||||||
@@ -46,13 +51,7 @@ class TransferService
|
|||||||
'updated' => [],
|
'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();
|
$order->items()->delete();
|
||||||
|
|
||||||
$newItemsKeys = [];
|
$newItemsKeys = [];
|
||||||
|
|
||||||
foreach ($itemsData as $data) {
|
foreach ($itemsData as $data) {
|
||||||
@@ -66,13 +65,10 @@ class TransferService
|
|||||||
'position' => $data['position'] ?? null,
|
'position' => $data['position'] ?? null,
|
||||||
'notes' => $data['notes'] ?? null,
|
'notes' => $data['notes'] ?? null,
|
||||||
]);
|
]);
|
||||||
// Eager load product for name
|
|
||||||
$item->load('product');
|
$item->load('product');
|
||||||
|
|
||||||
// 比對邏輯
|
|
||||||
if ($oldItemsMap->has($key)) {
|
if ($oldItemsMap->has($key)) {
|
||||||
$oldItem = $oldItemsMap->get($key);
|
$oldItem = $oldItemsMap->get($key);
|
||||||
// 檢查數值是否有變動
|
|
||||||
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
||||||
$oldItem->notes !== ($data['notes'] ?? null) ||
|
$oldItem->notes !== ($data['notes'] ?? null) ||
|
||||||
$oldItem->position !== ($data['position'] ?? null)) {
|
$oldItem->position !== ($data['position'] ?? null)) {
|
||||||
@@ -92,7 +88,6 @@ class TransferService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 新增 (使用者需求:顯示為更新,從 0 -> X)
|
|
||||||
$diff['updated'][] = [
|
$diff['updated'][] = [
|
||||||
'product_name' => $item->product->name,
|
'product_name' => $item->product->name,
|
||||||
'old' => [
|
'old' => [
|
||||||
@@ -107,7 +102,6 @@ class TransferService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 處理被移除的項目
|
|
||||||
foreach ($oldItemsMap as $key => $oldItem) {
|
foreach ($oldItemsMap as $key => $oldItem) {
|
||||||
if (!in_array($key, $newItemsKeys)) {
|
if (!in_array($key, $newItemsKeys)) {
|
||||||
$diff['removed'][] = [
|
$diff['removed'][] = [
|
||||||
@@ -120,7 +114,6 @@ class TransferService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 將 Diff 注入到 Model 的暫存屬性中
|
|
||||||
$hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
|
$hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
|
||||||
if ($hasChanged) {
|
if ($hasChanged) {
|
||||||
$order->activityProperties['items_diff'] = $diff;
|
$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');
|
$order->load('items.product');
|
||||||
|
|
||||||
DB::transaction(function () use ($order, $userId) {
|
DB::transaction(function () use ($order, $userId) {
|
||||||
$fromWarehouse = $order->fromWarehouse;
|
$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) {
|
foreach ($order->items as $item) {
|
||||||
if ($item->quantity <= 0) continue;
|
if ($item->quantity <= 0) continue;
|
||||||
@@ -162,46 +163,41 @@ class TransferService
|
|||||||
$oldSourceQty = $sourceInventory->quantity;
|
$oldSourceQty = $sourceInventory->quantity;
|
||||||
$newSourceQty = $oldSourceQty - $item->quantity;
|
$newSourceQty = $oldSourceQty - $item->quantity;
|
||||||
|
|
||||||
// 儲存庫存快照
|
|
||||||
$item->update(['snapshot_quantity' => $oldSourceQty]);
|
$item->update(['snapshot_quantity' => $oldSourceQty]);
|
||||||
|
|
||||||
$sourceInventory->quantity = $newSourceQty;
|
$sourceInventory->quantity = $newSourceQty;
|
||||||
// 更新總值 (假設成本不變)
|
|
||||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
||||||
$sourceInventory->save();
|
$sourceInventory->save();
|
||||||
|
|
||||||
// 記錄來源交易
|
|
||||||
$sourceInventory->transactions()->create([
|
$sourceInventory->transactions()->create([
|
||||||
'type' => '調撥出庫',
|
'type' => $outType,
|
||||||
'quantity' => -$item->quantity,
|
'quantity' => -$item->quantity,
|
||||||
'unit_cost' => $sourceInventory->unit_cost,
|
'unit_cost' => $sourceInventory->unit_cost,
|
||||||
'balance_before' => $oldSourceQty,
|
'balance_before' => $oldSourceQty,
|
||||||
'balance_after' => $newSourceQty,
|
'balance_after' => $newSourceQty,
|
||||||
'reason' => "調撥單 {$order->doc_no} 至 {$toWarehouse->name}",
|
'reason' => "調撥單 {$order->doc_no} 至 {$targetWarehouse->name}",
|
||||||
'actual_time' => now(),
|
'actual_time' => now(),
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. 處理目的倉 (增加)
|
// 2. 處理目的倉/在途倉 (增加)
|
||||||
$targetInventory = Inventory::firstOrCreate(
|
$targetInventory = Inventory::firstOrCreate(
|
||||||
[
|
[
|
||||||
'warehouse_id' => $order->to_warehouse_id,
|
'warehouse_id' => $targetWarehouseId,
|
||||||
'product_id' => $item->product_id,
|
'product_id' => $item->product_id,
|
||||||
'batch_number' => $item->batch_number,
|
'batch_number' => $item->batch_number,
|
||||||
'location' => $item->position, // 同步貨道至庫存位置
|
'location' => $hasTransit ? null : ($item->position ?? null),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'quantity' => 0,
|
'quantity' => 0,
|
||||||
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
|
'unit_cost' => $sourceInventory->unit_cost,
|
||||||
'total_value' => 0,
|
'total_value' => 0,
|
||||||
// 繼承其他屬性
|
|
||||||
'expiry_date' => $sourceInventory->expiry_date,
|
'expiry_date' => $sourceInventory->expiry_date,
|
||||||
'quality_status' => $sourceInventory->quality_status,
|
'quality_status' => $sourceInventory->quality_status,
|
||||||
'origin_country' => $sourceInventory->origin_country,
|
'origin_country' => $sourceInventory->origin_country,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 若是新建立的,且成本為0,確保繼承成本
|
|
||||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
||||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||||
}
|
}
|
||||||
@@ -213,9 +209,8 @@ class TransferService
|
|||||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
||||||
$targetInventory->save();
|
$targetInventory->save();
|
||||||
|
|
||||||
// 記錄目的交易
|
|
||||||
$targetInventory->transactions()->create([
|
$targetInventory->transactions()->create([
|
||||||
'type' => '調撥入庫',
|
'type' => $inType,
|
||||||
'quantity' => $item->quantity,
|
'quantity' => $item->quantity,
|
||||||
'unit_cost' => $targetInventory->unit_cost,
|
'unit_cost' => $targetInventory->unit_cost,
|
||||||
'balance_before' => $oldTargetQty,
|
'balance_before' => $oldTargetQty,
|
||||||
@@ -226,28 +221,126 @@ class TransferService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 準備品項快照供日誌使用
|
if ($hasTransit) {
|
||||||
$itemsSnapshot = $order->items->map(function($item) {
|
$order->status = 'dispatched';
|
||||||
return [
|
$order->dispatched_at = now();
|
||||||
'product_name' => $item->product->name,
|
$order->dispatched_by = $userId;
|
||||||
'old' => [
|
} else {
|
||||||
'quantity' => (float)$item->quantity,
|
$order->status = 'completed';
|
||||||
'notes' => $item->notes,
|
$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,
|
'quantity' => 0,
|
||||||
'notes' => $item->notes,
|
'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->status = 'completed';
|
||||||
$order->posted_at = now();
|
$order->posted_at = now();
|
||||||
$order->posted_by = $userId;
|
$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
|
public function void(InventoryTransferOrder $order, int $userId): void
|
||||||
{
|
{
|
||||||
if ($order->status !== 'draft') {
|
if ($order->status !== 'draft') {
|
||||||
|
|||||||
247
app/Modules/Inventory/Services/TurnoverService.php
Normal file
247
app/Modules/Inventory/Services/TurnoverService.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,11 @@ class PurchaseOrder extends Model
|
|||||||
return $this->belongsTo(Vendor::class);
|
return $this->belongsTo(Vendor::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Modules\Core\Models\User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
54
app/Modules/Procurement/Notifications/NewPurchaseOrder.php
Normal file
54
app/Modules/Procurement/Notifications/NewPurchaseOrder.php
Normal 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]), // 暫時導向列表並搜尋,若有詳情頁可改
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Modules/Procurement/Observers/PurchaseOrderObserver.php
Normal file
31
app/Modules/Procurement/Observers/PurchaseOrderObserver.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ use Illuminate\Support\ServiceProvider;
|
|||||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||||
use App\Modules\Procurement\Services\ProcurementService;
|
use App\Modules\Procurement\Services\ProcurementService;
|
||||||
|
|
||||||
|
|
||||||
|
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||||
|
use App\Modules\Procurement\Observers\PurchaseOrderObserver;
|
||||||
|
|
||||||
class ProcurementServiceProvider extends ServiceProvider
|
class ProcurementServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
@@ -15,6 +19,6 @@ class ProcurementServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
PurchaseOrder::observe(PurchaseOrderObserver::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class ProcurementService implements ProcurementServiceInterface
|
|||||||
return [
|
return [
|
||||||
'vendorsCount' => \App\Modules\Procurement\Models\Vendor::count(),
|
'vendorsCount' => \App\Modules\Procurement\Models\Vendor::count(),
|
||||||
'purchaseOrdersCount' => PurchaseOrder::count(),
|
'purchaseOrdersCount' => PurchaseOrder::count(),
|
||||||
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
|
'pendingOrdersCount' => PurchaseOrder::whereIn('status', ['approved', 'partial'])->count(), // 改為真正待進貨的狀態
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,23 +106,16 @@ class ProductionOrderController extends Controller
|
|||||||
{
|
{
|
||||||
$status = $request->input('status', 'draft');
|
$status = $request->input('status', 'draft');
|
||||||
|
|
||||||
$baseRules = [
|
$rules = [
|
||||||
'product_id' => 'required',
|
'product_id' => 'required',
|
||||||
'output_batch_number' => 'required|string|max:50',
|
|
||||||
'status' => 'nullable|in:draft,completed',
|
'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);
|
$validated = $request->validate($rules);
|
||||||
|
|
||||||
DB::transaction(function () use ($validated, $request, $status) {
|
DB::transaction(function () use ($validated, $request, $status) {
|
||||||
@@ -132,12 +125,12 @@ class ProductionOrderController extends Controller
|
|||||||
'product_id' => $validated['product_id'],
|
'product_id' => $validated['product_id'],
|
||||||
'warehouse_id' => $validated['warehouse_id'] ?? null,
|
'warehouse_id' => $validated['warehouse_id'] ?? null,
|
||||||
'output_quantity' => $validated['output_quantity'] ?? 0,
|
'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,
|
'output_box_count' => $request->output_box_count,
|
||||||
'production_date' => $validated['production_date'] ?? now()->toDateString(),
|
'production_date' => $request->production_date,
|
||||||
'expiry_date' => $request->expiry_date,
|
'expiry_date' => $request->expiry_date,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'status' => $status,
|
'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿
|
||||||
'remark' => $request->remark,
|
'remark' => $request->remark,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -155,43 +148,12 @@ class ProductionOrderController extends Controller
|
|||||||
'quantity_used' => $item['quantity_used'] ?? 0,
|
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||||
'unit_id' => $item['unit_id'] ?? null,
|
'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')
|
return redirect()->route('production-orders.index')
|
||||||
->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存');
|
->with('success', '生產單草稿已建立');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,7 +166,9 @@ class ProductionOrderController extends Controller
|
|||||||
if ($productionOrder->product) {
|
if ($productionOrder->product) {
|
||||||
$productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first();
|
$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);
|
$productionOrder->user = $this->coreService->getUser($productionOrder->user_id);
|
||||||
|
|
||||||
// 手動水和明細資料
|
// 手動水和明細資料
|
||||||
@@ -214,7 +178,7 @@ class ProductionOrderController extends Controller
|
|||||||
// 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor
|
// 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor
|
||||||
$inventories = $this->inventoryService->getInventoriesByIds(
|
$inventories = $this->inventoryService->getInventoriesByIds(
|
||||||
$inventoryIds,
|
$inventoryIds,
|
||||||
['product.baseUnit']
|
['product.baseUnit', 'warehouse']
|
||||||
)->keyBy('id');
|
)->keyBy('id');
|
||||||
|
|
||||||
// 手動載入 Purchase Orders
|
// 手動載入 Purchase Orders
|
||||||
@@ -238,6 +202,7 @@ class ProductionOrderController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('Production/Show', [
|
return Inertia::render('Production/Show', [
|
||||||
'productionOrder' => $productionOrder,
|
'productionOrder' => $productionOrder,
|
||||||
|
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +273,9 @@ class ProductionOrderController extends Controller
|
|||||||
|
|
||||||
// 基本水和
|
// 基本水和
|
||||||
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
|
$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;
|
$items = $productionOrder->items;
|
||||||
@@ -346,39 +313,27 @@ class ProductionOrderController extends Controller
|
|||||||
$status = $request->input('status', 'draft');
|
$status = $request->input('status', 'draft');
|
||||||
|
|
||||||
// 基礎驗證規則
|
// 基礎驗證規則
|
||||||
$baseRules = [
|
$rules = [
|
||||||
'product_id' => 'required|exists:products,id',
|
'product_id' => 'required',
|
||||||
'output_batch_number' => 'required|string|max:50',
|
|
||||||
'status' => 'required|in:draft,completed',
|
|
||||||
'remark' => 'nullable|string',
|
'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);
|
$validated = $request->validate($rules);
|
||||||
|
|
||||||
DB::transaction(function () use ($validated, $request, $status, $productionOrder) {
|
DB::transaction(function () use ($validated, $request, $productionOrder) {
|
||||||
$productionOrder->update([
|
$productionOrder->update([
|
||||||
'product_id' => $validated['product_id'],
|
'product_id' => $validated['product_id'],
|
||||||
'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id,
|
'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id,
|
||||||
'output_quantity' => $validated['output_quantity'] ?? 0,
|
'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,
|
'output_box_count' => $request->output_box_count,
|
||||||
'production_date' => $validated['production_date'] ?? now()->toDateString(),
|
'production_date' => $request->production_date ?? $productionOrder->production_date,
|
||||||
'expiry_date' => $request->expiry_date,
|
'expiry_date' => $request->expiry_date ?? $productionOrder->expiry_date,
|
||||||
'status' => $status,
|
|
||||||
'remark' => $request->remark,
|
'remark' => $request->remark,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -398,38 +353,8 @@ class ProductionOrderController extends Controller
|
|||||||
'quantity_used' => $item['quantity_used'] ?? 0,
|
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||||
'unit_id' => $item['unit_id'] ?? null,
|
'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')
|
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)
|
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) {
|
DB::transaction(function () use ($productionOrder) {
|
||||||
// 紀錄刪除動作 (需在刪除前或使用軟刪除)
|
$productionOrder->items()->delete();
|
||||||
|
$productionOrder->delete();
|
||||||
|
|
||||||
activity()
|
activity()
|
||||||
->performedOn($productionOrder)
|
->performedOn($productionOrder)
|
||||||
->causedBy(auth()->user())
|
->causedBy(auth()->user())
|
||||||
->log('deleted');
|
->log('deleted');
|
||||||
|
|
||||||
$productionOrder->items()->delete();
|
|
||||||
$productionOrder->delete();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect()->route('production-orders.index')->with('success', '生產單已刪除');
|
return redirect()->route('production-orders.index')->with('success', '生產單已刪除');
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ class ProductionOrder extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory, LogsActivity;
|
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 = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
'product_id',
|
'product_id',
|
||||||
@@ -25,6 +33,51 @@ class ProductionOrder extends Model
|
|||||||
'remark',
|
'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 = [
|
protected $casts = [
|
||||||
'production_date' => 'date',
|
'production_date' => 'date',
|
||||||
'expiry_date' => 'date',
|
'expiry_date' => 'date',
|
||||||
@@ -59,13 +112,17 @@ class ProductionOrder extends Model
|
|||||||
|
|
||||||
public static function generateCode()
|
public static function generateCode()
|
||||||
{
|
{
|
||||||
$prefix = 'PO' . now()->format('Ymd');
|
$prefix = 'PRO-' . now()->format('Ymd') . '-';
|
||||||
$lastOrder = self::where('code', 'like', $prefix . '%')->latest()->first();
|
$lastOrder = self::where('code', 'like', $prefix . '%')
|
||||||
|
->lockForUpdate()
|
||||||
|
->orderBy('code', 'desc')
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($lastOrder) {
|
if ($lastOrder) {
|
||||||
$lastSequence = intval(substr($lastOrder->code, -3));
|
$lastSequence = intval(substr($lastOrder->code, -2));
|
||||||
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
|
||||||
} else {
|
} else {
|
||||||
$sequence = '001';
|
$sequence = '01';
|
||||||
}
|
}
|
||||||
return $prefix . $sequence;
|
return $prefix . $sequence;
|
||||||
}
|
}
|
||||||
@@ -74,4 +131,9 @@ class ProductionOrder extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(ProductionOrderItem::class);
|
return $this->hasMany(ProductionOrderItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Modules\Core\Models\User::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
app/Modules/Production/Notifications/NewProductionOrder.php
Normal file
54
app/Modules/Production/Notifications/NewProductionOrder.php
Normal 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]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Modules/Production/Observers/ProductionOrderObserver.php
Normal file
26
app/Modules/Production/Observers/ProductionOrderObserver.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Modules/Production/ProductionServiceProvider.php
Normal file
20
app/Modules/Production/ProductionServiceProvider.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/production-orders/{productionOrder}/edit', [ProductionOrderController::class, 'edit'])->name('production-orders.edit');
|
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::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
|
// 生產管理 API
|
||||||
|
|||||||
99
app/Modules/System/Controllers/ManualController.php
Normal file
99
app/Modules/System/Controllers/ManualController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/Modules/System/Routes/web.php
Normal file
9
app/Modules/System/Routes/web.php
Normal 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');
|
||||||
|
});
|
||||||
@@ -24,6 +24,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->web(prepend: [
|
$middleware->web(prepend: [
|
||||||
\App\Http\Middleware\UniversalTenancy::class,
|
\App\Http\Middleware\UniversalTenancy::class,
|
||||||
]);
|
]);
|
||||||
|
$middleware->api(prepend: [
|
||||||
|
\App\Http\Middleware\UniversalTenancy::class,
|
||||||
|
]);
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ return [
|
|||||||
|
|
||||||
'name' => env('APP_NAME', 'Laravel'),
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
|
'version' => env('APP_VERSION', '1.0.0'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application Environment
|
| Application Environment
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -55,6 +55,8 @@ class PermissionSeeder extends Seeder
|
|||||||
'inventory_transfer.create' => '建立',
|
'inventory_transfer.create' => '建立',
|
||||||
'inventory_transfer.edit' => '編輯',
|
'inventory_transfer.edit' => '編輯',
|
||||||
'inventory_transfer.delete' => '刪除',
|
'inventory_transfer.delete' => '刪除',
|
||||||
|
'inventory_transfer.dispatch' => '確認出貨',
|
||||||
|
'inventory_transfer.receive' => '確認收貨',
|
||||||
|
|
||||||
// 庫存報表
|
// 庫存報表
|
||||||
'inventory_report.view' => '檢視',
|
'inventory_report.view' => '檢視',
|
||||||
@@ -77,6 +79,8 @@ class PermissionSeeder extends Seeder
|
|||||||
'production_orders.create' => '建立',
|
'production_orders.create' => '建立',
|
||||||
'production_orders.edit' => '編輯',
|
'production_orders.edit' => '編輯',
|
||||||
'production_orders.delete' => '刪除',
|
'production_orders.delete' => '刪除',
|
||||||
|
'production_orders.approve' => '核准',
|
||||||
|
'production_orders.cancel' => '作廢',
|
||||||
|
|
||||||
// 配方管理
|
// 配方管理
|
||||||
'recipes.view' => '檢視',
|
'recipes.view' => '檢視',
|
||||||
@@ -127,6 +131,17 @@ class PermissionSeeder extends Seeder
|
|||||||
'sales_imports.create' => '建立',
|
'sales_imports.create' => '建立',
|
||||||
'sales_imports.confirm' => '確認',
|
'sales_imports.confirm' => '確認',
|
||||||
'sales_imports.delete' => '刪除',
|
'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) {
|
foreach ($permissions as $name => $displayName) {
|
||||||
@@ -156,7 +171,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'inventory.view', 'inventory.view_cost', 'inventory.delete',
|
'inventory.view', 'inventory.view_cost', 'inventory.delete',
|
||||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.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_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',
|
'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',
|
||||||
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.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',
|
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
|
||||||
'accounting.view', 'accounting.export',
|
'accounting.view', 'accounting.export',
|
||||||
'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete',
|
'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 管理庫存與倉庫
|
// warehouse-manager 管理庫存與倉庫
|
||||||
@@ -178,12 +195,14 @@ class PermissionSeeder extends Seeder
|
|||||||
'inventory.view', 'inventory.delete',
|
'inventory.view', 'inventory.delete',
|
||||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.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_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',
|
'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',
|
||||||
'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',
|
'production_orders.view', 'production_orders.create', 'production_orders.edit',
|
||||||
'warehouses.view', 'warehouses.create', 'warehouses.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 管理採購與供應商
|
// purchaser 管理採購與供應商
|
||||||
@@ -206,6 +225,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'utility_fees.view',
|
'utility_fees.view',
|
||||||
'inventory_report.view',
|
'inventory_report.view',
|
||||||
'accounting.view',
|
'accounting.view',
|
||||||
|
'sales_orders.view',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 將現有使用者設為 super-admin(如果存在的話)
|
// 將現有使用者設為 super-admin(如果存在的話)
|
||||||
|
|||||||
@@ -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 \
|
&& 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 update \
|
||||||
&& apt-get install -y \
|
&& apt-get install -y \
|
||||||
libgd3 \
|
libgd3 \
|
||||||
php8.5-cli \
|
php8.5-cli \
|
||||||
php8.5-dev \
|
php8.5-dev \
|
||||||
php8.5-pgsql \
|
php8.5-pgsql \
|
||||||
php8.5-sqlite3 \
|
php8.5-sqlite3 \
|
||||||
php8.5-gd \
|
php8.5-gd \
|
||||||
php8.5-curl \
|
php8.5-curl \
|
||||||
php8.5-mongodb \
|
php8.5-mongodb \
|
||||||
php8.5-imap \
|
php8.5-imap \
|
||||||
php8.5-mysql \
|
php8.5-mysql \
|
||||||
php8.5-mbstring \
|
php8.5-mbstring \
|
||||||
php8.5-xml \
|
php8.5-xml \
|
||||||
php8.5-zip \
|
php8.5-zip \
|
||||||
php8.5-bcmath \
|
php8.5-bcmath \
|
||||||
php8.5-soap \
|
php8.5-soap \
|
||||||
php8.5-intl \
|
php8.5-intl \
|
||||||
php8.5-readline \
|
php8.5-readline \
|
||||||
php8.5-ldap \
|
php8.5-ldap \
|
||||||
php8.5-msgpack \
|
php8.5-msgpack \
|
||||||
php8.5-igbinary \
|
php8.5-igbinary \
|
||||||
php8.5-redis \
|
php8.5-redis \
|
||||||
#php8.5-swoole \
|
#php8.5-swoole \
|
||||||
php8.5-memcached \
|
php8.5-memcached \
|
||||||
php8.5-pcov \
|
php8.5-pcov \
|
||||||
php8.5-imagick \
|
php8.5-imagick \
|
||||||
php8.5-xdebug \
|
php8.5-xdebug \
|
||||||
&& curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
|
&& 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 \
|
&& 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 \
|
&& 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 \
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ stdout_logfile=/dev/stdout
|
|||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
|||||||
1925
package-lock.json
generated
1925
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/lodash": "^4.17.21",
|
"@types/lodash": "^4.17.21",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -49,6 +50,9 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@theme {
|
@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";
|
--font-sans: 'Noto Sans TC', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
|||||||
190
resources/js/Components/Header/NotificationDropdown.tsx
Normal file
190
resources/js/Components/Header/NotificationDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Badge } from "@/Components/ui/badge";
|
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||||
|
|
||||||
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
|
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" }> = {
|
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: StatusVariant }> = {
|
||||||
processing: { label: "處理中", variant: "warning" },
|
processing: { label: "處理中", variant: "info" },
|
||||||
completed: { label: "已完成", variant: "success" },
|
completed: { label: "已完成", variant: "success" },
|
||||||
cancelled: { label: "已取消", variant: "destructive" },
|
cancelled: { label: "已取消", variant: "destructive" },
|
||||||
};
|
};
|
||||||
@@ -19,28 +19,9 @@ export default function GoodsReceiptStatusBadge({
|
|||||||
}: GoodsReceiptStatusBadgeProps) {
|
}: GoodsReceiptStatusBadgeProps) {
|
||||||
const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
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 (
|
return (
|
||||||
<Badge
|
<StatusBadge variant={config.variant} className={className}>
|
||||||
variant="outline"
|
|
||||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full border ${badgeClass}`}
|
|
||||||
>
|
|
||||||
{config.label}
|
{config.label}
|
||||||
</Badge>
|
</StatusBadge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AlertTriangle, Edit, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
|
import { Edit, ChevronDown, ChevronRight, Package } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -14,14 +14,14 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/Components/ui/collapsible";
|
} from "@/Components/ui/collapsible";
|
||||||
import { WarehouseInventory, SafetyStockSetting } from "@/types/warehouse";
|
import { WarehouseInventory, SafetyStockSetting } from "@/types/warehouse";
|
||||||
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
|
import { getSafetyStockStatus } from "@/utils/inventory";
|
||||||
import { formatDate } from "@/utils/format";
|
import { formatDate } from "@/utils/format";
|
||||||
|
|
||||||
export type InventoryItemWithId = WarehouseInventory & { inventoryId: string };
|
export type InventoryItemWithId = WarehouseInventory & { inventoryId: string };
|
||||||
@@ -74,31 +74,28 @@ export default function InventoryTable({
|
|||||||
|
|
||||||
// 獲取狀態徽章
|
// 獲取狀態徽章
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
if (status === '正常') {
|
||||||
case "正常":
|
return (
|
||||||
return (
|
<StatusBadge variant="success">
|
||||||
<Badge className="bg-green-100 text-green-700 border-green-300">
|
庫存充足
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
</StatusBadge>
|
||||||
正常
|
);
|
||||||
</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="warning">
|
||||||
|
低於安全存量
|
||||||
|
</StatusBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === '低於') {
|
||||||
|
return (
|
||||||
|
<StatusBadge variant="destructive">
|
||||||
|
嚴重短缺
|
||||||
|
</StatusBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,12 +105,12 @@ export default function InventoryTable({
|
|||||||
(sum, item) => sum + item.quantity,
|
(sum, item) => sum + item.quantity,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
// 計算安全庫存狀態
|
// 計算安全庫存狀態
|
||||||
const status = group.safetySetting
|
const status = group.safetySetting
|
||||||
? getSafetyStockStatus(totalQuantity, group.safetySetting.safetyStock)
|
? getSafetyStockStatus(totalQuantity, group.safetySetting.safetyStock)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isLowStock = status === "低於";
|
const isLowStock = status === "低於";
|
||||||
const isExpanded = expandedProducts.has(group.productId);
|
const isExpanded = expandedProducts.has(group.productId);
|
||||||
const hasInventory = group.items.length > 0;
|
const hasInventory = group.items.length > 0;
|
||||||
@@ -127,10 +124,9 @@ export default function InventoryTable({
|
|||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
{/* 商品標題 - 可點擊折疊 */}
|
{/* 商品標題 - 可點擊折疊 */}
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${
|
className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${isLowStock ? "bg-red-50" : "bg-gray-50"
|
||||||
isLowStock ? "bg-red-50" : "bg-gray-50"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -164,9 +160,9 @@ export default function InventoryTable({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!group.safetySetting && (
|
{!group.safetySetting && (
|
||||||
<Badge variant="outline" className="text-gray-500">
|
<StatusBadge variant="neutral">
|
||||||
未設定
|
未設定
|
||||||
</Badge>
|
</StatusBadge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import { Button } from "@/Components/ui/button";
|
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 { Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown, Eye } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -122,15 +122,15 @@ export default function ProductTable({
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-grey-0">{product.name}</span>
|
<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>
|
</div>
|
||||||
<span className="text-xs text-gray-400 font-mono">代號: {product.code}</span>
|
<span className="text-xs text-gray-400 font-mono">代號: {product.code}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">
|
<StatusBadge variant="neutral">
|
||||||
{product.category?.name || '-'}
|
{product.category?.name || '-'}
|
||||||
</Badge>
|
</StatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
|
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -163,9 +163,9 @@ export default function ProductTable({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{product.is_active ? (
|
{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>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* 採購單狀態標籤組件
|
* 採購單狀態標籤組件
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
import { PurchaseOrderStatus } from "@/types/purchase-order";
|
import { PurchaseOrderStatus } from "@/types/purchase-order";
|
||||||
import { STATUS_CONFIG } from "@/constants/purchase-order";
|
import { STATUS_CONFIG } from "@/constants/purchase-order";
|
||||||
|
|
||||||
@@ -15,14 +15,11 @@ export default function PurchaseOrderStatusBadge({
|
|||||||
status,
|
status,
|
||||||
className,
|
className,
|
||||||
}: PurchaseOrderStatusBadgeProps) {
|
}: PurchaseOrderStatusBadgeProps) {
|
||||||
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
const config = STATUS_CONFIG[status] || { label: "未知", variant: "neutral" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<StatusBadge variant={config.variant} className={className}>
|
||||||
variant={config.variant}
|
|
||||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full`}
|
|
||||||
>
|
|
||||||
{config.label}
|
{config.label}
|
||||||
</Badge>
|
</StatusBadge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Input } from "@/Components/ui/input";
|
|||||||
import { Label } from "@/Components/ui/label";
|
import { Label } from "@/Components/ui/label";
|
||||||
import { SafetyStockSetting } from "@/types/warehouse";
|
import { SafetyStockSetting } from "@/types/warehouse";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
|
|
||||||
interface EditSafetyStockDialogProps {
|
interface EditSafetyStockDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -66,7 +66,7 @@ export default function EditSafetyStockDialog({
|
|||||||
<Label>商品名稱</Label>
|
<Label>商品名稱</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{setting.productName}</span>
|
<span className="font-medium">{setting.productName}</span>
|
||||||
<Badge variant="outline">{setting.productType}</Badge>
|
<StatusBadge variant="neutral">{setting.productType}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 安全庫存列表組件
|
* 安全庫存列表組件
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Edit, Trash2, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
|
import { Trash2, Pencil } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { SafetyStockSetting, WarehouseInventory, SafetyStockStatus } from "@/types/warehouse";
|
import { SafetyStockSetting, WarehouseInventory, SafetyStockStatus } from "@/types/warehouse";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
|
|
||||||
interface SafetyStockListProps {
|
interface SafetyStockListProps {
|
||||||
settings: SafetyStockSetting[];
|
settings: SafetyStockSetting[];
|
||||||
@@ -35,29 +35,28 @@ function getSafetyStockStatus(
|
|||||||
|
|
||||||
// 獲取狀態徽章
|
// 獲取狀態徽章
|
||||||
function getStatusBadge(status: SafetyStockStatus) {
|
function getStatusBadge(status: SafetyStockStatus) {
|
||||||
switch (status) {
|
if (status === '正常') {
|
||||||
case "正常":
|
return (
|
||||||
return (
|
<StatusBadge variant="success">
|
||||||
<Badge className="bg-green-100 text-green-700 border-green-300">
|
正常
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
</StatusBadge>
|
||||||
正常
|
);
|
||||||
</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="warning">
|
||||||
|
接近
|
||||||
|
</StatusBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === '低於') {
|
||||||
|
return (
|
||||||
|
<StatusBadge variant="destructive">
|
||||||
|
低於
|
||||||
|
</StatusBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null; // Should not happen if SafetyStockStatus is exhaustive
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SafetyStockList({
|
export default function SafetyStockList({
|
||||||
@@ -108,7 +107,7 @@ export default function SafetyStockList({
|
|||||||
<TableCell className="text-grey-2">{index + 1}</TableCell>
|
<TableCell className="text-grey-2">{index + 1}</TableCell>
|
||||||
<TableCell className="font-medium">{setting.productName}</TableCell>
|
<TableCell className="font-medium">{setting.productName}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{setting.productType}</Badge>
|
<StatusBadge variant="neutral">{setting.productType}</StatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className={isLowStock ? "text-red-600 font-medium" : ""}>
|
<span className={isLowStock ? "text-red-600 font-medium" : ""}>
|
||||||
@@ -126,7 +125,7 @@ export default function SafetyStockList({
|
|||||||
onClick={() => onEdit(setting)}
|
onClick={() => onEdit(setting)}
|
||||||
className="hover:bg-primary/10 hover:text-primary"
|
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>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -98,25 +98,22 @@ export default function InventoryTable({
|
|||||||
|
|
||||||
// 獲取狀態徽章
|
// 獲取狀態徽章
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
if (status === '正常') {
|
||||||
case "正常":
|
return (
|
||||||
return (
|
<StatusBadge variant="success">
|
||||||
<Badge className="bg-green-100 text-green-700 border-green-300">
|
正常
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
</StatusBadge>
|
||||||
正常
|
);
|
||||||
</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="destructive">
|
||||||
|
低於
|
||||||
|
</StatusBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -168,10 +165,9 @@ export default function InventoryTable({
|
|||||||
{isVending ? '' : (hasInventory ? `${group.batches.length} 個批號` : '無庫存')}
|
{isVending ? '' : (hasInventory ? `${group.batches.length} 個批號` : '無庫存')}
|
||||||
</span>
|
</span>
|
||||||
{group.batches.some(b => b.expiryDate && new Date(b.expiryDate) < new Date()) && (
|
{group.batches.some(b => b.expiryDate && new Date(b.expiryDate) < new Date()) && (
|
||||||
<Badge className="bg-red-50 text-red-600 border-red-200">
|
<StatusBadge variant="destructive">
|
||||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
|
||||||
含過期項目
|
含過期項目
|
||||||
</Badge>
|
</StatusBadge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -199,9 +195,9 @@ export default function InventoryTable({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-gray-500">
|
<StatusBadge variant="neutral">
|
||||||
未設定
|
未設定
|
||||||
</Badge>
|
</StatusBadge>
|
||||||
)}
|
)}
|
||||||
{onViewProduct && (
|
{onViewProduct && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Label } from "@/Components/ui/label";
|
|||||||
import { Checkbox } from "@/Components/ui/checkbox";
|
import { Checkbox } from "@/Components/ui/checkbox";
|
||||||
import { SafetyStockSetting, Product } from "@/types/warehouse";
|
import { SafetyStockSetting, Product } from "@/types/warehouse";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
|
|
||||||
interface AddSafetyStockDialogProps {
|
interface AddSafetyStockDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -193,7 +193,7 @@ export default function AddSafetyStockDialog({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{product.name}</div>
|
<div className="font-medium">{product.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{product.type}</Badge>
|
<StatusBadge variant="neutral">{product.type}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -223,7 +223,7 @@ export default function AddSafetyStockDialog({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{product.name}</span>
|
<span className="font-medium">{product.name}</span>
|
||||||
<Badge variant="outline">{product.type}</Badge>
|
<StatusBadge variant="neutral">{product.type}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 安全庫存設定列表
|
* 安全庫存設定列表
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Trash2, Pencil, CheckCircle, Package, AlertTriangle } from "lucide-react";
|
import { Trash2, Pencil, Package } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import { Button } from "@/Components/ui/button";
|
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 { SafetyStockSetting, WarehouseInventory } from "@/types/warehouse";
|
||||||
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
|
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
|
||||||
import { Can } from "@/Components/Permission/Can";
|
import { Can } from "@/Components/Permission/Can";
|
||||||
@@ -57,38 +57,35 @@ export default function SafetyStockList({
|
|||||||
// 如果是自動帶入的品項且尚未存檔,顯示「未設定」
|
// 如果是自動帶入的品項且尚未存檔,顯示「未設定」
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
return (
|
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);
|
const status = getSafetyStockStatus(quantity, safetyStock);
|
||||||
switch (status) {
|
if (status === '正常') {
|
||||||
case "正常":
|
return (
|
||||||
return (
|
<StatusBadge variant="success">
|
||||||
<Badge className="bg-green-100 text-green-700 border-green-300 hover:bg-green-100">
|
正常
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
</StatusBadge>
|
||||||
正常
|
);
|
||||||
</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 === '接近') { // 數量 <= 安全庫存 * 1.2
|
||||||
|
return (
|
||||||
|
<StatusBadge variant="warning">
|
||||||
|
接近
|
||||||
|
</StatusBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === '低於') { // 數量 < 安全庫存
|
||||||
|
return (
|
||||||
|
<StatusBadge variant="destructive">
|
||||||
|
低於
|
||||||
|
</StatusBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -118,9 +115,9 @@ export default function SafetyStockList({
|
|||||||
{setting.productName}
|
{setting.productName}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className="font-normal">
|
<StatusBadge variant="neutral">
|
||||||
{setting.productType}
|
{setting.productType}
|
||||||
</Badge>
|
</StatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-semibold">
|
<TableCell className="text-right font-semibold">
|
||||||
{setting.safetyStock} {setting.unit || '個'}
|
{setting.safetyStock} {setting.unit || '個'}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Warehouse, WarehouseStats } from "@/types/warehouse";
|
import { Warehouse, WarehouseStats } from "@/types/warehouse";
|
||||||
import { Button } from "@/Components/ui/button";
|
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 { Card, CardContent } from "@/Components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -101,13 +101,12 @@ export default function WarehouseCard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-1">
|
<div className="flex gap-2 mt-1">
|
||||||
<Badge
|
<StatusBadge
|
||||||
variant={warehouse.type === 'quarantine' ? "secondary" : "outline"}
|
variant={warehouse.type === 'quarantine' ? "destructive" : "neutral"}
|
||||||
className={`text-xs font-normal ${warehouse.type === 'quarantine' ? 'bg-red-100 text-red-700 border-red-200' : ''}`}
|
|
||||||
>
|
>
|
||||||
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
|
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
|
||||||
{warehouse.type === 'quarantine' ? ' (不計入可用)' : ' (計入可用)'}
|
{warehouse.type === 'quarantine' ? ' (不計入可用)' : ' (計入可用)'}
|
||||||
</Badge>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,12 +32,20 @@ import { validateWarehouse } from "@/utils/validation";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
|
||||||
|
interface TransitWarehouseOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
license_plate?: string;
|
||||||
|
driver_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface WarehouseDialogProps {
|
interface WarehouseDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
warehouse: Warehouse | null;
|
warehouse: Warehouse | null;
|
||||||
onSave: (warehouse: Omit<Warehouse, "id" | "createdAt" | "updatedAt">) => void;
|
onSave: (warehouse: Omit<Warehouse, "id" | "createdAt" | "updatedAt">) => void;
|
||||||
onDelete?: (warehouseId: string) => void;
|
onDelete?: (warehouseId: string) => void;
|
||||||
|
transitWarehouses?: TransitWarehouseOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [
|
const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [
|
||||||
@@ -55,6 +63,7 @@ export default function WarehouseDialog({
|
|||||||
warehouse,
|
warehouse,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
transitWarehouses = [],
|
||||||
}: WarehouseDialogProps) {
|
}: WarehouseDialogProps) {
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
code: string;
|
code: string;
|
||||||
@@ -64,6 +73,7 @@ export default function WarehouseDialog({
|
|||||||
type: WarehouseType;
|
type: WarehouseType;
|
||||||
license_plate: string;
|
license_plate: string;
|
||||||
driver_name: string;
|
driver_name: string;
|
||||||
|
default_transit_warehouse_id: string | null;
|
||||||
}>({
|
}>({
|
||||||
code: "",
|
code: "",
|
||||||
name: "",
|
name: "",
|
||||||
@@ -72,6 +82,7 @@ export default function WarehouseDialog({
|
|||||||
type: "standard",
|
type: "standard",
|
||||||
license_plate: "",
|
license_plate: "",
|
||||||
driver_name: "",
|
driver_name: "",
|
||||||
|
default_transit_warehouse_id: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
@@ -86,6 +97,7 @@ export default function WarehouseDialog({
|
|||||||
type: warehouse.type || "standard",
|
type: warehouse.type || "standard",
|
||||||
license_plate: warehouse.license_plate || "",
|
license_plate: warehouse.license_plate || "",
|
||||||
driver_name: warehouse.driver_name || "",
|
driver_name: warehouse.driver_name || "",
|
||||||
|
default_transit_warehouse_id: warehouse.default_transit_warehouse_id ? String(warehouse.default_transit_warehouse_id) : null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -96,6 +108,7 @@ export default function WarehouseDialog({
|
|||||||
type: "standard",
|
type: "standard",
|
||||||
license_plate: "",
|
license_plate: "",
|
||||||
driver_name: "",
|
driver_name: "",
|
||||||
|
default_transit_warehouse_id: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [warehouse, open]);
|
}, [warehouse, open]);
|
||||||
@@ -216,6 +229,32 @@ export default function WarehouseDialog({
|
|||||||
</div>
|
</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:位置 */}
|
{/* 區塊 B:位置 */}
|
||||||
|
|||||||
34
resources/js/Components/shared/StatusBadge.tsx
Normal file
34
resources/js/Components/shared/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +36,8 @@ interface SearchableSelectProps {
|
|||||||
searchThreshold?: number;
|
searchThreshold?: number;
|
||||||
/** 強制控制是否顯示搜尋框。若設定此值,則忽略 searchThreshold */
|
/** 強制控制是否顯示搜尋框。若設定此值,則忽略 searchThreshold */
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
|
/** 是否可清除選取 */
|
||||||
|
isClearable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchableSelect({
|
export function SearchableSelect({
|
||||||
@@ -49,6 +51,7 @@ export function SearchableSelect({
|
|||||||
className,
|
className,
|
||||||
searchThreshold = 10,
|
searchThreshold = 10,
|
||||||
showSearch,
|
showSearch,
|
||||||
|
isClearable = false,
|
||||||
}: SearchableSelectProps) {
|
}: SearchableSelectProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -86,7 +89,18 @@ export function SearchableSelect({
|
|||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedOption ? selectedOption.label : placeholder}
|
{selectedOption ? selectedOption.label : placeholder}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
FileUp
|
FileUp,
|
||||||
|
Store
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast, Toaster } from "sonner";
|
import { toast, Toaster } from "sonner";
|
||||||
import { useState, useEffect, useMemo, useRef } from "react";
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
@@ -44,6 +45,7 @@ import { usePermission } from "@/hooks/usePermission";
|
|||||||
import ApplicationLogo from "@/Components/ApplicationLogo";
|
import ApplicationLogo from "@/Components/ApplicationLogo";
|
||||||
import { generateLightestColor, generateLightColor, generateDarkColor, generateActiveColor } from "@/utils/colorUtils";
|
import { generateLightestColor, generateLightColor, generateDarkColor, generateActiveColor } from "@/utils/colorUtils";
|
||||||
import { PageProps } from "@/types/global";
|
import { PageProps } from "@/types/global";
|
||||||
|
import NotificationDropdown from "@/Components/Header/NotificationDropdown";
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -130,6 +132,13 @@ export default function AuthenticatedLayout({
|
|||||||
route: "/inventory/transfer-orders",
|
route: "/inventory/transfer-orders",
|
||||||
permission: "inventory_transfer.view",
|
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",
|
route: "/sales/imports",
|
||||||
permission: "sales_imports.view",
|
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",
|
route: "/inventory/report",
|
||||||
permission: "inventory_report.view",
|
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",
|
id: "system-management",
|
||||||
label: "系統管理",
|
label: "系統管理",
|
||||||
icon: <Settings className="h-5 w-5" />,
|
icon: <Settings className="h-5 w-5" />,
|
||||||
permission: ["users.view", "roles.view"],
|
permission: ["users.view", "roles.view", "system.view_logs"],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: "user-management",
|
id: "user-management",
|
||||||
@@ -269,6 +292,13 @@ export default function AuthenticatedLayout({
|
|||||||
route: "/admin/activity-logs",
|
route: "/admin/activity-logs",
|
||||||
permission: "system.view_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(() => {
|
const menuItems = useMemo(() => {
|
||||||
return allMenuItems
|
return allMenuItems
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
// 如果有子項目,先過濾子項目
|
// 如果有子項目
|
||||||
if (item.children && item.children.length > 0) {
|
if (item.children && item.children.length > 0) {
|
||||||
const filteredChildren = item.children.filter(hasPermissionForItem);
|
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;
|
if (!hasPermissionForItem(item)) return null;
|
||||||
return item;
|
return item;
|
||||||
})
|
})
|
||||||
.filter((item): item is MenuItem => item !== null);
|
.filter((item): item is MenuItem => item !== null);
|
||||||
}, [can, canAny]);
|
}, [allMenuItems, hasPermissionForItem]);
|
||||||
|
|
||||||
// 初始化狀態:優先讀取 localStorage
|
// 初始化狀態:優先讀取 localStorage
|
||||||
const [expandedItems, setExpandedItems] = useState<string[]>(() => {
|
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 hasChildren = item.children && item.children.length > 0;
|
||||||
const isExpanded = expandedItems.includes(item.id);
|
const isExpanded = expandedItems.includes(item.id);
|
||||||
const isActive = item.route
|
const isActive = item.route
|
||||||
? (item.route === '/' ? url === '/' : url.startsWith(item.route))
|
? (item.route === '/' ? url === '/' : url.startsWith(item.route))
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
const effectivelyCollapsed = isCollapsed && !forceExpand;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="mb-1">
|
<div key={item.id} className="mb-1">
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
@@ -392,21 +428,21 @@ export default function AuthenticatedLayout({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center transition-all rounded-lg group",
|
"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 ? "px-3 py-2.5" : "px-3 py-2 pl-10",
|
||||||
level === 0 && !isCollapsed && "hover:bg-slate-100",
|
level === 0 && !effectivelyCollapsed && "hover:bg-slate-100",
|
||||||
isCollapsed && level === 0 && "justify-center px-0 h-10 w-10 mx-auto 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 && (
|
{level === 0 && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"flex-shrink-0 transition-all",
|
"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}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!effectivelyCollapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 text-left text-base font-medium text-slate-700 group-hover:text-slate-900 truncate">
|
<span className="flex-1 text-left text-base font-medium text-slate-700 group-hover:text-slate-900 truncate">
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -429,22 +465,22 @@ export default function AuthenticatedLayout({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center transition-all rounded-lg group",
|
"w-full flex items-center transition-all rounded-lg group",
|
||||||
level === 0 ? "px-3 py-2.5" : "px-3 py-2",
|
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",
|
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 && (
|
{item.icon && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"flex-shrink-0 transition-all",
|
"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"
|
isActive ? "text-primary-main" : "text-slate-500 group-hover:text-slate-900"
|
||||||
)}>
|
)}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isCollapsed && (
|
{!effectivelyCollapsed && (
|
||||||
<span className="text-base font-medium truncate">
|
<span className="text-base font-medium truncate">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -452,9 +488,9 @@ export default function AuthenticatedLayout({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasChildren && isExpanded && !isCollapsed && (
|
{hasChildren && isExpanded && !effectivelyCollapsed && (
|
||||||
<div className="mt-1 space-y-1">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -491,47 +527,51 @@ export default function AuthenticatedLayout({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
<DropdownMenu modal={false}>
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
|
<NotificationDropdown />
|
||||||
<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">
|
<DropdownMenu modal={false}>
|
||||||
{user.name} ({user.username})
|
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
|
||||||
</span>
|
<div className="hidden md:flex flex-col items-end mr-1">
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-sm font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||||
{user.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
|
{user.name} ({user.username})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="text-xs text-slate-500">
|
||||||
<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.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
|
||||||
<User className="h-5 w-5" />
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
<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">
|
||||||
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
|
<User className="h-5 w-5" />
|
||||||
<DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
|
||||||
<Link
|
<DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
|
||||||
href={route('profile.edit')}
|
<DropdownMenuSeparator />
|
||||||
preserveScroll={true}
|
<DropdownMenuItem asChild>
|
||||||
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
|
<Link
|
||||||
>
|
href={route('profile.edit')}
|
||||||
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
|
preserveScroll={true}
|
||||||
<span>使用者設定</span>
|
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
|
||||||
</Link>
|
>
|
||||||
</DropdownMenuItem>
|
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
|
||||||
<DropdownMenuSeparator />
|
<span>使用者設定</span>
|
||||||
<DropdownMenuItem asChild>
|
</Link>
|
||||||
<Link
|
</DropdownMenuItem>
|
||||||
href={route('logout')}
|
<DropdownMenuSeparator />
|
||||||
method="post"
|
<DropdownMenuItem asChild>
|
||||||
as="button"
|
<Link
|
||||||
className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
|
href={route('logout')}
|
||||||
>
|
method="post"
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
as="button"
|
||||||
<span>登出系統</span>
|
className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
|
||||||
</Link>
|
>
|
||||||
</DropdownMenuItem>
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuContent>
|
<span>登出系統</span>
|
||||||
</DropdownMenu>
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Sidebar Desktop */}
|
{/* Sidebar Desktop */}
|
||||||
@@ -560,7 +600,7 @@ export default function AuthenticatedLayout({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border-t border-slate-100 flex items-center justify-between">
|
<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
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -600,20 +640,20 @@ export default function AuthenticatedLayout({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4" scroll-region="true">
|
<div className="flex-1 overflow-y-auto p-4" scroll-region="true">
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
{menuItems.map((item) => renderMenuItem(item))}
|
{menuItems.map((item) => renderMenuItem(item, 0, true))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className={cn(
|
<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",
|
"lg:ml-64",
|
||||||
isCollapsed && "lg:ml-20",
|
isCollapsed && "lg:ml-20",
|
||||||
"pt-16" // 始終為頁首保留空間
|
"pt-16" // 始終為頁首保留空間
|
||||||
)}>
|
)}>
|
||||||
<div className="relative">
|
<div className="relative flex-1 flex flex-col min-h-0">
|
||||||
<div className="container mx-auto px-6 pt-6 max-w-7xl">
|
<div className="container mx-auto px-6 pt-6 max-w-7xl shrink-0">
|
||||||
{breadcrumbs && breadcrumbs.length > 1 && (
|
{breadcrumbs && breadcrumbs.length > 1 && (
|
||||||
<BreadcrumbNav items={breadcrumbs} className="mb-2" />
|
<BreadcrumbNav items={breadcrumbs} className="mb-2" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Head, router } from '@inertiajs/react';
|
|||||||
import { PageProps } from '@/types/global';
|
import { PageProps } from '@/types/global';
|
||||||
import Pagination from '@/Components/shared/Pagination';
|
import Pagination from '@/Components/shared/Pagination';
|
||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
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 LogTable, { Activity } from '@/Components/ActivityLog/LogTable';
|
||||||
import ActivityDetailDialog from '@/Components/ActivityLog/ActivityDetailDialog';
|
import ActivityDetailDialog from '@/Components/ActivityLog/ActivityDetailDialog';
|
||||||
import { Button } from '@/Components/ui/button';
|
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 [causer, setCauser] = useState(filters.causer_id || 'all');
|
||||||
const [dateRangeType, setDateRangeType] = useState('custom');
|
const [dateRangeType, setDateRangeType] = useState('custom');
|
||||||
|
|
||||||
// Advanced Filter Toggle
|
|
||||||
const [showAdvancedFilter, setShowAdvancedFilter] = useState(
|
|
||||||
!!(filters.date_start || filters.date_end)
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDateRangeChange = (type: string) => {
|
const handleDateRangeChange = (type: string) => {
|
||||||
setDateRangeType(type);
|
setDateRangeType(type);
|
||||||
@@ -161,75 +158,12 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 篩選區塊 */}
|
{/* 篩選區塊 */}
|
||||||
<div className="bg-white p-5 rounded-lg shadow-sm border border-grey-4 mb-6">
|
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 mb-4">
|
<div className="space-y-4">
|
||||||
{/* 關鍵字搜尋 */}
|
{/* Top Config: Date Range & Quick Buttons */}
|
||||||
<div className="md:col-span-4 space-y-1">
|
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
|
||||||
<Label className="text-xs font-medium text-grey-1">關鍵字搜尋</Label>
|
<div className="flex-none space-y-2">
|
||||||
<div className="relative">
|
<Label className="text-xs font-medium text-grey-2">快速時間區間</Label>
|
||||||
<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="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[
|
{[
|
||||||
{ label: "今日", value: "today" },
|
{ label: "今日", value: "today" },
|
||||||
@@ -254,10 +188,11 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-6">
|
{/* Date Inputs */}
|
||||||
<div className="grid grid-cols-2 gap-4 items-end">
|
<div className="w-full lg:flex-1">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="space-y-1">
|
<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">
|
<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" />
|
<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
|
<Input
|
||||||
@@ -267,13 +202,12 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
|||||||
setDateStart(e.target.value);
|
setDateStart(e.target.value);
|
||||||
setDateRangeType('custom');
|
setDateRangeType('custom');
|
||||||
}}
|
}}
|
||||||
// block w-full to ensure it fills space
|
|
||||||
className="pl-9 block w-full h-9 bg-white"
|
className="pl-9 block w-full h-9 bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<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">
|
<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" />
|
<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
|
<Input
|
||||||
@@ -290,43 +224,88 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Bar */}
|
{/* Detailed Filters row */}
|
||||||
<div className="flex items-center justify-end border-t border-grey-4 pt-5 gap-3 mt-4">
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
|
||||||
<Button
|
{/* 事件類型 */}
|
||||||
variant="ghost"
|
<div className="md:col-span-2 space-y-1">
|
||||||
size="sm"
|
<Label className="text-xs font-medium text-grey-2">事件類型</Label>
|
||||||
onClick={() => setShowAdvancedFilter(!showAdvancedFilter)}
|
<Select value={event} onValueChange={setEvent}>
|
||||||
className="mr-auto text-gray-500 hover:text-gray-900 h-9"
|
<SelectTrigger className="h-9 bg-white">
|
||||||
>
|
<SelectValue placeholder="所有事件" />
|
||||||
{showAdvancedFilter ? (
|
</SelectTrigger>
|
||||||
<>
|
<SelectContent>
|
||||||
<ChevronUp className="h-4 w-4 mr-1" />
|
<SelectItem value="all">所有事件</SelectItem>
|
||||||
收合篩選
|
<SelectItem value="created">新增 (Created)</SelectItem>
|
||||||
</>
|
<SelectItem value="updated">更新 (Updated)</SelectItem>
|
||||||
) : (
|
<SelectItem value="deleted">刪除 (Deleted)</SelectItem>
|
||||||
<>
|
</SelectContent>
|
||||||
<ChevronDown className="h-4 w-4 mr-1" />
|
</Select>
|
||||||
進階篩選
|
</div>
|
||||||
{(dateStart || dateEnd) && (
|
|
||||||
<span className="ml-2 w-2 h-2 rounded-full bg-primary-main" />
|
{/* 操作對象 */}
|
||||||
)}
|
<div className="md:col-span-2 space-y-1">
|
||||||
</>
|
<Label className="text-xs font-medium text-grey-2">操作對象</Label>
|
||||||
)}
|
<SearchableSelect
|
||||||
</Button>
|
value={subjectType}
|
||||||
<Button
|
onValueChange={setSubjectType}
|
||||||
variant="outline"
|
options={[
|
||||||
onClick={handleReset}
|
{ label: "所有對象", value: "all" },
|
||||||
className="flex items-center gap-2 button-outlined-primary h-9"
|
...subject_types
|
||||||
>
|
]}
|
||||||
<RotateCcw className="h-4 w-4" />
|
placeholder="選擇對象"
|
||||||
重置
|
className="w-full h-9 bg-white"
|
||||||
</Button>
|
/>
|
||||||
<Button onClick={handleFilter} className="button-filled-primary h-9 px-6 gap-2">
|
</div>
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
查詢
|
{/* 操作人員 */}
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,30 @@
|
|||||||
import { Head, Link } from "@inertiajs/react";
|
import { Head, Link } from "@inertiajs/react";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import {
|
import {
|
||||||
Package,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
MinusCircle,
|
MinusCircle,
|
||||||
Clock,
|
Clock,
|
||||||
ArrowRight,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
ClipboardCheck,
|
||||||
|
Trophy,
|
||||||
|
Package,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Table,
|
AreaChart,
|
||||||
TableBody,
|
Area,
|
||||||
TableCell,
|
XAxis,
|
||||||
TableHead,
|
YAxis,
|
||||||
TableHeader,
|
CartesianGrid,
|
||||||
TableRow,
|
Tooltip as RechartsTooltip,
|
||||||
} from "@/Components/ui/table";
|
ResponsiveContainer,
|
||||||
import { Badge } from "@/Components/ui/badge";
|
} from "recharts";
|
||||||
import { Button } from "@/Components/ui/button";
|
import {
|
||||||
|
Tooltip,
|
||||||
interface AbnormalItem {
|
TooltipContent,
|
||||||
id: number;
|
TooltipTrigger,
|
||||||
product_code: string;
|
} from "@/Components/ui/tooltip";
|
||||||
product_name: string;
|
|
||||||
warehouse_name: string;
|
|
||||||
quantity: number;
|
|
||||||
safety_stock: number | null;
|
|
||||||
expiry_date: string | null;
|
|
||||||
statuses: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stats: {
|
stats: {
|
||||||
@@ -36,45 +32,71 @@ interface Props {
|
|||||||
lowStockCount: number;
|
lowStockCount: number;
|
||||||
negativeCount: number;
|
negativeCount: number;
|
||||||
expiringCount: 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 映射
|
export default function Dashboard({ stats }: Props) {
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
const mainCards = [
|
||||||
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 = [
|
|
||||||
{
|
{
|
||||||
label: "庫存明細數",
|
label: "庫存總值",
|
||||||
value: stats.totalItems,
|
value: `NT$ ${Math.round(stats.totalInventoryValue).toLocaleString()}`,
|
||||||
icon: <Package className="h-6 w-6" />,
|
description: `品項總數: ${stats.totalItems}`,
|
||||||
color: "text-primary-main",
|
icon: <TrendingUp className="h-5 w-5" />,
|
||||||
bgColor: "bg-primary-lightest",
|
color: "text-blue-600",
|
||||||
borderColor: "border-primary-light",
|
bgColor: "bg-blue-50",
|
||||||
href: "/inventory/stock-query",
|
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: "低庫存",
|
label: "低庫存",
|
||||||
value: stats.lowStockCount,
|
value: stats.lowStockCount,
|
||||||
icon: <AlertTriangle className="h-6 w-6" />,
|
icon: <AlertTriangle className="h-4 w-4" />,
|
||||||
color: "text-amber-600",
|
color: "text-amber-600",
|
||||||
bgColor: "bg-amber-50",
|
bgColor: "bg-amber-50",
|
||||||
borderColor: "border-amber-200",
|
borderColor: "border-amber-200",
|
||||||
@@ -84,7 +106,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
|
|||||||
{
|
{
|
||||||
label: "負庫存",
|
label: "負庫存",
|
||||||
value: stats.negativeCount,
|
value: stats.negativeCount,
|
||||||
icon: <MinusCircle className="h-6 w-6" />,
|
icon: <MinusCircle className="h-4 w-4" />,
|
||||||
color: "text-red-600",
|
color: "text-red-600",
|
||||||
bgColor: "bg-red-50",
|
bgColor: "bg-red-50",
|
||||||
borderColor: "border-red-200",
|
borderColor: "border-red-200",
|
||||||
@@ -94,7 +116,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
|
|||||||
{
|
{
|
||||||
label: "即將過期",
|
label: "即將過期",
|
||||||
value: stats.expiringCount,
|
value: stats.expiringCount,
|
||||||
icon: <Clock className="h-6 w-6" />,
|
icon: <Clock className="h-4 w-4" />,
|
||||||
color: "text-yellow-600",
|
color: "text-yellow-600",
|
||||||
bgColor: "bg-yellow-50",
|
bgColor: "bg-yellow-50",
|
||||||
borderColor: "border-yellow-200",
|
borderColor: "border-yellow-200",
|
||||||
@@ -105,155 +127,214 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout
|
||||||
breadcrumbs={[
|
breadcrumbs={[{ label: "儀表板", href: "/", isPage: true }]}
|
||||||
{
|
|
||||||
label: "儀表板",
|
|
||||||
href: "/",
|
|
||||||
isPage: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Head title="儀表板" />
|
<Head title="儀表板" />
|
||||||
|
|
||||||
<div className="container mx-auto p-6 max-w-7xl">
|
<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 className="mb-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
<LayoutDashboard className="h-6 w-6 text-primary-main" />
|
<LayoutDashboard className="h-6 w-6 text-primary-main" />
|
||||||
庫存總覽
|
系統概況
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-gray-500 mt-1">即時分析營運數據與庫存警示</p>
|
||||||
即時掌握庫存狀態,異常情況一目了然
|
</div>
|
||||||
</p>
|
<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>
|
||||||
|
|
||||||
{/* 統計卡片 */}
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
{mainCards.map((card) => (
|
||||||
{cards.map((card) => (
|
<div key={card.label} className={`relative rounded-xl border ${card.borderColor} bg-white p-6 shadow-sm`}>
|
||||||
<Link key={card.label} href={card.href}>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div
|
<div className={`p-2 rounded-lg ${card.bgColor} ${card.color}`}>
|
||||||
className={`relative rounded-xl border ${card.borderColor} ${card.bgColor} p-5 transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer`}
|
{card.icon}
|
||||||
>
|
</div>
|
||||||
{card.alert && (
|
{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>
|
</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>
|
||||||
|
|
||||||
{/* 異常庫存清單 */}
|
{/* 銷售趨勢 & 熱銷排行 */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
{/* 銷售趨勢 - Area Chart */}
|
||||||
<h2 className="text-lg font-semibold text-grey-0 flex items-center gap-2">
|
<div className="lg:col-span-2 bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
<div className="flex items-center gap-2 mb-6">
|
||||||
異常庫存清單
|
<TrendingUp className="h-5 w-5 text-emerald-500" />
|
||||||
</h2>
|
<h2 className="text-lg font-semibold text-gray-800">近 30 日銷售趨勢</h2>
|
||||||
<Link href="/inventory/stock-query?status=abnormal">
|
</div>
|
||||||
<Button
|
<div className="h-[300px] w-full">
|
||||||
variant="outline"
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
size="sm"
|
<AreaChart data={stats.salesTrend} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
className="button-outlined-primary gap-1"
|
<defs>
|
||||||
>
|
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
|
||||||
查看完整庫存
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8} />
|
||||||
<ArrowRight className="h-4 w-4" />
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||||
</Button>
|
</linearGradient>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Table>
|
{/* 熱銷商品排行 (金額) - Bar Chart */}
|
||||||
<TableHeader className="bg-gray-50">
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
<TableRow>
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<TableHead className="w-[50px] text-center">
|
<Trophy className="h-5 w-5 text-indigo-500" />
|
||||||
#
|
<h2 className="text-lg font-semibold text-gray-800">熱銷金額 Top 5</h2>
|
||||||
</TableHead>
|
</div>
|
||||||
<TableHead>商品代碼</TableHead>
|
<div className="h-[300px] w-full flex flex-col justify-center space-y-6">
|
||||||
<TableHead>商品名稱</TableHead>
|
{stats.topSellingProducts.length > 0 ? (
|
||||||
<TableHead>倉庫</TableHead>
|
(() => {
|
||||||
<TableHead className="text-right">
|
const maxAmount = Math.max(...stats.topSellingProducts.map(p => p.amount));
|
||||||
數量
|
return stats.topSellingProducts.map((product, index) => (
|
||||||
</TableHead>
|
<div key={index} className="space-y-1">
|
||||||
<TableHead className="text-center">
|
<div className="flex justify-between items-end">
|
||||||
狀態
|
<div className="min-w-0 flex-1 pr-4">
|
||||||
</TableHead>
|
<Tooltip>
|
||||||
</TableRow>
|
<TooltipTrigger asChild>
|
||||||
</TableHeader>
|
<span className="block text-sm font-medium text-gray-700 truncate cursor-help">
|
||||||
<TableBody>
|
{product.name}
|
||||||
{abnormalItems.length === 0 ? (
|
</span>
|
||||||
<TableRow>
|
</TooltipTrigger>
|
||||||
<TableCell
|
<TooltipContent>
|
||||||
colSpan={6}
|
<p>{product.name}</p>
|
||||||
className="text-center py-8 text-gray-500"
|
</TooltipContent>
|
||||||
>
|
</Tooltip>
|
||||||
🎉 目前沒有異常庫存,一切正常!
|
</div>
|
||||||
</TableCell>
|
<span className="text-sm font-bold text-indigo-600 shrink-0">
|
||||||
</TableRow>
|
NT$ {product.amount.toLocaleString()}
|
||||||
) : (
|
</span>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||||
</TableRow>
|
<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>
|
</div>
|
||||||
</Table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
|
|||||||
292
resources/js/Pages/Integration/SalesOrders/Index.tsx
Normal file
292
resources/js/Pages/Integration/SalesOrders/Index.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Head, Link, router } from "@inertiajs/react";
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
TrendingUp,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/Components/ui/table";
|
||||||
|
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
import Pagination from "@/Components/shared/Pagination";
|
||||||
|
import { formatDate } from "@/lib/date";
|
||||||
|
import { formatNumber } from "@/utils/format";
|
||||||
|
import { Can } from "@/Components/Permission/Can";
|
||||||
|
|
||||||
|
interface SalesOrder {
|
||||||
|
id: number;
|
||||||
|
external_order_id: string;
|
||||||
|
status: string;
|
||||||
|
payment_method: string;
|
||||||
|
total_amount: string;
|
||||||
|
sold_at: string;
|
||||||
|
created_at: string;
|
||||||
|
source: string;
|
||||||
|
source_label: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationLink {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orders: {
|
||||||
|
data: SalesOrder[];
|
||||||
|
total: number;
|
||||||
|
per_page: number;
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
links: PaginationLink[];
|
||||||
|
};
|
||||||
|
filters: {
|
||||||
|
search?: string;
|
||||||
|
per_page?: string;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 來源篩選選項
|
||||||
|
const sourceOptions = [
|
||||||
|
{ label: "全部來源", value: "" },
|
||||||
|
{ label: "POS 收銀機", value: "pos" },
|
||||||
|
{ label: "販賣機", value: "vending" },
|
||||||
|
{ label: "手動匯入", value: "manual_import" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSourceLabel = (source: string): string => {
|
||||||
|
switch (source) {
|
||||||
|
case 'pos': return 'POS';
|
||||||
|
case 'vending': return '販賣機';
|
||||||
|
case 'manual_import': return '手動匯入';
|
||||||
|
default: return source;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceVariant = (source: string): StatusVariant => {
|
||||||
|
switch (source) {
|
||||||
|
case 'pos': return 'info';
|
||||||
|
case 'vending': return 'warning';
|
||||||
|
case 'manual_import': return 'neutral';
|
||||||
|
default: return 'neutral';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusVariant = (status: string): StatusVariant => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success';
|
||||||
|
case 'pending': return 'warning';
|
||||||
|
case 'cancelled': return 'destructive';
|
||||||
|
default: return 'neutral';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return "已完成";
|
||||||
|
case 'pending': return "待處理";
|
||||||
|
case 'cancelled': return "已取消";
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||||
|
const [search, setSearch] = useState(filters.search || "");
|
||||||
|
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
router.get(
|
||||||
|
route("integration.sales-orders.index"),
|
||||||
|
{ ...filters, search, page: 1 },
|
||||||
|
{ preserveState: true, replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePerPageChange = (value: string) => {
|
||||||
|
setPerPage(value);
|
||||||
|
router.get(
|
||||||
|
route("integration.sales-orders.index"),
|
||||||
|
{ ...filters, per_page: value, page: 1 },
|
||||||
|
{ preserveState: false, replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startIndex = (orders.current_page - 1) * orders.per_page + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "銷售管理", href: "#" },
|
||||||
|
{
|
||||||
|
label: "銷售訂單管理",
|
||||||
|
href: route("integration.sales-orders.index"),
|
||||||
|
isPage: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="銷售訂單管理" />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-6 w-6 text-primary-main" />
|
||||||
|
銷售訂單管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
檢視從 POS 或販賣機同步進來的銷售訂單紀錄
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 篩選列 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<SearchableSelect
|
||||||
|
value={filters.source || ""}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
router.get(
|
||||||
|
route("integration.sales-orders.index"),
|
||||||
|
{ ...filters, source: v || undefined, page: 1 },
|
||||||
|
{ preserveState: true, replace: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
options={sourceOptions}
|
||||||
|
className="w-[160px] h-9"
|
||||||
|
showSearch={false}
|
||||||
|
placeholder="篩選來源"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
placeholder="搜尋外部訂單號 (External Order ID)..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary h-9"
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
|
<TableHead>外部訂單號</TableHead>
|
||||||
|
<TableHead className="text-center">來源</TableHead>
|
||||||
|
<TableHead className="text-center">狀態</TableHead>
|
||||||
|
<TableHead>付款方式</TableHead>
|
||||||
|
<TableHead className="text-right">總金額</TableHead>
|
||||||
|
<TableHead>銷售時間</TableHead>
|
||||||
|
<TableHead className="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{orders.data.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8 text-gray-500">
|
||||||
|
無符合條件的資料
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
orders.data.map((order, index) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell className="text-gray-500 font-medium text-center">
|
||||||
|
{startIndex + index}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{order.external_order_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<StatusBadge variant={getSourceVariant(order.source)}>
|
||||||
|
{order.source_label || getSourceLabel(order.source)}
|
||||||
|
</StatusBadge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<StatusBadge variant={getStatusVariant(order.status)}>
|
||||||
|
{getStatusLabel(order.status)}
|
||||||
|
</StatusBadge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-600">
|
||||||
|
{order.payment_method || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
${formatNumber(parseFloat(order.total_amount))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">
|
||||||
|
{formatDate(order.sold_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Can permission="sales_orders.view">
|
||||||
|
<Link href={route("integration.sales-orders.show", order.id)}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary"
|
||||||
|
title="檢視明細"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分頁 */}
|
||||||
|
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>每頁顯示</span>
|
||||||
|
<SearchableSelect
|
||||||
|
value={perPage}
|
||||||
|
onValueChange={handlePerPageChange}
|
||||||
|
options={[
|
||||||
|
{ label: "10", value: "10" },
|
||||||
|
{ label: "20", value: "20" },
|
||||||
|
{ label: "50", value: "50" },
|
||||||
|
{ label: "100", value: "100" },
|
||||||
|
]}
|
||||||
|
className="w-[90px] h-8"
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<span>筆</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
共 {orders.total} 筆紀錄
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Pagination links={orders.links} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
resources/js/Pages/Integration/SalesOrders/Show.tsx
Normal file
215
resources/js/Pages/Integration/SalesOrders/Show.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { ArrowLeft, TrendingUp, Package, CreditCard, Calendar, FileJson } from "lucide-react";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import { Head, Link } from "@inertiajs/react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/Components/ui/table";
|
||||||
|
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||||
|
import { formatDate } from "@/lib/date";
|
||||||
|
import { formatNumber } from "@/utils/format";
|
||||||
|
import CopyButton from "@/Components/shared/CopyButton";
|
||||||
|
|
||||||
|
interface SalesOrderItem {
|
||||||
|
id: number;
|
||||||
|
product_name: string;
|
||||||
|
quantity: string;
|
||||||
|
price: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SalesOrder {
|
||||||
|
id: number;
|
||||||
|
external_order_id: string;
|
||||||
|
status: string;
|
||||||
|
payment_method: string;
|
||||||
|
total_amount: string;
|
||||||
|
sold_at: string;
|
||||||
|
created_at: string;
|
||||||
|
raw_payload: any;
|
||||||
|
items: SalesOrderItem[];
|
||||||
|
source: string;
|
||||||
|
source_label: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSourceDisplay = (source: string, sourceLabel: string | null): string => {
|
||||||
|
const base = source === 'pos' ? 'POS 收銀機'
|
||||||
|
: source === 'vending' ? '販賣機'
|
||||||
|
: source === 'manual_import' ? '手動匯入'
|
||||||
|
: source;
|
||||||
|
return sourceLabel ? `${base} (${sourceLabel})` : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
order: SalesOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusVariant = (status: string): StatusVariant => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success';
|
||||||
|
case 'pending': return 'warning';
|
||||||
|
case 'cancelled': return 'destructive';
|
||||||
|
default: return 'neutral';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return "已完成";
|
||||||
|
case 'pending': return "待處理";
|
||||||
|
case 'cancelled': return "已取消";
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SalesOrderShow({ order }: Props) {
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "銷售管理", href: "#" },
|
||||||
|
{ label: "銷售訂單管理", href: route("integration.sales-orders.index") },
|
||||||
|
{ label: `訂單詳情 (#${order.external_order_id})`, href: "#", isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title={`銷售訂單詳情 - ${order.external_order_id}`} />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link href={route("integration.sales-orders.index")}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 button-outlined-primary mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回銷售訂單列表
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-6 w-6 text-primary-main" />
|
||||||
|
查看銷售訂單
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">外部單號:{order.external_order_id}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge variant={getStatusVariant(order.status)}>
|
||||||
|
{getStatusLabel(order.status)}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* 左側:基本資訊與明細 */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* 基本資訊卡片 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5 text-primary-main" />
|
||||||
|
基本資訊
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">外部訂單編號</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-mono font-medium text-gray-900">{order.external_order_id}</span>
|
||||||
|
<CopyButton text={order.external_order_id} label="複製單號" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">銷售時間</span>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-gray-900">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
{formatDate(order.sold_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">付款方式</span>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium text-gray-900">
|
||||||
|
<CreditCard className="h-4 w-4 text-gray-400" />
|
||||||
|
{order.payment_method || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">同步時間</span>
|
||||||
|
<span className="font-medium text-gray-900">{formatDate(order.created_at as any)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-gray-500 block mb-1">訂單來源</span>
|
||||||
|
<span className="font-medium text-gray-900">{getSourceDisplay(order.source, order.source_label)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 項目清單卡片 */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-100">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-0">銷售項目清單</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||||
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
|
<TableHead>商品名稱</TableHead>
|
||||||
|
<TableHead className="text-right">數量</TableHead>
|
||||||
|
<TableHead className="text-right">單價</TableHead>
|
||||||
|
<TableHead className="text-right">小計</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{order.items.map((item, index) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-gray-500 text-center">{index + 1}</TableCell>
|
||||||
|
<TableCell className="font-medium">{item.product_name}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{formatNumber(parseFloat(item.quantity))}</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600">${formatNumber(parseFloat(item.price))}</TableCell>
|
||||||
|
<TableCell className="text-right font-bold text-primary-main">${formatNumber(parseFloat(item.total))}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<div className="w-full max-w-sm bg-primary-lightest/30 px-6 py-4 rounded-xl border border-primary-light/20 flex flex-col gap-3">
|
||||||
|
<div className="flex justify-between items-end w-full">
|
||||||
|
<span className="text-sm text-gray-500 font-medium mb-1">訂單總金額</span>
|
||||||
|
<span className="text-2xl font-black text-primary-main">
|
||||||
|
${formatNumber(parseFloat(order.total_amount))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右側:原始資料 (Raw Payload) */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<FileJson className="h-5 w-5 text-primary-main" />
|
||||||
|
API 原始資料
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
此區塊顯示同步時接收到的完整原始 JSON 內容,可用於排查資料問題。
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-900 rounded-lg p-4 overflow-auto max-h-[600px]">
|
||||||
|
<pre className="text-xs text-slate-300 font-mono">
|
||||||
|
{JSON.stringify(order.raw_payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
|
||||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
import { Head, useForm, router, Link } from '@inertiajs/react';
|
import { Head, useForm, router, Link } from '@inertiajs/react';
|
||||||
import { usePermission } from '@/hooks/usePermission';
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -11,7 +13,6 @@ import {
|
|||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -167,13 +168,13 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'draft':
|
case 'draft':
|
||||||
return <Badge variant="secondary" className="bg-gray-100 text-gray-600 border-none">草稿</Badge>;
|
return <StatusBadge variant="neutral">草稿</StatusBadge>;
|
||||||
case 'posted':
|
case 'posted':
|
||||||
return <Badge className="bg-green-100 text-green-700 border-none">已過帳</Badge>;
|
return <StatusBadge variant="success">已過帳</StatusBadge>;
|
||||||
case 'voided':
|
case 'voided':
|
||||||
return <Badge variant="destructive" className="bg-red-100 text-red-700 border-none">已作廢</Badge>;
|
return <StatusBadge variant="destructive">已作廢</StatusBadge>;
|
||||||
default:
|
default:
|
||||||
return <Badge variant="outline">{status}</Badge>;
|
return <StatusBadge variant="neutral">{status}</StatusBadge>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,10 +258,10 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
<TableHead className="w-[180px] font-medium text-grey-600">單號</TableHead>
|
<TableHead className="w-[180px] font-medium text-grey-600">單號</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">倉庫</TableHead>
|
<TableHead className="font-medium text-grey-600">倉庫</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">調整原因</TableHead>
|
<TableHead className="font-medium text-grey-600">調整原因</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600 text-center">狀態</TableHead>
|
|
||||||
<TableHead className="font-medium text-grey-600">建立者</TableHead>
|
<TableHead className="font-medium text-grey-600">建立者</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">建立時間</TableHead>
|
<TableHead className="font-medium text-grey-600">建立時間</TableHead>
|
||||||
<TableHead className="font-medium text-grey-600">過帳時間</TableHead>
|
<TableHead className="font-medium text-grey-600">過帳時間</TableHead>
|
||||||
|
<TableHead className="font-medium text-grey-600 text-center">狀態</TableHead>
|
||||||
<TableHead className="text-center font-medium text-grey-600">操作</TableHead>
|
<TableHead className="text-center font-medium text-grey-600">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -286,10 +287,10 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{doc.warehouse_name}</TableCell>
|
<TableCell>{doc.warehouse_name}</TableCell>
|
||||||
<TableCell className="text-gray-500 max-w-[200px] truncate">{doc.reason}</TableCell>
|
<TableCell className="text-gray-500 max-w-[200px] truncate">{doc.reason}</TableCell>
|
||||||
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
|
|
||||||
<TableCell className="text-sm">{doc.created_by}</TableCell>
|
<TableCell className="text-sm">{doc.created_by}</TableCell>
|
||||||
<TableCell className="text-gray-500 text-sm">{doc.created_at}</TableCell>
|
<TableCell className="text-gray-500 text-sm">{doc.created_at}</TableCell>
|
||||||
<TableCell className="text-gray-500 text-sm">{doc.posted_at || '-'}</TableCell>
|
<TableCell className="text-gray-500 text-sm">{doc.posted_at || '-'}</TableCell>
|
||||||
|
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { Badge } from "@/Components/ui/badge";
|
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||||
import { Checkbox } from "@/Components/ui/checkbox";
|
import { Checkbox } from "@/Components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -243,9 +243,9 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
盤調單: {doc.doc_no}
|
盤調單: {doc.doc_no}
|
||||||
</h1>
|
</h1>
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<Badge variant="secondary" className="bg-blue-500 text-white border-none py-1 px-3">草稿</Badge>
|
<StatusBadge variant="neutral" className="border-none py-1 px-3">草稿</StatusBadge>
|
||||||
) : (
|
) : (
|
||||||
<Badge className="bg-green-500 text-white border-none py-1 px-3">已過帳</Badge>
|
<StatusBadge variant="success" className="border-none py-1 px-3">已過帳</StatusBadge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 mt-1 font-medium flex items-center gap-2">
|
<p className="text-sm text-gray-500 mt-1 font-medium flex items-center gap-2">
|
||||||
@@ -604,6 +604,6 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
442
resources/js/Pages/Inventory/Analysis/Index.tsx
Normal file
442
resources/js/Pages/Inventory/Analysis/Index.tsx
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import {
|
||||||
|
Filter,
|
||||||
|
Package,
|
||||||
|
RotateCcw,
|
||||||
|
BarChart3,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
XCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import { Head, router } from "@inertiajs/react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/Components/ui/table";
|
||||||
|
import Pagination from "@/Components/shared/Pagination";
|
||||||
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
|
import { PageProps } from "@/types/global";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/Components/ui/tooltip";
|
||||||
|
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||||
|
|
||||||
|
interface AnalysisItem {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
category_name: string;
|
||||||
|
current_stock: string; // decimal string from DB
|
||||||
|
sales_30d: string;
|
||||||
|
last_sale_date: string | null;
|
||||||
|
turnover_days: number;
|
||||||
|
turnover_days_display: string;
|
||||||
|
status: 'dead' | 'slow' | 'normal' | 'out_of_stock';
|
||||||
|
status_label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KPIProps {
|
||||||
|
total_stock_value: number;
|
||||||
|
dead_stock_value: number;
|
||||||
|
dead_stock_count: number;
|
||||||
|
avg_turnover_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PagePropsWithData extends PageProps {
|
||||||
|
analysisData: {
|
||||||
|
data: AnalysisItem[];
|
||||||
|
links: any[];
|
||||||
|
total: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
current_page: number;
|
||||||
|
};
|
||||||
|
kpis: KPIProps;
|
||||||
|
warehouses: { id: number; name: string }[];
|
||||||
|
categories: { id: number; name: string }[];
|
||||||
|
filters: {
|
||||||
|
warehouse_id?: string;
|
||||||
|
category_id?: string;
|
||||||
|
search?: string;
|
||||||
|
per_page?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define status mapping
|
||||||
|
const getStatusVariant = (status: string): StatusVariant => {
|
||||||
|
switch (status) {
|
||||||
|
case 'dead': return 'destructive';
|
||||||
|
case 'slow': return 'warning';
|
||||||
|
case 'normal': return 'success';
|
||||||
|
case 'out_of_stock': return 'neutral';
|
||||||
|
default: return 'neutral';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'dead': return '滯銷';
|
||||||
|
case 'slow': return '週轉慢';
|
||||||
|
case 'normal': return '正常';
|
||||||
|
case 'out_of_stock': return '缺貨';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: "全部狀態", value: "all" },
|
||||||
|
{ label: "滯銷 (>90天)", value: "dead" },
|
||||||
|
{ label: "週轉慢 (>60天)", value: "slow" },
|
||||||
|
{ label: "正常", value: "normal" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InventoryAnalysisIndex({ analysisData, kpis, warehouses, categories, filters }: PagePropsWithData) {
|
||||||
|
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || "all");
|
||||||
|
const [categoryId, setCategoryId] = useState(filters.category_id || "all");
|
||||||
|
const [search, setSearch] = useState(filters.search || "");
|
||||||
|
const [status, setStatus] = useState(filters.status || "all");
|
||||||
|
const [perPage, setPerPage] = useState(filters.per_page?.toString() || "10");
|
||||||
|
|
||||||
|
const handleFilter = useCallback(() => {
|
||||||
|
router.get(
|
||||||
|
route("inventory.analysis.index"),
|
||||||
|
{
|
||||||
|
warehouse_id: warehouseId === "all" ? "" : warehouseId,
|
||||||
|
category_id: categoryId === "all" ? "" : categoryId,
|
||||||
|
status: status === "all" ? "" : status,
|
||||||
|
search: search,
|
||||||
|
per_page: perPage,
|
||||||
|
sort_by: filters.sort_by,
|
||||||
|
sort_order: filters.sort_order,
|
||||||
|
},
|
||||||
|
{ preserveState: true, preserveScroll: true }
|
||||||
|
);
|
||||||
|
}, [warehouseId, categoryId, status, search, perPage, filters.sort_by, filters.sort_order]);
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setWarehouseId("all");
|
||||||
|
setCategoryId("all");
|
||||||
|
setStatus("all");
|
||||||
|
setSearch("");
|
||||||
|
setPerPage("10");
|
||||||
|
router.get(route("inventory.analysis.index"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
let newSortBy: string | undefined = field;
|
||||||
|
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||||
|
|
||||||
|
if (filters.sort_by === field) {
|
||||||
|
if (filters.sort_order === 'asc') {
|
||||||
|
newSortOrder = 'desc';
|
||||||
|
} else {
|
||||||
|
newSortBy = undefined;
|
||||||
|
newSortOrder = undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default sort order for numeric fields might be desc
|
||||||
|
if (['turnover_days', 'current_stock', 'sales_30d'].includes(field)) {
|
||||||
|
newSortOrder = 'desc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
route("inventory.analysis.index"),
|
||||||
|
{
|
||||||
|
warehouse_id: warehouseId === "all" ? "" : warehouseId,
|
||||||
|
category_id: categoryId === "all" ? "" : categoryId,
|
||||||
|
status: status === "all" ? "" : status,
|
||||||
|
search: search,
|
||||||
|
per_page: perPage,
|
||||||
|
sort_by: newSortBy,
|
||||||
|
sort_order: newSortOrder,
|
||||||
|
},
|
||||||
|
{ preserveState: true, preserveScroll: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePerPageChange = (value: string) => {
|
||||||
|
setPerPage(value);
|
||||||
|
// Trigger filter immediately
|
||||||
|
router.get(
|
||||||
|
route("inventory.analysis.index"),
|
||||||
|
{
|
||||||
|
warehouse_id: warehouseId === "all" ? "" : warehouseId,
|
||||||
|
category_id: categoryId === "all" ? "" : categoryId,
|
||||||
|
status: status === "all" ? "" : status,
|
||||||
|
search: search,
|
||||||
|
per_page: value,
|
||||||
|
sort_by: filters.sort_by,
|
||||||
|
sort_order: filters.sort_order,
|
||||||
|
},
|
||||||
|
{ preserveState: true, preserveScroll: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortIcon = ({ field }: { field: string }) => {
|
||||||
|
if (filters.sort_by !== field) {
|
||||||
|
return <ArrowUpDown className="h-4 w-4 text-gray-300 ml-1" />;
|
||||||
|
}
|
||||||
|
if (filters.sort_order === "asc") {
|
||||||
|
return <ArrowUp className="h-4 w-4 text-primary-main ml-1" />;
|
||||||
|
}
|
||||||
|
return <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout breadcrumbs={[{ label: "報表管理", href: "#" }, { label: "庫存分析", href: route("inventory.analysis.index"), isPage: true }]}>
|
||||||
|
<Head title="庫存分析" />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-6 w-6 text-primary-main" />
|
||||||
|
庫存分析
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
分析商品庫存週轉率、滯銷品項與庫存健康度
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-blue-50 rounded-lg text-blue-600">
|
||||||
|
<Clock className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 font-medium">平均週轉天數</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{kpis.avg_turnover_days} <span className="text-sm font-normal text-gray-500">天</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-red-50 rounded-lg text-red-600">
|
||||||
|
<AlertTriangle className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 font-medium">滯銷品項數</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{kpis.dead_stock_count} <span className="text-sm font-normal text-gray-500">項</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-orange-50 rounded-lg text-orange-600">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 font-medium">滯銷庫存成本</p>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 cursor-help">${Number(kpis.dead_stock_value).toLocaleString()}</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>在此定義為庫存大於 0 且超過 90 天未銷售的商品成本總和</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-emerald-50 rounded-lg text-emerald-600">
|
||||||
|
<CheckCircle2 className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 font-medium">庫存總成本</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">${Number(kpis.total_stock_value).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="md:col-span-3 space-y-1">
|
||||||
|
<Label className="text-xs text-grey-2 font-medium">關鍵字</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="搜尋商品代碼或名稱..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="h-9 bg-white"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warehouse & Category */}
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-grey-2 font-medium">倉庫</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={warehouseId}
|
||||||
|
onValueChange={setWarehouseId}
|
||||||
|
options={[{ label: "全部倉庫", value: "all" }, ...warehouses.map(w => ({ label: w.name, value: w.id.toString() }))]}
|
||||||
|
className="w-full h-9"
|
||||||
|
placeholder="選擇倉庫..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-grey-2 font-medium">分類</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={categoryId}
|
||||||
|
onValueChange={setCategoryId}
|
||||||
|
options={[{ label: "全部分類", value: "all" }, ...categories.map(c => ({ label: c.name, value: c.id.toString() }))]}
|
||||||
|
className="w-full h-9"
|
||||||
|
placeholder="選擇分類..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-grey-2 font-medium">狀態</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={status}
|
||||||
|
onValueChange={setStatus}
|
||||||
|
options={statusOptions}
|
||||||
|
className="w-full h-9"
|
||||||
|
placeholder="選擇狀態..."
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Action Buttons Integrated */}
|
||||||
|
<div className="md:col-span-3 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" /> 查詢
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Table */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[120px] cursor-pointer" onClick={() => handleSort('products.code')}>
|
||||||
|
<div className="flex items-center">商品代碼 <SortIcon field="products.code" /></div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="cursor-pointer" onClick={() => handleSort('products.name')}>
|
||||||
|
<div className="flex items-center">商品名稱 <SortIcon field="products.name" /></div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[100px]">分類</TableHead>
|
||||||
|
<TableHead className="text-right w-[100px] cursor-pointer" onClick={() => handleSort('current_stock')}>
|
||||||
|
<div className="flex items-center justify-end">現有庫存 <SortIcon field="current_stock" /></div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right w-[100px] cursor-pointer" onClick={() => handleSort('sales_30d')}>
|
||||||
|
<div className="flex items-center justify-end">30天銷量 <SortIcon field="sales_30d" /></div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right w-[120px] cursor-pointer" onClick={() => handleSort('turnover_days')}>
|
||||||
|
<div className="flex items-center justify-end">週轉天數 <SortIcon field="turnover_days" /></div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right w-[120px] cursor-pointer" onClick={() => handleSort('last_sale_date')}>
|
||||||
|
<div className="flex items-center justify-end">最後銷售 <SortIcon field="last_sale_date" /></div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">狀態</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{analysisData.data.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8}>
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400">
|
||||||
|
<Package className="h-10 w-10 opacity-20" />
|
||||||
|
<p>無符合條件的資料</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
analysisData.data.map((row) => (
|
||||||
|
<TableRow key={row.id} className="hover:bg-gray-50/50 transition-colors">
|
||||||
|
<TableCell className="font-medium text-gray-900">
|
||||||
|
{row.code}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-700">
|
||||||
|
{row.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500">{row.category_name || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{Number(row.current_stock).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-600">
|
||||||
|
{Number(row.sales_30d).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-bold text-gray-800">
|
||||||
|
{row.turnover_days_display}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-gray-500 text-sm">
|
||||||
|
{row.last_sale_date ? row.last_sale_date.split(' ')[0] : '從未銷售'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<StatusBadge variant={getStatusVariant(row.status)}>
|
||||||
|
{getStatusLabel(row.status)}
|
||||||
|
</StatusBadge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Footer */}
|
||||||
|
<div className="mt-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>每頁顯示</span>
|
||||||
|
<SearchableSelect
|
||||||
|
value={perPage}
|
||||||
|
onValueChange={handlePerPageChange}
|
||||||
|
options={[
|
||||||
|
{ label: "10", value: "10" },
|
||||||
|
{ label: "20", value: "20" },
|
||||||
|
{ label: "50", value: "50" },
|
||||||
|
{ label: "100", value: "100" }
|
||||||
|
]}
|
||||||
|
className="w-[100px] h-8"
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<span>筆</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
|
||||||
|
<Pagination links={analysisData.links} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user