Compare commits
22 Commits
2fd5de96b2
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f96d2870c3 | |||
| 96440f6b50 | |||
| 8f6b8d55cc | |||
| 60f5f00a9e | |||
| 0b4aeacb55 | |||
| e3ceedc579 | |||
| 7a1fc02dfc | |||
| bee8ecb55b | |||
| b57a4feeab | |||
| 6ca0bafd60 | |||
| adf13410ba | |||
| d52a215916 | |||
| 197df3bec4 | |||
| 2437aa2672 | |||
| a987f4345e | |||
| 89291918fd | |||
| 3f7a625191 | |||
| e11193c2a7 | |||
| 02e5f5d4ea | |||
| 36b90370a8 | |||
| 5290dd2cbe | |||
| 8e0252e8fc |
@@ -62,6 +62,10 @@ trigger: always_on
|
||||
* 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||
* 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||
* 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。
|
||||
* **核心要求:UI 規範與彈性設計 (重要)**:
|
||||
* 在開發「新功能」或「新頁面」前,產出的 `implementation_plan.md` 中**必須包含「UI 規範核對清單」**,明確列出將使用哪些已定義於 `ui-consistency/SKILL.md` 的元件(例如:`AlertDialog`、特定圖示名稱等)。
|
||||
* **已規範部分**:絕對遵循《客戶端後台 UI 統一規範》進行實作。
|
||||
* **未規範部分**:若遇到規範外的新 UI 區塊,請保有設計彈性,運用 Tailwind 打造符合 ERP 調性的初版設計,依據使用者的實際感受進行後續調整與收錄。
|
||||
|
||||
## 8. 多租戶開發規範 (Multi-tenancy Standards)
|
||||
本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則:
|
||||
@@ -84,3 +88,13 @@ trigger: always_on
|
||||
* **自動化部署**:本專案使用 CI/CD 自動化部署,開發者只需 push 程式碼至對應分支即可。
|
||||
* **Demo 環境 (對應 `demo` 分支)**:若需查修測試站問題(例如查看 Error Log 或資料庫),請連線 `ssh gitea_work`。
|
||||
* **Production 環境 (對應 `main` 分支)**:若需查修正式站問題,請連線 `ssh erp`。
|
||||
|
||||
## 11. 瀏覽器測試規範 (Browser Testing)
|
||||
當需要進行瀏覽器自動化測試或手動驗證時,請遵守以下連線資訊:
|
||||
|
||||
* **本地測試網址**:`http://localhost:8081/`
|
||||
* **預設管理員帳號**:`admin`
|
||||
* **預設管理員密碼**:`password`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 在執行 browser subagent 或進行 E2E 測試時,請務必確認為 `8081` Port,以避免連線至錯誤的服務環境。
|
||||
@@ -19,6 +19,8 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
| 跨模組、Service Interface、`Contracts`、模組間通訊、`ServiceProvider` 綁定、禁止跨模組引用 | **跨模組調用與通訊規範** | `.agents/skills/cross-module-communication/SKILL.md` |
|
||||
| 按鈕樣式、表格規範、圖標、分頁、Badge、Toast、表單、UI 統一、頁面佈局、`button-filled-*`、`button-outlined-*`、`lucide-react`、色彩系統 | **客戶端後台 UI 統一規範** | `.agents/skills/ui-consistency/SKILL.md` |
|
||||
| Git 分支、commit、push、合併、部署、`feature/`、`hotfix/`、`develop`、`main` | **Git 分支管理與開發規範** | `.agents/skills/git-workflows/SKILL.md` |
|
||||
| E2E、端到端測試、Playwright、`spec.ts`、功能驗證、自動化測試、回歸測試 | **E2E 端到端測試規範** | `.agents/skills/e2e-testing/SKILL.md` |
|
||||
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
|
||||
|
||||
---
|
||||
|
||||
@@ -31,6 +33,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
1. **permission-management** — 設定權限
|
||||
2. **ui-consistency** — 遵循 UI 規範
|
||||
3. **activity-logging** — 若涉及 Model CRUD,需加上操作紀錄
|
||||
4. **e2e-testing** — 確認是否需要新增對應的 E2E 測試
|
||||
|
||||
### 🔴 新增或修改 Model 時
|
||||
必須讀取:
|
||||
@@ -41,6 +44,10 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
|
||||
必須讀取:
|
||||
1. **git-workflows** — 分支命名與 commit 格式
|
||||
|
||||
### 🔴 新增或修改 API 與 Controller 撈取資料庫邏輯時
|
||||
必須讀取:
|
||||
1. **database-best-practices** — 確認查詢優化、交易安全、批量寫入與索引規範
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
266
.agents/skills/e2e-testing/SKILL.md
Normal file
266
.agents/skills/e2e-testing/SKILL.md
Normal file
@@ -0,0 +1,266 @@
|
||||
---
|
||||
name: E2E 端到端測試規範 (E2E Testing with Playwright)
|
||||
description: 規範 Playwright 端到端測試的撰寫慣例、目錄結構、共用工具與執行方式,確保所有 E2E 測試保持一致性與可維護性。
|
||||
---
|
||||
|
||||
# E2E 端到端測試規範 (E2E Testing with Playwright)
|
||||
|
||||
本技能定義了 Star ERP 系統中端到端 (E2E) 測試的實作標準,使用 Playwright 模擬真實使用者操作瀏覽器,驗證 UI 顯示與功能流程的正確性。
|
||||
|
||||
---
|
||||
|
||||
## 1. 專案結構
|
||||
|
||||
### 1.1 目錄配置
|
||||
|
||||
```
|
||||
star-erp/
|
||||
├── playwright.config.ts # Playwright 設定檔
|
||||
├── e2e/ # E2E 測試根目錄
|
||||
│ ├── helpers/ # 共用工具函式
|
||||
│ │ └── auth.ts # 登入 helper
|
||||
│ ├── screenshots/ # 測試截圖存放
|
||||
│ ├── auth.spec.ts # 認證相關測試(登入、登出)
|
||||
│ ├── inventory.spec.ts # 庫存模組測試
|
||||
│ ├── products.spec.ts # 商品模組測試
|
||||
│ └── {module}.spec.ts # 依模組命名
|
||||
├── playwright-report/ # HTML 測試報告(自動產生,已 gitignore)
|
||||
└── test-results/ # 失敗截圖與錄影(自動產生,已 gitignore)
|
||||
```
|
||||
|
||||
### 1.2 命名規範
|
||||
|
||||
| 項目 | 規範 | 範例 |
|
||||
|---|---|---|
|
||||
| 測試檔案 | 小寫,依模組命名 `.spec.ts` | `inventory.spec.ts` |
|
||||
| 測試群組 | `test.describe('中文功能名稱')` | `test.describe('庫存查詢')` |
|
||||
| 測試案例 | 中文描述「**應**」開頭 | `test('應顯示庫存清單')` |
|
||||
| 截圖檔案 | `{module}-{scenario}.png` | `inventory-search-result.png` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 設定檔 (playwright.config.ts)
|
||||
|
||||
### 2.1 核心設定
|
||||
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:8081', // Sail 開發伺服器
|
||||
screenshot: 'only-on-failure', // 失敗時自動截圖
|
||||
video: 'retain-on-failure', // 失敗時保留錄影
|
||||
trace: 'on-first-retry', // 重試時收集 trace
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 2.2 重要注意事項
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `baseURL` 必須指向本機 Sail 開發伺服器(預設 `http://localhost:8081`)。
|
||||
> 確保測試前已執行 `./vendor/bin/sail up -d` 與 `./vendor/bin/sail npm run dev`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 共用工具 (Helpers)
|
||||
|
||||
### 3.1 登入 Helper
|
||||
|
||||
位置:`e2e/helpers/auth.ts`
|
||||
|
||||
```typescript
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 共用登入函式
|
||||
* 使用測試帳號登入 ERP 系統
|
||||
*/
|
||||
export async function login(page: Page, username = 'mama', password = 'mama9453') {
|
||||
await page.goto('/');
|
||||
await page.fill('#username', username);
|
||||
await page.fill('#password', password);
|
||||
await page.getByRole('button', { name: '登入系統' }).click();
|
||||
// 等待儀表板載入完成
|
||||
await page.waitForSelector('text=系統概況', { timeout: 10000 });
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 使用方式
|
||||
|
||||
```typescript
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test('應顯示庫存清單', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/inventory/stock-query');
|
||||
// ...斷言
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 測試撰寫規範
|
||||
|
||||
### 4.1 測試結構模板
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('模組功能名稱', () => {
|
||||
|
||||
// 若整個 describe 都需要登入,使用 beforeEach
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應正確顯示頁面標題與關鍵元素', async ({ page }) => {
|
||||
await page.goto('/target-page');
|
||||
|
||||
// 驗證頁面標題
|
||||
await expect(page.getByText('頁面標題')).toBeVisible();
|
||||
|
||||
// 驗證表格存在
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('應能執行 CRUD 操作', async ({ page }) => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4.2 斷言 (Assertions) 慣例
|
||||
|
||||
| 場景 | 優先使用 | 避免使用 |
|
||||
|---|---|---|
|
||||
| 驗證頁面載入 | `page.getByText('關鍵文字')` | `page.waitForURL()` ※ |
|
||||
| 驗證元素存在 | `expect(locator).toBeVisible()` | `.count() > 0` |
|
||||
| 驗證表格資料 | `page.locator('table tbody tr')` | 硬編碼行數 |
|
||||
| 等待操作完成 | `expect().toBeVisible({ timeout })` | `page.waitForTimeout()` |
|
||||
|
||||
> [!NOTE]
|
||||
> ※ Star ERP 使用 Inertia.js,頁面導航不一定改變 URL(例如儀表板路由為 `/`)。
|
||||
> 因此**優先使用頁面內容驗證**,而非依賴 URL 變化。
|
||||
|
||||
### 4.3 選擇器優先順序
|
||||
|
||||
依照 Playwright 官方建議,選擇器優先順序為:
|
||||
|
||||
1. **Role** — `page.getByRole('button', { name: '登入系統' })`
|
||||
2. **Text** — `page.getByText('系統概況')`
|
||||
3. **Label** — `page.getByLabel('帳號')`
|
||||
4. **Placeholder** — `page.getByPlaceholder('請輸入...')`
|
||||
5. **Test ID** — `page.getByTestId('submit-btn')`(需在元件加 `data-testid`)
|
||||
6. **CSS** — `page.locator('#username')`(最後手段)
|
||||
|
||||
### 4.4 禁止事項
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止:硬等待(不可預期的等待時間)
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// ✅ 正確:等待特定條件
|
||||
await expect(page.getByText('操作成功')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// ❌ 禁止:在測試中寫死測試資料的 ID
|
||||
await page.goto('/products/42/edit');
|
||||
|
||||
// ✅ 正確:從頁面互動導航
|
||||
await page.locator('table tbody tr').first().getByRole('button', { name: '編輯' }).click();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 截圖與視覺回歸
|
||||
|
||||
### 5.1 手動截圖(文件用途)
|
||||
|
||||
```typescript
|
||||
// 成功截圖存於 e2e/screenshots/
|
||||
await page.screenshot({
|
||||
path: 'e2e/screenshots/inventory-list.png',
|
||||
fullPage: true,
|
||||
});
|
||||
```
|
||||
|
||||
### 5.2 視覺回歸測試(偵測 UI 變化)
|
||||
|
||||
```typescript
|
||||
test('庫存頁面 UI 應保持一致', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/inventory/stock-query');
|
||||
// 比對截圖,pixel 級差異會報錯
|
||||
await expect(page).toHaveScreenshot('stock-query.png', {
|
||||
maxDiffPixelRatio: 0.01, // 容許 1% 差異(動態資料)
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 首次執行 `toHaveScreenshot()` 會自動建立基準截圖。
|
||||
> 後續執行會與基準比對,更新基準用:`npx playwright test --update-snapshots`
|
||||
|
||||
---
|
||||
|
||||
## 6. 執行指令速查
|
||||
|
||||
```bash
|
||||
# 執行所有 E2E 測試
|
||||
npx playwright test
|
||||
|
||||
# 執行特定模組測試
|
||||
npx playwright test e2e/login.spec.ts
|
||||
|
||||
# UI 互動模式(可視化瀏覽器操作)
|
||||
npx playwright test --ui
|
||||
|
||||
# 帶頭模式(顯示瀏覽器畫面)
|
||||
npx playwright test --headed
|
||||
|
||||
# 產生 HTML 報告並開啟
|
||||
npx playwright test --reporter=html
|
||||
npx playwright show-report
|
||||
|
||||
# 更新視覺回歸基準截圖
|
||||
npx playwright test --update-snapshots
|
||||
|
||||
# 只執行特定測試案例(用 -g 篩選名稱)
|
||||
npx playwright test -g "登入"
|
||||
|
||||
# Debug 模式(逐步執行)
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 開發檢核清單 (Checklist)
|
||||
|
||||
### 新增頁面或功能時:
|
||||
|
||||
- [ ] 是否已為新頁面建立對應的 `.spec.ts` 測試檔?
|
||||
- [ ] 測試是否覆蓋主要的 Happy Path(正常操作流程)?
|
||||
- [ ] 測試是否覆蓋關鍵的 Error Path(錯誤處理)?
|
||||
- [ ] 共用的登入步驟是否使用 `helpers/auth.ts`?
|
||||
- [ ] 斷言是否優先使用頁面內容而非 URL?
|
||||
- [ ] 選擇器是否遵循優先順序(Role > Text > Label > CSS)?
|
||||
- [ ] 測試是否可獨立執行(不依賴其他測試的狀態)?
|
||||
|
||||
### 提交程式碼前:
|
||||
|
||||
- [ ] 全部 E2E 測試是否通過?(`npx playwright test`)
|
||||
- [ ] 是否有遺留的 `test.only` 或 `test.skip`?
|
||||
@@ -18,11 +18,12 @@ description: 規範開發過程中的 Git 分支架構、合併限制、環境
|
||||
## 2. 發布時段與約束 (Release Window)
|
||||
|
||||
### Main 分支發布限制 (Mandatory)
|
||||
1. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。
|
||||
2. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`:
|
||||
1. **強制規範**:若執行推送/合併指令時未明確包含目標分支,**嚴禁** 自行預設或推論為 `main`。我必須先詢問使用者:「請問要推送到哪一個目標分支?(dev / demo / main)」。
|
||||
2. **標準發布時間**:週一至週四,**12:00 (中午) 之前**。
|
||||
3. **非標準時段提醒**:若於上述時段以外(週五、週末、國定假日或下班時間)欲合併至 `main`:
|
||||
- AI 助手**必須攔截並主動提示風險**(例如:週末災難風險)。
|
||||
- 必須取得使用者明確書面同意(如:「我確定現在要上線」)方可執行。
|
||||
3. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。
|
||||
4. **合併鏈路**:一般功能/修正必須先上 `demo` 測試。`main` 的程式原則上應從 `demo` 分支合併而來。
|
||||
|
||||
## 3. 開發與修復流程 (SOP)
|
||||
|
||||
|
||||
@@ -781,7 +781,38 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
- **Select / SearchableSelect**: 必須確保 Trigger 按鈕高度為 `h-9`
|
||||
- **禁止使用**: 除非有特殊設計需求,否則避免使用 `h-10` (40px) 或其他非標準高度。
|
||||
|
||||
## 11.6 日期顯示規範 (Date Display)
|
||||
## 11.6 數字輸入框規範 (Numeric Inputs)
|
||||
|
||||
當需求為輸入**整數**數量(例如:實際產出數量、標準產出量)時,**嚴禁自行開發組合 `Plus` (+) 與 `Minus` (-) 按鈕的複合元件**。
|
||||
|
||||
**必須使用原生 HTML5 數字輸入與屬性**:
|
||||
1. 使用 `<Input type="number" />` 確保預設渲染瀏覽器原生的上下調整小箭頭 (Spinner)。
|
||||
2. 針對整數需求,固定加上 `step="1"` 屬性。
|
||||
3. 視需求加上 `min` 與 `max` 控制上下限。
|
||||
|
||||
這樣既能保持與現有「新增配方」等模組的「標準產出量」欄位行為高度一致,亦能維持畫面的極簡風格。
|
||||
|
||||
```tsx
|
||||
// ✅ 正確:依賴原生行為
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max={outputQuantity}
|
||||
value={actualOutputQuantity}
|
||||
onChange={(e) => setActualOutputQuantity(e.target.value)}
|
||||
className="h-9 w-24 text-center"
|
||||
/>
|
||||
|
||||
// ❌ 錯誤:過度設計、浪費空間與破壞一致性
|
||||
<div className="flex">
|
||||
<Button><Minus /></Button>
|
||||
<Input type="number" />
|
||||
<Button><Plus /></Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 11.7 日期顯示規範 (Date Display)
|
||||
|
||||
前端顯示日期時**禁止直接顯示原始 ISO 字串**(如 `2024-03-06T08:30:00.000000Z`),必須使用 `resources/js/lib/date.ts` 提供的工具函式。
|
||||
|
||||
|
||||
@@ -8,28 +8,28 @@ description: 將目前的變更提交並推送至指定的遠端分支 (遵守
|
||||
|
||||
## 執行步驟
|
||||
|
||||
1. **檢查變更內容**
|
||||
執行 `git status` 與 `git diff` 檢查目前的工作目錄,確保提交內容正確。
|
||||
1. **讀取規範 (Mandatory)**
|
||||
在執行任何 Git 操作前,**必須** 先讀取 Git 分支管理與開發規範:
|
||||
`view_file` -> [Git SKILL.md](file:///home/mama/projects/star-erp/.agents/skills/git-workflows/SKILL.md)
|
||||
|
||||
2. **撰寫規格化提交訊息 (Commit Message)**
|
||||
- 訊息一律使用 **繁體中文 (台灣用語)**。
|
||||
- 必須使用以下前綴之一:
|
||||
- `[FIX]`:修復 Bug。
|
||||
- `[FEAT]`:新增功能。
|
||||
- `[DOCS]`:文件更新。
|
||||
- `[STYLE]`:UI/樣式/格式調整。
|
||||
- `[REFACTOR]`:程式碼重構。
|
||||
- 描述應具體且真實反映修改內容。
|
||||
2. **檢查與準備**
|
||||
- 執行 `git status` 檢查目前工作目錄。
|
||||
- 根據 **SKILL.md** 的規範撰寫繁體中文提交訊息。
|
||||
|
||||
3. **目標分支安全檢查 (Release Window & Source Check)**
|
||||
- 若使用者指定的目標分支包含 **`main`**:
|
||||
- **來源檢查**:根據規範,上線 `main` 前必須先確保程式碼已在 `demo` 分支驗證完畢。我會優先檢查 `demo` 與 `main` 的差異,並提醒使用者應從 `demo` 合併。
|
||||
- **檢查目前時間**:標準發布時段為 **週一至週四 12:00 (中午) 之前**。
|
||||
- 若在非標準時段(週五、週末、下班時間),**必須** 先攔截並主動提醒風險,取得使用者明確書面同意(例如:「我確定現在要上線」)後方才執行推送。
|
||||
3. **目標分支安全檢查**
|
||||
- 嚴格遵守 **SKILL.md** 中的「分支架構」、「發布時段」與「強制分支明確指定」規則。
|
||||
- 若未指明目標分支,主動詢問使用者,不可私自預設為 `main`。
|
||||
- **【最嚴格限制】**:`main` 分支的程式碼**只能**, **必須**從 `demo` 分支合併而來。絕對禁止將 `dev` (或 `feature/*`) 直接合併進 `main`。
|
||||
|
||||
4. **執行推送 (Push)**
|
||||
- 依據指令帶入的分支名稱執行推送。
|
||||
- 範例:`git push origin [目前分支]:[目標分支]`。
|
||||
4. **執行推送 (Push) 與嚴格合併鏈路**
|
||||
- **若目標為 `dev`**:直接 `git push origin [目前分支]:dev` 或 commit 後 merge 到 dev。
|
||||
- **若目標為 `demo`**:必須先確保變更已在 `dev` 且無衝突,然後 `git checkout demo && git merge dev && git push origin demo`。
|
||||
- **若目標為 `main`**:
|
||||
必須確保變更已經依照順序通過前置環境,嚴格執行以下流程(缺一不可):
|
||||
1. `git checkout dev && git merge [目前分支] && git push origin dev`
|
||||
2. `git checkout demo && git merge dev && git push origin demo`
|
||||
3. `git checkout main && git merge demo && git push origin main`
|
||||
*(就算遭遇衝突,也必須在對應的分支上解衝突,絕對不可略過 `demo` 直接 `dev -> main`)*
|
||||
|
||||
5. **同步關聯分支**
|
||||
- 若為 `main` 的 Hotfix,修復後應評估是否同步回 `demo` 或 `dev` 分支。
|
||||
5. **後續同步 (針對 Hotfix)**
|
||||
- 依照 **SKILL.md** 的「緊急修復流程(Hotfix)」:若有從 main 開出來的 hotfix 分支直接併回 main 的例外情況(需使用者明確指示),**必須**同步將 main 分支 merge 回 `demo` 與 `dev` 分支,維持全環境版本一致。
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -18,6 +18,7 @@
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/tenant*
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
@@ -33,3 +34,12 @@ docs/f6_1770350984272.xlsx
|
||||
.gitignore
|
||||
BOM表自動計算成本.md
|
||||
公共事業費-類別維護.md
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
e2e/screenshots/
|
||||
|
||||
@@ -180,4 +180,3 @@ docker compose down
|
||||
- **多租戶**:
|
||||
- 中央邏輯 (Landlord) 與租戶邏輯 (Tenant) 分離。
|
||||
- 租戶路由定義於 `routes/tenant.php` (但在本專案架構中,大部分路由在 `web.php` 並透過 Middleware 判斷環境)。
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ class RoleController extends Controller
|
||||
*/
|
||||
private function getGroupedPermissions()
|
||||
{
|
||||
$allPermissions = Permission::orderBy('name')->get();
|
||||
$allPermissions = Permission::select('id', 'name', 'guard_name')->orderBy('name')->get();
|
||||
$grouped = [];
|
||||
|
||||
foreach ($allPermissions as $permission) {
|
||||
|
||||
@@ -16,7 +16,7 @@ class CoreService implements CoreServiceInterface
|
||||
*/
|
||||
public function getUsersByIds(array $ids): Collection
|
||||
{
|
||||
return User::whereIn('id', $ids)->get();
|
||||
return User::select('id', 'name')->whereIn('id', $ids)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +37,7 @@ class CoreService implements CoreServiceInterface
|
||||
*/
|
||||
public function getAllUsers(): Collection
|
||||
{
|
||||
return User::all();
|
||||
return User::select('id', 'name')->get();
|
||||
}
|
||||
|
||||
public function ensureSystemUserExists()
|
||||
|
||||
@@ -69,14 +69,25 @@ class AccountingReportController extends Controller
|
||||
}
|
||||
|
||||
$exportData = $allRecords->map(function ($record) {
|
||||
$taxAmount = (float)($record['tax_amount'] ?? 0);
|
||||
$totalAmount = (float)($record['amount'] ?? 0);
|
||||
$untaxedAmount = $totalAmount - $taxAmount;
|
||||
|
||||
return [
|
||||
$record['date'],
|
||||
$record['source'],
|
||||
$record['category'],
|
||||
$record['item'],
|
||||
$record['reference'],
|
||||
$record['invoice_number'],
|
||||
$record['amount'],
|
||||
$record['invoice_date'] ?? '-',
|
||||
$record['invoice_number'] ?? '-',
|
||||
$untaxedAmount,
|
||||
$taxAmount,
|
||||
$totalAmount,
|
||||
$record['payment_method'] ?? '-',
|
||||
$record['payment_note'] ?? '-',
|
||||
$record['remarks'] ?? '-',
|
||||
$record['status'] ?? '-',
|
||||
];
|
||||
});
|
||||
|
||||
@@ -91,7 +102,11 @@ class AccountingReportController extends Controller
|
||||
// BOM for Excel compatibility with UTF-8
|
||||
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
fputcsv($file, ['日期', '來源', '類別', '項目', '參考單號', '發票號碼', '金額']);
|
||||
fputcsv($file, [
|
||||
'日期', '來源', '類別', '項目', '參考單號',
|
||||
'發票日期', '發票號碼', '未稅金額', '稅額', '總金額',
|
||||
'付款方式', '付款備註', '內部備註', '狀態'
|
||||
]);
|
||||
|
||||
foreach ($exportData as $row) {
|
||||
fputcsv($file, $row);
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Modules\Finance\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Finance\Models\UtilityFee;
|
||||
use App\Modules\Finance\Models\UtilityFeeAttachment;
|
||||
use App\Modules\Finance\Contracts\FinanceServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UtilityFeeController extends Controller
|
||||
@@ -103,8 +105,82 @@ class UtilityFeeController extends Controller
|
||||
->event('deleted')
|
||||
->log('deleted');
|
||||
|
||||
// 刪除實體檔案 (如果 cascade 沒處理或是想要手動清理)
|
||||
foreach ($utility_fee->attachments as $attachment) {
|
||||
Storage::disk('public')->delete($attachment->file_path);
|
||||
}
|
||||
|
||||
$utility_fee->delete();
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取附件列表
|
||||
*/
|
||||
public function attachments(UtilityFee $utility_fee)
|
||||
{
|
||||
return response()->json([
|
||||
'attachments' => $utility_fee->attachments()->orderBy('created_at', 'desc')->get()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上傳附件
|
||||
*/
|
||||
public function uploadAttachment(Request $request, UtilityFee $utility_fee)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp,pdf|max:2048', // 2MB
|
||||
]);
|
||||
|
||||
// 檢查數量限制 (最多 3 張)
|
||||
if ($utility_fee->attachments()->count() >= 3) {
|
||||
return response()->json(['message' => '附件數量已達上限 (最多 3 個)'], 422);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$path = $file->store("utility-fee-attachments/{$utility_fee->id}", 'public');
|
||||
|
||||
$attachment = $utility_fee->attachments()->create([
|
||||
'file_path' => $path,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('attachment_uploaded')
|
||||
->log("uploaded attachment: {$attachment->original_name}");
|
||||
|
||||
return response()->json([
|
||||
'message' => '上傳成功',
|
||||
'attachment' => $attachment
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除附件
|
||||
*/
|
||||
public function deleteAttachment(UtilityFee $utility_fee, UtilityFeeAttachment $attachment)
|
||||
{
|
||||
// 確保附件屬於該費用
|
||||
if ($attachment->utility_fee_id !== $utility_fee->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($attachment->file_path);
|
||||
|
||||
$attachment->delete();
|
||||
|
||||
activity()
|
||||
->performedOn($utility_fee)
|
||||
->causedBy(auth()->user())
|
||||
->event('attachment_deleted')
|
||||
->log("deleted attachment: {$attachment->original_name}");
|
||||
|
||||
return response()->json(['message' => '刪除成功']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,16 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UtilityFee extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UtilityFeeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 此公共事業費的附件
|
||||
*/
|
||||
public function attachments()
|
||||
{
|
||||
return $this->hasMany(UtilityFeeAttachment::class);
|
||||
}
|
||||
|
||||
// 狀態常數
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_PAID = 'paid';
|
||||
|
||||
36
app/Modules/Finance/Models/UtilityFeeAttachment.php
Normal file
36
app/Modules/Finance/Models/UtilityFeeAttachment.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Finance\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class UtilityFeeAttachment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'utility_fee_id',
|
||||
'file_path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
];
|
||||
|
||||
protected $appends = ['url'];
|
||||
|
||||
/**
|
||||
* 附件所属的公共事業費
|
||||
*/
|
||||
public function utilityFee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UtilityFee::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取附件的全路徑 URL
|
||||
*/
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
return tenant_asset($this->file_path);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
Route::middleware('permission:utility_fees.edit')->group(function () {
|
||||
Route::put('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'update'])->name('utility-fees.update');
|
||||
|
||||
// 附件管理 (Ajax)
|
||||
Route::get('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'attachments'])->name('utility-fees.attachments');
|
||||
Route::post('/utility-fees/{utility_fee}/attachments', [UtilityFeeController::class, 'uploadAttachment'])->name('utility-fees.upload-attachment');
|
||||
Route::delete('/utility-fees/{utility_fee}/attachments/{attachment}', [UtilityFeeController::class, 'deleteAttachment'])->name('utility-fees.delete-attachment');
|
||||
});
|
||||
Route::middleware('permission:utility_fees.delete')->group(function () {
|
||||
Route::delete('/utility-fees/{utility_fee}', [UtilityFeeController::class, 'destroy'])->name('utility-fees.destroy');
|
||||
|
||||
@@ -70,6 +70,7 @@ class AccountPayableService
|
||||
|
||||
$latest = AccountPayable::where('document_number', 'like', $lastPrefix)
|
||||
->orderBy('document_number', 'desc')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$latest) {
|
||||
|
||||
@@ -19,23 +19,48 @@ class FinanceService implements FinanceServiceInterface
|
||||
|
||||
public function getAccountingReportData(string $start, string $end): array
|
||||
{
|
||||
// 1. 獲取採購單資料
|
||||
$purchaseOrders = $this->procurementService->getPurchaseOrdersByDate($start, $end)
|
||||
->map(function ($po) {
|
||||
return [
|
||||
'id' => 'PO-' . $po->id,
|
||||
'date' => Carbon::parse($po->created_at)->timezone(config('app.timezone'))->toDateString(),
|
||||
'source' => '採購單',
|
||||
'category' => '進貨支出',
|
||||
'item' => $po->vendor->name ?? '未知廠商',
|
||||
'reference' => $po->code,
|
||||
'invoice_number' => $po->invoice_number,
|
||||
'amount' => (float)$po->grand_total,
|
||||
];
|
||||
});
|
||||
// 1. 獲取應付帳款資料 (已付款)
|
||||
$accountPayables = \App\Modules\Finance\Models\AccountPayable::where('status', \App\Modules\Finance\Models\AccountPayable::STATUS_PAID)
|
||||
->whereNotNull('paid_at')
|
||||
->whereBetween('paid_at', [$start, $end])
|
||||
->get();
|
||||
|
||||
// 2. 獲取公共事業費 (注意:目前資料表欄位為 transaction_date)
|
||||
$utilityFees = UtilityFee::whereBetween('transaction_date', [$start, $end])
|
||||
// 取得供應商資料 (Manual Hydration)
|
||||
$vendorIds = $accountPayables->pluck('vendor_id')->unique()->filter()->toArray();
|
||||
$vendorsMap = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
|
||||
|
||||
// 付款方式對映
|
||||
$paymentMethodMap = [
|
||||
'cash' => '現金',
|
||||
'bank_transfer' => '銀行轉帳',
|
||||
'check' => '支票',
|
||||
'credit_card' => '信用卡',
|
||||
];
|
||||
|
||||
$payableRecords = $accountPayables->map(function ($ap) use ($vendorsMap, $paymentMethodMap) {
|
||||
$vendorName = isset($vendorsMap[$ap->vendor_id]) ? $vendorsMap[$ap->vendor_id]->name : '未知廠商';
|
||||
$mappedPaymentMethod = $paymentMethodMap[$ap->payment_method] ?? $ap->payment_method;
|
||||
return [
|
||||
'id' => 'AP-' . $ap->id,
|
||||
'date' => Carbon::parse($ap->paid_at)->timezone(config('app.timezone'))->toDateString(),
|
||||
'source' => '應付帳款',
|
||||
'category' => '進貨支出',
|
||||
'item' => $vendorName,
|
||||
'reference' => $ap->document_number,
|
||||
'invoice_date' => $ap->invoice_date ? $ap->invoice_date->format('Y-m-d') : null,
|
||||
'invoice_number' => $ap->invoice_number,
|
||||
'amount' => (float)$ap->total_amount,
|
||||
'tax_amount' => (float)$ap->tax_amount,
|
||||
'status' => $ap->status,
|
||||
'payment_method' => $mappedPaymentMethod,
|
||||
'payment_note' => $ap->payment_note,
|
||||
'remarks' => $ap->remarks,
|
||||
];
|
||||
});
|
||||
|
||||
// 2. 獲取公共事業費 (已繳費)
|
||||
$utilityFees = UtilityFee::where('payment_status', UtilityFee::STATUS_PAID)
|
||||
->whereBetween('transaction_date', [$start, $end])
|
||||
->get()
|
||||
->map(function ($fee) {
|
||||
return [
|
||||
@@ -45,12 +70,18 @@ class FinanceService implements FinanceServiceInterface
|
||||
'category' => $fee->category,
|
||||
'item' => $fee->description ?: $fee->category,
|
||||
'reference' => '-',
|
||||
'invoice_date' => null,
|
||||
'invoice_number' => $fee->invoice_number,
|
||||
'amount' => (float)$fee->amount,
|
||||
'tax_amount' => 0.0,
|
||||
'status' => $fee->payment_status,
|
||||
'payment_method' => null,
|
||||
'payment_note' => null,
|
||||
'remarks' => $fee->description,
|
||||
];
|
||||
});
|
||||
|
||||
$allRecords = $purchaseOrders->concat($utilityFees)
|
||||
$allRecords = $payableRecords->concat($utilityFees)
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
@@ -58,7 +89,7 @@ class FinanceService implements FinanceServiceInterface
|
||||
'records' => $allRecords,
|
||||
'summary' => [
|
||||
'total_amount' => $allRecords->sum('amount'),
|
||||
'purchase_total' => $purchaseOrders->sum('amount'),
|
||||
'payable_total' => $payableRecords->sum('amount'),
|
||||
'utility_total' => $utilityFees->sum('amount'),
|
||||
'record_count' => $allRecords->count(),
|
||||
]
|
||||
@@ -67,7 +98,7 @@ class FinanceService implements FinanceServiceInterface
|
||||
|
||||
public function getUtilityFees(array $filters)
|
||||
{
|
||||
$query = UtilityFee::query();
|
||||
$query = UtilityFee::withCount('attachments');
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
|
||||
@@ -58,42 +58,36 @@ class SyncOrderAction
|
||||
];
|
||||
}
|
||||
|
||||
// --- 預檢 (Pre-flight check) N+1 優化 ---
|
||||
// --- 預檢 (Pre-flight check) 僅使用 product_id ---
|
||||
$items = $data['items'];
|
||||
$posProductIds = array_column($items, 'pos_product_id');
|
||||
$targetErpIds = array_column($items, 'product_id');
|
||||
|
||||
// 一次性查出所有相關的 Product
|
||||
$products = $this->productService->findByExternalPosIds($posProductIds)->keyBy('external_pos_id');
|
||||
$productsById = $this->productService->findByIds($targetErpIds)->keyBy('id');
|
||||
|
||||
$resolvedProducts = [];
|
||||
$missingIds = [];
|
||||
foreach ($posProductIds as $id) {
|
||||
if (!$products->has($id)) {
|
||||
$missingIds[] = $id;
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$productId = $item['product_id'];
|
||||
$product = $productsById->get($productId);
|
||||
|
||||
if ($product) {
|
||||
$resolvedProducts[$index] = $product;
|
||||
} else {
|
||||
$missingIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingIds)) {
|
||||
// 回報所有缺漏的 ID
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["The following products are not found: " . implode(', ', $missingIds) . ". Please sync products first."]
|
||||
'items' => ["The following product IDs are not found: " . implode(', ', array_unique($missingIds)) . ". 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'] ?? 'cash',
|
||||
'total_amount' => 0,
|
||||
'sold_at' => $data['sold_at'] ?? now(),
|
||||
'raw_payload' => $data,
|
||||
'source' => $data['source'] ?? 'pos',
|
||||
'source_label' => $data['source_label'] ?? null,
|
||||
]);
|
||||
|
||||
// 2. 查找倉庫
|
||||
$result = DB::transaction(function () use ($data, $items, $resolvedProducts) {
|
||||
// 1. 查找倉庫(提前至建立訂單前,以便判定來源)
|
||||
$warehouseCode = $data['warehouse_code'];
|
||||
$warehouses = $this->inventoryService->getWarehousesByCodes([$warehouseCode]);
|
||||
|
||||
@@ -102,17 +96,36 @@ class SyncOrderAction
|
||||
'warehouse_code' => ["Warehouse with code {$warehouseCode} not found."]
|
||||
]);
|
||||
}
|
||||
$warehouseId = $warehouses->first()->id;
|
||||
$warehouse = $warehouses->first();
|
||||
$warehouseId = $warehouse->id;
|
||||
|
||||
// 2. 自動判定來源:若是販賣機倉庫則標記為 vending,其餘為 pos
|
||||
$source = ($warehouse->type === \App\Enums\WarehouseType::VENDING) ? 'vending' : 'pos';
|
||||
|
||||
// 3. 建立訂單
|
||||
$order = SalesOrder::create([
|
||||
'external_order_id' => $data['external_order_id'],
|
||||
'name' => $data['name'],
|
||||
'status' => 'completed',
|
||||
'payment_method' => $data['payment_method'] ?? 'cash',
|
||||
'total_amount' => $data['total_amount'],
|
||||
'total_qty' => $data['total_qty'],
|
||||
'sold_at' => $data['sold_at'] ?? now(),
|
||||
'raw_payload' => $data,
|
||||
'source' => $source,
|
||||
'source_label' => $data['source_label'] ?? null,
|
||||
]);
|
||||
|
||||
$totalAmount = 0;
|
||||
|
||||
// 3. 處理訂單明細
|
||||
$orderItemsData = [];
|
||||
foreach ($items as $itemData) {
|
||||
$product = $products->get($itemData['pos_product_id']);
|
||||
foreach ($items as $index => $itemData) {
|
||||
$product = $resolvedProducts[$index];
|
||||
|
||||
$qty = $itemData['qty'];
|
||||
$price = $itemData['price'];
|
||||
$batchNumber = $itemData['batch_number'] ?? null;
|
||||
$lineTotal = $qty * $price;
|
||||
$totalAmount += $lineTotal;
|
||||
|
||||
@@ -133,7 +146,11 @@ class SyncOrderAction
|
||||
$warehouseId,
|
||||
$qty,
|
||||
"POS Order: " . $order->external_order_id,
|
||||
true
|
||||
true,
|
||||
null, // Slot (location)
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
$order->id,
|
||||
$batchNumber
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,10 @@ class SyncVendingOrderAction
|
||||
$warehouseId,
|
||||
$qty,
|
||||
"Vending Order: " . $order->external_order_id,
|
||||
true
|
||||
true,
|
||||
null,
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
$order->id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,13 @@ class InventorySyncController extends Controller
|
||||
* @param string $warehouseCode
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function show(string $warehouseCode): JsonResponse
|
||||
public function show(\Illuminate\Http\Request $request, string $warehouseCode): JsonResponse
|
||||
{
|
||||
// 透過 Service 調用跨模組庫存查詢功能
|
||||
$inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode($warehouseCode);
|
||||
// 透過 Service 調用跨模組庫存查詢功能,傳入篩選條件
|
||||
$inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode(
|
||||
$warehouseCode,
|
||||
$request->only(['product_id', 'barcode', 'code', 'external_pos_id'])
|
||||
);
|
||||
|
||||
// 若回傳 null,表示尋無此倉庫代碼
|
||||
if (is_null($inventoryData)) {
|
||||
@@ -40,9 +43,18 @@ class InventorySyncController extends Controller
|
||||
'warehouse_code' => $warehouseCode,
|
||||
'data' => $inventoryData->map(function ($item) {
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'external_pos_id' => $item->external_pos_id,
|
||||
'product_code' => $item->product_code,
|
||||
'product_name' => $item->product_name,
|
||||
'barcode' => $item->barcode,
|
||||
'category_name' => $item->category_name ?? '未分類',
|
||||
'unit_name' => $item->unit_name ?? '個',
|
||||
'price' => (float) $item->price,
|
||||
'brand' => $item->brand,
|
||||
'specification' => $item->specification,
|
||||
'batch_number' => $item->batch_number,
|
||||
'expiry_date' => $item->expiry_date,
|
||||
'quantity' => (float) $item->total_quantity,
|
||||
];
|
||||
})
|
||||
|
||||
@@ -23,8 +23,8 @@ class ProductSyncController extends Controller
|
||||
'name' => 'required|string|max:255',
|
||||
'price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'unit' => 'nullable|string|max:100',
|
||||
'category' => 'required|string|max:100',
|
||||
'unit' => 'required|string|max:100',
|
||||
'brand' => 'nullable|string|max:100',
|
||||
'specification' => 'nullable|string|max:255',
|
||||
'cost_price' => 'nullable|numeric|min:0|max:99999999.99',
|
||||
@@ -41,6 +41,8 @@ class ProductSyncController extends Controller
|
||||
'data' => [
|
||||
'id' => $product->id,
|
||||
'external_pos_id' => $product->external_pos_id,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
@@ -50,4 +52,63 @@ class ProductSyncController extends Controller
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜尋商品(供外部 API 使用)。
|
||||
*
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'product_id' => 'nullable|integer',
|
||||
'external_pos_id' => 'nullable|string|max:255',
|
||||
'barcode' => 'nullable|string|max:100',
|
||||
'code' => 'nullable|string|max:100',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'updated_after' => 'nullable|date',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
]);
|
||||
|
||||
try {
|
||||
$perPage = $request->input('per_page', 50);
|
||||
$products = $this->productService->searchProducts($request->all(), $perPage);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'data' => $products->getCollection()->map(function ($product) {
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'code' => $product->code,
|
||||
'barcode' => $product->barcode,
|
||||
'name' => $product->name,
|
||||
'external_pos_id' => $product->external_pos_id,
|
||||
'category_name' => $product->category?->name ?? '未分類',
|
||||
'brand' => $product->brand,
|
||||
'specification' => $product->specification,
|
||||
'unit_name' => $product->baseUnit?->name ?? '個',
|
||||
'price' => (float) $product->price,
|
||||
'cost_price' => (float) $product->cost_price,
|
||||
'member_price' => (float) $product->member_price,
|
||||
'wholesale_price' => (float) $product->wholesale_price,
|
||||
'is_active' => (bool) $product->is_active,
|
||||
'updated_at' => $product->updated_at->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}),
|
||||
'meta' => [
|
||||
'current_page' => $products->currentPage(),
|
||||
'last_page' => $products->lastPage(),
|
||||
'per_page' => $products->perPage(),
|
||||
'total' => $products->total(),
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Product Search Failed', ['error' => $e->getMessage()]);
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Search failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ class SalesOrderController extends Controller
|
||||
|
||||
// 搜尋篩選 (外部訂單號)
|
||||
if ($request->filled('search')) {
|
||||
$query->where('external_order_id', 'like', '%' . $request->search . '%');
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('external_order_id', 'like', '%' . $request->search . '%')
|
||||
->orWhere('name', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// 來源篩選
|
||||
@@ -26,6 +29,11 @@ class SalesOrderController extends Controller
|
||||
$query->where('source', $request->source);
|
||||
}
|
||||
|
||||
// 付款方式篩選
|
||||
if ($request->filled('payment_method')) {
|
||||
$query->where('payment_method', $request->payment_method);
|
||||
}
|
||||
|
||||
// 排序
|
||||
$query->orderBy('sold_at', 'desc');
|
||||
|
||||
@@ -40,7 +48,7 @@ class SalesOrderController extends Controller
|
||||
|
||||
return Inertia::render('Integration/SalesOrders/Index', [
|
||||
'orders' => $orders,
|
||||
'filters' => $request->only(['search', 'per_page', 'source']),
|
||||
'filters' => $request->only(['search', 'per_page', 'source', 'status', 'payment_method']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ class SalesOrder extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'external_order_id',
|
||||
'name',
|
||||
'status',
|
||||
'payment_method',
|
||||
'total_amount',
|
||||
'total_qty',
|
||||
'sold_at',
|
||||
'raw_payload',
|
||||
'source',
|
||||
@@ -24,6 +26,7 @@ class SalesOrder extends Model
|
||||
'sold_at' => 'datetime',
|
||||
'raw_payload' => 'array',
|
||||
'total_amount' => 'decimal:4',
|
||||
'total_qty' => 'decimal:4',
|
||||
];
|
||||
|
||||
public function items(): HasMany
|
||||
|
||||
@@ -23,11 +23,15 @@ class SyncOrderRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'external_order_id' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
'warehouse_code' => 'required|string',
|
||||
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
'total_qty' => 'required|numeric|min:0',
|
||||
'sold_at' => 'nullable|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.pos_product_id' => 'required|string',
|
||||
'items.*.product_id' => 'required|integer',
|
||||
'items.*.batch_number' => 'nullable|string',
|
||||
'items.*.qty' => 'required|numeric|min:0.0001',
|
||||
'items.*.price' => 'required|numeric|min:0',
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Integration\Controllers\InventorySyncController;
|
||||
Route::prefix('api/v1/integration')
|
||||
->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum'])
|
||||
->group(function () {
|
||||
Route::get('products', [ProductSyncController::class, 'index']);
|
||||
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
|
||||
Route::post('orders', [OrderSyncController::class, 'store']);
|
||||
Route::post('vending/orders', [VendingOrderSyncController::class, 'store']);
|
||||
|
||||
@@ -21,9 +21,12 @@ interface InventoryServiceInterface
|
||||
* @param string|null $reason
|
||||
* @param bool $force
|
||||
* @param string|null $slot
|
||||
* @param string|null $referenceType
|
||||
* @param int|string|null $referenceId
|
||||
* @param string|null $batchNumber
|
||||
* @return void
|
||||
*/
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void;
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null, ?string $batchNumber = null): void;
|
||||
|
||||
/**
|
||||
* Get all active warehouses.
|
||||
@@ -162,9 +165,10 @@ interface InventoryServiceInterface
|
||||
* Get inventory summary (group by product) for a specific warehouse code
|
||||
*
|
||||
* @param string $code
|
||||
* @param array $filters
|
||||
* @return \Illuminate\Support\Collection|null
|
||||
*/
|
||||
public function getPosInventoryByWarehouseCode(string $code);
|
||||
public function getPosInventoryByWarehouseCode(string $code, array $filters = []);
|
||||
|
||||
/**
|
||||
* 處理批量入庫邏輯 (含批號產生與現有批號累加)。
|
||||
|
||||
@@ -31,6 +31,14 @@ interface ProductServiceInterface
|
||||
*/
|
||||
public function findByExternalPosIds(array $externalPosIds);
|
||||
|
||||
/**
|
||||
* 透過多個 ERP 內部 ID 查找產品。
|
||||
*
|
||||
* @param array $ids
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function findByIds(array $ids);
|
||||
|
||||
/**
|
||||
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
|
||||
*
|
||||
@@ -78,4 +86,13 @@ interface ProductServiceInterface
|
||||
* @return \App\Modules\Inventory\Models\Product|null
|
||||
*/
|
||||
public function findByBarcodeOrCode(?string $barcode, ?string $code);
|
||||
|
||||
/**
|
||||
* 搜尋商品(供外部 API 使用)。
|
||||
*
|
||||
* @param array $filters
|
||||
* @param int $perPage
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
||||
*/
|
||||
public function searchProducts(array $filters, int $perPage = 50);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ class AdjustDocController extends Controller
|
||||
|
||||
return Inertia::render('Inventory/Adjust/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class CountDocController extends Controller
|
||||
|
||||
return Inertia::render('Inventory/Count/Index', [
|
||||
'docs' => $docs,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['warehouse_id', 'search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class InventoryController extends Controller
|
||||
'inventories.lastIncomingTransaction',
|
||||
'inventories.lastOutgoingTransaction'
|
||||
]);
|
||||
$allProducts = Product::with('category')->get();
|
||||
$allProducts = Product::select('id', 'name', 'code', 'category_id')->with('category:id,name')->get();
|
||||
|
||||
// 1. 準備 availableProducts
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
@@ -167,8 +167,8 @@ class InventoryController extends Controller
|
||||
public function create(Warehouse $warehouse)
|
||||
{
|
||||
// ... (unchanged) ...
|
||||
$products = Product::with(['baseUnit', 'largeUnit'])
|
||||
->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||||
$products = Product::select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||||
->with(['baseUnit:id,name', 'largeUnit:id,name'])
|
||||
->get()
|
||||
->map(function ($product) {
|
||||
return [
|
||||
|
||||
@@ -112,12 +112,12 @@ class ProductController extends Controller
|
||||
];
|
||||
});
|
||||
|
||||
$categories = Category::where('is_active', true)->get();
|
||||
$categories = Category::select('id', 'name')->where('is_active', true)->get();
|
||||
|
||||
return Inertia::render('Product/Index', [
|
||||
'products' => $products,
|
||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
'categories' => $categories->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||
]);
|
||||
}
|
||||
@@ -172,8 +172,8 @@ class ProductController extends Controller
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('Product/Create', [
|
||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -231,8 +231,8 @@ class ProductController extends Controller
|
||||
'wholesale_price' => (float) $product->wholesale_price,
|
||||
'is_active' => (bool) $product->is_active,
|
||||
],
|
||||
'categories' => Category::where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::all()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
'categories' => Category::select('id', 'name')->where('is_active', true)->get()->map(fn($c) => (object)['id' => $c->id, 'name' => $c->name]),
|
||||
'units' => Unit::select('id', 'name', 'code')->get()->map(fn($u) => (object)['id' => (string) $u->id, 'name' => $u->name, 'code' => $u->code]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class SafetyStockController extends Controller
|
||||
*/
|
||||
public function index(Warehouse $warehouse)
|
||||
{
|
||||
$allProducts = Product::with(['category', 'baseUnit'])->get();
|
||||
$allProducts = Product::select('id', 'name', 'category_id', 'base_unit_id')->with(['category:id,name', 'baseUnit:id,name'])->get();
|
||||
|
||||
// 準備可選商品列表
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
|
||||
@@ -65,7 +65,7 @@ class TransferOrderController extends Controller
|
||||
|
||||
return Inertia::render('Inventory/Transfer/Index', [
|
||||
'orders' => $orders,
|
||||
'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'warehouses' => Warehouse::select('id', 'name')->get()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]),
|
||||
'filters' => $request->only(['search', 'warehouse_id', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -44,16 +44,23 @@ class AdjustService
|
||||
);
|
||||
|
||||
// 2. 抓取有差異的明細 (diff_qty != 0)
|
||||
$itemsToInsert = [];
|
||||
foreach ($countDoc->items as $item) {
|
||||
if (abs($item->diff_qty) < 0.0001) continue;
|
||||
|
||||
$adjDoc->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'adjust_doc_id' => $adjDoc->id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'qty_before' => $item->system_qty,
|
||||
'adjust_qty' => $item->diff_qty,
|
||||
'notes' => "盤點差異: " . $item->diff_qty,
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
if (!empty($itemsToInsert)) {
|
||||
InventoryAdjustItem::insert($itemsToInsert);
|
||||
}
|
||||
|
||||
return $adjDoc;
|
||||
@@ -84,25 +91,35 @@ class AdjustService
|
||||
|
||||
$doc->items()->delete();
|
||||
|
||||
$itemsToInsert = [];
|
||||
$productIds = collect($itemsData)->pluck('product_id')->unique()->toArray();
|
||||
$products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||||
|
||||
// 批次取得當前庫存
|
||||
$inventories = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->get();
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
// 取得當前庫存作為 qty_before 參考 (僅參考,實際扣減以過帳當下為準)
|
||||
$inventory = Inventory::where('warehouse_id', $doc->warehouse_id)
|
||||
->where('product_id', $data['product_id'])
|
||||
$inventory = $inventories->where('product_id', $data['product_id'])
|
||||
->where('batch_number', $data['batch_number'] ?? null)
|
||||
->first();
|
||||
|
||||
$qtyBefore = $inventory ? $inventory->quantity : 0;
|
||||
|
||||
$newItem = $doc->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'adjust_doc_id' => $doc->id,
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'qty_before' => $qtyBefore,
|
||||
'adjust_qty' => $data['adjust_qty'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
// 更新日誌中的品項列表
|
||||
$productName = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name;
|
||||
$productName = $products->get($data['product_id'])?->name ?? '未知商品';
|
||||
$found = false;
|
||||
foreach ($updatedItems as $idx => $ui) {
|
||||
if ($ui['product_name'] === $productName && $ui['new'] === null) {
|
||||
@@ -126,6 +143,10 @@ class AdjustService
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($itemsToInsert)) {
|
||||
InventoryAdjustItem::insert($itemsToInsert);
|
||||
}
|
||||
|
||||
// 清理沒被更新到的舊品項 (即真正被刪除的)
|
||||
$finalUpdatedItems = [];
|
||||
foreach ($updatedItems as $ui) {
|
||||
@@ -162,11 +183,20 @@ class AdjustService
|
||||
foreach ($doc->items as $item) {
|
||||
if ($item->adjust_qty == 0) continue;
|
||||
|
||||
$inventory = Inventory::firstOrNew([
|
||||
// 補上 lockForUpdate() 防止併發衝突
|
||||
$inventory = Inventory::where([
|
||||
'warehouse_id' => $doc->warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
]);
|
||||
])->lockForUpdate()->first();
|
||||
|
||||
if (!$inventory) {
|
||||
$inventory = new Inventory([
|
||||
'warehouse_id' => $doc->warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
]);
|
||||
}
|
||||
|
||||
// 如果是新建立的 object (id 為空),需要初始化 default 並先行儲存
|
||||
if (!$inventory->exists) {
|
||||
|
||||
@@ -47,14 +47,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
$productIds = collect($data['items'])->pluck('product_id')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
$itemsToInsert = [];
|
||||
foreach ($data['items'] as $itemData) {
|
||||
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
||||
$totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard'
|
||||
? (float) $itemData['subtotal']
|
||||
: $itemData['quantity_received'] * $itemData['unit_price'];
|
||||
|
||||
// Create GR Item
|
||||
$grItem = new GoodsReceiptItem([
|
||||
$itemsToInsert[] = [
|
||||
'goods_receipt_id' => $goodsReceipt->id,
|
||||
'product_id' => $itemData['product_id'],
|
||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||
'quantity_received' => $itemData['quantity_received'],
|
||||
@@ -62,8 +63,9 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
'total_amount' => $totalAmount,
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||
]);
|
||||
$goodsReceipt->items()->save($grItem);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$product = $products->get($itemData['product_id']);
|
||||
$diff['added'][] = [
|
||||
@@ -76,6 +78,10 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($itemsToInsert)) {
|
||||
GoodsReceiptItem::insert($itemsToInsert);
|
||||
}
|
||||
|
||||
// 4. 手動發送高品質日誌(包含品項明細)
|
||||
activity()
|
||||
->performedOn($goodsReceipt)
|
||||
@@ -146,13 +152,15 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
if (isset($data['items'])) {
|
||||
$goodsReceipt->items()->delete();
|
||||
|
||||
$itemsToInsert = [];
|
||||
foreach ($data['items'] as $itemData) {
|
||||
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
|
||||
$totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard'
|
||||
? (float) $itemData['subtotal']
|
||||
: $itemData['quantity_received'] * $itemData['unit_price'];
|
||||
|
||||
$grItem = new GoodsReceiptItem([
|
||||
$itemsToInsert[] = [
|
||||
'goods_receipt_id' => $goodsReceipt->id,
|
||||
'product_id' => $itemData['product_id'],
|
||||
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
|
||||
'quantity_received' => $itemData['quantity_received'],
|
||||
@@ -160,8 +168,13 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
'total_amount' => $totalAmount,
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
'expiry_date' => $itemData['expiry_date'] ?? null,
|
||||
]);
|
||||
$goodsReceipt->items()->save($grItem);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($itemsToInsert)) {
|
||||
GoodsReceiptItem::insert($itemsToInsert);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,11 +261,14 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
|
||||
*/
|
||||
public function submit(GoodsReceipt $goodsReceipt)
|
||||
{
|
||||
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
|
||||
throw new \Exception('只有草稿或被退回的進貨單可以確認點收。');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($goodsReceipt) {
|
||||
// Pessimistic locking to prevent double submission
|
||||
$goodsReceipt = GoodsReceipt::lockForUpdate()->find($goodsReceipt->id);
|
||||
|
||||
if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) {
|
||||
throw new \Exception('只有草稿或被退回的進貨單可以確認點收。');
|
||||
}
|
||||
|
||||
$goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED;
|
||||
$goodsReceipt->save();
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
{
|
||||
public function getAllWarehouses()
|
||||
{
|
||||
return Warehouse::all();
|
||||
return Warehouse::select('id', 'name', 'code', 'type')->get();
|
||||
}
|
||||
|
||||
public function getTopInventoryValue(int $limit = 5): \Illuminate\Support\Collection
|
||||
@@ -38,12 +38,14 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
public function getAllProducts()
|
||||
{
|
||||
return Product::with(['baseUnit', 'largeUnit'])->get();
|
||||
return Product::select('id', 'name', 'code', 'base_unit_id', 'large_unit_id')
|
||||
->with(['baseUnit:id,name', 'largeUnit:id,name'])
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getUnits()
|
||||
{
|
||||
return \App\Modules\Inventory\Models\Unit::all();
|
||||
return \App\Modules\Inventory\Models\Unit::select('id', 'name')->get();
|
||||
}
|
||||
|
||||
public function getInventoriesByIds(array $ids, array $with = [])
|
||||
@@ -85,41 +87,58 @@ class InventoryService implements InventoryServiceInterface
|
||||
return $stock >= $quantity;
|
||||
}
|
||||
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void
|
||||
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null, ?string $batchNumber = null): void
|
||||
{
|
||||
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot) {
|
||||
$query = Inventory::where('product_id', $productId)
|
||||
->where('warehouse_id', $warehouseId)
|
||||
->where('quantity', '>', 0);
|
||||
|
||||
if ($slot) {
|
||||
$query->where('location', $slot);
|
||||
}
|
||||
|
||||
$inventories = $query->orderBy('arrival_date', 'asc')
|
||||
->get();
|
||||
|
||||
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot, $referenceType, $referenceId, $batchNumber) {
|
||||
$defaultBatch = 'NO-BATCH';
|
||||
$targetBatch = $batchNumber ?? $defaultBatch;
|
||||
$remainingToDecrease = $quantity;
|
||||
|
||||
// 1. 優先嘗試扣除指定批號(或預設的 NO-BATCH)
|
||||
$inventories = Inventory::where('product_id', $productId)
|
||||
->where('warehouse_id', $warehouseId)
|
||||
->where('batch_number', $targetBatch)
|
||||
->where('quantity', '>', 0)
|
||||
->when($slot, fn($q) => $q->where('location', $slot))
|
||||
->lockForUpdate()
|
||||
->orderBy('arrival_date', 'asc')
|
||||
->get();
|
||||
|
||||
foreach ($inventories as $inventory) {
|
||||
if ($remainingToDecrease <= 0) break;
|
||||
|
||||
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
|
||||
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason);
|
||||
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
|
||||
$remainingToDecrease -= $decreaseAmount;
|
||||
}
|
||||
|
||||
// 2. 如果還有剩餘且剛才不是扣 NO-BATCH,則嘗試從 NO-BATCH 補位
|
||||
if ($remainingToDecrease > 0 && $targetBatch !== $defaultBatch) {
|
||||
$fallbackInventories = Inventory::where('product_id', $productId)
|
||||
->where('warehouse_id', $warehouseId)
|
||||
->where('batch_number', $defaultBatch)
|
||||
->where('quantity', '>', 0)
|
||||
->when($slot, fn($q) => $q->where('location', $slot))
|
||||
->lockForUpdate()
|
||||
->orderBy('arrival_date', 'asc')
|
||||
->get();
|
||||
|
||||
foreach ($fallbackInventories as $inventory) {
|
||||
if ($remainingToDecrease <= 0) break;
|
||||
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
|
||||
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
|
||||
$remainingToDecrease -= $decreaseAmount;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 處理最終仍不足的情況
|
||||
if ($remainingToDecrease > 0) {
|
||||
if ($force) {
|
||||
// Find any existing inventory record in this warehouse/slot to subtract from, or create one
|
||||
$query = Inventory::where('product_id', $productId)
|
||||
->where('warehouse_id', $warehouseId);
|
||||
|
||||
if ($slot) {
|
||||
$query->where('location', $slot);
|
||||
}
|
||||
|
||||
$inventory = $query->first();
|
||||
// 強制模式下,若指定批號或 NO-BATCH 均不足,統一在 NO-BATCH 建立/扣除負庫存
|
||||
$inventory = Inventory::where('product_id', $productId)
|
||||
->where('warehouse_id', $warehouseId)
|
||||
->where('batch_number', $defaultBatch)
|
||||
->when($slot, fn($q) => $q->where('location', $slot))
|
||||
->first();
|
||||
|
||||
if (!$inventory) {
|
||||
$inventory = Inventory::create([
|
||||
@@ -129,16 +148,19 @@ class InventoryService implements InventoryServiceInterface
|
||||
'quantity' => 0,
|
||||
'unit_cost' => 0,
|
||||
'total_value' => 0,
|
||||
'batch_number' => 'POS-AUTO-' . ($slot ? $slot . '-' : '') . time(),
|
||||
'batch_number' => $defaultBatch,
|
||||
'arrival_date' => now(),
|
||||
'origin_country' => 'TW',
|
||||
'quality_status' => 'normal',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason);
|
||||
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason, $referenceType, $referenceId);
|
||||
} else {
|
||||
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
|
||||
$context = ($targetBatch !== $defaultBatch)
|
||||
? "批號 {$targetBatch} 或 {$defaultBatch}"
|
||||
: "{$defaultBatch}";
|
||||
throw new \Exception("庫存不足,無法扣除所有請求的數量 ({$context})。");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -655,7 +677,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
* @param string $code
|
||||
* @return \Illuminate\Support\Collection|null
|
||||
*/
|
||||
public function getPosInventoryByWarehouseCode(string $code)
|
||||
public function getPosInventoryByWarehouseCode(string $code, array $filters = [])
|
||||
{
|
||||
$warehouse = Warehouse::where('code', $code)->first();
|
||||
|
||||
@@ -663,19 +685,60 @@ class InventoryService implements InventoryServiceInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
// 整理該倉庫的庫存,以 product_id 進行 GROUP BY 並加總 quantity
|
||||
return DB::table('inventories')
|
||||
$query = DB::table('inventories')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
|
||||
->leftJoin('units', 'products.base_unit_id', '=', 'units.id')
|
||||
->where('inventories.warehouse_id', $warehouse->id)
|
||||
->whereNull('inventories.deleted_at')
|
||||
->whereNull('products.deleted_at')
|
||||
->select(
|
||||
'products.id as product_id',
|
||||
'products.external_pos_id',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'products.barcode',
|
||||
'categories.name as category_name',
|
||||
'units.name as unit_name',
|
||||
'products.price',
|
||||
'products.brand',
|
||||
'products.specification',
|
||||
'inventories.batch_number',
|
||||
'inventories.expiry_date',
|
||||
DB::raw('SUM(inventories.quantity) as total_quantity')
|
||||
);
|
||||
|
||||
// 加入條件篩選
|
||||
if (!empty($filters['product_id'])) {
|
||||
$query->where('products.id', $filters['product_id']);
|
||||
}
|
||||
|
||||
if (!empty($filters['external_pos_id'])) {
|
||||
$query->where('products.external_pos_id', $filters['external_pos_id']);
|
||||
}
|
||||
|
||||
if (!empty($filters['barcode'])) {
|
||||
$query->where('products.barcode', $filters['barcode']);
|
||||
}
|
||||
|
||||
if (!empty($filters['code'])) {
|
||||
$query->where('products.code', $filters['code']);
|
||||
}
|
||||
|
||||
return $query->groupBy(
|
||||
'inventories.product_id',
|
||||
'products.external_pos_id',
|
||||
'products.code',
|
||||
'products.name',
|
||||
'products.barcode',
|
||||
'categories.name',
|
||||
'units.name',
|
||||
'products.price',
|
||||
'products.brand',
|
||||
'products.specification',
|
||||
'inventories.batch_number',
|
||||
'inventories.expiry_date'
|
||||
)
|
||||
->groupBy('inventories.product_id', 'products.external_pos_id', 'products.code', 'products.name')
|
||||
->get();
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,22 @@ class ProductService implements ProductServiceInterface
|
||||
|
||||
// Map allowed fields
|
||||
$product->name = $data['name'];
|
||||
$product->barcode = $data['barcode'] ?? $product->barcode;
|
||||
$product->price = $data['price'] ?? 0;
|
||||
|
||||
// Handle Barcode
|
||||
if (!empty($data['barcode'])) {
|
||||
$product->barcode = $data['barcode'];
|
||||
} elseif (empty($product->barcode)) {
|
||||
$product->barcode = $this->generateRandomBarcode();
|
||||
}
|
||||
|
||||
// Handle Code (SKU)
|
||||
if (!empty($data['code'])) {
|
||||
$product->code = $data['code'];
|
||||
} elseif (empty($product->code)) {
|
||||
$product->code = $this->generateRandomCode();
|
||||
}
|
||||
|
||||
// Map newly added extended fields
|
||||
if (isset($data['brand'])) $product->brand = $data['brand'];
|
||||
if (isset($data['specification'])) $product->specification = $data['specification'];
|
||||
@@ -48,11 +61,6 @@ class ProductService implements ProductServiceInterface
|
||||
if (isset($data['member_price'])) $product->member_price = $data['member_price'];
|
||||
if (isset($data['wholesale_price'])) $product->wholesale_price = $data['wholesale_price'];
|
||||
|
||||
// Generate Code if missing (use code or external_id)
|
||||
if (empty($product->code)) {
|
||||
$product->code = $data['code'] ?? $product->external_pos_id;
|
||||
}
|
||||
|
||||
// Handle Category — 每次同步都更新(若有傳入)
|
||||
if (!empty($data['category']) || empty($product->category_id)) {
|
||||
$categoryName = $data['category'] ?? '未分類';
|
||||
@@ -100,6 +108,17 @@ class ProductService implements ProductServiceInterface
|
||||
return Product::whereIn('external_pos_id', $externalPosIds)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 透過多個 ERP 內部 ID 查找產品。
|
||||
*
|
||||
* @param array $ids
|
||||
* @return \Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function findByIds(array $ids)
|
||||
{
|
||||
return Product::whereIn('id', $ids)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
|
||||
*
|
||||
@@ -178,4 +197,54 @@ class ProductService implements ProductServiceInterface
|
||||
}
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜尋商品(供外部 API 使用)。
|
||||
*
|
||||
* @param array $filters
|
||||
* @param int $perPage
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
|
||||
*/
|
||||
public function searchProducts(array $filters, int $perPage = 50)
|
||||
{
|
||||
$query = Product::query()
|
||||
->with(['category', 'baseUnit'])
|
||||
->where('is_active', true);
|
||||
|
||||
// 1. 精準過濾 (ID, 條碼, 代碼, 外部 ID)
|
||||
if (!empty($filters['product_id'])) {
|
||||
$query->where('id', $filters['product_id']);
|
||||
}
|
||||
if (!empty($filters['barcode'])) {
|
||||
$query->where('barcode', $filters['barcode']);
|
||||
}
|
||||
if (!empty($filters['code'])) {
|
||||
$query->where('code', $filters['code']);
|
||||
}
|
||||
if (!empty($filters['external_pos_id'])) {
|
||||
$query->where('external_pos_id', $filters['external_pos_id']);
|
||||
}
|
||||
|
||||
// 3. 分類過濾 (優先使用 ID,若傳入字串則按名稱)
|
||||
if (!empty($filters['category'])) {
|
||||
$categoryVal = $filters['category'];
|
||||
if (is_numeric($categoryVal)) {
|
||||
$query->where('category_id', $categoryVal);
|
||||
} else {
|
||||
$query->whereHas('category', function ($q) use ($categoryVal) {
|
||||
$q->where('name', $categoryVal);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 增量同步 (Updated After)
|
||||
if (!empty($filters['updated_after'])) {
|
||||
$query->where('updated_at', '>=', $filters['updated_after']);
|
||||
}
|
||||
|
||||
// 4. 排序 (預設按更新時間降冪)
|
||||
$query->orderBy('updated_at', 'desc');
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,15 +53,22 @@ class StoreRequisitionService
|
||||
// 靜默建立以抑制自動日誌
|
||||
$requisition->saveQuietly();
|
||||
|
||||
$itemsToInsert = [];
|
||||
$productIds = collect($items)->pluck('product_id')->unique()->toArray();
|
||||
$products = \App\Modules\Inventory\Models\Product::whereIn('id', $productIds)->get()->keyBy('id');
|
||||
|
||||
$diff = ['added' => [], 'removed' => [], 'updated' => []];
|
||||
foreach ($items as $item) {
|
||||
$requisition->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'store_requisition_id' => $requisition->id,
|
||||
'product_id' => $item['product_id'],
|
||||
'requested_qty' => $item['requested_qty'],
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$product = \App\Modules\Inventory\Models\Product::find($item['product_id']);
|
||||
$product = $products->get($item['product_id']);
|
||||
$diff['added'][] = [
|
||||
'product_name' => $product?->name ?? '未知商品',
|
||||
'new' => [
|
||||
@@ -70,6 +77,7 @@ class StoreRequisitionService
|
||||
]
|
||||
];
|
||||
}
|
||||
StoreRequisitionItem::insert($itemsToInsert);
|
||||
|
||||
// 如果需直接提交,觸發通知
|
||||
if ($submitImmediately) {
|
||||
@@ -179,13 +187,18 @@ class StoreRequisitionService
|
||||
|
||||
// 儲存實際變動
|
||||
$requisition->items()->delete();
|
||||
$itemsToInsert = [];
|
||||
foreach ($items as $item) {
|
||||
$requisition->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'store_requisition_id' => $requisition->id,
|
||||
'product_id' => $item['product_id'],
|
||||
'requested_qty' => $item['requested_qty'],
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
StoreRequisitionItem::insert($itemsToInsert);
|
||||
|
||||
// 檢查是否有任何變動 (主表或明細)
|
||||
$isDirty = $requisition->isDirty();
|
||||
@@ -314,6 +327,7 @@ class StoreRequisitionService
|
||||
$supplyWarehouseId = $requisition->supply_warehouse_id;
|
||||
$totalAvailable = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $supplyWarehouseId)
|
||||
->where('product_id', $reqItem->product_id)
|
||||
->lockForUpdate() // 補上鎖定
|
||||
->selectRaw('SUM(quantity - reserved_quantity) as available')
|
||||
->value('available') ?? 0;
|
||||
|
||||
|
||||
@@ -74,11 +74,12 @@ class TransferService
|
||||
return [$key => $item];
|
||||
});
|
||||
|
||||
// 釋放舊明細的預扣庫存
|
||||
// 釋放舊明細的預扣庫存 (必須加鎖,防止並發更新時數量出錯)
|
||||
foreach ($order->items as $item) {
|
||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($inv) {
|
||||
$inv->releaseReservedQuantity($item->quantity);
|
||||
@@ -91,42 +92,69 @@ class TransferService
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
// 先刪除舊明細
|
||||
$order->items()->delete();
|
||||
|
||||
$itemsToInsert = [];
|
||||
$newItemsKeys = [];
|
||||
|
||||
// 1. 批量收集待插入的明細數據
|
||||
foreach ($itemsData as $data) {
|
||||
$key = $data['product_id'] . '_' . ($data['batch_number'] ?? '');
|
||||
$newItemsKeys[] = $key;
|
||||
|
||||
$item = $order->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'transfer_order_id' => $order->id,
|
||||
'product_id' => $data['product_id'],
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'quantity' => $data['quantity'],
|
||||
'position' => $data['position'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
$item->load('product');
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
// 增加新明細的預扣庫存
|
||||
$inv = Inventory::firstOrCreate(
|
||||
[
|
||||
// 2. 執行批量寫入 (提升效能:100 筆明細只需 1 次寫入)
|
||||
if (!empty($itemsToInsert)) {
|
||||
InventoryTransferItem::insert($itemsToInsert);
|
||||
}
|
||||
|
||||
// 3. 重新載入明細進行預扣處理與 Diff 計算 (因 insert 不返回 Model)
|
||||
$order->load(['items.product.baseUnit']);
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
||||
|
||||
// 增加新明細的預扣庫存 (使用 lockForUpdate 確保並發安全)
|
||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$inv) {
|
||||
$inv = Inventory::create([
|
||||
'warehouse_id' => $order->from_warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => 0,
|
||||
'total_value' => 0,
|
||||
]
|
||||
);
|
||||
]);
|
||||
$inv = $inv->fresh()->lockForUpdate();
|
||||
}
|
||||
|
||||
$inv->reserveQuantity($item->quantity);
|
||||
|
||||
// 計算 Diff 用於日誌
|
||||
$data = collect($itemsData)->first(fn($d) => $d['product_id'] == $item->product_id && ($d['batch_number'] ?? '') == ($item->batch_number ?? ''));
|
||||
|
||||
if ($oldItemsMap->has($key)) {
|
||||
$oldItem = $oldItemsMap->get($key);
|
||||
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
||||
$oldItem->notes !== ($data['notes'] ?? null) ||
|
||||
$oldItem->position !== ($data['position'] ?? null)) {
|
||||
if ((float)$oldItem->quantity !== (float)$item->quantity ||
|
||||
$oldItem->notes !== $item->notes ||
|
||||
$oldItem->position !== $item->position) {
|
||||
|
||||
$diff['updated'][] = [
|
||||
'product_name' => $item->product->name,
|
||||
@@ -137,7 +165,7 @@ class TransferService
|
||||
'notes' => $oldItem->notes,
|
||||
],
|
||||
'new' => [
|
||||
'quantity' => (float)$data['quantity'],
|
||||
'quantity' => (float)$item->quantity,
|
||||
'position' => $item->position,
|
||||
'notes' => $item->notes,
|
||||
]
|
||||
@@ -158,8 +186,8 @@ class TransferService
|
||||
foreach ($oldItemsMap as $key => $oldItem) {
|
||||
if (!in_array($key, $newItemsKeys)) {
|
||||
$diff['removed'][] = [
|
||||
'product_name' => $oldItem->product->name,
|
||||
'unit_name' => $oldItem->product->baseUnit?->name,
|
||||
'product_name' => $oldItem->product?->name ?? "未知商品 (ID: {$oldItem->product_id})",
|
||||
'unit_name' => $oldItem->product?->baseUnit?->name,
|
||||
'old' => [
|
||||
'quantity' => (float)$oldItem->quantity,
|
||||
'notes' => $oldItem->notes,
|
||||
@@ -179,9 +207,6 @@ class TransferService
|
||||
|
||||
/**
|
||||
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
||||
*
|
||||
* 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched
|
||||
* 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯)
|
||||
*/
|
||||
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
@@ -194,18 +219,16 @@ class TransferService
|
||||
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
||||
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
||||
|
||||
$outType = '調撥出庫';
|
||||
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
|
||||
|
||||
$itemsDiff = [];
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 處理來源倉 (扣除)
|
||||
// 1. 處理來源倉 (扣除) - 使用 lockForUpdate 防止超賣
|
||||
$sourceInventory = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $item->quantity) {
|
||||
@@ -235,11 +258,11 @@ class TransferService
|
||||
|
||||
$sourceAfter = $sourceBefore - (float) $item->quantity;
|
||||
|
||||
// 2. 處理目的倉/在途倉 (增加)
|
||||
// 獲取目的倉異動前的庫存數(若無則為 0)
|
||||
// 2. 處理目的倉/在途倉 (增加) - 同樣需要鎖定,防止並發增加時出現 Race Condition
|
||||
$targetInventoryBefore = Inventory::where('warehouse_id', $targetWarehouseId)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||||
|
||||
@@ -310,7 +333,6 @@ class TransferService
|
||||
|
||||
/**
|
||||
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
||||
* 僅適用於有在途倉且狀態為 dispatched 的調撥單
|
||||
*/
|
||||
public function receive(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
@@ -333,10 +355,11 @@ class TransferService
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 在途倉扣除
|
||||
// 1. 在途倉扣除 - 使用 lockForUpdate 防止超賣
|
||||
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
||||
@@ -359,10 +382,11 @@ class TransferService
|
||||
|
||||
$transitAfter = $transitBefore - (float) $item->quantity;
|
||||
|
||||
// 2. 目的倉增加
|
||||
// 2. 目的倉增加 - 同樣需要鎖定
|
||||
$targetInventoryBefore = Inventory::where('warehouse_id', $order->to_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
$targetBefore = $targetInventoryBefore ? (float) $targetInventoryBefore->quantity : 0;
|
||||
|
||||
@@ -440,6 +464,7 @@ class TransferService
|
||||
$inv = Inventory::where('warehouse_id', $order->from_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($inv) {
|
||||
$inv->releaseReservedQuantity($item->quantity);
|
||||
|
||||
@@ -69,6 +69,12 @@ class TurnoverService
|
||||
->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d'))
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data
|
||||
->where(function ($q) {
|
||||
$q->whereIn('inventory_transactions.reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('inventory_transactions.reference_type');
|
||||
})
|
||||
->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo)
|
||||
->groupBy('inventories.product_id');
|
||||
|
||||
@@ -87,6 +93,12 @@ class TurnoverService
|
||||
->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date'))
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->where('inventory_transactions.type', '出庫')
|
||||
->where(function ($q) {
|
||||
$q->whereIn('inventory_transactions.reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('inventory_transactions.reference_type');
|
||||
})
|
||||
->groupBy('inventories.product_id');
|
||||
|
||||
if ($warehouseId) {
|
||||
@@ -199,6 +211,12 @@ class TurnoverService
|
||||
// Get IDs of products sold in last 90 days
|
||||
$soldProductIds = InventoryTransaction::query()
|
||||
->where('type', '出庫')
|
||||
->where(function ($q) {
|
||||
$q->whereIn('reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('reference_type');
|
||||
})
|
||||
->where('actual_time', '>=', $ninetyDaysAgo)
|
||||
->distinct()
|
||||
->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product.
|
||||
@@ -214,6 +232,12 @@ class TurnoverService
|
||||
$soldProductIdsQuery = DB::table('inventory_transactions')
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->where('inventory_transactions.type', '出庫')
|
||||
->where(function ($q) {
|
||||
$q->whereIn('inventory_transactions.reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('inventory_transactions.reference_type');
|
||||
})
|
||||
->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo)
|
||||
->select('inventories.product_id')
|
||||
->distinct();
|
||||
@@ -236,6 +260,12 @@ class TurnoverService
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->where('inventory_transactions.type', '出庫')
|
||||
->where(function ($q) {
|
||||
$q->whereIn('inventory_transactions.reference_type', [
|
||||
\App\Modules\Integration\Models\SalesOrder::class,
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
])->orWhereNull('inventory_transactions.reference_type');
|
||||
})
|
||||
->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays($analysisDays))
|
||||
->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId))
|
||||
->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId))
|
||||
|
||||
@@ -118,7 +118,7 @@ class PurchaseOrderController extends Controller
|
||||
public function create()
|
||||
{
|
||||
// 1. 獲取廠商(無關聯)
|
||||
$vendors = Vendor::all();
|
||||
$vendors = Vendor::select('id', 'name')->get();
|
||||
|
||||
// 2. 手動注入:獲取 Pivot 資料
|
||||
$vendorIds = $vendors->pluck('id')->toArray();
|
||||
@@ -254,17 +254,21 @@ class PurchaseOrderController extends Controller
|
||||
$productIds = collect($validated['items'])->pluck('productId')->unique()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
$itemsToInsert = [];
|
||||
foreach ($validated['items'] as $item) {
|
||||
// 反算單價
|
||||
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||
|
||||
$order->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'purchase_order_id' => $order->id,
|
||||
'product_id' => $item['productId'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_id' => $item['unitId'] ?? null,
|
||||
'unit_price' => $unitPrice,
|
||||
'subtotal' => $item['subtotal'],
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$product = $products->get($item['productId']);
|
||||
$diff['added'][] = [
|
||||
@@ -275,6 +279,7 @@ class PurchaseOrderController extends Controller
|
||||
]
|
||||
];
|
||||
}
|
||||
\App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert);
|
||||
|
||||
// 手動發送高品質日誌(包含品項明細)
|
||||
activity()
|
||||
@@ -379,7 +384,7 @@ class PurchaseOrderController extends Controller
|
||||
$order = PurchaseOrder::with(['items'])->findOrFail($id);
|
||||
|
||||
// 2. 獲取廠商與商品(與 create 邏輯一致)
|
||||
$vendors = Vendor::all();
|
||||
$vendors = Vendor::select('id', 'name')->get();
|
||||
$vendorIds = $vendors->pluck('id')->toArray();
|
||||
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
|
||||
$productIds = $pivots->pluck('product_id')->unique()->toArray();
|
||||
@@ -468,7 +473,8 @@ class PurchaseOrderController extends Controller
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$order = PurchaseOrder::findOrFail($id);
|
||||
// 加上 lockForUpdate() 防止併發修改
|
||||
$order = PurchaseOrder::lockForUpdate()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'required|exists:vendors,id',
|
||||
@@ -572,20 +578,23 @@ class PurchaseOrderController extends Controller
|
||||
// 同步項目(原始邏輯)
|
||||
$order->items()->delete();
|
||||
|
||||
$newItemsData = [];
|
||||
$itemsToInsert = [];
|
||||
foreach ($validated['items'] as $item) {
|
||||
// 反算單價
|
||||
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||
|
||||
$newItem = $order->items()->create([
|
||||
$itemsToInsert[] = [
|
||||
'purchase_order_id' => $order->id,
|
||||
'product_id' => $item['productId'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_id' => $item['unitId'] ?? null,
|
||||
'unit_price' => $unitPrice,
|
||||
'subtotal' => $item['subtotal'],
|
||||
]);
|
||||
$newItemsData[] = $newItem;
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
\App\Modules\Procurement\Models\PurchaseOrderItem::insert($itemsToInsert);
|
||||
|
||||
// 3. 計算項目差異
|
||||
$itemDiffs = [
|
||||
|
||||
@@ -48,7 +48,7 @@ class PurchaseReturnController extends Controller
|
||||
{
|
||||
// 取得可用的倉庫與廠商資料供前端選單使用
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
$vendors = Vendor::all();
|
||||
$vendors = Vendor::select('id', 'name')->get();
|
||||
|
||||
// 手動注入:獲取廠商商品 (與 PurchaseOrderController 邏輯一致)
|
||||
$vendorIds = $vendors->pluck('id')->toArray();
|
||||
@@ -157,7 +157,7 @@ class PurchaseReturnController extends Controller
|
||||
});
|
||||
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
$vendors = Vendor::all();
|
||||
$vendors = Vendor::select('id', 'name')->get();
|
||||
|
||||
// 手動注入:獲取廠商商品 (與 create 邏輯一致)
|
||||
$vendorIds = $vendors->pluck('id')->toArray();
|
||||
|
||||
@@ -33,20 +33,23 @@ class PurchaseReturnService
|
||||
|
||||
$purchaseReturn = PurchaseReturn::create($data);
|
||||
|
||||
$itemsToInsert = [];
|
||||
foreach ($data['items'] as $itemData) {
|
||||
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
||||
$totalAmount += $amount;
|
||||
|
||||
$prItem = new PurchaseReturnItem([
|
||||
$itemsToInsert[] = [
|
||||
'purchase_return_id' => $purchaseReturn->id,
|
||||
'product_id' => $itemData['product_id'],
|
||||
'quantity_returned' => $itemData['quantity_returned'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'total_amount' => $amount,
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
]);
|
||||
|
||||
$purchaseReturn->items()->save($prItem);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
PurchaseReturnItem::insert($itemsToInsert);
|
||||
|
||||
// 更新總計 (這裡假定不含額外稅金邏輯,或是由前端帶入 tax_amount)
|
||||
$taxAmount = $data['tax_amount'] ?? 0;
|
||||
@@ -87,19 +90,23 @@ class PurchaseReturnService
|
||||
$purchaseReturn->items()->delete();
|
||||
$totalAmount = 0;
|
||||
|
||||
$itemsToInsert = [];
|
||||
foreach ($data['items'] as $itemData) {
|
||||
$amount = $itemData['quantity_returned'] * $itemData['unit_price'];
|
||||
$totalAmount += $amount;
|
||||
|
||||
$prItem = new PurchaseReturnItem([
|
||||
$itemsToInsert[] = [
|
||||
'purchase_return_id' => $purchaseReturn->id,
|
||||
'product_id' => $itemData['product_id'],
|
||||
'quantity_returned' => $itemData['quantity_returned'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'total_amount' => $amount,
|
||||
'batch_number' => $itemData['batch_number'] ?? null,
|
||||
]);
|
||||
$purchaseReturn->items()->save($prItem);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
PurchaseReturnItem::insert($itemsToInsert);
|
||||
|
||||
$taxAmount = $purchaseReturn->tax_amount;
|
||||
$purchaseReturn->update([
|
||||
@@ -117,11 +124,14 @@ class PurchaseReturnService
|
||||
*/
|
||||
public function submit(PurchaseReturn $purchaseReturn)
|
||||
{
|
||||
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
|
||||
throw new Exception('只有草稿狀態的退回單可以提交。');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($purchaseReturn) {
|
||||
// 加上 lockForUpdate() 防止併發提交
|
||||
$purchaseReturn = PurchaseReturn::lockForUpdate()->find($purchaseReturn->id);
|
||||
|
||||
if ($purchaseReturn->status !== PurchaseReturn::STATUS_DRAFT) {
|
||||
throw new Exception('只有草稿狀態的退回單可以提交。');
|
||||
}
|
||||
|
||||
// 1. 儲存狀態,避免觸發自動修改紀錄 (合併行為)
|
||||
$purchaseReturn->status = PurchaseReturn::STATUS_COMPLETED;
|
||||
$purchaseReturn->saveQuietly();
|
||||
|
||||
@@ -137,12 +137,12 @@ class ProductionOrderController extends Controller
|
||||
|
||||
$rules = [
|
||||
'product_id' => 'required',
|
||||
'status' => 'nullable|in:draft,completed',
|
||||
'warehouse_id' => $status === 'completed' ? 'required' : 'nullable',
|
||||
'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric',
|
||||
'status' => 'nullable|in:draft,pending,completed',
|
||||
'warehouse_id' => 'required',
|
||||
'output_quantity' => 'required|numeric|min:0.01',
|
||||
'items' => 'nullable|array',
|
||||
'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable',
|
||||
'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric',
|
||||
'items.*.inventory_id' => 'required',
|
||||
'items.*.quantity_used' => 'required|numeric|min:0.0001',
|
||||
];
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
@@ -159,7 +159,7 @@ class ProductionOrderController extends Controller
|
||||
'production_date' => $request->production_date,
|
||||
'expiry_date' => $request->expiry_date,
|
||||
'user_id' => auth()->id(),
|
||||
'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿
|
||||
'status' => $status ?: ProductionOrder::STATUS_DRAFT,
|
||||
'remark' => $request->remark,
|
||||
]);
|
||||
|
||||
@@ -170,14 +170,18 @@ class ProductionOrderController extends Controller
|
||||
|
||||
// 2. 處理明細
|
||||
if (!empty($request->items)) {
|
||||
$itemsToInsert = [];
|
||||
foreach ($request->items as $item) {
|
||||
ProductionOrderItem::create([
|
||||
$itemsToInsert[] = [
|
||||
'production_order_id' => $productionOrder->id,
|
||||
'inventory_id' => $item['inventory_id'],
|
||||
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||
'unit_id' => $item['unit_id'] ?? null,
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
ProductionOrderItem::insert($itemsToInsert);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -380,14 +384,18 @@ class ProductionOrderController extends Controller
|
||||
$productionOrder->items()->delete();
|
||||
|
||||
if (!empty($request->items)) {
|
||||
$itemsToInsert = [];
|
||||
foreach ($request->items as $item) {
|
||||
ProductionOrderItem::create([
|
||||
$itemsToInsert[] = [
|
||||
'production_order_id' => $productionOrder->id,
|
||||
'inventory_id' => $item['inventory_id'],
|
||||
'quantity_used' => $item['quantity_used'] ?? 0,
|
||||
'unit_id' => $item['unit_id'] ?? null,
|
||||
]);
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
ProductionOrderItem::insert($itemsToInsert);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -406,9 +414,30 @@ class ProductionOrderController extends Controller
|
||||
return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403);
|
||||
}
|
||||
|
||||
// 送審前的資料完整性驗證
|
||||
if ($productionOrder->status === ProductionOrder::STATUS_DRAFT && $newStatus === ProductionOrder::STATUS_PENDING) {
|
||||
if (!$productionOrder->output_quantity || $productionOrder->output_quantity <= 0) {
|
||||
return back()->with('error', '送審工單前,必須先編輯填寫「生產數量」');
|
||||
}
|
||||
if (!$productionOrder->warehouse_id) {
|
||||
return back()->with('error', '送審工單前,必須先編輯選擇「預計入庫倉庫」');
|
||||
}
|
||||
if ($productionOrder->items()->count() === 0) {
|
||||
return back()->with('error', '送審工單前,請至少新增一項原物料明細');
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($newStatus, $productionOrder, $request) {
|
||||
// 使用鎖定重新獲取單據,防止併發狀態修改
|
||||
$productionOrder = ProductionOrder::where('id', $productionOrder->id)->lockForUpdate()->first();
|
||||
|
||||
$oldStatus = $productionOrder->status;
|
||||
|
||||
// 再次檢查狀態轉移(在鎖定後)
|
||||
if (!$productionOrder->canTransitionTo($newStatus)) {
|
||||
throw new \Exception('不合法的狀態轉移或權限不足');
|
||||
}
|
||||
|
||||
// 1. 執行特定狀態的業務邏輯
|
||||
if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) {
|
||||
// 開始製作 -> 扣除原料庫存
|
||||
@@ -428,6 +457,8 @@ class ProductionOrderController extends Controller
|
||||
$warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來
|
||||
$batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來
|
||||
$expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來
|
||||
$actualOutputQuantity = $request->input('actual_output_quantity'); // 實際產出數量
|
||||
$lossReason = $request->input('loss_reason'); // 耗損原因
|
||||
|
||||
if (!$warehouseId) {
|
||||
throw new \Exception('必須選擇入庫倉庫');
|
||||
@@ -435,8 +466,14 @@ class ProductionOrderController extends Controller
|
||||
if (!$batchNumber) {
|
||||
throw new \Exception('必須提供成品批號');
|
||||
}
|
||||
if (!$actualOutputQuantity || $actualOutputQuantity <= 0) {
|
||||
throw new \Exception('實際產出數量必須大於 0');
|
||||
}
|
||||
if ($actualOutputQuantity > $productionOrder->output_quantity) {
|
||||
throw new \Exception('實際產出數量不可大於預計產量');
|
||||
}
|
||||
|
||||
// --- 新增:計算原物料投入總成本 ---
|
||||
// --- 計算原物料投入總成本 ---
|
||||
$totalCost = 0;
|
||||
$items = $productionOrder->items()->with('inventory')->get();
|
||||
foreach ($items as $item) {
|
||||
@@ -445,23 +482,25 @@ class ProductionOrderController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 計算單位成本 (若產出數量為 0 則設為 0 避免除以零錯誤)
|
||||
$unitCost = $productionOrder->output_quantity > 0
|
||||
? $totalCost / $productionOrder->output_quantity
|
||||
// 單位成本以「實際產出數量」為分母,反映真實生產效率
|
||||
$unitCost = $actualOutputQuantity > 0
|
||||
? $totalCost / $actualOutputQuantity
|
||||
: 0;
|
||||
// --------------------------------
|
||||
|
||||
// 更新單據資訊:批號、效期與自動記錄生產日期
|
||||
// 更新單據資訊:批號、效期、實際產量與耗損原因
|
||||
$productionOrder->output_batch_number = $batchNumber;
|
||||
$productionOrder->expiry_date = $expiryDate;
|
||||
$productionOrder->production_date = now()->toDateString();
|
||||
$productionOrder->warehouse_id = $warehouseId;
|
||||
$productionOrder->actual_output_quantity = $actualOutputQuantity;
|
||||
$productionOrder->loss_reason = $lossReason;
|
||||
|
||||
// 成品入庫數量改用「實際產出數量」
|
||||
$this->inventoryService->createInventoryRecord([
|
||||
'warehouse_id' => $warehouseId,
|
||||
'product_id' => $productionOrder->product_id,
|
||||
'quantity' => $productionOrder->output_quantity,
|
||||
'unit_cost' => $unitCost, // 傳入計算後的單位成本
|
||||
'quantity' => $actualOutputQuantity,
|
||||
'unit_cost' => $unitCost,
|
||||
'batch_number' => $batchNumber,
|
||||
'box_number' => $productionOrder->output_box_count,
|
||||
'arrival_date' => now()->toDateString(),
|
||||
|
||||
@@ -24,6 +24,8 @@ class ProductionOrder extends Model
|
||||
'product_id',
|
||||
'warehouse_id',
|
||||
'output_quantity',
|
||||
'actual_output_quantity',
|
||||
'loss_reason',
|
||||
'output_batch_number',
|
||||
'output_box_count',
|
||||
'production_date',
|
||||
@@ -82,6 +84,7 @@ class ProductionOrder extends Model
|
||||
'production_date' => 'date',
|
||||
'expiry_date' => 'date',
|
||||
'output_quantity' => 'decimal:2',
|
||||
'actual_output_quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
@@ -91,6 +94,8 @@ class ProductionOrder extends Model
|
||||
'code',
|
||||
'status',
|
||||
'output_quantity',
|
||||
'actual_output_quantity',
|
||||
'loss_reason',
|
||||
'output_batch_number',
|
||||
'production_date',
|
||||
'remark'
|
||||
|
||||
@@ -101,11 +101,13 @@ class SalesImportController extends Controller
|
||||
|
||||
public function confirm(SalesImportBatch $import, InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
if ($import->status !== 'pending') {
|
||||
return back()->with('error', '此批次無法確認。');
|
||||
}
|
||||
return DB::transaction(function () use ($import, $inventoryService) {
|
||||
// 加上 lockForUpdate() 防止併發確認
|
||||
$import = SalesImportBatch::lockForUpdate()->find($import->id);
|
||||
|
||||
DB::transaction(function () use ($import, $inventoryService) {
|
||||
if (!$import || $import->status !== 'pending') {
|
||||
throw new \Exception('此批次無法確認或已被處理。');
|
||||
}
|
||||
// 1. Prepare Aggregation
|
||||
$aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
|
||||
|
||||
@@ -155,7 +157,9 @@ class SalesImportController extends Controller
|
||||
$deduction['quantity'],
|
||||
$reason,
|
||||
true, // Force deduction
|
||||
$deduction['slot'] // Location/Slot
|
||||
$deduction['slot'], // Location/Slot
|
||||
\App\Modules\Sales\Models\SalesImportBatch::class,
|
||||
$import->id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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('utility_fee_attachments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('utility_fee_id')->constrained('utility_fees')->cascadeOnDelete();
|
||||
$table->string('file_path'); // 儲存路徑
|
||||
$table->string('original_name'); // 原始檔名
|
||||
$table->string('mime_type'); // MIME 類型
|
||||
$table->unsignedBigInteger('size'); // 檔案大小 (bytes)
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('utility_fee_attachments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
<?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
|
||||
{
|
||||
// 1. warehouses (倉庫)
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->index('type');
|
||||
});
|
||||
|
||||
// 2. categories (分類)
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->index('is_active');
|
||||
});
|
||||
|
||||
// 3. products (商品/原物料)
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// is_active was added in a later migration, need to make sure column exists before indexing
|
||||
// Same for brand if not added at start (but brand is in the create migration)
|
||||
if (Schema::hasColumn('products', 'is_active')) {
|
||||
$table->index('is_active');
|
||||
}
|
||||
$table->index('brand');
|
||||
});
|
||||
|
||||
// 4. recipes (配方/BOM)
|
||||
Schema::table('recipes', function (Blueprint $table) {
|
||||
$table->index('is_active');
|
||||
});
|
||||
|
||||
// 5. inventory_transactions (庫存異動紀錄)
|
||||
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||
$table->index('type');
|
||||
$table->index('created_at');
|
||||
});
|
||||
|
||||
// 6. purchase_orders (採購單)
|
||||
Schema::table('purchase_orders', function (Blueprint $table) {
|
||||
$table->index('status');
|
||||
$table->index('expected_delivery_date');
|
||||
$table->index('created_at');
|
||||
});
|
||||
|
||||
// 7. production_orders (生產工單)
|
||||
Schema::table('production_orders', function (Blueprint $table) {
|
||||
$table->index('status');
|
||||
$table->index('created_at');
|
||||
});
|
||||
|
||||
// 8. sales_orders (門市/銷售單)
|
||||
Schema::table('sales_orders', function (Blueprint $table) {
|
||||
$table->index('status');
|
||||
$table->index('sold_at');
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->dropIndex(['type']);
|
||||
});
|
||||
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->dropIndex(['is_active']);
|
||||
});
|
||||
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('products', 'is_active')) {
|
||||
$table->dropIndex(['is_active']);
|
||||
}
|
||||
$table->dropIndex(['brand']);
|
||||
});
|
||||
|
||||
Schema::table('recipes', function (Blueprint $table) {
|
||||
$table->dropIndex(['is_active']);
|
||||
});
|
||||
|
||||
Schema::table('inventory_transactions', function (Blueprint $table) {
|
||||
$table->dropIndex(['type']);
|
||||
$table->dropIndex(['created_at']);
|
||||
});
|
||||
|
||||
Schema::table('purchase_orders', function (Blueprint $table) {
|
||||
$table->dropIndex(['status']);
|
||||
$table->dropIndex(['expected_delivery_date']);
|
||||
$table->dropIndex(['created_at']);
|
||||
});
|
||||
|
||||
Schema::table('production_orders', function (Blueprint $table) {
|
||||
$table->dropIndex(['status']);
|
||||
$table->dropIndex(['created_at']);
|
||||
});
|
||||
|
||||
Schema::table('sales_orders', function (Blueprint $table) {
|
||||
$table->dropIndex(['status']);
|
||||
$table->dropIndex(['sold_at']);
|
||||
$table->dropIndex(['created_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 為生產工單新增「實際產出數量」與「耗損原因」欄位。
|
||||
* 實際產出數量用於記錄完工時的真實產量(可能因耗損低於預計產量)。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('production_orders', function (Blueprint $table) {
|
||||
$table->decimal('actual_output_quantity', 10, 2)
|
||||
->nullable()
|
||||
->after('output_quantity')
|
||||
->comment('實際產出數量(預設等於 output_quantity,可於完工時調降)');
|
||||
|
||||
$table->string('loss_reason', 255)
|
||||
->nullable()
|
||||
->after('actual_output_quantity')
|
||||
->comment('耗損原因說明');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('production_orders', function (Blueprint $table) {
|
||||
$table->dropColumn(['actual_output_quantity', 'loss_reason']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('sales_orders', function (Blueprint $table) {
|
||||
$table->decimal('total_qty', 12, 4)->default(0)->after('total_amount');
|
||||
$table->string('name')->after('external_order_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('sales_orders', function (Blueprint $table) {
|
||||
$table->dropColumn(['total_qty', 'name']);
|
||||
});
|
||||
}
|
||||
};
|
||||
29
database/seeders/LocalTenantSeeder.php
Normal file
29
database/seeders/LocalTenantSeeder.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
|
||||
class LocalTenantSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// 建立預設租戶 demo
|
||||
$tenant = Tenant::firstOrCreate(
|
||||
['id' => 'demo'],
|
||||
['tenancy_db_name' => 'tenant_demo']
|
||||
);
|
||||
|
||||
// 建立域名對應 localhost
|
||||
$tenant->domains()->firstOrCreate(
|
||||
['domain' => 'localhost'],
|
||||
['tenant_id' => 'demo']
|
||||
);
|
||||
|
||||
$this->command->info('Local tenant "demo" with domain "localhost" created/restored.');
|
||||
}
|
||||
}
|
||||
64
docs/integration/products_api.md
Normal file
64
docs/integration/products_api.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 商品查詢 API (External Product Fetch)
|
||||
|
||||
本 API 供外部系統(如 POS)從 Star ERP 獲取商品資料。支援分頁、關鍵字搜尋與增量同步。
|
||||
|
||||
## 1. 介面詳情
|
||||
- **Endpoint**: `GET /api/v1/integration/products`
|
||||
- **Authentication**: `Bearer Token` (Sanctum)
|
||||
- **Tenant Isolation**: 需透過 `X-Tenant-Domain` Header 或網域識別租戶。
|
||||
|
||||
## 2. 請求參數 (Query Parameters)
|
||||
|
||||
| 參數名 | 類型 | 必填 | 說明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `keyword` | `string` | 否 | 關鍵字搜尋(符合名稱、代碼、條碼或外部編號)。 |
|
||||
| `category` | `string` | 否 | 分類名稱精準過濾(例如:`飲品`)。 |
|
||||
| `updated_after` | `datetime` | 否 | 增量同步機制。僅回傳該時間點之後有異動的商品(格式:`Y-m-d H:i:s`)。 |
|
||||
| `per_page` | `integer` | 否 | 每頁筆數(預設 50,最大 100)。 |
|
||||
| `page` | `integer` | 否 | 分頁頁碼(預設 1)。 |
|
||||
|
||||
## 3. 回應結構 (JSON)
|
||||
|
||||
### 成功回應 (200 OK)
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"id": 12,
|
||||
"code": "COKE-001",
|
||||
"barcode": "4710001",
|
||||
"name": "可口可樂 600ml",
|
||||
"external_pos_id": "POS-P-001",
|
||||
"category_name": "飲品",
|
||||
"brand": "可口可樂",
|
||||
"specification": "600ml",
|
||||
"unit_name": "瓶",
|
||||
"price": 25.0,
|
||||
"is_active": true,
|
||||
"updated_at": "2026-03-19 09:30:00"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"current_page": 1,
|
||||
"last_page": 5,
|
||||
"per_page": 50,
|
||||
"total": 240
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 同步策略建議
|
||||
|
||||
### 初次同步 (Initial Sync)
|
||||
1. 第一次串接時,不帶 `updated_after`。
|
||||
2. 根據 `meta.last_page` 遍歷所有分頁,將全量商品存入 POS。
|
||||
|
||||
### 增量同步 (Incremental Sync)
|
||||
1. 記錄上一次同步成功的時間戳記。
|
||||
2. 定期調用 API 並帶入 `updated_after={timestamp}`。
|
||||
3. 僅處理回傳的商品資料進行更新或新增。
|
||||
|
||||
## 5. 常見錯誤
|
||||
- `401 Unauthorized`: Token 無效或已過期。
|
||||
- `422 Unprocessable Entity`: 參數驗證失敗(例如 `updated_after` 格式錯誤)。
|
||||
34
e2e/admin.spec.ts
Normal file
34
e2e/admin.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('系統管理模組', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應能進入角色權限管理頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/admin/roles');
|
||||
await expect(page.locator('h1').filter({ hasText: '角色與權限' })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /新增角色/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('應能進入員工帳號管理頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/admin/users');
|
||||
await expect(page.getByRole('heading', { name: /使用者管理/ })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /新增使用者/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('應能進入系統操作紀錄頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/admin/activity-logs');
|
||||
await expect(page.getByRole('heading', { name: /操作紀錄/ })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('應能進入系統參數設定頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/admin/settings');
|
||||
await expect(page.locator('h1').filter({ hasText: '系統設定' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /存檔|儲存/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
28
e2e/finance.spec.ts
Normal file
28
e2e/finance.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('財務管理模組', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應能進入應付帳款管理頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/finance/account-payables');
|
||||
await expect(page.getByRole('heading', { name: /應付帳款管理/ })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('應能進入水電瓦斯費管理頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/utility-fees');
|
||||
await expect(page.getByRole('heading', { name: /公共事業費管理/ })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('應能進入財務報表頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/accounting-report');
|
||||
await expect(page.getByRole('heading', { name: /會計報表/ })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /匯出/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
15
e2e/helpers/auth.ts
Normal file
15
e2e/helpers/auth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 共用登入函式
|
||||
* 使用測試帳號登入 Star ERP 系統
|
||||
*/
|
||||
export async function login(page: Page, username = 'admin', password = 'password') {
|
||||
await page.goto('/');
|
||||
await page.fill('#username', username);
|
||||
await page.fill('#password', password);
|
||||
await page.getByRole('button', { name: '登入系統' }).click();
|
||||
// 等待儀表板載入完成 (改用更穩定的側邊欄文字或 URL)
|
||||
await page.waitForURL('**/');
|
||||
await expect(page.getByRole('link', { name: '儀表板' }).first()).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
14
e2e/integration.spec.ts
Normal file
14
e2e/integration.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('系統串接模組', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應能進入銷貨單據串接頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/integration/sales-orders');
|
||||
await expect(page.locator('h1').filter({ hasText: '銷售訂單管理' })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
});
|
||||
});
|
||||
81
e2e/inventory.spec.ts
Normal file
81
e2e/inventory.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* 庫存模組端到端測試
|
||||
*/
|
||||
test.describe('庫存管理 - 調撥單匯入', () => {
|
||||
// 登入 + 導航 + 匯入全流程需要較長時間
|
||||
test.use({ actionTimeout: 15000 });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應能成功匯入調撥單明細', async ({ page }) => {
|
||||
// 整體測試逾時設定為 60 秒
|
||||
test.setTimeout(60000);
|
||||
// 1. 前往調撥單列表
|
||||
await page.goto('/inventory/transfer-orders');
|
||||
await expect(page.getByText('庫存調撥管理')).toBeVisible();
|
||||
|
||||
// 2. 等待表格載入並尋找特定的 E2E 測試單據
|
||||
await page.waitForSelector('table tbody tr');
|
||||
|
||||
const draftRow = page.locator('tr:has-text("TRF-E2E-FINAL")').first();
|
||||
const hasDraft = await draftRow.count() > 0;
|
||||
|
||||
if (hasDraft) {
|
||||
// 點擊 "編輯" 按鈕
|
||||
await draftRow.locator('button[title="編輯"], a:has-text("編輯")').first().click();
|
||||
} else {
|
||||
throw new Error('測試環境中找不到單號為 TRF-E2E-FINAL 的調撥單。');
|
||||
}
|
||||
|
||||
// 3. 驗證已進入詳情頁 (標題包含調撥單單號)
|
||||
await expect(page.getByRole('heading', { name: /調撥單: TRF-/ })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 4. 開啟匯入對話框
|
||||
const importBtn = page.getByRole('button', { name: /匯入 Excel|匯入/ });
|
||||
await expect(importBtn).toBeVisible();
|
||||
await importBtn.click();
|
||||
|
||||
await expect(page.getByText('匯入調撥明細')).toBeVisible();
|
||||
|
||||
// 5. 準備測試檔案 (CSV 格式)
|
||||
const csvPath = path.join('/tmp', 'transfer_import_test.csv');
|
||||
// 欄位名稱必須與後端匹配,商品代碼使用 P2 (紅糖)
|
||||
const csvContent = "商品代碼,數量,批號,備註\nP2,10,BATCH001,E2E Test Import\n";
|
||||
fs.writeFileSync(csvPath, csvContent);
|
||||
|
||||
// 6. 執行上傳
|
||||
await page.setInputFiles('input[type="file"]', csvPath);
|
||||
|
||||
// 7. 點擊開始匯入
|
||||
await page.getByRole('button', { name: '開始匯入' }).click();
|
||||
|
||||
// 8. 等待頁面更新 (Inertia reload)
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 9. 驗證詳情頁表格是否出現匯入的資料
|
||||
// 注意:「E2E Test Import」是 input 的 value,不是靜態文字,hasText 無法匹配 input value
|
||||
// 因此先找包含 P2 文字的行(P2 是靜態 text),再驗證備註 input 的值
|
||||
const p2Row = page.locator('table tbody tr').filter({ hasText: 'P2' }).first();
|
||||
await expect(p2Row).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 驗證備註欄位的 input value 包含測試標記
|
||||
// 快照中備註欄位的 role 是 textbox,placeholder 是 "備註..."
|
||||
const remarkInput = p2Row.getByRole('textbox', { name: '備註...' });
|
||||
await expect(remarkInput).toHaveValue('E2E Test Import');
|
||||
|
||||
// 截圖留存
|
||||
if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true });
|
||||
await page.screenshot({ path: 'e2e/screenshots/inventory-transfer-import-success.png', fullPage: true });
|
||||
|
||||
// 清理臨時檔案
|
||||
if (fs.existsSync(csvPath)) fs.unlinkSync(csvPath);
|
||||
});
|
||||
|
||||
});
|
||||
72
e2e/login.spec.ts
Normal file
72
e2e/login.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
/**
|
||||
* Star ERP 登入流程端到端測試
|
||||
*/
|
||||
test.describe('登入功能', () => {
|
||||
|
||||
test('頁面應正確顯示登入表單', async ({ page }) => {
|
||||
// 前往登入頁面
|
||||
await page.goto('/');
|
||||
|
||||
// 驗證:帳號輸入框存在
|
||||
await expect(page.locator('#username')).toBeVisible();
|
||||
|
||||
// 驗證:密碼輸入框存在
|
||||
await expect(page.locator('#password')).toBeVisible();
|
||||
|
||||
// 驗證:登入按鈕存在
|
||||
await expect(page.getByRole('button', { name: '登入系統' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('輸入錯誤的帳密應顯示錯誤訊息', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// 輸入錯誤的帳號密碼
|
||||
await page.fill('#username', 'wronguser');
|
||||
await page.fill('#password', 'wrongpassword');
|
||||
|
||||
// 點擊登入
|
||||
await page.getByRole('button', { name: '登入系統' }).click();
|
||||
|
||||
// 等待頁面回應
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 驗證:應該還是停在登入頁面(未成功跳轉)
|
||||
await expect(page).toHaveURL(/\//);
|
||||
|
||||
// 驗證:頁面上應顯示某種錯誤提示(紅色文字或 toast)
|
||||
// 先用寬鬆的檢查方式,後續可以根據實際錯誤訊息調整
|
||||
const hasError = await page.locator('.text-red-500, .text-red-600, [role="alert"], .toast, [data-sonner-toast]').count();
|
||||
expect(hasError).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('輸入正確帳密後應成功登入並跳轉', async ({ page }) => {
|
||||
// 使用共用登入函式
|
||||
await login(page);
|
||||
|
||||
// 驗證:頁面右上角應顯示使用者名稱
|
||||
await expect(page.getByText('mama')).toBeVisible();
|
||||
|
||||
// 驗證:儀表板的關鍵指標卡片存在
|
||||
await expect(page.getByText('庫存總值')).toBeVisible();
|
||||
|
||||
// 截圖留存(成功登入畫面)
|
||||
await page.screenshot({ path: 'e2e/screenshots/login-success.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('空白帳密不應能登入', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// 不填任何東西,直接點登入
|
||||
await page.getByRole('button', { name: '登入系統' }).click();
|
||||
|
||||
// 等待頁面回應
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 驗證:應該還在登入頁面
|
||||
await expect(page.locator('#username')).toBeVisible();
|
||||
});
|
||||
|
||||
});
|
||||
127
e2e/procurement.spec.ts
Normal file
127
e2e/procurement.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* 採購模組端到端測試
|
||||
* 驗證「批量寫入」(多筆明細 bulk insert) 與「併發鎖定」(狀態變更 lockForUpdate)
|
||||
*/
|
||||
test.describe('採購管理 - 採購單建立', () => {
|
||||
// 登入 + 導航 + 表單操作需要較長時間
|
||||
test.use({ actionTimeout: 15000 });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應能成功建立含多筆明細的採購單', async ({ page }) => {
|
||||
// 整體測試逾時設定為 90 秒(含多次選單互動)
|
||||
test.setTimeout(90000);
|
||||
|
||||
// 1. 前往採購單列表
|
||||
await page.goto('/purchase-orders');
|
||||
await expect(page.getByRole('heading', { name: '採購單管理' })).toBeVisible();
|
||||
|
||||
// 2. 點擊「建立採購單」按鈕
|
||||
await page.getByRole('button', { name: /建立採購單/ }).click();
|
||||
await expect(page.getByRole('heading', { name: '建立採購單' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 3. 選擇倉庫 (使用 SearchableSelect combobox)
|
||||
const warehouseCombobox = page.locator('label:has-text("預計入庫倉庫")').locator('..').getByRole('combobox');
|
||||
await warehouseCombobox.click();
|
||||
await page.getByRole('option', { name: '中央倉庫' }).click();
|
||||
|
||||
// 4. 選擇供應商
|
||||
const supplierCombobox = page.locator('label:has-text("供應商")').locator('..').getByRole('combobox');
|
||||
await supplierCombobox.click();
|
||||
await page.getByRole('option', { name: '台積電' }).click();
|
||||
|
||||
// 5. 填寫下單日期(應該已有預設值,但確保有值)
|
||||
const orderDateInput = page.locator('label:has-text("下單日期")').locator('..').locator('input[type="date"]');
|
||||
const currentDate = await orderDateInput.inputValue();
|
||||
if (!currentDate) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
await orderDateInput.fill(today);
|
||||
}
|
||||
|
||||
// 6. 填寫備註
|
||||
await page.getByPlaceholder('備註這筆採購單的特殊需求...').fill('E2E 自動化測試 - 批量寫入驗證');
|
||||
|
||||
// 7. 新增第一個品項
|
||||
await page.getByRole('button', { name: '新增一個品項' }).click();
|
||||
|
||||
// 選擇商品(第一行)
|
||||
const firstRow = page.locator('table tbody tr').first();
|
||||
const firstProductCombobox = firstRow.getByRole('combobox').first();
|
||||
await firstProductCombobox.click();
|
||||
await page.getByRole('option', { name: '紅糖' }).click();
|
||||
|
||||
// 填寫數量
|
||||
const firstQtyInput = firstRow.locator('input[type="number"]').first();
|
||||
await firstQtyInput.clear();
|
||||
await firstQtyInput.fill('5');
|
||||
|
||||
// 填寫小計(主要金額欄位)
|
||||
const firstSubtotalInput = firstRow.locator('input[type="number"]').nth(1);
|
||||
await firstSubtotalInput.fill('500');
|
||||
|
||||
// 8. 新增第二個品項(驗證批量寫入)
|
||||
await page.getByRole('button', { name: '新增一個品項' }).click();
|
||||
|
||||
const secondRow = page.locator('table tbody tr').nth(1);
|
||||
const secondProductCombobox = secondRow.getByRole('combobox').first();
|
||||
await secondProductCombobox.click();
|
||||
await page.getByRole('option', { name: '粗吸管' }).click();
|
||||
|
||||
const secondQtyInput = secondRow.locator('input[type="number"]').first();
|
||||
await secondQtyInput.clear();
|
||||
await secondQtyInput.fill('10');
|
||||
|
||||
const secondSubtotalInput = secondRow.locator('input[type="number"]').nth(1);
|
||||
await secondSubtotalInput.fill('200');
|
||||
|
||||
// 9. 點擊「確認發布採購單」
|
||||
await page.getByRole('button', { name: '確認發布採購單' }).click();
|
||||
|
||||
// 10. 驗證結果 — 應跳轉回列表頁或顯示詳情頁
|
||||
// Inertia.js 的 onSuccess 會觸發頁面導航
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '採購單管理' }).or(page.getByRole('heading', { name: /PO-/ }))
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 11. 截圖留存
|
||||
if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true });
|
||||
await page.screenshot({ path: 'e2e/screenshots/procurement-po-create-success.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('應能成功編輯採購單', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
// 1. 前往採購單列表
|
||||
await page.goto('/purchase-orders');
|
||||
await expect(page.getByRole('heading', { name: '採購單管理' })).toBeVisible();
|
||||
|
||||
// 2. 找到並點擊第一個可編輯的採購單 (草稿或待審核狀態)
|
||||
const editLink = page.locator('button[title="編輯"], a[title="編輯"]').first();
|
||||
await expect(editLink).toBeVisible({ timeout: 10000 });
|
||||
await editLink.click();
|
||||
|
||||
// 3. 驗證已進入編輯頁
|
||||
await expect(page.getByRole('heading', { name: '編輯採購單' })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 4. 修改備註
|
||||
await page.getByPlaceholder('備註這筆採購單的特殊需求...').fill('E2E 自動化測試 - 已被編輯過');
|
||||
|
||||
// 5. 點擊「更新採購單」
|
||||
await page.getByRole('button', { name: '更新採購單' }).click();
|
||||
|
||||
// 6. 驗證結果 — 返回列表或詳情頁
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '採購單管理' }).or(page.getByRole('heading', { name: /PO-/ }))
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 7. 截圖留存
|
||||
if (!fs.existsSync('e2e/screenshots')) fs.mkdirSync('e2e/screenshots', { recursive: true });
|
||||
await page.screenshot({ path: 'e2e/screenshots/procurement-po-edit-success.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
22
e2e/production.spec.ts
Normal file
22
e2e/production.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('生產管理模組', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應能進入配方管理頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/recipes');
|
||||
await expect(page.getByRole('heading', { name: /配方管理/ })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('應能進入生產單管理頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/production-orders');
|
||||
await expect(page.getByRole('heading', { name: /生產工單/ })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /建立生產單/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
15
e2e/products.spec.ts
Normal file
15
e2e/products.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('商品管理模組', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應能進入商品列表頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/products');
|
||||
await expect(page.getByRole('heading', { name: /商品資料管理/ })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
15
e2e/sales.spec.ts
Normal file
15
e2e/sales.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('銷售管理模組', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test.skip('應能進入銷貨匯入頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/sales/imports');
|
||||
await expect(page.getByRole('heading', { name: /功能製作中/ })).toBeVisible();
|
||||
// await expect(page.locator('table')).toBeVisible();
|
||||
// await expect(page.getByRole('button', { name: /匯入/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
15
e2e/vendors.spec.ts
Normal file
15
e2e/vendors.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('供應商管理模組', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應能進入供應商列表頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/vendors');
|
||||
await expect(page.getByRole('heading', { name: /廠商資料管理/ })).toBeVisible();
|
||||
await expect(page.locator('table')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /新增/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
14
e2e/warehouses.spec.ts
Normal file
14
e2e/warehouses.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
test.describe('倉庫管理模組', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('應能進入倉庫列表頁面並顯示主要元素', async ({ page }) => {
|
||||
await page.goto('/warehouses');
|
||||
await expect(page.getByRole('heading', { name: /倉庫管理/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /新增倉庫/ })).toBeVisible();
|
||||
});
|
||||
});
|
||||
13811
package-lock.json
generated
13811
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
116
package.json
116
package.json
@@ -1,59 +1,61 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"name": "star-erp",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inertiajs/react": "^2.3.4",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jsbarcode": "^3.12.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
}
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"name": "star-erp",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inertiajs/react": "^2.3.4",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jsbarcode": "^3.12.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
}
|
||||
}
|
||||
47
playwright.config.ts
Normal file
47
playwright.config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright E2E 測試設定檔
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
|
||||
/* 平行執行測試 */
|
||||
fullyParallel: true,
|
||||
|
||||
/* CI 環境下禁止 test.only */
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
/* CI 環境下失敗重試 2 次 */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
/* CI 環境下單 worker */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
/* 使用 HTML 報告 */
|
||||
reporter: 'html',
|
||||
|
||||
/* 全域共用設定 */
|
||||
use: {
|
||||
/* 本機開發伺服器位址 */
|
||||
baseURL: 'http://localhost:8081',
|
||||
|
||||
/* 失敗時自動截圖 */
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
/* 失敗時保留錄影 */
|
||||
video: 'retain-on-failure',
|
||||
|
||||
/* 失敗重試時收集 trace */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* 只使用 Chromium 進行測試(開發階段足夠) */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 生產工單完工入庫 - 選擇倉庫彈窗
|
||||
* 含產出確認與耗損記錄功能
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
@@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react";
|
||||
import { Warehouse as WarehouseIcon, AlertTriangle, Tag, CalendarIcon, CheckCircle2, X } from "lucide-react";
|
||||
import { formatQuantity } from "@/lib/utils";
|
||||
|
||||
interface Warehouse {
|
||||
id: number;
|
||||
@@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps {
|
||||
warehouseId: number;
|
||||
batchNumber: string;
|
||||
expiryDate: string;
|
||||
actualOutputQuantity: number;
|
||||
lossReason: string;
|
||||
}) => void;
|
||||
warehouses: Warehouse[];
|
||||
processing?: boolean;
|
||||
// 新增商品資訊以利產生批號
|
||||
// 商品資訊用於產生批號
|
||||
productCode?: string;
|
||||
productId?: number;
|
||||
// 預計產量(用於耗損計算)
|
||||
outputQuantity: number;
|
||||
// 成品單位名稱
|
||||
unitName?: string;
|
||||
}
|
||||
|
||||
export default function WarehouseSelectionModal({
|
||||
@@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({
|
||||
processing = false,
|
||||
productCode,
|
||||
productId,
|
||||
outputQuantity,
|
||||
unitName = '',
|
||||
}: WarehouseSelectionModalProps) {
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||
const [batchNumber, setBatchNumber] = React.useState<string>("");
|
||||
const [expiryDate, setExpiryDate] = React.useState<string>("");
|
||||
const [actualOutputQuantity, setActualOutputQuantity] = React.useState<string>("");
|
||||
const [lossReason, setLossReason] = React.useState<string>("");
|
||||
|
||||
// 當開啟時,初始化實際產出數量為預計產量
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setActualOutputQuantity(String(outputQuantity));
|
||||
setLossReason("");
|
||||
}
|
||||
}, [isOpen, outputQuantity]);
|
||||
|
||||
// 當開啟時,嘗試產生成品批號 (若有資訊)
|
||||
React.useEffect(() => {
|
||||
@@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({
|
||||
}
|
||||
}, [isOpen, productCode, productId]);
|
||||
|
||||
// 計算耗損數量
|
||||
const actualQty = parseFloat(actualOutputQuantity) || 0;
|
||||
const lossQuantity = outputQuantity - actualQty;
|
||||
const hasLoss = lossQuantity > 0;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedId && batchNumber) {
|
||||
if (selectedId && batchNumber && actualQty > 0) {
|
||||
onConfirm({
|
||||
warehouseId: selectedId,
|
||||
batchNumber,
|
||||
expiryDate
|
||||
expiryDate,
|
||||
actualOutputQuantity: actualQty,
|
||||
lossReason: hasLoss ? lossReason : '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 驗證:實際產出不可大於預計產量,也不可小於等於 0
|
||||
const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-primary-main">
|
||||
<WarehouseIcon className="h-5 w-5" />
|
||||
選擇完工入庫倉庫
|
||||
完工入庫確認
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-6 space-y-6">
|
||||
{/* 倉庫選擇 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||
<WarehouseIcon className="h-3 w-3" />
|
||||
@@ -96,6 +127,7 @@ export default function WarehouseSelectionModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 成品批號 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
@@ -109,6 +141,7 @@ export default function WarehouseSelectionModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 成品效期 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
@@ -121,6 +154,64 @@ export default function WarehouseSelectionModal({
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分隔線 - 產出確認區 */}
|
||||
<div className="border-t border-grey-4 pt-4">
|
||||
<p className="text-xs font-bold text-grey-2 uppercase tracking-wider mb-4">產出確認</p>
|
||||
|
||||
{/* 預計產量(唯讀) */}
|
||||
<div className="flex items-center justify-between mb-3 px-3 py-2 bg-grey-5 rounded-lg border border-grey-4">
|
||||
<span className="text-sm text-grey-2">預計產量</span>
|
||||
<span className="font-bold text-grey-0">
|
||||
{formatQuantity(outputQuantity)} {unitName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 實際產出數量 */}
|
||||
<div className="space-y-1 mb-3">
|
||||
<Label className="text-xs font-medium text-grey-2">
|
||||
實際產出數量 *
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max={outputQuantity}
|
||||
value={actualOutputQuantity}
|
||||
onChange={(e) => setActualOutputQuantity(e.target.value)}
|
||||
className={`h-9 font-bold ${!isActualQtyValid && actualOutputQuantity !== '' ? 'border-red-400 focus:ring-red-400' : ''}`}
|
||||
/>
|
||||
{unitName && <span className="text-sm text-grey-2 whitespace-nowrap">{unitName}</span>}
|
||||
</div>
|
||||
{actualQty > outputQuantity && (
|
||||
<p className="text-xs text-red-500 mt-1">實際產出不可超過預計產量</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 耗損顯示 */}
|
||||
{hasLoss && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 space-y-2 animate-in fade-in duration-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-bold text-orange-700">
|
||||
耗損數量:{formatQuantity(lossQuantity)} {unitName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-orange-600">
|
||||
耗損原因 (選填)
|
||||
</Label>
|
||||
<Input
|
||||
value={lossReason}
|
||||
onChange={(e) => setLossReason(e.target.value)}
|
||||
placeholder="例如:製作過程損耗、品質不合格..."
|
||||
className="h-9 border-orange-200 focus:ring-orange-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -134,7 +225,7 @@ export default function WarehouseSelectionModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedId || !batchNumber || processing}
|
||||
disabled={!selectedId || !batchNumber || !isActualQtyValid || processing}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
|
||||
304
resources/js/Components/UtilityFee/AttachmentDialog.tsx
Normal file
304
resources/js/Components/UtilityFee/AttachmentDialog.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
FileText,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Eye,
|
||||
Upload
|
||||
} from "lucide-react";
|
||||
import { UtilityFee } from "./UtilityFeeDialog";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
|
||||
interface Attachment {
|
||||
id: number;
|
||||
url: string;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fee: UtilityFee | null;
|
||||
onAttachmentsChange?: () => void;
|
||||
}
|
||||
|
||||
export default function AttachmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fee,
|
||||
onAttachmentsChange,
|
||||
}: Props) {
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && fee) {
|
||||
fetchAttachments();
|
||||
} else {
|
||||
setAttachments([]);
|
||||
}
|
||||
}, [open, fee]);
|
||||
|
||||
const fetchAttachments = async () => {
|
||||
if (!fee) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(route("utility-fees.attachments", fee.id));
|
||||
setAttachments(response.data.attachments || []);
|
||||
} catch (error) {
|
||||
toast.error("取得附件失敗");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0 || !fee) return;
|
||||
|
||||
const file = files[0];
|
||||
|
||||
// 驗證檔案大小 (2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error("檔案大小不能超過 2MB");
|
||||
return;
|
||||
}
|
||||
|
||||
// 驗證檔案類型
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error("僅支援 JPG, PNG, WebP 圖片及 PDF 文件");
|
||||
return;
|
||||
}
|
||||
|
||||
// 驗證數量限制 (3張)
|
||||
if (attachments.length >= 3) {
|
||||
toast.error("最多僅可上傳 3 個附件");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
await axios.post(route("utility-fees.upload-attachment", fee.id), formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
toast.success("上傳成功");
|
||||
fetchAttachments();
|
||||
onAttachmentsChange?.();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.message || Object.values(error.response?.data?.errors || {})[0] || "上傳失敗";
|
||||
toast.error(errorMsg as string);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// 清空 input 讓同一個檔案可以重複觸發 onChange
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (id: number) => {
|
||||
setDeleteId(id);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId || !fee) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await axios.delete(route("utility-fees.delete-attachment", [fee.id, deleteId]));
|
||||
toast.success("附件已刪除");
|
||||
setAttachments(attachments.filter((a) => a.id !== deleteId));
|
||||
onAttachmentsChange?.();
|
||||
} catch (error) {
|
||||
toast.error("刪除失敗");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteId(null);
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<FileText className="h-6 w-6 text-primary-main" />
|
||||
附件管理
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
管理 {fee?.billing_month} {fee?.category_name} 的支援文件(對帳單、收據等)。
|
||||
最多可上傳 3 個檔案,單一檔案上限 2MB。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-6">
|
||||
{/* 上傳區塊 */}
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-dashed border-slate-300">
|
||||
<div className="text-sm text-slate-500">
|
||||
{attachments.length < 3
|
||||
? `還可以上傳 ${3 - attachments.length} 個附件`
|
||||
: "已達到上傳數量上限"}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading || attachments.length >= 3}
|
||||
accept=".jpg,.jpeg,.png,.webp,.pdf"
|
||||
/>
|
||||
<Button
|
||||
asChild
|
||||
disabled={uploading || attachments.length >= 3}
|
||||
className="button-filled-primary"
|
||||
>
|
||||
<label htmlFor="file-upload" className="cursor-pointer flex items-center">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
選擇檔案
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 附件列表 */}
|
||||
<div className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||
<Loader2 className="h-8 w-8 animate-spin mb-2" />
|
||||
<p>載入中...</p>
|
||||
</div>
|
||||
) : attachments.length === 0 ? (
|
||||
<div className="text-center py-8 bg-slate-50 rounded-xl border border-slate-100">
|
||||
<p className="text-slate-400">目前尚無附件</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{attachments.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="group relative flex items-center justify-between p-3 bg-white rounded-xl border border-slate-200 hover:border-primary-light transition-all shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className="h-12 w-12 rounded-lg bg-slate-100 flex items-center justify-center overflow-hidden shrink-0 border border-slate-100">
|
||||
{file.mime_type.startsWith("image/") ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.original_name}
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = "";
|
||||
(e.target as HTMLImageElement).className = "hidden";
|
||||
(e.target as HTMLImageElement).parentElement?.classList.add("bg-slate-200");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FileText className="h-6 w-6 text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-medium text-slate-900 truncate pr-2" title={file.original_name}>
|
||||
{file.original_name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary h-8 w-8 p-0"
|
||||
asChild
|
||||
title="另開分頁"
|
||||
>
|
||||
<a href={file.url} target="_blank" rel="noopener noreferrer">
|
||||
<Eye className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error h-8 w-8 p-0"
|
||||
onClick={() => confirmDelete(file.id)}
|
||||
disabled={isDeleting}
|
||||
title="刪除附件"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除附件?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
這將永久刪除此附件,此操作無法撤銷。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,13 +20,19 @@ import { validateInvoiceNumber } from "@/utils/validation";
|
||||
|
||||
export interface UtilityFee {
|
||||
id: number;
|
||||
transaction_date: string | null;
|
||||
due_date: string;
|
||||
category: string;
|
||||
billing_month: string;
|
||||
category_id: number;
|
||||
category?: string; // 相容於舊版或特定視圖
|
||||
category_name?: string;
|
||||
amount: number | string;
|
||||
payment_status: 'pending' | 'paid' | 'overdue';
|
||||
status: string;
|
||||
payment_status?: 'pending' | 'paid' | 'overdue'; // 相容於舊版
|
||||
due_date: string;
|
||||
payment_date?: string;
|
||||
transaction_date?: string; // 相容於舊版
|
||||
invoice_number?: string;
|
||||
description?: string;
|
||||
attachments_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -266,14 +266,13 @@ export default function WarehouseDialog({
|
||||
{/* 倉庫地址 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">
|
||||
倉庫地址 <span className="text-red-500">*</span>
|
||||
倉庫地址
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
placeholder="例:台北市信義區信義路五段7號"
|
||||
required
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,22 +27,33 @@ export default function Pagination({ links, className }: PaginationProps) {
|
||||
const isNext = label === "Next";
|
||||
const activeIndex = links.findIndex(l => l.active);
|
||||
|
||||
// Tablet/Mobile visibility logic (< md):
|
||||
// Show: Previous, Next, Active, and up to 2 neighbors (Total ~5 numeric pages)
|
||||
// Hide others on small screens (hidden md:flex)
|
||||
// User requested: "small than 800... display 5 pages"
|
||||
const isVisibleOnTablet =
|
||||
// Responsive visibility logic:
|
||||
// Global: Previous, Next, Active are always visible
|
||||
// Mobile (< sm): Active, +-1, First, Last, and Ellipses
|
||||
// Tablet (sm < md): Active, +-2, First, Last, and Ellipses
|
||||
// Desktop (>= md): All standard pages
|
||||
const isFirst = key === 1;
|
||||
const isLast = key === links.length - 2;
|
||||
const isEllipsis = !isPrevious && !isNext && !link.url;
|
||||
|
||||
const isMobileVisible =
|
||||
isPrevious ||
|
||||
isNext ||
|
||||
link.active ||
|
||||
isFirst ||
|
||||
isLast ||
|
||||
isEllipsis ||
|
||||
key === activeIndex - 1 ||
|
||||
key === activeIndex + 1 ||
|
||||
key === activeIndex + 1;
|
||||
|
||||
const isTabletVisible =
|
||||
isMobileVisible ||
|
||||
key === activeIndex - 2 ||
|
||||
key === activeIndex + 2;
|
||||
|
||||
const baseClasses = cn(
|
||||
isVisibleOnTablet ? "flex" : "hidden md:flex",
|
||||
"h-9 items-center justify-center rounded-md border px-3 text-sm"
|
||||
"h-9 items-center justify-center rounded-md border px-3 text-sm",
|
||||
isMobileVisible ? "flex" : (isTabletVisible ? "hidden sm:flex md:flex" : "hidden md:flex")
|
||||
);
|
||||
|
||||
// 如果是 Previous/Next 但沒有 URL,則不渲染(或者渲染為 disabled)
|
||||
|
||||
@@ -38,6 +38,8 @@ interface SearchableSelectProps {
|
||||
showSearch?: boolean;
|
||||
/** 是否可清除選取 */
|
||||
isClearable?: boolean;
|
||||
/** 是否為無效狀態(顯示紅色邊框) */
|
||||
"aria-invalid"?: boolean;
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
@@ -52,6 +54,7 @@ export function SearchableSelect({
|
||||
searchThreshold = 10,
|
||||
showSearch,
|
||||
isClearable = false,
|
||||
"aria-invalid": ariaInvalid,
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
@@ -79,12 +82,15 @@ export function SearchableSelect({
|
||||
!selectedOption && "text-grey-3",
|
||||
// Focus state - primary border with ring
|
||||
"focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]",
|
||||
// Error state
|
||||
ariaInvalid && "border-destructive ring-destructive/20",
|
||||
// Disabled state
|
||||
"disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
// Height
|
||||
"h-9",
|
||||
className
|
||||
)}
|
||||
aria-invalid={ariaInvalid}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Calendar,
|
||||
Filter,
|
||||
TrendingDown,
|
||||
Package,
|
||||
Wallet,
|
||||
Pocket,
|
||||
RotateCcw,
|
||||
FileText
|
||||
@@ -29,6 +29,7 @@ import Pagination from "@/Components/shared/Pagination";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
|
||||
interface Record {
|
||||
id: string;
|
||||
@@ -37,8 +38,14 @@ interface Record {
|
||||
category: string;
|
||||
item: string;
|
||||
reference: string;
|
||||
invoice_number?: string;
|
||||
invoice_date?: string | null;
|
||||
invoice_number?: string | null;
|
||||
amount: number | string;
|
||||
tax_amount: number | string;
|
||||
status?: string;
|
||||
payment_method?: string | null;
|
||||
payment_note?: string | null;
|
||||
remarks?: string | null;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
@@ -52,7 +59,7 @@ interface PageProps {
|
||||
};
|
||||
summary: {
|
||||
total_amount: number;
|
||||
purchase_total: number;
|
||||
payable_total: number;
|
||||
utility_total: number;
|
||||
record_count: number;
|
||||
};
|
||||
@@ -273,10 +280,10 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-orange-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<Package className="h-6 w-6 text-orange-500 shrink-0" />
|
||||
<Wallet className="h-6 w-6 text-orange-500 shrink-0" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-sm text-gray-500 font-medium shrink-0">採購支出</span>
|
||||
<span className="text-xl font-bold text-gray-900 truncate">$ {Number(summary.purchase_total).toLocaleString()}</span>
|
||||
<span className="text-sm text-gray-500 font-medium shrink-0">應付帳款</span>
|
||||
<span className="text-xl font-bold text-gray-900 truncate">$ {Number(summary.payable_total).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -305,13 +312,16 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
||||
<TableHead className="w-[120px] text-center">來源</TableHead>
|
||||
<TableHead className="w-[140px] text-center">類別</TableHead>
|
||||
<TableHead className="px-6">項目詳細</TableHead>
|
||||
<TableHead className="w-[180px] text-right px-6">金額</TableHead>
|
||||
<TableHead className="w-[160px] text-center">付款方式 / 備註</TableHead>
|
||||
<TableHead className="w-[100px] text-center">狀態</TableHead>
|
||||
<TableHead className="w-[120px] text-right">稅額</TableHead>
|
||||
<TableHead className="w-[150px] text-right px-6">總金額</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<TableCell colSpan={7}>
|
||||
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400">
|
||||
<FileText className="h-10 w-10 opacity-20" />
|
||||
<p>此日期區間內無支出紀錄</p>
|
||||
@@ -333,7 +343,7 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary" className={
|
||||
record.source === '採購單'
|
||||
record.source === '應付帳款'
|
||||
? 'bg-orange-50 text-orange-700 border-orange-100'
|
||||
: 'bg-blue-50 text-blue-700 border-blue-100'
|
||||
}>
|
||||
@@ -348,11 +358,47 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{record.item}</span>
|
||||
{record.invoice_number && (
|
||||
<span className="text-xs text-gray-400">發票:{record.invoice_number}</span>
|
||||
{(record.invoice_number || record.invoice_date) && (
|
||||
<span className="text-xs text-gray-400 mt-0.5">
|
||||
發票:{record.invoice_number || '-'}
|
||||
{record.invoice_date && ` (${record.invoice_date})`}
|
||||
</span>
|
||||
)}
|
||||
{record.remarks && (
|
||||
<span className="text-xs text-gray-500 mt-0.5 truncate max-w-[200px]" title={record.remarks}>
|
||||
備註:{record.remarks}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-sm text-gray-700">{record.payment_method || '-'}</span>
|
||||
{record.payment_note && (
|
||||
<span className="text-xs text-gray-400 truncate max-w-[120px]" title={record.payment_note}>
|
||||
{record.payment_note}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{record.status === 'paid' ? (
|
||||
<StatusBadge variant="success">已付款</StatusBadge>
|
||||
) : record.status === 'pending' ? (
|
||||
<StatusBadge variant="warning">待付款</StatusBadge>
|
||||
) : record.status === 'overdue' ? (
|
||||
<StatusBadge variant="destructive">已逾期</StatusBadge>
|
||||
) : record.status === 'draft' ? (
|
||||
<StatusBadge variant="neutral">草稿</StatusBadge>
|
||||
) : record.status === 'approved' ? (
|
||||
<StatusBadge variant="info">已核准</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge variant="neutral">{record.status || '-'}</StatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600">
|
||||
{record.tax_amount ? `$ ${Number(record.tax_amount).toLocaleString()}` : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-gray-900 px-4">
|
||||
$ {Number(record.amount).toLocaleString()}
|
||||
</TableCell>
|
||||
|
||||
@@ -318,10 +318,10 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
||||
from={activities.from}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-4 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<span className="shrink-0">每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
@@ -334,11 +334,11 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
<span className="shrink-0">筆</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">共 {activities.total} 筆資料</span>
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">共 {activities.total} 筆資料</span>
|
||||
</div>
|
||||
<div className="w-full md:w-auto flex justify-center md:justify-end">
|
||||
<div className="w-full sm:w-auto flex justify-center sm:justify-end shrink-0">
|
||||
<Pagination links={activities.links} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import {
|
||||
Search,
|
||||
TrendingUp,
|
||||
Eye,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
@@ -26,9 +28,11 @@ import { Can } from "@/Components/Permission/Can";
|
||||
interface SalesOrder {
|
||||
id: number;
|
||||
external_order_id: string;
|
||||
name: string | null;
|
||||
status: string;
|
||||
payment_method: string;
|
||||
total_amount: string;
|
||||
total_qty: string;
|
||||
sold_at: string;
|
||||
created_at: string;
|
||||
source: string;
|
||||
@@ -54,6 +58,8 @@ interface Props {
|
||||
search?: string;
|
||||
per_page?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
payment_method?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +71,14 @@ const sourceOptions = [
|
||||
{ label: "手動匯入", value: "manual_import" },
|
||||
];
|
||||
|
||||
const paymentMethodOptions = [
|
||||
{ label: "全部付款方式", value: "" },
|
||||
{ label: "現金", value: "cash" },
|
||||
{ label: "信用卡", value: "credit_card" },
|
||||
{ label: "Line Pay", value: "line_pay" },
|
||||
{ label: "悠遊卡", value: "easycard" },
|
||||
];
|
||||
|
||||
const getSourceLabel = (source: string): string => {
|
||||
switch (source) {
|
||||
case 'pos': return 'POS';
|
||||
@@ -105,12 +119,32 @@ 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, preserveScroll: true }
|
||||
);
|
||||
const debouncedFilter = useCallback(
|
||||
debounce((params: any) => {
|
||||
router.get(route("integration.sales-orders.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearch(term);
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: term,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
[key]: value || undefined,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
@@ -153,38 +187,40 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
{/* 篩選列 */}
|
||||
<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, preserveScroll: true }
|
||||
)
|
||||
}
|
||||
options={sourceOptions}
|
||||
className="w-[160px] h-9"
|
||||
showSearch={false}
|
||||
placeholder="篩選來源"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[300px] relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="搜尋外部訂單號 (External Order ID)..."
|
||||
className="h-9"
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="搜尋訂單名稱或外部訂單號 (External Order ID)..."
|
||||
className="pl-10 pr-10 h-9"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary h-9"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => handleSearchChange("")}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<SearchableSelect
|
||||
value={filters.source || ""}
|
||||
onValueChange={(val) => handleFilterChange("source", val)}
|
||||
options={sourceOptions}
|
||||
className="w-[140px] h-9"
|
||||
showSearch={false}
|
||||
placeholder="篩選來源"
|
||||
/>
|
||||
<SearchableSelect
|
||||
value={filters.payment_method || ""}
|
||||
onValueChange={(val) => handleFilterChange("payment_method", val)}
|
||||
options={paymentMethodOptions}
|
||||
className="w-[160px] h-9"
|
||||
showSearch={false}
|
||||
placeholder="篩選付款方式"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,9 +231,11 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>外部訂單號</TableHead>
|
||||
<TableHead>名稱</TableHead>
|
||||
<TableHead className="text-center">來源</TableHead>
|
||||
<TableHead className="text-center">狀態</TableHead>
|
||||
<TableHead>付款方式</TableHead>
|
||||
<TableHead className="text-right">總數量</TableHead>
|
||||
<TableHead className="text-right">總金額</TableHead>
|
||||
<TableHead>銷售時間</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
@@ -206,7 +244,7 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
<TableBody>
|
||||
{orders.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-gray-500">
|
||||
<TableCell colSpan={10} className="text-center py-8 text-gray-500">
|
||||
無符合條件的資料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -219,6 +257,9 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
<TableCell className="font-mono text-sm">
|
||||
{order.external_order_id}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate" title={order.name || ""}>
|
||||
{order.name || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<StatusBadge variant={getSourceVariant(order.source)}>
|
||||
{order.source_label || getSourceLabel(order.source)}
|
||||
@@ -233,6 +274,9 @@ export default function SalesOrderIndex({ orders, filters }: Props) {
|
||||
{order.payment_method || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatNumber(parseFloat(order.total_qty))}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-primary-main">
|
||||
${formatNumber(parseFloat(order.total_amount))}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
|
||||
@@ -28,7 +28,9 @@ interface SalesOrder {
|
||||
status: string;
|
||||
payment_method: string;
|
||||
total_amount: string;
|
||||
total_qty: string;
|
||||
sold_at: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
raw_payload: any;
|
||||
items: SalesOrderItem[];
|
||||
@@ -103,6 +105,7 @@ export default function SalesOrderShow({ order }: Props) {
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 font-medium flex flex-wrap items-center gap-2">
|
||||
銷售時間: {formatDate(order.sold_at)} <span className="mx-1">|</span>
|
||||
名稱: {order.name || "—"} <span className="mx-1">|</span>
|
||||
付款方式: {order.payment_method || "—"} <span className="mx-1">|</span>
|
||||
訂單來源: {getSourceDisplay(order.source, order.source_label)} <span className="mx-1">|</span>
|
||||
同步時間: {formatDate(order.created_at as any)}
|
||||
@@ -146,6 +149,13 @@ export default function SalesOrderShow({ order }: Props) {
|
||||
</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-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">訂單總數量</span>
|
||||
<span className="text-lg font-bold text-gray-700">
|
||||
{formatNumber(parseFloat(order.total_qty))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-primary-light/10 my-1"></div>
|
||||
<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">
|
||||
|
||||
@@ -96,11 +96,11 @@ export default function Create({ products, warehouses }: Props) {
|
||||
const [recipes, setRecipes] = useState<any[]>([]);
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
|
||||
|
||||
const { data, setData, processing, errors } = useForm({
|
||||
// 提交表單
|
||||
const { data, setData, processing, errors, setError, clearErrors } = useForm({
|
||||
product_id: "",
|
||||
warehouse_id: "",
|
||||
output_quantity: "",
|
||||
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
|
||||
remark: "",
|
||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||
});
|
||||
@@ -108,7 +108,6 @@ export default function Create({ products, warehouses }: Props) {
|
||||
// 獲取特定商品在各倉庫的庫存分佈
|
||||
const fetchProductInventories = async (productId: string) => {
|
||||
if (!productId) return;
|
||||
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
|
||||
if (loadingProducts[productId]) return;
|
||||
|
||||
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
||||
@@ -159,7 +158,6 @@ export default function Create({ products, warehouses }: Props) {
|
||||
item.unit_id = "";
|
||||
item.ui_input_quantity = "";
|
||||
item.ui_selected_unit = "base";
|
||||
// 清除 cache 資訊
|
||||
delete item.ui_product_name;
|
||||
delete item.ui_batch_number;
|
||||
delete item.ui_available_qty;
|
||||
@@ -180,21 +178,13 @@ export default function Create({ products, warehouses }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
|
||||
if (field === 'ui_warehouse_id') {
|
||||
item.inventory_id = "";
|
||||
// 不重置數量
|
||||
// item.quantity_used = "";
|
||||
// item.ui_input_quantity = "";
|
||||
// item.ui_selected_unit = "base";
|
||||
|
||||
// 清除某些 cache
|
||||
delete item.ui_batch_number;
|
||||
delete item.ui_available_qty;
|
||||
delete item.ui_expiry_date;
|
||||
}
|
||||
|
||||
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
|
||||
if (field === 'inventory_id' && value) {
|
||||
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||
const inv = currentOptions.find(i => String(i.id) === value);
|
||||
@@ -203,45 +193,31 @@ export default function Create({ products, warehouses }: Props) {
|
||||
item.ui_batch_number = inv.batch_number;
|
||||
item.ui_available_qty = inv.quantity;
|
||||
item.ui_expiry_date = inv.expiry_date || '';
|
||||
|
||||
// 單位與轉換率
|
||||
item.ui_base_unit_name = inv.unit_name || '';
|
||||
item.ui_base_unit_id = inv.base_unit_id;
|
||||
item.ui_large_unit_id = inv.large_unit_id;
|
||||
item.ui_purchase_unit_id = inv.purchase_unit_id;
|
||||
item.ui_conversion_rate = inv.conversion_rate || 1;
|
||||
item.ui_unit_cost = inv.unit_cost || 0;
|
||||
|
||||
// 預設單位
|
||||
item.ui_selected_unit = 'base';
|
||||
item.unit_id = String(inv.base_unit_id || '');
|
||||
|
||||
// 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
|
||||
if (!item.ui_input_quantity) {
|
||||
item.ui_input_quantity = formatQuantity(inv.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 計算最終數量 (Base Quantity)
|
||||
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
|
||||
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
||||
const rate = item.ui_conversion_rate || 1;
|
||||
|
||||
if (item.ui_selected_unit === 'large') {
|
||||
item.quantity_used = String(inputQty * rate);
|
||||
item.unit_id = String(item.ui_base_unit_id || '');
|
||||
} else {
|
||||
item.quantity_used = String(inputQty);
|
||||
item.unit_id = String(item.ui_base_unit_id || '');
|
||||
}
|
||||
item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : String(inputQty);
|
||||
item.unit_id = String(item.ui_base_unit_id || '');
|
||||
}
|
||||
|
||||
updated[index] = item;
|
||||
setBomItems(updated);
|
||||
};
|
||||
|
||||
// 同步 BOM items 到表單 data
|
||||
useEffect(() => {
|
||||
setData('items', bomItems.map(item => ({
|
||||
inventory_id: Number(item.inventory_id),
|
||||
@@ -250,33 +226,22 @@ export default function Create({ products, warehouses }: Props) {
|
||||
})));
|
||||
}, [bomItems]);
|
||||
|
||||
// 應用配方到表單 (獨立函式)
|
||||
const applyRecipe = (recipe: any) => {
|
||||
if (!recipe || !recipe.items) return;
|
||||
|
||||
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
||||
// 自動帶入配方標準產量
|
||||
setData('output_quantity', formatQuantity(yieldQty));
|
||||
|
||||
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
||||
const baseQty = parseFloat(item.quantity || "0");
|
||||
const calculatedQty = baseQty; // 保持精度
|
||||
|
||||
// 若有配方商品,預先載入庫存分佈
|
||||
if (item.product_id) {
|
||||
fetchProductInventories(String(item.product_id));
|
||||
}
|
||||
|
||||
if (item.product_id) fetchProductInventories(String(item.product_id));
|
||||
return {
|
||||
inventory_id: "",
|
||||
quantity_used: String(calculatedQty),
|
||||
quantity_used: String(item.quantity || "0"),
|
||||
unit_id: String(item.unit_id),
|
||||
ui_warehouse_id: "",
|
||||
ui_product_id: String(item.product_id),
|
||||
ui_product_name: item.product_name,
|
||||
ui_batch_number: "",
|
||||
ui_available_qty: 0,
|
||||
ui_input_quantity: formatQuantity(calculatedQty),
|
||||
ui_input_quantity: formatQuantity(item.quantity || "0"),
|
||||
ui_selected_unit: 'base',
|
||||
ui_base_unit_name: item.unit_name,
|
||||
ui_base_unit_id: item.unit_id,
|
||||
@@ -284,45 +249,30 @@ export default function Create({ products, warehouses }: Props) {
|
||||
};
|
||||
});
|
||||
setBomItems(newBomItems);
|
||||
|
||||
toast.success(`已自動載入配方: ${recipe.name}`, {
|
||||
description: `標準產量: ${formatQuantity(yieldQty)} 份`
|
||||
});
|
||||
toast.success(`已自動載入配方: ${recipe.name}`);
|
||||
};
|
||||
|
||||
// 當手動切換配方時
|
||||
useEffect(() => {
|
||||
if (!selectedRecipeId) return;
|
||||
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
|
||||
if (targetRecipe) {
|
||||
applyRecipe(targetRecipe);
|
||||
}
|
||||
if (targetRecipe) applyRecipe(targetRecipe);
|
||||
}, [selectedRecipeId]);
|
||||
|
||||
// 自動產生成品批號與載入配方
|
||||
useEffect(() => {
|
||||
if (!data.product_id) return;
|
||||
|
||||
// 2. 自動載入配方列表
|
||||
const fetchRecipes = async () => {
|
||||
try {
|
||||
// 改為抓取所有配方
|
||||
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
|
||||
const recipesData = await res.json();
|
||||
|
||||
if (Array.isArray(recipesData) && recipesData.length > 0) {
|
||||
setRecipes(recipesData);
|
||||
// 預設選取最新的 (第一個)
|
||||
const latest = recipesData[0];
|
||||
setSelectedRecipeId(String(latest.id));
|
||||
setSelectedRecipeId(String(recipesData[0].id));
|
||||
} else {
|
||||
// 若無配方
|
||||
setRecipes([]);
|
||||
setSelectedRecipeId("");
|
||||
setBomItems([]); // 清空 BOM
|
||||
setBomItems([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch recipes", e);
|
||||
setRecipes([]);
|
||||
setBomItems([]);
|
||||
}
|
||||
@@ -330,61 +280,74 @@ export default function Create({ products, warehouses }: Props) {
|
||||
fetchRecipes();
|
||||
}, [data.product_id]);
|
||||
|
||||
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量
|
||||
// 當有驗證錯誤時,自動聚焦到第一個錯誤欄位
|
||||
useEffect(() => {
|
||||
if (bomItems.length > 0 && data.output_quantity) {
|
||||
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
|
||||
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
|
||||
// 但如果是剛載入(inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
|
||||
const errorKeys = Object.keys(errors);
|
||||
if (errorKeys.length > 0) {
|
||||
// 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染)
|
||||
setTimeout(() => {
|
||||
const firstInvalid = document.querySelector('[aria-invalid="true"]');
|
||||
if (firstInvalid instanceof HTMLElement) {
|
||||
firstInvalid.focus();
|
||||
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [data.output_quantity]);
|
||||
}, [errors]);
|
||||
|
||||
// 提交表單
|
||||
const submit = (status: 'draft' | 'completed') => {
|
||||
// 驗證(簡單前端驗證,完整驗證在後端)
|
||||
if (status === 'completed') {
|
||||
const missingFields = [];
|
||||
if (!data.product_id) missingFields.push('成品商品');
|
||||
if (!data.output_quantity) missingFields.push('生產數量');
|
||||
if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
|
||||
if (bomItems.length === 0) missingFields.push('原物料明細');
|
||||
const submit = (status: 'draft') => {
|
||||
clearErrors();
|
||||
let hasError = false;
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
toast.error("請填寫必要欄位", {
|
||||
description: `缺漏:${missingFields.join('、')}`
|
||||
});
|
||||
return;
|
||||
// 草稿建立時也要求必填生產數量與預計入庫倉庫
|
||||
if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; }
|
||||
if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; }
|
||||
if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; }
|
||||
if (bomItems.length === 0) { toast.error("請至少新增一項原物料明細"); hasError = true; }
|
||||
|
||||
// 驗證 BOM 明細
|
||||
bomItems.forEach((item, index) => {
|
||||
if (!item.ui_product_id) {
|
||||
setError(`items.${index}.ui_product_id` as any, '請選擇商品');
|
||||
hasError = true;
|
||||
} else {
|
||||
if (!item.inventory_id) {
|
||||
setError(`items.${index}.inventory_id` as any, '請選擇批號');
|
||||
hasError = true;
|
||||
}
|
||||
if (!item.quantity_used || parseFloat(item.quantity_used) <= 0) {
|
||||
setError(`items.${index}.quantity_used` as any, '請輸入數量');
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasError) {
|
||||
toast.error("建立失敗,請檢查標單內紅框欄位");
|
||||
return;
|
||||
}
|
||||
|
||||
// 轉換 BOM items 格式
|
||||
const formattedItems = bomItems
|
||||
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used))
|
||||
.map(item => ({
|
||||
inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null,
|
||||
quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0,
|
||||
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
|
||||
}));
|
||||
const formattedItems = bomItems.map(item => ({
|
||||
inventory_id: parseInt(item.inventory_id),
|
||||
quantity_used: parseFloat(item.quantity_used),
|
||||
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
|
||||
}));
|
||||
|
||||
// 使用 router.post 提交完整資料
|
||||
router.post(route('production-orders.store'), {
|
||||
...data,
|
||||
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
|
||||
items: formattedItems,
|
||||
status: status,
|
||||
}, {
|
||||
onError: (errors) => {
|
||||
const errorCount = Object.keys(errors).length;
|
||||
toast.error("建立失敗,請檢查表單", {
|
||||
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
|
||||
});
|
||||
onError: () => {
|
||||
toast.error("建立失敗,請檢查表單");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
submit('completed');
|
||||
submit('draft');
|
||||
};
|
||||
|
||||
const getBomItemUnitCost = (item: BomItem) => {
|
||||
@@ -423,7 +386,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Factory className="h-6 w-6 text-primary-main" />
|
||||
建立生產工單
|
||||
</h1>
|
||||
@@ -431,14 +394,18 @@ export default function Create({ products, warehouses }: Props) {
|
||||
建立新的生產排程,選擇原物料並記錄產出
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => submit('draft')}
|
||||
disabled={processing}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
儲存工單 (草稿)
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => submit('draft')}
|
||||
disabled={processing}
|
||||
className="button-filled-primary gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
儲存工單 (草稿)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
}))}
|
||||
placeholder="選擇成品"
|
||||
className="w-full h-9"
|
||||
aria-invalid={!!errors.product_id}
|
||||
/>
|
||||
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
||||
|
||||
@@ -493,6 +461,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||
placeholder="例如: 50"
|
||||
className="h-9 font-mono"
|
||||
aria-invalid={!!errors.output_quantity}
|
||||
/>
|
||||
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
|
||||
</div>
|
||||
@@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
}))}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full h-9"
|
||||
aria-invalid={!!errors.warehouse_id}
|
||||
/>
|
||||
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
|
||||
</div>
|
||||
@@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
options={productOptions}
|
||||
placeholder="選擇商品"
|
||||
className="w-full"
|
||||
aria-invalid={!!errors[`items.${index}.ui_product_id` as any]}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
||||
className="w-full"
|
||||
disabled={!item.ui_warehouse_id}
|
||||
aria-invalid={!!errors[`items.${index}.inventory_id` as any]}
|
||||
/>
|
||||
{item.inventory_id && (() => {
|
||||
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
||||
@@ -655,6 +627,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
placeholder="0"
|
||||
className="h-9 text-right"
|
||||
disabled={!item.inventory_id}
|
||||
aria-invalid={!!errors[`items.${index}.quantity_used` as any]}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* 生產工單管理主頁面
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react';
|
||||
import { debounce } from "lodash";
|
||||
import { formatQuantity } from "@/lib/utils";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
@@ -77,16 +78,25 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
setPerPage(filters.per_page || productionOrders.per_page?.toString() || "10");
|
||||
}, [filters]);
|
||||
|
||||
const handleFilter = () => {
|
||||
router.get(
|
||||
route('production-orders.index'),
|
||||
{
|
||||
search,
|
||||
status: status === 'all' ? undefined : status,
|
||||
per_page: perPage,
|
||||
},
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
const debouncedFilter = useCallback(
|
||||
debounce((params: any) => {
|
||||
router.get(route("production-orders.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearch(term);
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: term,
|
||||
status: status === "all" ? undefined : status,
|
||||
per_page: perPage,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -129,16 +139,12 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
<Input
|
||||
placeholder="搜尋生產單號、批號、商品名稱..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-10 pr-10 h-9"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
router.get(route('production-orders.index'), { ...filters, search: "" }, { preserveState: true, replace: true, preserveScroll: true });
|
||||
}}
|
||||
onClick={() => handleSearchChange("")}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -172,15 +178,6 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
onClick={handleFilter}
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
搜尋
|
||||
</Button>
|
||||
|
||||
<Can permission="production_orders.create">
|
||||
<Button
|
||||
onClick={handleNavigateToCreate}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* 配方管理主頁面
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Plus, Search, Pencil, Trash2, BookOpen, Eye } from 'lucide-react';
|
||||
import { debounce } from "lodash";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, Link } from "@inertiajs/react";
|
||||
@@ -73,17 +74,25 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
setPerPage(filters.per_page || "10");
|
||||
}, [filters]);
|
||||
|
||||
const handleFilter = () => {
|
||||
router.get(
|
||||
route('recipes.index'),
|
||||
{
|
||||
search,
|
||||
per_page: perPage,
|
||||
},
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
const debouncedFilter = useCallback(
|
||||
debounce((params: any) => {
|
||||
router.get(route("recipes.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearch(term);
|
||||
debouncedFilter({
|
||||
...filters,
|
||||
search: term,
|
||||
per_page: perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
@@ -140,16 +149,12 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
<Input
|
||||
placeholder="搜尋配方代號、名稱、產品名稱..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-10 pr-10 h-9"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
router.get(route('recipes.index'), { ...filters, search: "" }, { preserveState: true, replace: true, preserveScroll: true });
|
||||
}}
|
||||
onClick={() => handleSearchChange("")}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> {/* Using Trash2/X as clear icon, need to check imports. Inventory used X. */}
|
||||
@@ -159,15 +164,6 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
onClick={handleFilter}
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
搜尋
|
||||
</Button>
|
||||
|
||||
<Can permission="recipes.create">
|
||||
<Link href={route('recipes.create')}>
|
||||
<Button className="button-filled-primary">
|
||||
|
||||
@@ -56,6 +56,8 @@ interface ProductionOrder {
|
||||
output_batch_number: string;
|
||||
output_box_count: string | null;
|
||||
output_quantity: number;
|
||||
actual_output_quantity: number | null;
|
||||
loss_reason: string | null;
|
||||
production_date: string;
|
||||
expiry_date: string | null;
|
||||
status: ProductionOrderStatus;
|
||||
@@ -88,12 +90,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
||||
warehouseId?: number;
|
||||
batchNumber?: string;
|
||||
expiryDate?: string;
|
||||
actualOutputQuantity?: number;
|
||||
lossReason?: string;
|
||||
}) => {
|
||||
router.patch(route('production-orders.update-status', productionOrder.id), {
|
||||
status: newStatus,
|
||||
warehouse_id: extraData?.warehouseId,
|
||||
output_batch_number: extraData?.batchNumber,
|
||||
expiry_date: extraData?.expiryDate,
|
||||
actual_output_quantity: extraData?.actualOutputQuantity,
|
||||
loss_reason: extraData?.lossReason,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsWarehouseModalOpen(false);
|
||||
@@ -129,6 +135,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
||||
processing={processing}
|
||||
productCode={productionOrder.product?.code}
|
||||
productId={productionOrder.product?.id}
|
||||
outputQuantity={Number(productionOrder.output_quantity)}
|
||||
unitName={productionOrder.product?.base_unit?.name}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
||||
@@ -276,7 +284,7 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">預計/實際產量</p>
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">預計產量</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<p className="font-bold text-grey-0 text-xl">
|
||||
{formatQuantity(productionOrder.output_quantity)}
|
||||
@@ -289,6 +297,28 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 實際產量與耗損(僅完成狀態顯示) */}
|
||||
{productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED && productionOrder.actual_output_quantity != null && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">實際產量</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<p className="font-bold text-grey-0 text-xl">
|
||||
{formatQuantity(productionOrder.actual_output_quantity)}
|
||||
</p>
|
||||
{productionOrder.product?.base_unit?.name && (
|
||||
<span className="text-grey-2 font-medium">{productionOrder.product.base_unit.name}</span>
|
||||
)}
|
||||
{Number(productionOrder.output_quantity) > Number(productionOrder.actual_output_quantity) && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-bold border border-orange-200">
|
||||
耗損 {formatQuantity(Number(productionOrder.output_quantity) - Number(productionOrder.actual_output_quantity))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{productionOrder.loss_reason && (
|
||||
<p className="text-xs text-orange-600 mt-1">原因:{productionOrder.loss_reason}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">入庫倉庫</p>
|
||||
<div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import UtilityFeeDialog, { UtilityFee } from "@/Components/UtilityFee/UtilityFeeDialog";
|
||||
import AttachmentDialog from "@/Components/UtilityFee/AttachmentDialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -77,8 +78,19 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isAttachmentDialogOpen, setIsAttachmentDialogOpen] = useState(false);
|
||||
const [editingFee, setEditingFee] = useState<UtilityFee | null>(null);
|
||||
const [deletingFeeId, setDeletingFeeId] = useState<number | null>(null);
|
||||
const [attachmentFee, setAttachmentFee] = useState<UtilityFee | null>(null);
|
||||
|
||||
const openAttachmentDialog = (fee: UtilityFee) => {
|
||||
setAttachmentFee(fee);
|
||||
setIsAttachmentDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAttachmentsChange = () => {
|
||||
router.reload({ only: ['fees'] });
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -447,6 +459,22 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Can permission="utility_fees.edit">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary relative"
|
||||
onClick={() => openAttachmentDialog(fee)}
|
||||
title="附件管理"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{(fee.attachments_count || 0) > 0 && (
|
||||
<span className="absolute -top-2 -right-2 flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-[10px] text-white font-bold ring-2 ring-white animate-in zoom-in">
|
||||
{fee.attachments_count}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Can>
|
||||
<Can permission="utility_fees.edit">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -510,6 +538,13 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
availableCategories={availableCategories}
|
||||
/>
|
||||
|
||||
<AttachmentDialog
|
||||
open={isAttachmentDialogOpen}
|
||||
onOpenChange={setIsAttachmentDialogOpen}
|
||||
fee={attachmentFee}
|
||||
onAttachmentsChange={handleAttachmentsChange}
|
||||
/>
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -64,10 +64,6 @@ export const validateWarehouse = (formData: {
|
||||
return { isValid: false, error: "倉庫名稱為必填欄位" };
|
||||
}
|
||||
|
||||
if (!formData.address.trim()) {
|
||||
return { isValid: false, error: "倉庫地址為必填欄位" };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,61 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
|
||||
---
|
||||
|
||||
## 1. 產品資料同步 (Upsert Product)
|
||||
## 1. 商品資料讀取 (Product Retrieval)
|
||||
|
||||
此 API 用於讓外部系統(如 POS)依據條件,從 ERP 中批量抓取商品資料。支援分頁與增量同步。
|
||||
|
||||
- **Endpoint**: `/products`
|
||||
- **Method**: `GET`
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| 參數名稱 | 類型 | 必填 | 說明 |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| `product_id` | Integer| 否 | 依 ERP 商品 ID (`products.id`) 精準篩選 |
|
||||
| `external_pos_id` | String | 否 | 依外部 POS 端的唯一識別碼 (`external_pos_id`) 精準篩選 |
|
||||
| `barcode` | String | 否 | 依商品條碼 (Barcode) 精準篩選 |
|
||||
| `code` | String | 否 | 依商品代碼 (Code) 精準篩選 |
|
||||
| `category` | String | 否 | 分類名稱精準過濾 |
|
||||
| `updated_after` | String | 否 | 增量同步機制。僅回傳該時間點之後有異動的商品 (格式: `YYYY-MM-DD HH:mm:ss`) |
|
||||
| `per_page` | Integer| 否 | 每頁筆數 (預設 50, 最大 100) |
|
||||
| `page` | Integer| 否 | 分頁頁碼 (預設 1) |
|
||||
|
||||
### Response
|
||||
|
||||
**Success (HTTP 200)**
|
||||
僅開放公開價格 `price`,隱藏敏感成本與會員價。
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"id": 12,
|
||||
"code": "PROD-001",
|
||||
"barcode": "4710001",
|
||||
"name": "可口可樂 600ml",
|
||||
"external_pos_id": "POS-P-001",
|
||||
"category_name": "飲品",
|
||||
"brand": "可口可樂",
|
||||
"specification": "600ml",
|
||||
"unit_name": "瓶",
|
||||
"price": 25.0,
|
||||
"is_active": true,
|
||||
"updated_at": "2026-03-19 09:30:00"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"current_page": 1,
|
||||
"last_page": 5,
|
||||
"per_page": 50,
|
||||
"total": 240
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 商品資料同步 (Upsert Product)
|
||||
|
||||
此 API 用於將第三方系統(如 POS)的產品資料單向同步至 ERP。採用 Upsert 邏輯:若 `external_pos_id` 存在則更新資料,不存在則新增產品。
|
||||
|
||||
@@ -23,14 +77,15 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
|
||||
### Request Body (JSON)
|
||||
|
||||
| 欄位名稱 | 類型 | 必填 | 說明 |
|
||||
| 參數名稱 | 類型 | 必填 | 說明 |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| `external_pos_id` | String | **是** | 在 POS 系統中的唯一商品 ID (Primary Key) |
|
||||
| `name` | String | **是** | 商品名稱 (最大 255 字元) |
|
||||
| `category` | String | **是** | 商品分類名稱。若系統中不存在則自動建立 (最大 100 字元) |
|
||||
| `unit` | String | **是** | 商品單位 (例如:個、杯、件)。若不存在則自動建立 (最大 100 字元) |
|
||||
| `code` | String | 否 | 商品代碼。若未提供將由 ERP 自動產生 (最大 100 字元) |
|
||||
| `price` | Decimal | 否 | 商品售價 (預設 0) |
|
||||
| `barcode` | String | 否 | 商品條碼 (最大 100 字元) |
|
||||
| `category` | String | 否 | 商品分類名稱。若系統中不存在,會自動建立 (最大 100 字元) |
|
||||
| `unit` | String | 否 | 商品單位 (例如:個、杯、件)。若不存在會自動建立 (最大 100 字元) |
|
||||
| `barcode` | String | 否 | 商品條碼 (最大 100 字元)。若未提供將由 ERP 自動產生 |
|
||||
| `brand` | String | 否 | 商品品牌名稱 (最大 100 字元) |
|
||||
| `specification` | String | 否 | 商品規格描述 (最大 255 字元) |
|
||||
| `cost_price` | Decimal | 否 | 成本價 (預設 0) |
|
||||
@@ -44,10 +99,10 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
{
|
||||
"external_pos_id": "POS-PROD-9001",
|
||||
"name": "特級冷壓初榨橄欖油 500ml",
|
||||
"price": 380.00,
|
||||
"barcode": "4711234567890",
|
||||
"category": "調味料",
|
||||
"unit": "瓶",
|
||||
"price": 380.00,
|
||||
"barcode": "4711234567890",
|
||||
"brand": "健康王",
|
||||
"specification": "500ml / 玻璃瓶裝",
|
||||
"cost_price": 250.00,
|
||||
@@ -58,22 +113,34 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
}
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **自動編碼與分類機制**:
|
||||
> - **必填項**:`category` 與 `unit` 為必填。若 ERP 中無對應名稱,將會依據傳入值自動建立。
|
||||
> - **自動編碼**:若未提供 `code` (商品代碼),將由 ERP 自動產生 8 位隨機代碼。
|
||||
> - **自動條碼**:若未提供 `barcode` (條碼),將由 ERP 自動產生 13 位隨機數字條碼。
|
||||
> - 建議在同步後儲存回傳的 `id`、`code` 與 `barcode`,以利後續精確對接。
|
||||
|
||||
### Response
|
||||
|
||||
**Success (HTTP 200)**
|
||||
回傳 ERP 端的完整商品主檔資訊,供外部系統回存 ID 或代碼。
|
||||
|
||||
### 回傳範例 (Success)
|
||||
- **Status Code**: `200 OK`
|
||||
```json
|
||||
{
|
||||
"message": "Product synced successfully",
|
||||
"data": {
|
||||
"id": 15,
|
||||
"external_pos_id": "POS-ITEM-001"
|
||||
"id": 1,
|
||||
"external_pos_id": "POS-P-999",
|
||||
"code": "A1B2C3D4",
|
||||
"barcode": "4710009990001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 門市庫存查詢 (Query Inventory)
|
||||
## 3. 門市庫存查詢 (Query Inventory)
|
||||
|
||||
此 API 用於讓外部系統(如 POS)依據特定的「倉庫代碼」,查詢該倉庫目前所有商品的庫存餘額。
|
||||
**注意**:此 API 會回傳該倉庫內的所有商品數量,不論該商品是否已綁定外部 POS ID。
|
||||
@@ -85,27 +152,55 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
|
||||
| 參數名稱 | 類型 | 必填 | 說明 |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`) |
|
||||
| `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`,測試可使用預設建立之 `api-test-01`) |
|
||||
|
||||
### Query Parameters (選填)
|
||||
|
||||
| 參數名稱 | 類型 | 說明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `product_id` | String | 依 ERP 商品 ID (`products.id`) 篩選。 |
|
||||
| `external_pos_id` | String | 依外部 POS 端的唯一識別碼 (`external_pos_id`) 篩選。 |
|
||||
| `barcode` | String | 依商品條碼 (Barcode) 篩選商品。 |
|
||||
| `code` | String | 依商品代碼 (Code) 篩選商品。 |
|
||||
|
||||
若不帶任何參數,將回傳該倉庫下所有商品的庫存餘額。
|
||||
|
||||
### Response
|
||||
|
||||
**Success (HTTP 200)**
|
||||
回傳該倉庫內所有的商品目前庫存總數。若商品未建置 `external_pos_id`,該欄位將顯示為 `null`。
|
||||
回傳該倉庫內所有的商品目前庫存總數及詳細資訊。若商品未建置 `external_pos_id`,該欄位將顯示為 `null`。
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"warehouse_code": "STORE-001",
|
||||
"warehouse_code": "api-test-01",
|
||||
"data": [
|
||||
{
|
||||
"external_pos_id": "POS-ITEM-001",
|
||||
"product_id": 1,
|
||||
"external_pos_id": "PROD-001",
|
||||
"product_code": "PROD-A001",
|
||||
"product_name": "特級冷壓初榨橄欖油 500ml",
|
||||
"barcode": "4710123456789",
|
||||
"category_name": "調味料",
|
||||
"unit_name": "瓶",
|
||||
"price": 450.00,
|
||||
"brand": "奧利塔",
|
||||
"specification": "500ml/瓶",
|
||||
"batch_number": "PROD-A001-TW-20231026-01",
|
||||
"expiry_date": "2025-10-26",
|
||||
"quantity": 15
|
||||
},
|
||||
{
|
||||
"external_pos_id": null,
|
||||
"product_code": "MAT-001",
|
||||
"product_name": "未包裝干貝醬原料",
|
||||
"barcode": null,
|
||||
"category_name": "原料",
|
||||
"unit_name": "kg",
|
||||
"price": 0.00,
|
||||
"brand": null,
|
||||
"specification": null,
|
||||
"batch_number": "MAT-001-TW-20231020-01",
|
||||
"expiry_date": "2024-04-20",
|
||||
"quantity": 2.5
|
||||
}
|
||||
]
|
||||
@@ -123,10 +218,10 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
|
||||
---
|
||||
|
||||
## 3. 訂單資料寫入與扣庫 (Create Order)
|
||||
## 4. 訂單資料寫入與扣庫 (Create Order)
|
||||
|
||||
此 API 用於讓第三方系統(如 POS 結帳後)將「已成交」的訂單推送到 ERP。
|
||||
**重要提醒**:寫入訂單的同時,ERP 會自動且**強制**扣除對應倉庫的庫存(允許扣至負數,以利後續盤點或補單)。
|
||||
**重要提醒**:寫入訂單時,ERP 會無條件扣除庫存。若指定的「批號」庫存不足,系統會自動轉向 `NO-BATCH` 庫存項目扣除;若最終仍不足,則會在 `NO-BATCH` 產生負數庫存。
|
||||
|
||||
- **Endpoint**: `/orders`
|
||||
- **Method**: `POST`
|
||||
@@ -136,8 +231,11 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
| 欄位名稱 | 型態 | 必填 | 說明 |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| `external_order_id` | String | **是** | 第三方系統中的唯一訂單編號,不可重複 (Unique) |
|
||||
| `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`STORE-001`)。若找不到對應倉庫將直接拒絕請求 |
|
||||
| `name` | String | **是** | 訂單名稱或客戶名稱 (最多 255 字元) |
|
||||
| `warehouse_code` | String | **是** | 指定扣除庫存的倉庫代碼 (例如:`api-test-01` 測試倉)。若找不到對應倉庫將直接拒絕請求 |
|
||||
| `payment_method` | String | 否 | 付款方式,僅接受:`cash`, `credit_card`, `line_pay`, `ecpay`, `transfer`, `other`。預設為 `cash` |
|
||||
| `total_amount` | Number | **是** | 整筆訂單的總交易金額 (例如:500) |
|
||||
| `total_qty` | Number | **是** | 整筆訂單的商品總數量 (例如:5) |
|
||||
| `sold_at` | String(Date) | 否 | 交易發生時間,預設為當下時間 (格式: YYYY-MM-DD HH:mm:ss) |
|
||||
| `items` | Array | **是** | 訂單明細陣列,至少需包含一筆商品 |
|
||||
|
||||
@@ -145,27 +243,29 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
|
||||
| 欄位名稱 | 型態 | 必填 | 說明 |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| `pos_product_id` | String | **是** | 對應產品的 `external_pos_id`。**注意:商品必須先透過產品同步 API 建立至 ERP 中。** |
|
||||
| `product_id` | Integer | **是** | **ERP 內部的商品 ID (`id`)**。請優先使用商品同步 API 取得此 ID |
|
||||
| `batch_number` | String | 否 | **商品批號**。若提供,將優先扣除該批號庫存;若該批號無剩餘或找不到,將自動 fallback 至 `NO-BATCH` 扣除 |
|
||||
| `qty` | Number | **是** | 銷售數量 (必須 > 0) |
|
||||
| `price` | Number | **是** | 銷售單價 |
|
||||
|
||||
**注意**:請確保傳入正確的 `product_id` 以便 ERP 準確識別商品與扣除庫存。
|
||||
|
||||
**請求範例:**
|
||||
```json
|
||||
{
|
||||
"external_order_id": "ORD-20231026-0001",
|
||||
"warehouse_code": "STORE-001",
|
||||
"external_order_id": "ORD-20231024-001",
|
||||
"name": "陳小明-干貝醬訂購",
|
||||
"warehouse_code": "STORE-01",
|
||||
"payment_method": "credit_card",
|
||||
"sold_at": "2023-10-26 14:30:00",
|
||||
"total_amount": 1050,
|
||||
"total_qty": 3,
|
||||
"sold_at": "2023-10-24 15:30:00",
|
||||
"items": [
|
||||
{
|
||||
"pos_product_id": "POS-ITEM-001",
|
||||
"product_id": 15,
|
||||
"batch_number": "BATCH-2024-A1",
|
||||
"qty": 2,
|
||||
"price": 450
|
||||
},
|
||||
{
|
||||
"pos_product_id": "POS-ITEM-005",
|
||||
"qty": 1,
|
||||
"price": 120
|
||||
"price": 500
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -181,17 +281,9 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
}
|
||||
```
|
||||
|
||||
**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 - 驗證失敗)
|
||||
|
||||
當傳入資料格式有誤、商品編號不存在,或是該訂單正在處理中被系統鎖定時返回。
|
||||
當傳入資料格式有誤、商品 ID 於系統中不存在(如 `items` 內傳入了無法辨識的商品 ID),或是倉庫代碼無效時返回。
|
||||
|
||||
- **`message`** (字串): 錯誤摘要。
|
||||
- **`errors`** (物件): 具體的錯誤明細列表,能一次性回報多個商品不存在或其它欄位錯誤。
|
||||
@@ -201,10 +293,10 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS
|
||||
"message": "Validation failed",
|
||||
"errors": {
|
||||
"items": [
|
||||
"The following products are not found: POS-999, POS-888. Please sync products first."
|
||||
"The following product IDs are not found: 15, 22. Please ensure these products exist in the system."
|
||||
],
|
||||
"external_order_id": [
|
||||
"The order ORD-01 is currently being processed by another transaction. Please try again later."
|
||||
"warehouse_code": [
|
||||
"Warehouse with code STORE-999 not found."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
211
tests/Feature/Integration/InventoryQueryApiTest.php
Normal file
211
tests/Feature/Integration/InventoryQueryApiTest.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?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\Warehouse;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
class InventoryQueryApiTest extends TestCase
|
||||
{
|
||||
protected $tenant;
|
||||
protected $user;
|
||||
protected $domain;
|
||||
protected $warehouse;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Artisan::call('migrate:fresh');
|
||||
|
||||
$this->domain = 'inventory-test-' . Str::random(8) . '.erp.local';
|
||||
$tenantId = 'test_tenant_inv_' . Str::random(8);
|
||||
|
||||
tenancy()->central(function () use ($tenantId) {
|
||||
$this->tenant = Tenant::create([
|
||||
'id' => $tenantId,
|
||||
'name' => 'Inventory Test Tenant',
|
||||
]);
|
||||
$this->tenant->domains()->create(['domain' => $this->domain]);
|
||||
});
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
$this->user = User::factory()->create(['name' => 'Inventory Admin']);
|
||||
|
||||
// 建立測試資料
|
||||
$cat = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']);
|
||||
$unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']);
|
||||
|
||||
$p1 = Product::create([
|
||||
'name' => '可口可樂',
|
||||
'code' => 'COKE-001',
|
||||
'barcode' => '4710001',
|
||||
'external_pos_id' => 'POS-COKE',
|
||||
'price' => 25,
|
||||
'category_id' => $cat->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$p2 = Product::create([
|
||||
'name' => '百事可樂',
|
||||
'code' => 'PEPSI-001',
|
||||
'barcode' => '4710002',
|
||||
'external_pos_id' => 'POS-PEPSI',
|
||||
'price' => 23,
|
||||
'category_id' => $cat->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->warehouse = Warehouse::create([
|
||||
'name' => '台北門市倉',
|
||||
'code' => 'WH-TP-01',
|
||||
'type' => 'retail',
|
||||
]);
|
||||
|
||||
// 建立庫存
|
||||
Inventory::create([
|
||||
'warehouse_id' => $this->warehouse->id,
|
||||
'product_id' => $p1->id,
|
||||
'quantity' => 100,
|
||||
'unit_cost' => 15,
|
||||
'total_value' => 1500,
|
||||
'batch_number' => 'BATCH-001',
|
||||
'arrival_date' => now(),
|
||||
]);
|
||||
|
||||
Inventory::create([
|
||||
'warehouse_id' => $this->warehouse->id,
|
||||
'product_id' => $p2->id,
|
||||
'quantity' => 50,
|
||||
'unit_cost' => 12,
|
||||
'total_value' => 600,
|
||||
'batch_number' => 'BATCH-002',
|
||||
'arrival_date' => now(),
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->tenant) {
|
||||
$this->tenant->delete();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_can_query_all_inventory_for_warehouse()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_can_filter_inventory_by_product_id()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 先找出可樂的 ERP ID
|
||||
$productId = Product::where('code', 'COKE-001')->value('id');
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id={$productId}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.product_code', 'COKE-001')
|
||||
->assertJsonPath('data.0.quantity', 100);
|
||||
}
|
||||
|
||||
public function test_can_filter_inventory_by_external_pos_id()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?external_pos_id=POS-COKE");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.external_pos_id', 'POS-COKE')
|
||||
->assertJsonPath('data.0.quantity', 100);
|
||||
}
|
||||
|
||||
public function test_can_filter_inventory_by_barcode()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?barcode=4710002");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.product_code', 'PEPSI-001')
|
||||
->assertJsonPath('data.0.quantity', 50);
|
||||
}
|
||||
|
||||
public function test_can_filter_inventory_by_code()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?code=COKE-001");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.product_code', 'COKE-001');
|
||||
}
|
||||
|
||||
public function test_returns_empty_when_no_match()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id=NON-EXISTENT");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(0, 'data');
|
||||
}
|
||||
|
||||
public function test_returns_404_when_warehouse_not_found()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/INVALID-WH");
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ use App\Modules\Core\Models\Tenant;
|
||||
use App\Modules\Core\Models\User;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Integration\Models\SalesOrder;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Stancl\Tenancy\Facades\Tenancy;
|
||||
|
||||
class PosApiTest extends TestCase
|
||||
@@ -26,9 +29,9 @@ class PosApiTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
\Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait
|
||||
Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait
|
||||
|
||||
$this->domain = 'test-' . \Illuminate\Support\Str::random(8) . '.erp.local';
|
||||
$this->domain = 'test-' . Str::random(8) . '.erp.local';
|
||||
$tenantId = 'test_tenant_' . \Illuminate\Support\Str::random(8);
|
||||
|
||||
// Ensure we are in central context
|
||||
@@ -45,7 +48,7 @@ class PosApiTest extends TestCase
|
||||
// Initialize to create User and Token
|
||||
tenancy()->initialize($this->tenant);
|
||||
|
||||
\Artisan::call('tenants:migrate');
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'email' => 'admin@test.local',
|
||||
@@ -83,26 +86,33 @@ class PosApiTest extends TestCase
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$payload = [
|
||||
'external_pos_id' => 'EXT-NEW-002',
|
||||
'name' => 'New Product',
|
||||
'price' => 200,
|
||||
'sku' => 'SKU-NEW',
|
||||
$productData = [
|
||||
'external_pos_id' => 'POS-P-999',
|
||||
'name' => '新款可口可樂',
|
||||
'price' => 29.0,
|
||||
'barcode' => '471000999',
|
||||
'category' => '飲品',
|
||||
'unit' => '瓶',
|
||||
];
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->postJson('/api/v1/integration/products/upsert', $payload);
|
||||
])->postJson('/api/v1/integration/products/upsert', $productData);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('message', 'Product synced successfully');
|
||||
->assertJsonPath('data.external_pos_id', 'POS-P-999')
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id', 'external_pos_id', 'code', 'barcode'
|
||||
]
|
||||
]);
|
||||
|
||||
// Verify in Tenant DB
|
||||
tenancy()->initialize($this->tenant);
|
||||
$this->assertDatabaseHas('products', [
|
||||
'external_pos_id' => 'EXT-NEW-002',
|
||||
'name' => 'New Product',
|
||||
'external_pos_id' => 'POS-P-999',
|
||||
'name' => '新款可口可樂',
|
||||
]);
|
||||
tenancy()->end();
|
||||
}
|
||||
@@ -115,6 +125,8 @@ class PosApiTest extends TestCase
|
||||
'external_pos_id' => 'EXT-001',
|
||||
'name' => 'Updated Name',
|
||||
'price' => 150,
|
||||
'category' => '飲品',
|
||||
'unit' => '瓶',
|
||||
];
|
||||
|
||||
$response = $this->withHeaders([
|
||||
@@ -147,7 +159,7 @@ class PosApiTest extends TestCase
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'quantity' => 100,
|
||||
'batch_number' => 'BATCH-TEST-001',
|
||||
'batch_number' => 'NO-BATCH', // 改為系統預設值
|
||||
'arrival_date' => now()->toDateString(),
|
||||
'origin_country' => 'TW',
|
||||
]);
|
||||
@@ -159,11 +171,15 @@ class PosApiTest extends TestCase
|
||||
|
||||
$payload = [
|
||||
'external_order_id' => 'ORD-001',
|
||||
'warehouse_id' => $warehouseId,
|
||||
'name' => '測試訂單一號',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'payment_method' => 'cash',
|
||||
'total_amount' => 500, // 5 * 100
|
||||
'total_qty' => 5,
|
||||
'sold_at' => now()->toIso8601String(),
|
||||
'items' => [
|
||||
[
|
||||
'pos_product_id' => 'EXT-001',
|
||||
'product_id' => $product->id,
|
||||
'qty' => 5,
|
||||
'price' => 100
|
||||
]
|
||||
@@ -175,6 +191,9 @@ class PosApiTest extends TestCase
|
||||
'Accept' => 'application/json',
|
||||
])->postJson('/api/v1/integration/orders', $payload);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('message', 'Order synced and stock deducted successfully');
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('message', 'Order synced and stock deducted successfully');
|
||||
|
||||
@@ -197,6 +216,322 @@ class PosApiTest extends TestCase
|
||||
'quantity' => 95,
|
||||
]);
|
||||
|
||||
$order = \App\Modules\Integration\Models\SalesOrder::where('external_order_id', 'ORD-001')->first();
|
||||
$this->assertDatabaseHas('inventory_transactions', [
|
||||
'reference_type' => \App\Modules\Integration\Models\SalesOrder::class,
|
||||
'reference_id' => $order->id,
|
||||
'quantity' => -5,
|
||||
'type' => '出庫',
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
public function test_order_creation_with_batch_number_deduction()
|
||||
{
|
||||
tenancy()->initialize($this->tenant);
|
||||
$product = Product::where('code', 'P-001')->first();
|
||||
$warehouse = \App\Modules\Inventory\Models\Warehouse::firstOrCreate(['code' => 'MAIN'], ['name' => 'Main Warehouse']);
|
||||
|
||||
// Scenario: Two records for the same product, one with batch, one without
|
||||
\App\Modules\Inventory\Models\Inventory::create([
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'quantity' => 10,
|
||||
'batch_number' => 'BATCH-A',
|
||||
'arrival_date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
\App\Modules\Inventory\Models\Inventory::create([
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'quantity' => 5,
|
||||
'batch_number' => 'NO-BATCH',
|
||||
'arrival_date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 1. Test deducting from SPECIFIC batch
|
||||
$payloadA = [
|
||||
'external_order_id' => 'ORD-BATCH-A',
|
||||
'name' => '測試訂單A',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'payment_method' => 'cash',
|
||||
'total_amount' => 300, // 3 * 100
|
||||
'total_qty' => 3,
|
||||
'items' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => 'BATCH-A',
|
||||
'qty' => 3,
|
||||
'price' => 100
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payloadA)
|
||||
->assertStatus(201);
|
||||
|
||||
// 2. Test deducting from NULL batch (default)
|
||||
$payloadNull = [
|
||||
'external_order_id' => 'ORD-BATCH-NULL',
|
||||
'name' => '無批號測試',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'payment_method' => 'cash',
|
||||
'total_amount' => 200, // 2 * 100
|
||||
'total_qty' => 2,
|
||||
'items' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => null, // 測試不傳入批號時應自動轉向 NO-BATCH
|
||||
'qty' => 2,
|
||||
'price' => 100
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payloadNull)
|
||||
->assertStatus(201);
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
// Verify BATCH-A: 10 - 3 = 7
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => 'BATCH-A',
|
||||
'quantity' => 7,
|
||||
]);
|
||||
|
||||
// Verify NO-BATCH batch: 5 - 2 = 3
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => 'NO-BATCH',
|
||||
'quantity' => 3,
|
||||
]);
|
||||
tenancy()->end();
|
||||
}
|
||||
|
||||
public function test_upsert_auto_generates_code_and_barcode_if_missing()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$productData = [
|
||||
'external_pos_id' => 'POS-AUTO-GEN-01',
|
||||
'name' => '自動編號商品',
|
||||
'price' => 50.0,
|
||||
'category' => '測試分類',
|
||||
'unit' => '個',
|
||||
];
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->postJson('/api/v1/integration/products/upsert', $productData);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = $response->json('data');
|
||||
$this->assertNotEmpty($data['code']);
|
||||
$this->assertNotEmpty($data['barcode']);
|
||||
$this->assertEquals(8, strlen($data['code']));
|
||||
$this->assertEquals(13, strlen($data['barcode']));
|
||||
|
||||
// Ensure they are stored in DB
|
||||
tenancy()->initialize($this->tenant);
|
||||
$this->assertDatabaseHas('products', [
|
||||
'external_pos_id' => 'POS-AUTO-GEN-01',
|
||||
'code' => $data['code'],
|
||||
'barcode' => $data['barcode'],
|
||||
]);
|
||||
tenancy()->end();
|
||||
}
|
||||
|
||||
public function test_inventory_query_returns_detailed_info()
|
||||
{
|
||||
tenancy()->initialize($this->tenant);
|
||||
|
||||
// 1. 建立具有完整資訊的商品
|
||||
$category = \App\Modules\Inventory\Models\Category::create(['name' => '測試大類']);
|
||||
$unit = \App\Modules\Inventory\Models\Unit::create(['name' => '測試單位']);
|
||||
|
||||
$product = Product::create([
|
||||
'external_pos_id' => 'DETAIL-001',
|
||||
'code' => 'DET-001',
|
||||
'barcode' => '1234567890123',
|
||||
'name' => '詳盡商品',
|
||||
'category_id' => $category->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'price' => 123.45,
|
||||
'brand' => '品牌A',
|
||||
'specification' => '規格B',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$warehouse = \App\Modules\Inventory\Models\Warehouse::create(['name' => '詳盡倉庫', 'code' => 'DETAIL-WH']);
|
||||
|
||||
// 2. 增加庫存
|
||||
\App\Modules\Inventory\Models\Inventory::create([
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'quantity' => 10.5,
|
||||
'batch_number' => 'BATCH-DETAIL-01',
|
||||
'arrival_date' => now(),
|
||||
'expiry_date' => now()->addYear(),
|
||||
'origin_country' => 'TW',
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 3. 查詢該倉庫庫存
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/inventory/DETAIL-WH');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'status',
|
||||
'warehouse_code',
|
||||
'data' => [
|
||||
'*' => [
|
||||
'product_id',
|
||||
'external_pos_id',
|
||||
'product_code',
|
||||
'product_name',
|
||||
'barcode',
|
||||
'category_name',
|
||||
'unit_name',
|
||||
'price',
|
||||
'brand',
|
||||
'specification',
|
||||
'batch_number',
|
||||
'expiry_date',
|
||||
'quantity'
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json('data.0');
|
||||
$this->assertEquals($product->id, $data['product_id']);
|
||||
$this->assertEquals('DETAIL-001', $data['external_pos_id']);
|
||||
$this->assertEquals('DET-001', $data['product_code']);
|
||||
$this->assertEquals('1234567890123', $data['barcode']);
|
||||
$this->assertEquals('測試大類', $data['category_name']);
|
||||
$this->assertEquals('測試單位', $data['unit_name']);
|
||||
$this->assertEquals(123.45, (float)$data['price']);
|
||||
$this->assertEquals('品牌A', $data['brand']);
|
||||
$this->assertEquals('規格B', $data['specification']);
|
||||
$this->assertEquals('BATCH-DETAIL-01', $data['batch_number']);
|
||||
$this->assertNotEmpty($data['expiry_date']);
|
||||
$this->assertEquals(10.5, $data['quantity']);
|
||||
}
|
||||
|
||||
public function test_order_creation_with_insufficient_stock_creates_negative_no_batch()
|
||||
{
|
||||
tenancy()->initialize($this->tenant);
|
||||
$product = Product::where('code', 'P-001')->first();
|
||||
// 確保倉庫存在
|
||||
$warehouse = \App\Modules\Inventory\Models\Warehouse::firstOrCreate(['code' => 'MAIN'], ['name' => 'Main Warehouse']);
|
||||
|
||||
// 清空該商品的現有庫存以利測試負數
|
||||
\App\Modules\Inventory\Models\Inventory::where('product_id', $product->id)->delete();
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$payload = [
|
||||
'external_order_id' => 'ORD-NEGATIVE-TEST',
|
||||
'name' => '負數庫存測試',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'payment_method' => 'cash',
|
||||
'total_amount' => 1000,
|
||||
'total_qty' => 10,
|
||||
'items' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => 'ANY-BATCH-NAME',
|
||||
'qty' => 10,
|
||||
'price' => 100
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payload)
|
||||
->assertStatus(201);
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
|
||||
// 驗證應該在 NO-BATCH 產生 -10 的庫存
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'batch_number' => 'NO-BATCH',
|
||||
'quantity' => -10,
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
}
|
||||
|
||||
public function test_order_source_automation_based_on_warehouse_type()
|
||||
{
|
||||
tenancy()->initialize($this->tenant);
|
||||
$product = Product::where('code', 'P-001')->first();
|
||||
|
||||
// 1. Create a VENDING warehouse
|
||||
$vendingWarehouse = \App\Modules\Inventory\Models\Warehouse::create([
|
||||
'name' => 'Vending Machine 01',
|
||||
'code' => 'VEND-01',
|
||||
'type' => \App\Enums\WarehouseType::VENDING,
|
||||
]);
|
||||
|
||||
// 2. Create a STANDARD warehouse
|
||||
$standardWarehouse = \App\Modules\Inventory\Models\Warehouse::create([
|
||||
'name' => 'General Warehouse',
|
||||
'code' => 'ST-01',
|
||||
'type' => \App\Enums\WarehouseType::STANDARD,
|
||||
]);
|
||||
tenancy()->end();
|
||||
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// Case A: Vending Warehouse -> Source should be 'vending'
|
||||
$payloadVending = [
|
||||
'external_order_id' => 'ORD-VEND',
|
||||
'name' => 'Vending Order',
|
||||
'warehouse_code' => 'VEND-01',
|
||||
'total_amount' => 100,
|
||||
'total_qty' => 1,
|
||||
'items' => [['product_id' => $product->id, 'qty' => 1, 'price' => 100]]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payloadVending)
|
||||
->assertStatus(201);
|
||||
|
||||
// Case B: Standard Warehouse -> Source should be 'pos'
|
||||
$payloadPos = [
|
||||
'external_order_id' => 'ORD-POS',
|
||||
'name' => 'POS Order',
|
||||
'warehouse_code' => 'ST-01',
|
||||
'total_amount' => 100,
|
||||
'total_qty' => 1,
|
||||
'items' => [['product_id' => $product->id, 'qty' => 1, 'price' => 100]]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payloadPos)
|
||||
->assertStatus(201);
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
$this->assertDatabaseHas('sales_orders', ['external_order_id' => 'ORD-VEND', 'source' => 'vending']);
|
||||
$this->assertDatabaseHas('sales_orders', ['external_order_id' => 'ORD-POS', 'source' => 'pos']);
|
||||
tenancy()->end();
|
||||
}
|
||||
}
|
||||
|
||||
194
tests/Feature/Integration/ProductSearchApiTest.php
Normal file
194
tests/Feature/Integration/ProductSearchApiTest.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?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 App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stancl\Tenancy\Facades\Tenancy;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductSearchApiTest extends TestCase
|
||||
{
|
||||
protected $tenant;
|
||||
protected $user;
|
||||
protected $domain;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Artisan::call('migrate:fresh');
|
||||
|
||||
$this->domain = 'search-test-' . Str::random(8) . '.erp.local';
|
||||
$tenantId = 'test_tenant_s_' . Str::random(8);
|
||||
|
||||
tenancy()->central(function () use ($tenantId) {
|
||||
$this->tenant = Tenant::create([
|
||||
'id' => $tenantId,
|
||||
'name' => 'Search Test Tenant',
|
||||
]);
|
||||
$this->tenant->domains()->create(['domain' => $this->domain]);
|
||||
});
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
$this->user = User::factory()->create(['name' => 'Search Admin']);
|
||||
|
||||
// 建立測試資料
|
||||
$cat1 = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']);
|
||||
$cat2 = Category::firstOrCreate(['name' => '食品'], ['code' => 'CAT-FOOD']);
|
||||
$unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']);
|
||||
|
||||
Product::create([
|
||||
'name' => '可口可樂',
|
||||
'code' => 'COKE-001',
|
||||
'barcode' => '4710001',
|
||||
'price' => 25,
|
||||
'category_id' => $cat1->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$pepsi = Product::create([
|
||||
'name' => '百事可樂',
|
||||
'code' => 'PEPSI-001',
|
||||
'barcode' => '4710002',
|
||||
'price' => 23,
|
||||
'category_id' => $cat1->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
DB::table('products')->where('id', $pepsi->id)->update(['updated_at' => now()->subDay()]);
|
||||
|
||||
Product::create([
|
||||
'name' => '漢堡',
|
||||
'code' => 'BURGER-001',
|
||||
'barcode' => '4710003',
|
||||
'price' => 50,
|
||||
'category_id' => $cat2->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
Product::create([
|
||||
'name' => '停用商品',
|
||||
'code' => 'INACTIVE-001',
|
||||
'is_active' => false,
|
||||
'category_id' => $cat1->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->tenant) {
|
||||
$this->tenant->delete();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_can_search_all_active_products()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonCount(3, 'data'); // 3 active products
|
||||
}
|
||||
|
||||
public function test_can_filter_by_category()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products?category=食品');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.name', '漢堡');
|
||||
}
|
||||
|
||||
public function test_can_filter_by_updated_after()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 搜尋今天更新的商品 (百事可樂是昨天更新的,應該被過濾掉)
|
||||
$timeStr = now()->startOfDay()->format('Y-m-d H:i:s');
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/products?updated_after={$timeStr}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(2, 'data'); // 可口可樂, 漢堡 (百事可樂是昨天)
|
||||
}
|
||||
|
||||
public function test_pagination_works()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products?per_page=2');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('meta.per_page', 2)
|
||||
->assertJsonPath('meta.total', 3);
|
||||
}
|
||||
|
||||
public function test_can_filter_by_precision_params()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 1. By Barcode
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products?barcode=4710001');
|
||||
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '可口可樂');
|
||||
|
||||
// 2. By Code
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products?code=PEPSI-001');
|
||||
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '百事可樂');
|
||||
|
||||
// 3. By product_id (ERP ID)
|
||||
$productId = Product::where('code', 'BURGER-001')->value('id');
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/products?product_id={$productId}");
|
||||
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '漢堡');
|
||||
}
|
||||
|
||||
public function test_is_protected_by_auth()
|
||||
{
|
||||
// 不帶 Token
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products');
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class InventoryTransferImportTest extends TestCase
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $user;
|
||||
protected $tenant;
|
||||
protected $fromWarehouse;
|
||||
protected $toWarehouse;
|
||||
protected $order;
|
||||
@@ -25,6 +26,15 @@ class InventoryTransferImportTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create a unique tenant for this test run
|
||||
$tenantId = 'test_' . str_replace('.', '', microtime(true));
|
||||
$this->tenant = \App\Modules\Core\Models\Tenant::create([
|
||||
'id' => $tenantId,
|
||||
]);
|
||||
$this->tenant->domains()->create(['domain' => $tenantId . '.test']);
|
||||
tenancy()->initialize($this->tenant);
|
||||
|
||||
$this->user = User::create([
|
||||
'name' => 'Test User',
|
||||
'username' => 'testuser',
|
||||
@@ -52,10 +62,13 @@ class InventoryTransferImportTest extends TestCase
|
||||
'created_by' => $this->user->id,
|
||||
]);
|
||||
|
||||
$category = \App\Modules\Inventory\Models\Category::create(['name' => 'General', 'code' => 'GEN']);
|
||||
|
||||
$this->product = Product::create([
|
||||
'code' => 'P001',
|
||||
'name' => 'Test Product',
|
||||
'status' => 'enabled',
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -80,8 +93,9 @@ class InventoryTransferImportTest extends TestCase
|
||||
// 但如果我們在 Import 類別中直接讀取 $row['商品代碼'],我們得確定它真的在那裡。
|
||||
|
||||
$rows = collect([
|
||||
collect(['商品代碼' => 'P001', '批號' => 'BATCH001', '數量' => '10', '備註' => 'Imported Via Test']),
|
||||
collect(['商品代碼' => 'P001', '批號' => '', '數量' => '5', '備註' => 'Batch should be NO-BATCH']),
|
||||
collect(['商品代碼', '批號', '數量', '備註']),
|
||||
collect(['P001', 'BATCH001', '10', 'Imported Via Test']),
|
||||
collect(['P001', '', '5', 'Batch should be NO-BATCH']),
|
||||
]);
|
||||
|
||||
$import->collection($rows);
|
||||
|
||||
Reference in New Issue
Block a user