Compare commits
45 Commits
bb2cf77ccb
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba6b3a1cd | |||
| ec239279f4 | |||
| e2c36e9c0f | |||
| 30bf8ef79c | |||
| 590d1ea9e9 | |||
| cd0f454c98 | |||
| 54e1e5df5a | |||
| 8e3d951d0d | |||
| d04e5bbffb | |||
| 27273bfee4 | |||
| 2a88649f75 | |||
| e9313158ba | |||
| f3da49a76a | |||
| 747f70865d | |||
| 6bb2afa3b7 | |||
| 59008eb59e | |||
| a33e470e4d | |||
| 71b676b533 | |||
| 406d03297a | |||
| 4259c7745b | |||
| 8169ff3f59 | |||
| 1acc4daebb | |||
| 1acbfb7246 | |||
| e02d7c7125 | |||
| a133b94a05 | |||
| acd0590a38 | |||
| a2fe7b5a95 | |||
| 5f1f08869f | |||
| e85c1fa95a | |||
| 62dcf04e95 | |||
| 6dd3396fb7 | |||
| 2f30a78118 | |||
| 904132e460 | |||
| a05acd96dc | |||
| 29cdf37b71 | |||
| d7d1be81a9 | |||
| 227cfec0d2 | |||
| 034a21cd31 | |||
| 6358e23816 | |||
| ac149533f0 | |||
| b20a47f710 | |||
| d017d7e5e0 | |||
| 8207e6fe94 | |||
| e6cf03b991 | |||
| 8ef82d49cb |
@@ -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. 頁面佈局規範
|
||||||
|
|||||||
@@ -44,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
|
||||||
|
|||||||
100
.gitea/workflows/deploy-demo.yaml
Normal file
100
.gitea/workflows/deploy-demo.yaml
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
name: ERP-Deploy-Demo
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- demo
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-demo:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
github-server-url: https://gitea.taiwan-star.com.tw
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Step 1 - Push Code to Demo
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y rsync openssh-client
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
||||||
|
chmod 600 ~/.ssh/id_rsa_demo
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='vendor' \
|
||||||
|
--exclude='storage' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='public/build' \
|
||||||
|
-e "ssh -p 2227 -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
||||||
|
./ root@220.132.7.82:/var/www/star-erp-demo/
|
||||||
|
rm ~/.ssh/id_rsa_demo
|
||||||
|
|
||||||
|
- name: Step 2 - Check if Rebuild Needed
|
||||||
|
id: check_rebuild
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 220.132.7.82
|
||||||
|
port: 2227
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /var/www/star-erp-demo
|
||||||
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||||
|
echo "REBUILD_NEEDED=true"
|
||||||
|
else
|
||||||
|
echo "REBUILD_NEEDED=false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Step 3 - Container Up & Health Check
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 220.132.7.82
|
||||||
|
port: 2227
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /var/www/star-erp-demo
|
||||||
|
chown -R 1000:1000 .
|
||||||
|
|
||||||
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.demo\.yaml|docker-compose\.yaml)'; then
|
||||||
|
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --build --wait
|
||||||
|
else
|
||||||
|
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
|
||||||
|
echo "容器未運行,正在啟動..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.demo.yaml up -d --wait
|
||||||
|
else
|
||||||
|
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
|
||||||
|
|
||||||
|
- name: Step 4 - Composer & NPM Build
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 220.132.7.82
|
||||||
|
port: 2227
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.DEMO_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||||
|
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||||
|
npm install &&
|
||||||
|
npm run build &&
|
||||||
|
rm -f public/hot &&
|
||||||
|
php artisan storage:link &&
|
||||||
|
php artisan migrate --force &&
|
||||||
|
php artisan tenants:migrate --force &&
|
||||||
|
php artisan db:seed --force &&
|
||||||
|
php artisan tenants:run db:seed --option=\"class=PermissionSeeder\" --option=\"force=true\" &&
|
||||||
|
php artisan permission:cache-reset &&
|
||||||
|
php artisan optimize:clear &&
|
||||||
|
php artisan optimize &&
|
||||||
|
php artisan view:cache
|
||||||
|
"
|
||||||
|
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||||
93
.gitea/workflows/deploy-prod.yaml
Normal file
93
.gitea/workflows/deploy-prod.yaml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
name: ERP-Deploy-Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-production:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Step 1 - Push Code to Production
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y rsync openssh-client
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_rsa_prod
|
||||||
|
chmod 600 ~/.ssh/id_rsa_prod
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='vendor' \
|
||||||
|
--exclude='storage' \
|
||||||
|
--exclude='public/build' \
|
||||||
|
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
|
||||||
|
./ root@220.132.7.82:/var/www/star-erp/
|
||||||
|
rm ~/.ssh/id_rsa_prod
|
||||||
|
|
||||||
|
- name: Step 2 - Check if Rebuild Needed
|
||||||
|
id: check_rebuild_prod
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 220.132.7.82
|
||||||
|
port: 2224
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.PROD_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /var/www/star-erp
|
||||||
|
|
||||||
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
||||||
|
echo "REBUILD_NEEDED=true"
|
||||||
|
else
|
||||||
|
echo "REBUILD_NEEDED=false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Step 3 - Container Up & Health Check
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: 220.132.7.82
|
||||||
|
port: 2224
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.PROD_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /var/www/star-erp
|
||||||
|
chown -R 1000:1000 .
|
||||||
|
|
||||||
|
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|compose\.prod\.yaml|docker-compose\.yaml)'; then
|
||||||
|
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.prod.yaml up -d --build --wait
|
||||||
|
else
|
||||||
|
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
|
||||||
|
echo "容器未運行,正在啟動..."
|
||||||
|
WWWGROUP=1000 WWWUSER=1000 docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
|
||||||
|
else
|
||||||
|
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
|
||||||
|
|
||||||
|
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
||||||
|
composer install --no-dev --optimize-autoloader &&
|
||||||
|
npm install &&
|
||||||
|
npm run build &&
|
||||||
|
rm -f public/hot
|
||||||
|
|
||||||
|
php artisan storage:link &&
|
||||||
|
php artisan migrate --force &&
|
||||||
|
php artisan tenants:migrate --force &&
|
||||||
|
php artisan db:seed --force &&
|
||||||
|
php artisan tenants:run db:seed --option=\"class=PermissionSeeder\" --option=\"force=true\" &&
|
||||||
|
php artisan permission:cache-reset &&
|
||||||
|
php artisan optimize:clear &&
|
||||||
|
php artisan optimize &&
|
||||||
|
php artisan view:cache
|
||||||
|
"
|
||||||
|
docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
name: Koori-ERP-Deploy-System
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- demo
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# --- 1. Demo 環境部署 (103 本機) ---
|
|
||||||
deploy-demo:
|
|
||||||
if: false
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
repository: ${{ github.repository }}
|
|
||||||
|
|
||||||
- name: Step 1 - Push Code to Demo
|
|
||||||
run: |
|
|
||||||
apt-get update && apt-get install -y rsync openssh-client
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${{ secrets.DEMO_SSH_KEY }}" > ~/.ssh/id_rsa_demo
|
|
||||||
chmod 600 ~/.ssh/id_rsa_demo
|
|
||||||
rsync -avz --delete \
|
|
||||||
--exclude='.git' \
|
|
||||||
--exclude='node_modules' \
|
|
||||||
--exclude='vendor' \
|
|
||||||
--exclude='storage' \
|
|
||||||
--exclude='.env' \
|
|
||||||
--exclude='public/build' \
|
|
||||||
-e "ssh -i ~/.ssh/id_rsa_demo -o StrictHostKeyChecking=no" \
|
|
||||||
./ amba@192.168.0.103:/home/amba/star-erp/
|
|
||||||
rm ~/.ssh/id_rsa_demo
|
|
||||||
|
|
||||||
- name: Step 2 - Check if Rebuild Needed
|
|
||||||
id: check_rebuild
|
|
||||||
uses: appleboy/ssh-action@master
|
|
||||||
with:
|
|
||||||
host: 192.168.0.103
|
|
||||||
port: 22
|
|
||||||
username: amba
|
|
||||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
cd /home/amba/star-erp
|
|
||||||
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: 192.168.0.103
|
|
||||||
port: 22
|
|
||||||
username: amba
|
|
||||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
cd /home/amba/koori-erp
|
|
||||||
chown -R 1000:1000 .
|
|
||||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
|
||||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
|
||||||
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --build --wait
|
|
||||||
else
|
|
||||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
|
||||||
if ! docker ps --format '{{.Names}}' | grep -q 'koori-erp-laravel'; then
|
|
||||||
echo "容器未運行,正在啟動..."
|
|
||||||
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --wait
|
|
||||||
else
|
|
||||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "容器狀態:" && docker ps --filter "name=koori-erp-laravel"
|
|
||||||
|
|
||||||
- name: Step 4 - Composer & NPM Build
|
|
||||||
uses: appleboy/ssh-action@master
|
|
||||||
with:
|
|
||||||
host: 192.168.0.103
|
|
||||||
port: 22
|
|
||||||
username: amba
|
|
||||||
key: ${{ secrets.DEMO_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
|
||||||
# 0. 更新版本號 (直接使用 Github Actions 變數注入)
|
|
||||||
VERSION=\"v1.0-$(echo '${{ github.sha }}' | cut -c1-7)\"
|
|
||||||
sed -i \"s/^APP_VERSION=.*/APP_VERSION=\$VERSION/\" .env || echo \"APP_VERSION=\$VERSION\" >> .env
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# --- 2. 正式環境部署 (erp.koori.tw:2224) ---
|
|
||||||
deploy-production:
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
repository: ${{ github.repository }}
|
|
||||||
|
|
||||||
- name: Step 1 - Push Code to Production
|
|
||||||
run: |
|
|
||||||
apt-get update && apt-get install -y rsync openssh-client
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_rsa_prod
|
|
||||||
chmod 600 ~/.ssh/id_rsa_prod
|
|
||||||
rsync -avz --delete \
|
|
||||||
--exclude='.git' \
|
|
||||||
--exclude='.env' \
|
|
||||||
--exclude='node_modules' \
|
|
||||||
--exclude='vendor' \
|
|
||||||
--exclude='storage' \
|
|
||||||
--exclude='public/build' \
|
|
||||||
-e "ssh -p 2224 -i ~/.ssh/id_rsa_prod -o StrictHostKeyChecking=no" \
|
|
||||||
./ root@erp.koori.tw:/var/www/star-erp/
|
|
||||||
rm ~/.ssh/id_rsa_prod
|
|
||||||
|
|
||||||
|
|
||||||
# 2. 檢查是否需要重建容器(只有 Dockerfile 或 compose.yaml 變動時才重建)
|
|
||||||
- name: Step 2 - Check if Rebuild Needed
|
|
||||||
id: check_rebuild_prod
|
|
||||||
uses: appleboy/ssh-action@master
|
|
||||||
with:
|
|
||||||
host: erp.koori.tw
|
|
||||||
port: 2224
|
|
||||||
username: root
|
|
||||||
key: ${{ secrets.PROD_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
cd /var/www/star-erp
|
|
||||||
# [Patch] 修正正式機 Nginx Proxy 配置 (對應外部 SSL/OpenResty)
|
|
||||||
sed -i "s/- '8080:8080'/- '80:80'\n - '8080:8080'/" compose.yaml
|
|
||||||
sed -i "s/demo-proxy.conf/prod-proxy.conf/" compose.yaml
|
|
||||||
|
|
||||||
# 檢查最近的 commit 是否包含 Dockerfile 或 compose.yaml 的變更
|
|
||||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
|
||||||
echo "REBUILD_NEEDED=true"
|
|
||||||
else
|
|
||||||
echo "REBUILD_NEEDED=false"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. 啟動或重建容器(根據檢查結果決定是否加 --build)
|
|
||||||
- name: Step 3 - Container Up & Health Check
|
|
||||||
uses: appleboy/ssh-action@master
|
|
||||||
with:
|
|
||||||
host: erp.koori.tw
|
|
||||||
port: 2224
|
|
||||||
username: root
|
|
||||||
key: ${{ secrets.PROD_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
cd /var/www/star-erp
|
|
||||||
chown -R 1000:1000 .
|
|
||||||
# 檢查是否需要重建
|
|
||||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE '(Dockerfile|compose\.yaml|docker-compose\.yaml)'; then
|
|
||||||
echo "🔄 偵測到 Docker 相關檔案變更,執行完整重建..."
|
|
||||||
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --build --wait
|
|
||||||
else
|
|
||||||
echo "⚡ 無 Docker 檔案變更,僅重載服務..."
|
|
||||||
if ! docker ps --format '{{.Names}}' | grep -q 'star-erp-laravel'; then
|
|
||||||
echo "容器未運行,正在啟動..."
|
|
||||||
WWWGROUP=1000 WWWUSER=1000 docker compose up -d --wait
|
|
||||||
else
|
|
||||||
echo "容器已運行,跳過 docker compose,直接進行程式碼部署..."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "容器狀態:" && docker ps --filter "name=star-erp-laravel"
|
|
||||||
|
|
||||||
docker exec -u 1000:1000 -w /var/www/html star-erp-laravel sh -c "
|
|
||||||
# 0. 更新版本號 (直接注入)
|
|
||||||
VERSION=\"v1.0-$(echo '${{ github.sha }}' | cut -c1-7)\"
|
|
||||||
sed -i \"s/^APP_VERSION=.*/APP_VERSION=\$VERSION/\" .env || echo \"APP_VERSION=\$VERSION\" >> .env
|
|
||||||
|
|
||||||
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
|
|
||||||
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;
|
|
||||||
|
|
||||||
foreach ($request->items as $itemData) {
|
|
||||||
// Find product by external ID (Strict Check)
|
|
||||||
$product = Product::where('external_pos_id', $itemData['pos_product_id'])->first();
|
|
||||||
|
|
||||||
if (!$product) {
|
|
||||||
throw new \Exception("Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$qty = $itemData['qty'];
|
|
||||||
$price = $itemData['price'];
|
|
||||||
$lineTotal = $qty * $price;
|
|
||||||
$totalAmount += $lineTotal;
|
|
||||||
|
|
||||||
// 2. Create Order Item
|
|
||||||
SalesOrderItem::create([
|
|
||||||
'sales_order_id' => $order->id,
|
|
||||||
'product_id' => $product->id,
|
|
||||||
'product_name' => $product->name, // Snapshot name
|
|
||||||
'quantity' => $qty,
|
|
||||||
'price' => $price,
|
|
||||||
'total' => $lineTotal,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 3. Deduct Stock (Force negative allowed for POS orders)
|
|
||||||
$this->inventoryService->decreaseStock(
|
|
||||||
$product->id,
|
|
||||||
$warehouseId,
|
|
||||||
$qty,
|
|
||||||
"POS Order: " . $order->external_order_id,
|
|
||||||
true // Force = true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$order->update(['total_amount' => $totalAmount]);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Order synced and stock deducted successfully',
|
'message' => $result['message'],
|
||||||
'order_id' => $order->id,
|
'order_id' => $result['order_id'] ?? null,
|
||||||
], 201);
|
], $statusCode);
|
||||||
});
|
|
||||||
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||||
|
// 捕捉 Action 中拋出的預先驗證錯誤 (如查無商品、或鎖定逾時)
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed',
|
||||||
|
'errors' => $e->errors()
|
||||||
|
], 422);
|
||||||
|
|
||||||
} 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -590,5 +590,30 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
'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/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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
7
compose.demo.yaml
Normal file
7
compose.demo.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
proxy:
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '8080:8080'
|
||||||
|
volumes:
|
||||||
|
- './nginx/demo-proxy.conf:/etc/nginx/conf.d/default.conf:ro'
|
||||||
7
compose.prod.yaml
Normal file
7
compose.prod.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
proxy:
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '8080:8080'
|
||||||
|
volumes:
|
||||||
|
- './nginx/prod-proxy.conf:/etc/nginx/conf.d/default.conf:ro'
|
||||||
17
compose.yaml
17
compose.yaml
@@ -6,8 +6,8 @@ services:
|
|||||||
args:
|
args:
|
||||||
WWWGROUP: '${WWWGROUP}'
|
WWWGROUP: '${WWWGROUP}'
|
||||||
image: 'sail-8.5/app'
|
image: 'sail-8.5/app'
|
||||||
container_name: star-erp-laravel
|
container_name: laravel
|
||||||
hostname: star-erp-laravel
|
hostname: laravel
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- 'host.docker.internal:host-gateway'
|
- 'host.docker.internal:host-gateway'
|
||||||
ports:
|
ports:
|
||||||
@@ -29,8 +29,8 @@ services:
|
|||||||
# - mailpit
|
# - mailpit
|
||||||
mysql:
|
mysql:
|
||||||
image: 'mysql/mysql-server:8.0'
|
image: 'mysql/mysql-server:8.0'
|
||||||
container_name: star-erp-mysql
|
container_name: mysql
|
||||||
hostname: star-erp-mysql
|
hostname: mysql
|
||||||
ports:
|
ports:
|
||||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
- '${FORWARD_DB_PORT:-3306}:3306'
|
||||||
environment:
|
environment:
|
||||||
@@ -56,8 +56,8 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
redis:
|
redis:
|
||||||
image: 'redis:alpine'
|
image: 'redis:alpine'
|
||||||
container_name: star-erp-redis
|
container_name: redis
|
||||||
hostname: star-erp-redis
|
hostname: redis
|
||||||
# ports:
|
# ports:
|
||||||
# - '${FORWARD_REDIS_PORT:-6379}:6379'
|
# - '${FORWARD_REDIS_PORT:-6379}:6379'
|
||||||
volumes:
|
volumes:
|
||||||
@@ -74,11 +74,6 @@ services:
|
|||||||
proxy:
|
proxy:
|
||||||
image: 'nginx:alpine'
|
image: 'nginx:alpine'
|
||||||
container_name: star-erp-proxy
|
container_name: star-erp-proxy
|
||||||
ports:
|
|
||||||
- '8080:8080'
|
|
||||||
- '8081:8081'
|
|
||||||
volumes:
|
|
||||||
- './nginx/demo-proxy.conf:/etc/nginx/conf.d/default.conf:ro'
|
|
||||||
networks:
|
networks:
|
||||||
- sail
|
- sail
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -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,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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -139,6 +139,9 @@ class PermissionSeeder extends Seeder
|
|||||||
'store_requisitions.delete' => '刪除',
|
'store_requisitions.delete' => '刪除',
|
||||||
'store_requisitions.approve' => '核準',
|
'store_requisitions.approve' => '核準',
|
||||||
'store_requisitions.cancel' => '取消',
|
'store_requisitions.cancel' => '取消',
|
||||||
|
|
||||||
|
// 銷售訂單管理 (API)
|
||||||
|
'sales_orders.view' => '檢視',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($permissions as $name => $displayName) {
|
foreach ($permissions as $name => $displayName) {
|
||||||
@@ -222,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(如果存在的話)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ WORKDIR /var/www/html
|
|||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80"
|
ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=8080"
|
||||||
ENV SUPERVISOR_PHP_USER="sail"
|
ENV SUPERVISOR_PHP_USER="sail"
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=0
|
ENV PLAYWRIGHT_BROWSERS_PATH=0
|
||||||
|
|
||||||
@@ -75,8 +75,6 @@ RUN apt-get update && apt-get upgrade -y \
|
|||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.5
|
|
||||||
|
|
||||||
RUN userdel -r ubuntu
|
RUN userdel -r ubuntu
|
||||||
RUN groupadd --force -g $WWWGROUP sail
|
RUN groupadd --force -g $WWWGROUP sail
|
||||||
RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail
|
RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail
|
||||||
@@ -87,6 +85,6 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
|||||||
COPY php.ini /etc/php/8.5/cli/conf.d/99-sail.ini
|
COPY php.ini /etc/php/8.5/cli/conf.d/99-sail.ini
|
||||||
RUN chmod +x /usr/local/bin/start-container
|
RUN chmod +x /usr/local/bin/start-container
|
||||||
|
|
||||||
EXPOSE 80/tcp
|
EXPOSE 8080/tcp
|
||||||
|
|
||||||
ENTRYPOINT ["start-container"]
|
ENTRYPOINT ["start-container"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
# 總後台 (landlord) - 端口 8080
|
# Demo 環境 (Demo) - 端口 80
|
||||||
server {
|
# 外部 SSL 終止後(如 Cloudflare/NPM)轉發至此端口
|
||||||
listen 8080;
|
|
||||||
server_name 192.168.0.103;
|
|
||||||
|
|
||||||
location / {
|
# 定義 map 以正確處理 X-Forwarded-Proto
|
||||||
proxy_pass http://star-erp-laravel:80;
|
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
||||||
proxy_set_header Host star-erp.demo;
|
default $http_x_forwarded_proto;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
'' $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host:$server_port;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# koori 租戶 - 端口 8081
|
|
||||||
server {
|
server {
|
||||||
listen 8081;
|
listen 80;
|
||||||
server_name 192.168.0.103;
|
server_name demo-erp.taiwan-star.com.tw;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://star-erp-laravel:80;
|
proxy_pass http://star-erp-laravel:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Host $host:$server_port;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ server {
|
|||||||
server_name erp.koori.tw erp.mamaiclub.com;
|
server_name erp.koori.tw erp.mamaiclub.com;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://star-erp-laravel:80;
|
proxy_pass http://star-erp-laravel:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
1531
package-lock.json
generated
1531
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,7 +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",
|
"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,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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -249,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",
|
||||||
@@ -278,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",
|
||||||
|
// 手冊開放給所有登入使用者
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -295,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;
|
// 若有子項目符合權限,則顯示該群組(群組本身的權限僅作為額外過濾)
|
||||||
|
if (filteredChildren.length > 0) {
|
||||||
return { ...item, children: filteredChildren };
|
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[]>(() => {
|
||||||
@@ -622,13 +647,13 @@ export default function AuthenticatedLayout({
|
|||||||
|
|
||||||
{/* 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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
resources/js/Pages/System/Manual/Index.tsx
Normal file
160
resources/js/Pages/System/Manual/Index.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { Head, Link } from "@inertiajs/react";
|
||||||
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Search,
|
||||||
|
Menu,
|
||||||
|
FileText,
|
||||||
|
HelpCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Page {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
title: string;
|
||||||
|
pages: Page[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
toc: Section[];
|
||||||
|
currentSlug: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManualIndex({ toc, currentSlug, content }: Props) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
|
|
||||||
|
// Filter TOC based on search
|
||||||
|
const filteredToc = toc.map(section => ({
|
||||||
|
...section,
|
||||||
|
pages: section.pages.filter(page =>
|
||||||
|
page.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
section.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
})).filter(section => section.pages.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout breadcrumbs={[
|
||||||
|
{ label: "系統管理", href: "#" },
|
||||||
|
{ label: "操作手冊", href: route('system.manual.index'), isPage: true }
|
||||||
|
]}>
|
||||||
|
<Head title="操作手冊" />
|
||||||
|
|
||||||
|
<div className="flex h-[calc(100vh-140px)] bg-slate-50/50 rounded-xl border border-slate-200 shadow-sm overflow-hidden m-2 md:m-6">
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={cn(
|
||||||
|
"w-72 border-r border-slate-200 bg-white flex flex-col transition-all duration-300",
|
||||||
|
!isSidebarOpen && "w-0 opacity-0 overflow-hidden"
|
||||||
|
)}>
|
||||||
|
<div className="p-5 border-b border-slate-100 bg-slate-50/30">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜尋功能手冊..."
|
||||||
|
className="pl-10 h-10 bg-white border-slate-200 focus:ring-2 focus:ring-primary-lighter transition-all placeholder:text-slate-400"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-4 space-y-8">
|
||||||
|
{filteredToc.map((section, idx) => (
|
||||||
|
<div key={idx} className="space-y-2">
|
||||||
|
<h3 className="px-3 text-[11px] font-bold text-slate-400 uppercase tracking-[0.1em]">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{section.pages.map((page) => (
|
||||||
|
<Link
|
||||||
|
key={page.slug}
|
||||||
|
href={route('system.manual.index', { slug: page.slug })}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all",
|
||||||
|
currentSlug === page.slug
|
||||||
|
? "bg-primary-main text-white shadow-md shadow-primary-main/20"
|
||||||
|
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileText className={cn("h-4 w-4 shrink-0", currentSlug === page.slug ? "text-white" : "text-slate-400")} />
|
||||||
|
<span className="truncate">{page.title}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 flex flex-col min-w-0 bg-white">
|
||||||
|
{/* Content Header mobile toggle */}
|
||||||
|
<div className="h-14 shrink-0 border-b border-slate-100 flex items-center px-6 gap-3 bg-white/80 backdrop-blur-md sticky top-0 z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
className="p-2 hover:bg-slate-100 rounded-lg text-slate-500 transition-colors border border-transparent hover:border-slate-200"
|
||||||
|
title={isSidebarOpen ? "收起選單" : "展開選單"}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div className="h-5 w-px bg-slate-200 mx-1" />
|
||||||
|
<div className="flex items-center gap-2 text-slate-800">
|
||||||
|
<BookOpen className="h-5 w-5 text-primary-main" />
|
||||||
|
<span className="text-sm font-bold tracking-tight">操作指南</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto bg-white scroll-smooth" id="manual-content-scroll">
|
||||||
|
<style dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
#manual-article { font-size: 15px; line-height: 1.6; color: #475569; }
|
||||||
|
#manual-article h1 { font-size: 1.75rem; margin-top: 0 !important; margin-bottom: 0.75rem !important; padding-bottom: 0.5rem; border-bottom: 1px solid #f1f5f9; font-weight: 800; color: #0f172a; }
|
||||||
|
#manual-article h2 { font-size: 1.25rem; margin-top: 1.25rem !important; margin-bottom: 0.5rem !important; font-weight: 700; color: #1e293b; }
|
||||||
|
#manual-article h3 { font-size: 1.1rem; margin-top: 1rem !important; margin-bottom: 0.4rem !important; font-weight: 600; color: #334155; }
|
||||||
|
#manual-article p { margin-top: 0.4rem !important; margin-bottom: 0.4rem !important; }
|
||||||
|
#manual-article ul, #manual-article ol { margin-top: 0.4rem !important; margin-bottom: 0.4rem !important; padding-left: 1.25rem; }
|
||||||
|
#manual-article li { margin-top: 0.2rem !important; margin-bottom: 0.2rem !important; }
|
||||||
|
#manual-article blockquote { margin: 0.75rem 0 !important; padding: 0.25rem 1rem !important; border-left: 4px solid var(--primary-main); background: #f8fafc; border-radius: 0 4px 4px 0; }
|
||||||
|
#manual-article img { margin: 1rem 0 !important; border-radius: 8px; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
|
||||||
|
#manual-article code { padding: 0.1rem 0.3rem; background: #e6f7f3; color: #018a6a; border-radius: 4px; font-size: 0.85em; font-family: ui-monospace, monospace; }
|
||||||
|
#manual-article pre { margin: 0.75rem 0 !important; padding: 1rem !important; background: #f8fafc; color: #334155; border: 1px solid #e2e8f0; border-radius: 8px; overflow-x: auto; box-shadow: inset 0 1px 2px rgba(0,0,0,0.02); }
|
||||||
|
#manual-article pre code { background: transparent; padding: 0; color: inherit; font-size: 13.5px; }
|
||||||
|
`}} />
|
||||||
|
<div className="max-w-4xl mx-auto p-4 md:p-10 lg:p-12">
|
||||||
|
<article id="manual-article" className="prose prose-slate max-w-none">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="mt-16 pt-8 border-t border-slate-100 flex flex-col md:flex-row items-center justify-between gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-full border border-slate-200">
|
||||||
|
<HelpCircle className="h-4 w-4 text-primary-main" />
|
||||||
|
<span className="text-slate-600 font-medium whitespace-nowrap">需要更多幫助?請聯繫技術中心 (分機: 8888)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-slate-400">
|
||||||
|
<span>最後更新: 2026-02-13</span>
|
||||||
|
<span className="h-1 w-1 bg-slate-300 rounded-full" />
|
||||||
|
<span className="font-semibold tracking-widest uppercase">Star ERP v1.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
resources/markdown/manual/api-integration.md
Normal file
204
resources/markdown/manual/api-integration.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# 第三方系統 API 對接手冊
|
||||||
|
|
||||||
|
Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS 機或其他第三方系統串接。
|
||||||
|
所有的整合 API 均受到 Laravel Sanctum Token 與多租戶 (Multi-tenant) Middleware 保護。
|
||||||
|
|
||||||
|
## 基礎連線資訊
|
||||||
|
|
||||||
|
- **API Base URL**: `https://[租戶網域]/api/v1/integration` (依實際部署網址為準,單機開發為 `http://localhost/api/v1/integration`)
|
||||||
|
- **Headers 要求**:
|
||||||
|
- `Accept: application/json`
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- `Authorization: Bearer {YOUR_API_TOKEN}` (由 ERP 系統管理員核發的 Sanctum Token)
|
||||||
|
- **速率限制**:每位使用者每分鐘最多 60 次請求。超過時會回傳 `429 Too Many Requests`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 產品資料同步 (Upsert Product)
|
||||||
|
|
||||||
|
此 API 用於將第三方系統(如 POS)的產品資料單向同步至 ERP。採用 Upsert 邏輯:若 `external_pos_id` 存在則更新資料,不存在則新增產品。
|
||||||
|
|
||||||
|
- **Endpoint**: `/products/upsert`
|
||||||
|
- **Method**: `POST`
|
||||||
|
|
||||||
|
### Request Body (JSON)
|
||||||
|
|
||||||
|
| 欄位名稱 | 類型 | 必填 | 說明 |
|
||||||
|
| :--- | :--- | :---: | :--- |
|
||||||
|
| `external_pos_id` | String | **是** | 在 POS 系統中的唯一商品 ID (Primary Key) |
|
||||||
|
| `name` | String | **是** | 商品名稱 (最大 255 字元) |
|
||||||
|
| `price` | Decimal | 否 | 商品售價 (預設 0) |
|
||||||
|
| `barcode` | String | 否 | 商品條碼 (最大 100 字元) |
|
||||||
|
| `category` | String | 否 | 商品分類名稱。若系統中不存在,會自動建立 (最大 100 字元) |
|
||||||
|
| `unit` | String | 否 | 商品單位 (例如:個、杯、件)。若不存在會自動建立 (最大 100 字元) |
|
||||||
|
| `brand` | String | 否 | 商品品牌名稱 (最大 100 字元) |
|
||||||
|
| `specification` | String | 否 | 商品規格描述 (最大 255 字元) |
|
||||||
|
| `cost_price` | Decimal | 否 | 成本價 (預設 0) |
|
||||||
|
| `member_price` | Decimal | 否 | 會員價 (預設 0) |
|
||||||
|
| `wholesale_price` | Decimal | 否 | 批發價 (預設 0) |
|
||||||
|
| `is_active` | Boolean| 否 | 是否上架/啟用 (預設 true) |
|
||||||
|
| `updated_at` | String | 否 | POS 端的最後更新時間 (格式: YYYY-MM-DD HH:mm:ss) |
|
||||||
|
|
||||||
|
**請求範例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"external_pos_id": "POS-PROD-9001",
|
||||||
|
"name": "特級冷壓初榨橄欖油 500ml",
|
||||||
|
"price": 380.00,
|
||||||
|
"barcode": "4711234567890",
|
||||||
|
"category": "調味料",
|
||||||
|
"unit": "瓶",
|
||||||
|
"brand": "健康王",
|
||||||
|
"specification": "500ml / 玻璃瓶裝",
|
||||||
|
"cost_price": 250.00,
|
||||||
|
"member_price": 350.00,
|
||||||
|
"wholesale_price": 300.00,
|
||||||
|
"is_active": true,
|
||||||
|
"updated_at": "2024-03-15 14:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success (HTTP 200)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Product synced successfully",
|
||||||
|
"data": {
|
||||||
|
"id": 15,
|
||||||
|
"external_pos_id": "POS-ITEM-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 訂單資料寫入與扣庫 (Create Order)
|
||||||
|
|
||||||
|
此 API 用於讓第三方系統(如 POS 結帳後)將「已成交」的訂單推送到 ERP。
|
||||||
|
**重要提醒**:寫入訂單的同時,ERP 會自動且**強制**扣除對應倉庫的庫存(允許扣至負數,以利後續盤點或補單)。
|
||||||
|
|
||||||
|
- **Endpoint**: `/orders`
|
||||||
|
- **Method**: `POST`
|
||||||
|
|
||||||
|
### Request Body (JSON)
|
||||||
|
|
||||||
|
| 欄位名稱 | 型態 | 必填 | 說明 |
|
||||||
|
| :--- | :--- | :---: | :--- |
|
||||||
|
| `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) |
|
||||||
|
| `warehouse_id` | Integer | 否 | 指定出貨倉庫的系統 ID (若已知) |
|
||||||
|
| `warehouse` | String | 否 | 指定出貨倉庫名稱。若 `warehouse_id` 與此欄皆未傳,系統將預設寫入並建立「銷售倉庫」 |
|
||||||
|
| `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` |
|
||||||
|
| `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) |
|
||||||
|
| `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 |
|
||||||
|
|
||||||
|
#### `items` 陣列欄位說明:
|
||||||
|
|
||||||
|
| 欄位名稱 | 型態 | 必填 | 說明 |
|
||||||
|
| :--- | :--- | :---: | :--- |
|
||||||
|
| `pos_product_id` | String | **是** | 對應產品的 `external_pos_id`。**注意:商品必須先透過產品同步 API 建立至 ERP 中。** |
|
||||||
|
| `qty` | Number | **是** | 銷售數量 (必須 > 0) |
|
||||||
|
| `price` | Number | **是** | 銷售單價 |
|
||||||
|
|
||||||
|
**請求範例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"external_order_id": "ORD-20231026-0001",
|
||||||
|
"warehouse": "台北大安門市",
|
||||||
|
"payment_method": "credit_card",
|
||||||
|
"sold_at": "2023-10-26 14:30:00",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"pos_product_id": "POS-ITEM-001",
|
||||||
|
"qty": 2,
|
||||||
|
"price": 450
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pos_product_id": "POS-ITEM-005",
|
||||||
|
"qty": 1,
|
||||||
|
"price": 120
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
**Success (HTTP 201)**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Order synced and stock deducted successfully",
|
||||||
|
"order_id": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error: Product Not Found (HTTP 400)**
|
||||||
|
若 `items` 內傳入了未曾同步過的 `pos_product_id`,會導致寫入失敗。
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Sync failed: Product not found for POS ID: POS-ITEM-999. Please sync product first."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 錯誤回應 (HTTP 422 Unprocessable Entity - 驗證失敗)
|
||||||
|
|
||||||
|
當傳入資料格式有誤、商品編號不存在,或是該訂單正在處理中被系統鎖定時返回。
|
||||||
|
|
||||||
|
- **`message`** (字串): 錯誤摘要。
|
||||||
|
- **`errors`** (物件): 具體的錯誤明細列表,能一次性回報多個商品不存在或其它欄位錯誤。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Validation failed",
|
||||||
|
"errors": {
|
||||||
|
"items": [
|
||||||
|
"The following products are not found: POS-999, POS-888. Please sync products first."
|
||||||
|
],
|
||||||
|
"external_order_id": [
|
||||||
|
"The order ORD-01 is currently being processed by another transaction. Please try again later."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 錯誤回應 (HTTP 500 Internal Server Error - 伺服器異常)
|
||||||
|
|
||||||
|
當系統發生預期外的錯誤(如資料庫連線失敗)時返回。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Sync failed: An unexpected error occurred."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 幂等性說明 (Idempotency)
|
||||||
|
|
||||||
|
訂單 API 支援幂等性處理:若傳入已存在的 `external_order_id`,系統不會報錯,而是回傳該訂單的資訊:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Order already exists",
|
||||||
|
"order_id": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
這讓第三方系統在網路問題導致重送時,不會產生重複訂單或重複扣庫。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常見問題與除錯 (FAQ)
|
||||||
|
|
||||||
|
1. **收到 `401 Unauthorized` 錯誤?**
|
||||||
|
- 請檢查請求標頭 (Header) 是否有正確攜帶 `Authorization: Bearer {Token}`。
|
||||||
|
- 確認該 Token 尚未過期或被撤銷。
|
||||||
|
|
||||||
|
2. **收到 `422 Unprocessable Entity` 錯誤?**
|
||||||
|
- 代表傳送的 JSON 欄位不符合格式要求,例如必填欄位遺漏、數量為負數、或 `payment_method` 不在允許的值中。Laravel 會在回應的 `errors` 物件中詳細說明哪個欄位驗證失敗。
|
||||||
|
|
||||||
|
3. **收到 `429 Too Many Requests` 錯誤?**
|
||||||
|
- 代表已超過速率限制(每分鐘 60 次),請稍後再試。
|
||||||
|
|
||||||
|
4. **庫存扣除邏輯**
|
||||||
|
- POS 訂單寫入視為「已發生之事實」,系統會無條件扣除庫存。若該產品在指定倉庫原先庫存為 0,訂單寫入後庫存將變為負數,提醒門市人員需進行調撥補貨。
|
||||||
30
resources/markdown/manual/faq.md
Normal file
30
resources/markdown/manual/faq.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 常見問題 (FAQ)
|
||||||
|
|
||||||
|
這裡彙整了使用者最常遇到的問題與解決方案。
|
||||||
|
|
||||||
|
## 1. 系統登入與權限
|
||||||
|
|
||||||
|
### Q: 為什麼我登入後看不見某些功能選單?
|
||||||
|
**A:** 系統採用權限控管機制。如果您看不見特定選單(例如:「財務管理」),表示您的角色尚未被賦予該項目的權限。請聯繫系統管理員協助處理。
|
||||||
|
|
||||||
|
### Q: 如何變更登入密碼?
|
||||||
|
**A:** 請點擊右上角個人圖示進入「使用者設定」,即可找到變更密碼的功能。
|
||||||
|
|
||||||
|
## 2. 採購與進貨
|
||||||
|
|
||||||
|
### Q: 進貨單儲存後可以修改嗎?
|
||||||
|
**A:** 為了確保帳務與庫存紀錄的嚴謹性,進貨單一旦儲存並生效後即無法直接修改。若輸入錯誤,請建立一張「庫存調整單」進行修正。
|
||||||
|
|
||||||
|
### Q: 採購單為什麼無法點收進貨?
|
||||||
|
**A:** 請確認該採購單是否已處於「已核准」狀態。只有核准後的採購單才能被進貨流程引用。
|
||||||
|
|
||||||
|
## 3. 庫存管理
|
||||||
|
|
||||||
|
### Q: 庫存分析中的「週轉天數」是如何計算的?
|
||||||
|
**A:** 週轉天數 = (目前庫存 / 過去 30 天日平均銷量)。這能幫助您評估現有庫存還能支撐多久。
|
||||||
|
|
||||||
|
### Q: 「滯銷」的定義是什麼?
|
||||||
|
**A:** 系統預設將「目前有庫存」且「超過 90 天未有銷售紀錄」的商品標記為滯銷。
|
||||||
|
|
||||||
|
---
|
||||||
|
*如有其他問題,請聯繫技術中心 (分機: 8888)。*
|
||||||
34
resources/markdown/manual/getting-started.md
Normal file
34
resources/markdown/manual/getting-started.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 登入與帳號設定
|
||||||
|
|
||||||
|
歡迎使用 **Star ERP** 系統。本章節將引導您完成首次登入及基礎帳號設定。
|
||||||
|
|
||||||
|
## 1. 系統登入
|
||||||
|
|
||||||
|
若要開始使用系統,請按照以下步驟操作:
|
||||||
|
|
||||||
|
1. 開啟瀏覽器並輸入系統網址。
|
||||||
|
2. 在登入頁面輸入您的**帳號 (Username)** 與**密碼 (Password)**。
|
||||||
|
3. 點擊「登入」按鈕進入儀表板。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 若您忘記密碼,請聯繫您的部門主管或系統管理員進行重設。
|
||||||
|
|
||||||
|
## 2. 個人資料與密碼修改
|
||||||
|
|
||||||
|
為了確保帳號安全,建議您定期更改密碼:
|
||||||
|
|
||||||
|
1. 點擊右上角的人員圖示或您的名稱。
|
||||||
|
2. 選擇「**使用者設定**」。
|
||||||
|
3. 在個人資料頁面,您可以更新:
|
||||||
|
* **姓名**
|
||||||
|
* **電子郵件**
|
||||||
|
* **變更密碼**:輸入目前密碼及新密碼後,點擊「儲存」。
|
||||||
|
|
||||||
|
## 3. 介面導覽
|
||||||
|
|
||||||
|
* **側邊欄**:位於左側,包含所有功能選單(如:商品管理、採購管理、報表管理等)。
|
||||||
|
* **頂部導覽列**:包含麵包屑導航(幫助您了解目前位置)、通知中心與個人選單。
|
||||||
|
* **收合按鈕**:左下角的箭頭按鈕可收合側邊欄,增加工作區域的空間。
|
||||||
|
|
||||||
|
---
|
||||||
|
*下一章:[採購流程說明](purchasing-workflow)*
|
||||||
30
resources/markdown/manual/inventory-management.md
Normal file
30
resources/markdown/manual/inventory-management.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 庫存管理規範
|
||||||
|
|
||||||
|
有效率的庫存管理是降低經營成本、提高週轉率的關鍵。本系統提供即時庫存追蹤與多維度分析工具。
|
||||||
|
|
||||||
|
## 1. 庫存查詢與監控
|
||||||
|
|
||||||
|
- **即時庫存查詢**:
|
||||||
|
- 路徑:`商品與庫存管理 > 即時庫存查詢`
|
||||||
|
- 功能:查看所有倉庫、所有批號的即時剩餘數量。
|
||||||
|
- **庫存報表**:
|
||||||
|
- 路徑:`報表管理 > 庫存報表`
|
||||||
|
- 功能:查看特定期間內的進銷存變動明細。
|
||||||
|
|
||||||
|
## 2. 庫存異動操作
|
||||||
|
|
||||||
|
除了自動產生的進貨與銷售異動外,您可以進行手動調整:
|
||||||
|
1. **庫存調撥 (Transfer)**:將商品從 A 倉庫移動到 B 倉庫。
|
||||||
|
2. **庫存盤點 (Counting)**:定期比對電腦庫存與實際庫存。
|
||||||
|
3. **庫存調整 (Adjustment)**:針對損壞、過期或盤點差異進行庫存增減。
|
||||||
|
|
||||||
|
## 3. 智慧分析工具
|
||||||
|
|
||||||
|
- **週轉天數分析**:系統自動計算商品平均賣出所需的時間。
|
||||||
|
- **滯銷預警**:
|
||||||
|
- `滯銷`:超過 90 天未銷售。
|
||||||
|
- `週轉慢`:週轉天數超過 60 天。
|
||||||
|
- **安全庫存設定**:您可以為重要品項設定最低水位,當庫存低於該值時,系統會在儀表板發出提醒。
|
||||||
|
|
||||||
|
---
|
||||||
|
*下一章:[常見問題 (FAQ)](faq)*
|
||||||
44
resources/markdown/manual/purchasing-workflow.md
Normal file
44
resources/markdown/manual/purchasing-workflow.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 採購流程說明
|
||||||
|
|
||||||
|
本系統提供完整的採購管理流程,從廠商資料建立到進貨驗收。
|
||||||
|
|
||||||
|
## 1. 廠商資料管理
|
||||||
|
|
||||||
|
在建立採購單之前,必須先確認供應商資料已建立:
|
||||||
|
- 路徑:`供應鏈管理 > 廠商資料管理`
|
||||||
|
- 功能:新增、編輯或停用供應商。
|
||||||
|
|
||||||
|
## 2. 採購單 (Purchase Order) 流程
|
||||||
|
|
||||||
|
採購單是系統核心流程的起點:
|
||||||
|
1. **建立採購單**:
|
||||||
|
- 路徑:`供應鏈管理 > 採購單管理`
|
||||||
|
- 點擊「建立採購單」,選擇供應商、預計進貨倉庫與商品明細。
|
||||||
|
2. **狀態說明**:
|
||||||
|
- `草稿`:編輯中,尚未正式生效。
|
||||||
|
- `待審核`:已提交,等待主管核准。
|
||||||
|
- `已核准`:已核准,可進行後續進貨。
|
||||||
|
- `已結案`:進貨完成。
|
||||||
|
|
||||||
|
## 3. 進貨單 (Goods Receipt) 流程
|
||||||
|
|
||||||
|
當貨物送達倉庫時,需進行進貨點收動作:
|
||||||
|
1. 路徑:`供應鏈管理 > 進貨單管理`。
|
||||||
|
2. 點擊「建立進貨單」,**引用採購單 (PO)**。
|
||||||
|
3. 確認實際收貨數量、批號與有效日期(若適用)。
|
||||||
|
4. 儲存後,系統將自動增加對應倉庫的庫存。
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 進貨單一旦儲存,系統會立即更新庫存餘額。請務必確認數量正確。
|
||||||
|
|
||||||
|
## 4. 儀表板 (Dashboard) 功能
|
||||||
|
|
||||||
|
儀表板提供系統目前的關鍵指標與待辦事項:
|
||||||
|
- **存貨分析 KPI**:顯示目前總庫存、滯銷品項與即將過期商品。
|
||||||
|
- **待辦事項**:列出需審核的單據與待收貨的採購單。
|
||||||
|
- **熱銷/積壓排行**:即時掌握銷售趨勢與庫存壓力。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
*下一章:[採購流程說明](purchasing-workflow)*
|
||||||
37
resources/markdown/manual/toc.json
Normal file
37
resources/markdown/manual/toc.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "新手上路",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"title": "登入與帳號設定",
|
||||||
|
"slug": "getting-started"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "核心流程",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"title": "採購流程說明",
|
||||||
|
"slug": "purchasing-workflow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "庫存管理規範",
|
||||||
|
"slug": "inventory-management"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "其他區域",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"title": "常見問題 (FAQ)",
|
||||||
|
"slug": "faq"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "外部系統 API 對接",
|
||||||
|
"slug": "api-integration"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
151
tests/Feature/Integration/ProductSyncTest.php
Normal file
151
tests/Feature/Integration/ProductSyncTest.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Integration;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Modules\Core\Models\Tenant;
|
||||||
|
use App\Modules\Core\Models\User;
|
||||||
|
use App\Modules\Inventory\Models\Product;
|
||||||
|
use App\Modules\Inventory\Models\Category;
|
||||||
|
use Stancl\Tenancy\Facades\Tenancy;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ProductSyncTest extends TestCase
|
||||||
|
{
|
||||||
|
protected $tenant;
|
||||||
|
protected $user;
|
||||||
|
protected $domain;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// 每次測試前重置資料庫(在測試環境)
|
||||||
|
\Artisan::call('migrate:fresh');
|
||||||
|
|
||||||
|
$this->domain = 'product-test-' . Str::random(8) . '.erp.local';
|
||||||
|
$tenantId = 'test_tenant_p_' . Str::random(8);
|
||||||
|
|
||||||
|
// 建立租戶
|
||||||
|
tenancy()->central(function () use ($tenantId) {
|
||||||
|
$this->tenant = Tenant::create([
|
||||||
|
'id' => $tenantId,
|
||||||
|
'name' => 'Product Test Tenant',
|
||||||
|
]);
|
||||||
|
$this->tenant->domains()->create(['domain' => $this->domain]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化租戶並遷移
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
\Artisan::call('tenants:migrate');
|
||||||
|
|
||||||
|
// 建立測試使用者與分類
|
||||||
|
$this->user = User::factory()->create(['name' => 'Test Admin']);
|
||||||
|
Category::create(['name' => '測試分類', 'code' => 'TEST-CAT']);
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if ($this->tenant) {
|
||||||
|
$this->tenant->delete();
|
||||||
|
}
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 測試產品同步新增功能
|
||||||
|
*/
|
||||||
|
public function test_product_sync_can_create_new_product()
|
||||||
|
{
|
||||||
|
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'external_pos_id' => 'POS-NEW-999',
|
||||||
|
'name' => '全新同步商品',
|
||||||
|
'price' => 299,
|
||||||
|
'barcode' => '1234567890123',
|
||||||
|
'category' => '測試分類',
|
||||||
|
'cost_price' => 150
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Tenant-Domain' => $this->domain,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->postJson('/api/v1/integration/products/upsert', $payload);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonPath('message', 'Product synced successfully');
|
||||||
|
|
||||||
|
// 驗證租戶資料庫
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
$this->assertDatabaseHas('products', [
|
||||||
|
'external_pos_id' => 'POS-NEW-999',
|
||||||
|
'name' => '全新同步商品',
|
||||||
|
'price' => 299,
|
||||||
|
]);
|
||||||
|
tenancy()->end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 測試產品同步更新功能 (Upsert)
|
||||||
|
*/
|
||||||
|
public function test_product_sync_can_update_existing_product()
|
||||||
|
{
|
||||||
|
// 先建立一個既有商品
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
Product::create([
|
||||||
|
'name' => '舊商品名稱',
|
||||||
|
'code' => 'OLD-CODE',
|
||||||
|
'external_pos_id' => 'POS-EXIST-001',
|
||||||
|
'price' => 100,
|
||||||
|
'category_id' => Category::first()->id,
|
||||||
|
]);
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'external_pos_id' => 'POS-EXIST-001',
|
||||||
|
'name' => '更新後的商品名稱',
|
||||||
|
'price' => 888,
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Tenant-Domain' => $this->domain,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->postJson('/api/v1/integration/products/upsert', $payload);
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
tenancy()->initialize($this->tenant);
|
||||||
|
$this->assertDatabaseHas('products', [
|
||||||
|
'external_pos_id' => 'POS-EXIST-001',
|
||||||
|
'name' => '更新後的商品名稱',
|
||||||
|
'price' => 888,
|
||||||
|
]);
|
||||||
|
tenancy()->end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 測試產品同步驗證失敗
|
||||||
|
*/
|
||||||
|
public function test_product_sync_validation_fails_without_required_fields()
|
||||||
|
{
|
||||||
|
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'external_pos_id' => '', // 缺少此欄位
|
||||||
|
'name' => '', // 缺少此欄位
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'X-Tenant-Domain' => $this->domain,
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->postJson('/api/v1/integration/products/upsert', $payload);
|
||||||
|
|
||||||
|
$response->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['external_pos_id', 'name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
tests/manual/test_integration_api.sh
Executable file
83
tests/manual/test_integration_api.sh
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Star ERP 整合 API 手動測試腳本
|
||||||
|
# 用途:手動驗證商品同步、POS 訂單、販賣機訂單 API
|
||||||
|
|
||||||
|
# --- 設定區 ---
|
||||||
|
BASE_URL=${1:-"http://localhost:8081"}
|
||||||
|
TOKEN=${2:-"YOUR_TOKEN_HERE"}
|
||||||
|
TENANT_DOMAIN="localhost"
|
||||||
|
|
||||||
|
echo "=== Star ERP Integration API Test ==="
|
||||||
|
echo "Target URL: $BASE_URL"
|
||||||
|
echo "Tenant Domain: $TENANT_DOMAIN"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 顏色定義
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# 輔助函式:執行 curl
|
||||||
|
function call_api() {
|
||||||
|
local method=$1
|
||||||
|
local path=$2
|
||||||
|
local data=$3
|
||||||
|
local title=$4
|
||||||
|
|
||||||
|
echo -e "Testing: ${GREEN}$title${NC} ($path)"
|
||||||
|
|
||||||
|
curl -s -X "$method" "$BASE_URL$path" \
|
||||||
|
-H "X-Tenant-Domain: $TENANT_DOMAIN" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data" | jq .
|
||||||
|
|
||||||
|
echo -e "-----------------------------------\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 商品同步 (Upsert)
|
||||||
|
PRODUCT_ID="TEST-AUTO-$(date +%s)"
|
||||||
|
call_api "POST" "/api/v1/integration/products/upsert" "{
|
||||||
|
\"external_pos_id\": \"$PRODUCT_ID\",
|
||||||
|
\"name\": \"自動測試商品 $(date +%H%M%S)\",
|
||||||
|
\"price\": 150,
|
||||||
|
\"barcode\": \"690123456789\",
|
||||||
|
\"category\": \"飲品\",
|
||||||
|
\"cost_price\": 80
|
||||||
|
}" "Product Upsert"
|
||||||
|
|
||||||
|
# 2. POS 訂單同步
|
||||||
|
call_api "POST" "/api/v1/integration/orders" "{
|
||||||
|
\"external_order_id\": \"POS-AUTO-$(date +%s)\",
|
||||||
|
\"status\": \"completed\",
|
||||||
|
\"payment_method\": \"cash\",
|
||||||
|
\"sold_at\": \"$(date -Iseconds)\",
|
||||||
|
\"warehouse_id\": 2,
|
||||||
|
\"items\": [
|
||||||
|
{
|
||||||
|
\"pos_product_id\": \"TEST-FINAL-VERIFY\",
|
||||||
|
\"qty\": 1,
|
||||||
|
\"price\": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}" "POS Order Sync"
|
||||||
|
|
||||||
|
# 3. 販賣機訂單同步
|
||||||
|
call_api "POST" "/api/v1/integration/vending/orders" "{
|
||||||
|
\"external_order_id\": \"VEND-AUTO-$(date +%s)\",
|
||||||
|
\"machine_id\": \"Vending-Machine-Test-01\",
|
||||||
|
\"payment_method\": \"line_pay\",
|
||||||
|
\"sold_at\": \"$(date -Iseconds)\",
|
||||||
|
\"warehouse_id\": 2,
|
||||||
|
\"items\": [
|
||||||
|
{
|
||||||
|
\"product_code\": \"FINAL-OK\",
|
||||||
|
\"qty\": 2,
|
||||||
|
\"price\": 50
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}" "Vending Machine Order Sync"
|
||||||
|
|
||||||
|
echo "Test Completed."
|
||||||
Reference in New Issue
Block a user