Compare commits
34 Commits
29cdf37b71
...
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 |
@@ -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;
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
'order_id' => $result['order_id'] ?? null,
|
||||||
|
], $statusCode);
|
||||||
|
|
||||||
foreach ($request->items as $itemData) {
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||||
// Find product by external ID (Strict Check)
|
// 捕捉 Action 中拋出的預先驗證錯誤 (如查無商品、或鎖定逾時)
|
||||||
$product = Product::where('external_pos_id', $itemData['pos_product_id'])->first();
|
return response()->json([
|
||||||
|
'message' => 'Validation failed',
|
||||||
if (!$product) {
|
'errors' => $e->errors()
|
||||||
throw new \Exception("Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first.");
|
], 422);
|
||||||
}
|
|
||||||
|
|
||||||
$qty = $itemData['qty'];
|
|
||||||
$price = $itemData['price'];
|
|
||||||
$lineTotal = $qty * $price;
|
|
||||||
$totalAmount += $lineTotal;
|
|
||||||
|
|
||||||
// 2. Create Order Item
|
|
||||||
SalesOrderItem::create([
|
|
||||||
'sales_order_id' => $order->id,
|
|
||||||
'product_id' => $product->id,
|
|
||||||
'product_name' => $product->name, // Snapshot name
|
|
||||||
'quantity' => $qty,
|
|
||||||
'price' => $price,
|
|
||||||
'total' => $lineTotal,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 3. Deduct Stock (Force negative allowed for POS orders)
|
|
||||||
$this->inventoryService->decreaseStock(
|
|
||||||
$product->id,
|
|
||||||
$warehouseId,
|
|
||||||
$qty,
|
|
||||||
"POS Order: " . $order->external_order_id,
|
|
||||||
true // Force = true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$order->update(['total_amount' => $totalAmount]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Order synced and stock deducted successfully',
|
|
||||||
'order_id' => $order->id,
|
|
||||||
], 201);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Order Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
|
// 系統層級的錯誤
|
||||||
return response()->json(['message' => 'Sync failed: ' . $e->getMessage()], 400);
|
Log::error('Order Sync Failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'payload' => $request->all()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Sync failed: An unexpected error occurred.'
|
||||||
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ namespace App\Modules\Integration\Controllers;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Modules\Inventory\Services\ProductService;
|
use App\Modules\Inventory\Contracts\ProductServiceInterface;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class ProductSyncController extends Controller
|
class ProductSyncController extends Controller
|
||||||
{
|
{
|
||||||
protected $productService;
|
protected $productService;
|
||||||
|
|
||||||
public function __construct(ProductService $productService)
|
public function __construct(ProductServiceInterface $productService)
|
||||||
{
|
{
|
||||||
$this->productService = $productService;
|
$this->productService = $productService;
|
||||||
}
|
}
|
||||||
@@ -19,12 +19,17 @@ class ProductSyncController extends Controller
|
|||||||
public function upsert(Request $request)
|
public function upsert(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'external_pos_id' => 'required|string',
|
'external_pos_id' => 'required|string|max:255',
|
||||||
'name' => 'required|string',
|
'name' => 'required|string|max:255',
|
||||||
'price' => 'nullable|numeric',
|
'price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||||
'barcode' => 'nullable|string',
|
'barcode' => 'nullable|string|max:100',
|
||||||
'category' => 'nullable|string',
|
'category' => 'nullable|string|max:100',
|
||||||
'unit' => 'nullable|string',
|
'unit' => 'nullable|string|max:100',
|
||||||
|
'brand' => 'nullable|string|max:100',
|
||||||
|
'specification' => 'nullable|string|max:255',
|
||||||
|
'cost_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||||
|
'member_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||||
|
'wholesale_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||||
'updated_at' => 'nullable|date',
|
'updated_at' => 'nullable|date',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -40,7 +45,9 @@ class ProductSyncController extends Controller
|
|||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
|
Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
|
||||||
return response()->json(['message' => 'Sync failed'], 500);
|
return response()->json([
|
||||||
|
'message' => 'Sync failed: ' . $e->getMessage(),
|
||||||
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
52
app/Modules/Integration/Controllers/SalesOrderController.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Integration\Models\SalesOrder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class SalesOrderController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 顯示銷售訂單列表
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = SalesOrder::query();
|
||||||
|
|
||||||
|
// 搜尋篩選 (外部訂單號)
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$query->where('external_order_id', 'like', '%' . $request->search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 來源篩選
|
||||||
|
if ($request->filled('source')) {
|
||||||
|
$query->where('source', $request->source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
$query->orderBy('sold_at', 'desc');
|
||||||
|
|
||||||
|
$orders = $query->paginate($request->input('per_page', 10))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return Inertia::render('Integration/SalesOrders/Index', [
|
||||||
|
'orders' => $orders,
|
||||||
|
'filters' => $request->only(['search', 'per_page', 'source']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示單一銷售訂單詳情
|
||||||
|
*/
|
||||||
|
public function show(SalesOrder $salesOrder)
|
||||||
|
{
|
||||||
|
$salesOrder->load(['items']);
|
||||||
|
|
||||||
|
return Inertia::render('Integration/SalesOrders/Show', [
|
||||||
|
'order' => $salesOrder,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Modules\Integration\Requests\SyncVendingOrderRequest;
|
||||||
|
use App\Modules\Integration\Actions\SyncVendingOrderAction;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class VendingOrderSyncController extends Controller
|
||||||
|
{
|
||||||
|
protected $syncVendingOrderAction;
|
||||||
|
|
||||||
|
public function __construct(SyncVendingOrderAction $syncVendingOrderAction)
|
||||||
|
{
|
||||||
|
$this->syncVendingOrderAction = $syncVendingOrderAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收並同步販賣機交易訂單
|
||||||
|
*
|
||||||
|
* @param SyncVendingOrderRequest $request
|
||||||
|
* @return JsonResponse
|
||||||
|
*/
|
||||||
|
public function store(SyncVendingOrderRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->syncVendingOrderAction->execute($request->validated());
|
||||||
|
|
||||||
|
$statusCode = ($result['status'] === 'exists') ? 200 : 201;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $result['message'],
|
||||||
|
'order_id' => $result['order_id'] ?? null,
|
||||||
|
], $statusCode);
|
||||||
|
|
||||||
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed',
|
||||||
|
'errors' => $e->errors()
|
||||||
|
], 422);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Vending Order Sync Failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'payload' => $request->all()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Sync failed: An unexpected error occurred.'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ namespace App\Modules\Integration;
|
|||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use App\Modules\Integration\Middleware\TenantIdentificationMiddleware;
|
use App\Modules\Integration\Middleware\TenantIdentificationMiddleware;
|
||||||
|
|
||||||
class IntegrationServiceProvider extends ServiceProvider
|
class IntegrationServiceProvider extends ServiceProvider
|
||||||
@@ -11,10 +14,16 @@ class IntegrationServiceProvider extends ServiceProvider
|
|||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
|
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
|
||||||
|
$this->loadRoutesFrom(__DIR__ . '/Routes/web.php');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
|
||||||
|
|
||||||
// Register Middleware Alias
|
// 註冊 Middleware 別名
|
||||||
Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class);
|
Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class);
|
||||||
|
|
||||||
|
// 定義 Integration API 速率限制(每分鐘 60 次,依 Token 使用者識別)
|
||||||
|
RateLimiter::for('integration', function (Request $request) {
|
||||||
|
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register()
|
public function register()
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class SalesOrder extends Model
|
|||||||
'total_amount',
|
'total_amount',
|
||||||
'sold_at',
|
'sold_at',
|
||||||
'raw_payload',
|
'raw_payload',
|
||||||
|
'source',
|
||||||
|
'source_label',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
36
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
36
app/Modules/Integration/Requests/SyncOrderRequest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SyncOrderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'external_order_id' => 'required|string',
|
||||||
|
'warehouse' => 'nullable|string',
|
||||||
|
'warehouse_id' => 'nullable|integer',
|
||||||
|
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
|
||||||
|
'sold_at' => 'nullable|date',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.pos_product_id' => 'required|string',
|
||||||
|
'items.*.qty' => 'required|numeric|min:0.0001',
|
||||||
|
'items.*.price' => 'required|numeric|min:0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
37
app/Modules/Integration/Requests/SyncVendingOrderRequest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Integration\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SyncVendingOrderRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 販賣機訂單同步的驗證規則
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'external_order_id' => 'required|string',
|
||||||
|
'machine_id' => 'nullable|string',
|
||||||
|
'warehouse' => 'nullable|string',
|
||||||
|
'warehouse_id' => 'nullable|integer',
|
||||||
|
'payment_method' => 'nullable|string|in:cash,electronic,line_pay,other',
|
||||||
|
'sold_at' => 'nullable|date',
|
||||||
|
'items' => 'required|array|min:1',
|
||||||
|
'items.*.product_code' => 'required|string', // 使用 ERP 商品代碼
|
||||||
|
'items.*.qty' => 'required|numeric|min:0.0001',
|
||||||
|
'items.*.price' => 'required|numeric|min:0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Modules\Integration\Controllers\ProductSyncController;
|
use App\Modules\Integration\Controllers\ProductSyncController;
|
||||||
use App\Modules\Integration\Controllers\OrderSyncController;
|
use App\Modules\Integration\Controllers\OrderSyncController;
|
||||||
|
use App\Modules\Integration\Controllers\VendingOrderSyncController;
|
||||||
|
|
||||||
Route::prefix('api/v1/integration')
|
Route::prefix('api/v1/integration')
|
||||||
->middleware(['api', 'integration.tenant', 'auth:sanctum']) // integration.tenant middleware to identify tenant
|
->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum'])
|
||||||
->group(function () {
|
->group(function () {
|
||||||
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
|
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
|
||||||
Route::post('orders', [OrderSyncController::class, 'store']);
|
Route::post('orders', [OrderSyncController::class, 'store']);
|
||||||
|
Route::post('vending/orders', [VendingOrderSyncController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|||||||
11
app/Modules/Integration/Routes/web.php
Normal file
11
app/Modules/Integration/Routes/web.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Modules\Integration\Controllers\SalesOrderController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'verified'])->group(function () {
|
||||||
|
Route::prefix('integration')->name('integration.')->group(function () {
|
||||||
|
Route::get('sales-orders', [SalesOrderController::class, 'index'])->name('sales-orders.index');
|
||||||
|
Route::get('sales-orders/{salesOrder}', [SalesOrderController::class, 'show'])->name('sales-orders.show');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,4 +131,12 @@ interface InventoryServiceInterface
|
|||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function getDashboardStats(): array;
|
public function getDashboardStats(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
|
||||||
|
*
|
||||||
|
* @param string $warehouseName
|
||||||
|
* @return object
|
||||||
|
*/
|
||||||
|
public function findOrCreateWarehouseByName(string $warehouseName);
|
||||||
}
|
}
|
||||||
41
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal file
41
app/Modules/Inventory/Contracts/ProductServiceInterface.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 產品服務介面 — 供跨模組使用(如 Integration 模組)。
|
||||||
|
*/
|
||||||
|
interface ProductServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 透過外部 POS ID 進行產品新增或更新(Upsert)。
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return object
|
||||||
|
*/
|
||||||
|
public function upsertFromPos(array $data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透過外部 POS ID 查找產品。
|
||||||
|
*
|
||||||
|
* @param string $externalPosId
|
||||||
|
* @return object|null
|
||||||
|
*/
|
||||||
|
public function findByExternalPosId(string $externalPosId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透過多個外部 POS ID 查找產品。
|
||||||
|
*
|
||||||
|
* @param array $externalPosIds
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection
|
||||||
|
*/
|
||||||
|
public function findByExternalPosIds(array $externalPosIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
|
||||||
|
*
|
||||||
|
* @param array $codes
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection
|
||||||
|
*/
|
||||||
|
public function findByCodes(array $codes);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -28,32 +28,32 @@ RUN apt-get update && apt-get upgrade -y \
|
|||||||
&& echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
|
&& echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y \
|
&& apt-get install -y \
|
||||||
libgd3 \
|
libgd3 \
|
||||||
php8.5-cli \
|
php8.5-cli \
|
||||||
php8.5-dev \
|
php8.5-dev \
|
||||||
php8.5-pgsql \
|
php8.5-pgsql \
|
||||||
php8.5-sqlite3 \
|
php8.5-sqlite3 \
|
||||||
php8.5-gd \
|
php8.5-gd \
|
||||||
php8.5-curl \
|
php8.5-curl \
|
||||||
php8.5-mongodb \
|
php8.5-mongodb \
|
||||||
php8.5-imap \
|
php8.5-imap \
|
||||||
php8.5-mysql \
|
php8.5-mysql \
|
||||||
php8.5-mbstring \
|
php8.5-mbstring \
|
||||||
php8.5-xml \
|
php8.5-xml \
|
||||||
php8.5-zip \
|
php8.5-zip \
|
||||||
php8.5-bcmath \
|
php8.5-bcmath \
|
||||||
php8.5-soap \
|
php8.5-soap \
|
||||||
php8.5-intl \
|
php8.5-intl \
|
||||||
php8.5-readline \
|
php8.5-readline \
|
||||||
php8.5-ldap \
|
php8.5-ldap \
|
||||||
php8.5-msgpack \
|
php8.5-msgpack \
|
||||||
php8.5-igbinary \
|
php8.5-igbinary \
|
||||||
php8.5-redis \
|
php8.5-redis \
|
||||||
#php8.5-swoole \
|
#php8.5-swoole \
|
||||||
php8.5-memcached \
|
php8.5-memcached \
|
||||||
php8.5-pcov \
|
php8.5-pcov \
|
||||||
php8.5-imagick \
|
php8.5-imagick \
|
||||||
php8.5-xdebug \
|
php8.5-xdebug \
|
||||||
&& curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
|
&& curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
|
||||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -130,7 +130,8 @@ export default function ManualIndex({ toc, currentSlug, content }: Props) {
|
|||||||
#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 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 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 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: 0.75rem !important; background: #1e293b; color: #f8fafc; border-radius: 8px; overflow-x: auto; }
|
#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">
|
<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">
|
<article id="manual-article" className="prose prose-slate max-w-none">
|
||||||
|
|||||||
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,訂單寫入後庫存將變為負數,提醒門市人員需進行調撥補貨。
|
||||||
@@ -27,6 +27,10 @@
|
|||||||
{
|
{
|
||||||
"title": "常見問題 (FAQ)",
|
"title": "常見問題 (FAQ)",
|
||||||
"slug": "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